├── .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 | 
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 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/arrows-repeat-right-left-outlined.svelte:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/bubble-2-outlined.svelte:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/bubbles-outlined.svelte:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/circle-ban-sign-outlined.svelte:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/circle-info-outlined.svelte:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/compass-round-outlined.svelte:
--------------------------------------------------------------------------------
1 |
15 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/dot-grid-1x3-horizontal-outlined.svelte:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/earth-outlined.svelte:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/group-2-outlined.svelte:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/hashtag-outlined.svelte:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/heart-outlined.svelte:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/info-outlined.svelte:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/magnifying-glass-outlined.svelte:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/pin-outlined.svelte:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/play-solid.svelte:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/square-arrow-top-right-outlined.svelte:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/thread-outlined.svelte:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/lib/components/central-icons/trending-2-outlined.svelte:
--------------------------------------------------------------------------------
1 |
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 |
24 |
25 |
90 |
--------------------------------------------------------------------------------
/src/lib/components/embeds/external-embed.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 |
30 | {#if external.thumb}
31 |
32 | {/if}
33 |
34 |
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 |
44 |
45 |
46 |
109 |
--------------------------------------------------------------------------------
/src/lib/components/embeds/video-thumbnail-embed.svelte:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |

27 |
28 |
31 |
32 |
33 |
34 |
35 |
36 |
89 |
--------------------------------------------------------------------------------
/src/lib/components/feeds/feed-item.svelte:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 |
34 |
35 |
{trimRichText(feed.description ?? '')}
36 |
37 |
38 | {feed.likeCount === 1
39 | ? `Liked by ${formatLongNumber(feed.likeCount)} user`
40 | : `Liked by ${formatLongNumber(feed.likeCount ?? 0)} users`}
41 |
42 |
43 |
44 |
103 |
--------------------------------------------------------------------------------
/src/lib/components/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 |
34 |
35 |
{trimRichText(list.description ?? '')}
36 |
37 |
38 |
91 |
--------------------------------------------------------------------------------
/src/lib/components/overflow-menu.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 |
27 |
28 |
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 |
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 |
34 |
35 |
{trimRichText(record.description ?? '')}
36 |
37 |
38 |
91 |
--------------------------------------------------------------------------------
/src/lib/components/timeline/post-meta.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 |
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