├── .env ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── mise.toml ├── package.json ├── pnpm-lock.yaml ├── src ├── app.d.ts ├── app.html ├── hooks.server.ts ├── lib │ ├── assets │ │ ├── default-feed-avatar.svg │ │ ├── default-labeler-avatar.svg │ │ ├── default-list-avatar.svg │ │ ├── default-starterpack-avatar.svg │ │ └── default-user-avatar.svg │ ├── components │ │ ├── avatar.svelte │ │ ├── central-icons │ │ │ ├── arrow-right-outlined.svelte │ │ │ ├── arrows-repeat-right-left-outlined.svelte │ │ │ ├── bubble-2-outlined.svelte │ │ │ ├── bubbles-outlined.svelte │ │ │ ├── circle-ban-sign-outlined.svelte │ │ │ ├── circle-info-outlined.svelte │ │ │ ├── compass-round-outlined.svelte │ │ │ ├── dot-grid-1x3-horizontal-outlined.svelte │ │ │ ├── earth-outlined.svelte │ │ │ ├── group-2-outlined.svelte │ │ │ ├── hashtag-outlined.svelte │ │ │ ├── heart-outlined.svelte │ │ │ ├── info-outlined.svelte │ │ │ ├── magnifying-glass-outlined.svelte │ │ │ ├── pin-outlined.svelte │ │ │ ├── play-solid.svelte │ │ │ ├── square-arrow-top-right-outlined.svelte │ │ │ ├── thread-outlined.svelte │ │ │ └── trending-2-outlined.svelte │ │ ├── content-hider.svelte │ │ ├── embeds │ │ │ ├── components │ │ │ │ └── image-alt.svelte │ │ │ ├── embeds.svelte │ │ │ ├── external-embed.svelte │ │ │ ├── feed-embed.svelte │ │ │ ├── image-embed.svelte │ │ │ ├── list-embed.svelte │ │ │ ├── quote-blocked-embed.svelte │ │ │ ├── quote-embed.svelte │ │ │ ├── starterpack-embed.svelte │ │ │ ├── video-standalone-embed.svelte │ │ │ └── video-thumbnail-embed.svelte │ │ ├── feeds │ │ │ └── feed-item.svelte │ │ ├── island.svelte │ │ ├── islands │ │ │ └── time.svelte │ │ ├── lists │ │ │ └── list-item.svelte │ │ ├── overflow-menu.svelte │ │ ├── page │ │ │ ├── page-container.svelte │ │ │ ├── page-header.svelte │ │ │ └── page-listing.svelte │ │ ├── profiles │ │ │ └── profile-item.svelte │ │ ├── richtext-raw-renderer.svelte │ │ ├── richtext-renderer.svelte │ │ ├── starterpacks │ │ │ └── starterpack-item.svelte │ │ └── timeline │ │ │ ├── post-feed-item.svelte │ │ │ ├── post-meta.svelte │ │ │ └── post-metrics.svelte │ ├── constants.ts │ ├── models │ │ └── timeline.ts │ ├── moderation.ts │ ├── queries │ │ ├── constellation.ts │ │ ├── handle.ts │ │ ├── post.ts │ │ └── timeline.ts │ ├── redirector.ts │ ├── rss.ts │ ├── styles │ │ └── app.css │ ├── types │ │ ├── at-uri.ts │ │ ├── identity.ts │ │ ├── nsid.ts │ │ ├── rkey.ts │ │ └── valita.ts │ └── utils │ │ ├── bluesky │ │ ├── display.ts │ │ ├── embeds.ts │ │ ├── lists.ts │ │ ├── records.ts │ │ ├── richtext.ts │ │ ├── urls.ts │ │ └── videos.ts │ │ ├── intl │ │ ├── date.ts │ │ └── number.ts │ │ ├── invariant.ts │ │ ├── pagination.ts │ │ ├── search-params.ts │ │ ├── strings.ts │ │ ├── types.ts │ │ └── url.ts ├── params │ ├── cidRaw.ts │ ├── did.ts │ ├── didOrHandle.ts │ ├── handle.ts │ ├── rkey.ts │ └── tid.ts └── routes │ ├── (app) │ ├── (profile) │ │ └── [actor=didOrHandle] │ │ │ ├── (timeline) │ │ │ ├── +layout.svelte │ │ │ ├── +page.svelte │ │ │ ├── +page.ts │ │ │ ├── media │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── rss │ │ │ │ └── +server.ts │ │ │ └── with_replies │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── +layout.svelte │ │ │ ├── +layout.ts │ │ │ ├── components │ │ │ ├── profile-aside.svelte │ │ │ └── profile-meta-tags.svelte │ │ │ ├── feeds │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ │ ├── followers │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ │ ├── following │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ │ ├── lists │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ │ └── packs │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ ├── +layout.svelte │ ├── +page.server.ts │ ├── +page.svelte │ ├── [actor=didOrHandle] │ │ ├── [rkey=tid] │ │ │ ├── +page.svelte │ │ │ ├── +page.ts │ │ │ ├── components │ │ │ │ ├── blocked-ascendant-item.svelte │ │ │ │ ├── descendants.svelte │ │ │ │ ├── interaction-state.svelte │ │ │ │ ├── main-post-metrics.svelte │ │ │ │ ├── main-post.svelte │ │ │ │ ├── missing-descendant-item.svelte │ │ │ │ ├── nonexistent-ascendant-post.svelte │ │ │ │ ├── overflow-ascendant-item.svelte │ │ │ │ ├── overflow-descendant-item.svelte │ │ │ │ ├── post-ascendant-item.svelte │ │ │ │ ├── post-descendant-item.svelte │ │ │ │ └── post-meta-tags.svelte │ │ │ └── utils.ts │ │ ├── feeds │ │ │ └── [rkey=rkey] │ │ │ │ ├── +layout.svelte │ │ │ │ ├── +layout.ts │ │ │ │ ├── +page.svelte │ │ │ │ ├── +page.ts │ │ │ │ ├── components │ │ │ │ ├── feed-aside.svelte │ │ │ │ └── feed-meta-tags.svelte │ │ │ │ └── likes │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ ├── lists │ │ │ └── [rkey=rkey] │ │ │ │ ├── +layout.svelte │ │ │ │ ├── +layout.ts │ │ │ │ ├── +page.ts │ │ │ │ ├── components │ │ │ │ ├── list-aside.svelte │ │ │ │ └── list-meta-tags.svelte │ │ │ │ ├── members │ │ │ │ └── +page.svelte │ │ │ │ └── posts │ │ │ │ ├── +page.svelte │ │ │ │ ├── +page.ts │ │ │ │ └── rss │ │ │ │ └── +server.ts │ │ └── packs │ │ │ └── [rkey=rkey] │ │ │ ├── +layout.svelte │ │ │ ├── +layout.ts │ │ │ ├── +page.svelte │ │ │ ├── +page.ts │ │ │ ├── components │ │ │ ├── pack-aside.svelte │ │ │ └── pack-meta-tags.svelte │ │ │ ├── feeds │ │ │ └── +page.svelte │ │ │ └── posts │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ ├── [actor=did] │ │ └── [rkey=tid] │ │ │ ├── all-quotes │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ │ ├── all-replies │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ │ ├── likes │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ │ ├── quotes │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ │ ├── reposts │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ │ └── unroll │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ ├── search │ │ ├── +layout.svelte │ │ ├── +server.ts │ │ ├── feeds │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ ├── posts │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ └── users │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ └── trending │ │ ├── +page.svelte │ │ ├── +page.ts │ │ └── utils.ts │ ├── +error.svelte │ ├── +layout.ts │ ├── go │ └── [shortid] │ │ └── +page.ts │ ├── profile │ └── [actor=didOrHandle] │ │ ├── +server.ts │ │ ├── feed │ │ └── [rkey=rkey] │ │ │ └── +server.ts │ │ ├── lists │ │ └── [rkey=rkey] │ │ │ └── +server.ts │ │ └── post │ │ └── [rkey=tid] │ │ └── +server.ts │ └── watch │ └── [actor=did] │ └── [cid=cidRaw] │ ├── +page.svelte │ └── +page.ts ├── static ├── _scripts │ ├── _lib │ │ └── signals.js │ ├── time-formatter.js │ └── video-embed.js ├── favicon.png └── robots.txt ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.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://public.api.bsky.app 7 | PUBLIC_GO_BSKY_URL=https://go.bsky.app 8 | 9 | PUBLIC_CONSTELLATION_URL=https://constellation.microcosm.blue 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | @jsr:registry=https://npm.jsr.io 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "typescript.tsdk": "node_modules/typescript/lib" 4 | } 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "23.11.0" 3 | pnpm = "10.8.1" 4 | -------------------------------------------------------------------------------- /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 --cache --write .", 13 | "lint": "prettier --cache --check ." 14 | }, 15 | "devDependencies": { 16 | "@sveltejs/adapter-cloudflare": "^7.0.2", 17 | "@sveltejs/kit": "^2.20.8", 18 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 19 | "prettier": "^3.5.3", 20 | "prettier-plugin-css-order": "^2.1.2", 21 | "prettier-plugin-svelte": "^3.3.3", 22 | "svelte": "^5.28.2", 23 | "svelte-check": "^4.1.6", 24 | "typescript": "~5.8.3", 25 | "vite": "^6.3.4", 26 | "wrangler": "^4.13.2" 27 | }, 28 | "dependencies": { 29 | "@atcute/bluesky": "^2.1.0", 30 | "@atcute/bluesky-richtext-parser": "^1.0.7", 31 | "@atcute/bluesky-richtext-segmenter": "^2.0.0", 32 | "@atcute/client": "^3.0.1", 33 | "@badrap/valita": "^0.4.4", 34 | "@mary/array-fns": "npm:@jsr/mary__array-fns@^0.1.4", 35 | "@mary/date-fns": "npm:@jsr/mary__date-fns@^0.1.3", 36 | "hls.js": "^1.6.2" 37 | }, 38 | "pnpm": { 39 | "onlyBuiltDependencies": [ 40 | "esbuild", 41 | "workerd" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /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/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/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { XRPCError } 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 XRPCError) { 8 | if (error.status === 403) { 9 | return { 10 | message: `Upstream server is forbidding access to this resource`, 11 | }; 12 | } 13 | 14 | if (error.kind === 'AuthRequired' || error.kind === 'auth required') { 15 | return { 16 | message: `Upstream server is requiring authentication to access this resource`, 17 | }; 18 | } 19 | 20 | if (error.kind === '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/assets/default-feed-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/lib/assets/default-labeler-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/lib/assets/default-list-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/lib/assets/default-starterpack-avatar.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/lib/assets/default-user-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /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/lib/components/central-icons/arrow-right-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/arrows-repeat-right-left-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/bubble-2-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/bubbles-outlined.svelte: -------------------------------------------------------------------------------- 1 | 16 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/circle-ban-sign-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/circle-info-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/compass-round-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/dot-grid-1x3-horizontal-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/earth-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/group-2-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/hashtag-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/heart-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/info-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/magnifying-glass-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/pin-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/play-solid.svelte: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/square-arrow-top-right-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/thread-outlined.svelte: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/lib/components/central-icons/trending-2-outlined.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/lib/components/embeds/components/image-alt.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 18 | 19 | 20 |

