├── .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 |
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 |
20 | {#each Object.entries(groupedEndpoints) as [key, value], index (key)}
21 |
22 |
25 | {groupedEndpoints[key][0].name}
26 |
27 |
28 | {/each}
29 |
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 |
45 | {/if}
46 |
--------------------------------------------------------------------------------
/src/lib/components/SearchPage.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
13 |
14 |
{
23 | if (e.key == 'Enter') {
24 | search();
25 | }
26 | }}
27 | />
28 |
32 |
40 |
45 |
46 |
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 |
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 |
{
36 | navigator.clipboard.writeText(`https://phrasetown.com/cast/${hash}`);
37 | linkCopied = true;
38 | await new Promise((r) => setTimeout(r, 1000));
39 | linkCopied = false;
40 | toggleOptionModal();
41 | }}
42 | >
43 |
49 |
54 |
55 | Copy link
56 |
57 |
58 | {#if isSelf}
59 |
{
62 | toggleOptionModal();
63 | const response = await fetch('/api/delete-cast', {
64 | method: 'DELETE',
65 | body: JSON.stringify({
66 | castHash: hash,
67 | userHubKey: $userHubKeyWritable
68 | })
69 | });
70 |
71 | if (response.ok) showNotice.set('Cast deleted successfully!');
72 | }}
73 | >
74 |
80 |
85 |
86 | Delete cast
87 |
88 | {/if}
89 |
90 |
--------------------------------------------------------------------------------
/src/lib/components/ReplyRecastLike.svelte:
--------------------------------------------------------------------------------
1 |
63 |
64 |
65 |
69 | {cast.replies} replies
70 |
71 |
72 | {#if !recastPulse}
73 |
77 | {cast.recasts} recasts
78 |
79 | {:else}
80 |
81 | {cast.recasts}
82 | recasts
83 |
84 | {/if}
85 |
86 | {#if !likePulse}
87 |
91 | {cast.likes} likes
92 |
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 |
118 |
119 |
120 |
--------------------------------------------------------------------------------
/src/lib/components/TheCast.svelte:
--------------------------------------------------------------------------------
1 |
36 |
37 |
40 |
41 |
42 |
45 |
46 | {#if optionModal}
47 |
53 | {/if}
54 |
55 |
56 |
57 | {#if intersecting}
58 |
59 |
65 |
66 | {:else}
67 |
68 | {/if}
69 |
70 |
78 |
79 |
80 |
86 |
89 |
90 |
91 |
92 |
93 |
94 |
{@html cast.text}
95 |
96 | {#if cast.image}
97 |
107 | {/if}
108 |
109 |
110 |
111 |
112 |
113 |
114 | {#if replyTextbox}
115 |
116 | {/if}
117 |
118 |
119 |
--------------------------------------------------------------------------------
/src/lib/components/Panel.svelte:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 |
Phrasetown
48 |
About
49 |
New
52 |
55 |
56 | {#if $userHubKeyWritable}
57 |
Mentions
58 |
@{$usernameWritable}
61 |
66 | New Cast
67 |
75 |
76 |
77 |
78 | {:else}
79 |
Mentions
80 |
Connect
84 |
87 |
New Cast
88 |
96 |
97 |
98 |
99 | {/if}
100 |
101 | {#if newCastTextbox}
102 |
149 | {/if}
150 |
151 |
--------------------------------------------------------------------------------
/src/lib/components/Cast.svelte:
--------------------------------------------------------------------------------
1 |
36 |
37 |
39 |
40 |
41 |
42 | {#if cast.recasted}
43 |
44 |
45 |
61 |
62 |
63 | @{cast.recasted?.username} recasted
65 |
66 |
67 | {:else}
68 |
71 | {/if}
72 |
73 |
74 | {#if intersecting}
75 |
76 |
82 |
83 | {:else}
84 |
85 | {/if}
86 |
87 |
88 |
89 |
90 | {#if optionModal}
91 |
92 | {/if}
93 |
94 |
95 |
96 |
{cast.author.displayName}
99 |
@{cast.author.username}
100 |
·
101 |
{getTimeago(cast.timestamp)}
102 |
103 |
104 |
110 |
113 |
114 |
115 |
116 | {#if cast.parent}
117 |
118 | Replying to @{cast.parent.username}
119 |
120 | {/if}
121 |
{@html cast.text}
122 |
123 | {#if cast.image}
124 |
134 | {/if}
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 | {#if replyTextbox}
143 |
144 | {/if}
145 |
146 | {#if !pfpLineDown}
147 |
148 | {/if}
149 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import type { CastInterface, EndpointInterface } from '$lib/types';
2 | import linkifyHtml from 'linkify-html';
3 | import "linkify-plugin-mention";
4 | import sanitizeHtml from 'sanitize-html';
5 | import { orderBy, shuffle } from 'lodash-es';
6 | import type { Cast as SearchcasterCast, Root as SearchcasterApiResponse } from '$lib/types/searchcasterCasts';
7 | import type { PerlCastTypeOne, PerlCastTypeTwo } from '$lib/types/perl';
8 | import type { OpenGraph as MerkleOpenGraph, Cast as MerkleCast, Data as MerkleApiResponse } from '$lib/types/merkleUser';
9 | import type { Data as MerkleNotificationApiResponse } from '$lib/types/merkleNotification';
10 | import * as timeago from 'timeago.js';
11 |
12 | /**
13 | * this function doesn't have params because it uses user's hub key,
14 | * which is passed from frontend ($userHubKey store)
15 | *
16 | * @returns mention endpoint
17 | */
18 | function getNotificationEndpoints(): EndpointInterface[] {
19 | return [
20 | {
21 | id: idOf('Mentions'),
22 | name: 'Mentions',
23 | url: 'https://api.farcaster.xyz/v2/mention-and-reply-notifications',
24 | type: 'merkleNotification',
25 | },
26 | ];
27 | }
28 |
29 | /**
30 | * @returns endpoint to fetch latest casts in the network
31 | */
32 | function getNewEndpoints(): EndpointInterface[] {
33 | return [
34 | {
35 | id: idOf('New'),
36 | name: 'New',
37 | url: 'https://api.farcaster.xyz/v2/recent-casts',
38 | type: 'merkleUser',
39 | },
40 | ];
41 | }
42 |
43 | /**
44 | * @returns endpoint to fetch random perls
45 | */
46 | function getPerlEndpoints(): EndpointInterface[] {
47 | return [
48 | {
49 | id: idOf('Perl'),
50 | name: 'Random Perls (Perl.xyz)',
51 | url: 'https://api.perl.xyz/shuffled-perls',
52 | type: 'perl',
53 | nextPage: 1
54 | },
55 | ];
56 | }
57 |
58 | /**
59 | * farlist is farcaster list, think twitter list but farcaster
60 | *
61 | * @returns an array of farlist endpoints
62 | */
63 | function getFarlistEndpoints(): EndpointInterface[] {
64 | const farlist = [
65 | {
66 | name: "Builders", users: [
67 | { fid: 1356, username: 'borodutch' },
68 | { fid: 347, username: 'greg' },
69 | { fid: 2, username: 'v' },
70 | { fid: 378, username: 'colin' },
71 | { fid: 359, username: 'pushix' },
72 | { fid: 539, username: 'peter' },
73 | { fid: 451, username: 'pfista' },
74 | ]
75 | },
76 | {
77 | name: "Interesting", users: [
78 | { fid: 1001, username: 'mattdesl' },
79 | { fid: 1946, username: 'dragonbanec' },
80 | { fid: 5253, username: 'dbkw' },
81 | { fid: 604, username: 'emodi' },
82 | { fid: 1287, username: 'july' },
83 | { fid: 1179, username: 'dbasch' },
84 | { fid: 528, username: '0xen' },
85 | ]
86 | },
87 | {
88 | name: "Interesting #2", users: [
89 | { fid: 267, username: 'aman' },
90 | { fid: 312, username: 'les' },
91 | { fid: 5009, username: 'tg' },
92 | { fid: 4877, username: 'trish' },
93 | { fid: 2687, username: 'blackdave' },
94 | { fid: 2714, username: 'rhys' },
95 | { fid: 1325, username: 'cassie' },
96 | { fid: 1355, username: 'bias' },
97 | ]
98 | },
99 | {
100 | name: "Farcaster OG", users: [
101 | { fid: 129, username: 'phil' },
102 | { fid: 127, username: 'neuroswish' },
103 | { fid: 8, username: 'jacob' },
104 | { fid: 60, username: 'brenner' },
105 | { fid: 3, username: 'dwr' },
106 | { fid: 143, username: 'mk' },
107 | ]
108 | },
109 | {
110 | name: "Cool", users: [
111 | { fid: 373, username: 'jayme' },
112 | { fid: 431, username: 'j4ck' },
113 | { fid: 2458, username: 'rafa' },
114 | { fid: 617, username: 'cameron' },
115 | { fid: 576, username: 'nonlinear' },
116 | { fid: 557, username: 'pugson' },
117 | { fid: 6319, username: '0xwoid' },
118 | ]
119 | }
120 | ];
121 |
122 | /**
123 | * loop through farlist, turn it into endpoint, flatten array to 1d
124 | * (the `.map()` returns a 2d array, because farlist is a 2d array)
125 | */
126 | return farlist
127 | .map((list) => {
128 | return list.users.map(user => {
129 | return makeFarlistEndpoint(list.name, user.fid, user.username);
130 | });
131 | })
132 | .flat(1);
133 | }
134 |
135 | /**
136 | * @param listName name of endpoint
137 | * @param fid
138 | * @param username used for "recasted by @handle" text
139 | * @returns the farlist endpoint
140 | */
141 | function makeFarlistEndpoint(listName: string, fid: number, username: string): EndpointInterface {
142 | // todo: figure out how to handle this potential undefined
143 | return {
144 | id: idOf(listName),
145 | name: listName,
146 | url: `https://api.farcaster.xyz/v2/casts?fid=${fid}&includeDeletedCasts=false&limit=15`,
147 | type: 'merkleUser',
148 | username: username,
149 | };
150 | }
151 |
152 | /**
153 | * id here is generated with `npx nanoid`
154 | * nanoid docs: https://github.com/ai/nanoid
155 | *
156 | * @returns return all endpoint-to-id mapping
157 | */
158 | function getEndpointIdNameMapping() {
159 | return [
160 | { name: 'New', id: 'GK-rQ3w0s41xcTeRwVXgw' },
161 | { name: 'Mentions', id: 'eVGJjvV-nABOx8dMqu9ZE' },
162 | { name: 'Home', id: 'REyJisAJvqk4-sjeB4tWW' },
163 | { name: 'Builders', id: 'ZQ_v4OlpAaRH6UJyB_ZsG' },
164 | { name: 'Interesting', id: 'rz_mqas0eC-yTTyA5CE_k' },
165 | { name: 'Interesting #2', id: 'wX7AVGycind3A6hX5gyFn' },
166 | { name: 'Farcaster OG', id: 'DlX08LLpV8luiFrXboW_n' },
167 | { name: 'Cool', id: 'H8_KkcycgRmc8JyV7vB9p' },
168 | { name: '?search=BTCÐ', id: 'engxPcFaJ0WrtvbnGFoOX' },
169 | { name: '?search=product', id: 'ESf-K7o8Nu7QmHTp6XLsr' },
170 | { name: '?search=nouns', id: 'i4HKWuOsocVvY1y3-8gms' },
171 | { name: 'Perl', id: 'BCZ6RMlaGNa-dF6eD_FW4' },
172 | ];
173 | }
174 |
175 | /**
176 | * @param name name of endpoint
177 | * @returns the id of endpoint
178 | */
179 | export function idOf(name: string): string | undefined {
180 | const mapping = getEndpointIdNameMapping().find((mapping) => mapping.name === name);
181 | return mapping ? mapping.id : undefined;
182 | }
183 |
184 | /**
185 | * @returns all endpoints of the app
186 | */
187 | function getAllEndpoints(): EndpointInterface[] {
188 | return [
189 | ...getNewEndpoints(),
190 | ...getNotificationEndpoints(),
191 | ...getFarlistEndpoints(),
192 | ...getPerlEndpoints(),
193 | ...getSearchcasterEndpoints(),
194 | ...getHomeEndpoints(import.meta.env.PROD)
195 | ];
196 | }
197 |
198 | /**
199 | * @param id endpoint id, use idOf() to get it
200 | * @returns endpoints with that id
201 | */
202 | export function getEndpoints(id: string | undefined): EndpointInterface[] {
203 | return getAllEndpoints().filter(endpoint => endpoint.id === id);
204 | }
205 |
206 | /**
207 | * @param id array of endpoint id, use idOf() to get it
208 | * @returns endpoints where those ids are filtered out
209 | */
210 | export function getEndpointsWithout(id: (string | undefined)[]): EndpointInterface[] {
211 | return getAllEndpoints().filter(endpoint => !id.includes(endpoint.id));
212 | }
213 |
214 | /**
215 | * an endpoint is an object which contains all the information to fetch
216 | * casts, which is then turned into columns
217 | *
218 | * this function returns endpoints to fetch searchcaster
219 | *
220 | * @returns list of unfetched endpoints
221 | */
222 | function getSearchcasterEndpoints(): EndpointInterface[] {
223 | const searchlist = [
224 | { name: "?search=BTCÐ", queries: ['bitcoin', 'btc', 'ethereum', 'eth+'] },
225 | { name: "?search=product", queries: ['product', 'startup'] },
226 | { name: "?search=nouns", queries: ['nouns'] },
227 | { name: "?search=government", queries: ['government'] },
228 | ];
229 |
230 | return searchlist
231 | .map((list) => {
232 | return list.queries.map(query => {
233 | return makeSearchcasterEndpoint(list.name, query);
234 | });
235 | })
236 | .flat(1);
237 | }
238 |
239 | /**
240 | * @param listName name of endpoint
241 | * @param query the search term
242 | * @returns the searchcaster endpoint
243 | */
244 | function makeSearchcasterEndpoint(listName: string, query: string): EndpointInterface {
245 | return {
246 | id: idOf(listName),
247 | name: listName,
248 | url: `https://searchcaster.xyz/api/search?text=${query}&count=30`,
249 | type: 'searchcaster',
250 | nextPage: 1
251 | };
252 | }
253 |
254 | /**
255 | * endpoint to fetch home feed (/)
256 | *
257 | * it is extremely distracting to have real feed when developing, on
258 | * dev environment, use the most liked cast instead of "hot 24h" casts,
259 | * the most liked cast has slow velocity, which means less novelty,
260 | * which means less distraction
261 | *
262 | * @param isProd whether environment is on prod (import.meta.env.PROD)
263 | * @returns endpoint to fetch home feed
264 | */
265 | export function getHomeEndpoints(isProd: boolean): EndpointInterface[] {
266 | if (isProd) {
267 | return [
268 | {
269 | id: 'REyJisAJvqk4-sjeB4tWW',
270 | name: 'Home',
271 | url: `https://searchcaster.xyz/api/search?count=35&engagement=reactions&after=${getUnixTimeMinusXHours(24)}`,
272 | type: 'searchcaster',
273 | nextPage: 1
274 | },
275 | {
276 | id: "REyJisAJvqk4-sjeB4tWW",
277 | name: 'Home',
278 | url: `https://searchcaster.xyz/api/search?count=15&engagement=replies&after=${getUnixTimeMinusXHours(6)}`,
279 | type: 'searchcaster',
280 | nextPage: 1
281 | },
282 | ];
283 | } else {
284 | return [
285 | {
286 | id: 'REyJisAJvqk4-sjeB4tWW',
287 | name: 'Home',
288 | url: 'https://searchcaster.xyz/api/search?count=50&engagement=reactions',
289 | type: 'searchcaster',
290 | nextPage: 1
291 | },
292 | ];
293 | }
294 | }
295 |
296 | /**
297 | * @param x number in hours
298 | * @returns unix timestamp of Date.now() minus x
299 | */
300 | function getUnixTimeMinusXHours(x: number): number {
301 | return Date.now() - 60 * 60 * x * 1000;
302 | }
303 |
304 | /**
305 | * extract out the jpg link (if there's any)
306 | *
307 | * @param openGraph openGraph object from Merkle's or Perl's API
308 | * @returns link to image
309 | */
310 | function getImageLink(openGraph: MerkleOpenGraph): string | undefined {
311 | if (typeof openGraph.url === 'string' && openGraph.url !== '') {
312 | if (/\.(jpg|png|gif)$/.test(openGraph.url)) {
313 | return addCdnLinkToImage(openGraph.url);
314 | }
315 | } else if (typeof openGraph.image === 'string' && openGraph.image !== '') {
316 | if (/\.(jpg|png|gif)$/.test(openGraph.image)) {
317 | return addCdnLinkToImage(openGraph.image);
318 | }
319 | }
320 | }
321 |
322 | /**
323 | * matches farcaster://casts//
324 | * @returns if text contains that regex, turn it into a phrasetown rast link
325 | */
326 | function replaceFarcasterProtocolString(text: string): string {
327 | const regex = /farcaster:\/\/casts\/([a-zA-Z0-9]+)\/[a-zA-Z0-9]+/;
328 | return text.replace(regex, 'phrasetown.com/cast/$1');
329 | }
330 |
331 | /**
332 | * takes raw user cast, replaces all `@name` and links
333 | * with anchor tags, then sanitize it
334 | *
335 | * the cast component displays raw html content, not string
336 | *
337 | * @param text user's cast in string
338 | * @returns user's cast as html
339 | */
340 | export function linkify(text: string): string {
341 | const linkifyOption = {
342 | truncate: 30,
343 | // href needs to be sliced because it starts with `/`
344 | formatHref: {
345 | mention: (href: string) => `/@${href.slice(1)}`
346 | },
347 | };
348 |
349 | return sanitizeHtml(linkifyHtml(replaceFarcasterProtocolString(text), linkifyOption));
350 | }
351 |
352 | /**
353 | * @param imageUrl
354 | * @returns `${cdnUrl}/${imageUrl}`
355 | */
356 | function addCdnLinkToImage(imageUrl: string): string {
357 | return `${import.meta.env.VITE_CLOUDINARY_URL}/${imageUrl}`;
358 | return imageUrl; // return this on image cdn emergency
359 | }
360 |
361 | function removeRecastStrings(casts: CastInterface[]): CastInterface[] {
362 | return casts.filter(cast => !cast.text.startsWith('recast:farcaster://'));
363 | }
364 |
365 | /**
366 | * @param timestamp unix timestamp
367 | * @returns text like "4w, 3h, 11mo"
368 | */
369 | export function getTimeago(timestamp: number): string {
370 | function enShort(number: number, index: number): [string, string] {
371 | return [
372 | ['just now', 'right now'],
373 | ['%ss ago', 'in %ss'],
374 | ['1m ago', 'in 1m'],
375 | ['%sm ago', 'in %sm'],
376 | ['1h ago', 'in 1h'],
377 | ['%sh ago', 'in %sh'],
378 | ['1d ago', 'in 1d'],
379 | ['%sd ago', 'in %sd'],
380 | ['1w ago', 'in 1w'],
381 | ['%sw ago', 'in %sw'],
382 | ['1mo ago', 'in 1mo'],
383 | ['%smo ago', 'in %smo'],
384 | ['1yr ago', 'in 1yr'],
385 | ['%syr ago', 'in %syr']
386 | ][index] as [string, string];
387 | }
388 | timeago.register('en-short', enShort);
389 | return timeago.format(timestamp, 'en-short').replace(' ago', '');
390 | }
391 |
392 | export function removeDuplicate(casts: CastInterface[]): CastInterface[] {
393 | return [...new Set(casts)];
394 | }
395 |
396 | function sortCasts(casts: CastInterface[]): CastInterface[] {
397 | return orderBy(casts, 'timestamp', 'desc');
398 | }
399 |
400 | /**
401 | *
402 | * @param cast cast from merkle's api
403 | * @param recaster useful for "recasted by @handlename" text
404 | * @returns CastInterface
405 | */
406 | export function transformMerkleCast(cast: MerkleCast, recaster?: string): CastInterface {
407 | const parent = (cast.parentAuthor && cast.parentHash) ? { username: cast.parentAuthor.username, hash: cast.parentHash } : undefined;
408 | const recasted = ('recast' in cast && typeof recaster === 'string') ? { username: recaster } : undefined;
409 | const image = (cast.attachments && cast.attachments.openGraph.length > 0) ? getImageLink(cast.attachments.openGraph[0]) : undefined;
410 |
411 | return {
412 | author: {
413 | username: cast.author.username,
414 | displayName: cast.author.displayName,
415 | pfp: addCdnLinkToImage(cast.author.pfp.url),
416 | fid: cast.author.fid
417 | },
418 | parent,
419 | recasted,
420 | hash: cast.hash,
421 | text: linkify(cast.text),
422 | image,
423 | timestamp: cast.timestamp,
424 | likes: cast.reactions.count,
425 | replies: cast.replies.count,
426 | recasts: cast.recasts.count,
427 | };
428 | }
429 |
430 | /**
431 | *
432 | * @param cast cast from Searchcaster
433 | * @returns CastInterface
434 | */
435 | function transformSearchcasterCast(cast: SearchcasterCast): CastInterface {
436 | const parent = (typeof cast.body.data.replyParentMerkleRoot === 'string' &&
437 | typeof cast.meta.replyParentUsername.username === 'string')
438 |
439 | ? { hash: cast.body.data.replyParentMerkleRoot, username: cast.meta.replyParentUsername.username }
440 | : undefined;
441 |
442 | const image = cast.body.data.image ? addCdnLinkToImage(cast.body.data.image) : undefined;
443 |
444 | return {
445 | author: {
446 | username: cast.body.username,
447 | pfp: addCdnLinkToImage(cast.meta.avatar),
448 | displayName: cast.meta.displayName,
449 | },
450 | parent,
451 | recasted: undefined,
452 | text: linkify(cast.body.data.text),
453 | image,
454 | timestamp: cast.body.publishedAt,
455 | likes: cast.meta.reactions.count,
456 | recasts: cast.meta.recasts.count,
457 | replies: cast.meta.numReplyChildren,
458 | hash: cast.merkleRoot,
459 | };
460 | }
461 |
462 |
463 | /**
464 | * from the perl backend, there are two kinds of cast returned,
465 | * each with their own types, needs to be transformed differently
466 | *
467 | * @param cast cast from perl
468 | * @returns CastInterface
469 | */
470 | function transformPerlCast(anyCast: any): CastInterface | undefined {
471 | if (anyCast.type == 'farcaster') {
472 | if (anyCast.payload.hash) {
473 | const cast = anyCast as PerlCastTypeOne;
474 | const image = ("attachments" in cast.payload) ? getImageLink(cast.payload.attachments.openGraph[0]) : undefined;
475 |
476 | return {
477 | author: {
478 | username: cast.payload.author?.username,
479 | displayName: cast.payload.author?.displayName,
480 | pfp: cast.payload.author?.pfp.url,
481 | fid: cast.payload.author?.fid
482 | },
483 | parent: undefined,
484 | recasted: undefined,
485 | hash: cast.payload.hash,
486 | text: linkify(cast.payload.text),
487 | image,
488 | timestamp: cast.payload.timestamp,
489 | likes: cast.payload.reactions.count,
490 | recasts: cast.payload.recasts.count,
491 | replies: cast.payload.replies.count
492 | };
493 | } else if (anyCast.payload.merkleRoot) {
494 | const cast = anyCast as PerlCastTypeTwo;
495 | const image = (cast.payload.attachments.openGraph) ? getImageLink(cast.payload.attachments.openGraph[0]) : undefined;
496 |
497 | return {
498 | author: {
499 | username: cast.payload.body.username,
500 | displayName: cast.payload.meta.displayName,
501 | pfp: cast.payload.meta.avatar,
502 | fid: cast.payload.body.fid
503 | },
504 | parent: undefined,
505 | recasted: undefined,
506 | hash: cast.payload.merkleRoot,
507 | text: linkify(cast.payload.body.data.text),
508 | image,
509 | timestamp: parseInt(cast.timestamp),
510 | likes: cast.payload.meta.reactions.count,
511 | recasts: cast.payload.meta.recasts.count,
512 | replies: cast.payload.meta.numReplyChildren
513 | };
514 | }
515 | }
516 | }
517 |
518 | /**
519 | * todo
520 | *
521 | * @param data
522 | * @param type
523 | * @param recaster
524 | * @returns
525 | */
526 | export function transformCasts(casts: any, type: string, recaster?: string): CastInterface[] {
527 | // todo: naming can be clarified
528 | if (type == 'merkleUser' || type == 'merkleNotification') {
529 | return casts.map((cast: MerkleCast) => transformMerkleCast(cast, recaster));
530 | } else if (type == 'searchcaster') {
531 | return casts.map((cast: SearchcasterCast) => transformSearchcasterCast(cast));
532 | } else if (type == 'perl') {
533 | return casts.map((cast: PerlCastTypeOne | PerlCastTypeOne) => transformPerlCast(cast));
534 | }
535 |
536 | // todo: handle error
537 | }
538 |
539 | function getUrl(url: string, withAmpersand = true, nextPage?: number, cursor?: string) {
540 | const ampersandOrQuestion = withAmpersand ? '&' : '?';
541 | if (nextPage) return `${url}${ampersandOrQuestion}page=${nextPage}`;
542 | else if (cursor) return `${url}${ampersandOrQuestion}cursor=${cursor}`;
543 | return url;
544 | }
545 |
546 | // logic untested
547 | function updateNextPage(endpoint: EndpointInterface, cursor?: string) {
548 | if (cursor) return { ...endpoint, cursor };
549 | else if (endpoint.nextPage) return { ...endpoint, nextPage: endpoint.nextPage++ };
550 | return endpoint;
551 | }
552 |
553 | async function fetchEndpoint(url: string, type: string, hubKey?: string, nextPage?: number, cursor?: string) {
554 | if (type == 'searchcaster') {
555 | const response = await fetch(getUrl(url, true, nextPage));
556 | return await response.json();
557 | } else if (type == 'merkleUser') {
558 | const response = await fetch(getUrl(url, true, undefined, cursor), {
559 | headers: {
560 | 'Content-Type': 'application/json',
561 | 'Authorization': `Bearer ${hubKey}`
562 | },
563 | });
564 | return await response.json();
565 | } else if (type == 'merkleNotification') {
566 | const response = await fetch(getUrl(url, true, undefined, cursor), {
567 | headers: {
568 | 'Content-Type': 'application/json',
569 | 'Authorization': `Bearer ${hubKey}`
570 | },
571 | });
572 | return await response.json();
573 | } else if (type == 'perl') {
574 | const response = await fetch(getUrl(url, false, nextPage), {
575 | headers: {
576 | 'Content-Type': 'application/json',
577 | 'Authorization': `Bearer ${hubKey}`
578 | },
579 | });
580 |
581 | return await response.json();
582 | }
583 | }
584 |
585 | /**
586 | * fetch endpoints, extract the casts, clean casts, returns it,
587 | * and also returns the updated endpoints (fetch next page)
588 | *
589 | * todo: split into multiple functions instead of being lumped into one
590 | *
591 | * @param endpoints
592 | * @param firstPage if true, fetch next page (with cursor or ?page=X)
593 | */
594 | export async function fetchEndpoints(endpoints: EndpointInterface[], userHubKey?: string):
595 | Promise<{ casts: CastInterface[], endpoints: EndpointInterface[]; }> {
596 | const hubKey = userHubKey ? userHubKey : import.meta.env.VITE_HUB_KEY;
597 |
598 | const data = await Promise.all(
599 | endpoints.map(async endpoint => {
600 | if (endpoint.type == 'searchcaster' && endpoint.nextPage) {
601 | const fetchFunction = fetchEndpoint(endpoint.url, endpoint.type, undefined, endpoint.nextPage);
602 | const data: SearchcasterApiResponse = await fetchFunction;
603 | return { casts: transformCasts(data.casts, endpoint.type), endpoints: updateNextPage(endpoint) };
604 | }
605 | else if (endpoint.type == 'merkleUser') {
606 | const fetchFunction = fetchEndpoint(endpoint.url, endpoint.type, hubKey, undefined, endpoint.cursor);
607 | const data: MerkleApiResponse = await fetchFunction;
608 | return {
609 | casts: transformCasts(data.result.casts, endpoint.type, endpoint.username),
610 | endpoints: ('next' in data) ? updateNextPage(endpoint, data.next.cursor) : endpoint
611 | };
612 | }
613 | else if (endpoint.type == 'merkleNotification') {
614 | const fetchFunction = fetchEndpoint(endpoint.url, endpoint.type, hubKey, undefined, endpoint.cursor);
615 | const data: MerkleNotificationApiResponse = await fetchFunction;
616 |
617 | if ("notifications" in data.result) {
618 | const casts = data.result.notifications.map(notification => {
619 | if (notification.type == 'cast-reply' || notification.type == 'cast-mention') {
620 | return notification.content.cast;
621 | }
622 | });
623 |
624 | return {
625 | casts: transformCasts(casts, endpoint.type, endpoint.username),
626 | endpoints: ('next' in data) ? updateNextPage(endpoint, data.next.cursor) : endpoint
627 | };
628 | }
629 | }
630 | else if (endpoint.type == 'perl' && endpoint.nextPage) {
631 | const fetchFunction = fetchEndpoint(endpoint.url, endpoint.type, undefined, endpoint.nextPage);
632 | const data = await fetchFunction;
633 |
634 | return {
635 | casts: transformCasts(data, endpoint.type).filter(cast => cast !== undefined),
636 | endpoints: updateNextPage(endpoint)
637 | };
638 | }
639 | })
640 | );
641 |
642 | const casts = data.map(feed => feed?.casts).flat(1);
643 |
644 | return {
645 | casts: sortCasts(removeDuplicate(removeRecastStrings(removeUndefined(casts)))),
646 | endpoints: removeUndefined(data.map(feed => feed?.endpoints))
647 | };
648 | }
649 |
650 | function removeUndefined(arr: (any | undefined)[]): any[] {
651 | return arr.filter(value => value !== undefined);
652 | }
653 |
654 | /**
655 | * returns upstash name, uesd for fetching cached casts
656 | */
657 | export function getUpstashName() {
658 | if (import.meta.env.PROD) {
659 | return {
660 | upstashColumnName: "prod_phrasetown_home",
661 | upstashEndpointName: "prod_phrasetown_home_endpoints"
662 | };
663 | } else {
664 | return {
665 | upstashColumnName: "local_phrasetown_home",
666 | upstashEndpointName: "local_phrasetown_home_endpoints"
667 | };
668 | }
669 | }
670 |
671 | /**
672 | * take casts, endpoints, fetch from endpoint, append to cast
673 | * returns the appended casts and the new endpoints
674 | *
675 | * @param casts casts to append
676 | * @param endpoints list of endpoints to fetch from
677 | */
678 | export async function fetchMore(casts: CastInterface[], endpoints: EndpointInterface[]) {
679 | const response = await fetch('/api/fetch-more', {
680 | method: 'PUT',
681 | body: JSON.stringify({ endpoints })
682 | });
683 |
684 | const data = await response.json();
685 | const newCasts: CastInterface[] = data.casts;
686 | if (newCasts) {
687 | return { casts: removeDuplicate(([...casts, ...newCasts])), endpoints: data.endpoints };
688 | }
689 | }
690 |
691 | /**
692 | * todo: perhaps this should return a URL object instead?
693 | *
694 | * @param isProd
695 | * @returns localhost or api.phrasetown.com
696 | */
697 | export function getApiUrl(isProd: boolean): string {
698 | if (isProd) return 'https://api.phrasetown.com';
699 | return 'http://localhost:5000';
700 | }
701 |
702 | if (import.meta.vitest) {
703 | const { it, expect } = import.meta.vitest;
704 | it('returns the correct upstash database name', () => {
705 | expect(getUpstashName().upstashColumnName).toBeTypeOf('string');
706 | });
707 |
708 | /**
709 | * todo, needs to be tested:
710 | *
711 | * getAllEndpoints: make sure it's returning all endpoints,
712 | * with correct type
713 | *
714 | * fetchEndpoints: make sure it fetches well, processes the data correctly
715 | *
716 | * fetchMore: take endpoints, return feed
717 | *
718 | * getUrl: make sure it returns the correct url, can be flaky at times
719 | *
720 | */
721 | }
--------------------------------------------------------------------------------