├── .npmrc ├── static ├── robots.txt ├── favicon.png └── _scripts │ ├── _lib │ └── signals.js │ └── video-embed.js ├── mise.toml ├── .prettierignore ├── src ├── lib │ ├── utils │ │ ├── types.ts │ │ ├── bluesky │ │ │ ├── richtext.ts │ │ │ ├── videos.ts │ │ │ ├── display.ts │ │ │ ├── lists.ts │ │ │ ├── records.ts │ │ │ ├── urls.ts │ │ │ └── embeds.ts │ │ ├── url.ts │ │ ├── invariant.ts │ │ ├── intl │ │ │ └── number.ts │ │ ├── strings.ts │ │ └── pagination.ts │ ├── components │ │ ├── central-icons │ │ │ ├── hashtag-outlined.svelte │ │ │ ├── arrow-right-outlined.svelte │ │ │ ├── play-solid.svelte │ │ │ ├── heart-outlined.svelte │ │ │ ├── circle-minus-outlined.svelte │ │ │ ├── circle-plus-outlined.svelte │ │ │ ├── magnifying-glass-outlined.svelte │ │ │ ├── bubble-2-outlined.svelte │ │ │ ├── circle-ban-sign-outlined.svelte │ │ │ ├── trending-2-outlined.svelte │ │ │ ├── arrows-repeat-right-left-outlined.svelte │ │ │ ├── thread-outlined.svelte │ │ │ ├── circle-info-outlined.svelte │ │ │ ├── square-arrow-top-right-outlined.svelte │ │ │ ├── pin-outlined.svelte │ │ │ ├── compass-round-outlined.svelte │ │ │ ├── bubbles-outlined.svelte │ │ │ ├── group-2-outlined.svelte │ │ │ ├── dot-grid-1x3-horizontal-outlined.svelte │ │ │ └── earth-outlined.svelte │ │ ├── page │ │ │ ├── page-container.svelte │ │ │ ├── page-header.svelte │ │ │ └── page-listing.svelte │ │ ├── island.svelte │ │ ├── islands │ │ │ └── time.svelte │ │ ├── embeds │ │ │ ├── quote-blocked-embed.svelte │ │ │ ├── video-thumbnail-embed.svelte │ │ │ ├── components │ │ │ │ └── image-alt.svelte │ │ │ ├── feed-embed.svelte │ │ │ ├── list-embed.svelte │ │ │ ├── starterpack-embed.svelte │ │ │ ├── external-embed.svelte │ │ │ └── embeds.svelte │ │ ├── content-hider.svelte │ │ ├── richtext-raw-renderer.svelte │ │ ├── timeline │ │ │ ├── post-metrics.svelte │ │ │ └── post-meta.svelte │ │ ├── lists │ │ │ └── list-item.svelte │ │ ├── starterpacks │ │ │ └── starterpack-item.svelte │ │ ├── richtext-renderer.svelte │ │ ├── profiles │ │ │ └── profile-item.svelte │ │ ├── feeds │ │ │ └── feed-item.svelte │ │ ├── overflow-menu.svelte │ │ └── avatar.svelte │ ├── assets │ │ ├── default-labeler-avatar.svg │ │ ├── default-user-avatar.svg │ │ ├── default-list-avatar.svg │ │ ├── default-feed-avatar.svg │ │ └── default-starterpack-avatar.svg │ ├── queries │ │ ├── handle.ts │ │ └── post.ts │ ├── types │ │ ├── at-uri.ts │ │ └── valibot.ts │ ├── styles │ │ └── app.css │ └── constants.ts ├── routes │ ├── +layout.ts │ ├── profile │ │ └── [actor=didOrHandle] │ │ │ ├── +server.ts │ │ │ ├── post │ │ │ └── [rkey=tid] │ │ │ │ └── +server.ts │ │ │ ├── feed │ │ │ └── [rkey=rkey] │ │ │ │ └── +server.ts │ │ │ └── lists │ │ │ └── [rkey=rkey] │ │ │ └── +server.ts │ ├── (app) │ │ ├── +layout.svelte │ │ ├── [actor=didOrHandle] │ │ │ ├── packs │ │ │ │ └── [rkey=rkey] │ │ │ │ │ ├── feeds │ │ │ │ │ └── +page.svelte │ │ │ │ │ ├── +page.svelte │ │ │ │ │ ├── +layout.ts │ │ │ │ │ ├── posts │ │ │ │ │ ├── +page.ts │ │ │ │ │ └── +page.svelte │ │ │ │ │ ├── +page.ts │ │ │ │ │ ├── components │ │ │ │ │ ├── pack-meta-tags.svelte │ │ │ │ │ └── pack-aside.svelte │ │ │ │ │ └── +layout.svelte │ │ │ ├── lists │ │ │ │ └── [rkey=rkey] │ │ │ │ │ ├── +page.ts │ │ │ │ │ ├── members │ │ │ │ │ └── +page.svelte │ │ │ │ │ ├── posts │ │ │ │ │ ├── +page.ts │ │ │ │ │ ├── +page.svelte │ │ │ │ │ └── rss │ │ │ │ │ │ └── +server.ts │ │ │ │ │ ├── +layout.ts │ │ │ │ │ ├── components │ │ │ │ │ ├── list-meta-tags.svelte │ │ │ │ │ └── list-aside.svelte │ │ │ │ │ └── +layout.svelte │ │ │ ├── (profile) │ │ │ │ ├── lists │ │ │ │ │ ├── +page.ts │ │ │ │ │ └── +page.svelte │ │ │ │ ├── feeds │ │ │ │ │ ├── +page.ts │ │ │ │ │ └── +page.svelte │ │ │ │ ├── following │ │ │ │ │ ├── +page.ts │ │ │ │ │ └── +page.svelte │ │ │ │ ├── followers │ │ │ │ │ ├── +page.ts │ │ │ │ │ └── +page.svelte │ │ │ │ ├── packs │ │ │ │ │ ├── +page.ts │ │ │ │ │ └── +page.svelte │ │ │ │ ├── (timeline) │ │ │ │ │ ├── +page.ts │ │ │ │ │ ├── media │ │ │ │ │ │ ├── +page.ts │ │ │ │ │ │ └── +page.svelte │ │ │ │ │ ├── with_replies │ │ │ │ │ │ ├── +page.ts │ │ │ │ │ │ └── +page.svelte │ │ │ │ │ ├── +page.svelte │ │ │ │ │ ├── +layout.svelte │ │ │ │ │ └── rss │ │ │ │ │ │ └── +server.ts │ │ │ │ ├── +layout.ts │ │ │ │ └── components │ │ │ │ │ └── profile-meta-tags.svelte │ │ │ ├── feeds │ │ │ │ └── [rkey=rkey] │ │ │ │ │ ├── likes │ │ │ │ │ ├── +page.svelte │ │ │ │ │ └── +page.ts │ │ │ │ │ ├── +layout.ts │ │ │ │ │ ├── +page.ts │ │ │ │ │ ├── +layout.svelte │ │ │ │ │ ├── +page.svelte │ │ │ │ │ └── components │ │ │ │ │ └── feed-meta-tags.svelte │ │ │ └── [rkey=tid] │ │ │ │ ├── components │ │ │ │ ├── overflow-descendant-item.svelte │ │ │ │ ├── missing-descendant-item.svelte │ │ │ │ ├── overflow-ascendant-item.svelte │ │ │ │ ├── blocked-ascendant-item.svelte │ │ │ │ ├── nonexistent-ascendant-post.svelte │ │ │ │ ├── main-post-metrics.svelte │ │ │ │ ├── descendants.svelte │ │ │ │ └── post-ascendant-item.svelte │ │ │ │ └── +page.ts │ │ ├── trending │ │ │ ├── +page.ts │ │ │ ├── utils.ts │ │ │ └── +page.svelte │ │ ├── [actor=did] │ │ │ └── [rkey=tid] │ │ │ │ ├── quotes │ │ │ │ ├── +page.ts │ │ │ │ └── +page.svelte │ │ │ │ ├── reposts │ │ │ │ ├── +page.ts │ │ │ │ └── +page.svelte │ │ │ │ ├── likes │ │ │ │ ├── +page.ts │ │ │ │ └── +page.svelte │ │ │ │ ├── all-quotes │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ │ ├── all-replies │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ │ └── unroll │ │ │ │ └── +page.ts │ │ ├── search │ │ │ ├── feeds │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── users │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── posts │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ └── +server.ts │ │ └── +page.server.ts │ ├── +error.svelte │ ├── watch │ │ └── [actor=did] │ │ │ └── [cid=cidRaw] │ │ │ └── +page.ts │ └── go │ │ └── [shortid] │ │ └── +page.ts ├── params │ ├── did.ts │ ├── tid.ts │ ├── handle.ts │ ├── rkey.ts │ ├── didOrHandle.ts │ └── cidRaw.ts ├── app.d.ts ├── app.html └── hooks.server.ts ├── .vscode └── settings.json ├── .gitignore ├── README.md ├── .env ├── wrangler.jsonc ├── .prettierrc ├── tsconfig.json ├── svelte.config.js ├── vite.config.ts ├── LICENSE └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "24.2.0" 3 | pnpm = "10.12.2" 4 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mary-ext/anartia/HEAD/static/favicon.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /src/lib/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type UnwrapArray = T extends (infer V)[] ? V : never; 2 | -------------------------------------------------------------------------------- /src/routes/+layout.ts: -------------------------------------------------------------------------------- 1 | import { dev } from '$app/environment'; 2 | 3 | export const csr = dev; 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "typescript.tsdk": "node_modules/typescript/lib" 5 | } 6 | -------------------------------------------------------------------------------- /src/params/did.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | import { isDid } from '@atcute/lexicons/syntax'; 4 | 5 | export const match = isDid satisfies ParamMatcher; 6 | -------------------------------------------------------------------------------- /src/params/tid.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | import { isTid } from '@atcute/lexicons/syntax'; 4 | 5 | export const match = isTid satisfies ParamMatcher; 6 | -------------------------------------------------------------------------------- /src/lib/utils/bluesky/richtext.ts: -------------------------------------------------------------------------------- 1 | const WS_TRIM_RE = /^\s+|\s+$| +(?=\n)|\n(?=(?: *\n){2}) */g; 2 | 3 | export const trimRichText = (str: string) => { 4 | return str.replace(WS_TRIM_RE, ''); 5 | }; 6 | -------------------------------------------------------------------------------- /src/params/handle.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | import { isHandle } from '@atcute/lexicons/syntax'; 4 | 5 | export const match = isHandle satisfies ParamMatcher; 6 | -------------------------------------------------------------------------------- /src/params/rkey.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | import { isRecordKey } from '@atcute/lexicons/syntax'; 4 | 5 | export const match = isRecordKey satisfies ParamMatcher; 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | .output 4 | .vercel 5 | .netlify 6 | .wrangler 7 | /.svelte-kit 8 | /build 9 | 10 | *.local 11 | .idea 12 | .DS_Store 13 | Thumbs.db 14 | 15 | vite.config.*.timestamp-* 16 | -------------------------------------------------------------------------------- /src/params/didOrHandle.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | import { isActorIdentifier } from '@atcute/lexicons/syntax'; 4 | 5 | export const match = isActorIdentifier satisfies ParamMatcher; 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Anartia 2 | 3 | JavaScript-optional public web frontend for Bluesky. 4 | 5 | ![Web client displaying @bsky.app's Bluesky profile](https://github.com/user-attachments/assets/710a213b-025b-4b52-9a89-251cc1a53c75) 6 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/hashtag-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/utils/bluesky/videos.ts: -------------------------------------------------------------------------------- 1 | export const replaceVideoCdnUrl = (url: string) => { 2 | // Redirect video-related assets from the middleware server to the CDN directly 3 | return url.replace('https://video.bsky.app/watch/', 'https://video.cdn.bsky.app/hls/'); 4 | }; 5 | -------------------------------------------------------------------------------- /src/lib/utils/bluesky/display.ts: -------------------------------------------------------------------------------- 1 | const INVISIBLE_RE = /[\u00ad\u200b\u200c\u2060\ufeff]/g; 2 | const WHITESPACE_RE = /\s+/g; 3 | 4 | export const normalizeDisplayName = (name: string) => { 5 | return name.replace(INVISIBLE_RE, '').replace(WHITESPACE_RE, ' ').trim(); 6 | }; 7 | -------------------------------------------------------------------------------- /src/routes/profile/[actor=didOrHandle]/+server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | import type { RequestHandler } from './$types'; 4 | 5 | // Redirect to /:didOrHandle 6 | export const GET: RequestHandler = async ({ params }) => { 7 | redirect(302, `/${params.actor}`); 8 | }; 9 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/arrow-right-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | PUBLIC_APP_NAME=Anartia 2 | PUBLIC_APP_URL=https://anartia.kelinci.net 3 | 4 | PUBLIC_APP_USER_AGENT=codeberg:mary-ext/anartia 5 | 6 | PUBLIC_APPVIEW_URL=https://api.bsky.app 7 | PUBLIC_GO_BSKY_URL=https://go.bsky.app 8 | 9 | PUBLIC_CONSTELLATION_URL=https://constellation.microcosm.blue 10 | -------------------------------------------------------------------------------- /src/lib/assets/default-labeler-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/params/cidRaw.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | // cidv1; multibase=base32; multihash=sha2-256; multicodec=raw 4 | const RAW_CID_RE = /^bafkrei[2-7a-z]{52}$/; 5 | 6 | export const match = ((param: string): param is string => { 7 | return RAW_CID_RE.test(param); 8 | }) as ParamMatcher; 9 | -------------------------------------------------------------------------------- /src/lib/utils/url.ts: -------------------------------------------------------------------------------- 1 | export const safeUrlParse = (rawUrl: string): URL | null => { 2 | const url = URL.parse(rawUrl); 3 | if (!url) { 4 | return null; 5 | } 6 | 7 | const protocol = url.protocol; 8 | if (protocol !== 'https:' && protocol !== 'http:') { 9 | return null; 10 | } 11 | 12 | return url; 13 | }; 14 | -------------------------------------------------------------------------------- /src/routes/profile/[actor=didOrHandle]/post/[rkey=tid]/+server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | import type { RequestHandler } from './$types'; 4 | 5 | // Redirect to /:didOrHandle/:tid 6 | export const GET: RequestHandler = async ({ params }) => { 7 | redirect(302, `/${params.actor}/${params.rkey}#main`); 8 | }; 9 | -------------------------------------------------------------------------------- /src/routes/profile/[actor=didOrHandle]/feed/[rkey=rkey]/+server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | import type { RequestHandler } from './$types'; 4 | 5 | // Redirect to /:didOrHandle/feeds/:rkey 6 | export const GET: RequestHandler = async ({ params }) => { 7 | redirect(302, `/${params.actor}/feeds/${params.rkey}`); 8 | }; 9 | -------------------------------------------------------------------------------- /src/routes/profile/[actor=didOrHandle]/lists/[rkey=rkey]/+server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | import type { RequestHandler } from './$types'; 4 | 5 | // Redirect to /:didOrHandle/lists/:rkey 6 | export const GET: RequestHandler = async ({ params }) => { 7 | redirect(302, `/${params.actor}/lists/${params.rkey}`); 8 | }; 9 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/play-solid.svelte: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/assets/default-user-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/heart-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/routes/(app)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | {@render children()} 18 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/circle-minus-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/wrangler@latest/config-schema.json", 3 | "name": "anartia", 4 | "main": ".svelte-kit/cloudflare/_worker.js", 5 | "compatibility_flags": ["nodejs_als"], 6 | "compatibility_date": "2025-08-16", 7 | "assets": { 8 | "binding": "ASSETS", 9 | "directory": ".svelte-kit/cloudflare", 10 | }, 11 | "placement": { 12 | "mode": "smart", 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/circle-plus-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/queries/handle.ts: -------------------------------------------------------------------------------- 1 | import { ok, type Client } from '@atcute/client'; 2 | import type { Did, Handle } from '@atcute/lexicons'; 3 | 4 | export const resolveHandle = async ({ client, handle }: { client: Client; handle: Handle }): Promise => { 5 | const data = await ok( 6 | client.get('com.atproto.identity.resolveHandle', { 7 | params: { handle }, 8 | }), 9 | ); 10 | 11 | return data.did; 12 | }; 13 | -------------------------------------------------------------------------------- /src/lib/utils/invariant.ts: -------------------------------------------------------------------------------- 1 | export function assert(condition: any, message?: string): asserts condition { 2 | if (!condition) { 3 | if (import.meta.env.DEV) { 4 | throw new Error(`Assertion failed` + (message ? `: ${message}` : ``)); 5 | } 6 | 7 | throw new Error(`Assertion failed`); 8 | } 9 | } 10 | 11 | export function assertNever(value: never, message?: string): never { 12 | assert(false, message); 13 | } 14 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/magnifying-glass-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/utils/bluesky/lists.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyGraphDefs } from '@atcute/bluesky'; 2 | 3 | export const purposeToLabel = (purpose: AppBskyGraphDefs.ListView['purpose']): string => { 4 | switch (purpose) { 5 | case 'app.bsky.graph.defs#curatelist': { 6 | return `User list`; 7 | } 8 | case 'app.bsky.graph.defs#modlist': { 9 | return `Moderation list`; 10 | } 11 | default: { 12 | return `Unknown list`; 13 | } 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/bubble-2-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/utils/bluesky/records.ts: -------------------------------------------------------------------------------- 1 | export const collectionToLabel = (collection: string): string | null => { 2 | switch (collection) { 3 | case 'app.bsky.feed.post': 4 | return 'post'; 5 | case 'app.bsky.feed.generator': 6 | return 'feed'; 7 | case 'app.bsky.graph.list': 8 | return 'list'; 9 | case 'app.bsky.graph.starterpack': 10 | return 'starter pack'; 11 | case 'app.bsky.labeler.service': 12 | return 'labeler'; 13 | } 14 | 15 | return null; 16 | }; 17 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/circle-ban-sign-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/components/page/page-container.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | {@render children()} 13 |
14 | 15 | 24 | -------------------------------------------------------------------------------- /src/lib/utils/intl/number.ts: -------------------------------------------------------------------------------- 1 | const long = new Intl.NumberFormat('en-US'); 2 | const compact = new Intl.NumberFormat('en-US', { notation: 'compact' }); 3 | 4 | export const formatCompactNumber = (value: number) => { 5 | if (value < 1_000) { 6 | return '' + value; 7 | } 8 | 9 | if (value < 100_000) { 10 | return long.format(value); 11 | } 12 | 13 | return compact.format(value); 14 | }; 15 | 16 | export const formatLongNumber = (value: number) => { 17 | return long.format(value); 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/feeds/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | {#each data.pack.feeds ?? [] as feed (feed.uri)} 12 | 13 | {/each} 14 | 15 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | import '@atcute/bluesky/lexicons'; 2 | 3 | // See https://svelte.dev/docs/kit/types#app.d.ts 4 | // for information about these interfaces 5 | declare global { 6 | namespace App { 7 | // interface Error {} 8 | // interface Locals {} 9 | // interface PageData {} 10 | // interface PageState {} 11 | // interface Platform {} 12 | } 13 | } 14 | 15 | declare module 'svelte/elements' { 16 | export interface AriaAttributes { 17 | 'aria-description'?: string; 18 | } 19 | } 20 | 21 | export {}; 22 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/trending-2-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/utils/strings.ts: -------------------------------------------------------------------------------- 1 | export const truncateMiddle = (text: string, max: number): string => { 2 | const len = text.length; 3 | 4 | if (len <= max) { 5 | return text; 6 | } 7 | 8 | const left = Math.ceil((max - 1) / 2); 9 | const right = Math.floor((max - 1) / 2); 10 | 11 | return text.slice(0, left) + '…' + text.slice(len - right); 12 | }; 13 | 14 | export const truncateRight = (text: string, max: number): string => { 15 | if (text.length <= max) { 16 | return text; 17 | } 18 | 19 | return text.slice(0, max - 1) + '…'; 20 | }; 21 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/arrows-repeat-right-left-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | %sveltekit.head% 14 | 15 | 16 | %sveltekit.body% 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/thread-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/+page.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import type { PageLoad } from './$types'; 3 | import { base } from '$app/paths'; 4 | 5 | export const load: PageLoad = async ({ params, parent }) => { 6 | const { list } = await parent(); 7 | 8 | const baseUrl = `${base}/${list.creator.did}/lists/${params.rkey}`; 9 | 10 | switch (list.purpose) { 11 | case 'app.bsky.graph.defs#curatelist': { 12 | redirect(302, `${baseUrl}/posts`); 13 | } 14 | default: { 15 | redirect(302, `${baseUrl}/members`); 16 | } 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/lib/assets/default-list-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/circle-info-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/square-arrow-top-right-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/utils/bluesky/urls.ts: -------------------------------------------------------------------------------- 1 | export const BSKY_FEED_LINK_RE = /^\/profile\/([^/]+)\/feed\/([^/]+)\/?$/; 2 | export const BSKY_HASHTAG_LINK_RE = /^\/hashtag\/([^/]+)\/?$/; 3 | export const BSKY_LIST_LINK_RE = /^\/profile\/([^/]+)\/lists\/([^/]+)\/?$/; 4 | export const BSKY_POST_LINK_RE = /^\/profile\/([^/]+)\/post\/([^/]+)\/?$/; 5 | export const BSKY_PROFILE_LINK_RE = /^\/profile\/([^/]+)\/?$/; 6 | export const BSKY_SEARCH_LINK_RE = /^\/search\/?$/; 7 | export const BSKY_STARTERPACK_LINK_RE = /^\/(starter-pack|start)\/([^/]+)\/([^/]+)\/?$/; 8 | 9 | // go.bsky.app/ 10 | export const BSKY_GO_SHORTLINK_RE = /^\/([1-9A-HJ-NP-Za-km-z]{1,22})\/?$/; 11 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/pin-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "useTabs": true, 4 | "tabWidth": 2, 5 | "printWidth": 110, 6 | "semi": true, 7 | "singleQuote": true, 8 | "bracketSpacing": true, 9 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-css-order"], 10 | "overrides": [ 11 | { 12 | "files": "*.svelte", 13 | "options": { 14 | "parser": "svelte" 15 | } 16 | }, 17 | { 18 | "files": ["tsconfig.json", "jsconfig.json", "tsconfig.*.json"], 19 | "options": { 20 | "parser": "jsonc" 21 | } 22 | }, 23 | { 24 | "files": ["*.md"], 25 | "options": { 26 | "printWidth": 100, 27 | "proseWrap": "always" 28 | } 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/compass-round-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/lists/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | export const load: PageLoad = async ({ url, params, fetch }) => { 7 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const data = await ok( 10 | client.get('app.bsky.graph.getLists', { 11 | params: { 12 | actor: params.actor, 13 | limit: 50, 14 | cursor: url.searchParams.get('cursor') || undefined, 15 | }, 16 | }), 17 | ); 18 | 19 | return { lists: { cursor: data.cursor, items: data.lists } }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/feeds/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | export const load: PageLoad = async ({ url, params, fetch }) => { 7 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const data = await ok( 10 | client.get('app.bsky.feed.getActorFeeds', { 11 | params: { 12 | actor: params.actor, 13 | limit: 50, 14 | cursor: url.searchParams.get('cursor') || undefined, 15 | }, 16 | }), 17 | ); 18 | 19 | return { feeds: { cursor: data.cursor, items: data.feeds } }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/lib/assets/default-feed-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/following/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | export const load: PageLoad = async ({ url, params, fetch }) => { 7 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const data = await ok( 10 | client.get('app.bsky.graph.getFollows', { 11 | params: { 12 | actor: params.actor, 13 | limit: 50, 14 | cursor: url.searchParams.get('cursor') || undefined, 15 | }, 16 | }), 17 | ); 18 | 19 | return { following: { cursor: data.cursor, items: data.follows } }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/+page.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | {#each data.members.items as item (item.subject.did)} 17 | 18 | {/each} 19 | 20 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/bubbles-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 16 | 17 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/followers/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | export const load: PageLoad = async ({ url, params, fetch }) => { 7 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const data = await ok( 10 | client.get('app.bsky.graph.getFollowers', { 11 | params: { 12 | actor: params.actor, 13 | limit: 50, 14 | cursor: url.searchParams.get('cursor') || undefined, 15 | }, 16 | }), 17 | ); 18 | 19 | return { followers: { cursor: data.cursor, items: data.followers } }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/packs/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | export const load: PageLoad = async ({ url, params, fetch }) => { 7 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const data = await ok( 10 | client.get('app.bsky.graph.getActorStarterPacks', { 11 | params: { 12 | actor: params.actor, 13 | limit: 50, 14 | cursor: url.searchParams.get('cursor') || undefined, 15 | }, 16 | }), 17 | ); 18 | 19 | return { packs: { cursor: data.cursor, items: data.starterPacks } }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/lib/utils/bluesky/embeds.ts: -------------------------------------------------------------------------------- 1 | import type { RecordEmbed } from '@atcute/bluesky'; 2 | import { parseCanonicalResourceUri } from '@atcute/lexicons'; 3 | 4 | export const getQuoteEmbed = (embed: RecordEmbed | undefined) => { 5 | switch (embed?.$type) { 6 | case 'app.bsky.embed.record#viewRecord': { 7 | return embed; 8 | } 9 | 10 | case 'app.bsky.embed.record#viewNotFound': 11 | case 'app.bsky.embed.record#viewDetached': 12 | case 'app.bsky.embed.record#viewBlocked': { 13 | const result = parseCanonicalResourceUri(embed.uri); 14 | if (!result.ok) { 15 | return; 16 | } 17 | 18 | const uri = result.value; 19 | if (uri.collection === 'app.bsky.feed.post') { 20 | return embed; 21 | } 22 | } 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /src/routes/(app)/trending/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | import { mapDefined } from '@mary/array-fns'; 3 | 4 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 5 | import type { PageLoad } from './$types'; 6 | 7 | import { mapTopic } from './utils'; 8 | 9 | export const load: PageLoad = async ({ fetch }) => { 10 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 11 | 12 | const data = await ok( 13 | client.get('app.bsky.unspecced.getTrendingTopics', { 14 | params: { 15 | limit: 14, 16 | }, 17 | }), 18 | ); 19 | 20 | return { 21 | suggested: mapDefined(data.suggested, mapTopic), 22 | topics: mapDefined(data.topics, mapTopic), 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/+layout.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { LayoutLoad } from './$types'; 5 | 6 | import { makeAtUri } from '$lib/types/at-uri'; 7 | 8 | export const load: LayoutLoad = async ({ params, fetch }) => { 9 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | const data = await ok( 12 | client.get('app.bsky.graph.getStarterPack', { 13 | params: { 14 | starterPack: makeAtUri(params.actor, 'app.bsky.graph.starterpack', params.rkey), 15 | }, 16 | }), 17 | ); 18 | 19 | const view = data.starterPack; 20 | 21 | return { pack: view }; 22 | }; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["@atcute/bluesky"], 5 | "allowJs": true, 6 | "checkJs": true, 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "resolveJsonModule": true, 10 | "skipLibCheck": true, 11 | "sourceMap": true, 12 | "strict": true, 13 | "moduleResolution": "bundler", 14 | }, 15 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 16 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 17 | // 18 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 19 | // from the referenced tsconfig.json - TypeScript does not merge them in 20 | } 21 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 |
11 |

Error {page.status}

12 |

13 | {page.error?.message} 14 |

15 |
16 |
17 | 18 | 36 | -------------------------------------------------------------------------------- /src/routes/watch/[actor=did]/[cid=cidRaw]/+page.ts: -------------------------------------------------------------------------------- 1 | import type { PageLoad } from './$types'; 2 | 3 | export const ssr = false; 4 | export const csr = true; 5 | 6 | export const load: PageLoad = async ({ params }) => { 7 | return { 8 | // Ideally we should just be using `video.cdn.bsky.app` here for the playlist, 9 | // the problem is that the original M3U8 playlist stored by the CDN doesn't contain 10 | // the caption definitions, they're added in by the middleware service. 11 | // 12 | // We'll replace the subsequent playlist and segment URLs when setting up the player. 13 | playlistUrl: `https://video.bsky.app/watch/${params.actor}/${params.cid}/playlist.m3u8`, 14 | thumbnailUrl: `https://video.cdn.bsky.app/hls/${params.actor}/${params.cid}/thumbnail.jpg`, 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/lib/types/at-uri.ts: -------------------------------------------------------------------------------- 1 | import { 2 | parseCanonicalResourceUri, 3 | type Did, 4 | type Handle, 5 | type Nsid, 6 | type ParsedCanonicalResourceUri, 7 | type ResourceUri, 8 | } from '@atcute/lexicons'; 9 | import type { Records } from '@atcute/lexicons/ambient'; 10 | 11 | import { assert } from '$lib/utils/invariant'; 12 | 13 | export const makeAtUri = ( 14 | repo: Did | Handle, 15 | collection: keyof Records | (Nsid & {}), 16 | rkey: string, 17 | ): ResourceUri => { 18 | return `at://${repo}/${collection as Nsid}/${rkey}`; 19 | }; 20 | 21 | export const assertCanonicalResourceUri = (input: string): ParsedCanonicalResourceUri => { 22 | const result = parseCanonicalResourceUri(input); 23 | if (!result.ok) { 24 | assert(false, result.error); 25 | } 26 | 27 | return result.value; 28 | }; 29 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-cloudflare'; 2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://svelte.dev/docs/kit/integrations 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | compilerOptions: { 10 | runes: true, 11 | }, 12 | 13 | kit: { 14 | // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. 15 | // If your environment is not supported, or you settled on a specific environment, switch out the adapter. 16 | // See https://svelte.dev/docs/kit/adapters for more information about adapters. 17 | adapter: adapter(), 18 | }, 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/group-2-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/quotes/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | import { makeAtUri } from '$lib/types/at-uri'; 7 | 8 | export const load: PageLoad = async ({ url, params, fetch }) => { 9 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | const uri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey); 12 | 13 | const data = await ok( 14 | client.get('app.bsky.feed.getQuotes', { 15 | params: { 16 | uri, 17 | limit: 50, 18 | cursor: url.searchParams.get('cursor') || undefined, 19 | }, 20 | }), 21 | ); 22 | 23 | return { quotes: { cursor: data.cursor, items: data.posts } }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/reposts/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | import { makeAtUri } from '$lib/types/at-uri'; 7 | 8 | export const load: PageLoad = async ({ url, params, fetch }) => { 9 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | const uri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey); 12 | 13 | const data = await ok( 14 | client.get('app.bsky.feed.getRepostedBy', { 15 | params: { 16 | uri, 17 | limit: 50, 18 | cursor: url.searchParams.get('cursor') || undefined, 19 | }, 20 | }), 21 | ); 22 | 23 | return { reposts: { cursor: data.cursor, items: data.repostedBy } }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/routes/(app)/search/feeds/+page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | Searching feeds with "{data.query}" — {PUBLIC_APP_NAME} 18 | 19 | 20 | 21 | {#each data.feeds.items as feed (feed.uri)} 22 | 23 | {/each} 24 | 25 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/likes/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | import { makeAtUri } from '$lib/types/at-uri'; 7 | 8 | export const load: PageLoad = async ({ url, params, fetch }) => { 9 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | const uri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey); 12 | 13 | const data = await ok( 14 | client.get('app.bsky.feed.getLikes', { 15 | params: { 16 | uri, 17 | limit: 50, 18 | cursor: url.searchParams.get('cursor') || undefined, 19 | }, 20 | }), 21 | ); 22 | 23 | return { likes: { cursor: data.cursor, items: data.likes.map((like) => like.actor) } }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/routes/(app)/search/users/+page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | Searching users with "{data.query}" — {PUBLIC_APP_NAME} 18 | 19 | 20 | 21 | {#each data.profiles.items as profile (profile.did)} 22 | 23 | {/each} 24 | 25 | -------------------------------------------------------------------------------- /src/lib/assets/default-starterpack-avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/dot-grid-1x3-horizontal-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 16 | 23 | 24 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/posts/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, Client } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | import { fetchTimeline, TimelineType } from '$lib/queries/timeline'; 7 | 8 | export const load: PageLoad = async ({ url, fetch, parent }) => { 9 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | const { pack } = await parent(); 12 | 13 | if (!pack.list) { 14 | return { timeline: { cursor: undefined, items: [] } }; 15 | } 16 | 17 | const timeline = await fetchTimeline({ 18 | client: client, 19 | params: { 20 | type: TimelineType.USER_LIST, 21 | list: pack.list.uri, 22 | cursor: url.searchParams.get('cursor') || undefined, 23 | }, 24 | }); 25 | 26 | return { timeline }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/earth-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/components/page/page-header.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 47 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/posts/+page.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | {#each data.timeline.items as item (item.id)} 23 | 24 | {/each} 25 | 26 | -------------------------------------------------------------------------------- /src/lib/utils/pagination.ts: -------------------------------------------------------------------------------- 1 | export interface PaginationResult { 2 | rootUrl: string | undefined; 3 | nextUrl: string | undefined; 4 | } 5 | 6 | const relative = (url: URL | undefined, canonicalPath?: string): string | undefined => { 7 | if (!url) { 8 | return undefined; 9 | } 10 | 11 | const queryAndHash = url.search + url.hash; 12 | return canonicalPath ? canonicalPath + queryAndHash : queryAndHash || '?'; 13 | }; 14 | 15 | export const paginate = (url: URL, cursor?: string, canonicalPath?: string): PaginationResult => { 16 | let rootUrl: URL | undefined; 17 | let nextUrl: URL | undefined; 18 | 19 | if (url.searchParams.has('cursor')) { 20 | rootUrl = new URL(url.href); 21 | rootUrl.searchParams.delete('cursor'); 22 | } 23 | 24 | if (cursor) { 25 | nextUrl = new URL(url.href); 26 | nextUrl.searchParams.set('cursor', cursor); 27 | } 28 | 29 | return { 30 | rootUrl: relative(rootUrl, canonicalPath), 31 | nextUrl: relative(nextUrl, canonicalPath), 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/likes/+page.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | Feed liked by — {PUBLIC_APP_NAME} 19 | 20 | 21 | 22 | 23 | 24 | {#each data.likes.items as profile (profile.did)} 25 | 26 | {/each} 27 | 28 | -------------------------------------------------------------------------------- /src/routes/(app)/search/posts/+page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | Searching posts with "{data.query}" — {PUBLIC_APP_NAME} 18 | 19 | 20 | 21 | {#each data.posts.items as post (post.uri)} 22 | 32 | {/each} 33 | 34 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/overflow-descendant-item.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 |
13 | 14 |
15 | 16 | Continue thread 17 |
18 | 19 | 44 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/+layout.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | import { isDid, type Did } from '@atcute/lexicons/syntax'; 3 | 4 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 5 | import type { LayoutLoad } from './$types'; 6 | 7 | import { resolveHandle } from '$lib/queries/handle'; 8 | import { makeAtUri } from '$lib/types/at-uri'; 9 | 10 | export const load: LayoutLoad = async ({ params, fetch }) => { 11 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 12 | 13 | let did: Did; 14 | if (isDid(params.actor)) { 15 | did = params.actor; 16 | } else { 17 | did = await resolveHandle({ client: client, handle: params.actor }); 18 | } 19 | 20 | const data = await ok( 21 | client.get('app.bsky.feed.getFeedGenerator', { 22 | params: { 23 | feed: makeAtUri(did, 'app.bsky.feed.generator', params.rkey), 24 | }, 25 | }), 26 | ); 27 | 28 | const view = data.view; 29 | 30 | return { feed: view }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/(timeline)/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, simpleFetchHandler } from '@atcute/client'; 2 | import { isDid, type Did } from '@atcute/lexicons/syntax'; 3 | 4 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 5 | import type { PageLoad } from './$types'; 6 | 7 | import { fetchTimeline, ProfileFilter, TimelineType } from '$lib/queries/timeline'; 8 | 9 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 10 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 11 | 12 | let did: Did; 13 | if (isDid(params.actor)) { 14 | did = params.actor; 15 | } else { 16 | const parentData = await parent(); 17 | did = parentData.profile.did as Did; 18 | } 19 | 20 | const timeline = await fetchTimeline({ 21 | client, 22 | params: { 23 | type: TimelineType.PROFILE, 24 | actor: did, 25 | filter: ProfileFilter.POSTS, 26 | cursor: url.searchParams.get('cursor') || undefined, 27 | }, 28 | }); 29 | 30 | return { timeline }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/missing-descendant-item.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 |
17 | {count === 1 ? `${count} missing reply` : `${count} missing replies`} 18 | View 21 |
22 | 23 | 41 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/(timeline)/media/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, simpleFetchHandler } from '@atcute/client'; 2 | import { isDid, type Did } from '@atcute/lexicons/syntax'; 3 | 4 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 5 | import type { PageLoad } from './$types'; 6 | 7 | import { fetchTimeline, ProfileFilter, TimelineType } from '$lib/queries/timeline'; 8 | 9 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 10 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 11 | 12 | let did: Did; 13 | if (isDid(params.actor)) { 14 | did = params.actor; 15 | } else { 16 | const parentData = await parent(); 17 | did = parentData.profile.did as Did; 18 | } 19 | 20 | const timeline = await fetchTimeline({ 21 | client, 22 | params: { 23 | type: TimelineType.PROFILE, 24 | actor: did, 25 | filter: ProfileFilter.MEDIA, 26 | cursor: url.searchParams.get('cursor') || undefined, 27 | }, 28 | }); 29 | 30 | return { timeline }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/(app)/search/users/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { asString, useSearchParams } from '$lib/utils/search-params'; 4 | 5 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 6 | import type { PageLoad } from './$types'; 7 | 8 | export const load: PageLoad = async ({ url }) => { 9 | const [{ q, cursor }] = useSearchParams(url, { 10 | q: asString.withDefault(''), 11 | cursor: asString, 12 | }); 13 | 14 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 15 | 16 | const query = q.trim(); 17 | if (query.length === 0) { 18 | return { query, profiles: { cursor: undefined, items: [] } }; 19 | } 20 | 21 | const data = await ok( 22 | client.get('app.bsky.actor.searchActors', { 23 | params: { 24 | q: query, 25 | limit: 50, 26 | cursor: cursor || undefined, 27 | }, 28 | }), 29 | ); 30 | 31 | return { 32 | query, 33 | profiles: { 34 | cursor: data.cursor, 35 | items: data.actors, 36 | }, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { ClientResponseError } from '@atcute/client'; 2 | import type { HandleServerError } from '@sveltejs/kit'; 3 | 4 | export const handleError: HandleServerError = async ({ error, event, status, message }) => { 5 | console.error(error); 6 | 7 | if (error instanceof ClientResponseError) { 8 | if (error.status === 403) { 9 | return { 10 | message: `Upstream server is forbidding access to this resource`, 11 | }; 12 | } 13 | 14 | if (error.error === 'AuthRequired' || error.error === 'auth required') { 15 | return { 16 | message: `Upstream server is requiring authentication to access this resource`, 17 | }; 18 | } 19 | 20 | if (error.error === 'InternalServerError' || error.description === 'Internal Server Error') { 21 | return { 22 | message: `Upstream server returned an internal error`, 23 | }; 24 | } 25 | } 26 | 27 | if (status === 404) { 28 | return { 29 | message: `Page not found`, 30 | }; 31 | } 32 | 33 | return { 34 | message: `Something went wrong, sorry about that`, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /src/lib/queries/post.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedDefs } from '@atcute/bluesky'; 2 | import { type Client, ClientResponseError, ok } from '@atcute/client'; 3 | import type { ResourceUri } from '@atcute/lexicons'; 4 | 5 | export interface GetPostReturn { 6 | post: AppBskyFeedDefs.PostView; 7 | threadgate?: AppBskyFeedDefs.ThreadgateView; 8 | } 9 | 10 | export const getPost = async ({ 11 | client, 12 | uri, 13 | }: { 14 | client: Client; 15 | uri: ResourceUri; 16 | }): Promise => { 17 | const data = await ok( 18 | client.get('app.bsky.feed.getPostThread', { 19 | params: { 20 | uri: uri, 21 | depth: 0, 22 | parentHeight: 0, 23 | }, 24 | }), 25 | ); 26 | 27 | const { thread, threadgate } = data; 28 | switch (thread.$type) { 29 | case 'app.bsky.feed.defs#notFoundPost': 30 | case 'app.bsky.feed.defs#blockedPost': { 31 | throw new ClientResponseError({ 32 | status: 400, 33 | data: { error: 'NotFound', message: `Post not found` }, 34 | }); 35 | } 36 | } 37 | 38 | return { post: thread.post, threadgate }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/feeds/+page.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | Feeds by @{data.profile.handle} — {PUBLIC_APP_NAME} 22 | 23 | 24 | 25 | 26 | 27 | {#each data.feeds.items as feed (feed.uri)} 28 | 29 | {/each} 30 | 31 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/lists/+page.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | Lists by @{data.profile.handle} — {PUBLIC_APP_NAME} 22 | 23 | 24 | 25 | 26 | 27 | {#each data.lists.items as list (list.uri)} 28 | 29 | {/each} 30 | 31 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/(timeline)/with_replies/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, simpleFetchHandler } from '@atcute/client'; 2 | import { isDid, type Did } from '@atcute/lexicons/syntax'; 3 | 4 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 5 | import type { PageLoad } from './$types'; 6 | 7 | import { fetchTimeline, ProfileFilter, TimelineType } from '$lib/queries/timeline'; 8 | 9 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 10 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 11 | 12 | let did: Did; 13 | if (isDid(params.actor)) { 14 | did = params.actor; 15 | } else { 16 | const parentData = await parent(); 17 | did = parentData.profile.did as Did; 18 | } 19 | 20 | const timeline = await fetchTimeline({ 21 | client, 22 | params: { 23 | type: TimelineType.PROFILE, 24 | actor: did, 25 | filter: ProfileFilter.POSTS_WITH_REPLIES, 26 | cursor: url.searchParams.get('cursor') || undefined, 27 | }, 28 | }); 29 | 30 | return { timeline }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/members/+page.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 24 | {title} 25 | 26 | 27 | 28 | {#each data.members.items as item (item.subject.did)} 29 | 30 | {/each} 31 | 32 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/+layout.ts: -------------------------------------------------------------------------------- 1 | import { Client, ClientResponseError, simpleFetchHandler } from '@atcute/client'; 2 | import { error } from '@sveltejs/kit'; 3 | 4 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 5 | 6 | import type { LayoutLoad } from './$types'; 7 | 8 | export const load: LayoutLoad = async ({ params, fetch }) => { 9 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | const response = await client.get('app.bsky.actor.getProfile', { 12 | params: { 13 | actor: params.actor, 14 | }, 15 | }); 16 | 17 | if (!response.ok) { 18 | const err = response.data; 19 | switch (err.error) { 20 | case 'InvalidRequest': { 21 | error(404, `Account doesn't exist`); 22 | } 23 | case 'AccountTakedown': { 24 | error(404, `Account is taken down`); 25 | } 26 | case 'AccountDeactivated': { 27 | error(404, `Account is deactivated`); 28 | } 29 | } 30 | 31 | throw new ClientResponseError(response); 32 | } 33 | 34 | return { profile: response.data }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/likes/+page.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | Post liked by — {PUBLIC_APP_NAME} 20 | 21 | 22 | 23 | 24 | 25 | 26 | {#each data.likes.items as profile (profile.did)} 27 | 28 | {/each} 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, simpleFetchHandler } from '@atcute/client'; 2 | import { isDid, type Did } from '@atcute/lexicons/syntax'; 3 | 4 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 5 | import type { PageLoad } from './$types'; 6 | 7 | import { fetchTimeline, TimelineType } from '$lib/queries/timeline'; 8 | import { makeAtUri } from '$lib/types/at-uri'; 9 | 10 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 11 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 12 | 13 | let did: Did; 14 | if (isDid(params.actor)) { 15 | did = params.actor; 16 | } else { 17 | const parentData = await parent(); 18 | did = parentData.feed.creator.did as Did; 19 | } 20 | 21 | const timeline = await fetchTimeline({ 22 | client: client, 23 | params: { 24 | type: TimelineType.CUSTOM_FEED, 25 | feed: makeAtUri(did, 'app.bsky.feed.generator', params.rkey), 26 | cursor: url.searchParams.get('cursor') || undefined, 27 | }, 28 | }); 29 | 30 | return { timeline }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/posts/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, simpleFetchHandler } from '@atcute/client'; 2 | import { isDid, type Did } from '@atcute/lexicons/syntax'; 3 | 4 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 5 | import type { PageLoad } from './$types'; 6 | 7 | import { fetchTimeline, TimelineType } from '$lib/queries/timeline'; 8 | import { makeAtUri } from '$lib/types/at-uri'; 9 | 10 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 11 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 12 | 13 | let did: Did; 14 | if (isDid(params.actor)) { 15 | did = params.actor; 16 | } else { 17 | const parentData = await parent(); 18 | did = parentData.list.creator.did as Did; 19 | } 20 | 21 | const timeline = await fetchTimeline({ 22 | client: client, 23 | params: { 24 | type: TimelineType.USER_LIST, 25 | list: makeAtUri(did, 'app.bsky.graph.list', params.rkey), 26 | cursor: url.searchParams.get('cursor') || undefined, 27 | }, 28 | }); 29 | 30 | return { timeline }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/packs/+page.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | Starter packs by @{data.profile.handle} — {PUBLIC_APP_NAME} 22 | 23 | 24 | 25 | 26 | 27 | {#each data.packs.items as pack (pack.uri)} 28 | 29 | {/each} 30 | 31 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/likes/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | import { isDid, type Did } from '@atcute/lexicons/syntax'; 3 | 4 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 5 | import type { PageLoad } from './$types'; 6 | 7 | import { makeAtUri } from '$lib/types/at-uri'; 8 | 9 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 10 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 11 | 12 | let did: Did; 13 | if (isDid(params.actor)) { 14 | did = params.actor; 15 | } else { 16 | const parentData = await parent(); 17 | did = parentData.feed.creator.did as Did; 18 | } 19 | 20 | const data = await ok( 21 | client.get('app.bsky.feed.getLikes', { 22 | params: { 23 | uri: makeAtUri(did, 'app.bsky.feed.generator', params.rkey), 24 | limit: 50, 25 | cursor: url.searchParams.get('cursor') || undefined, 26 | }, 27 | }), 28 | ); 29 | 30 | return { likes: { cursor: data.cursor, items: data.likes.map((like) => like.actor) } }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/followers/+page.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | Users following @{data.profile.handle} — {PUBLIC_APP_NAME} 22 | 23 | 24 | 25 | 26 | 27 | {#each data.followers.items as profile (profile.did)} 28 | 29 | {/each} 30 | 31 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | export const load: PageLoad = async ({ url, fetch, parent }) => { 7 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const { pack } = await parent(); 10 | 11 | // It shouldn't be missing, but oh well. 12 | if (!pack.list) { 13 | return { members: { cursor: undefined, items: [] } }; 14 | } 15 | 16 | if (pack.listItemsSample) { 17 | if ((pack.list.listItemCount ?? 0) <= pack.listItemsSample.length) { 18 | return { members: { cursor: undefined, items: pack.listItemsSample } }; 19 | } 20 | } 21 | 22 | const data = await ok( 23 | client.get('app.bsky.graph.getList', { 24 | params: { 25 | list: pack.list.uri, 26 | limit: 50, 27 | cursor: url.searchParams.get('cursor') || undefined, 28 | }, 29 | }), 30 | ); 31 | 32 | return { members: { cursor: data.cursor, items: data.items } }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/reposts/+page.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | Post reposted by — {PUBLIC_APP_NAME} 20 | 21 | 22 | 23 | 24 | 25 | 26 | {#each data.reposts.items as profile (profile.did)} 27 | 28 | {/each} 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/following/+page.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | Users followed by @{data.profile.handle} — {PUBLIC_APP_NAME} 22 | 23 | 24 | 25 | 26 | 27 | {#each data.following.items as profile (profile.did)} 28 | 29 | {/each} 30 | 31 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig(({ command }) => { 5 | return { 6 | build: { 7 | target: 'es2024', 8 | }, 9 | esbuild: { 10 | target: 'es2024', 11 | }, 12 | plugins: [ 13 | sveltekit(), 14 | 15 | // Nasty hack to remove the hydration markers that SvelteKit adds to the HTML 16 | // command === 'build' && { 17 | // name: 'remove-hydration-markers', 18 | // transform(code, id, options) { 19 | // if (id.endsWith('.svelte') && code.includes('$$payload')) { 20 | // code = code 21 | // .replace(//g, '') 22 | // .replace(/\$\$slots: {.+?},?/g, '') 23 | // .replace(/\$\$payload\.out \+= ["'`]{2};|\$\.(push|pop)\(\);/g, '') 24 | // .replace(/(?<=\$\$payload\.out \+= )`\${([a-zA-Z0-9_$.,()[\]\s]+?)}`(?=;)/, '$1'); 25 | 26 | // return code; 27 | // } else if (id.includes('node_modules/svelte/') && code.includes('\1/g, '$1$1'); 29 | 30 | // return code; 31 | // } 32 | // }, 33 | // }, 34 | ], 35 | }; 36 | }); 37 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/all-quotes/+page.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | All quotes — {PUBLIC_APP_NAME} 20 | 21 | 22 | 23 | 24 | 25 | 26 | {#each data.quotes.items as post (post.uri)} 27 | 37 | {/each} 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/all-quotes/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | import { getLinksMultiPath } from '$lib/queries/constellation'; 7 | import { makeAtUri } from '$lib/types/at-uri'; 8 | 9 | export const load: PageLoad = async ({ url, params, fetch }) => { 10 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 11 | 12 | const parentUri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey); 13 | 14 | const { cursor, linking_records } = await getLinksMultiPath({ 15 | uri: parentUri, 16 | collection: 'app.bsky.feed.post', 17 | paths: ['.embed.record.uri', '.embed.record.record.uri'], 18 | cursor: url.searchParams.get('cursor'), 19 | limit: 25, 20 | }); 21 | 22 | const items = await (async () => { 23 | const data = await ok( 24 | client.get('app.bsky.feed.getPosts', { 25 | params: { 26 | uris: linking_records.map((link) => makeAtUri(link.did, 'app.bsky.feed.post', link.rkey)), 27 | }, 28 | }), 29 | ); 30 | 31 | return data.posts; 32 | })(); 33 | 34 | return { quotes: { cursor: cursor ?? undefined, items } }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/lib/types/valibot.ts: -------------------------------------------------------------------------------- 1 | import { 2 | isCanonicalResourceUri, 3 | isDid, 4 | isNsid, 5 | isRecordKey, 6 | isResourceUri, 7 | type CanonicalResourceUri, 8 | type Did, 9 | type Nsid, 10 | type RecordKey, 11 | type ResourceUri, 12 | } from '@atcute/lexicons/syntax'; 13 | 14 | import * as v from 'valibot'; 15 | 16 | export const didString = v.pipe( 17 | v.string(), 18 | v.check((input) => isDid(input), `must be a did`), 19 | ) as v.GenericSchema; 20 | 21 | export const nsidString = v.pipe( 22 | v.string(), 23 | v.check((input) => isNsid(input), `must be an nsid`), 24 | ) as v.GenericSchema; 25 | 26 | export const recordKeyString = v.pipe( 27 | v.string(), 28 | v.check((input) => isRecordKey(input), `must be a record key`), 29 | ) as v.GenericSchema; 30 | 31 | export const resourceUriString = v.pipe( 32 | v.string(), 33 | v.check((input) => isResourceUri(input), `must be a resource uri`), 34 | ) as v.GenericSchema; 35 | 36 | export const canonicalResourceUriString = v.pipe( 37 | v.string(), 38 | v.check((input) => isCanonicalResourceUri(input), `must be a canonical resource uri`), 39 | ) as v.GenericSchema; 40 | 41 | export const integer = v.pipe(v.number(), v.safeInteger(), v.minValue(0)); 42 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/+layout.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | {#key data.feed.uri} 10 |
11 |
12 | 13 |
14 | 15 |
16 | {@render children()} 17 |
18 |
19 | {/key} 20 | 21 | 57 | -------------------------------------------------------------------------------- /src/lib/components/island.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | 47 | 48 | 49 | {#if first} 50 | 51 | {/if} 52 | 53 | 54 | {@render children()} 55 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/(timeline)/+page.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | {title} 37 | 38 | 39 | 40 | {#each data.timeline.items as item (item.id)} 41 | 42 | {/each} 43 | 44 | -------------------------------------------------------------------------------- /src/routes/(app)/search/feeds/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { AUTHENTICATED_FEEDS, DECOMISSIONED_FEEDS } from '$lib/constants'; 4 | import { asString, useSearchParams } from '$lib/utils/search-params'; 5 | 6 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 7 | import type { CanonicalResourceUri } from '@atcute/lexicons'; 8 | import type { PageLoad } from './$types'; 9 | 10 | export const load: PageLoad = async ({ url }) => { 11 | const [{ q, cursor }] = useSearchParams(url, { 12 | q: asString.withDefault(''), 13 | cursor: asString, 14 | }); 15 | 16 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 17 | 18 | const query = q.trim(); 19 | const data = await ok( 20 | client.get('app.bsky.unspecced.getPopularFeedGenerators', { 21 | params: { 22 | query: query, 23 | limit: 50, 24 | cursor: cursor || undefined, 25 | }, 26 | }), 27 | ); 28 | 29 | let feeds = data.feeds; 30 | if (query.length === 0) { 31 | feeds = feeds.filter((feed) => { 32 | const uri = feed.uri as CanonicalResourceUri; 33 | return !AUTHENTICATED_FEEDS.includes(uri) && !DECOMISSIONED_FEEDS.includes(uri); 34 | }); 35 | } 36 | 37 | return { 38 | query, 39 | feeds: { 40 | cursor: data.cursor, 41 | items: feeds, 42 | }, 43 | }; 44 | }; 45 | -------------------------------------------------------------------------------- /src/routes/(app)/trending/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyUnspeccedDefs } from '@atcute/bluesky'; 2 | import { isDid, isHandle, isRecordKey } from '@atcute/lexicons/syntax'; 3 | 4 | // /profile/jaz.bsky.social/feed/cv:cat 5 | // /profile/bossett.social/feed/for-science 6 | const FEED_RE = /^\/profile\/([^/]+)\/feed\/([^/]+)$/; 7 | 8 | // /starter-pack/crimew.gay/3lbfhvsingk2i 9 | const STARTERPACK_RE = /^\/starter-pack\/([^/]+)\/([^/]+)$/; 10 | 11 | export interface MappedTopic { 12 | type: 'feed' | 'starterpack'; 13 | name: string; 14 | href: string; 15 | } 16 | 17 | export const mapTopic = ({ topic, link }: AppBskyUnspeccedDefs.TrendingTopic): MappedTopic | undefined => { 18 | let match: RegExpMatchArray | null | undefined; 19 | 20 | if ((match = link.match(FEED_RE))) { 21 | const [, actor, rkey] = match; 22 | 23 | if (!isHandle(actor) && !isDid(actor)) return; 24 | if (!isRecordKey(rkey)) return; 25 | 26 | return { 27 | type: 'feed', 28 | name: topic, 29 | href: `/${actor}/feeds/${rkey}`, 30 | }; 31 | } 32 | 33 | if ((match = link.match(STARTERPACK_RE))) { 34 | const [, actor, rkey] = match; 35 | 36 | if (!isHandle(actor) && !isDid(actor)) return; 37 | if (!isRecordKey(rkey)) return; 38 | 39 | return { 40 | type: 'starterpack', 41 | name: topic, 42 | href: `/${actor}/packs/${rkey}`, 43 | }; 44 | } 45 | 46 | return; 47 | }; 48 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/(timeline)/media/+page.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | {title} 37 | 38 | 39 | 40 | {#each data.timeline.items as item (item.id)} 41 | 42 | {/each} 43 | 44 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/overflow-ascendant-item.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 |
13 | 14 |
15 | 16 |
17 | 18 | See parent replies 19 |
20 | 21 | 57 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/+layout.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | import type { Did } from '@atcute/lexicons'; 3 | import { isDid } from '@atcute/lexicons/syntax'; 4 | 5 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 6 | import type { LayoutLoad } from './$types'; 7 | 8 | import { resolveHandle } from '$lib/queries/handle'; 9 | import { makeAtUri } from '$lib/types/at-uri'; 10 | 11 | export const load: LayoutLoad = async ({ url, route, params, fetch }) => { 12 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 13 | 14 | let did: Did; 15 | if (isDid(params.actor)) { 16 | did = params.actor; 17 | } else { 18 | did = await resolveHandle({ client: client, handle: params.actor }); 19 | } 20 | 21 | const isListing = route.id === '/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/members'; 22 | const cursor = url.searchParams.get('cursor') || undefined; 23 | 24 | const data = await ok( 25 | client.get('app.bsky.graph.getList', { 26 | params: { 27 | list: makeAtUri(did, 'app.bsky.graph.list', params.rkey), 28 | limit: isListing ? 50 : 1, 29 | cursor: isListing ? cursor : undefined, 30 | }, 31 | }), 32 | ); 33 | 34 | const view = data.list; 35 | 36 | return { list: view, members: { cursor: data.cursor, items: data.items } }; 37 | }; 38 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/(timeline)/with_replies/+page.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | {title} 37 | 38 | 39 | 40 | {#each data.timeline.items as item (item.id)} 41 | 42 | {/each} 43 | 44 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/blocked-ascendant-item.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 |
13 | 14 |
15 | 16 |
17 | 18 | Blocked post 19 | 20 | View 21 |
22 | 23 | 59 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/posts/+page.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | {title} 31 | 32 | 33 | 34 | 35 | {#each data.timeline.items as item (item.id)} 36 | 37 | {/each} 38 | 39 | -------------------------------------------------------------------------------- /static/_scripts/_lib/signals.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** 4 | * @template T 5 | * @typedef {{ value: T }} Signal 6 | */ 7 | 8 | /** @type {?() => void} */ 9 | let tracking_effect = null; 10 | 11 | /** 12 | * @template T 13 | * @param {T} value 14 | * @returns {Signal} 15 | */ 16 | export const signal = (value) => { 17 | const listeners = new Set(); 18 | 19 | return { 20 | get value() { 21 | if (tracking_effect) { 22 | listeners.add(tracking_effect); 23 | } 24 | 25 | return value; 26 | }, 27 | set value(next) { 28 | if (next !== value) { 29 | value = next; 30 | listeners.forEach((listener) => listener()); 31 | } 32 | }, 33 | }; 34 | }; 35 | 36 | /** 37 | * @param {() => void} fn 38 | */ 39 | export const effect = (fn) => { 40 | const runner = () => { 41 | const previous_effect = tracking_effect; 42 | tracking_effect = runner; 43 | 44 | try { 45 | fn(); 46 | } finally { 47 | tracking_effect = previous_effect; 48 | } 49 | }; 50 | 51 | runner(); 52 | }; 53 | 54 | /** 55 | * @template T 56 | * @param {() => T} fn 57 | * @returns {T} 58 | */ 59 | export const untrack = (fn) => { 60 | if (tracking_effect === null) { 61 | return fn(); 62 | } 63 | 64 | const previous_effect = tracking_effect; 65 | tracking_effect = null; 66 | 67 | try { 68 | return fn(); 69 | } finally { 70 | tracking_effect = previous_effect; 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /src/routes/go/[shortid]/+page.ts: -------------------------------------------------------------------------------- 1 | import { error, redirect } from '@sveltejs/kit'; 2 | 3 | import * as v from 'valibot'; 4 | 5 | import { PUBLIC_GO_BSKY_URL } from '$env/static/public'; 6 | import type { PageLoad } from './$types'; 7 | 8 | import { redirectBskyUrl } from '$lib/redirector'; 9 | import { safeUrlParse } from '$lib/utils/url'; 10 | 11 | const jsonSchema = v.object({ 12 | url: v.string(), 13 | }); 14 | 15 | export const load: PageLoad = async ({ params }) => { 16 | const response = await fetch(`${PUBLIC_GO_BSKY_URL}/${encodeURIComponent(params.shortid)}`, { 17 | headers: { 18 | accept: 'application/json', 19 | }, 20 | }); 21 | 22 | if (response.status === 404) { 23 | error(404, `Shortlink not found`); 24 | } 25 | if (!response.ok) { 26 | error(500, `Upstream server returned ${response.status}`); 27 | } 28 | 29 | const raw = await response.json(); 30 | 31 | const result = v.safeParse(jsonSchema, raw); 32 | if (!result.success) { 33 | error(500, `Invalid response from upstream server`); 34 | } 35 | 36 | const url = safeUrlParse(result.output.url); 37 | if (!url) { 38 | error(500, `Invalid URL from upstream server; got ${result.output.url}`); 39 | } 40 | 41 | const redir = redirectBskyUrl(url); 42 | 43 | if (!redir || redir.type !== 'internal') { 44 | error(500, `Invalid URL from upstream server; got ${url}`); 45 | } 46 | 47 | redirect(301, redir.url); 48 | }; 49 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/nonexistent-ascendant-post.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 |
13 | 14 |
15 | 16 |
17 | 18 | Post is unavailable 19 | 20 | View 21 |
22 | 23 | 59 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/+page.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 25 | {data.feed.displayName} by @{data.feed.creator.handle} — {PUBLIC_APP_NAME} 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | {#each data.timeline.items as item (item.id)} 34 | 35 | {/each} 36 | 37 | -------------------------------------------------------------------------------- /src/lib/components/page/page-listing.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 | {#if rootUrl} 16 | Show latest {subject} 17 | {/if} 18 | 19 | {@render children()} 20 | 21 | {#if nextUrl} 22 | 23 | {subject === 'timeline' ? `Show older posts` : `Show more ${subject}`} 24 | 25 | {:else} 26 |
27 | {subject === 'timeline' ? `No more posts.` : `No more ${subject}.`} 28 |
29 | {/if} 30 |
31 | 32 | 60 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/components/profile-meta-tags.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | {#if profile.avatar} 31 | 32 | {/if} 33 | 34 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/(timeline)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
21 | Posts 22 | Replies 23 | Media 24 |
25 | 26 | {@render children()} 27 | 28 | 54 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/components/pack-meta-tags.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/components/feed-meta-tags.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | {#if feed.avatar} 42 | 43 | {/if} 44 | 45 | -------------------------------------------------------------------------------- /src/lib/styles/app.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --accent: #1083fe; 3 | --accent-text: #ffffff; 4 | 5 | --text-primary: #0f1419; 6 | --text-blurb: #536471; 7 | --text-link: var(--accent); 8 | 9 | --bg-slate: #e6ecf0; 10 | --bg-primary: #ffffff; 11 | 12 | --divider-sm: #eff3f4; 13 | --divider-md: #cfd9de; 14 | 15 | --tap: var(--text-primary); 16 | 17 | @media (prefers-color-scheme: dark) { 18 | --text-primary: #e7e9ea; 19 | --text-blurb: #8a8f93; 20 | 21 | --bg-slate: #000000; 22 | --bg-primary: #111215; 23 | 24 | --divider-sm: #2f3336; 25 | --divider-md: #333639; 26 | } 27 | } 28 | 29 | :root { 30 | --tap-sm: rgb(from var(--tap) r g b / 0.03); 31 | --tap-sm-pressed: rgb(from var(--tap) r g b / 0.07); 32 | --tap-md: rgb(from var(--tap) r g b / 0.1); 33 | --tap-md-pressed: rgb(from var(--tap) r g b / 0.2); 34 | } 35 | 36 | body { 37 | background: var(--bg-slate); 38 | overflow-y: scroll; 39 | color: var(--text-primary); 40 | font-size: 0.875rem; 41 | line-height: 1.25rem; 42 | font-family: sans-serif; 43 | } 44 | 45 | :where(*, *::before, *::after) { 46 | box-sizing: border-box; 47 | margin: 0; 48 | outline-color: var(--accent); 49 | outline-width: 2px; 50 | padding: 0; 51 | 52 | &:focus-visible { 53 | outline-style: solid; 54 | } 55 | } 56 | 57 | :where(button, input, select, textarea) { 58 | font: inherit; 59 | line-height: inherit; 60 | } 61 | 62 | :where(a) { 63 | color: var(--text-link); 64 | text-decoration: none; 65 | } 66 | 67 | .sv-icon { 68 | flex-shrink: 0; 69 | width: 1em; 70 | height: 1em; 71 | } 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, Mary 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "anartia", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "prepare": "svelte-kit sync || echo ''", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "format": "PRETTIER_EXPERIMENTAL_CLI=1 prettier --cache --write .", 13 | "lint": "PRETTIER_EXPERIMENTAL_CLI=1 prettier --cache --check ." 14 | }, 15 | "devDependencies": { 16 | "@sveltejs/adapter-cloudflare": "^7.2.4", 17 | "@sveltejs/kit": "^2.49.1", 18 | "@sveltejs/vite-plugin-svelte": "^6.2.1", 19 | "postcss": "^8.5.6", 20 | "prettier": "^3.7.4", 21 | "prettier-plugin-css-order": "^2.1.2", 22 | "prettier-plugin-svelte": "^3.4.0", 23 | "svelte": "^5.45.6", 24 | "svelte-check": "^4.3.4", 25 | "typescript": "~5.9.3", 26 | "vite": "^7.2.6", 27 | "wrangler": "^4.53.0" 28 | }, 29 | "dependencies": { 30 | "@atcute/atproto": "^3.1.9", 31 | "@atcute/bluesky": "^3.2.12", 32 | "@atcute/bluesky-richtext-parser": "^1.0.7", 33 | "@atcute/bluesky-richtext-segmenter": "^2.0.4", 34 | "@atcute/client": "^4.1.0", 35 | "@atcute/lexicons": "^1.2.5", 36 | "@atcute/multibase": "^1.1.6", 37 | "@atcute/uint8array": "^1.0.6", 38 | "@mary/array-fns": "jsr:^0.1.5", 39 | "@mary/date-fns": "jsr:^0.1.3", 40 | "hls.js": "^1.6.15", 41 | "valibot": "^1.2.0" 42 | }, 43 | "pnpm": { 44 | "onlyBuiltDependencies": [ 45 | "esbuild", 46 | "workerd" 47 | ] 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/lib/components/islands/time.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | {#if dev} 43 | 46 | {:else} 47 | 48 | 51 | 52 | {/if} 53 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/components/list-meta-tags.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | {#if list.avatar} 43 | 44 | {/if} 45 | 46 | -------------------------------------------------------------------------------- /src/routes/(app)/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fail, redirect, type Actions } from '@sveltejs/kit'; 2 | 3 | import { base } from '$app/paths'; 4 | 5 | import { redirectAtUri, redirectBskyUrl, redirectOtherUrl, type RedirectResult } from '$lib/redirector'; 6 | import { safeUrlParse } from '$lib/utils/url'; 7 | 8 | const MAYBE_HANDLE_RE = /^@[a-zA-Z0-9-. ]+$/; 9 | 10 | export const actions = { 11 | async search({ request }) { 12 | const formData = await request.formData(); 13 | 14 | let query = formData.get('query'); 15 | if (typeof query !== 'string') { 16 | return fail(400, { place: 'search', error: `Invalid form data` }); 17 | } 18 | 19 | query = query.trim(); 20 | 21 | if (MAYBE_HANDLE_RE.test(query)) { 22 | redirect(302, `${base}/search/users?q=${encodeURIComponent(query)}`); 23 | } 24 | 25 | redirect(302, `${base}/search/posts?q=${encodeURIComponent(query)}`); 26 | }, 27 | async redirect({ request }) { 28 | const formData = await request.formData(); 29 | 30 | let query = formData.get('query'); 31 | if (typeof query !== 'string') { 32 | return fail(400, { place: 'redirect', error: `Invalid form data` }); 33 | } 34 | 35 | query = query.trim(); 36 | 37 | let redir: RedirectResult | undefined; 38 | if (query.startsWith('at://')) { 39 | redir = redirectAtUri(query); 40 | } else { 41 | const url = safeUrlParse(query); 42 | if (url) { 43 | redir = redirectBskyUrl(url) || redirectOtherUrl(url); 44 | } 45 | } 46 | 47 | if (redir && redir.type === 'internal') { 48 | redirect(302, redir.url); 49 | } 50 | 51 | return fail(400, { place: 'redirect', error: `Invalid link provided` }); 52 | }, 53 | } satisfies Actions; 54 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/quotes/+page.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | Quotes — {PUBLIC_APP_NAME} 24 | 25 | 26 | 27 | 28 | 37 | 38 | 39 | 40 | {#each data.quotes.items as post (post.uri)} 41 | 51 | {/each} 52 | 53 | 54 | -------------------------------------------------------------------------------- /src/lib/components/embeds/quote-blocked-embed.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | {embed.$type === 'app.bsky.embed.record#viewDetached' ? `Quote detached` : `Interaction blocked`} 22 | 23 | 24 | View 25 | 26 | 27 | 67 | -------------------------------------------------------------------------------- /src/routes/(app)/search/posts/+page.ts: -------------------------------------------------------------------------------- 1 | import { Client, ClientResponseError, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import { asString, asStringUnion, useSearchParams } from '$lib/utils/search-params'; 4 | 5 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 6 | import type { PageLoad } from './$types'; 7 | import { mapDefined, unique } from '@mary/array-fns'; 8 | import type { AppBskyFeedDefs, AppBskyFeedPost } from '@atcute/bluesky'; 9 | 10 | export const load: PageLoad = async ({ url }) => { 11 | const [{ q, sort, cursor }] = useSearchParams(url, { 12 | q: asString.withDefault(''), 13 | sort: asStringUnion(['top', 'latest']).withDefault('top'), 14 | cursor: asString, 15 | }); 16 | 17 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 18 | 19 | const query = q.trim(); 20 | if (query.length === 0) { 21 | return { query, posts: { cursor: undefined, items: [] } }; 22 | } 23 | 24 | const response = await client.get('app.bsky.feed.searchPosts', { 25 | params: { 26 | q: query, 27 | limit: 50, 28 | sort: sort, 29 | cursor: cursor || undefined, 30 | }, 31 | }); 32 | 33 | if (!response.ok) { 34 | if (response.status === 403) { 35 | return { query, posts: { cursor: undefined, items: [] } }; 36 | } 37 | 38 | throw new ClientResponseError(response); 39 | } 40 | 41 | const data = response.data; 42 | 43 | const replyUris = unique( 44 | mapDefined(data.posts, (post) => { 45 | const record = post.record as AppBskyFeedPost.Main; 46 | const reply = record.reply; 47 | if (reply) { 48 | return [reply.root.uri, reply.parent.uri]; 49 | } 50 | }).flat(), 51 | ); 52 | 53 | return { query, posts: { cursor: data.cursor, items: data.posts } }; 54 | }; 55 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/main-post-metrics.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | {#snippet Stat(count: number | undefined, one: string, many: string, href: string)} 19 | {#if count !== undefined && count > 0} 20 | 21 | {formatCompactNumber(count)} 22 | {count === 1 ? one : many} 23 | 24 | {/if} 25 | {/snippet} 26 | 27 | {#if post.repostCount || post.quoteCount || post.likeCount} 28 |
29 | {@render Stat(post.repostCount, 'repost', 'reposts', `${baseUrl}/reposts`)} 30 | {@render Stat(post.quoteCount, 'quote', 'quotes', `${baseUrl}/quotes`)} 31 | {@render Stat(post.likeCount, 'like', 'likes', `${baseUrl}/likes`)} 32 |
33 | {/if} 34 | 35 | 63 | -------------------------------------------------------------------------------- /src/lib/components/content-hider.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | {#if !blur} 17 | {@render children()} 18 | {:else} 19 |
20 | 21 | 22 | 23 | {blur.name} 24 | 25 | 26 | 27 | 28 |
29 | {@render children()} 30 |
31 |
32 | {/if} 33 | 34 | 89 | -------------------------------------------------------------------------------- /static/_scripts/video-embed.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | /** @type {Map void>} */ 4 | const callbacks = new Map(); 5 | 6 | const observer = new ResizeObserver((entries) => { 7 | for (let idx = 0, len = entries.length; idx < len; idx++) { 8 | const entry = entries[idx]; 9 | 10 | const target = entry.target; 11 | const callback = callbacks.get(target); 12 | 13 | if (callback) { 14 | callback(entry); 15 | } else { 16 | observer.unobserve(target); 17 | } 18 | } 19 | }); 20 | 21 | (() => { 22 | /** @type {NodeListOf} */ 23 | const nodes = document.querySelectorAll('.isl-video-embed > .constrainer > .link'); 24 | 25 | for (const anchor of nodes) { 26 | const parent = /** @type {HTMLDivElement} */ (anchor.parentElement); 27 | 28 | // listen for clicks on the anchor 29 | anchor.addEventListener('click', (event) => { 30 | event.preventDefault(); 31 | 32 | // replace the anchor with an iframe 33 | const iframe = document.createElement('iframe'); 34 | iframe.src = anchor.href; 35 | 36 | anchor.replaceWith(iframe); 37 | 38 | // observe the parent element to resize the iframe 39 | callbacks.set(parent, (entry) => { 40 | iframe.width = '' + entry.contentRect.width; 41 | iframe.height = '' + entry.contentRect.height; 42 | }); 43 | 44 | observer.observe(parent); 45 | }); 46 | 47 | // prefetch on hover 48 | { 49 | const controller = new AbortController(); 50 | const signal = controller.signal; 51 | 52 | const prefetch = () => { 53 | const link = document.createElement('link'); 54 | link.rel = 'prefetch'; 55 | link.as = 'document'; 56 | link.href = anchor.href; 57 | 58 | document.head.appendChild(link); 59 | 60 | controller.abort(); 61 | }; 62 | 63 | anchor.addEventListener('mouseover', prefetch, { signal }); 64 | anchor.addEventListener('touchstart', prefetch, { signal }); 65 | } 66 | } 67 | })(); 68 | -------------------------------------------------------------------------------- /src/lib/components/richtext-raw-renderer.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | 21 |

22 | {#each tokenize(text) as token} 23 | {#if token.type === 'autolink'} 24 | {@const parsed = safeUrlParse(token.url)} 25 | 26 | {#if parsed === null} 27 | {token.raw} 28 | {:else} 29 | {@const redir = redirectBskyUrl(parsed)} 30 | {@const label = token.raw.replace(HTTP_RE, '')} 31 | 32 | {#if redir && redir.type === 'internal'} 33 | {label} 34 | {:else} 35 | {label} 38 | {/if} 39 | {/if} 40 | {:else if token.type === 'mention'} 41 | {token.raw} 42 | {:else if token.type === 'topic'} 43 | {token.raw} 44 | {:else} 45 | {token.raw} 46 | {/if} 47 | {/each} 48 |

49 | 50 | 77 | -------------------------------------------------------------------------------- /src/lib/components/embeds/video-thumbnail-embed.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 |
20 |
21 | {video.alt} 27 | 28 |
29 | 30 |
31 | 32 |
33 |
34 |
35 | 36 | 89 | -------------------------------------------------------------------------------- /src/lib/components/embeds/components/image-alt.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | 19 | 20 |

Image description

21 | 22 |

{alt}

23 |
24 | 25 | 90 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/+page.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedGetPostThread } from '@atcute/bluesky'; 2 | import { Client, ClientResponseError, ok, simpleFetchHandler } from '@atcute/client'; 3 | import { isDid, type Did } from '@atcute/lexicons/syntax'; 4 | 5 | import { error } from '@sveltejs/kit'; 6 | 7 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 8 | import type { PageLoad } from './$types'; 9 | 10 | import { resolveHandle } from '$lib/queries/handle'; 11 | import { makeAtUri } from '$lib/types/at-uri'; 12 | 13 | export const load: PageLoad = async ({ params }) => { 14 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 15 | 16 | let did: Did; 17 | if (!isDid(params.actor)) { 18 | try { 19 | did = await resolveHandle({ client: client, handle: params.actor }); 20 | } catch (err) { 21 | if (err instanceof ClientResponseError) { 22 | switch (err.error) { 23 | case 'InvalidRequest': { 24 | error(404, `Account doesn't exist`); 25 | } 26 | } 27 | } 28 | 29 | throw err; 30 | } 31 | } else { 32 | did = params.actor; 33 | } 34 | 35 | const uri = makeAtUri(did, 'app.bsky.feed.post', params.rkey); 36 | 37 | let data: AppBskyFeedGetPostThread.$output; 38 | 39 | try { 40 | data = await ok( 41 | client.get('app.bsky.feed.getPostThread', { 42 | params: { 43 | uri: uri, 44 | depth: 4, 45 | parentHeight: 10, 46 | }, 47 | }), 48 | ); 49 | } catch (err) { 50 | if (err instanceof ClientResponseError) { 51 | switch (err.error) { 52 | case 'NotFound': { 53 | error(404, `Post not found`); 54 | } 55 | } 56 | } 57 | 58 | throw err; 59 | } 60 | 61 | const thread = data.thread; 62 | switch (thread.$type) { 63 | case 'app.bsky.feed.defs#notFoundPost': { 64 | error(404, `Post not found`); 65 | } 66 | case 'app.bsky.feed.defs#blockedPost': { 67 | // shouldn't happen? 68 | error(404, `Blocked post`); 69 | } 70 | } 71 | 72 | return { thread, threadgate: data.threadgate }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/all-replies/+page.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | All replies — {PUBLIC_APP_NAME} 19 | 20 | 21 | 22 | 23 | 24 |
25 | {#if rootUrl} 26 | Show latest replies 27 | {/if} 28 | 29 |
30 | 31 |
32 | 33 | {#if nextUrl} 34 | Show more replies 35 | {:else} 36 |
No more replies
37 | {/if} 38 |
39 |
40 | 41 | 80 | -------------------------------------------------------------------------------- /src/lib/components/timeline/post-metrics.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | {#snippet Stat(count: number, Icon: Component, one: string, many: string)} 24 |
28 |
29 | 30 |
31 | 32 | 33 | {formatCompactNumber(count)} 34 | 35 |
36 | {/snippet} 37 | 38 |
39 | {@render Stat(replyCount, Bubble_2Outlined, 'reply', 'replies')} 40 | {@render Stat(repostCount, ArrowsRepeatRightLeftOutlined, 'repost', 'reposts')} 41 | {@render Stat(likeCount, HeartOutlined, 'like', 'likes')} 42 |
43 | 44 | 76 | -------------------------------------------------------------------------------- /src/lib/components/embeds/feed-embed.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 26 |
27 | 28 | 29 |
30 |

{normalizeDisplayName(feed.displayName)}

31 |

Feed by @{creator.handle}

32 |
33 |
34 | 35 |

{truncateRight(trimRichText(feed.description ?? ''), 190)}

36 |
37 | 38 | 90 | -------------------------------------------------------------------------------- /src/lib/components/lists/list-item.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 | 37 | 38 | 91 | -------------------------------------------------------------------------------- /src/lib/components/timeline/post-meta.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 | 40 | 41 | 98 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/posts/rss/+server.ts: -------------------------------------------------------------------------------- 1 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 | 3 | import type { Did } from '@atcute/lexicons'; 4 | import { isDid } from '@atcute/lexicons/syntax'; 5 | 6 | import { PUBLIC_APP_URL, PUBLIC_APPVIEW_URL } from '$env/static/public'; 7 | import type { RequestHandler } from './$types'; 8 | 9 | import { buildTimelineSlices } from '$lib/models/timeline'; 10 | import { resolveHandle } from '$lib/queries/handle'; 11 | import { createRssFeed, feedPostToFeedItem } from '$lib/rss'; 12 | import { makeAtUri } from '$lib/types/at-uri'; 13 | 14 | export const GET: RequestHandler = async ({ params, fetch }) => { 15 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 16 | 17 | let did: Did; 18 | if (isDid(params.actor)) { 19 | did = params.actor; 20 | } else { 21 | did = await resolveHandle({ client: client, handle: params.actor }); 22 | } 23 | 24 | const uri = makeAtUri(did, 'app.bsky.graph.list', params.rkey); 25 | 26 | const [list, timeline] = await Promise.all([ 27 | (async () => { 28 | const data = await ok( 29 | client.get('app.bsky.graph.getList', { 30 | params: { 31 | list: uri, 32 | limit: 1, 33 | }, 34 | }), 35 | ); 36 | 37 | return data.list; 38 | })(), 39 | 40 | (async () => { 41 | const data = await ok( 42 | client.get('app.bsky.feed.getListFeed', { 43 | params: { 44 | list: uri, 45 | limit: 100, 46 | }, 47 | }), 48 | ); 49 | 50 | const slices = buildTimelineSlices(data.feed); 51 | 52 | return slices 53 | .flatMap((slice) => slice.items) 54 | .sort((a, b) => (a.post.indexedAt > b.post.indexedAt ? -1 : 1)); 55 | })(), 56 | ]); 57 | 58 | const rss = createRssFeed({ 59 | meta: { 60 | title: list.name.trim(), 61 | description: `Posts from ${list.creator.handle}'s list`, 62 | pageUrl: `${PUBLIC_APP_URL}/${did}/lists/${params.rkey}/posts`, 63 | rssUrl: `${PUBLIC_APP_URL}/${did}/lists/${params.rkey}/posts/rss`, 64 | image: list.avatar ? { src: list.avatar } : undefined, 65 | }, 66 | items: timeline.map(feedPostToFeedItem), 67 | }); 68 | 69 | return new Response(rss, { 70 | headers: { 71 | 'content-type': 'application/rss+xml; charset=utf-8', 72 | 'cache-control': 'public, max-age=300', // 5 minutes 73 | }, 74 | }); 75 | }; 76 | -------------------------------------------------------------------------------- /src/lib/components/embeds/list-embed.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | 27 |
28 | 29 | 30 |
31 |

{normalizeDisplayName(list.name)}

32 |

{purposeToLabel(list.purpose)} by @{creator.handle}

33 |
34 |
35 | 36 |

{truncateRight(trimRichText(list.description ?? ''), 190)}

37 |
38 | 39 | 91 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/all-replies/+page.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyFeedDefs } from '@atcute/bluesky'; 2 | import { Client, ClientResponseError, simpleFetchHandler } from '@atcute/client'; 3 | import { definite } from '@mary/array-fns'; 4 | 5 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 6 | import type { PageLoad } from './$types'; 7 | 8 | import { getLinks } from '$lib/queries/constellation'; 9 | import { getPost } from '$lib/queries/post'; 10 | import { makeAtUri } from '$lib/types/at-uri'; 11 | 12 | export const load: PageLoad = async ({ url, params, fetch }) => { 13 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 14 | 15 | const parentUri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey); 16 | 17 | // Fetch the parent post, but don't block. 18 | const postPromise = getPost({ client, uri: parentUri }); 19 | void postPromise.catch(() => {}); 20 | 21 | // Get links from Constellation 22 | const { cursor, linking_records } = await getLinks({ 23 | uri: parentUri, 24 | collection: 'app.bsky.feed.post', 25 | path: '.reply.parent.uri', 26 | cursor: url.searchParams.get('cursor'), 27 | }); 28 | 29 | // Hydrate the links 30 | const resolvedReplies = await Promise.all( 31 | linking_records.map(async (link) => { 32 | const response = await client.get('app.bsky.feed.getPostThread', { 33 | params: { 34 | uri: makeAtUri(link.did, 'app.bsky.feed.post', link.rkey), 35 | depth: 3, 36 | parentHeight: 0, 37 | }, 38 | }); 39 | 40 | if (!response.ok) { 41 | // AppView says not found, carry on 42 | if (response.data.error === 'NotFound') { 43 | return null; 44 | } 45 | 46 | throw new ClientResponseError(response); 47 | } 48 | 49 | const thread = response.data.thread; 50 | switch (thread.$type) { 51 | // same goes for this union 52 | case 'app.bsky.feed.defs#notFoundPost': 53 | case 'app.bsky.feed.defs#blockedPost': { 54 | return null; 55 | } 56 | } 57 | 58 | return thread; 59 | }), 60 | ); 61 | 62 | const replies = definite(resolvedReplies); 63 | const { post: parentPost, threadgate } = await postPromise; 64 | 65 | const thread: AppBskyFeedDefs.ThreadViewPost = { 66 | post: parentPost, 67 | replies: replies, 68 | }; 69 | 70 | return { 71 | cursor: cursor || undefined, 72 | thread: thread, 73 | threadgate, 74 | }; 75 | }; 76 | -------------------------------------------------------------------------------- /src/lib/components/starterpacks/starterpack-item.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
26 | 34 | 35 |

{trimRichText(record.description ?? '')}

36 |
37 | 38 | 91 | -------------------------------------------------------------------------------- /src/lib/components/richtext-renderer.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 28 | 29 |

30 | {#each segmentize(text, facets) as segment} 31 | {@const feature = grabFirstSupported(segment.features)} 32 | 33 | {#if !feature} 34 | {segment.text} 35 | {:else if feature.$type === 'app.bsky.richtext.facet#link'} 36 | {@const parsed = safeUrlParse(feature.uri)} 37 | 38 | {#if parsed === null} 39 | {segment.text} 40 | {:else} 41 | {@const redir = redirectBskyUrl(parsed)} 42 | 43 | {#if redir && redir.type === 'internal'} 44 | {segment.text} 45 | {:else} 46 | {segment.text} 49 | {/if} 50 | {/if} 51 | {:else if feature.$type === 'app.bsky.richtext.facet#mention'} 52 | {segment.text} 53 | {:else if feature.$type === 'app.bsky.richtext.facet#tag'} 54 | {segment.text} 57 | {/if} 58 | {/each} 59 |

60 | 61 | 88 | -------------------------------------------------------------------------------- /src/routes/(app)/trending/+page.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | Trending — {PUBLIC_APP_NAME} 15 | 16 | 17 | {#snippet Topic(topic: MappedTopic)} 18 | 19 | {#if topic.type === 'starterpack'} 20 | 21 | 23 | {/if} 24 | 25 | {topic.name} 26 | 27 | {/snippet} 28 | 29 | 30 |
31 |
32 |

Trending

33 | 34 |
35 | {#each data.topics as topic (topic.href)} 36 | {@render Topic(topic)} 37 | {/each} 38 |
39 |
40 | 41 |
42 |

Recommended

43 | 44 |
45 | {#each data.suggested as topic (topic.href)} 46 | {@render Topic(topic)} 47 | {/each} 48 |
49 |
50 |
51 |
52 | 53 | 107 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import type { CanonicalResourceUri } from '@atcute/lexicons'; 2 | 3 | // Popular feeds that requires authentication to view 4 | export const AUTHENTICATED_FEEDS: CanonicalResourceUri[] = [ 5 | // "Popular With Friends" by @bsky.app 6 | `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/with-friends`, 7 | // "Mutuals" by @skyfeed.xyz 8 | `at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/mutuals`, 9 | // "Only Posts" by @skyfeed.xyz 10 | `at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/only-posts`, 11 | // "Mentions" by @flicknow.xyz 12 | `at://did:plc:wzsilnxf24ehtmmc3gssy5bu/app.bsky.feed.generator/mentions`, 13 | // "My Bangers" by @jaz.bsky.social 14 | `at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/bangers`, 15 | // "Mutuals" by @bsky.app 16 | `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/mutuals`, 17 | // "Media" by @jcsalterego.bsky.social 18 | `at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.generator/media`, 19 | // "The 'Gram" by @why.bsky.team 20 | `at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followpics`, 21 | // "Discover" by @skyfeed.xyz 22 | `at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/discover`, 23 | // "Latest from Follows" by @why.bsky.team 24 | `at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/bestoffollows`, 25 | // "Teams" by @retr0.id 26 | `at://did:plc:vwzwgnygau7ed7b7wt5ux7y2/app.bsky.feed.generator/teams`, 27 | // "Quiet Posters" by @why.bsky.team 28 | `at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/infreq`, 29 | // "Best of Follows" by @bsky.app 30 | `at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/best-of-follows`, 31 | // "FollowersLike" by @why.bsky.team 32 | `at://did:plc:vpkhqolt662uhesyj6nxm7ys/app.bsky.feed.generator/followlikes`, 33 | // "Re+Posts" by @skyfeed.xyz 34 | `at://did:plc:tenurhgjptubkk5zf5qhi3og/app.bsky.feed.generator/re-plus-posts`, 35 | ]; 36 | 37 | export const DECOMISSIONED_FEEDS: CanonicalResourceUri[] = [ 38 | // "Japanese Cluster" by @jaz.bsky.social 39 | `at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/cl-japanese`, 40 | // "Brazil Supercluster" by @jaz.bsky.social 41 | `at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/cl-brasil`, 42 | // "Cat Pics" by @jaz.bsky.social 43 | `at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/cv:cat`, 44 | // "Trans+Queer Shitposters" by @jaz.bsky.social 45 | `at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/cl-tqsp`, 46 | // "Dog Pics" by @jaz.bsky.social 47 | `at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.generator/cv:dog`, 48 | ]; 49 | -------------------------------------------------------------------------------- /src/lib/components/profiles/profile-item.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
21 |
22 | 23 |
24 | 25 |
26 | 32 | 33 |

{'description' in profile ? trimRichText(profile.description ?? '') : ''}

34 |
35 |
36 | 37 | 115 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/unroll/+page.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@sveltejs/kit'; 2 | 3 | import type { AppBskyFeedDefs } from '@atcute/bluesky'; 4 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 5 | import type { $type } from '@atcute/lexicons'; 6 | 7 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 8 | import type { PageLoad } from './$types'; 9 | 10 | import { makeAtUri } from '$lib/types/at-uri'; 11 | 12 | export const load: PageLoad = async ({ params }) => { 13 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 14 | 15 | let currentUri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey); 16 | const items: AppBskyFeedDefs.ThreadViewPost[] = []; 17 | 18 | while (true) { 19 | const data = await ok( 20 | client.get('app.bsky.feed.getPostThread', { 21 | params: { 22 | uri: currentUri, 23 | // The max is 1000, but the AppView only returns 10. 24 | depth: 1000, 25 | parentHeight: 0, 26 | }, 27 | }), 28 | ); 29 | 30 | switch (data.thread.$type) { 31 | case 'app.bsky.feed.defs#notFoundPost': { 32 | error(404, `Post not found`); 33 | } 34 | case 'app.bsky.feed.defs#blockedPost': { 35 | error(404, `Blocked post`); 36 | } 37 | } 38 | 39 | // Add the root thread 40 | if (items.length === 0) { 41 | items.push(data.thread); 42 | } else { 43 | items[items.length - 1] = data.thread; 44 | } 45 | 46 | // Walk through the thread tree structure 47 | let foundReply = false; 48 | while (true) { 49 | const tail = items[items.length - 1]; 50 | if (!tail.replies) { 51 | break; 52 | } 53 | 54 | const replies = tail.replies.filter((reply): reply is $type.enforce => { 55 | if (reply.$type !== 'app.bsky.feed.defs#threadViewPost') { 56 | return false; 57 | } 58 | 59 | if (reply.post.author.did !== tail.post.author.did) { 60 | return false; 61 | } 62 | 63 | return true; 64 | }); 65 | 66 | if (replies.length === 0) { 67 | break; 68 | } 69 | 70 | // Get earliest first 71 | replies.sort((a, b) => { 72 | const aIndexed = a.post.indexedAt; 73 | const bIndexed = b.post.indexedAt; 74 | 75 | if (aIndexed < bIndexed) { 76 | return -1; 77 | } 78 | if (aIndexed > bIndexed) { 79 | return 1; 80 | } 81 | 82 | return 0; 83 | }); 84 | 85 | items.push(replies[0]); 86 | 87 | currentUri = replies[0].post.uri; 88 | foundReply = true; 89 | } 90 | 91 | // No further valid reply, break out of loop 92 | if (!foundReply) { 93 | break; 94 | } 95 | } 96 | 97 | return { posts: items.map((item) => item.post) }; 98 | }; 99 | -------------------------------------------------------------------------------- /src/lib/components/feeds/feed-item.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
26 | 34 | 35 |

{trimRichText(feed.description ?? '')}

36 | 37 |

38 | {feed.likeCount === 1 39 | ? `Liked by ${formatLongNumber(feed.likeCount)} user` 40 | : `Liked by ${formatLongNumber(feed.likeCount ?? 0)} users`} 41 |

42 |
43 | 44 | 103 | -------------------------------------------------------------------------------- /src/lib/components/overflow-menu.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 27 | 28 | 29 | {#each items as { id, label, href, external, icon: Icon }} 30 | 31 | 32 | {label} 33 | 34 | {/each} 35 | 36 | 37 | 108 | -------------------------------------------------------------------------------- /src/lib/components/avatar.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 45 | 46 | {#snippet Image()} 47 | 53 | {/snippet} 54 | 55 | {#if href} 56 | 57 | {@render Image()} 58 | 59 | {:else} 60 |
61 | {@render Image()} 62 |
63 | {/if} 64 | 65 | 113 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/descendants.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | {@render Replies(root, false)} 25 | 26 | {#snippet Replies(thread: AppBskyFeedDefs.ThreadViewPost, drawLines: boolean)} 27 | {@const replies = thread.replies?.toSorted((a, b) => sort(thread.post, a, b)) ?? []} 28 | 29 | {#each replies as item, idx} 30 | {#if drawLines} 31 |
32 | 33 |
34 |
35 | {/if} 36 | 37 | {#if item.$type === 'app.bsky.feed.defs#threadViewPost'} 38 | {@const hasDescendant = !!(item.replies?.length || item.post.replyCount)} 39 | {@const isNested = (item.replies?.length || item.post.replyCount || 0) > 1} 40 | 41 | 47 | {@render Replies(item, isNested)} 48 | 49 | {:else if item.$type === 'app.bsky.feed.defs#blockedPost'} 50 |
blocked
51 | {/if} 52 | {:else} 53 | {#if thread.post.replyCount !== 0 && thread !== root} 54 | {@const post = thread.post} 55 | 56 | {#if drawLines} 57 |
58 |
59 |
60 | {/if} 61 | 62 | 65 | {/if} 66 | {/each} 67 | {/snippet} 68 | 69 | 91 | -------------------------------------------------------------------------------- /src/lib/components/embeds/starterpack-embed.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 | {#if large} 29 | 30 | {/if} 31 | 32 |
33 |
34 | 35 | 36 |
37 |

{normalizeDisplayName(record.name)}

38 |

Starter pack by @{creator.handle}

39 |
40 |
41 | 42 |

{truncateRight(trimRichText(record.description ?? ''), 190)}

43 |
44 |
45 | 46 | 109 | -------------------------------------------------------------------------------- /src/routes/(app)/search/+server.ts: -------------------------------------------------------------------------------- 1 | import { redirect, type RequestHandler } from '@sveltejs/kit'; 2 | 3 | import { isDid, isHandle, isRecordKey, isTid } from '@atcute/lexicons/syntax'; 4 | 5 | import { 6 | BSKY_FEED_LINK_RE, 7 | BSKY_LIST_LINK_RE, 8 | BSKY_POST_LINK_RE, 9 | BSKY_PROFILE_LINK_RE, 10 | } from '$lib/utils/bluesky/urls'; 11 | import { asString, useSearchParams } from '$lib/utils/search-params'; 12 | 13 | import { base } from '$app/paths'; 14 | 15 | export const GET: RequestHandler = async ({ url }) => { 16 | const [{ q }] = useSearchParams(url, { 17 | q: asString.withDefault(''), 18 | }); 19 | 20 | const query = q.trim(); 21 | 22 | // redirect to user search if query starts with '@' and is a valid handle 23 | if (query.startsWith('@') && isHandle(query.slice(1))) { 24 | redirect(302, `${base}/search/users?q=${encodeURIComponent(query)}`); 25 | } 26 | 27 | // redirect if it's a known bsky.app link 28 | { 29 | const redirectUrl = findLinkRedirect(query); 30 | console.log(redirectUrl, query); 31 | if (redirectUrl) { 32 | redirect(302, redirectUrl); 33 | } 34 | } 35 | 36 | redirect(302, `${base}/search/posts?q=${encodeURIComponent(query)}`); 37 | }; 38 | 39 | const findLinkRedirect = (raw: string): string | null | undefined => { 40 | const url = URL.parse(raw); 41 | if (!url) { 42 | return; 43 | } 44 | 45 | const host = url.host; 46 | const pathname = url.pathname; 47 | let match: RegExpExecArray | null | undefined; 48 | 49 | if (host === 'bsky.app' || host === 'staging.bsky.app' || host === 'main.bsky.dev') { 50 | if ((match = BSKY_PROFILE_LINK_RE.exec(pathname))) { 51 | const [, actor] = match; 52 | 53 | if (!isHandle(actor) && !isDid(actor)) { 54 | return null; 55 | } 56 | 57 | return `${base}/${match[1]}`; 58 | } 59 | 60 | if ((match = BSKY_POST_LINK_RE.exec(pathname))) { 61 | const [, actor, rkey] = match; 62 | 63 | if (!isHandle(actor) && !isDid(actor)) { 64 | return null; 65 | } 66 | if (!isTid(rkey)) { 67 | return null; 68 | } 69 | 70 | return `${base}/${actor}/${rkey}`; 71 | } 72 | 73 | if ((match = BSKY_FEED_LINK_RE.exec(pathname))) { 74 | const [, actor, rkey] = match; 75 | 76 | if (!isHandle(actor) && !isDid(actor)) { 77 | return null; 78 | } 79 | if (!isRecordKey(rkey)) { 80 | return null; 81 | } 82 | 83 | return `${base}/${actor}/feeds/${rkey}`; 84 | } 85 | 86 | if ((match = BSKY_LIST_LINK_RE.exec(pathname))) { 87 | const [, actor, rkey] = match; 88 | 89 | if (!isHandle(actor) && !isDid(actor)) { 90 | return null; 91 | } 92 | if (!isRecordKey(rkey)) { 93 | return null; 94 | } 95 | 96 | return `${base}/${actor}/lists/${rkey}`; 97 | } 98 | 99 | return null; 100 | } 101 | 102 | return; 103 | }; 104 | -------------------------------------------------------------------------------- /src/lib/components/embeds/external-embed.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 30 | {#if external.thumb} 31 | 32 | {/if} 33 | 34 |
35 |

{truncateRight(external.title.trim().replace(/\s+/g, ' '), 190)}

36 |

{truncateRight(external.description.trim().replace(/\s+/g, ' '), 190)}

37 | 38 | {#if domain} 39 |
40 | 41 | 42 | {domain} 43 |
44 | {/if} 45 |
46 |
47 | 48 | 119 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/+layout.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {#key data.list.uri} 36 |
37 |
38 | 39 |
40 | 41 |
42 |
43 | {#if data.list.purpose === 'app.bsky.graph.defs#curatelist'} 44 | Posts 45 | {/if} 46 | 47 | Members 48 | 49 |
50 |
51 | 52 | {@render children()} 53 |
54 |
55 | {/key} 56 | 57 | 117 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/[rkey=tid]/components/post-ascendant-item.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 |
38 |
39 |
40 | {#if item.prev} 41 |
42 | {/if} 43 | 44 | 45 | 46 |
47 |
48 | 49 |
50 | 51 | 52 | 53 | 54 | 55 | {#if post.embed} 56 | 57 | {/if} 58 | 59 | 60 | 61 |
62 |
63 |
64 | 65 | 116 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/components/pack-aside.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 |
29 | 30 | 31 | 48 | 49 |

{normalizeDisplayName(record.name)}

50 | 51 | {#if record.description} 52 | {#if record.descriptionFacets === undefined} 53 | 54 | {:else} 55 | 56 | {/if} 57 | {/if} 58 | 59 |
60 | 61 | {pack.creator.handle} 62 |
63 |
64 | 65 | 108 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/(profile)/(timeline)/rss/+server.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyActorDefs } from '@atcute/bluesky'; 2 | import { Client, ok, simpleFetchHandler } from '@atcute/client'; 3 | 4 | import { PUBLIC_APP_URL, PUBLIC_APPVIEW_URL } from '$env/static/public'; 5 | import type { RequestHandler } from './$types'; 6 | 7 | import { buildTimelineSlices } from '$lib/models/timeline'; 8 | import { createRssFeed, feedPostToFeedItem } from '$lib/rss'; 9 | import { normalizeDisplayName } from '$lib/utils/bluesky/display'; 10 | 11 | export const GET: RequestHandler = async ({ params, fetch }) => { 12 | const client = new Client({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 13 | 14 | const [profile, timeline] = await Promise.all([ 15 | (async () => { 16 | const data = await ok( 17 | client.get('app.bsky.actor.getProfile', { 18 | params: { 19 | actor: params.actor, 20 | }, 21 | }), 22 | ); 23 | 24 | return data; 25 | })(), 26 | 27 | (async () => { 28 | const data = await ok( 29 | client.get('app.bsky.feed.getAuthorFeed', { 30 | params: { 31 | actor: params.actor, 32 | limit: 100, 33 | filter: 'posts_and_author_threads', 34 | includePins: false, 35 | }, 36 | }), 37 | ); 38 | 39 | // Build into slices so we can filter out non-self threads 40 | const slices = buildTimelineSlices( 41 | data.feed, 42 | (slice) => { 43 | // Skip any posts that doesn't look like a self thread 44 | 45 | const first = slice.items[0]; 46 | const reply = first.reply; 47 | if (reply) { 48 | const { root, parent, grandparentAuthor } = reply; 49 | 50 | const authors: AppBskyActorDefs.ProfileViewBasic[] = []; 51 | 52 | if (root.$type === 'app.bsky.feed.defs#postView') { 53 | authors.push(root.author); 54 | } 55 | 56 | if (parent.$type === 'app.bsky.feed.defs#postView') { 57 | authors.push(parent.author); 58 | } 59 | 60 | if (grandparentAuthor) { 61 | authors.push(grandparentAuthor); 62 | } 63 | 64 | if (authors.some((author) => author.did !== first.post.author.did)) { 65 | return false; 66 | } 67 | 68 | return true; 69 | } 70 | 71 | return true; 72 | }, 73 | (item) => { 74 | // Skip reposts 75 | const reason = item.reason; 76 | return !reason || reason.$type !== 'app.bsky.feed.defs#reasonRepost'; 77 | }, 78 | ); 79 | 80 | return slices 81 | .flatMap((slice) => slice.items) 82 | .sort((a, b) => (a.post.indexedAt > b.post.indexedAt ? -1 : 1)); 83 | })(), 84 | ]); 85 | 86 | const rss = createRssFeed({ 87 | meta: { 88 | title: normalizeDisplayName(profile.displayName ?? '') || `@${profile.handle}`, 89 | description: `Posts from @${profile.handle}`, 90 | pageUrl: `${PUBLIC_APP_URL}/${profile.did}`, 91 | rssUrl: `${PUBLIC_APP_URL}/${profile.did}/rss`, 92 | image: profile.avatar ? { src: profile.avatar } : undefined, 93 | }, 94 | items: timeline.map(feedPostToFeedItem), 95 | }); 96 | 97 | return new Response(rss, { 98 | headers: { 99 | 'content-type': 'application/rss+xml; charset=utf-8', 100 | 'cache-control': 'public, max-age=300', // 5 minutes 101 | }, 102 | }); 103 | }; 104 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/+layout.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 | 34 | {record.name.trim()} by @{data.pack.creator.handle} — {PUBLIC_APP_NAME} 35 | 36 | 37 | 38 | 39 | 40 | 41 | {#key data.pack.uri} 42 |
43 |
44 | 45 |
46 | 47 |
48 |
49 | Users 50 | 51 | {#if (data.pack.feeds?.length ?? 0) > 0} 52 | Feeds 53 | {/if} 54 | 55 | Posts 56 | 57 |
58 |
59 | 60 | {@render children()} 61 |
62 |
63 | {/key} 64 | 65 | 125 | -------------------------------------------------------------------------------- /src/lib/components/embeds/embeds.svelte: -------------------------------------------------------------------------------- 1 | 31 | 32 |
33 | {#if media} 34 | {@render Media(media)} 35 | {/if} 36 | {#if record} 37 | {@render Record(record)} 38 | {/if} 39 |
40 | 41 | {#snippet Media(embed: MediaEmbed)} 42 | {@const blur = post && findLabel(post.labels, post.author.did, FlagsBlurMedia)} 43 | 44 | 45 | {#if embed.$type === 'app.bsky.embed.external#view'} 46 | 47 | {:else if embed.$type === 'app.bsky.embed.images#view'} 48 | 49 | {:else if embed.$type === 'app.bsky.embed.video#view'} 50 | 51 | {:else} 52 | {@render Message(`Unsupported media embed`)} 53 | {/if} 54 | 55 | {/snippet} 56 | 57 | {#snippet Record(embed: RecordEmbed)} 58 | {#if embed.$type === 'app.bsky.embed.record#viewRecord'} 59 | 60 | {:else if embed.$type === 'app.bsky.feed.defs#generatorView'} 61 | 62 | {:else if embed.$type === 'app.bsky.graph.defs#listView'} 63 | 64 | {:else if embed.$type === 'app.bsky.graph.defs#starterPackViewBasic'} 65 | 66 | {:else} 67 | {@const uri = assertCanonicalResourceUri(embed.uri)} 68 | 69 | {#if uri.collection === 'app.bsky.feed.post' && (embed.$type === 'app.bsky.embed.record#viewBlocked' || embed.$type === 'app.bsky.embed.record#viewDetached')} 70 | 71 | {:else} 72 | {@const resource = collectionToLabel(uri.collection)} 73 | {@const isUnavailable = 74 | resource && 75 | (embed.$type === 'app.bsky.embed.record#viewNotFound' || 76 | embed.$type === 'app.bsky.embed.record#viewBlocked')} 77 | 78 | {@render Message(isUnavailable ? `This ${resource} is unavailable` : `Unsupported record embed`)} 79 | {/if} 80 | {/if} 81 | {/snippet} 82 | 83 | {#snippet Message(message: string)} 84 |
{message}
85 | {/snippet} 86 | 87 | 102 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/components/list-aside.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 |
29 | 30 | 31 | 48 | 49 |

{normalizeDisplayName(list.name)}

50 | 51 | {#if list.description} 52 | {#if list.descriptionFacets === undefined} 53 | 54 | {:else} 55 | 56 | {/if} 57 | {/if} 58 | 59 |

60 | {list.listItemCount === 1 61 | ? `${formatLongNumber(list.listItemCount)} member` 62 | : `${formatLongNumber(list.listItemCount ?? 0)} members`} 63 |

64 | 65 |
66 | 67 | {list.creator.handle} 68 |
69 |
70 | 71 | 125 | --------------------------------------------------------------------------------