├── .npmrc ├── static ├── farcaster.png ├── favicon.png └── farcaster_small.png ├── .gitignore ├── src ├── index.test.ts ├── app.d.ts ├── routes │ ├── feed │ │ ├── [id] │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ └── +page.svelte │ ├── api │ │ ├── test │ │ │ └── +server.ts │ │ ├── fetch-more │ │ │ └── +server.ts │ │ ├── me │ │ │ └── +server.ts │ │ ├── fetch-mentions │ │ │ └── +server.ts │ │ ├── get-user-by-username │ │ │ └── +server.ts │ │ ├── like-cast │ │ │ └── +server.ts │ │ ├── delete-cast │ │ │ └── +server.ts │ │ ├── recast-cast │ │ │ └── +server.ts │ │ ├── cast │ │ │ └── +server.ts │ │ └── cache-home │ │ │ └── +server.ts │ ├── search │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── +error.svelte │ ├── welcome │ │ └── +page.svelte │ ├── about │ │ └── +page.svelte │ ├── +page.server.ts │ ├── cast │ │ └── [hash] │ │ │ ├── +page.svelte │ │ │ └── +page.server.ts │ ├── mentions │ │ └── +page.svelte │ ├── auth │ │ └── +page.svelte │ ├── [fname] │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── +page.svelte │ └── +layout.svelte ├── lib │ ├── components │ │ ├── NoticeModal.svelte │ │ ├── NoticeModalError.svelte │ │ ├── PageHeader.svelte │ │ ├── SearchPage.svelte │ │ ├── BottomPanel.svelte │ │ ├── CastOptionModal.svelte │ │ ├── ReplyRecastLike.svelte │ │ ├── ReplyTextbox.svelte │ │ ├── TheCast.svelte │ │ ├── Panel.svelte │ │ └── Cast.svelte │ ├── types │ │ ├── searchcasterUser.ts │ │ ├── merkleUserByUsername.ts │ │ ├── searchcasterCasts.ts │ │ ├── merkleCast.ts │ │ ├── index.ts │ │ ├── merkleAllReply.ts │ │ ├── merkleUser.ts │ │ ├── perl.ts │ │ └── merkleNotification.ts │ ├── stores.ts │ ├── nprogress.css │ └── utils.ts ├── app.postcss └── app.html ├── README.md ├── tailwind.config.cjs ├── .eslintignore ├── .prettierignore ├── .prettierrc ├── vite.config.js ├── postcss.config.cjs ├── svelte.config.js ├── .eslintrc.cjs ├── tsconfig.json ├── LICENSE └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /static/farcaster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinliao/phrasetown-old/HEAD/static/farcaster.png -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinliao/phrasetown-old/HEAD/static/favicon.png -------------------------------------------------------------------------------- /static/farcaster_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinliao/phrasetown-old/HEAD/static/farcaster_small.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | vite.config.js.timestamp-* 10 | vite.config.ts.timestamp-* 11 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('sum test', () => { 4 | it('adds 1 + 2 to equal 3', () => { 5 | expect(1 + 2).toBe(3); 6 | }); 7 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GM 2 | Phrasetown is a Farcaster client in the browser. This is perfect for people who love Farcaster, who prefers desktop over mobile, but aren't in the Apple Ecosystem. 3 | 4 | https://phrasetown.com -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | content: ['./src/**/*.{html,js,svelte,ts}'], 3 | 4 | theme: { 5 | extend: {} 6 | }, 7 | 8 | plugins: [] 9 | }; 10 | 11 | module.exports = config; 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | // and what to do when importing types 4 | declare namespace App { 5 | // interface Locals {} 6 | // interface PageData {} 7 | // interface Error {} 8 | // interface Platform {} 9 | } 10 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | 3 | /** @type {import('vite').UserConfig} */ 4 | const config = { 5 | plugins: [sveltekit()], 6 | test: { 7 | include: ['src/**/*.{test,spec}.{js,ts}', 'src/**/utils.{js,ts}'] 8 | } 9 | }; 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /src/routes/feed/[id]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { fetchEndpoints, getEndpoints } from "$lib/utils"; 2 | import type { PageServerLoad } from './$types'; 3 | 4 | export const load: PageServerLoad = async ({ params }) => { 5 | const { casts, endpoints } = await fetchEndpoints(getEndpoints(params.id)); 6 | 7 | return { 8 | casts, endpoints 9 | }; 10 | }; -------------------------------------------------------------------------------- /src/routes/api/test/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import type { RequestHandler } from './$types'; 3 | import { getEndpoints, getEndpointsWithout, idOf } from '$lib/utils'; 4 | 5 | export const GET: RequestHandler = async () => { 6 | return json({ 7 | data: getEndpointsWithout([idOf('Mentions'), idOf('New'), idOf('Home')]) 8 | }); 9 | }; -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const tailwindcss = require('tailwindcss'); 2 | const autoprefixer = require('autoprefixer'); 3 | 4 | const config = { 5 | plugins: [ 6 | //Some plugins, like tailwindcss/nesting, need to run before Tailwind, 7 | tailwindcss(), 8 | //But others, like autoprefixer, need to run after, 9 | autoprefixer 10 | ] 11 | }; 12 | 13 | module.exports = config; 14 | -------------------------------------------------------------------------------- /src/lib/components/NoticeModal.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
11 | {message} 12 |
13 | -------------------------------------------------------------------------------- /src/lib/components/NoticeModalError.svelte: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 |
12 | {message} 13 |
14 | -------------------------------------------------------------------------------- /src/routes/api/fetch-more/+server.ts: -------------------------------------------------------------------------------- 1 | import { json, error } from '@sveltejs/kit'; 2 | import type { RequestHandler } from './$types'; 3 | import { fetchEndpoints } from '$lib/utils'; 4 | 5 | export const PUT: RequestHandler = async ({ request }) => { 6 | const { endpoints } = await request.json(); 7 | 8 | const { casts, endpoints: newEndpoints } = await fetchEndpoints(endpoints); 9 | 10 | return json({ casts, endpoints: newEndpoints }); 11 | }; -------------------------------------------------------------------------------- /src/lib/types/searchcasterUser.ts: -------------------------------------------------------------------------------- 1 | export type Root = Root2[] 2 | 3 | export interface Root2 { 4 | body: Body 5 | connectedAddress: string 6 | } 7 | 8 | export interface Body { 9 | id: number 10 | address: string 11 | username: string 12 | displayName: string 13 | bio: string 14 | followers: number 15 | following: number 16 | avatarUrl: string 17 | isVerifiedAvatar: boolean 18 | proofUrl: string 19 | registeredAt: number 20 | } 21 | -------------------------------------------------------------------------------- /src/routes/search/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { PageServerLoad } from './$types'; 2 | import { getApiUrl } from '$lib/utils'; 3 | 4 | export const load: PageServerLoad = async ({ url }) => { 5 | const query = url.searchParams.get('q'); 6 | if (!query) return { casts: [] }; 7 | console.log(getApiUrl(false)); 8 | 9 | const response = await fetch(`${getApiUrl(import.meta.env.PROD)}/v0/search?q=${query}`); 10 | return { 11 | casts: (await response.json()).casts 12 | }; 13 | }; -------------------------------------------------------------------------------- /src/routes/search/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | {#if casts.length == 0} 12 | 13 | {:else} 14 | 15 | {#each casts as cast} 16 | 17 | {/each} 18 | {/if} 19 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import preprocess from 'svelte-preprocess'; 2 | import adapter from '@sveltejs/adapter-auto'; 3 | import { vitePreprocess } from '@sveltejs/kit/vite'; 4 | 5 | /** @type {import('@sveltejs/kit').Config} */ 6 | const config = { 7 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 8 | // for more information about preprocessors 9 | preprocess: [ 10 | vitePreprocess(), 11 | preprocess({ 12 | postcss: true 13 | }) 14 | ], 15 | 16 | kit: { 17 | adapter: adapter() 18 | } 19 | }; 20 | 21 | export default config; 22 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 |
4 |
5 | Oops, something is wrong. Can you try refreshing? Or, go back home. 10 | error message 15 |
16 |
17 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/lib/stores.ts: -------------------------------------------------------------------------------- 1 | import { type Writable, writable } from 'svelte/store'; 2 | 3 | export const showNotice: Writable = writable(undefined); 4 | export const showNoticeError: Writable = writable(undefined); 5 | 6 | 7 | export const userHubKeyWritable: Writable = writable(undefined); 8 | export const fidWritable: Writable = writable(undefined); 9 | export const usernameWritable: Writable = writable(undefined); 10 | 11 | export const programmaticallyRefreshColumn: Writable = writable(false); -------------------------------------------------------------------------------- /src/routes/api/me/+server.ts: -------------------------------------------------------------------------------- 1 | import { json, error } from '@sveltejs/kit'; 2 | import type { RequestHandler } from './$types'; 3 | 4 | export const PUT: RequestHandler = async ({ request }) => { 5 | const { userHubKey } = await request.json(); 6 | 7 | const response = await fetch('https://api.farcaster.xyz/v2/me', 8 | { 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | 'Authorization': `Bearer ${userHubKey}` 12 | }, 13 | } 14 | ); 15 | 16 | const data = await response.json(); 17 | 18 | return json({ username: data.result.user.username, fid: data.result.user.fid }); 19 | }; -------------------------------------------------------------------------------- /src/routes/api/fetch-mentions/+server.ts: -------------------------------------------------------------------------------- 1 | import { json, error } from '@sveltejs/kit'; 2 | import type { RequestHandler } from '../../../../.svelte-kit/types/src/routes/api/fetch-mentions/$types'; 3 | import { fetchEndpoints, getEndpoints, idOf } from '$lib/utils'; 4 | 5 | export const PUT: RequestHandler = async ({ request }) => { 6 | const { userHubKey } = await request.json(); 7 | if (userHubKey) { 8 | const data = await fetchEndpoints(getEndpoints(idOf('Mentions')), userHubKey); 9 | return json({ casts: data.casts, endpoints: data.endpoints }); 10 | } 11 | 12 | throw error(500, 'user hub key must exist'); 13 | }; -------------------------------------------------------------------------------- /src/routes/welcome/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 |
14 |

Connection successful. Welcome to Phrasetown!

15 | 16 |

You will be redirected to the home page shortly.

17 | 18 |

🟪⛩️

19 |
20 |
21 | -------------------------------------------------------------------------------- /src/app.postcss: -------------------------------------------------------------------------------- 1 | /* Write your global styles here, in PostCSS syntax */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | 6 | @layer base { 7 | p > a { 8 | @apply text-purple-500 hover:underline; 9 | } 10 | 11 | body { 12 | @apply bg-[#0a0a0a] text-neutral-200; 13 | } 14 | 15 | ::-webkit-scrollbar { 16 | width: 6px; 17 | } 18 | 19 | ::-webkit-scrollbar-track { 20 | background: #262626; 21 | } 22 | 23 | ::-webkit-scrollbar-thumb { 24 | background: #737373; 25 | } 26 | 27 | ::-webkit-scrollbar-thumb:hover { 28 | background: #d4d4d4; 29 | } 30 | 31 | .nprogress-bar { 32 | background: #f44336 !important; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/routes/api/get-user-by-username/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import type { RequestHandler } from '../../../../.svelte-kit/types/src/routes/api/get-user-by-username/$types'; 3 | 4 | // get request, use local hubkey instead 5 | export const GET: RequestHandler = async ({ url }) => { 6 | const username = url.searchParams.get('username'); 7 | const response = await fetch(`https://api.farcaster.xyz/v2/user-by-username?username=${username}`, { 8 | headers: { 9 | 'Content-Type': 'application/json', 10 | 'Authorization': `Bearer ${import.meta.env.VITE_HUB_KEY}` 11 | }, 12 | }); 13 | return json({ data: await response.json() }); 14 | }; -------------------------------------------------------------------------------- /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 | "types": [ 13 | "vitest/importMeta" 14 | ] 15 | } 16 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 17 | // 18 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 19 | // from the referenced tsconfig.json - TypeScript does not merge them in 20 | } 21 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | %sveltekit.head% 14 | 15 | 16 | 17 |
%sveltekit.body%
18 | 19 | 20 | -------------------------------------------------------------------------------- /src/routes/api/like-cast/+server.ts: -------------------------------------------------------------------------------- 1 | import { json, error } from '@sveltejs/kit'; 2 | import type { RequestHandler } from '../../../../.svelte-kit/types/src/routes/api/show-profile copy/$types'; 3 | 4 | export const PUT: RequestHandler = async ({ request }) => { 5 | const { castHash, userHubKey } = await request.json(); 6 | 7 | const response = await fetch(`https://api.farcaster.xyz/v2/cast-likes`, { 8 | method: 'PUT', 9 | headers: { 10 | 'Content-Type': 'application/json', 11 | Authorization: `Bearer ${userHubKey}` 12 | }, 13 | body: JSON.stringify({ castHash }) 14 | }); 15 | 16 | if (response.status == 200) { 17 | return json({ status: 'OK' }); 18 | } 19 | 20 | throw error(500, 'something wrong with liking cast'); 21 | }; -------------------------------------------------------------------------------- /src/lib/components/PageHeader.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | {#if backButton} 8 | 9 | { 17 | window.history.back(); 18 | }} 19 | > 20 | 25 | 26 | {/if} 27 | {name} 28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /src/routes/api/delete-cast/+server.ts: -------------------------------------------------------------------------------- 1 | import { json, error } from '@sveltejs/kit'; 2 | import type { RequestHandler } from '../../../../.svelte-kit/types/src/routes/api/delete-cast/$types'; 3 | 4 | export const DELETE: RequestHandler = async ({ request }) => { 5 | const { castHash, userHubKey } = await request.json(); 6 | 7 | if (!castHash) throw error(500, 'like cast must have parameter cast hash and author fid'); 8 | 9 | const response = await fetch(`https://api.farcaster.xyz/v2/casts`, { 10 | method: 'DELETE', 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | Authorization: `Bearer ${userHubKey}` 14 | }, 15 | body: JSON.stringify({ castHash }) 16 | }); 17 | 18 | if (response.status == 200) { 19 | return json({ status: 'OK' }); 20 | } 21 | 22 | throw error(500, 'something wrong with liking cast'); 23 | }; -------------------------------------------------------------------------------- /src/routes/api/recast-cast/+server.ts: -------------------------------------------------------------------------------- 1 | import { json, error } from '@sveltejs/kit'; 2 | import type { RequestHandler } from '../../../../.svelte-kit/types/src/routes/api/show-profile copy/$types'; 3 | 4 | export const PUT: RequestHandler = async ({ request }) => { 5 | const { castHash, userHubKey } = await request.json(); 6 | 7 | if (!castHash) throw error(500, 'like cast must have parameter cast hash and author fid'); 8 | 9 | const response = await fetch(`https://api.farcaster.xyz/v2/recasts`, { 10 | method: 'PUT', 11 | headers: { 12 | 'Content-Type': 'application/json', 13 | Authorization: `Bearer ${userHubKey}` 14 | }, 15 | body: JSON.stringify({ castHash }) 16 | }); 17 | 18 | if (response.status == 200) { 19 | return json({ status: 'OK' }); 20 | } 21 | 22 | throw error(500, 'something wrong with liking cast'); 23 | }; -------------------------------------------------------------------------------- /src/lib/types/merkleUserByUsername.ts: -------------------------------------------------------------------------------- 1 | // https://api.farcaster.xyz/v2/user-by-username?username=${username} 2 | 3 | export interface Root { 4 | data: Data; 5 | } 6 | 7 | export interface Data { 8 | result: Result; 9 | } 10 | 11 | export interface Result { 12 | user: User; 13 | } 14 | 15 | export interface User { 16 | fid: number; 17 | username: string; 18 | displayName: string; 19 | pfp: Pfp; 20 | profile: Profile; 21 | followerCount: number; 22 | followingCount: number; 23 | referrerUsername: string; 24 | viewerContext: ViewerContext; 25 | } 26 | 27 | export interface Pfp { 28 | url: string; 29 | verified: boolean; 30 | } 31 | 32 | export interface Profile { 33 | bio: Bio; 34 | } 35 | 36 | export interface Bio { 37 | text: string; 38 | mentions: any[]; 39 | } 40 | 41 | export interface ViewerContext { 42 | following: boolean; 43 | followedBy: boolean; 44 | canSendDirectCasts: boolean; 45 | } 46 | -------------------------------------------------------------------------------- /src/routes/about/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 | 8 |
9 |

GM.

10 | 11 |

12 | Phrasetown is a Farcaster client in the browser. This is perfect for people who love 13 | Farcaster, who prefers desktop over mobile, but aren't in the Apple Ecosystem. 14 |

15 | 16 |

17 | It's open-source: https://github.com/vinliao/phrasetown 20 |

21 |

22 | Complaints, comments, feature requests? Ping me on Farcaster @pixel or email me vincent@pixelhack.xyz. Bug report? Please file a GitHub Issue. 25 |

26 | 27 |

🟪⛩️

28 |
29 |
30 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { shuffle } from 'lodash-es'; 2 | import { getApiUrl, transformMerkleCast } from '$lib/utils'; 3 | import type { PageServerLoad } from './$types'; 4 | import type { Cast as MerkleCast } from '$lib/types/merkleCast'; 5 | /** 6 | * get cached casts from redis 7 | * 8 | * POST /v0/home-cache cache casts to redis and also returns the casts 9 | */ 10 | export const load: PageServerLoad = async ({ params }) => { 11 | const apiUrl = getApiUrl(import.meta.env.PROD); 12 | try { 13 | const response = await fetch(`${apiUrl}/v0/trending-casts`); 14 | const casts = (await response.json()).casts; 15 | return { casts: casts.map((cast: MerkleCast) => transformMerkleCast(cast)) }; 16 | } catch { 17 | const response = await fetch(`${apiUrl}/v0/index-trending-casts`, { 18 | method: 'POST' 19 | }); 20 | const casts = (await response.json()).casts.map((cast: MerkleCast) => transformMerkleCast(cast)); 21 | return { casts: shuffle(casts).slice(0, 15) }; 22 | } 23 | }; -------------------------------------------------------------------------------- /src/routes/cast/[hash]/+page.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
15 | 16 | {#each ancestors as ancestor, index} 17 | {#if index == 0} 18 | 19 | {:else} 20 | 21 | {/if} 22 | {/each} 23 | 24 | 25 | {#each children as child} 26 | 27 | {/each} 28 |
29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vincent Liao 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/routes/api/cast/+server.ts: -------------------------------------------------------------------------------- 1 | import { json, error } from '@sveltejs/kit'; 2 | import type { RequestHandler } from './$types'; 3 | 4 | export const POST: RequestHandler = async ({ request }) => { 5 | const { castText, replyTo, fid, userHubKey } = await request.json(); 6 | if (!castText) throw error(500, 'cast text cannot be empty'); 7 | if (castText.length > 320) throw error(500, 'cast text cannot exceed 320 characters'); 8 | 9 | interface castPayloadInterface { 10 | text: string, 11 | parent?: { hash: string, fid: number; }; 12 | embeds?: string[]; 13 | } 14 | let payload: castPayloadInterface = { text: castText }; 15 | if (replyTo) payload.parent = { hash: replyTo, fid }; 16 | 17 | const response = await fetch(`https://api.farcaster.xyz/v2/casts`, { 18 | method: 'POST', 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | 'Authorization': `Bearer ${userHubKey}` 22 | }, 23 | body: JSON.stringify(payload) 24 | }); 25 | 26 | if (response.ok) { 27 | return new Response(String('Send cast OK')); 28 | } 29 | 30 | throw error(500, 'error when trying to send cast'); 31 | }; 32 | -------------------------------------------------------------------------------- /src/routes/feed/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 |
12 | 13 |
14 |

Curated feeds

15 | 16 | I have curated some of the most interesting people in Farcaster, and turned their casts into 17 | feeds. Here are some of them. 18 | 19 | 30 |
31 |
32 | -------------------------------------------------------------------------------- /src/lib/types/searchcasterCasts.ts: -------------------------------------------------------------------------------- 1 | export interface Root { 2 | casts: Cast[]; 3 | meta: Meta2; 4 | } 5 | 6 | export interface Cast { 7 | body: Body; 8 | meta: Meta; 9 | merkleRoot: string; 10 | uri: string; 11 | } 12 | 13 | export interface Body { 14 | publishedAt: number; 15 | username: string; 16 | data: Data; 17 | } 18 | 19 | export interface Data { 20 | text: string; 21 | image?: string; 22 | replyParentMerkleRoot?: string; 23 | threadMerkleRoot: any; 24 | } 25 | 26 | export interface Meta { 27 | displayName: string; 28 | avatar: string; 29 | isVerifiedAvatar: boolean; 30 | numReplyChildren: number; 31 | reactions: Reactions; 32 | recasts: Recasts; 33 | watches: Watches; 34 | replyParentUsername: ReplyParentUsername; 35 | mentions: Mention[]; 36 | } 37 | 38 | export interface Reactions { 39 | count: number; 40 | type: string; 41 | } 42 | 43 | export interface Recasts { 44 | count: number; 45 | } 46 | 47 | export interface Watches { 48 | count: number; 49 | } 50 | 51 | export interface ReplyParentUsername { 52 | username?: string; 53 | } 54 | 55 | export interface Mention { 56 | address: string; 57 | username: string; 58 | } 59 | 60 | export interface Meta2 { 61 | count: number; 62 | responseTime: number; 63 | } -------------------------------------------------------------------------------- /src/routes/mentions/+page.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 | 26 | {#if casts.length > 0} 27 |
28 | {#each casts as cast} 29 | 30 | {/each} 31 |
32 | {:else} 33 |
34 | 42 | 43 | 44 |
45 | {/if} 46 |
-------------------------------------------------------------------------------- /src/lib/components/SearchPage.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
13 |
14 | { 23 | if (e.key == 'Enter') { 24 | search(); 25 | } 26 | }} 27 | /> 28 | 47 |
48 |
49 | -------------------------------------------------------------------------------- /src/routes/api/cache-home/+server.ts: -------------------------------------------------------------------------------- 1 | import { json, error } from '@sveltejs/kit'; 2 | import type { RequestHandler } from '../../../../.svelte-kit/types/src/routes/api/cache-home/$types'; 3 | import { fetchEndpoints, getHomeEndpoints } from '$lib/utils'; 4 | import { encode } from 'js-base64'; 5 | import { getUpstashName } from '$lib/utils'; 6 | 7 | export const POST: RequestHandler = async () => { 8 | 9 | const endpoints = getHomeEndpoints(import.meta.env.PROD); 10 | const data = await fetchEndpoints(endpoints); 11 | 12 | const upstashUrl = import.meta.env.VITE_UPSTASH_URL; 13 | const upstashKey = import.meta.env.VITE_UPSTASH_KEY; 14 | 15 | const homeDataBase64 = encode(JSON.stringify(data)); 16 | const endpointsBase64 = encode(JSON.stringify(endpoints)); 17 | 18 | const { upstashColumnName, upstashEndpointName } = getUpstashName(); 19 | 20 | const columnResponse = await fetch(`${upstashUrl}/set/${upstashColumnName}`, { 21 | method: "POST", 22 | headers: { 23 | Authorization: `Bearer ${upstashKey}` 24 | }, 25 | body: homeDataBase64 26 | }); 27 | 28 | const endpointResponse = await fetch(`${upstashUrl}/set/${upstashEndpointName}`, { 29 | method: "POST", 30 | headers: { 31 | Authorization: `Bearer ${upstashKey}` 32 | }, 33 | body: endpointsBase64 34 | }); 35 | 36 | if (columnResponse.status == 200 && endpointResponse.status == 200) { 37 | return json({ success: 'OK' }); 38 | } 39 | 40 | throw error(500, 'Error in posting data to Upstash'); 41 | }; -------------------------------------------------------------------------------- /src/lib/types/merkleCast.ts: -------------------------------------------------------------------------------- 1 | export interface Root { 2 | result: Result 3 | } 4 | 5 | export interface Result { 6 | cast: Cast 7 | } 8 | 9 | export interface Cast { 10 | hash: string 11 | threadHash: string 12 | author: Author 13 | text: string 14 | timestamp: number 15 | attachments: Attachments 16 | replies: Replies 17 | reactions: Reactions 18 | recasts: Recasts 19 | watches: Watches 20 | parentHash?: string; 21 | parentAuthor?: ParentAuthor; 22 | } 23 | 24 | export interface ParentAuthor { 25 | fid: number; 26 | username: string; 27 | displayName: string; 28 | pfp: Pfp; 29 | followerCount: number; 30 | followingCount: number; 31 | } 32 | 33 | export interface Author { 34 | fid: number 35 | username: string 36 | displayName: string 37 | pfp: Pfp 38 | profile: Profile 39 | followerCount: number 40 | followingCount: number 41 | } 42 | 43 | export interface Pfp { 44 | url: string 45 | verified: boolean 46 | } 47 | 48 | export interface Profile { 49 | bio: Bio 50 | } 51 | 52 | export interface Bio { 53 | text: string 54 | mentions: any[] 55 | } 56 | 57 | export interface Attachments { 58 | openGraph: OpenGraph[] 59 | } 60 | 61 | export interface OpenGraph { 62 | url: string 63 | domain: string 64 | logo: string 65 | useLargeImage: boolean 66 | strippedCastText: string 67 | } 68 | 69 | export interface Replies { 70 | count: number 71 | } 72 | 73 | export interface Reactions { 74 | count: number 75 | } 76 | 77 | export interface Recasts { 78 | count: number 79 | recasters: any[] 80 | } 81 | 82 | export interface Watches { 83 | count: number 84 | } 85 | -------------------------------------------------------------------------------- /src/routes/feed/[id]/+page.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | { 41 | localFetchMore(); 42 | }} 43 | > 44 |
45 | 46 | {#each casts as cast, index} 47 | {#if index == casts.length - 10} 48 |
49 | 50 |
51 | {:else} 52 | 53 | {/if} 54 | {/each} 55 |
56 |
57 | -------------------------------------------------------------------------------- /src/lib/nprogress.css: -------------------------------------------------------------------------------- 1 | /* Make clicks pass-through */ 2 | #nprogress { 3 | pointer-events: none; 4 | } 5 | 6 | #nprogress .bar { 7 | background: white; 8 | 9 | position: fixed; 10 | z-index: 1031; 11 | top: 0; 12 | left: 0; 13 | 14 | width: 100%; 15 | height: 5px; 16 | } 17 | 18 | /* Fancy blur effect */ 19 | #nprogress .peg { 20 | display: block; 21 | position: absolute; 22 | right: 0px; 23 | width: 100px; 24 | height: 100%; 25 | box-shadow: 0 0 10px white, 0 0 5px white; 26 | opacity: 1.0; 27 | 28 | -webkit-transform: rotate(3deg) translate(0px, -4px); 29 | -ms-transform: rotate(3deg) translate(0px, -4px); 30 | transform: rotate(3deg) translate(0px, -4px); 31 | } 32 | 33 | /* Remove these to get rid of the spinner */ 34 | #nprogress .spinner { 35 | display: block; 36 | position: fixed; 37 | z-index: 1031; 38 | top: 15px; 39 | right: 15px; 40 | } 41 | 42 | #nprogress .spinner-icon { 43 | width: 18px; 44 | height: 18px; 45 | box-sizing: border-box; 46 | 47 | border: solid 2px transparent; 48 | border-top-color: white; 49 | border-left-color: white; 50 | border-radius: 50%; 51 | 52 | -webkit-animation: nprogress-spinner 400ms linear infinite; 53 | animation: nprogress-spinner 400ms linear infinite; 54 | } 55 | 56 | .nprogress-custom-parent { 57 | overflow: hidden; 58 | position: relative; 59 | } 60 | 61 | .nprogress-custom-parent #nprogress .spinner, 62 | .nprogress-custom-parent #nprogress .bar { 63 | position: absolute; 64 | } 65 | 66 | @-webkit-keyframes nprogress-spinner { 67 | 0% { -webkit-transform: rotate(0deg); } 68 | 100% { -webkit-transform: rotate(360deg); } 69 | } 70 | @keyframes nprogress-spinner { 71 | 0% { transform: rotate(0deg); } 72 | 100% { transform: rotate(360deg); } 73 | } 74 | 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phrasetown", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 10 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 11 | "test:unit": "vitest", 12 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 13 | "format": "prettier --plugin-search-dir . --write ." 14 | }, 15 | "devDependencies": { 16 | "@sveltejs/adapter-auto": "next", 17 | "@sveltejs/kit": "next", 18 | "@types/autosize": "^4.0.1", 19 | "@types/lodash-es": "^4.17.6", 20 | "@types/nprogress": "^0.2.0", 21 | "@types/sanitize-html": "^2.6.2", 22 | "@typescript-eslint/eslint-plugin": "^5.45.0", 23 | "@typescript-eslint/parser": "^5.45.0", 24 | "autoprefixer": "^10.4.7", 25 | "eslint": "^8.28.0", 26 | "eslint-config-prettier": "^8.5.0", 27 | "eslint-plugin-svelte3": "^4.0.0", 28 | "nprogress": "^0.2.0", 29 | "postcss": "^8.4.14", 30 | "postcss-load-config": "^4.0.1", 31 | "prettier": "^2.8.0", 32 | "prettier-plugin-svelte": "^2.8.1", 33 | "svelte": "^3.54.0", 34 | "svelte-check": "^2.9.2", 35 | "svelte-intersection-observer": "^0.10.0", 36 | "svelte-preprocess": "^4.10.7", 37 | "tailwind-scrollbar": "^2.0.1", 38 | "tailwindcss": "^3.1.5", 39 | "tslib": "^2.4.1", 40 | "typescript": "^4.9.3", 41 | "vite": "^4.0.0", 42 | "vitest": "^0.25.3" 43 | }, 44 | "type": "module", 45 | "dependencies": { 46 | "autosize": "^5.0.2", 47 | "js-base64": "^3.7.3", 48 | "linkify-html": "^4.0.2", 49 | "linkify-plugin-mention": "^4.0.2", 50 | "linkifyjs": "^4.0.2", 51 | "lodash-es": "^4.17.21", 52 | "sanitize-html": "^2.7.3", 53 | "timeago.js": "^4.0.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/routes/auth/+page.svelte: -------------------------------------------------------------------------------- 1 | 52 | -------------------------------------------------------------------------------- /src/routes/[fname]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { error, json } from '@sveltejs/kit'; 2 | import type { PageServerLoad } from '../../../.svelte-kit/types/src/routes/[fname]/$types'; 3 | import type { Data as MerkleUserRoot } from '$lib/types/merkleUser'; 4 | import { transformCasts } from '$lib/utils'; 5 | import type { CastInterface } from '$lib/types'; 6 | import type { Data, User } from '$lib/types/merkleUserByUsername'; 7 | 8 | async function getUser(fname: string): Promise { 9 | const response = await fetch(`https://api.farcaster.xyz/v2/user-by-username?username=${fname}`, { 10 | headers: { 11 | 'Content-Type': 'application/json', 12 | 'Authorization': `Bearer ${import.meta.env.VITE_HUB_KEY}` 13 | }, 14 | }); 15 | const data: Data = await response.json(); 16 | return data.result.user; 17 | } 18 | 19 | export const load: PageServerLoad = async ({ params }) => { 20 | if (params.fname.startsWith('@') && typeof params.fname === 'string') { 21 | const fname = params.fname.slice(1); 22 | const user = await getUser(fname); 23 | 24 | const hubKey = import.meta.env.VITE_HUB_KEY; 25 | const response = await fetch(`https://api.farcaster.xyz/v2/casts?fid=${user.fid}&includeDeletedCasts=false&limit=50`, { 26 | method: 'GET', 27 | headers: { 28 | 'Content-Type': 'application/json', 29 | 'Authorization': `Bearer ${hubKey}` 30 | }, 31 | }); 32 | 33 | const data: MerkleUserRoot = await response.json(); 34 | const casts: CastInterface[] = transformCasts(data.result.casts, 'merkleUser', user.username); 35 | 36 | // recast is top-level cast 37 | const topLevelCasts = casts.filter(cast => !cast.parent || cast.recasted); 38 | const replyCasts = casts.filter(cast => cast.parent && !cast.recasted); 39 | 40 | return { user: user, topLevelCasts, replyCasts }; 41 | } 42 | 43 | throw error(404, 'Not found'); 44 | }; -------------------------------------------------------------------------------- /src/lib/types/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * CORE CONCEPT: 3 | * 4 | * cast is a piece of message in the farcaster network (who says 5 | * what at what time) 6 | * 7 | * endpoint is the information to get casts (url, pagination) 8 | * 9 | * feed is an array of cast and an array of endpoint, array of endpoint 10 | * means the feed's casts can be taken from multiple sources 11 | */ 12 | 13 | export interface CastInterface { 14 | author: { 15 | username: string, 16 | displayName: string, 17 | pfp: string, 18 | fid?: number, // eventually make this not optional 19 | }; 20 | parent?: { 21 | username: string, 22 | hash: string, 23 | }; 24 | hash: string, 25 | timestamp: number; 26 | text: string; 27 | image?: string, 28 | likes: number, 29 | replies: number, 30 | recasts: number, 31 | recasted?: { 32 | username: string, 33 | }; 34 | } 35 | 36 | export interface ColumnInterface { 37 | casts: CastInterface[], 38 | name: string; 39 | index: number; 40 | } 41 | 42 | /** 43 | * @param id id of endpoint, a feed with multiple endpoints have 44 | * the same id 45 | * 46 | * @param url where to fetch it from 47 | * 48 | * @param type for deciding how to parse the url 49 | * it's an enum of "searchcaster", 'merkle', 50 | * 51 | * @param name the name of the feed, human-readable id 52 | * 53 | * @param cursor for fetching next page from merkle's api 54 | * 55 | * @param nextpage for fetching next page from searchcaster 56 | * 57 | * @param username for recast text (todo: move this somewhere) 58 | */ 59 | export interface EndpointInterface { 60 | id: string, 61 | url: string, 62 | type: string, 63 | name: string, 64 | cursor?: string, 65 | nextPage?: number, 66 | username?: string; // only exist when endpoint is a farlist 67 | } 68 | 69 | export interface FeedInterface { 70 | casts: CastInterface[], 71 | enpdoints: EndpointInterface[]; 72 | } -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 52 | 53 | { 57 | localFetchMore(); 58 | }} 59 | > 60 |
61 | 62 | {#each casts as cast, index} 63 | {#if index == casts.length - 10} 64 |
65 | 66 |
67 | {:else} 68 | 69 | {/if} 70 | {/each} 71 |
72 |
73 | -------------------------------------------------------------------------------- /src/lib/components/BottomPanel.svelte: -------------------------------------------------------------------------------- 1 | 76 | -------------------------------------------------------------------------------- /src/lib/types/merkleAllReply.ts: -------------------------------------------------------------------------------- 1 | export interface Root { 2 | result: Result 3 | } 4 | 5 | export interface Result { 6 | casts: Cast[] 7 | } 8 | 9 | export interface Cast { 10 | hash: string 11 | threadHash: string 12 | author: Author 13 | text: string 14 | timestamp: number 15 | attachments?: Attachments 16 | replies: Replies 17 | reactions: Reactions 18 | recasts: Recasts 19 | watches: Watches 20 | parentHash?: string 21 | parentAuthor?: ParentAuthor 22 | mentions?: Mention[] 23 | } 24 | 25 | export interface Author { 26 | fid: number 27 | username: string 28 | displayName: string 29 | pfp: Pfp 30 | profile: Profile 31 | followerCount: number 32 | followingCount: number 33 | } 34 | 35 | export interface Pfp { 36 | url: string 37 | verified: boolean 38 | } 39 | 40 | export interface Profile { 41 | bio: Bio 42 | } 43 | 44 | export interface Bio { 45 | text: string 46 | mentions: string[] 47 | } 48 | 49 | export interface Attachments { 50 | openGraph: OpenGraph[] 51 | } 52 | 53 | export interface OpenGraph { 54 | url: string 55 | domain: string 56 | logo?: string 57 | useLargeImage: boolean 58 | strippedCastText: string 59 | title?: string 60 | description?: string 61 | image?: string 62 | } 63 | 64 | export interface Replies { 65 | count: number 66 | } 67 | 68 | export interface Reactions { 69 | count: number 70 | } 71 | 72 | export interface Recasts { 73 | count: number 74 | recasters: any[] 75 | } 76 | 77 | export interface Watches { 78 | count: number 79 | } 80 | 81 | export interface ParentAuthor { 82 | fid: number 83 | username: string 84 | displayName: string 85 | pfp: Pfp2 86 | profile: Profile2 87 | followerCount: number 88 | followingCount: number 89 | } 90 | 91 | export interface Pfp2 { 92 | url: string 93 | verified: boolean 94 | } 95 | 96 | export interface Profile2 { 97 | bio: Bio2 98 | } 99 | 100 | export interface Bio2 { 101 | text: string 102 | mentions: string[] 103 | } 104 | 105 | export interface Mention { 106 | fid: number 107 | username: string 108 | displayName: string 109 | pfp: Pfp3 110 | profile: Profile3 111 | followerCount: number 112 | followingCount: number 113 | } 114 | 115 | export interface Pfp3 { 116 | url: string 117 | verified: boolean 118 | } 119 | 120 | export interface Profile3 { 121 | bio: Bio3 122 | } 123 | 124 | export interface Bio3 { 125 | text: string 126 | mentions: string[] 127 | } 128 | -------------------------------------------------------------------------------- /src/lib/types/merkleUser.ts: -------------------------------------------------------------------------------- 1 | export interface Root { 2 | data: Data; 3 | } 4 | 5 | export interface Data { 6 | result: Result; 7 | next: Next; 8 | } 9 | 10 | export interface Result { 11 | casts: Cast[]; 12 | } 13 | 14 | export interface Cast { 15 | hash: string; 16 | threadHash: string; 17 | parentHash?: string; 18 | parentAuthor?: ParentAuthor; 19 | author: Author; 20 | text: string; 21 | timestamp: number; 22 | replies: Replies; 23 | reactions: Reactions; 24 | recasts: Recasts; 25 | watches: Watches; 26 | viewerContext: ViewerContext; 27 | recast?: boolean; 28 | mentions?: Mention[]; 29 | attachments?: Attachments; 30 | } 31 | 32 | export interface ParentAuthor { 33 | fid: number; 34 | username: string; 35 | displayName: string; 36 | pfp: Pfp; 37 | followerCount: number; 38 | followingCount: number; 39 | } 40 | 41 | export interface Pfp { 42 | url: string; 43 | verified: boolean; 44 | } 45 | 46 | export interface Author { 47 | fid: number; 48 | username: string; 49 | displayName: string; 50 | pfp: Pfp2; 51 | followerCount: number; 52 | followingCount: number; 53 | } 54 | 55 | export interface Pfp2 { 56 | url: string; 57 | verified: boolean; 58 | } 59 | 60 | export interface Replies { 61 | count: number; 62 | } 63 | 64 | export interface Reactions { 65 | count: number; 66 | } 67 | 68 | export interface Recasts { 69 | count: number; 70 | recasters: Recaster[]; 71 | } 72 | 73 | export interface Recaster { 74 | fid: number; 75 | username: string; 76 | displayName: string; 77 | recastHash: string; 78 | } 79 | 80 | export interface Watches { 81 | count: number; 82 | } 83 | 84 | export interface ViewerContext { 85 | reacted: boolean; 86 | recast: boolean; 87 | watched: boolean; 88 | } 89 | 90 | export interface Mention { 91 | fid: number; 92 | username: string; 93 | displayName: string; 94 | pfp: Pfp3; 95 | followerCount: number; 96 | followingCount: number; 97 | } 98 | 99 | export interface Pfp3 { 100 | url: string; 101 | verified: boolean; 102 | } 103 | 104 | export interface Attachments { 105 | openGraph: OpenGraph[]; 106 | } 107 | 108 | export interface OpenGraph { 109 | url: string; 110 | title?: string; 111 | description?: string; 112 | domain: string; 113 | image?: string; 114 | logo?: string; 115 | useLargeImage: boolean; 116 | strippedCastText: string; 117 | } 118 | 119 | export interface Next { 120 | cursor: string; 121 | } 122 | -------------------------------------------------------------------------------- /src/routes/[fname]/+page.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 |
29 |
30 |
31 |
32 | 33 |
34 |
35 |
36 | {user.displayName} 37 | @{user.username} 38 |
39 |
40 | {#if user.profile.bio.text != ''} 41 |

{@html linkify(user.profile.bio.text)}

42 | {/if} 43 |
44 | 45 |
46 | {#if currentDisplayIndex == 0} 47 | Casts 48 | · 49 | 50 | Reply casts 51 | {:else if currentDisplayIndex == 1} 52 | 53 | Casts 54 | · 55 | Reply casts 56 | {/if} 57 |
58 |
59 | 60 | 61 | 62 | {#if currentDisplayIndex == 0} 63 |
64 | {#each topLevelCasts as cast} 65 | 66 | {/each} 67 |
68 | {:else if currentDisplayIndex == 1} 69 |
70 | {#each replyCasts as cast} 71 | 72 | {/each} 73 |
74 | {/if} 75 |
76 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 63 | 64 | {#if $showNotice} 65 | 66 | {/if} 67 | 68 | {#if $showNoticeError} 69 | 70 | {/if} 71 | 72 |
75 | 78 |
79 | 80 | 81 |
82 | 83 |
84 |
85 |
86 | -------------------------------------------------------------------------------- /src/lib/types/perl.ts: -------------------------------------------------------------------------------- 1 | export type PerlCastTypeOne = { 2 | id: number 3 | type: string 4 | payload: { 5 | hash: string 6 | text: string 7 | author: { 8 | fid: number 9 | pfp: { 10 | url: string 11 | verified: boolean 12 | } 13 | profile: { 14 | bio: { 15 | text: string 16 | mentions: Array 17 | } 18 | } 19 | username: string 20 | displayName: string 21 | followerCount: number 22 | followingCount: number 23 | } 24 | recasts: { 25 | count: number 26 | recasters: Array<{ 27 | fid: number 28 | username: string 29 | recastHash: string 30 | displayName: string 31 | }> 32 | } 33 | replies: { 34 | count: number 35 | } 36 | watches: { 37 | count: number 38 | } 39 | reactions: { 40 | count: number 41 | } 42 | timestamp: number 43 | threadHash: string 44 | attachments: { 45 | openGraph: Array<{ 46 | url: string 47 | domain: string 48 | useLargeImage: boolean 49 | strippedCastText: string 50 | }> 51 | } 52 | } 53 | num_saves: string 54 | timestamp: string 55 | } 56 | 57 | export type PerlCastTypeTwo = { 58 | id: number 59 | type: string 60 | payload: { 61 | body: { 62 | fid: number 63 | data: { 64 | text: string 65 | } 66 | type: string 67 | address: string 68 | sequence: number 69 | username: string 70 | publishedAt: number 71 | prevMerkleRoot: string 72 | } 73 | meta: { 74 | avatar: string 75 | parents: Array 76 | recasts: { 77 | self: boolean 78 | count: number 79 | } 80 | replies: Array 81 | watches: { 82 | self: boolean 83 | count: number 84 | } 85 | mentions: Array<{ 86 | address: string 87 | username: string 88 | }> 89 | reactions: { 90 | self: boolean 91 | type: string 92 | count: number 93 | } 94 | recasters: Array 95 | displayName: string 96 | followerCount: number 97 | followingCount: number 98 | isVerifiedAvatar: boolean 99 | numReplyChildren: number 100 | } 101 | signature: string 102 | merkleRoot: string 103 | attachments: { 104 | openGraph: Array<{ 105 | url: string 106 | logo: string 107 | domain: string 108 | useLargeImage: boolean 109 | strippedCastText: string 110 | }> 111 | } 112 | threadMerkleRoot: string 113 | } 114 | num_saves: string 115 | timestamp: string 116 | } -------------------------------------------------------------------------------- /src/lib/types/merkleNotification.ts: -------------------------------------------------------------------------------- 1 | export interface Root { 2 | data: Data 3 | } 4 | 5 | export interface Data { 6 | result: Result 7 | next: Next 8 | } 9 | 10 | export interface Result { 11 | notifications: Notification[] 12 | } 13 | 14 | export interface Notification { 15 | type: string 16 | id: string 17 | timestamp: number 18 | actor: Actor 19 | content: Content 20 | } 21 | 22 | export interface Actor { 23 | fid: number 24 | username: string 25 | displayName: string 26 | pfp: Pfp 27 | followerCount: number 28 | followingCount: number 29 | } 30 | 31 | export interface Pfp { 32 | url: string 33 | verified: boolean 34 | } 35 | 36 | export interface Content { 37 | cast: Cast 38 | } 39 | 40 | export interface Cast { 41 | hash: string 42 | threadHash: string 43 | author: Author 44 | text: string 45 | timestamp: number 46 | mentions?: Mention[] 47 | replies: Replies 48 | reactions: Reactions 49 | recasts: Recasts 50 | watches: Watches 51 | viewerContext: ViewerContext 52 | parentHash?: string 53 | parentAuthor?: ParentAuthor 54 | attachments?: Attachments 55 | } 56 | 57 | export interface Author { 58 | fid: number 59 | username: string 60 | displayName: string 61 | pfp: Pfp2 62 | followerCount: number 63 | followingCount: number 64 | } 65 | 66 | export interface Pfp2 { 67 | url: string 68 | verified: boolean 69 | } 70 | 71 | export interface Mention { 72 | fid: number 73 | username: string 74 | displayName: string 75 | pfp: Pfp3 76 | followerCount: number 77 | followingCount: number 78 | } 79 | 80 | export interface Pfp3 { 81 | url: string 82 | verified: boolean 83 | } 84 | 85 | export interface Replies { 86 | count: number 87 | } 88 | 89 | export interface Reactions { 90 | count: number 91 | } 92 | 93 | export interface Recasts { 94 | count: number 95 | recasters: Recaster[] 96 | } 97 | 98 | export interface Recaster { 99 | fid: number 100 | username: string 101 | displayName: string 102 | recastHash: string 103 | } 104 | 105 | export interface Watches { 106 | count: number 107 | } 108 | 109 | export interface ViewerContext { 110 | reacted: boolean 111 | recast: boolean 112 | watched: boolean 113 | } 114 | 115 | export interface ParentAuthor { 116 | fid: number 117 | username: string 118 | displayName: string 119 | pfp: Pfp4 120 | followerCount: number 121 | followingCount: number 122 | } 123 | 124 | export interface Pfp4 { 125 | url: string 126 | verified: boolean 127 | } 128 | 129 | export interface Attachments { 130 | openGraph: OpenGraph[] 131 | } 132 | 133 | export interface OpenGraph { 134 | url: string 135 | title?: string 136 | description?: string 137 | domain: string 138 | image?: string 139 | logo?: string 140 | useLargeImage: boolean 141 | strippedCastText: string 142 | } 143 | 144 | export interface Next { 145 | cursor: string 146 | } 147 | -------------------------------------------------------------------------------- /src/lib/components/CastOptionModal.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | 28 |
33 | 57 | 58 | {#if isSelf} 59 | 88 | {/if} 89 |
90 | -------------------------------------------------------------------------------- /src/lib/components/ReplyRecastLike.svelte: -------------------------------------------------------------------------------- 1 | 63 | 64 |
65 | 71 | 72 | {#if !recastPulse} 73 | 79 | {:else} 80 |
81 | {cast.recasts} 82 | recasts 83 |
84 | {/if} 85 | 86 | {#if !likePulse} 87 | 93 | {:else} 94 |
95 | {cast.likes} 96 | likes 97 |
98 | {/if} 99 | {#if time} 100 |
101 | {time} 102 | {/if} 103 |
104 | -------------------------------------------------------------------------------- /src/routes/cast/[hash]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { error } from '@sveltejs/kit'; 2 | import type { PageServerLoad } from './$types'; 3 | import { transformCasts } from '$lib/utils'; 4 | import type { Root, Cast } from '$lib/types/merkleAllReply'; 5 | import type { CastInterface } from '$lib/types'; 6 | 7 | /** 8 | * @param hash hash of cast 9 | * @returns the thread hash (root of thread), returns itself if it's the root 10 | */ 11 | async function getThreadHash(hash: string): Promise { 12 | const response = await fetch(`https://api.farcaster.xyz/v2/cast?hash=${hash}`, { 13 | headers: { 14 | 'Content-Type': 'application/json', 15 | 'Authorization': `Bearer ${import.meta.env.VITE_HUB_KEY}` 16 | }, 17 | }); 18 | const data = await response.json(); 19 | return data.result.cast.threadHash; 20 | } 21 | 22 | /** 23 | * @param hash get all the replies of a threadHash 24 | * @returns 25 | */ 26 | async function getReplies(hash: string): Promise { 27 | const response = await fetch(`https://api.farcaster.xyz/v2/all-casts-in-thread?threadHash=${hash}`, { 28 | headers: { 29 | 'Content-Type': 'application/json', 30 | 'Authorization': `Bearer ${import.meta.env.VITE_HUB_KEY}` 31 | }, 32 | }); 33 | return response.json(); 34 | } 35 | 36 | /** 37 | * given a hash, get the chains of cast from it to the root (thread) 38 | * 39 | * given a hash and a list of replies, find the ancestor chain: an array 40 | * array of casts where the previous index is the parent 41 | * 42 | * bug: the /v2/all-casts-in-thread only returns a certain amount of cast 43 | * (maybe a hundred), and if the casts aren't included in the one hundred 44 | * cast returned by it, the getAncestors return an empty array, which 45 | * breaks the front-end 46 | * 47 | * @param hash the cast hash 48 | * @param data the return of getReplies() 49 | * @returns an array of cast, where each index is the parent of the previous index 50 | */ 51 | function getAncestors(hash: string, data: Root): CastInterface[] { 52 | let ancestorChain = []; 53 | let currentHash: string | undefined = hash; 54 | const casts = data.result.casts; 55 | while (currentHash) { 56 | let currentCast = casts.find(cast => cast.hash === currentHash); 57 | if (!currentCast) break; 58 | ancestorChain.unshift(currentCast); 59 | currentHash = currentCast.parentHash; 60 | } 61 | 62 | return transformCasts(ancestorChain.map(cast => removeParent(cast)), 'merkleUser'); 63 | } 64 | 65 | function removeParent(cast: Cast): Cast { 66 | return { ...cast, parentAuthor: undefined, parentHash: undefined }; 67 | } 68 | 69 | /** 70 | * given a hash, get all the direct children 71 | * 72 | * @param hash the cast hash 73 | * @param data the return of getReplies() 74 | * @returns an array of cast, the children 75 | */ 76 | function getChildren(hash: string, data: Root): CastInterface[] { 77 | const directChildrenCasts = data.result.casts.filter(cast => cast.parentHash === hash); 78 | return transformCasts(directChildrenCasts, 'merkleUser'); 79 | } 80 | 81 | export const load: PageServerLoad = async ({ params }) => { 82 | if (params.hash.startsWith('0x') && typeof params.hash === 'string' && params.hash.length == 66) { 83 | const hash = params.hash; 84 | const replies = await getReplies(await getThreadHash(hash)); 85 | return { ancestors: getAncestors(hash, replies), children: getChildren(hash, replies) }; 86 | } 87 | 88 | throw error(404, 'Not found'); 89 | }; -------------------------------------------------------------------------------- /src/lib/components/ReplyTextbox.svelte: -------------------------------------------------------------------------------- 1 | 62 | 63 | 64 |
69 |
70 |
71 |

@{cast.author.username} says:

72 |

{@html cast.text}

73 |
74 |