Image description

21 | 22 |

{alt}

23 |
24 | 25 | 90 | -------------------------------------------------------------------------------- /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/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/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/lib/components/embeds/quote-blocked-embed.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 21 | 22 | {embed.$type === 'app.bsky.embed.record#viewDetached' ? `Quote detached` : `Interaction blocked`} 23 | 24 | 25 | View 26 | 27 | 28 | 68 | -------------------------------------------------------------------------------- /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/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/feeds/feed-item.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
26 |
27 | 28 | 29 | 30 |

{normalizeDisplayName(feed.displayName)}

31 |

Feed by @{truncateMiddle(creator.handle, 29)}

32 |
33 |
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/island.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 | 30 | 47 | 48 | 49 | {#if first} 50 | 51 | {/if} 52 | 53 | 54 | {@render children()} 55 | -------------------------------------------------------------------------------- /src/lib/components/islands/time.svelte: -------------------------------------------------------------------------------- 1 | 41 | 42 | {#if dev} 43 | 46 | {:else} 47 | 48 | 51 | 52 | {/if} 53 | -------------------------------------------------------------------------------- /src/lib/components/lists/list-item.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
26 |
27 | 28 | 29 | 30 |

{normalizeDisplayName(list.name)}

31 |

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

32 |
33 |
34 | 35 |

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

36 |
37 | 38 | 91 | -------------------------------------------------------------------------------- /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/page/page-container.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | {@render children()} 13 |
14 | 15 | 24 | -------------------------------------------------------------------------------- /src/lib/components/page/page-header.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 47 | -------------------------------------------------------------------------------- /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/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/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/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/lib/components/starterpacks/starterpack-item.svelte: -------------------------------------------------------------------------------- 1 | 24 | 25 |
26 |
27 | 28 | 29 | 30 |

{normalizeDisplayName(record.name)}

31 |

Starter pack by @{truncateMiddle(creator.handle, 29)}

32 |
33 |
34 | 35 |

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

36 |
37 | 38 | 91 | -------------------------------------------------------------------------------- /src/lib/components/timeline/post-meta.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 |
24 | 25 | {#if authorName} 26 | 27 | {authorName} 28 | 29 | {/if} 30 | 31 | @{author.handle} 32 | 33 | 34 | 35 | 36 | 37 | 39 |
40 | 41 | 98 | -------------------------------------------------------------------------------- /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 | {formatCompactNumber(count)} 32 | 33 |
34 | {/snippet} 35 | 36 |
37 | {@render Stat(replyCount, Bubble_2Outlined, 'reply', 'replies')} 38 | {@render Stat(repostCount, ArrowsRepeatRightLeftOutlined, 'repost', 'reposts')} 39 | {@render Stat(likeCount, HeartOutlined, 'like', 'likes')} 40 |
41 | 42 | 68 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import type { At } from '@atcute/client/lexicons'; 2 | 3 | // Popular feeds that requires authentication to view 4 | export const AUTHENTICATED_FEEDS: At.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 | -------------------------------------------------------------------------------- /src/lib/queries/handle.ts: -------------------------------------------------------------------------------- 1 | import type { XRPC } from '@atcute/client'; 2 | import type { At } from '@atcute/client/lexicons'; 3 | 4 | import type { Did } from '$lib/types/identity'; 5 | 6 | export const resolveHandle = async ({ rpc, handle }: { rpc: XRPC; handle: At.Handle }): Promise => { 7 | const { data } = await rpc.get('com.atproto.identity.resolveHandle', { 8 | params: { handle }, 9 | }); 10 | 11 | // because my types are stricter than atcute's 12 | return data.did as Did; 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/queries/post.ts: -------------------------------------------------------------------------------- 1 | import { XRPC, XRPCError } from '@atcute/client'; 2 | import type { AppBskyFeedDefs, At } from '@atcute/client/lexicons'; 3 | 4 | export interface GetPostReturn { 5 | post: AppBskyFeedDefs.PostView; 6 | threadgate?: AppBskyFeedDefs.ThreadgateView; 7 | } 8 | 9 | export const getPost = async ({ rpc, uri }: { rpc: XRPC; uri: At.ResourceUri }): Promise => { 10 | const { data } = await rpc.get('app.bsky.feed.getPostThread', { 11 | params: { 12 | uri: uri, 13 | depth: 0, 14 | parentHeight: 0, 15 | }, 16 | }); 17 | 18 | const { thread, threadgate } = data; 19 | switch (thread.$type) { 20 | case 'app.bsky.feed.defs#notFoundPost': 21 | case 'app.bsky.feed.defs#blockedPost': { 22 | throw new XRPCError(400, { kind: 'NotFound', description: 'Post not found' }); 23 | } 24 | } 25 | 26 | return { post: thread.post, threadgate }; 27 | }; 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/lib/types/at-uri.ts: -------------------------------------------------------------------------------- 1 | import type { At, Records } from '@atcute/client/lexicons'; 2 | 3 | import { assert } from '$lib/utils/invariant'; 4 | 5 | import { isDid, isHandle, type Did, type Handle } from './identity'; 6 | import { isNsid, type Nsid } from './nsid'; 7 | import { isRecordKey, type RecordKey } from './rkey'; 8 | 9 | const ATURI_RE = 10 | /^at:\/\/([a-zA-Z0-9._:%-]+)(?:\/([a-zA-Z0-9-.]+)(?:\/([a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(?:#(\/[a-zA-Z0-9._~:@!$&%')(*+,;=\-[\]/\\]*))?$/; 11 | 12 | export type AddressedAtUri = { 13 | repo: Did; 14 | collection: Nsid; 15 | rkey: RecordKey; 16 | fragment: string | undefined; 17 | }; 18 | 19 | export const parseAddressedAtUri = (str: string): AddressedAtUri => { 20 | const match = ATURI_RE.exec(str); 21 | assert(match !== null, `invalid addressed-at-uri: ${str}`); 22 | 23 | const [, r, c, k, f] = match; 24 | assert(isDid(r), `invalid repo in addressed-at-uri: ${r}`); 25 | assert(isNsid(c), `invalid collection in addressed-at-uri: ${c}`); 26 | assert(isRecordKey(k), `invalid rkey in addressed-at-uri: ${k}`); 27 | 28 | return { 29 | repo: r, 30 | collection: c, 31 | rkey: k, 32 | fragment: f, 33 | }; 34 | }; 35 | 36 | export type PartialAtUri = 37 | | { repo: Did | Handle; collection: undefined; rkey: undefined; fragment: string | undefined } 38 | | { repo: Did | Handle; collection: Nsid; rkey: undefined; fragment: string | undefined } 39 | | { repo: Did | Handle; collection: Nsid; rkey: RecordKey; fragment: string | undefined }; 40 | 41 | export const parsePartialAtUri = (str: string): PartialAtUri => { 42 | const match = ATURI_RE.exec(str); 43 | assert(match !== null, `invalid partial-at-uri: ${str}`); 44 | 45 | const [, r, c, k, f] = match; 46 | assert(isDid(r) || isHandle(r), `invalid repo in partial-at-uri: ${r}`); 47 | assert(c === undefined || isNsid(c), `invalid collection in partial-at-uri: ${c}`); 48 | assert(k === undefined || isRecordKey(k), `invalid rkey in partial-at-uri: ${k}`); 49 | 50 | return { 51 | repo: r, 52 | collection: c, 53 | rkey: k, 54 | fragment: f, 55 | }; 56 | }; 57 | 58 | export const makeAtUri = ( 59 | repo: Did | Handle, 60 | collection: keyof Records | (Nsid & {}), 61 | rkey: string, 62 | ): At.ResourceUri => { 63 | return `at://${repo}/${collection as Nsid}/${rkey}`; 64 | }; 65 | -------------------------------------------------------------------------------- /src/lib/types/identity.ts: -------------------------------------------------------------------------------- 1 | export type Handle = `${string}.${string}`; 2 | 3 | const HANDLE_RE = 4 | /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+([a-zA-Z][a-zA-Z0-9-]{0,61}[a-zA-Z])$/; 5 | 6 | export const isHandle = (input: unknown): input is Handle => { 7 | return typeof input === 'string' && input.length >= 3 && input.length <= 253 && HANDLE_RE.test(input); 8 | }; 9 | 10 | export type Did = `did:${TMethod}:${string}`; 11 | export type AtprotoDid = Did<'plc' | 'web'>; 12 | 13 | const DID_RE = /^did:([a-z]+):([a-zA-Z0-9._:%\-]*[a-zA-Z0-9._\-])$/; 14 | 15 | export const isDid = (input: unknown): input is Did => { 16 | return typeof input === 'string' && input.length >= 7 && input.length <= 2048 && DID_RE.test(input); 17 | }; 18 | 19 | const ATPROTO_WEB_DID_RE = 20 | /^did:web:([a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)*(?:\.[a-zA-Z]{2,})|localhost(?:%3[aA]\d+)?)$/; 21 | 22 | export const isAtprotoWebDid = (input: unknown): input is Did<'web'> => { 23 | return typeof input === 'string' && input.length >= 12 && ATPROTO_WEB_DID_RE.test(input); 24 | }; 25 | 26 | const PLC_DID_RE = /^did:plc:([a-z2-7]{24})$/; 27 | 28 | export const isPlcDid = (input: unknown): input is Did<'plc'> => { 29 | return typeof input === 'string' && input.length === 32 && PLC_DID_RE.test(input); 30 | }; 31 | 32 | export const isAtprotoDid = (input: unknown): input is AtprotoDid => { 33 | return isPlcDid(input) || isAtprotoWebDid(input); 34 | }; 35 | -------------------------------------------------------------------------------- /src/lib/types/nsid.ts: -------------------------------------------------------------------------------- 1 | export type Nsid = `${string}.${string}.${string}`; 2 | 3 | const NSID_RE = 4 | /^[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)$/; 5 | 6 | export const isNsid = (input: unknown): input is Nsid => { 7 | return typeof input === 'string' && input.length >= 5 && input.length <= 317 && NSID_RE.test(input); 8 | }; 9 | -------------------------------------------------------------------------------- /src/lib/types/rkey.ts: -------------------------------------------------------------------------------- 1 | const RECORD_KEY_RE = /^(?!\.{1,2}$)[a-zA-Z0-9_~.:-]{1,512}$/; 2 | 3 | export type RecordKey = string; 4 | 5 | export const isRecordKey = (input: unknown): input is RecordKey => { 6 | return typeof input === 'string' && input.length >= 1 && input.length <= 512 && RECORD_KEY_RE.test(input); 7 | }; 8 | 9 | const TID_RE = /^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$/; 10 | 11 | export const isTid = (input: string) => { 12 | return input.length === 13 && TID_RE.test(input); 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/types/valita.ts: -------------------------------------------------------------------------------- 1 | import * as v from '@badrap/valita'; 2 | 3 | import { isDid } from './identity'; 4 | import { isNsid } from './nsid'; 5 | import { isRecordKey } from './rkey'; 6 | 7 | export const didString = v.string().assert(isDid); 8 | 9 | export const nsidString = v.string().assert(isNsid); 10 | 11 | export const recordKeyString = v.string().assert(isRecordKey); 12 | 13 | export const integer = v.number().assert((input) => Number.isSafeInteger(input) && input >= 0); 14 | -------------------------------------------------------------------------------- /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/lib/utils/bluesky/embeds.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyEmbedRecordWithMedia, AppBskyFeedDefs } from '@atcute/client/lexicons'; 2 | 3 | import { parseAddressedAtUri } from '$lib/types/at-uri'; 4 | 5 | export interface Embed { 6 | media?: AppBskyEmbedRecordWithMedia.View['media']; 7 | record?: AppBskyEmbedRecordWithMedia.View['record']; 8 | } 9 | 10 | export type MediaEmbed = NonNullable; 11 | export type RecordEmbed = NonNullable; 12 | 13 | export const unwrapMediaEmbedView = (embed: AppBskyFeedDefs.PostView['embed']): Embed['media'] => { 14 | switch (embed?.$type) { 15 | case 'app.bsky.embed.recordWithMedia#view': 16 | return embed.media; 17 | case 'app.bsky.embed.record#view': 18 | return; 19 | } 20 | 21 | return embed; 22 | }; 23 | 24 | export const unwrapRecordEmbedView = (embed: AppBskyFeedDefs.PostView['embed']): Embed['record'] => { 25 | switch (embed?.$type) { 26 | case 'app.bsky.embed.recordWithMedia#view': 27 | return embed.record; 28 | 29 | case 'app.bsky.embed.record#view': 30 | return embed; 31 | } 32 | }; 33 | 34 | export const unwrapEmbedView = (embed: AppBskyFeedDefs.PostView['embed']): Embed => { 35 | return { 36 | media: unwrapMediaEmbedView(embed), 37 | record: unwrapRecordEmbedView(embed), 38 | }; 39 | }; 40 | 41 | export const getQuoteEmbedView = (embed: RecordEmbed | undefined) => { 42 | const record = embed?.record; 43 | 44 | switch (record?.$type) { 45 | case 'app.bsky.embed.record#viewRecord': { 46 | return record; 47 | } 48 | 49 | case 'app.bsky.embed.record#viewNotFound': 50 | case 'app.bsky.embed.record#viewDetached': 51 | case 'app.bsky.embed.record#viewBlocked': { 52 | const uri = parseAddressedAtUri(record.uri); 53 | if (uri.collection === 'app.bsky.feed.post') { 54 | return record; 55 | } 56 | } 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/lib/utils/bluesky/lists.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyGraphDefs } from '@atcute/client/lexicons'; 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/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/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/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/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/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/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/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/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/utils/types.ts: -------------------------------------------------------------------------------- 1 | export type UnwrapArray = T extends (infer V)[] ? V : never; 2 | -------------------------------------------------------------------------------- /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/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/params/did.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | import { isDid } from '$lib/types/identity'; 4 | 5 | export const match = isDid satisfies ParamMatcher; 6 | -------------------------------------------------------------------------------- /src/params/didOrHandle.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | import { isDid, isHandle, type Did, type Handle } from '$lib/types/identity'; 4 | 5 | export const match = ((param: string): param is Did | Handle => { 6 | return isDid(param) || isHandle(param); 7 | }) satisfies ParamMatcher; 8 | -------------------------------------------------------------------------------- /src/params/handle.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | import { isHandle } from '$lib/types/identity'; 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 '$lib/types/rkey'; 4 | 5 | export const match = isRecordKey satisfies ParamMatcher; 6 | -------------------------------------------------------------------------------- /src/params/tid.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | import { isTid } from '$lib/types/rkey'; 4 | 5 | export const match = isTid satisfies ParamMatcher; 6 | -------------------------------------------------------------------------------- /src/routes/(app)/(profile)/[actor=didOrHandle]/(timeline)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 |
21 | Posts 22 | Replies 23 | Media 24 |
25 | 26 | {@render children()} 27 | 28 | 54 | -------------------------------------------------------------------------------- /src/routes/(app)/(profile)/[actor=didOrHandle]/(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)/(profile)/[actor=didOrHandle]/(timeline)/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | import { fetchTimeline, ProfileFilter, TimelineType } from '$lib/queries/timeline'; 7 | import { isDid, type Did } from '$lib/types/identity'; 8 | 9 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 10 | const rpc = new XRPC({ 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 | rpc, 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)/(profile)/[actor=didOrHandle]/(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)/(profile)/[actor=didOrHandle]/(timeline)/media/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import { fetchTimeline, ProfileFilter, TimelineType } from '$lib/queries/timeline'; 5 | import { isDid, type Did } from '$lib/types/identity'; 6 | 7 | import type { PageLoad } from './$types'; 8 | 9 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 10 | const rpc = new XRPC({ 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 | rpc, 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)/(profile)/[actor=didOrHandle]/(timeline)/rss/+server.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 | import type { AppBskyActorDefs } from '@atcute/client/lexicons'; 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 13 | 14 | const [profile, timeline] = await Promise.all([ 15 | (async () => { 16 | const { data } = await rpc.get('app.bsky.actor.getProfile', { 17 | params: { 18 | actor: params.actor, 19 | }, 20 | }); 21 | 22 | return data; 23 | })(), 24 | 25 | (async () => { 26 | const { data } = await rpc.get('app.bsky.feed.getAuthorFeed', { 27 | params: { 28 | actor: params.actor, 29 | limit: 100, 30 | filter: 'posts_and_author_threads', 31 | includePins: false, 32 | }, 33 | }); 34 | 35 | // Build into slices so we can filter out non-self threads 36 | const slices = buildTimelineSlices( 37 | data.feed, 38 | (slice) => { 39 | // Skip any posts that doesn't look like a self thread 40 | 41 | const first = slice.items[0]; 42 | const reply = first.reply; 43 | if (reply) { 44 | const { root, parent, grandparentAuthor } = reply; 45 | 46 | const authors: AppBskyActorDefs.ProfileViewBasic[] = []; 47 | 48 | if (root.$type === 'app.bsky.feed.defs#postView') { 49 | authors.push(root.author); 50 | } 51 | 52 | if (parent.$type === 'app.bsky.feed.defs#postView') { 53 | authors.push(parent.author); 54 | } 55 | 56 | if (grandparentAuthor) { 57 | authors.push(grandparentAuthor); 58 | } 59 | 60 | if (authors.some((author) => author.did !== first.post.author.did)) { 61 | return false; 62 | } 63 | 64 | return true; 65 | } 66 | 67 | return true; 68 | }, 69 | (item) => { 70 | // Skip reposts 71 | const reason = item.reason; 72 | return !reason || reason.$type !== 'app.bsky.feed.defs#reasonRepost'; 73 | }, 74 | ); 75 | 76 | return slices 77 | .flatMap((slice) => slice.items) 78 | .sort((a, b) => (a.post.indexedAt > b.post.indexedAt ? -1 : 1)); 79 | })(), 80 | ]); 81 | 82 | const rss = createRssFeed({ 83 | meta: { 84 | title: normalizeDisplayName(profile.displayName ?? '') || `@${profile.handle}`, 85 | description: `Posts from @${profile.handle}`, 86 | pageUrl: `${PUBLIC_APP_URL}/${profile.did}`, 87 | rssUrl: `${PUBLIC_APP_URL}/${profile.did}/rss`, 88 | image: profile.avatar ? { src: profile.avatar } : undefined, 89 | }, 90 | items: timeline.map(feedPostToFeedItem), 91 | }); 92 | 93 | return new Response(rss, { 94 | headers: { 95 | 'content-type': 'application/rss+xml; charset=utf-8', 96 | 'cache-control': 'public, max-age=300', // 5 minutes 97 | }, 98 | }); 99 | }; 100 | -------------------------------------------------------------------------------- /src/routes/(app)/(profile)/[actor=didOrHandle]/(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)/(profile)/[actor=didOrHandle]/(timeline)/with_replies/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import { fetchTimeline, ProfileFilter, TimelineType } from '$lib/queries/timeline'; 5 | import { isDid, type Did } from '$lib/types/identity'; 6 | 7 | import type { PageLoad } from './$types'; 8 | 9 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 10 | const rpc = new XRPC({ 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 | rpc, 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)/(profile)/[actor=didOrHandle]/+layout.ts: -------------------------------------------------------------------------------- 1 | import { XRPC, XRPCError, 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | try { 12 | const { data } = await rpc.get('app.bsky.actor.getProfile', { 13 | params: { 14 | actor: params.actor, 15 | }, 16 | }); 17 | 18 | return { 19 | profile: data, 20 | }; 21 | } catch (err) { 22 | if (err instanceof XRPCError) { 23 | switch (err.kind) { 24 | case 'InvalidRequest': { 25 | error(404, `Account doesn't exist`); 26 | } 27 | case 'AccountTakedown': { 28 | error(404, `Account is taken down`); 29 | } 30 | case 'AccountDeactivated': { 31 | error(404, `Account is deactivated`); 32 | } 33 | } 34 | } 35 | 36 | throw err; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /src/routes/(app)/(profile)/[actor=didOrHandle]/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)/(profile)/[actor=didOrHandle]/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)/(profile)/[actor=didOrHandle]/feeds/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const { data } = await rpc.get('app.bsky.feed.getActorFeeds', { 10 | params: { 11 | actor: params.actor, 12 | limit: 50, 13 | cursor: url.searchParams.get('cursor') || undefined, 14 | }, 15 | }); 16 | 17 | return { feeds: { cursor: data.cursor, items: data.feeds } }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(app)/(profile)/[actor=didOrHandle]/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)/(profile)/[actor=didOrHandle]/followers/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const { data } = await rpc.get('app.bsky.graph.getFollowers', { 10 | params: { 11 | actor: params.actor, 12 | limit: 50, 13 | cursor: url.searchParams.get('cursor') || undefined, 14 | }, 15 | }); 16 | 17 | return { followers: { cursor: data.cursor, items: data.followers } }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(app)/(profile)/[actor=didOrHandle]/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 | -------------------------------------------------------------------------------- /src/routes/(app)/(profile)/[actor=didOrHandle]/following/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const { data } = await rpc.get('app.bsky.graph.getFollows', { 10 | params: { 11 | actor: params.actor, 12 | limit: 50, 13 | cursor: url.searchParams.get('cursor') || undefined, 14 | }, 15 | }); 16 | 17 | return { following: { cursor: data.cursor, items: data.follows } }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(app)/(profile)/[actor=didOrHandle]/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)/(profile)/[actor=didOrHandle]/lists/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const { data } = await rpc.get('app.bsky.graph.getLists', { 10 | params: { 11 | actor: params.actor, 12 | limit: 50, 13 | cursor: url.searchParams.get('cursor') || undefined, 14 | }, 15 | }); 16 | 17 | return { lists: { cursor: data.cursor, items: data.lists } }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(app)/(profile)/[actor=didOrHandle]/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)/(profile)/[actor=didOrHandle]/packs/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 8 | 9 | const { data } = await rpc.get('app.bsky.graph.getActorStarterPacks', { 10 | params: { 11 | actor: params.actor, 12 | limit: 50, 13 | cursor: url.searchParams.get('cursor') || undefined, 14 | }, 15 | }); 16 | 17 | return { packs: { cursor: data.cursor, items: data.starterPacks } }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(app)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | {@render children()} 18 | -------------------------------------------------------------------------------- /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=didOrHandle]/[rkey=tid]/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC, XRPCError } from '@atcute/client'; 2 | import type { AppBskyFeedGetPostThread } from '@atcute/client/lexicons'; 3 | import { error } from '@sveltejs/kit'; 4 | 5 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 6 | import type { PageLoad } from './$types'; 7 | 8 | import { resolveHandle } from '$lib/queries/handle'; 9 | import { makeAtUri } from '$lib/types/at-uri'; 10 | import { isDid, type Did } from '$lib/types/identity'; 11 | 12 | export const load: PageLoad = async ({ params }) => { 13 | const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 14 | 15 | let did: Did; 16 | if (!isDid(params.actor)) { 17 | try { 18 | did = await resolveHandle({ rpc, handle: params.actor }); 19 | } catch (err) { 20 | if (err instanceof XRPCError) { 21 | switch (err.kind) { 22 | case 'InvalidRequest': { 23 | error(404, `Account doesn't exist`); 24 | } 25 | } 26 | } 27 | 28 | throw err; 29 | } 30 | } else { 31 | did = params.actor; 32 | } 33 | 34 | const uri = makeAtUri(did, 'app.bsky.feed.post', params.rkey); 35 | 36 | let data: AppBskyFeedGetPostThread.Output; 37 | 38 | try { 39 | const response = await rpc.get('app.bsky.feed.getPostThread', { 40 | params: { 41 | uri: uri, 42 | depth: 4, 43 | parentHeight: 10, 44 | }, 45 | }); 46 | 47 | data = response.data; 48 | } catch (err) { 49 | if (err instanceof XRPCError) { 50 | switch (err.kind) { 51 | case 'NotFound': { 52 | error(404, `Post not found`); 53 | } 54 | } 55 | } 56 | 57 | throw err; 58 | } 59 | 60 | const thread = data.thread; 61 | switch (thread.$type) { 62 | case 'app.bsky.feed.defs#notFoundPost': { 63 | error(404, `Post not found`); 64 | } 65 | case 'app.bsky.feed.defs#blockedPost': { 66 | // shouldn't happen? 67 | error(404, `Blocked post`); 68 | } 69 | } 70 | 71 | return { thread, threadgate: data.threadgate }; 72 | }; 73 | -------------------------------------------------------------------------------- /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]/[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 | 63 | {/if} 64 | {/each} 65 | {/snippet} 66 | 67 | 89 | -------------------------------------------------------------------------------- /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/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 19 |
20 | 21 | 39 | -------------------------------------------------------------------------------- /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]/[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]/[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]/[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]/[rkey=tid]/components/post-descendant-item.svelte: -------------------------------------------------------------------------------- 1 | 37 | 38 |
39 | 40 | 41 | 42 | 43 | 44 |
45 | 46 | 47 | 48 | {#if post.embed} 49 | 50 | {/if} 51 | 52 | 53 | 54 | 55 | {#if hasDescendant} 56 |
57 | {/if} 58 |
59 | 60 | {#if hasDescendant} 61 |
62 | {@render children?.()} 63 |
64 | {/if} 65 |
66 | 67 | 120 | -------------------------------------------------------------------------------- /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/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/+layout.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { LayoutLoad } from './$types'; 5 | 6 | import { resolveHandle } from '$lib/queries/handle'; 7 | import { makeAtUri } from '$lib/types/at-uri'; 8 | import { isDid, type Did } from '$lib/types/identity'; 9 | 10 | export const load: LayoutLoad = async ({ params, fetch }) => { 11 | const rpc = new XRPC({ 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({ rpc, handle: params.actor }); 18 | } 19 | 20 | const { data } = await rpc.get('app.bsky.feed.getFeedGenerator', { 21 | params: { 22 | feed: makeAtUri(did, 'app.bsky.feed.generator', params.rkey), 23 | }, 24 | }); 25 | 26 | const view = data.view; 27 | 28 | return { feed: view }; 29 | }; 30 | -------------------------------------------------------------------------------- /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/routes/(app)/[actor=didOrHandle]/feeds/[rkey=rkey]/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 | import { makeAtUri } from '$lib/types/at-uri'; 8 | import { isDid, type Did } from '$lib/types/identity'; 9 | 10 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 11 | const rpc = new XRPC({ 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 | rpc, 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]/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/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)/[actor=didOrHandle]/feeds/[rkey=rkey]/likes/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 | import { isDid, type Did } from '$lib/types/identity'; 8 | 9 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 10 | const rpc = new XRPC({ 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 rpc.get('app.bsky.feed.getLikes', { 21 | params: { 22 | uri: makeAtUri(did, 'app.bsky.feed.generator', params.rkey), 23 | limit: 50, 24 | cursor: url.searchParams.get('cursor') || undefined, 25 | }, 26 | }); 27 | 28 | return { likes: { cursor: data.cursor, items: data.likes.map((like) => like.actor) } }; 29 | }; 30 | -------------------------------------------------------------------------------- /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]/lists/[rkey=rkey]/+layout.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { LayoutLoad } from './$types'; 5 | 6 | import { resolveHandle } from '$lib/queries/handle'; 7 | import { makeAtUri } from '$lib/types/at-uri'; 8 | import { isDid, type Did } from '$lib/types/identity'; 9 | 10 | export const load: LayoutLoad = async ({ url, route, params, fetch }) => { 11 | const rpc = new XRPC({ 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({ rpc, handle: params.actor }); 18 | } 19 | 20 | const isListing = route.id === '/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/members'; 21 | const cursor = url.searchParams.get('cursor') || undefined; 22 | const { data } = await rpc.get('app.bsky.graph.getList', { 23 | params: { 24 | list: makeAtUri(did, 'app.bsky.graph.list', params.rkey), 25 | limit: isListing ? 50 : 1, 26 | cursor: isListing ? cursor : undefined, 27 | }, 28 | }); 29 | 30 | const view = data.list; 31 | 32 | return { list: view, members: { cursor: data.cursor, items: data.items } }; 33 | }; 34 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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)/[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]/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 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=didOrHandle]/lists/[rkey=rkey]/posts/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 | 3 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { PageLoad } from './$types'; 5 | 6 | import { isDid, type Did } from '$lib/types/identity'; 7 | import { makeAtUri } from '$lib/types/at-uri'; 8 | import { fetchTimeline, TimelineType } from '$lib/queries/timeline'; 9 | 10 | export const load: PageLoad = async ({ url, params, fetch, parent }) => { 11 | const rpc = new XRPC({ 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 | rpc, 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]/lists/[rkey=rkey]/posts/rss/+server.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 | 3 | import { PUBLIC_APP_URL, PUBLIC_APPVIEW_URL } from '$env/static/public'; 4 | import type { RequestHandler } from './$types'; 5 | 6 | import { buildTimelineSlices } from '$lib/models/timeline'; 7 | import { resolveHandle } from '$lib/queries/handle'; 8 | import { createRssFeed, feedPostToFeedItem } from '$lib/rss'; 9 | import { makeAtUri } from '$lib/types/at-uri'; 10 | import { isDid, type Did } from '$lib/types/identity'; 11 | 12 | export const GET: RequestHandler = async ({ params, fetch }) => { 13 | const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 14 | 15 | let did: Did; 16 | if (isDid(params.actor)) { 17 | did = params.actor; 18 | } else { 19 | did = await resolveHandle({ rpc, handle: params.actor }); 20 | } 21 | 22 | const uri = makeAtUri(did, 'app.bsky.graph.list', params.rkey); 23 | 24 | const [list, timeline] = await Promise.all([ 25 | (async () => { 26 | const { data } = await rpc.get('app.bsky.graph.getList', { 27 | params: { 28 | list: uri, 29 | limit: 1, 30 | }, 31 | }); 32 | 33 | return data.list; 34 | })(), 35 | 36 | (async () => { 37 | const { data } = await rpc.get('app.bsky.feed.getListFeed', { 38 | params: { 39 | list: uri, 40 | limit: 100, 41 | }, 42 | }); 43 | 44 | const slices = buildTimelineSlices(data.feed); 45 | 46 | return slices 47 | .flatMap((slice) => slice.items) 48 | .sort((a, b) => (a.post.indexedAt > b.post.indexedAt ? -1 : 1)); 49 | })(), 50 | ]); 51 | 52 | const rss = createRssFeed({ 53 | meta: { 54 | title: list.name.trim(), 55 | description: `Posts from ${list.creator.handle}'s list`, 56 | pageUrl: `${PUBLIC_APP_URL}/${did}/lists/${params.rkey}/posts`, 57 | rssUrl: `${PUBLIC_APP_URL}/${did}/lists/${params.rkey}/posts/rss`, 58 | image: list.avatar ? { src: list.avatar } : undefined, 59 | }, 60 | items: timeline.map(feedPostToFeedItem), 61 | }); 62 | 63 | return new Response(rss, { 64 | headers: { 65 | 'content-type': 'application/rss+xml; charset=utf-8', 66 | 'cache-control': 'public, max-age=300', // 5 minutes 67 | }, 68 | }); 69 | }; 70 | -------------------------------------------------------------------------------- /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/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/+layout.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | const { data } = await rpc.get('app.bsky.graph.getStarterPack', { 12 | params: { 13 | starterPack: makeAtUri(params.actor, 'app.bsky.graph.starterpack', params.rkey), 14 | }, 15 | }); 16 | 17 | const view = data.starterPack; 18 | 19 | return { pack: view }; 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/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ 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 rpc.get('app.bsky.graph.getList', { 23 | params: { 24 | list: pack.list.uri, 25 | limit: 50, 26 | cursor: url.searchParams.get('cursor') || undefined, 27 | }, 28 | }); 29 | 30 | return { members: { cursor: data.cursor, items: data.items } }; 31 | }; 32 | -------------------------------------------------------------------------------- /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]/packs/[rkey=rkey]/components/pack-meta-tags.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /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/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/routes/(app)/[actor=didOrHandle]/packs/[rkey=rkey]/posts/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ 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 | rpc, 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/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 { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ 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 rpc.get('app.bsky.feed.getPosts', { 24 | params: { 25 | uris: linking_records.map((link) => makeAtUri(link.did, 'app.bsky.feed.post', link.rkey)), 26 | }, 27 | }); 28 | 29 | return data.posts; 30 | })(); 31 | 32 | return { quotes: { cursor: cursor ?? undefined, items } }; 33 | }; 34 | -------------------------------------------------------------------------------- /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/routes/(app)/[actor=did]/[rkey=tid]/all-replies/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC, XRPCError } from '@atcute/client'; 2 | import type { AppBskyFeedDefs, AppBskyFeedGetPostThread } from '@atcute/client/lexicons'; 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 rpc = new XRPC({ 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({ rpc, 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 | let data: AppBskyFeedGetPostThread.Output; 33 | try { 34 | const response = await rpc.get('app.bsky.feed.getPostThread', { 35 | params: { 36 | uri: makeAtUri(link.did, 'app.bsky.feed.post', link.rkey), 37 | depth: 3, 38 | parentHeight: 0, 39 | }, 40 | }); 41 | 42 | data = response.data; 43 | } catch (err) { 44 | if (err instanceof XRPCError) { 45 | // ignore if AppView says it's not found 46 | if (err.kind === 'NotFound') { 47 | return null; 48 | } 49 | } 50 | 51 | throw err; 52 | } 53 | 54 | const thread = data.thread; 55 | switch (thread.$type) { 56 | // same goes for this union 57 | case 'app.bsky.feed.defs#notFoundPost': 58 | case 'app.bsky.feed.defs#blockedPost': { 59 | return null; 60 | } 61 | } 62 | 63 | return thread; 64 | }), 65 | ); 66 | 67 | const replies = definite(resolvedReplies); 68 | const { post: parentPost, threadgate } = await postPromise; 69 | 70 | const thread: AppBskyFeedDefs.ThreadViewPost = { 71 | post: parentPost, 72 | replies: replies, 73 | }; 74 | 75 | return { 76 | cursor: cursor || undefined, 77 | thread: thread, 78 | threadgate, 79 | }; 80 | }; 81 | -------------------------------------------------------------------------------- /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=did]/[rkey=tid]/likes/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | const uri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey); 12 | 13 | const { data } = await rpc.get('app.bsky.feed.getLikes', { 14 | params: { 15 | uri, 16 | limit: 50, 17 | cursor: url.searchParams.get('cursor') || undefined, 18 | }, 19 | }); 20 | 21 | return { likes: { cursor: data.cursor, items: data.likes.map((like) => like.actor) } }; 22 | }; 23 | -------------------------------------------------------------------------------- /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/routes/(app)/[actor=did]/[rkey=tid]/quotes/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | const uri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey); 12 | 13 | const { data } = await rpc.get('app.bsky.feed.getQuotes', { 14 | params: { 15 | uri, 16 | limit: 50, 17 | cursor: url.searchParams.get('cursor') || undefined, 18 | }, 19 | }); 20 | 21 | return { quotes: { cursor: data.cursor, items: data.posts } }; 22 | }; 23 | -------------------------------------------------------------------------------- /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=did]/[rkey=tid]/reposts/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 10 | 11 | const uri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey); 12 | 13 | const { data } = await rpc.get('app.bsky.feed.getRepostedBy', { 14 | params: { 15 | uri, 16 | limit: 50, 17 | cursor: url.searchParams.get('cursor') || undefined, 18 | }, 19 | }); 20 | 21 | return { reposts: { cursor: data.cursor, items: data.repostedBy } }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/routes/(app)/[actor=did]/[rkey=tid]/unroll/+page.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@sveltejs/kit'; 2 | 3 | import { simpleFetchHandler, XRPC } from '@atcute/client'; 4 | import type { AppBskyFeedDefs, Brand } from '@atcute/client/lexicons'; 5 | 6 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 7 | import type { PageLoad } from './$types'; 8 | 9 | import { makeAtUri } from '$lib/types/at-uri'; 10 | 11 | export const load: PageLoad = async ({ params }) => { 12 | const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 13 | 14 | let currentUri = makeAtUri(params.actor, 'app.bsky.feed.post', params.rkey); 15 | const items: AppBskyFeedDefs.ThreadViewPost[] = []; 16 | 17 | while (true) { 18 | const { data } = await rpc.get('app.bsky.feed.getPostThread', { 19 | params: { 20 | uri: currentUri, 21 | // The max is 1000, but the AppView only returns 10. 22 | depth: 1000, 23 | parentHeight: 0, 24 | }, 25 | }); 26 | 27 | switch (data.thread.$type) { 28 | case 'app.bsky.feed.defs#notFoundPost': { 29 | error(404, `Post not found`); 30 | } 31 | case 'app.bsky.feed.defs#blockedPost': { 32 | error(404, `Blocked post`); 33 | } 34 | } 35 | 36 | // Add the root thread 37 | if (items.length === 0) { 38 | items.push(data.thread); 39 | } else { 40 | items[items.length - 1] = data.thread; 41 | } 42 | 43 | // Walk through the thread tree structure 44 | let foundReply = false; 45 | while (true) { 46 | const tail = items[items.length - 1]; 47 | if (!tail.replies) { 48 | break; 49 | } 50 | 51 | const replies = tail.replies.filter((reply): reply is Brand.Union => { 52 | if (reply.$type !== 'app.bsky.feed.defs#threadViewPost') { 53 | return false; 54 | } 55 | 56 | if (reply.post.author.did !== tail.post.author.did) { 57 | return false; 58 | } 59 | 60 | return true; 61 | }); 62 | 63 | if (replies.length === 0) { 64 | break; 65 | } 66 | 67 | // Get earliest first 68 | replies.sort((a, b) => { 69 | const aIndexed = a.post.indexedAt; 70 | const bIndexed = b.post.indexedAt; 71 | 72 | if (aIndexed < bIndexed) { 73 | return -1; 74 | } 75 | if (aIndexed > bIndexed) { 76 | return 1; 77 | } 78 | 79 | return 0; 80 | }); 81 | 82 | items.push(replies[0]); 83 | 84 | currentUri = replies[0].post.uri; 85 | foundReply = true; 86 | } 87 | 88 | // No further valid reply, break out of loop 89 | if (!foundReply) { 90 | break; 91 | } 92 | } 93 | 94 | return { posts: items.map((item) => item.post) }; 95 | }; 96 | -------------------------------------------------------------------------------- /src/routes/(app)/search/+server.ts: -------------------------------------------------------------------------------- 1 | import { redirect, type RequestHandler } from '@sveltejs/kit'; 2 | 3 | import { isDid, isHandle } from '$lib/types/identity'; 4 | import { isRecordKey, isTid } from '$lib/types/rkey'; 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/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)/search/feeds/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } from '@atcute/client'; 2 | import type { At } from '@atcute/client/lexicons'; 3 | 4 | import { AUTHENTICATED_FEEDS } from '$lib/constants'; 5 | import { asString, useSearchParams } from '$lib/utils/search-params'; 6 | 7 | import { PUBLIC_APPVIEW_URL } from '$env/static/public'; 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 17 | 18 | const query = q.trim(); 19 | const { data } = await rpc.get('app.bsky.unspecced.getPopularFeedGenerators', { 20 | params: { 21 | query: query, 22 | limit: 50, 23 | cursor: cursor || undefined, 24 | }, 25 | }); 26 | 27 | let feeds = data.feeds; 28 | if (query.length === 0) { 29 | feeds = feeds.filter((feed) => !AUTHENTICATED_FEEDS.includes(feed.uri as At.CanonicalResourceUri)); 30 | } 31 | 32 | return { 33 | query, 34 | feeds: { 35 | cursor: data.cursor, 36 | items: feeds, 37 | }, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /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)/search/posts/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC, XRPCError } 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 | 8 | export const load: PageLoad = async ({ url }) => { 9 | const [{ q, sort, cursor }] = useSearchParams(url, { 10 | q: asString.withDefault(''), 11 | sort: asStringUnion(['top', 'latest']).withDefault('top'), 12 | cursor: asString, 13 | }); 14 | 15 | const rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 16 | 17 | const query = q.trim(); 18 | if (query.length === 0) { 19 | return { query, posts: { cursor: undefined, items: [] } }; 20 | } 21 | 22 | try { 23 | const { data } = await rpc.get(randomCase('app.bsky.feed.searchPosts', !!cursor), { 24 | params: { 25 | q: query, 26 | limit: 50, 27 | sort: sort, 28 | cursor: cursor || undefined, 29 | }, 30 | }); 31 | 32 | return { 33 | query, 34 | posts: { 35 | cursor: data.cursor, 36 | items: data.posts, 37 | }, 38 | }; 39 | } catch (err) { 40 | if (err instanceof XRPCError) { 41 | if (err.status === 403) { 42 | return { query, posts: { cursor: undefined, items: [] } }; 43 | } 44 | } 45 | 46 | throw err; 47 | } 48 | }; 49 | 50 | const randomCase = (str: T, enabled: boolean): T => { 51 | if (!enabled) { 52 | return str; 53 | } 54 | 55 | let result: string; 56 | 57 | do { 58 | result = str 59 | .split('') 60 | .map((char) => (Math.random() < 0.5 ? char.toLowerCase() : char.toUpperCase())) 61 | .join(''); 62 | } while (result === str); 63 | 64 | return result as T; 65 | }; 66 | -------------------------------------------------------------------------------- /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/routes/(app)/search/users/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ 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 rpc.get('app.bsky.actor.searchActors', { 22 | params: { 23 | q: query, 24 | limit: 50, 25 | cursor: cursor || undefined, 26 | }, 27 | }); 28 | 29 | return { 30 | query, 31 | profiles: { 32 | cursor: data.cursor, 33 | items: data.actors, 34 | }, 35 | }; 36 | }; 37 | -------------------------------------------------------------------------------- /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/routes/(app)/trending/+page.ts: -------------------------------------------------------------------------------- 1 | import { simpleFetchHandler, XRPC } 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 rpc = new XRPC({ handler: simpleFetchHandler({ service: PUBLIC_APPVIEW_URL }) }); 11 | 12 | const { data } = await rpc.get('app.bsky.unspecced.getTrendingTopics', { 13 | params: { 14 | limit: 14, 15 | }, 16 | }); 17 | 18 | return { 19 | suggested: mapDefined(data.suggested, mapTopic), 20 | topics: mapDefined(data.topics, mapTopic), 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/routes/(app)/trending/utils.ts: -------------------------------------------------------------------------------- 1 | import type { AppBskyUnspeccedDefs } from '@atcute/client/lexicons'; 2 | 3 | import { isDid, isHandle } from '$lib/types/identity'; 4 | import { isRecordKey } from '$lib/types/rkey'; 5 | 6 | // /profile/jaz.bsky.social/feed/cv:cat 7 | // /profile/bossett.social/feed/for-science 8 | const FEED_RE = /^\/profile\/([^/]+)\/feed\/([^/]+)$/; 9 | 10 | // /starter-pack/crimew.gay/3lbfhvsingk2i 11 | const STARTERPACK_RE = /^\/starter-pack\/([^/]+)\/([^/]+)$/; 12 | 13 | export interface MappedTopic { 14 | type: 'feed' | 'starterpack'; 15 | name: string; 16 | href: string; 17 | } 18 | 19 | export const mapTopic = ({ topic, link }: AppBskyUnspeccedDefs.TrendingTopic): MappedTopic | undefined => { 20 | let match: RegExpMatchArray | null | undefined; 21 | 22 | if ((match = link.match(FEED_RE))) { 23 | const [, actor, rkey] = match; 24 | 25 | if (!isHandle(actor) && !isDid(actor)) return; 26 | if (!isRecordKey(rkey)) return; 27 | 28 | return { 29 | type: 'feed', 30 | name: topic, 31 | href: `/${actor}/feeds/${rkey}`, 32 | }; 33 | } 34 | 35 | if ((match = link.match(STARTERPACK_RE))) { 36 | const [, actor, rkey] = match; 37 | 38 | if (!isHandle(actor) && !isDid(actor)) return; 39 | if (!isRecordKey(rkey)) return; 40 | 41 | return { 42 | type: 'starterpack', 43 | name: topic, 44 | href: `/${actor}/packs/${rkey}`, 45 | }; 46 | } 47 | 48 | return; 49 | }; 50 | -------------------------------------------------------------------------------- /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/+layout.ts: -------------------------------------------------------------------------------- 1 | import { dev } from '$app/environment'; 2 | 3 | export const csr = dev; 4 | -------------------------------------------------------------------------------- /src/routes/go/[shortid]/+page.ts: -------------------------------------------------------------------------------- 1 | import { error, redirect } from '@sveltejs/kit'; 2 | 3 | import * as v from '@badrap/valita'; 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 = jsonSchema.try(raw); 32 | if (!result.ok) { 33 | error(500, `Invalid response from upstream server`); 34 | } 35 | 36 | const url = safeUrlParse(result.value.url); 37 | if (!url) { 38 | error(500, `Invalid URL from upstream server; got ${result.value.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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mary-ext/anartia/b9f970704bbded90430619957154076d87be53f2/static/favicon.png -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler", 13 | }, 14 | // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias 15 | // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /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('/g, ''); 29 | 30 | return code; 31 | } 32 | }, 33 | }, 34 | ], 35 | }; 36 | }); 37 | --------------------------------------------------------------------------------