Showing preview only (641K chars total). Download the full file or copy to clipboard to get everything.
Repository: stephancill/opencast
Branch: main
Commit: c163bd00cfc0
Files: 236
Total size: 582.2 KB
Directory structure:
gitextract_cyfq76i2/
├── .eslintignore
├── .eslintrc.json
├── .eslintrc.json.bak
├── .github/
│ └── workflows/
│ └── deployment.yaml
├── .gitignore
├── .husky/
│ └── pre-commit.bak
├── .prettierignore
├── .prettierrc.json
├── .vscode/
│ └── settings.json
├── Dockerfile
├── LICENSE
├── README.md
├── docker-compose.yml
├── jest.config.js
├── next.config.js
├── nixpacks.toml
├── package.json
├── postcss.config.js
├── prisma/
│ └── schema.prisma
├── public/
│ └── site.webmanifest
├── src/
│ ├── app/
│ │ └── frames/
│ │ └── route.ts
│ ├── components/
│ │ ├── aside/
│ │ │ ├── aside-footer.tsx
│ │ │ ├── aside-trends.tsx
│ │ │ ├── aside.tsx
│ │ │ ├── search-bar.tsx
│ │ │ ├── suggestions.tsx.bak
│ │ │ └── trends.tsx
│ │ ├── common/
│ │ │ ├── app-head.tsx
│ │ │ ├── load-more.tsx
│ │ │ ├── placeholder.tsx
│ │ │ └── seo.tsx
│ │ ├── feed/
│ │ │ └── tweet-feed.tsx
│ │ ├── frames/
│ │ │ ├── Frame.tsx
│ │ │ └── frame-ui.tsx
│ │ ├── home/
│ │ │ ├── main-container.tsx
│ │ │ ├── main-header.tsx
│ │ │ └── update-username.tsx.bak
│ │ ├── input/
│ │ │ ├── image-preview.tsx
│ │ │ ├── input-accent-radio.tsx
│ │ │ ├── input-field.tsx
│ │ │ ├── input-form.tsx
│ │ │ ├── input-options.tsx
│ │ │ ├── input-theme-radio.tsx
│ │ │ ├── input.tsx
│ │ │ ├── progress-bar.tsx
│ │ │ └── search-bar.tsx
│ │ ├── layout/
│ │ │ ├── auth-layout.tsx
│ │ │ ├── common-layout.tsx
│ │ │ ├── main-layout.tsx
│ │ │ ├── user-data-layout.tsx
│ │ │ ├── user-follow-layout.tsx
│ │ │ └── user-home-layout.tsx
│ │ ├── login/
│ │ │ ├── login-footer.tsx
│ │ │ ├── login-main.tsx
│ │ │ └── sign-in-with-warpcast.tsx
│ │ ├── modal/
│ │ │ ├── action-modal.tsx
│ │ │ ├── display-modal.tsx
│ │ │ ├── edit-profile-modal.tsx
│ │ │ ├── image-modal.tsx
│ │ │ ├── mobile-sidebar-modal.tsx
│ │ │ ├── modal.tsx
│ │ │ ├── save-passkey-modal.tsx
│ │ │ ├── sign-in-modal-wallet.tsx
│ │ │ ├── sign-in-modal-warpcast.tsx
│ │ │ ├── tip-modal.tsx
│ │ │ ├── tweet-reply-modal.tsx
│ │ │ ├── tweet-stats-modal.tsx
│ │ │ └── username-modal.tsx
│ │ ├── search/
│ │ │ ├── search-topics.tsx
│ │ │ └── user-search-result.tsx
│ │ ├── sidebar/
│ │ │ ├── menu-link.tsx
│ │ │ ├── mobile-sidebar-link.tsx
│ │ │ ├── mobile-sidebar.tsx
│ │ │ ├── more-settings.tsx
│ │ │ ├── sidebar-link.tsx
│ │ │ ├── sidebar-profile.tsx
│ │ │ └── sidebar.tsx
│ │ ├── sync/
│ │ │ └── sync-view.tsx
│ │ ├── tweet/
│ │ │ ├── number-stats.tsx
│ │ │ ├── stats-empty.tsx
│ │ │ ├── tweet-actions.tsx
│ │ │ ├── tweet-date.tsx
│ │ │ ├── tweet-embed.tsx
│ │ │ ├── tweet-option.tsx
│ │ │ ├── tweet-parent.tsx
│ │ │ ├── tweet-parent.tsx.bak
│ │ │ ├── tweet-share.tsx
│ │ │ ├── tweet-stats.tsx
│ │ │ ├── tweet-status.tsx
│ │ │ ├── tweet-text.tsx
│ │ │ ├── tweet-topic.tsx
│ │ │ ├── tweet-with-parent.tsx
│ │ │ ├── tweet-with-parent.tsx.bak
│ │ │ └── tweet.tsx
│ │ ├── ui/
│ │ │ ├── button.tsx
│ │ │ ├── caution-warn.tsx
│ │ │ ├── custom-icon.tsx
│ │ │ ├── error.tsx
│ │ │ ├── feed-ordering-selector.tsx
│ │ │ ├── follow-button.tsx
│ │ │ ├── hero-icon.tsx
│ │ │ ├── loading.tsx
│ │ │ ├── menu-row.tsx
│ │ │ ├── next-image.tsx
│ │ │ ├── segmented-nav-link.tsx
│ │ │ └── tooltip.tsx
│ │ ├── user/
│ │ │ ├── user-avatar.tsx
│ │ │ ├── user-card.tsx
│ │ │ ├── user-cards.tsx
│ │ │ ├── user-details.tsx
│ │ │ ├── user-edit-profile.tsx
│ │ │ ├── user-fid.tsx
│ │ │ ├── user-follow-stats.tsx
│ │ │ ├── user-follow.tsx
│ │ │ ├── user-following.tsx
│ │ │ ├── user-header.tsx
│ │ │ ├── user-home-avatar.tsx
│ │ │ ├── user-home-cover.tsx
│ │ │ ├── user-known-followers.tsx
│ │ │ ├── user-name.tsx
│ │ │ ├── user-nav.tsx
│ │ │ ├── user-share.tsx
│ │ │ ├── user-tooltip.tsx
│ │ │ └── user-username.tsx
│ │ └── view/
│ │ ├── view-parent-tweet.tsx
│ │ ├── view-tweet-stats.tsx
│ │ └── view-tweet.tsx
│ ├── contracts/
│ │ ├── id-registry.ts
│ │ ├── index.ts
│ │ ├── key-gateway.ts
│ │ ├── key-registry.ts
│ │ └── validator.ts
│ ├── lib/
│ │ ├── api/
│ │ │ ├── auth.ts
│ │ │ └── trends.ts
│ │ ├── chains/
│ │ │ └── resolve-chain-icon.ts
│ │ ├── context/
│ │ │ ├── auth-context.tsx
│ │ │ ├── theme-context.tsx
│ │ │ ├── user-context.tsx
│ │ │ └── window-context.tsx
│ │ ├── crypto.ts
│ │ ├── date.ts
│ │ ├── embeds.ts
│ │ ├── env.ts
│ │ ├── farcaster/
│ │ │ ├── index.ts
│ │ │ └── utils.ts
│ │ ├── fetch.ts
│ │ ├── hooks/
│ │ │ ├── useConnectedWalletFid.tsx
│ │ │ ├── useInfiniteScroll.tsx
│ │ │ ├── useInfiniteScrollUsers.tsx
│ │ │ ├── useModal.ts
│ │ │ └── useRequireAuth.ts
│ │ ├── imgur/
│ │ │ └── upload.ts
│ │ ├── keys.ts
│ │ ├── lru-cache.ts
│ │ ├── merge.ts
│ │ ├── paginated-reactions.ts
│ │ ├── paginated-tweets.ts
│ │ ├── passkeys.ts
│ │ ├── prisma.ts
│ │ ├── random.ts
│ │ ├── signers.ts
│ │ ├── topics/
│ │ │ └── resolve-topic.ts
│ │ ├── types/
│ │ │ ├── app-auth.ts
│ │ │ ├── available.ts
│ │ │ ├── bookmark.ts
│ │ │ ├── feed.ts
│ │ │ ├── file.ts
│ │ │ ├── keypair.ts
│ │ │ ├── notifications.ts
│ │ │ ├── online.ts
│ │ │ ├── place.ts
│ │ │ ├── responses.ts
│ │ │ ├── signer.ts
│ │ │ ├── stats.ts
│ │ │ ├── theme.ts
│ │ │ ├── topic.ts
│ │ │ ├── trends.ts
│ │ │ ├── tweet.ts
│ │ │ └── user.ts
│ │ ├── user/
│ │ │ └── resolve-user.ts
│ │ ├── utils.ts
│ │ └── validation.ts
│ ├── pages/
│ │ ├── 404.tsx
│ │ ├── [...redirect].tsx
│ │ ├── _app.tsx
│ │ ├── _document.tsx
│ │ ├── api/
│ │ │ ├── embeds.ts
│ │ │ ├── feed.ts
│ │ │ ├── hub/
│ │ │ │ ├── batch.ts
│ │ │ │ └── index.ts
│ │ │ ├── online/
│ │ │ │ └── index.ts
│ │ │ ├── search.ts
│ │ │ ├── signer/
│ │ │ │ └── [pubKey]/
│ │ │ │ ├── authorize.ts
│ │ │ │ └── user.ts
│ │ │ ├── topic/
│ │ │ │ └── index.ts
│ │ │ ├── trends/
│ │ │ │ └── index.ts
│ │ │ ├── tweet/
│ │ │ │ ├── [id]/
│ │ │ │ │ ├── engagers.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── replies.ts
│ │ │ │ └── batch.ts
│ │ │ └── user/
│ │ │ ├── [id]/
│ │ │ │ ├── index.ts
│ │ │ │ ├── interests.ts
│ │ │ │ ├── known-followers.ts
│ │ │ │ ├── likes.ts
│ │ │ │ ├── links.ts
│ │ │ │ ├── notifications.ts
│ │ │ │ ├── signers/
│ │ │ │ │ ├── [pubKey]/
│ │ │ │ │ │ ├── backup.ts
│ │ │ │ │ │ ├── casts.ts
│ │ │ │ │ │ └── index.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── sync.ts
│ │ │ │ └── tweets.ts
│ │ │ └── resolve-usernames.ts
│ │ ├── bookmarks.tsx.bak
│ │ ├── home.tsx
│ │ ├── index.tsx
│ │ ├── login.tsx
│ │ ├── notifications.tsx
│ │ ├── people.tsx.bak
│ │ ├── settings/
│ │ │ ├── index.tsx
│ │ │ └── manage-signers/
│ │ │ ├── [pubKey].tsx
│ │ │ └── index.tsx
│ │ ├── topic/
│ │ │ └── index.tsx
│ │ ├── trends.tsx
│ │ ├── tweet/
│ │ │ └── [id].tsx
│ │ └── user/
│ │ └── [id]/
│ │ ├── followers.tsx
│ │ ├── following.tsx
│ │ ├── index.tsx
│ │ ├── likes.tsx
│ │ ├── media.tsx.bak
│ │ ├── with_replies.tsx
│ │ └── with_replies.tsx.bak
│ └── styles/
│ ├── fonts.scss
│ └── globals.scss
├── tailwind.config.js
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintignore
================================================
# next config
next.config.js
# tailwind config
tailwind.config.js
postcss.config.js
# jest config
jest.config.js
================================================
FILE: .eslintrc.json
================================================
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json"
},
"settings": {
"import/resolver": {
"typescript": true,
"node": true
}
},
"plugins": ["react-hooks"]
}
================================================
FILE: .eslintrc.json.bak
================================================
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "tsconfig.json"
},
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking",
"next/core-web-vitals"
],
"settings": {
"import/resolver": {
"typescript": true,
"node": true
}
},
"rules": {
"semi": ["error", "always"],
"curly": ["warn", "multi"],
"quotes": ["error", "single", { "avoidEscape": true }],
"jsx-quotes": ["error", "prefer-single"],
"linebreak-style": ["error", "unix"],
"no-console": "warn",
"comma-dangle": ["error", "never"],
"no-unused-expressions": "error",
"no-constant-binary-expression": "error",
"import/order": [
"warn",
{
"pathGroups": [
{
"pattern": "*.scss",
"group": "builtin",
"position": "before",
"patternOptions": { "matchBase": true }
},
{
"pattern": "@lib/**",
"group": "external",
"position": "after"
},
{
"pattern": "@components/**",
"group": "external",
"position": "after"
}
],
"warnOnUnassignedImports": true,
"pathGroupsExcludedImportTypes": ["type"],
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index",
"object",
"type"
]
}
],
"@typescript-eslint/no-misused-promises": [
"error",
{
"checksVoidReturn": { "attributes": false }
}
],
"@typescript-eslint/consistent-type-imports": "warn",
"@typescript-eslint/prefer-nullish-coalescing": "warn",
"@typescript-eslint/explicit-function-return-type": "warn"
}
}
================================================
FILE: .github/workflows/deployment.yaml
================================================
name: Deploy 🚀
on:
push:
branches: ['main']
pull_request:
branches: ['main']
jobs:
prettier:
name: 🧪 Prettier
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: 📥 Download deps
run: npm ci
- name: 🔍 Format
run: npm run format
eslint:
name: ✅ ESLint
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: 📥 Download deps
run: npm ci
- name: 🪄 Lint
run: npm run lint
jest:
name: 🃏 Jest
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
- name: 📥 Download deps
run: npm ci
# ! uncomment this after you add test
# - name: 🔬 Test
# run: npm run test:ci
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# python
/.mypy_cache
*.py
.env
.idea
================================================
FILE: .husky/pre-commit.bak
================================================
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
exec 1> /dev/tty
npx lint-staged
================================================
FILE: .prettierignore
================================================
# testing
/coverage
# next.js
/.next/
/.vercel/
/out/
# production
/build
# compiled js functions
/functions/lib/
# python
*.py
.mypy_cache/
================================================
FILE: .prettierrc.json
================================================
{
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "none"
}
================================================
FILE: .vscode/settings.json
================================================
{
"WillLuke.nextjs.hasPrompted": true
}
================================================
FILE: Dockerfile
================================================
# https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN yarn prisma generate
RUN \
if [ -f yarn.lock ]; then yarn run build; \
elif [ -f package-lock.json ]; then npm run build; \
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else echo "Lockfile not found." && exit 1; \
fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
# server.js is created by next build from the standalone output
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2022 ccrsxx
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Opencast
A fully open source Twitter flavoured Farcaster client. Originally a fork of [ccrsxx/twitter-clone](https://github.com/ccrsxx/twitter-clone).
The goal of this project is to be a fully standalone Farcaster client that you can run on your own machine. It only depends on [stephancill/lazy-indexer](https://github.com/stephancill/lazy-indexer) and a connection to a Farcaster Hub.
## Running it yourself
### Prerequisites
- [Docker](https://docs.docker.com/engine/install/)
1. Clone the repo
```
git clone git@github.com:stephancill/opencast.git
```
2. Copy .env.sample, rename it to .env and fill in the values
```
cp .env.sample .env
```
3. Run the Docker Compose file
```
docker-compose up -d
```
4. Go to Opencast at http://localhost:3000 and log in. It will take a few moments to index your profile and might require you to refresh the page.
## Development
### Farcaster Indexer
This project depends on the Lazy Farcaster Indexer. Follow the instructions at [https://github.com/stephancill/lazy-indexer](https://github.com/stephancill/lazy-indexer) to set up an instance.
### Local
Install dependencies
```
yarn install
```
Fill in the environment variables
```
cp .env.dev.sample .env
```
Run the development server
```
yarn dev
```
## Todo
- [ ] Feed
- [x] Reverse chronological feed
- [x] Pagination
- [x] Number of likes, comments, and reposts
- [ ] Recasts
- [x] Cast detail
- [x] Number of likes, comments, and reposts
- [x] Paginated replies
- [x] User profiles
- [x] Casts
- [x] Casts with replies
- [ ] Media
- [x] Likes
- [ ] Edit profile
- [x] Auth
- [x] Engagement actions
- [x] Post creation
- [x] Text only
- [x] Media
- [x] Mentions
- [x] Embeds
- [x] Topic
- [x] Post deletion
- [ ] Search
- [x] User
- [ ] Topic
- [ ] Posts
- [x] Channels (now called Topics)
- [x] Channel detail
- [x] Channel discovery
- [ ] Index channels
- [x] Fix mobile layout
- [ ] Rebrand
- [x] Renaming (casts -> tweets, etc)
- [x] Images
- [ ] Code
- [x] Notifications
- [x] Badge counter
- [x] Notifications page
- [ ] Optimize
- [ ] DB queries
- [ ] Bandwidth
...
================================================
FILE: docker-compose.yml
================================================
version: '3.8'
services:
opencast:
build: ./
container_name: opencast
restart: unless-stopped
env_file:
# Set in .env
# APP_FID
# APP_MNENOMIC
- .env
environment:
DATABASE_URL: postgresql://indexer:password@postgres:5432/indexer
FC_HUB_URL: 'hub-grpc.pinata.cloud'
FC_HUB_USE_TLS: 'true'
NEXT_PUBLIC_FC_CLIENT_NAME: 'Opencast'
NEXT_PUBLIC_WALLETCONNECT_ID: '0fcda49e9f4acad4b84401373fbc5a4f'
NEXT_PUBLIC_URL: 'http://localhost:3000'
INDEXER_API_URL: 'http://lazy-indexer:3005'
ports:
- '3000:3000'
depends_on:
- lazy-indexer
networks:
- app-network
lazy-indexer:
image: stephancill/lazy-indexer
container_name: lazy-indexer
env_file:
# Set in .env
# TARGET_SIGNER_FID (usually same as APP_FID)
- .env
environment:
DATABASE_URL: postgresql://indexer:password@postgres:5432/indexer
REDIS_URL: redis://redis:6379
HUB_REST_URL: https://hub.pinata.cloud
HUB_RPC: hub-grpc.pinata.cloud
HUB_SSL: true
WORKER_CONCURRENCY: 5
LOG_LEVEL: debug
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
ports:
- '3005:3005'
networks:
- app-network
postgres:
image: 'postgres:16-alpine'
restart: unless-stopped
ports:
- '5432:5432'
environment:
- POSTGRES_DB=indexer
- POSTGRES_USER=indexer
- POSTGRES_PASSWORD=password
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 5s # Check every 5 seconds for readiness
timeout: 5s # Allow up to 5 seconds for a response
retries: 3 # Fail after 3 unsuccessful attempts
start_period: 10s # Start checks after 10 seconds
networks:
- app-network
redis:
image: 'redis:7.2-alpine'
restart: unless-stopped
command: --loglevel warning --maxmemory-policy noeviction
volumes:
- redis-data:/data
ports:
- '6379:6379'
healthcheck:
test: ['CMD-SHELL', 'redis-cli ping']
interval: 5s # Check every 5 seconds
timeout: 5s # Allow up to 5 seconds for a response
retries: 3 # Fail after 3 unsuccessful attempts
start_period: 5s # Start health checks after 5 seconds
networks:
- app-network
volumes:
postgres-data:
redis-data:
networks:
app-network:
driver: bridge
================================================
FILE: jest.config.js
================================================
// jest.config.js
const nextJest = require('next/jest');
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './'
});
// Add any custom config to be passed to Jest
const customJestConfig = {
// Add more setup options before each test is run
// setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
// if using TypeScript with a baseUrl set to the root directory then you need the below for alias' to work
modulePaths: ['<rootDir>/src'],
testEnvironment: 'jest-environment-jsdom',
// Math aliases too instead of just baseUrl
moduleNameMapper: {
'^@components(.*)$': '<rootDir>/src/components$1',
'^@lib(.*)$': '<rootDir>/src/lib$1',
'^@styles(.*)$': '<rootDir>/src/styles$1'
}
};
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig);
================================================
FILE: next.config.js
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
output: "standalone",
webpack: (config) => {
config.resolve.fallback = { fs: false, net: false, tls: false };
return config;
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*'
},
{
protocol: 'http',
hostname: '*'
}
]
},
experimental: {
scrollRestoration: true
},
async headers() {
return [
{
// matching all API routes
source: '/api/:path*',
headers: [
{ key: 'Access-Control-Allow-Credentials', value: 'true' },
{ key: 'Access-Control-Allow-Origin', value: '*' },
{
key: 'Access-Control-Allow-Methods',
value: 'GET,DELETE,PATCH,POST,PUT'
},
{
key: 'Access-Control-Allow-Headers',
value:
'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version'
}
]
}
];
}
};
module.exports = nextConfig;
================================================
FILE: nixpacks.toml
================================================
[phases.setup]
nixPkgs = ['...', 'python3', 'gcc']
[phases.install]
cmds = ['yarn global add node-gyp', '...']
================================================
FILE: package.json
================================================
{
"name": "opencast",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"format": "prettier --check .",
"lint": "next lint",
"test": "jest --watch",
"test:ci": "jest --ci",
"prepare": "husky install",
"postinstall": "patch-package"
},
"dependencies": {
"@farcaster/core": "^0.13.3",
"@farcaster/hub-nodejs": "^0.10.3",
"@farcaster/hub-web": "^0.6.0",
"@frames.js/render": "^0.2.20",
"@headlessui/react": "^1.7.2",
"@heroicons/react": "^2.0.11",
"@noble/ed25519": "^2.0.0",
"@prisma/client": "^5.1.0",
"@rainbow-me/rainbowkit": "^2.1.3",
"@tanstack/react-query": "^5.49.2",
"clsx": "^1.2.1",
"firebase": "^9.9.4",
"framer-motion": "^7.2.1",
"frames.js": "^0.17.1",
"lodash": "^4.17.21",
"lru-cache": "^10.0.1",
"metadata-scraper": "^0.2.61",
"next": "^14.1.4",
"patch-package": "^8.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hot-toast": "^2.3.0",
"react-qr-code": "^2.0.11",
"react-query": "^3.39.3",
"react-textarea-autosize": "^8.3.4",
"swr": "^1.3.0",
"validator": "^13.11.0",
"viem": "2.x",
"wagmi": "^2.10.9"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/lodash": "^4.14.197",
"@types/node": "18.6.4",
"@types/react": "18.0.16",
"@types/react-dom": "18.0.6",
"@types/validator": "^13.11.1",
"@typescript-eslint/eslint-plugin": "^5.32.0",
"@typescript-eslint/parser": "^5.32.0",
"autoprefixer": "^10.4.8",
"dotenv": "^16.4.5",
"eslint": "8.21.0",
"eslint-config-next": "12.2.4",
"eslint-import-resolver-typescript": "^3.4.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.1",
"jest": "^28.1.3",
"jest-environment-jsdom": "^28.1.3",
"lint-staged": "^13.0.3",
"postcss": "^8.4.16",
"prettier": "^2.7.1",
"prettier-plugin-tailwindcss": "^0.1.13",
"prisma": "^5.1.0",
"sass": "^1.54.4",
"tailwindcss": "^3.2.4",
"typescript": "^5.5.3"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
},
"engines": {
"node": ">=18.0.0"
},
"resolutions": {
"ffi-napi": "https://registry.yarnpkg.com/@favware/skip-dependency/-/skip-dependency-1.0.2.tgz"
}
}
================================================
FILE: postcss.config.js
================================================
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
================================================
FILE: prisma/schema.prisma
================================================
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model casts {
id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
timestamp DateTime @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
pruned_at DateTime? @db.Timestamptz(6)
fid BigInt
parent_fid BigInt?
hash Bytes @unique
root_parent_hash Bytes?
parent_hash Bytes?
root_parent_url String?
parent_url String?
text String
signer Bytes
embeds Json @default("[]") @db.Json
mentions Json @default("[]") @db.Json
mentions_positions Json @default("[]") @db.Json
@@index([timestamp], map: "casts_timestamp_index")
}
model fids {
fid BigInt @id
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
registered_at DateTime @db.Timestamptz(6)
custody_address Bytes
recovery_address Bytes
}
model kysely_migration {
name String @id @db.VarChar(255)
timestamp String @db.VarChar(255)
}
model kysely_migration_lock {
id String @id @db.VarChar(255)
is_locked Int @default(0)
}
model links {
id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
timestamp DateTime @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
pruned_at DateTime? @db.Timestamptz(6)
fid BigInt
target_fid BigInt
display_timestamp DateTime? @db.Timestamptz(6)
type String
hash Bytes @unique
signer Bytes
}
model reactions {
id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
timestamp DateTime @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
pruned_at DateTime? @db.Timestamptz(6)
fid BigInt
target_cast_fid BigInt?
type Int @db.SmallInt
hash Bytes @unique
target_cast_hash Bytes?
target_url String?
signer Bytes
}
model signers {
id String? @default(dbgenerated("generate_ulid()")) @db.Uuid
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
added_at DateTime @db.Timestamptz(6)
removed_at DateTime? @db.Timestamptz(6)
fid BigInt
requester_fid BigInt
key_type Int @db.SmallInt
metadata_type Int @db.SmallInt
key Bytes
metadata Json @db.Json
@@unique([fid, key], map: "signers_fid_key_unique")
@@index([fid], map: "signers_fid_index")
@@index([requester_fid], map: "signers_requester_fid_index")
}
model user_data {
id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
timestamp DateTime @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
fid BigInt
type Int @db.SmallInt
hash Bytes @unique
value String
signer Bytes
@@unique([fid, type], map: "user_data_fid_type_unique")
}
/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info.
/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info.
/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info.
model verifications {
id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
timestamp DateTime @db.Timestamptz(6)
deleted_at DateTime? @db.Timestamptz(6)
fid BigInt
hash Bytes
signer_address Bytes
block_hash Bytes
signature Bytes
@@unique([signer_address, fid], map: "verifications_signer_address_fid_unique")
@@index([fid, timestamp], map: "verifications_fid_timestamp_index")
}
model hubs {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
gossip_address String
rpc_address String
excluded_hashes String[]
count Int @default(0)
hub_version String
network String
app_version String
timestamp BigInt
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
}
model storage {
id String? @default(dbgenerated("generate_ulid()")) @db.Uuid
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
rented_at DateTime @db.Timestamptz(6)
expires_at DateTime @db.Timestamptz(6)
fid BigInt
units Int @db.SmallInt
payer Bytes
@@unique([fid, expires_at], map: "storage_fid_expires_at_unique")
@@index([fid, expires_at], map: "storage_fid_expires_at_index")
}
model targets {
id String @id @default(dbgenerated("generate_ulid()")) @db.Uuid
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
fid BigInt @unique(map: "target_fid_unique")
}
================================================
FILE: public/site.webmanifest
================================================
{
"name": "Opencast - Open Source Farcaster Client",
"short_name": "Opencast",
"description": "Fully open source Twitter flavoured Farcaster client",
"display": "standalone",
"start_url": "/",
"theme_color": "#fff",
"background_color": "#000000",
"orientation": "portrait",
"icons": [
{
"src": "/logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/logo512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}
================================================
FILE: src/app/frames/route.ts
================================================
export { GET, POST } from '@frames.js/render/next';
================================================
FILE: src/components/aside/aside-footer.tsx
================================================
const footerLinks = [
['GitHub', 'https://github.com/stephancill/twitter-farcaster-client']
] as const;
export function AsideFooter(): JSX.Element {
return (
<footer
className='sticky top-[90vh] flex flex-col gap-3 text-center text-sm
text-light-secondary dark:text-dark-secondary'
>
<nav className='flex flex-wrap justify-center gap-2'>
{footerLinks.map(([linkName, href]) => (
<a
className='custom-underline'
target='_blank'
rel='noreferrer'
href={href}
key={href}
>
{linkName}
</a>
))}
</nav>
<div></div>
<p>Built by @stephancill. Use at own risk.</p>
</footer>
);
}
================================================
FILE: src/components/aside/aside-trends.tsx
================================================
import Link from 'next/link';
import cn from 'clsx';
import { motion } from 'framer-motion';
import { formatNumber } from '@lib/date';
import { preventBubbling } from '@lib/utils';
import { useTrends } from '@lib/api/trends';
import { Error } from '@components/ui/error';
import { HeroIcon } from '@components/ui/hero-icon';
import { Button } from '@components/ui/button';
import { ToolTip } from '@components/ui/tooltip';
import { Loading } from '@components/ui/loading';
import type { MotionProps } from 'framer-motion';
export const variants: MotionProps = {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.8 }
};
type AsideTrendsProps = {
inTrendsPage?: boolean;
};
export function AsideTrends({ inTrendsPage }: AsideTrendsProps): JSX.Element {
const { data, loading } = useTrends(1, inTrendsPage ? 100 : 10, {
refreshInterval: 30000
});
const { trends, location } = data ?? {};
return (
<section
className={cn(
!inTrendsPage &&
'hover-animation rounded-2xl bg-main-sidebar-background'
)}
>
{loading ? (
<Loading />
) : trends ? (
<motion.div
// className={cn('inner:px-4 inner:py-3', inTrendsPage && 'mt-0.5')}
{...variants}
>
{!inTrendsPage && (
<h2 className='text-xl font-extrabold'>Trends for you</h2>
)}
{trends.map(({ name, query, tweet_volume, url }) => (
<Link
href={url}
key={query}
className='hover-animation accent-tab hover-card relative
flex cursor-not-allowed flex-col gap-0.5'
>
<div className='absolute right-2 top-2'>
<Button
className='hover-animation group relative cursor-not-allowed p-2
hover:bg-accent-blue/10 focus-visible:bg-accent-blue/20
focus-visible:!ring-accent-blue/80'
onClick={preventBubbling()}
>
<HeroIcon
className='h-5 w-5 text-light-secondary group-hover:text-accent-blue
group-focus-visible:text-accent-blue dark:text-dark-secondary'
iconName='EllipsisHorizontalIcon'
/>
<ToolTip tip='More' />
</Button>
</div>
<p className='text-sm text-light-secondary dark:text-dark-secondary'>
Trending{' '}
{location === 'Worldwide'
? 'Worldwide'
: `in ${location as string}`}
</p>
<p className='font-bold'>{name}</p>
<p className='text-sm text-light-secondary dark:text-dark-secondary'>
{formatNumber(tweet_volume)} tweets
</p>
</Link>
))}
{!inTrendsPage && (
<Link
href='/trends'
className='custom-button accent-tab hover-card block w-full rounded-2xl
rounded-t-none text-center text-main-accent'
>
Show more
</Link>
)}
</motion.div>
) : (
<Error />
)}
</section>
);
}
================================================
FILE: src/components/aside/aside.tsx
================================================
import { useWindow } from '@lib/context/window-context';
import Link from 'next/link';
import type { ReactNode } from 'react';
import { User } from '../../lib/types/user';
import { UserSearchResult } from '../search/user-search-result';
import { AsideFooter } from './aside-footer';
import { SearchBar } from './search-bar';
type AsideProps = {
children: ReactNode;
};
export function Aside({ children }: AsideProps): JSX.Element | null {
const { width } = useWindow();
if (width < 1024) return null;
return (
<aside className='flex w-96 flex-col gap-4 px-4 py-3 pt-1'>
<SearchBar<User>
urlBuilder={(input) =>
input.length > 0 ? `/api/search?q=${input}` : null
}
resultBuilder={(user, callback) => {
return (
<Link href={`/user/${user.username}`}>
<UserSearchResult user={user} key={user.id} callback={callback} />
</Link>
);
}}
/>
{children}
<AsideFooter />
</aside>
);
}
================================================
FILE: src/components/aside/search-bar.tsx
================================================
import { Button } from '@components/ui/button';
import { HeroIcon } from '@components/ui/hero-icon';
import cn from 'clsx';
import { debounce } from 'lodash';
import { useRouter } from 'next/router';
import type { ChangeEvent, FormEvent, KeyboardEvent } from 'react';
import { useCallback, useRef, useState } from 'react';
import useSWR from 'swr';
import { fetchJSON } from '../../lib/fetch';
import { BaseResponse } from '../../lib/types/responses';
import { Loading } from '../ui/loading';
export type SearchBarProps<T> = {
urlBuilder: (query: string) => string | null;
resultBuilder: (data: T, callback: () => void) => JSX.Element;
};
export function SearchBar<T>({
urlBuilder,
resultBuilder
}: SearchBarProps<T>): JSX.Element {
const [inputValue, setInputValue] = useState('');
const [queryValue, setQueryValue] = useState('');
const [resultsVisible, setResultsVisible] = useState(false);
const blurTimeout = useRef<NodeJS.Timeout>();
const handleFocusEvent = () => {
clearTimeout(blurTimeout.current);
setResultsVisible(true);
};
const handleBlurEvent = () => {
blurTimeout.current = setTimeout(() => {
setResultsVisible(false);
}, 200);
};
const handleClickOnResult = () => {
clearTimeout(blurTimeout.current);
// your code for when a user clicks a search result
setResultsVisible(false);
setInputValue('');
setQueryValue('');
};
const { push } = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const handleChange = (e: ChangeEvent<HTMLInputElement>): void => {
setInputValue(e.target.value);
};
const handleChangeDebounced = useCallback(
debounce((e) => {
setQueryValue(e.target.value);
}, 1000),
[]
);
const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
e.preventDefault();
// if (inputValue) void push(`/search?q=${inputValue}`);
};
const clearInputValue = (focus?: boolean) => (): void => {
if (focus) inputRef.current?.focus();
else inputRef.current?.blur();
setInputValue('');
};
const handleEscape = ({ key }: KeyboardEvent<HTMLInputElement>): void => {
if (key === 'Escape') clearInputValue()();
};
const { data, isValidating } = useSWR(
() => urlBuilder(queryValue),
async (url) => (await fetchJSON<BaseResponse<T[]>>(url)).result,
{ revalidateOnFocus: false }
);
return (
<div className='hover-animation sticky top-0 z-10 flex-col bg-main-background py-2'>
{/* TODO: Use SearchBar component */}
<form className='' onSubmit={handleSubmit}>
<label
className='group flex items-center justify-between gap-4 rounded-full
bg-main-search-background px-4 py-2 transition focus-within:bg-main-background
focus-within:ring-2 focus-within:ring-main-accent'
>
<i>
<HeroIcon
className='h-5 w-5 text-light-secondary transition-colors
group-focus-within:text-main-accent dark:text-dark-secondary'
iconName='MagnifyingGlassIcon'
/>
</i>
<input
className='peer flex-1 bg-transparent outline-none
placeholder:text-light-secondary dark:placeholder:text-dark-secondary'
type='text'
placeholder='Search Farcaster'
ref={inputRef}
value={inputValue}
onChange={(e) => {
handleChange(e);
handleChangeDebounced(e);
}}
onKeyUp={handleEscape}
onBlur={handleBlurEvent}
onFocus={handleFocusEvent}
/>
<Button
className={cn(
'accent-tab scale-50 bg-main-accent p-1 opacity-0 transition hover:brightness-90 disabled:opacity-0',
inputValue &&
'focus:scale-100 focus:opacity-100 peer-focus:scale-100 peer-focus:opacity-100'
)}
onClick={clearInputValue(true)}
disabled={!inputValue}
>
<HeroIcon className='h-3 w-3 stroke-white' iconName='XMarkIcon' />
</Button>
</label>
</form>
{resultsVisible && (data || isValidating) && (
<div className='menu-container hover-animation absolute mt-1 w-full overflow-hidden rounded-2xl bg-main-background'>
{isValidating ? (
<div>{<Loading className='p-4' />}</div>
) : data && data.length > 0 ? (
data?.map((result) =>
resultBuilder(result, () => {
handleClickOnResult();
})
)
) : (
<div className='p-4'>No results</div>
)}
</div>
)}
</div>
);
}
================================================
FILE: src/components/aside/suggestions.tsx.bak
================================================
import Link from 'next/link';
import { motion } from 'framer-motion';
import {
doc,
limit,
query,
where,
orderBy,
documentId
} from 'firebase/firestore';
import { useAuth } from '@lib/context/auth-context';
import { useCollection } from '@lib/hooks/useCollection';
import { useDocument } from '@lib/hooks/useDocument';
import { usersCollection } from '@lib/firebase/collections';
import { UserCard } from '@components/user/user-card';
import { Loading } from '@components/ui/loading';
import { Error } from '@components/ui/error';
import { variants } from './aside-trends';
export function Suggestions(): JSX.Element {
const { randomSeed } = useAuth();
const { data: adminData, loading: adminLoading } = useDocument(
doc(usersCollection, 'Twt0A27bx9YcG4vu3RTsR7ifJzf2'),
{ allowNull: true }
);
const { data: suggestionsData, loading: suggestionsLoading } = useCollection(
query(
usersCollection,
where(documentId(), '>=', randomSeed),
orderBy(documentId()),
limit(2)
),
{ allowNull: true }
);
return (
<section className='hover-animation rounded-2xl bg-main-sidebar-background'>
{adminLoading || suggestionsLoading ? (
<Loading className='flex h-52 items-center justify-center p-4' />
) : suggestionsData ? (
<motion.div className='inner:px-4 inner:py-3' {...variants}>
<h2 className='text-xl font-bold'>Who to follow</h2>
{adminData && <UserCard {...adminData} />}
{suggestionsData?.map((userData) => (
<UserCard {...userData} key={userData.id} />
))}
<Link href='/people'>
<a
className='custom-button accent-tab hover-card block w-full rounded-2xl
rounded-t-none text-center text-main-accent'
>
Show more
</a>
</Link>
</motion.div>
) : (
<Error />
)}
</section>
);
}
================================================
FILE: src/components/aside/trends.tsx
================================================
import { Error } from '@components/ui/error';
import { Loading } from '@components/ui/loading';
import { formatNumber } from '@lib/date';
import cn from 'clsx';
import type { MotionProps } from 'framer-motion';
import { motion } from 'framer-motion';
import Link from 'next/link';
import useSWR from 'swr';
import { fetchJSON } from '../../lib/fetch';
import { TrendsResponse } from '../../lib/types/trends';
import { NextImage } from '../ui/next-image';
export const variants: MotionProps = {
initial: { opacity: 0 },
animate: { opacity: 1 },
transition: { duration: 0.8 }
};
type AsideTrendsProps = {
inTrendsPage?: boolean;
};
export function AsideTrends({ inTrendsPage }: AsideTrendsProps): JSX.Element {
const { data: trends, isValidating: loading } = useSWR(
`/api/trends?limit=${inTrendsPage ? 20 : 5}`,
async (url: string) => (await fetchJSON<TrendsResponse>(url)).result,
{
revalidateOnFocus: false
}
);
return (
<section
className={cn(
!inTrendsPage &&
'hover-animation sticky top-[4.5rem] overflow-hidden rounded-2xl bg-main-sidebar-background'
)}
>
{loading ? (
<Loading />
) : trends ? (
<motion.div
className={cn('flex flex-col gap-4', inTrendsPage && 'mt-0.5')}
{...variants}
>
{!inTrendsPage && (
<h2 className='px-4 pt-4 text-xl font-extrabold'>Trending topics</h2>
)}
<div className='flex flex-col'>
<div className='flex flex-col'>
{trends.map(({ topic: topic, volume }) => (
<Link href={`/topic?url=${topic?.url}`} key={topic?.url}>
<div
className='hover-animation accent-tab hover-card relative
flex cursor-pointer flex-col gap-0.5 py-2 px-4'
>
<div className='flex items-center'>
{topic?.image && (
<div className='mr-2 overflow-hidden rounded-md border'>
<NextImage
imgClassName='object-fill'
src={topic.image}
alt={topic.name}
// layout='fill'
width={36}
height={36}
></NextImage>
</div>
)}
<div>
<p className='font-bold'>{topic?.name}</p>
<p className='text-sm text-light-secondary dark:text-dark-secondary'>
{formatNumber(volume)} posts today
</p>
</div>
</div>
</div>
</Link>
))}
</div>
{!inTrendsPage && (
<Link
href='/trends'
className='custom-button accent-tab hover-card block w-full rounded-2xl
rounded-t-none text-center text-main-accent'
>
Show more
</Link>
)}
</div>
</motion.div>
) : (
<Error />
)}
</section>
);
}
================================================
FILE: src/components/common/app-head.tsx
================================================
import Head from 'next/head';
export function AppHead(): JSX.Element {
return (
<Head>
<title>Opencast</title>
<meta name='og:title' content='Opencast' />
<link rel='icon' href='/favicon.ico' />
<link rel='manifest' href='/site.webmanifest' key='site-manifest' />
<meta name='twitter:card' content='summary_large_image' />
</Head>
);
}
================================================
FILE: src/components/common/load-more.tsx
================================================
import { useEffect, useRef } from 'react';
// TODO: Replace other load more components with this one
export function LoadMoreSentinel({
loadMore,
isLoading
}: {
loadMore: () => void;
isLoading: boolean;
}) {
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !isLoading) {
loadMore();
}
},
{
root: null,
rootMargin: '0px',
threshold: 1.0
}
);
if (ref.current) {
observer.observe(ref.current);
}
return () => {
if (ref.current) {
observer.unobserve(ref.current);
}
};
}, [loadMore, isLoading]);
return <div ref={ref}></div>;
}
================================================
FILE: src/components/common/placeholder.tsx
================================================
import { CustomIcon } from '@components/ui/custom-icon';
import { SEO } from './seo';
export function Placeholder(): JSX.Element {
return (
<main className='flex min-h-screen items-center justify-center'>
<SEO
title='Opencast'
description='Fully open source Twitter flavoured Farcaster client.'
image='/banner.png'
/>
<i>
<CustomIcon
className='h-20 w-20 text-[#1DA1F2]'
iconName='TwitterIcon'
/>
</i>
</main>
);
}
================================================
FILE: src/components/common/seo.tsx
================================================
import { useRouter } from 'next/router';
import Head from 'next/head';
import { siteURL } from '@lib/env';
type MainLayoutProps = {
title: string;
image?: string;
description?: string;
};
export function SEO({
title,
image,
description
}: MainLayoutProps): JSX.Element {
const { asPath } = useRouter();
return (
<Head>
<title>{title}</title>
<meta name='og:title' content={title} />
{description && <meta name='description' content={description} />}
{description && <meta name='og:description' content={description} />}
{image && <meta property='og:image' content={image} />}
<meta
name='og:url'
content={`${siteURL}${asPath === '/' ? '' : asPath}`}
/>
</Head>
);
}
================================================
FILE: src/components/feed/tweet-feed.tsx
================================================
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import { useAuth } from '../../lib/context/auth-context';
import {
PaginatedTweetsResponse,
TweetsResponse
} from '../../lib/paginated-tweets';
import { FeedOrderingType } from '../../lib/types/feed';
import { populateTweetUsers } from '../../lib/types/tweet';
import { isPlural } from '../../lib/utils';
import { LoadMoreSentinel } from '../common/load-more';
import { Tweet } from '../tweet/tweet';
import { Error } from '../ui/error';
import { Loading } from '../ui/loading';
interface TweetFeedProps {
feedOrdering: FeedOrderingType;
apiEndpoint: string;
}
export function TweetFeed({ feedOrdering, apiEndpoint }: TweetFeedProps) {
const { user, timelineCursor, setTimelineCursor } = useAuth();
const {
data: pages,
size,
setSize,
isValidating: loading,
error
} = useSWRInfinite<PaginatedTweetsResponse>(
(pageIndex, prevPage) => {
if (!user || !timelineCursor) return null;
if (prevPage && !prevPage.result?.nextPageCursor) return null;
const baseUrl = `${apiEndpoint}&limit=10&full=true&cursor=${timelineCursor.toISOString()}&ordering=${feedOrdering}`;
if (pageIndex === 0) {
return `${baseUrl}&skip=0`;
}
if (!prevPage?.result) return null;
return `${baseUrl}&skip=${prevPage.result.nextPageCursor}`;
},
{
revalidateOnFocus: false,
revalidateFirstPage: false
}
);
const hasMore = !!pages?.[size - 1]?.result?.tweets.length;
// Fetch new tweets every 20 seconds
const { data: newPage } = useSWR<TweetsResponse>(
!!pages && timelineCursor && feedOrdering === 'latest'
? `${apiEndpoint}&cursor=${timelineCursor.toISOString()}&limit=100&after=true`
: null,
null,
{ refreshInterval: 10_000 }
);
const onShowNewTweets = () => {
if (!newPage?.result?.tweets) return;
const cursor = new Date();
setTimelineCursor(cursor);
};
return (
<section className='mt-0.5 xs:mt-0'>
<>
{newPage?.result?.tweets && (newPage.result.tweets.length || 0) > 0 && (
<button
className='custom-button accent-tab hover-card border-bottom block w-full cursor-pointer rounded-none
border-b border-t-0 border-light-border text-center text-main-accent dark:border-dark-border'
onClick={onShowNewTweets}
>
Show {newPage.result.tweets.length} new cast
{isPlural(newPage.result.tweets.length) ? 's' : ''}
</button>
)}
{pages?.map(({ result }) => {
if (!result) return;
const { tweets, users } = result;
return tweets.map((tweet) => {
if (!users[tweet.createdBy]) {
return <></>;
}
return (
<Tweet
{...populateTweetUsers(tweet, users)}
user={users[tweet.createdBy]}
key={tweet.id}
usersMap={users}
/>
);
});
})}
{hasMore && (
<LoadMoreSentinel
loadMore={() => {
setSize(size + 1);
}}
isLoading={loading}
></LoadMoreSentinel>
)}
</>
{loading ? (
<Loading className='mt-5' />
) : (
!pages && <Error message='Something went wrong' />
)}
</section>
);
}
================================================
FILE: src/components/frames/Frame.tsx
================================================
'use client';
import {
FarcasterFrameContext,
FarcasterSigner,
signFrameAction
} from '@frames.js/render/farcaster';
import { useFrame } from '@frames.js/render/use-frame';
import { useAuth } from '@lib/context/auth-context';
import { Frame as FrameType } from 'frames.js';
import { useEffect, useState } from 'react';
import * as chains from 'viem/chains';
import {
useAccount,
useChainId,
useSendTransaction,
useSwitchChain
} from 'wagmi';
import { FrameUI } from './frame-ui';
type FrameProps = {
url: string;
frame: FrameType;
frameContext: FarcasterFrameContext;
};
const getChainFromId = (id: number): chains.Chain | undefined => {
return Object.values(chains).find((chain) => chain.id === id);
};
export function Frame({ frame, frameContext, url }: FrameProps) {
const { user } = useAuth();
const { address: connectedAddress } = useAccount();
const [farcasterSigner, setFarcasterSigner] = useState<
FarcasterSigner | undefined
>(undefined);
const { sendTransactionAsync, sendTransaction } = useSendTransaction();
const currentChainId = useChainId();
const { switchChainAsync } = useSwitchChain();
useEffect(() => {
if (user?.keyPair) {
setFarcasterSigner({
fid: parseInt(user.id),
privateKey: user.keyPair.privateKey,
status: 'approved',
publicKey: user.keyPair.publicKey
});
} else {
setFarcasterSigner(undefined);
}
}, [user]);
const frameState = useFrame({
homeframeUrl: url,
frame,
frameActionProxy: '/frames',
connectedAddress,
frameGetProxy: '/frames',
frameContext,
signerState: {
hasSigner: farcasterSigner !== undefined,
signer: farcasterSigner,
onSignerlessFramePress: () => {
// Only run if `hasSigner` is set to `false`
// This is a good place to throw an error or prompt the user to login
// alert("A frame button was pressed without a signer. Perhaps you want to prompt a login");
},
signFrameAction: signFrameAction
},
onTransaction: async ({ transactionData }) => {
// Switch to the chain that the transaction is on
const chainId = parseInt(transactionData.chainId.split(':')[1]);
if (chainId !== currentChainId) {
const newChain = await switchChainAsync?.({ chainId });
if (!newChain) {
console.error('Failed to switch network');
return null;
}
}
const hash = await sendTransactionAsync({
...transactionData.params,
value: transactionData.params.value
? BigInt(transactionData.params.value)
: undefined,
chainId: parseInt(transactionData.chainId.split(':')[1])
});
return hash || null;
}
});
return (
<div className='w-full overflow-hidden'>
<FrameUI frameState={frameState} />
</div>
);
}
================================================
FILE: src/components/frames/frame-ui.tsx
================================================
import { FrameUI as BaseFrameUI } from '@frames.js/render/ui';
import Image from 'next/image';
import React from 'react';
import cn from 'clsx';
import { HeroIcon } from '../ui/hero-icon';
type Props = React.ComponentProps<
typeof BaseFrameUI<{ className?: string; style?: React.CSSProperties }>
>;
const components: Props['components'] = {
Button(
{ frameButton, isDisabled, onPress, index, frameState },
stylingProps
) {
return (
<button
{...stylingProps}
key={index}
className={cn(
'flex-1 rounded border p-2 text-sm text-light-primary hover:bg-gray-100 hover:text-light-primary dark:border-dark-border dark:text-white dark:hover:bg-gray-100/10 dark:hover:text-dark-primary',
(frameState.status === 'loading' || frameState.isImageLoading) &&
'cursor-default bg-gray-100',
stylingProps.className
)}
disabled={isDisabled}
onClick={onPress}
type='button'
>
{frameButton.action === 'mint' ? `⬗ ` : ''}
{frameButton.label}
{frameButton.action === 'tx' ? (
<HeroIcon
iconName='BoltIcon'
className='align-text-middle mb-[2px] ml-1 inline-block h-4 w-4 select-none overflow-visible text-gray-400'
></HeroIcon>
) : (
''
)}
{frameButton.action === 'post_redirect' || frameButton.action === 'link'
? ` ↗`
: ''}
</button>
);
},
MessageTooltip(props, stylingProps) {
return (
<div
{...stylingProps}
className={cn(
'absolute inset-x-2 bottom-2 rounded-sm border border-slate-100 shadow-md',
'flex items-center gap-2 p-2 text-sm',
props.status === 'error' && 'text-red-500',
stylingProps.className
)}
>
{props.status === 'error' ? (
<HeroIcon iconName='ExclamationCircleIcon' className='text-red-500' />
) : (
<HeroIcon
iconName='InformationCircleIcon'
className='text-gray-500'
/>
)}
{props.message}
</div>
);
},
LoadingScreen(props, stylingProps) {
return (
<div {...stylingProps}>
<div className='h-full w-full animate-pulse bg-gray-100 dark:bg-light-secondary'></div>
</div>
);
},
Image(props, stylingProps) {
if (props.status === 'frame-loading') {
return <div />;
}
return (
<Image
{...stylingProps}
src={props.src}
onLoad={props.onImageLoadEnd}
onError={props.onImageLoadEnd}
alt='Frame image'
sizes='100vw'
height={0}
width={0}
/>
);
}
};
const theme: Props['theme'] = {
ButtonsContainer: {
className: 'flex gap-[8px] px-2 pb-2'
},
Root: {
className:
'flex flex-col w-full gap-2 border rounded-lg overflow-hidden relative'
},
Error: {
className:
'flex text-red-500 text-sm p-2 border border-red-500 rounded-md shadow-md aspect-square justify-center items-center'
},
LoadingScreen: {
className:
'absolute top-0 left-0 right-0 bottom-0 z-10 bg-white dark:bg-light-primary'
},
Image: {
className: 'w-full object-cover max-h-full'
},
ImageContainer: {
className: 'relative w-full h-full border-b border-gray-300 overflow-hidden'
},
TextInput: {
className: 'p-[6px] border rounded border-gray-300 box-border w-full'
},
TextInputContainer: {
className: 'w-full px-2'
}
};
export function FrameUI(props: Props) {
return <BaseFrameUI {...props} components={components} theme={theme} />;
}
================================================
FILE: src/components/home/main-container.tsx
================================================
import cn from 'clsx';
import type { ReactNode } from 'react';
type MainContainerProps = {
children: ReactNode;
className?: string;
};
export function MainContainer({
children,
className
}: MainContainerProps): JSX.Element {
return (
<main
className={cn(
`hover-animation flex min-h-screen w-full max-w-xl flex-col border-x-0
border-light-border pb-96 dark:border-dark-border xs:border-x`,
className
)}
>
{children}
</main>
);
}
================================================
FILE: src/components/home/main-header.tsx
================================================
import cn from 'clsx';
import { Button } from '@components/ui/button';
import { HeroIcon } from '@components/ui/hero-icon';
import { ToolTip } from '@components/ui/tooltip';
import { MobileSidebar } from '@components/sidebar/mobile-sidebar';
import type { ReactNode } from 'react';
import type { IconName } from '@components/ui/hero-icon';
import { NextImage } from '../ui/next-image';
type HomeHeaderProps = {
tip?: string;
title?: string;
description?: string;
children?: ReactNode;
imageUrl?: string;
iconName?: IconName;
className?: string;
disableSticky?: boolean;
useActionButton?: boolean;
useMobileSidebar?: boolean;
action?: () => void;
};
export function MainHeader({
tip,
title,
description,
children,
imageUrl,
iconName,
className,
disableSticky,
useActionButton,
useMobileSidebar,
action
}: HomeHeaderProps): JSX.Element {
return (
<header
className={cn(
'hover-animation even z-10 bg-main-background/60 px-4 py-2 backdrop-blur-md',
!disableSticky && 'sticky top-0',
className ?? 'flex items-center gap-6'
)}
>
{useActionButton && (
<Button
className='dark-bg-tab group relative p-2 hover:bg-light-primary/10 active:bg-light-primary/20
dark:hover:bg-dark-primary/10 dark:active:bg-dark-primary/20'
onClick={action}
>
<HeroIcon
className='h-5 w-5'
iconName={iconName ?? 'ArrowLeftIcon'}
/>
<ToolTip tip={tip ?? 'Back'} />
</Button>
)}
{title && (
<div className='flex items-center'>
{imageUrl && (
<span className='mr-2 inline flex-shrink-0 flex-grow-0 overflow-hidden rounded-md'>
<NextImage
src={imageUrl}
alt={title || 'image'}
objectFit='contain'
width={48}
height={48}
></NextImage>
</span>
)}
<div className='flex flex-col'>
{useMobileSidebar && <MobileSidebar />}
<h2 className='text-xl font-bold' key={title}>
{title}
</h2>
{description && (
<p
className='text-light-secondary dark:text-dark-secondary'
key={description}
>
{description}
</p>
)}
</div>
</div>
)}
{children}
</header>
);
}
================================================
FILE: src/components/home/update-username.tsx.bak
================================================
/* eslint-disable react-hooks/exhaustive-deps */
import { useState, useEffect } from 'react';
import { toast } from 'react-hot-toast';
import { checkUsernameAvailability, updateUsername } from '@lib/firebase/utils';
import { useAuth } from '@lib/context/auth-context';
import { useModal } from '@lib/hooks/useModal';
import { isValidUsername } from '@lib/validation';
import { sleep } from '@lib/utils';
import { Button } from '@components/ui/button';
import { HeroIcon } from '@components/ui/hero-icon';
import { ToolTip } from '@components/ui/tooltip';
import { Modal } from '@components/modal/modal';
import { UsernameModal } from '@components/modal/username-modal';
import { InputField } from '@components/input/input-field';
import type { FormEvent, ChangeEvent } from 'react';
export function UpdateUsername(): JSX.Element {
const [alreadySet, setAlreadySet] = useState(false);
const [available, setAvailable] = useState(false);
const [loading, setLoading] = useState(false);
const [visited, setVisited] = useState(false);
const [inputValue, setInputValue] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const { user } = useAuth();
const { open, openModal, closeModal } = useModal();
useEffect(() => {
const checkAvailability = async (value: string): Promise<void> => {
const empty = await checkUsernameAvailability(value);
if (empty) setAvailable(true);
else {
setAvailable(false);
setErrorMessage('This username has been taken. Please choose another.');
}
};
if (!visited && inputValue.length > 0) setVisited(true);
if (visited) {
if (errorMessage) setErrorMessage('');
const error = isValidUsername(user?.username as string, inputValue);
if (error) {
setAvailable(false);
setErrorMessage(error);
} else void checkAvailability(inputValue);
}
}, [inputValue]);
useEffect(() => {
if (!user?.updatedAt) openModal();
else setAlreadySet(true);
}, []);
const changeUsername = async (
e: FormEvent<HTMLFormElement>
): Promise<void> => {
e.preventDefault();
if (!available) return;
setLoading(true);
await sleep(500);
await updateUsername(user?.id as string, inputValue);
closeModal();
setLoading(false);
setInputValue('');
setVisited(false);
setAvailable(false);
toast.success('Username updated successfully');
};
const cancelUpdateUsername = (): void => {
closeModal();
if (!alreadySet) void updateUsername(user?.id as string);
};
const handleChange = ({
target: { value }
}: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void =>
setInputValue(value);
return (
<>
<Modal
modalClassName='flex flex-col gap-6 max-w-xl bg-main-background w-full p-8 rounded-2xl h-[576px]'
open={open}
closeModal={cancelUpdateUsername}
>
<UsernameModal
loading={loading}
available={available}
alreadySet={alreadySet}
changeUsername={changeUsername}
cancelUpdateUsername={cancelUpdateUsername}
>
<InputField
label='Username'
inputId='username'
inputValue={inputValue}
errorMessage={errorMessage}
handleChange={handleChange}
/>
</UsernameModal>
</Modal>
<Button
className='dark-bg-tab group relative p-2 hover:bg-light-primary/10
active:bg-light-primary/20 dark:hover:bg-dark-primary/10
dark:active:bg-dark-primary/20'
onClick={openModal}
>
<HeroIcon className='h-5 w-5' iconName='SparklesIcon' />
<ToolTip tip='Top tweets' />
</Button>
</>
);
}
================================================
FILE: src/components/input/image-preview.tsx
================================================
import { useEffect, useState } from 'react';
import cn from 'clsx';
import { useModal } from '@lib/hooks/useModal';
import { preventBubbling } from '@lib/utils';
import { ImageModal } from '@components/modal/image-modal';
import { Modal } from '@components/modal/modal';
import { NextImage } from '@components/ui/next-image';
import { Button } from '@components/ui/button';
import { HeroIcon } from '@components/ui/hero-icon';
import { ToolTip } from '@components/ui/tooltip';
import type { MotionProps } from 'framer-motion';
import type { ImagesPreview, ImageData } from '@lib/types/file';
type ImagePreviewProps = {
tweet?: boolean;
viewTweet?: boolean;
previewCount: number;
imagesPreview: ImagesPreview;
removeImage?: (targetId: string) => () => void;
};
const variants: MotionProps = {
initial: { opacity: 0, scale: 0.5 },
animate: {
opacity: 1,
scale: 1,
transition: { duration: 0.3 }
},
exit: { opacity: 0, scale: 0.5 },
transition: { type: 'spring', duration: 0.5 }
};
type PostImageBorderRadius = Record<number, string[]>;
const postImageBorderRadius: Readonly<PostImageBorderRadius> = {
1: ['rounded-2xl'],
2: ['rounded-tl-2xl rounded-bl-2xl', 'rounded-tr-2xl rounded-br-2xl'],
3: ['rounded-tl-2xl rounded-bl-2xl', 'rounded-tr-2xl', 'rounded-br-2xl'],
4: ['rounded-tl-2xl', 'rounded-tr-2xl', 'rounded-bl-2xl', 'rounded-br-2xl']
};
export function ImagePreview({
tweet,
viewTweet,
previewCount,
imagesPreview,
removeImage
}: ImagePreviewProps): JSX.Element {
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedImage, setSelectedImage] = useState<ImageData | null>(null);
const { open, openModal, closeModal } = useModal();
useEffect(() => {
const imageData = imagesPreview[selectedIndex];
setSelectedImage(imageData);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedIndex]);
const handleSelectedImage = (index: number) => () => {
setSelectedIndex(index);
openModal();
};
const handleNextIndex = (type: 'prev' | 'next') => () => {
const nextIndex =
type === 'prev'
? selectedIndex === 0
? previewCount - 1
: selectedIndex - 1
: selectedIndex === previewCount - 1
? 0
: selectedIndex + 1;
setSelectedIndex(nextIndex);
};
const isTweet = tweet ?? viewTweet;
return (
<div
className={cn(
'grid grid-cols-2 grid-rows-2 rounded-2xl',
viewTweet
? 'h-[51vw] xs:h-[42vw] md:h-[305px]'
: 'h-[42vw] xs:h-[37vw] md:h-[271px]',
isTweet ? 'mt-2 gap-0.5' : 'gap-3'
)}
>
<Modal
modalClassName={cn(
'flex justify-center w-full items-center relative',
isTweet && 'h-full'
)}
open={open}
closeModal={closeModal}
closePanelOnClick
>
<ImageModal
tweet={isTweet}
imageData={selectedImage as ImageData}
previewCount={previewCount}
selectedIndex={selectedIndex}
handleNextIndex={handleNextIndex}
/>
</Modal>
{imagesPreview.map(({ id, src, alt }, index) => (
<button
className={cn(
'accent-tab relative transition-shadow',
isTweet
? postImageBorderRadius[previewCount][index]
: 'rounded-2xl',
{
'col-span-2 row-span-2': previewCount === 1,
'row-span-2':
previewCount === 2 || (index === 0 && previewCount === 3)
}
)}
onClick={preventBubbling(handleSelectedImage(index))}
key={id}
>
<div
className='flex h-full w-full cursor-pointer
justify-center transition hover:brightness-75 hover:duration-200'
>
<img className={cn('rounded-2xl object-cover')} src={src} alt={alt} />
</div>
{removeImage && (
<Button
className='group absolute left-0 top-0 translate-x-1 translate-y-1
bg-light-primary/75 p-1 backdrop-blur-sm
hover:bg-image-preview-hover/75'
onClick={preventBubbling(removeImage(id))}
>
<HeroIcon className='h-5 w-5 text-white' iconName='XMarkIcon' />
<ToolTip className='translate-y-2' tip='Remove' />
</Button>
)}
</button>
))}
</div>
);
}
================================================
FILE: src/components/input/input-accent-radio.tsx
================================================
import cn from 'clsx';
import { useTheme } from '@lib/context/theme-context';
import { HeroIcon } from '@components/ui/hero-icon';
import type { Accent } from '@lib/types/theme';
type InputAccentRadioProps = {
type: Accent;
};
type InputAccentData = Record<Accent, string>;
const InputColors: Readonly<InputAccentData> = {
yellow:
'bg-accent-yellow hover:ring-accent-yellow/10 active:ring-accent-yellow/20',
blue: 'bg-accent-blue hover:ring-accent-blue/10 active:ring-accent-blue/20',
pink: 'bg-accent-pink hover:ring-accent-pink/10 active:ring-accent-pink/20',
purple:
'bg-accent-purple hover:ring-accent-purple/10 active:ring-accent-purple/20',
orange:
'bg-accent-orange hover:ring-accent-orange/10 active:ring-accent-orange/20',
green:
'bg-accent-green hover:ring-accent-green/10 active:ring-accent-green/20'
};
export function InputAccentRadio({ type }: InputAccentRadioProps): JSX.Element {
const { accent, changeAccent } = useTheme();
const bgColor = InputColors[type];
const isChecked = type === accent;
return (
<label
className={cn(
`hover-animation flex h-10 w-10 cursor-pointer items-center justify-center
rounded-full hover:ring`,
bgColor
)}
htmlFor={type}
>
<input
className='peer absolute h-0 w-0 opacity-0'
id={type}
type='radio'
name='accent'
value={type}
checked={isChecked}
onChange={changeAccent}
/>
<i className='text-white peer-checked:inner:opacity-100'>
<HeroIcon
className='h-6 w-6 opacity-0 transition-opacity duration-200'
iconName='CheckIcon'
/>
</i>
</label>
);
}
================================================
FILE: src/components/input/input-field.tsx
================================================
import cn from 'clsx';
import type { User, EditableData } from '@lib/types/user';
import type { KeyboardEvent, ChangeEvent } from 'react';
export type InputFieldProps = {
label: string;
inputId: EditableData | Extract<keyof User, 'username'>;
inputValue: string | null;
inputLimit?: number;
useTextArea?: boolean;
errorMessage?: string;
handleChange: (
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => void;
handleKeyboardShortcut?: ({
key,
ctrlKey
}: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
};
export function InputField({
label,
inputId,
inputValue,
inputLimit,
useTextArea,
errorMessage,
handleChange,
handleKeyboardShortcut
}: InputFieldProps): JSX.Element {
const slicedInputValue = inputValue?.slice(0, inputLimit) ?? '';
const inputLength = slicedInputValue.length;
const isHittingInputLimit = inputLimit && inputLength > inputLimit;
return (
<div className='flex flex-col gap-1'>
<div
className={cn(
'relative rounded ring-1 transition-shadow duration-200',
errorMessage
? 'ring-accent-red'
: `ring-light-line-reply focus-within:ring-2
focus-within:!ring-main-accent dark:ring-dark-border`
)}
>
{useTextArea ? (
<textarea
className='peer mt-6 w-full resize-none bg-inherit px-3 pb-1
placeholder-transparent outline-none transition'
id={inputId}
placeholder={inputId}
onChange={!isHittingInputLimit ? handleChange : undefined}
onKeyUp={handleKeyboardShortcut}
value={slicedInputValue}
rows={3}
/>
) : (
<input
className='peer mt-6 w-full bg-inherit px-3 pb-1
placeholder-transparent outline-none transition'
id={inputId}
type='text'
placeholder={inputId}
onChange={!isHittingInputLimit ? handleChange : undefined}
value={slicedInputValue}
onKeyUp={handleKeyboardShortcut}
/>
)}
<label
className={cn(
`group-peer absolute left-3 translate-y-1 bg-main-background text-sm
text-light-secondary transition-all peer-placeholder-shown:translate-y-3
peer-placeholder-shown:text-lg peer-focus:translate-y-1 peer-focus:text-sm
dark:text-dark-secondary`,
errorMessage
? '!text-accent-red peer-focus:text-accent-red'
: 'peer-focus:text-main-accent'
)}
htmlFor={inputId}
>
{label}
</label>
{inputLimit && (
<span
className={cn(
`absolute right-3 top-0 translate-y-1 text-sm text-light-secondary transition-opacity
duration-200 peer-focus:visible peer-focus:opacity-100 dark:text-dark-secondary`,
errorMessage ? 'visible opacity-100' : 'invisible opacity-0'
)}
>
{inputLength} / {inputLimit}
</span>
)}
</div>
{errorMessage && (
<p className='text-sm text-accent-red'>{errorMessage}</p>
)}
</div>
);
}
================================================
FILE: src/components/input/input-form.tsx
================================================
import { useEffect } from 'react';
import TextArea from 'react-textarea-autosize';
import { motion } from 'framer-motion';
import { useModal } from '@lib/hooks/useModal';
import { Modal } from '@components/modal/modal';
import { ActionModal } from '@components/modal/action-modal';
import { HeroIcon } from '@components/ui/hero-icon';
import { Button } from '@components/ui/button';
import type {
ReactNode,
RefObject,
ChangeEvent,
KeyboardEvent,
ClipboardEvent
} from 'react';
import type { Variants } from 'framer-motion';
type InputFormProps = {
modal?: boolean;
formId: string;
loading: boolean;
visited: boolean;
reply?: boolean;
children: ReactNode;
inputRef: RefObject<HTMLTextAreaElement>;
inputValue: string;
replyModal?: boolean;
isValidTweet: boolean;
isUploadingImages: boolean;
sendTweet: () => Promise<void>;
handleFocus: () => void;
discardTweet: () => void;
handleChange: ({
target: { value }
}: ChangeEvent<HTMLTextAreaElement>) => void;
handleImageUpload: (
e: ChangeEvent<HTMLInputElement> | ClipboardEvent<HTMLTextAreaElement>
) => void;
};
const variants: Variants[] = [
{
initial: { y: -25, opacity: 0 },
animate: { y: 0, opacity: 1, transition: { type: 'spring' } }
},
{
initial: { x: 25, opacity: 0 },
animate: { x: 0, opacity: 1, transition: { type: 'spring' } }
}
];
export const [fromTop, fromBottom] = variants;
export function InputForm({
modal,
reply,
formId,
loading,
visited,
children,
inputRef,
replyModal,
inputValue,
isValidTweet,
isUploadingImages,
sendTweet,
handleFocus,
discardTweet,
handleChange,
handleImageUpload
}: InputFormProps): JSX.Element {
const { open, openModal, closeModal } = useModal();
useEffect(() => handleShowHideNav(true), []);
const handleKeyboardShortcut = ({
key,
ctrlKey
}: KeyboardEvent<HTMLTextAreaElement>): void => {
if (!modal && key === 'Escape')
if (isValidTweet) {
inputRef.current?.blur();
openModal();
} else discardTweet();
else if (ctrlKey && key === 'Enter' && isValidTweet) void sendTweet();
};
const handleShowHideNav = (blur?: boolean) => (): void => {
const sidebar = document.getElementById('sidebar') as HTMLElement;
if (!sidebar) return;
if (blur) {
setTimeout(() => (sidebar.style.opacity = ''), 200);
return;
}
if (window.innerWidth < 500) sidebar.style.opacity = '0';
};
const handleFormFocus = (): void => {
handleShowHideNav()();
handleFocus();
};
const handleClose = (): void => {
discardTweet();
closeModal();
};
// const isVisibilityShown = visited && !reply && !replyModal && !loading;
return (
<div className='flex min-h-[48px] w-full flex-col justify-center gap-4'>
<Modal
modalClassName='max-w-xs bg-main-background w-full p-8 rounded-2xl'
open={open}
closeModal={closeModal}
>
<ActionModal
title='Discard Tweet?'
description='This can’t be undone and you’ll lose your draft.'
mainBtnClassName='bg-accent-red hover:bg-accent-red/90 active:bg-accent-red/75'
mainBtnLabel='Discard'
action={handleClose}
closeModal={closeModal}
/>
</Modal>
<div className='flex flex-col gap-6'>
{/* {isVisibilityShown && (
<motion.button
type='button'
className='custom-button accent-tab accent-bg-tab flex cursor-not-allowed items-center gap-1
self-start border border-light-line-reply px-3 py-0 text-main-accent
hover:bg-main-accent/10 active:bg-main-accent/20 dark:border-light-secondary'
{...fromTop}
>
<p className='font-bold'>Everyone</p>
<HeroIcon className='h-4 w-4' iconName='ChevronDownIcon' />
</motion.button>
)} */}
<div className='flex items-center gap-3'>
<TextArea
id={formId}
className='w-full min-w-0 resize-none bg-transparent text-xl outline-none
placeholder:text-light-secondary dark:placeholder:text-dark-secondary'
value={inputValue}
placeholder={
reply || replyModal ? 'Cast your reply' : "What's happening?"
}
onBlur={handleShowHideNav(true)}
minRows={loading ? 1 : modal && !isUploadingImages ? 3 : 1}
maxRows={isUploadingImages ? 5 : 15}
onFocus={handleFormFocus}
onPaste={handleImageUpload}
onKeyUp={handleKeyboardShortcut}
onChange={handleChange}
ref={inputRef}
/>
{reply && !visited && (
<Button
className='cursor-pointer bg-main-accent px-4 py-1.5 font-bold text-white opacity-50'
onClick={handleFocus}
>
Reply
</Button>
)}
</div>
</div>
{children}
{/* {isVisibilityShown && (
<motion.div
className='flex border-b border-light-border pb-2 dark:border-dark-border'
{...fromBottom}
>
<button
type='button'
className='custom-button accent-tab accent-bg-tab flex cursor-not-allowed items-center gap-1 px-3
py-0 text-main-accent hover:bg-main-accent/10 active:bg-main-accent/20'
>
<HeroIcon className='h-4 w-4' iconName='GlobeAmericasIcon' />
<p className='font-bold'>Everyone can reply</p>
</button>
</motion.div>
)} */}
</div>
);
}
================================================
FILE: src/components/input/input-options.tsx
================================================
import { useRef } from 'react';
import { motion } from 'framer-motion';
import { Button } from '@components/ui/button';
import { HeroIcon } from '@components/ui/hero-icon';
import { ToolTip } from '@components/ui/tooltip';
import { variants } from './input';
import { ProgressBar } from './progress-bar';
import type { ChangeEvent, ClipboardEvent } from 'react';
import type { IconName } from '@components/ui/hero-icon';
type Options = {
name: string;
iconName: IconName;
disabled: boolean;
onClick?: () => void;
}[];
type InputOptionsProps = {
reply?: boolean;
modal?: boolean;
inputLimit: number;
inputLength: number;
isValidTweet: boolean;
isCharLimitExceeded: boolean;
handleImageUpload: (
e: ChangeEvent<HTMLInputElement> | ClipboardEvent<HTMLTextAreaElement>
) => void;
options: Readonly<Options>;
};
export function InputOptions({
reply,
modal,
inputLimit,
inputLength,
isValidTweet,
isCharLimitExceeded,
handleImageUpload,
options
}: InputOptionsProps): JSX.Element {
const inputFileRef = useRef<HTMLInputElement>(null);
const imgOnClick = (): void => inputFileRef.current?.click();
let filteredOptions = options;
return (
<motion.div className='flex justify-between' {...variants}>
<div
className='flex text-main-accent [&>button:nth-child(n+4)]:hidden
xs:[&>button:nth-child(n+6)]:hidden md:[&>button]:!block'
>
<input
className='hidden'
type='file'
accept='image/*'
onChange={handleImageUpload}
ref={inputFileRef}
multiple
/>
{filteredOptions.map(({ name, iconName, disabled, onClick }, index) => (
<Button
className='accent-tab accent-bg-tab group relative rounded-full p-2
hover:bg-main-accent/10 active:bg-main-accent/20'
onClick={onClick ? onClick : imgOnClick}
disabled={disabled}
key={name}
>
<HeroIcon className='h-5 w-5' iconName={iconName} />
<ToolTip tip={name} modal={modal} />
</Button>
))}
</div>
<div className='flex items-center gap-4'>
<motion.div
className='flex items-center gap-4'
animate={
inputLength ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0 }
}
>
<ProgressBar
modal={modal}
inputLimit={inputLimit}
inputLength={inputLength}
isCharLimitExceeded={isCharLimitExceeded}
/>
{!reply && (
<>
<i className='hidden h-8 w-[1px] bg-[#B9CAD3] dark:bg-[#3E4144] xs:block' />
<Button
className='group relative hidden rounded-full border border-light-line-reply p-[1px]
text-main-accent dark:border-light-secondary xs:block'
disabled
>
<HeroIcon className='h-5 w-5' iconName='PlusIcon' />
<ToolTip tip='Add' modal={modal} />
</Button>
</>
)}
</motion.div>
<Button
type='submit'
className='accent-tab bg-main-accent px-4 py-1.5 font-bold text-white
enabled:hover:bg-main-accent/90
enabled:active:bg-main-accent/75'
disabled={!isValidTweet}
>
{reply ? 'Reply' : 'Cast'}
</Button>
</div>
</motion.div>
);
}
================================================
FILE: src/components/input/input-theme-radio.tsx
================================================
import cn from 'clsx';
import { useTheme } from '@lib/context/theme-context';
import { HeroIcon } from '@components/ui/hero-icon';
import type { Theme } from '@lib/types/theme';
type InputThemeRadioProps = {
type: Theme;
label: string;
};
type InputThemeData = Record<
Theme,
{
textColor: string;
backgroundColor: string;
iconBorderColor: string;
hoverBackgroundColor: string;
}
>;
const inputThemeData: Readonly<InputThemeData> = {
light: {
textColor: 'text-black',
backgroundColor: 'bg-white',
iconBorderColor: 'border-[#B9CAD3]',
hoverBackgroundColor:
'[&:hover>div]:bg-light-secondary/10 [&:active>div]:bg-light-secondary/20'
},
dim: {
textColor: 'text-[#F7F9F9]',
backgroundColor: 'bg-[#15202B]',
iconBorderColor: 'border-[#5C6E7E]',
hoverBackgroundColor:
'[&:hover>div]:bg-light-secondary/10 [&:active>div]:bg-light-secondary/20'
},
dark: {
textColor: 'text-dark-primary',
backgroundColor: 'bg-black',
iconBorderColor: 'border-[#3E4144]',
hoverBackgroundColor:
'[&:hover>div]:bg-dark-primary/10 [&:active>div]:bg-dark-primary/20'
}
};
export function InputThemeRadio({
type,
label
}: InputThemeRadioProps): JSX.Element {
const { theme, changeTheme } = useTheme();
const { textColor, backgroundColor, iconBorderColor, hoverBackgroundColor } =
inputThemeData[type];
const isChecked = type == theme;
return (
<label
className={cn(
`flex cursor-pointer items-center gap-2 rounded p-3 font-bold ring-main-accent transition
duration-200 [&:has(div>input:checked)]:ring-2`,
textColor,
backgroundColor,
hoverBackgroundColor
)}
htmlFor={type}
>
<div className='hover-animation flex h-10 w-10 items-center justify-center rounded-full'>
<input
className='peer absolute h-0 w-0 opacity-0'
id={type}
type='radio'
name='theme'
value={type}
checked={isChecked}
onChange={changeTheme}
/>
<i
className={cn(
`flex h-5 w-5 items-center justify-center rounded-full
border-2 border-[#B9CAD3] text-white transition
duration-200 peer-checked:border-transparent
peer-checked:bg-main-accent peer-checked:inner:opacity-100`,
iconBorderColor
)}
>
<HeroIcon
className='h-full w-full p-0.5 opacity-0 transition-opacity duration-200'
iconName='CheckIcon'
/>
</i>
</div>
{label}
</label>
);
}
================================================
FILE: src/components/input/input.tsx
================================================
import { UserAvatar } from '@components/user/user-avatar';
import { Message } from '@farcaster/hub-web';
import { useAuth } from '@lib/context/auth-context';
import type { FilesWithId, ImageData, ImagesPreview } from '@lib/types/file';
import type { User, UsersMapType } from '@lib/types/user';
import { getHttpsUrls, sleep } from '@lib/utils';
import { getImagesData } from '@lib/validation';
import cn from 'clsx';
import type { Variants } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { debounce } from 'lodash';
import Link from 'next/link';
import type { ChangeEvent, ClipboardEvent, FormEvent, ReactNode } from 'react';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { toast } from 'react-hot-toast';
import useSWR from 'swr';
import { createCastMessage, submitHubMessage } from '../../lib/farcaster/utils';
import { fetchJSON } from '../../lib/fetch';
import { uploadToImgur } from '../../lib/imgur/upload';
import { BaseResponse } from '../../lib/types/responses';
import { TopicResponse, TopicType } from '../../lib/types/topic';
import { ExternalEmbed } from '../../lib/types/tweet';
import { SearchTopics } from '../search/search-topics';
import { UserSearchResult } from '../search/user-search-result';
import { TweetEmbed } from '../tweet/tweet-embed';
import { TopicView, TweetTopicSkeleton } from '../tweet/tweet-topic';
import { Loading } from '../ui/loading';
import { ImagePreview } from './image-preview';
import { InputForm, fromTop } from './input-form';
import { InputOptions } from './input-options';
type InputProps = {
modal?: boolean;
reply?: boolean;
parent?: { id: string; username: string; userId: string };
disabled?: boolean;
children?: ReactNode;
replyModal?: boolean;
parentUrl?: string;
closeModal?: () => void;
};
export const variants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1 }
};
// TODO: Generalize this and move it somewhere else
function extractAndReplaceMentions(
input: string,
usersMap: { [key: string]: number }
) {
let result = '';
let mentions: number[] = [];
let mentionsPositions: number[] = [];
// Split on newlines and spaces, preserving delimiters
let splits = input.split(/(\s|\n)/);
splits.forEach((split, i) => {
if (split.startsWith('@')) {
const username = split.slice(1);
// Check if user is in the usersMap
if (username in usersMap) {
// Get the starting position of each username mention
const position = Buffer.from(result).length;
mentions.push(usersMap[username]);
mentionsPositions.push(position);
// result += '@[...]'; // replace username mention with what you would like
} else {
result += split;
}
} else {
result += split;
}
});
// Return object with replaced text and user mentions array
return {
text: result,
mentions,
mentionsPositions
};
}
export function Input({
modal,
reply,
parent,
disabled,
children,
replyModal,
parentUrl,
closeModal
}: InputProps): JSX.Element {
const [selectedImages, setSelectedImages] = useState<FilesWithId>([]);
const [imagesPreview, setImagesPreview] = useState<ImagesPreview>([]);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const [visited, setVisited] = useState(false);
const [embedUrls, setEmbedUrls] = useState<string[]>([]); // URLs to be fetched
const [embeds, setEmbeds] = useState<ExternalEmbed[]>([]); // Fetched embeds
const [ignoredEmbedUrls, setIgnoredEmbedUrls] = useState<string[]>([]); // URLs of embeds to be ignored in the cast message
const [topicUrl, setTopicUrl] = useState(parentUrl);
const [showingTopicSelector, setShowingTopicSelector] = useState(false);
const [topic, setTopic] = useState<TopicType | null>();
const { data: topicResult, isValidating: loadingTopic } = useSWR(
topicUrl ? `/api/topic?url=${encodeURIComponent(topicUrl)}` : null,
async (url) => {
const res = await fetchJSON<TopicResponse>(url);
return res.result;
},
{ revalidateOnFocus: false }
);
useEffect(() => {
if (topicUrl !== parentUrl) {
setTopicUrl(parentUrl);
}
}, [parentUrl]);
useEffect(() => {
if (topicUrl === topic?.url || topic === undefined) return;
setTopicUrl(topic?.url);
}, [topic]);
useEffect(() => {
if (topicResult && topic?.url !== topicResult.url) {
setTopic(topicResult);
}
}, [topicResult]);
const { user, isAdmin } = useAuth();
const { name, username, photoURL } = user as User;
const inputRef = useRef<HTMLTextAreaElement>(null);
const previewCount = imagesPreview.length;
const isUploadingImages = !!previewCount;
useEffect(
() => {
if (modal) inputRef.current?.focus();
return cleanImage;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const sendTweet = async (): Promise<void> => {
inputRef.current?.blur();
setLoading(true);
if (!inputValue && selectedImages.length === 0) {
setLoading(false);
return;
}
const isReplying = reply ?? replyModal;
const userId = user?.id as string;
if (isReplying && !parent) {
setLoading(false);
return;
}
const uploadedLinks: string[] = [];
// Sequentially upload files
for (let i = 0; i < selectedImages.length; i++) {
const link = await uploadToImgur(selectedImages[i]);
if (!link) {
toast.error(
() => <span className='flex gap-2'>Failed to upload image</span>,
{ duration: 6000 }
);
setLoading(false);
return;
}
uploadedLinks.push(link);
}
const rawText = inputValue.trim();
// Get fids of mentioned users
const mentionedUsers = ((input: string) => {
let splits = input.split(/(\s|\n)/);
const usernames: string[] = [];
splits.forEach((split, i) => {
if (split.startsWith('@')) {
const username = split.slice(1);
usernames.push(username);
}
});
return usernames;
})(rawText);
const usersMapResponse = await fetchJSON<
BaseResponse<{ [key: string]: number }>
>(`/api/user/resolve-usernames?usernames=${mentionedUsers.join(',')}`);
const usersMap = usersMapResponse.result;
if (!usersMap) {
toast.error(
() => <span className='flex gap-2'>Failed to resolve usernames</span>,
{ duration: 6000 }
);
setLoading(false);
return;
}
// Extract mentions for cast message
const { text, mentions, mentionsPositions } = extractAndReplaceMentions(
rawText,
usersMap
);
// setLoading(false);
// return;
// TODO: Limit to only 2 embeds
const castMessage = await createCastMessage({
text: text,
fid: parseInt(userId),
embeds: [
...uploadedLinks.map((link) => ({ url: link })),
...embeds
.filter((embed) => !ignoredEmbedUrls.includes(embed.url))
.map(({ url }) => ({ url }))
],
mentions: mentions,
mentionsPositions: mentionsPositions,
parentCastHash: isReplying && parent ? parent.id : undefined,
parentCastFid: isReplying && parent ? parseInt(parent.userId) : undefined,
parentUrl: !parent ? topicUrl : undefined
});
if (castMessage) {
const res = await submitHubMessage(castMessage);
const message = Message.fromJSON(res);
await sleep(500);
if (!modal && !replyModal) {
discardTweet();
setLoading(false);
}
if (closeModal) closeModal();
const tweetId = Buffer.from(message.hash).toString('hex');
toast.success(
() => (
<span className='flex gap-2'>
Your post was sent
<Link
href={`/tweet/${tweetId}`}
className='custom-underline font-bold'
>
View
</Link>
</span>
),
{ duration: 6000 }
);
} else {
setLoading(false);
toast.error(
() => <span className='flex gap-2'>Failed to create post</span>,
{ duration: 6000 }
);
}
};
const handleImageUpload = (
e: ChangeEvent<HTMLInputElement> | ClipboardEvent<HTMLTextAreaElement>
): void => {
const isClipboardEvent = 'clipboardData' in e;
if (isClipboardEvent) {
const isPastingText = e.clipboardData.getData('text');
if (isPastingText) return;
}
const files = isClipboardEvent ? e.clipboardData.files : e.target.files;
const imagesData = getImagesData(files, previewCount);
if (!imagesData) {
toast.error('Please choose a GIF or photo up to 4');
return;
}
const { imagesPreviewData, selectedImagesData } = imagesData;
setImagesPreview([...imagesPreview, ...imagesPreviewData]);
setSelectedImages([...selectedImages, ...selectedImagesData]);
inputRef.current?.focus();
};
const removeImage = (targetId: string) => (): void => {
setSelectedImages(selectedImages.filter(({ id }) => id !== targetId));
setImagesPreview(imagesPreview.filter(({ id }) => id !== targetId));
const { src } = imagesPreview.find(
({ id }) => id === targetId
) as ImageData;
URL.revokeObjectURL(src);
};
const cleanImage = (): void => {
imagesPreview.forEach(({ src }) => URL.revokeObjectURL(src));
setSelectedImages([]);
setImagesPreview([]);
};
const discardTweet = (): void => {
setInputValue('');
setVisited(false);
cleanImage();
setEmbedUrls([]);
setEmbeds([]);
setIgnoredEmbedUrls([]);
inputRef.current?.blur();
};
const handleEmbedsChange = (value: string) => {
if (value) {
const urls = getHttpsUrls(value).filter(
(url) => !ignoredEmbedUrls.includes(url)
);
setEmbedUrls(urls.slice(0, 2));
}
};
const handleChangeDebounced = useCallback(
debounce((e) => {
handleEmbedsChange(e.target.value);
}, 1500),
[]
);
const [showUsers, setShowUsers] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const {
data: usersSearch,
error,
isValidating: usersSearchLoading
} = useSWR(
searchTerm.length > 0 ? `/api/search?q=${searchTerm}` : null,
async (url) => (await fetchJSON<BaseResponse<User[]>>(url)).result
);
const debouncedSetSearchTerm = useCallback(
debounce((value) => {
setSearchTerm(value);
}, 1000),
[]
);
useEffect(() => {
if (!inputRef.current) return;
const cursorPosition = inputRef.current.selectionStart;
const textBeforeCursor = inputValue.slice(0, cursorPosition);
// TODO: Handle edge cases like \n
const lastKeyword = textBeforeCursor.split(' ').pop() || '';
if (lastKeyword.startsWith('@')) {
setShowUsers(true);
debouncedSetSearchTerm(lastKeyword.slice(1));
} else {
setShowUsers(false);
}
}, [inputValue]);
const handleUserClick = (user: User) => {
if (!inputRef.current) return;
const cursorPosition = inputRef.current.selectionStart;
const textBeforeCursor = inputValue.slice(0, cursorPosition);
const textAfterCursor = inputValue.slice(cursorPosition);
const lastSpaceBeforeCursorIndex = textBeforeCursor.lastIndexOf(' ');
const newTextBeforeCursor = textBeforeCursor.slice(
0,
lastSpaceBeforeCursorIndex + 1
);
const newTextAfterCursor = '@' + user.username + ' ' + textAfterCursor;
setInputValue(newTextBeforeCursor + newTextAfterCursor);
setShowUsers(false);
};
const handleChange = ({
target: { value }
}: ChangeEvent<HTMLTextAreaElement>): void => {
setInputValue(value);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>): void => {
e.preventDefault();
void sendTweet();
};
const handleFocus = (): void => setVisited(!loading);
const formId = useId();
const inputLimit = 320;
const inputLength = Buffer.from(inputValue).length;
const isValidInput = !!inputValue.trim().length;
const isCharLimitExceeded = inputLength > inputLimit;
const isValidTweet =
!isCharLimitExceeded && (isValidInput || isUploadingImages);
const { data: newEmbeds, isValidating } = useSWR(
embedUrls.length > 0 ? `/api/embeds?urls=${embedUrls.join(',')}` : null,
fetchJSON<(ExternalEmbed | null)[]>
);
useEffect(() => {
setEmbeds((prevEmbeds) => {
if (newEmbeds) {
return newEmbeds.filter((embed) => embed !== null) as ExternalEmbed[];
} else {
return prevEmbeds;
}
});
}, [newEmbeds]);
useEffect(() => {
handleEmbedsChange(inputValue);
}, [ignoredEmbedUrls]);
return (
<form
className={cn('flex flex-col', {
'-mx-4': reply,
'gap-2': replyModal,
'cursor-not-allowed': disabled
})}
onSubmit={handleSubmit}
>
{loading && (
<motion.i className='h-1 animate-pulse bg-main-accent' {...variants} />
)}
{children}
{reply && visited && (
<motion.p
className='-mb-2 ml-[75px] mt-2 text-light-secondary dark:text-dark-secondary'
{...fromTop}
>
Replying to{' '}
<Link
href={`/user/${parent?.username as string}`}
className='custom-underline text-main-accent'
>
{parent?.username as string}
</Link>
</motion.p>
)}
<label
className={cn(
'hover-animation grid w-full grid-cols-[auto,1fr] gap-3 px-4 py-3',
reply
? 'pb-1 pt-3'
: replyModal
? 'pt-0'
: 'border-b-2 border-light-border dark:border-dark-border',
(disabled || loading) && 'pointer-events-none opacity-50'
)}
htmlFor={formId}
>
<UserAvatar src={photoURL} alt={name} username={username} />
<div className='flex w-full flex-col gap-4'>
<InputForm
modal={modal}
reply={reply}
formId={formId}
visited={visited}
loading={loading}
inputRef={inputRef}
replyModal={replyModal}
inputValue={inputValue}
isValidTweet={isValidTweet}
isUploadingImages={isUploadingImages}
sendTweet={sendTweet}
handleFocus={handleFocus}
discardTweet={discardTweet}
handleChange={(e) => {
handleChangeDebounced(e);
handleChange(e);
}}
handleImageUpload={handleImageUpload}
>
{showUsers &&
(usersSearchLoading ? (
<Loading />
) : (
usersSearch &&
usersSearch.length > 0 && (
<ul className='menu-container hover-animation mt-1 overflow-hidden rounded-2xl bg-main-background'>
{usersSearch.map((user) => {
return (
<li
key={user.id}
className='cursor-pointer p-2'
onClick={() => handleUserClick(user)}
>
<UserSearchResult user={user} />
</li>
);
})}
</ul>
)
))}
{isUploadingImages && (
<ImagePreview
imagesPreview={imagesPreview}
previewCount={previewCount}
removeImage={!loading ? removeImage : undefined}
/>
)}
{embeds?.map(
(embed) =>
embed &&
!ignoredEmbedUrls.includes(embed.url) && (
<div key={embed.url} className='flex items-center gap-2'>
<button
className='text-light-secondary dark:text-dark-secondary'
onClick={() => {
setIgnoredEmbedUrls([...ignoredEmbedUrls, embed.url]);
}}
>
x
</button>
<TweetEmbed {...embed} key={embed.url} />
</div>
)
)}
</InputForm>
{loadingTopic ? (
<div className='w-10'>
<TweetTopicSkeleton />
</div>
) : showingTopicSelector && !parent ? (
<SearchTopics
enabled={showingTopicSelector}
onSelectRawUrl={setTopicUrl}
onSelectTopic={setTopic}
setShowing={setShowingTopicSelector}
/>
) : (
topic && (
<div
className='cursor-pointer text-light-secondary dark:text-dark-secondary'
onClick={() => setShowingTopicSelector(true)}
>
<TopicView topic={topic} />
</div>
)
)}
<AnimatePresence initial={false}>
{(reply ? reply && visited && !loading : !loading) && (
<InputOptions
reply={reply}
modal={modal}
inputLimit={inputLimit}
inputLength={inputLength}
isValidTweet={isValidTweet}
isCharLimitExceeded={isCharLimitExceeded}
handleImageUpload={handleImageUpload}
options={[
{
name: 'Media',
iconName: 'PhotoIcon',
disabled: false
},
{
name: 'Topic',
iconName: 'ChatBubbleBottomCenterTextIcon',
disabled: false,
onClick() {
setShowingTopicSelector(!showingTopicSelector);
}
}
]}
/>
)}
</AnimatePresence>
</div>
</label>
</form>
);
}
================================================
FILE: src/components/input/progress-bar.tsx
================================================
import cn from 'clsx';
import { ToolTip } from '@components/ui/tooltip';
type ProgressBarProps = {
modal?: boolean;
inputLimit: number;
inputLength: number;
isCharLimitExceeded: boolean;
};
const baseOffset = [56.5487, 87.9646] as const;
const circleStyles = [
{
container: null,
viewBox: '0 0 20 20',
stroke: 'stroke-main-accent',
r: 9
},
{
container: 'scale-150',
viewBox: '0 0 30 30',
stroke: 'stroke-accent-yellow',
r: 14
}
] as const;
export function ProgressBar({
modal,
inputLimit,
inputLength,
isCharLimitExceeded
}: ProgressBarProps): JSX.Element {
const isCloseToLimit = inputLength >= inputLimit - 20;
const baseCircle = baseOffset[+isCloseToLimit];
const inputPercentage = (inputLength / inputLimit) * 100;
const circleLength = baseCircle - (baseCircle * inputPercentage) / 100;
const remainingCharacters = inputLimit - inputLength;
const isHittingCharLimit = remainingCharacters <= 0;
const { container, viewBox, stroke, r } = circleStyles[+isCloseToLimit];
return (
<button
className='group relative cursor-pointer outline-none'
type='button'
>
<i
className={cn(
'flex h-5 w-5 -rotate-90 items-center justify-center transition',
container,
remainingCharacters <= -10 && 'opacity-0'
)}
>
<svg
className='overflow-visible'
width='100%'
height='100%'
viewBox={viewBox}
>
<circle
className='stroke-light-border dark:stroke-dark-border'
cx='50%'
cy='50%'
fill='none'
strokeWidth='2'
r={r}
/>
<circle
className={cn(
'transition-colors',
isHittingCharLimit ? 'stroke-accent-red' : stroke
)}
cx='50%'
cy='50%'
fill='none'
strokeWidth='2'
r={r}
strokeLinecap='round'
style={{
strokeDashoffset: !isCharLimitExceeded ? circleLength : 0,
strokeDasharray: baseCircle
}}
/>
</svg>
</i>
<span
className={cn(
`absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
scale-50 text-3xl opacity-0 text-light-secondary dark:text-dark-secondary`,
{
'scale-100 opacity-100 transition': isCloseToLimit,
'text-accent-red': isHittingCharLimit
}
)}
>
{remainingCharacters}
</span>
<ToolTip
tip={
isCharLimitExceeded
? 'You have exceeded the character limit'
: `${remainingCharacters} characters remaining`
}
modal={modal}
/>
</button>
);
}
================================================
FILE: src/components/input/search-bar.tsx
================================================
import cn from 'clsx';
import {
DetailedHTMLProps,
InputHTMLAttributes,
KeyboardEvent,
useRef
} from 'react';
import { Button } from '../ui/button';
import { HeroIcon } from '../ui/hero-icon';
export function SearchBar({
setInputValue,
inputValue,
className,
...inputProps
}: DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> & {
setInputValue: (value: string) => void;
inputValue: string;
className?: string;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const clearInputValue = (focus?: boolean) => (): void => {
if (focus) inputRef.current?.focus();
else inputRef.current?.blur();
setInputValue('');
};
const handleEscape = ({ key }: KeyboardEvent<HTMLInputElement>): void => {
if (key === 'Escape') clearInputValue()();
};
const blurTimeout = useRef<NodeJS.Timeout>();
return (
<label
className={cn(
'group flex items-center justify-between gap-4 rounded-full bg-main-search-background px-4 py-2 transition focus-within:bg-main-background focus-within:ring-2 focus-within:ring-main-accent',
className
)}
>
<i>
<HeroIcon
className='h-5 w-5 text-light-secondary transition-colors
group-focus-within:text-main-accent dark:text-dark-secondary'
iconName='MagnifyingGlassIcon'
/>
</i>
<input
className='peer flex-1 bg-transparent outline-none
placeholder:text-light-secondary dark:placeholder:text-dark-secondary'
type='text'
placeholder='Search'
ref={inputRef}
value={inputValue}
// onChange={(e) => {
// handleChange(e);
// handleChangeDebounced(e);
// }}
onKeyUp={handleEscape}
{...inputProps}
/>
<Button
className={cn(
'accent-tab scale-50 bg-main-accent p-1 opacity-0 transition hover:brightness-90 disabled:opacity-0',
inputValue &&
'focus:scale-100 focus:opacity-100 peer-focus:scale-100 peer-focus:opacity-100'
)}
onClick={clearInputValue(true)}
disabled={!inputValue}
>
<HeroIcon className='h-3 w-3 stroke-white' iconName='XMarkIcon' />
</Button>
</label>
);
}
================================================
FILE: src/components/layout/auth-layout.tsx
================================================
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '@lib/context/auth-context';
import { sleep } from '@lib/utils';
import { Placeholder } from '@components/common/placeholder';
import type { LayoutProps } from './common-layout';
export function AuthLayout({
children,
forceLogin
}: LayoutProps & { forceLogin?: boolean }): JSX.Element {
const [pending, setPending] = useState(true);
const { user, loading } = useAuth();
const { replace } = useRouter();
useEffect(() => {
const checkLogin = async (): Promise<void> => {
setPending(true);
if (user && !forceLogin) {
await sleep(500);
void replace('/home');
} else if (!loading) {
await sleep(500);
setPending(false);
}
};
void checkLogin();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, loading, forceLogin]);
if (loading || pending) return <Placeholder />;
return <>{children}</>;
}
================================================
FILE: src/components/layout/common-layout.tsx
================================================
import { Aside } from '@components/aside/aside';
import { Placeholder } from '@components/common/placeholder';
import { useRequireAuth } from '@lib/hooks/useRequireAuth';
import type { ReactNode } from 'react';
import { AsideTrends } from '../aside/trends';
export type LayoutProps = {
children: ReactNode;
};
export function ProtectedLayout({ children }: LayoutProps): JSX.Element {
const user = useRequireAuth();
if (!user) return <Placeholder />;
return <>{children}</>;
}
export function HomeLayout({ children }: LayoutProps): JSX.Element {
return (
<>
{children}
<Aside>
{/* <Suggestions /> */}
<AsideTrends />
</Aside>
</>
);
}
export function UserLayout({ children }: LayoutProps): JSX.Element {
return (
<>
{children}
<Aside>
{/* <Suggestions /> */}
<></>
</Aside>
<></>
</>
);
}
export function TrendsLayout({ children }: LayoutProps): JSX.Element {
return (
<>
{children}
<Aside>
{/* <Suggestions /> */}
<></>
</Aside>
</>
);
}
export function PeopleLayout({ children }: LayoutProps): JSX.Element {
return <>{children}</>;
}
================================================
FILE: src/components/layout/main-layout.tsx
================================================
import { SWRConfig } from 'swr';
import { Toaster } from 'react-hot-toast';
import { fetchJSON } from '@lib/fetch';
import { WindowContextProvider } from '@lib/context/window-context';
import { Sidebar } from '@components/sidebar/sidebar';
import type { DefaultToastOptions } from 'react-hot-toast';
import type { LayoutProps } from './common-layout';
const toastOptions: DefaultToastOptions = {
style: {
color: 'white',
borderRadius: '4px',
backgroundColor: 'rgb(var(--main-accent))'
},
success: { duration: 4000 }
};
export function MainLayout({ children }: LayoutProps): JSX.Element {
return (
<div className='flex w-full justify-center gap-0 lg:gap-4'>
<WindowContextProvider>
<Sidebar />
<SWRConfig value={{ fetcher: fetchJSON }}>{children}</SWRConfig>
</WindowContextProvider>
<Toaster
position='bottom-center'
toastOptions={toastOptions}
containerClassName='mb-12 xs:mb-0'
/>
</div>
);
}
================================================
FILE: src/components/layout/user-data-layout.tsx
================================================
import { SEO } from '@components/common/seo';
import { MainContainer } from '@components/home/main-container';
import { MainHeader } from '@components/home/main-header';
import { UserHeader } from '@components/user/user-header';
import { UserContextProvider } from '@lib/context/user-context';
import { useRouter } from 'next/router';
import useSWR from 'swr';
import { fetchJSON } from '../../lib/fetch';
import { UserFull, UserFullResponse, UserResponse } from '../../lib/types/user';
import type { LayoutProps } from './common-layout';
export function UserDataLayout({ children }: LayoutProps): JSX.Element {
const {
query: { id },
back
} = useRouter();
const { data: user, isValidating: loading } = useSWR(
id ? `/api/user/${id}` : null,
async (url) => (await fetchJSON<UserFullResponse>(url)).result,
{ revalidateOnFocus: false, revalidateOnReconnect: false }
);
return (
<UserContextProvider
value={{ user: (user as UserFull) || null, loading: !user && loading }}
>
{!user && !loading && <SEO title='User not found / Opencast' />}
<MainContainer>
<MainHeader useActionButton action={back}>
<UserHeader />
</MainHeader>
{children}
</MainContainer>
</UserContextProvider>
);
}
================================================
FILE: src/components/layout/user-follow-layout.tsx
================================================
import { motion } from 'framer-motion';
import { useUser } from '@lib/context/user-context';
import { Loading } from '@components/ui/loading';
import { UserNav } from '@components/user/user-nav';
import { variants } from '@components/user/user-header';
import type { LayoutProps } from './common-layout';
export function UserFollowLayout({ children }: LayoutProps): JSX.Element {
const { user: userData, loading } = useUser();
return (
<>
{!userData ? (
<motion.section {...variants}>
{loading ? (
<Loading className='mt-5 w-full' />
) : (
<div className='w-full p-8 text-center'>
<p className='text-3xl font-bold'>This account doesn’t exist</p>
<p className='text-light-secondary dark:text-dark-secondary'>
Try searching for another.
</p>
</div>
)}
</motion.section>
) : (
<>
<UserNav follow userId={userData.username} />
{children}
</>
)}
</>
);
}
================================================
FILE: src/components/layout/user-home-layout.tsx
================================================
import { SEO } from '@components/common/seo';
import { Button } from '@components/ui/button';
import { FollowButton } from '@components/ui/follow-button';
import { HeroIcon } from '@components/ui/hero-icon';
import { Loading } from '@components/ui/loading';
import { ToolTip } from '@components/ui/tooltip';
import { UserDetails } from '@components/user/user-details';
import { UserEditProfile } from '@components/user/user-edit-profile';
import { variants } from '@components/user/user-header';
import { UserHomeAvatar } from '@components/user/user-home-avatar';
import { UserNav } from '@components/user/user-nav';
import { UserShare } from '@components/user/user-share';
import { useAuth } from '@lib/context/auth-context';
import { useUser } from '@lib/context/user-context';
import { motion } from 'framer-motion';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { TipModal } from '../modal/tip-modal';
import type { LayoutProps } from './common-layout';
export function UserHomeLayout({ children }: LayoutProps): JSX.Element {
const { user, isAdmin } = useAuth();
const { user: userData, loading } = useUser();
const {
query: { id }
} = useRouter();
const [isTipModalOpen, setIsTipModalOpen] = useState(false);
const profileData = userData
? { src: userData.photoURL, alt: userData.name }
: null;
const { id: userId } = user ?? {};
const isOwner = userData?.id === userId;
return (
<>
{userData && (
<SEO
title={`${`${userData.name} (@${userData.username})`} / Opencast`}
/>
)}
<TipModal
isUserLoading={loading}
tipCloseModal={() => setIsTipModalOpen(false)}
tipUserOpen={isTipModalOpen}
user={userData || undefined}
username={userData?.username || '...'}
/>
<motion.section {...variants} exit={undefined}>
{loading ? (
<Loading className='mt-5' />
) : !userData ? (
<>
{/* <UserHomeCover /> */}
<div className='flex flex-col gap-8'>
<div className='relative flex flex-col gap-3 px-4 py-3'>
<UserHomeAvatar />
<p className='text-xl font-bold'>@{id}</p>
</div>
<div className='p-8 text-center'>
<p className='text-3xl font-bold'>This account doesn’t exist</p>
<p className='text-light-secondary dark:text-dark-secondary'>
Try searching for another.
</p>
</div>
</div>
</>
) : (
<>
{/* <UserHomeCover coverData={coverData} /> */}
<div className='relative flex flex-col gap-3 px-4 py-3'>
<div className='flex justify-between'>
<UserHomeAvatar profileData={profileData} />
{isOwner ? (
<UserEditProfile />
) : (
<div className='flex gap-2 self-start'>
<UserShare username={userData.username} />
<Button
className='dark-bg-tab group relative border border-light-line-reply p-2
hover:bg-light-primary/10 active:bg-light-primary/20 dark:border-light-secondary
dark:hover:bg-dark-primary/10 dark:active:bg-dark-primary/20'
onClick={() => setIsTipModalOpen(true)}
>
<HeroIcon className='h-5 w-5' iconName='BanknotesIcon' />
<ToolTip tip='Tip' />
</Button>
<FollowButton
userTargetId={userData.id}
userTargetUsername={userData.username}
/>
{isAdmin && <UserEditProfile hide />}
</div>
)}
</div>
<UserDetails {...userData} />
</div>
</>
)}
</motion.section>
{userData && (
<>
<UserNav userId={userData.username} />
{children}
</>
)}
</>
);
}
================================================
FILE: src/components/login/login-footer.tsx
================================================
// const footerLinks = [
// ['About', 'https://about.twitter.com'],
// ['Help Center', 'https://help.twitter.com'],
// ['Privacy Policy', 'https://twitter.com/tos'],
// ['Cookie Policy', 'https://support.twitter.com/articles/20170514'],
// ['Accessibility', 'https://help.twitter.com/resources/accessibility'],
// [
// 'Ads Info',
// 'https://business.twitter.com/en/help/troubleshooting/how-twitter-ads-work.html'
// ],
// ['Blog', 'https://blog.twitter.com'],
// ['Status', 'https://status.twitterstat.us'],
// ['Careers', 'https://careers.twitter.com'],
// ['Brand Resources', 'https://about.twitter.com/press/brand-assets'],
// ['Advertising', 'https://ads.twitter.com/?ref=gl-tw-tw-twitter-advertise'],
// ['Marketing', 'https://marketing.twitter.com'],
// ['Twitter for Business', 'https://business.twitter.com'],
// ['Developers', 'https://developer.twitter.com'],
// ['Directory', 'https://twitter.com/i/directory/profiles'],
// ['Settings', 'https://twitter.com/settings']
// ] as const;
export function LoginFooter(): JSX.Element {
return (
<footer className='hidden justify-center p-4 text-sm text-light-secondary dark:text-dark-secondary lg:flex'>
<nav className='flex flex-wrap justify-center gap-4 gap-y-2'>
{/* {footerLinks.map(([linkName, href]) => (
<a
className='custom-underline'
target='_blank'
rel='noreferrer'
href={href}
key={linkName}
>
{linkName}
</a>
))} */}
<p>Opencast</p>
</nav>
</footer>
);
}
================================================
FILE: src/components/login/login-main.tsx
================================================
import { Button } from '@components/ui/button';
import { CustomIcon } from '@components/ui/custom-icon';
import { NextImage } from '@components/ui/next-image';
import Link from 'next/link';
import { bytesToHex } from 'viem';
import { useAuth } from '../../lib/context/auth-context';
import { getKeyPair } from '../../lib/crypto';
import { useModal } from '../../lib/hooks/useModal';
import { addKeyPair } from '../../lib/keys';
import WalletSignInModal from '../modal/sign-in-modal-wallet';
import { WarpcastSignInModal } from '../modal/sign-in-modal-warpcast';
import { HeroIcon } from '../ui/hero-icon';
export function LoginMain(): JSX.Element {
const {
openModal: openModalWarpcast,
closeModal: closeModalWarpcast,
open: openWarpcast
} = useModal();
const {
openModal: openModalWallet,
closeModal: closeModalWallet,
open: openWallet
} = useModal();
const { handleUserAuth } = useAuth();
return (
<main className='grid lg:grid-cols-[1fr,45vw]'>
<div className='relative hidden items-center justify-center lg:flex'>
<NextImage
imgClassName='object-cover'
blurClassName='bg-accent-blue'
src='/assets/twitter-banner.png'
alt='Opencast banner'
layout='fill'
useSkeleton
/>
<i className='absolute'>
<CustomIcon className='h-96 w-96 text-white' iconName='TwitterIcon' />
</i>
</div>
<WarpcastSignInModal
closeModal={closeModalWarpcast}
open={openWarpcast}
></WarpcastSignInModal>
<WalletSignInModal
closeModal={closeModalWallet}
open={openWallet}
></WalletSignInModal>
<div className='flex flex-col items-center justify-between gap-6 p-8 lg:items-start lg:justify-center'>
<i className='mb-0 self-center lg:mb-10 lg:self-auto'>
<CustomIcon
className='-mt-4 h-6 w-6 text-accent-blue lg:h-12 lg:w-12 dark:lg:text-twitter-icon'
iconName='TwitterIcon'
/>
</i>
<div className='flex max-w-xs flex-col gap-4 font-twitter-chirp-extended lg:max-w-none lg:gap-16'>
<h1
className='text-3xl before:content-["See_what’s_happening_in_the_world_right_now."]
lg:text-6xl lg:before:content-["Happening_now"]'
/>
<h2 className='hidden text-xl lg:block lg:text-3xl'>
Use Opencast today.
</h2>
</div>
<div className='flex max-w-xs flex-col gap-6 [&_button]:py-2'>
<div className='grid gap-3 font-bold'>
<Button
className='flex justify-center gap-2 border border-light-line-reply font-bold text-light-primary transition
hover:bg-[#e6e6e6] focus-visible:bg-[#e6e6e6] active:bg-[#cccccc] dark:border-0 dark:bg-white
dark:hover:brightness-90 dark:focus-visible:brightness-90 dark:active:brightness-75'
onClick={openModalWarpcast}
>
<CustomIcon iconName='TriangleIcon' /> Sign in with Warpcast
</Button>
<Button
className='flex justify-center gap-2 border border-light-line-reply font-bold text-light-primary transition
hover:bg-[#e6e6e6] focus-visible:bg-[#e6e6e6] active:bg-[#cccccc] dark:border-0 dark:bg-white
dark:hover:brightness-90 dark:focus-visible:brightness-90 dark:active:brightness-75'
onClick={openModalWallet}
>
<HeroIcon iconName='GlobeAltIcon' /> Sign in with Ethereum
</Button>
<Button
className='flex justify-center gap-2 border border-light-line-reply font-bold text-light-primary transition
hover:bg-[#e6e6e6] focus-visible:bg-[#e6e6e6] active:bg-[#cccccc] dark:border-0 dark:bg-white
dark:hover:brightness-90 dark:focus-visible:brightness-90 dark:active:brightness-75'
onClick={async () => {
const challenge = new Uint8Array(32);
const assertion = await navigator.credentials.get({
publicKey: {
challenge,
extensions: {
// @ts-ignore -- This is a valid property
largeBlob: {
read: true
}
}
}
});
try {
if (
// @ts-ignore -- This is a valid property
typeof assertion?.getClientExtensionResults().largeBlob
.blob !== 'undefined'
) {
// Reading a large blob was successful.
const blobBits = new Uint8Array(
// @ts-ignore -- This is a valid property
assertion.getClientExtensionResults().largeBlob.blob
);
const privateKey = bytesToHex(blobBits);
const keyPair = await getKeyPair(privateKey);
addKeyPair(keyPair);
handleUserAuth(keyPair);
} else {
// The large blob could not be read (e.g. because the data is corrupted).
// The assertion is still valid.
console.log('The large blob could not be read.');
}
} catch (error) {
console.error(error);
}
}}
>
<HeroIcon iconName='KeyIcon' /> Sign in with Passkey
</Button>
<Link
href='/home'
className='custom-button main-tab flex justify-center gap-2 border border-white bg-black font-bold text-white
transition hover:bg-opacity-90 focus-visible:bg-opacity-90 active:bg-opacity-80
dark:hover:brightness-125 dark:focus-visible:brightness-125 dark:active:brightness-150'
>
Continue without signing in
</Link>
<p
className='inner:custom-underline inner:custom-underline text-center text-xs
text-light-secondary inner:text-accent-blue dark:text-dark-secondary'
>
By signing up you agree that you are doing so at your own risk.
</p>
</div>
</div>
</div>
</main>
);
}
================================================
FILE: src/components/login/sign-in-with-warpcast.tsx
================================================
import { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import QRCode from 'react-qr-code';
import { useAuth } from '../../lib/context/auth-context';
import { generateKeyPair } from '../../lib/crypto';
import { fetchJSON } from '../../lib/fetch';
import { KeyPair } from '../../lib/types/keypair';
import { BaseResponse } from '../../lib/types/responses';
import { addKeyPair } from '../../lib/keys';
const PENDING_REQUEST_KEY = '-opencast-pendingWarpcastRequest';
type WarpcastSignerResponseBase = {
token: string;
deeplinkUrl: string;
key: string;
state: 'pending' | 'approved' | 'completed';
userFid?: number;
};
type WarpcastRequest =
| (WarpcastSignerResponseBase & {
keyPair: KeyPair;
authorization: { deadline: number };
})
| {
state: 'preparing';
keyPair: KeyPair;
};
/* https://warpcast.notion.site/Signer-Request-API-Migration-Guide-Public-9e74827f9070442fb6f2a7ffe7226b3c */
const WarpcastAuthPopup = ({ closeModal }: { closeModal?: () => void }) => {
const [pendingRequest, setPendingRequest] = useState<WarpcastRequest | null>(
null
);
const [polling, setPolling] = useState<boolean>(false);
const [deepLinkUrl, setDeepLinkUrl] = useState<string | null>(null);
const [initiated, setInitiated] = useState<boolean>(false);
const { handleUserAuth } = useAuth();
useEffect(() => {
if (pendingRequest) {
localStorage.setItem(PENDING_REQUEST_KEY, JSON.stringify(pendingRequest));
if (pendingRequest.state === 'preparing') {
// Get app signature
(
fetchJSON(
`/api/signer/${pendingRequest!.keyPair.publicKey}/authorize`
) as Promise<
BaseResponse<{
requestFid: number;
signature: string;
deadline: number;
}>
>
).then(({ result: authorizationResult }) => {
if (!authorizationResult) {
toast.error('Error generating signature');
return;
} else {
// Send signature to Warpcast for transaction broadcast
(
fetchJSON(`https://api.warpcast.com/v2/signed-key-requests`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
key: pendingRequest?.keyPair.publicKey,
signature: authorizationResult.signature,
requestFid: authorizationResult.requestFid,
deadline: authorizationResult.deadline
})
}) as Promise<{
result: { signedKeyRequest: WarpcastSignerResponseBase };
}>
).then(({ result }) => {
if (!result) {
toast.error('Error generating signed key');
return;
}
setPendingRequest({
...result.signedKeyRequest,
keyPair: pendingRequest!.keyPair,
authorization: authorizationResult
});
});
}
});
} else if (pendingRequest.state === 'pending') {
setDeepLinkUrl(pendingRequest.deeplinkUrl);
if (!polling) {
pollForSigner(pendingRequest.token);
}
} else if (pendingRequest.state === 'completed') {
setTimeout(() => {
addKeyPair(pendingRequest.keyPair);
localStorage.removeItem(PENDING_REQUEST_KEY);
handleUserAuth(pendingRequest.keyPair);
closeModal?.();
}, 5_000); // Give indexer 5 seconds to index event
}
}
}, [pendingRequest]);
// Initiate the Signer request
const initiateSignerRequest = async () => {
// Load existing request
const pendingWarpcastRequestRaw = localStorage.getItem(
PENDING_REQUEST_KEY
) as string;
let request: WarpcastRequest | undefined;
if (pendingWarpcastRequestRaw) {
const parsed: WarpcastRequest = JSON.parse(pendingWarpcastRequestRaw);
// Check that request is still valid
if (
parsed.state !== 'preparing' &&
parsed.authorization.deadline &&
parsed.authorization.deadline > Math.floor(Date.now() / 1000)
) {
request = parsed;
}
}
if (!request) {
request = {
state: 'preparing',
keyPair: await generateKeyPair()
};
localStorage.setItem(PENDING_REQUEST_KEY, JSON.stringify(request));
}
setPendingRequest(request);
};
// Poll for the status of the Signer request
// TODO: Loading indicators
const pollForSigner = async (token: string) => {
if (pendingRequest?.state !== 'pending') return;
setPolling(true);
let tries = 0;
// TODO: Loading indicators
while (true || tries < 40) {
tries += 1;
await new Promise((r) => setTimeout(r, 2000));
const { result } = (await fetchJSON(
`https://api.warpcast.com/v2/signed-key-request?token=${token}`
)) as { result: { signedKeyRequest: WarpcastSignerResponseBase } };
setPendingRequest({
...result.signedKeyRequest,
keyPair: pendingRequest.keyPair,
authorization: pendingRequest.authorization
});
if (result.signedKeyRequest.state === 'completed') {
break;
}
}
};
useEffect(() => {
setInitiated(true);
}, []);
// Debounced
useEffect(() => {
if (initiated) {
initiateSignerRequest();
}
}, [initiated]);
return (
<div>
{deepLinkUrl && (
<div>
<div className={'rounded bg-white p-2'}>
<QRCode value={deepLinkUrl} />{' '}
</div>
<span className='pt-4 text-gray-500'>
On mobile?{' '}
<a className='underline' href={deepLinkUrl} target={'_blank'}>
Open in Warpcast
</a>
</span>
</div>
)}
</div>
);
};
export default WarpcastAuthPopup;
================================================
FILE: src/components/modal/action-modal.tsx
================================================
import { useRef, useEffect } from 'react';
import cn from 'clsx';
import { Dialog } from '@headlessui/react';
import { Button } from '@components/ui/button';
import { CustomIcon } from '@components/ui/custom-icon';
type ActionModalProps = {
title: string;
useIcon?: boolean;
description: string;
mainBtnLabel: string;
focusOnMainBtn?: boolean;
mainBtnClassName?: string;
secondaryBtnLabel?: string;
secondaryBtnClassName?: string;
closeModalBtnLabel?: string;
closeModalBtnClassName?: string;
action: () => void;
secondaryAction?: () => void;
closeModal: () => void;
};
export function ActionModal({
title,
useIcon,
description,
mainBtnLabel,
focusOnMainBtn,
mainBtnClassName,
secondaryBtnLabel,
secondaryBtnClassName,
closeModalBtnLabel,
closeModalBtnClassName,
action,
secondaryAction,
closeModal
}: ActionModalProps): JSX.Element {
const mainBtn = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!focusOnMainBtn) return;
const timeoutId = setTimeout(() => mainBtn.current?.focus(), 50);
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className='flex flex-col gap-6'>
<div className='flex flex-col gap-4'>
{useIcon && (
<i className='mx-auto'>
<CustomIcon
className='h-10 w-10 text-accent-blue dark:text-twitter-icon'
iconName='TwitterIcon'
/>
</i>
)}
<div className='flex flex-col gap-2'>
<Dialog.Title className='text-xl font-bold'>{title}</Dialog.Title>
<Dialog.Description className='text-light-secondary dark:text-dark-secondary'>
{description}
</Dialog.Description>
</div>
</div>
<div className='flex flex-col gap-3 inner:py-2 inner:font-bold'>
<button
className={cn(
'custom-button main-tab text-white',
mainBtnClassName ??
`bg-light-primary hover:bg-light-primary/90 focus-visible:bg-light-primary/90 active:bg-light-primary/80
dark:bg-light-border dark:text-light-primary dark:hover:bg-light-border/90
dark:focus-visible:bg-light-border/90 dark:active:bg-light-border/75`
)}
ref={mainBtn}
onClick={action}
>
{mainBtnLabel}
</button>
{secondaryAction && (
<button
className={cn(
'custom-button main-tab text-white',
secondaryBtnClassName ??
`bg-light-primary hover:bg-light-primary/90 focus-visible:bg-light-primary/90 active:bg-light-primary/80
dark:bg-light-border dark:text-light-primary dark:hover:bg-light-border/90
dark:focus-visible:bg-light-border/90 dark:active:bg-light-border/75`
)}
ref={mainBtn}
onClick={secondaryAction}
>
{secondaryBtnLabel}
</button>
)}
<Button
className={cn(
'border border-light-line-reply dark:border-light-secondary dark:text-light-border',
closeModalBtnClassName ??
`hover:bg-light-primary/10 focus-visible:bg-light-primary/10 active:bg-light-primary/20
dark:hover:bg-light-border/10 dark:focus-visible:bg-light-border/10 dark:active:bg-light-border/20`
)}
onClick={closeModal}
>
{closeModalBtnLabel ?? 'Cancel'}
</Button>
</div>
</div>
);
}
================================================
FILE: src/components/modal/display-modal.tsx
================================================
import { UserAvatar } from '@components/user/user-avatar';
import { UserName } from '@components/user/user-name';
import { InputThemeRadio } from '@components/input/input-theme-radio';
import { Button } from '@components/ui/button';
import { InputAccentRadio } from '@components/input/input-accent-radio';
import type { Theme, Accent } from '@lib/types/theme';
type DisplayModalProps = {
closeModal: () => void;
};
const themes: Readonly<[Theme, string][]> = [
['light', 'Default'],
['dim', 'Dim'],
['dark', 'Lights out']
];
const accentsColor: Readonly<Accent[]> = [
'blue',
'yellow',
'pink',
'purple',
'orange',
'green'
];
export function DisplayModal({ closeModal }: DisplayModalProps): JSX.Element {
return (
<div className='flex flex-col items-center gap-6'>
<div className='flex flex-col gap-3 text-center'>
<h2 className='text-2xl font-bold'>Customize your view</h2>
<p className='text-light-secondary dark:text-dark-secondary'>
These settings affect all the Opencast accounts on this browser.
</p>
</div>
<article
className='hover-animation mx-8 rounded-2xl border
border-light-border px-4 py-3 dark:border-dark-border'
>
<div className='grid grid-cols-[auto,1fr] gap-3'>
<UserAvatar src='/assets/twitter-avatar.jpg' alt='Opencast' />
<div>
<div className='flex gap-1'>
<UserName verified name='Opencast' />
<p className='text-light-secondary dark:text-dark-secondary'>
@opencast
</p>
<div className='flex gap-1 text-light-secondary dark:text-dark-secondary'>
<i>·</i>
<p>26m</p>
</div>
</div>
<p className='whitespace-pre-line break-words'>
At the heart of Farcaster are short messages called casts — just
like this one — which can include photos, videos, links, text,
hashtags, and mentions like{' '}
<span className='text-main-accent'>@farcaster</span>.
</p>
</div>
</div>
</article>
<div className='flex w-full flex-col gap-1'>
<p className='text-sm font-bold text-light-secondary dark:text-dark-secondary'>
Color
</p>
<div
className='hover-animation grid grid-cols-3 grid-rows-2 justify-items-center gap-3
rounded-2xl bg-main-sidebar-background py-3 xs:grid-cols-6 xs:grid-rows-none'
>
{accentsColor.map((accentColor) => (
<InputAccentRadio type={accentColor} key={accentColor} />
))}
</div>
</div>
<div className='flex w-full flex-col gap-1'>
<p className='text-sm font-bold text-light-secondary dark:text-dark-secondary'>
Background
</p>
<div
className='hover-animation grid grid-rows-3 gap-3 rounded-2xl bg-main-sidebar-background
px-4 py-3 xs:grid-cols-3 xs:grid-rows-none'
>
{themes.map(([themeType, label]) => (
<InputThemeRadio type={themeType} label={label} key={themeType} />
))}
</div>
</div>
<Button
className='bg-main-accent px-4 py-1.5 font-bold
text-white hover:bg-main-accent/90 active:bg-main-accent/75'
onClick={closeModal}
>
Done
</Button>
</div>
);
}
================================================
FILE: src/components/modal/edit-profile-modal.tsx
================================================
import { useRef } from 'react';
import cn from 'clsx';
import { MainHeader } from '@components/home/main-header';
import { Button } from '@components/ui/button';
import { HeroIcon } from '@components/ui/hero-icon';
import { NextImage } from '@components/ui/next-image';
import { ToolTip } from '@components/ui/tooltip';
import type { ReactNode, ChangeEvent } from 'react';
import type { User, UserFull } from '@lib/types/user';
type EditProfileModalProps = Pick<
UserFull,
'name' | 'photoURL' | 'coverPhotoURL'
> & {
loading: boolean;
children: ReactNode;
inputNameError: string;
editImage: (
type: 'cover' | 'profile'
) => ({ target: { files } }: ChangeEvent<HTMLInputElement>) => void;
closeModal: () => void;
updateData: () => Promise<void>;
removeCoverImage: () => void;
resetUserEditData: () => void;
};
export function EditProfileModal({
name,
loading,
photoURL,
children,
inputNameError,
editImage,
closeModal,
updateData,
resetUserEditData
}: EditProfileModalProps): JSX.Element {
const coverInputFileRef = useRef<HTMLInputElement>(null);
const profileInputFileRef = useRef<HTMLInputElement>(null);
const handleClick = (type: 'cover' | 'profile') => (): void => {
if (type === 'cover') coverInputFileRef.current?.click();
else profileInputFileRef.current?.click();
};
return (
<>
<MainHeader
useActionButton
disableSticky
iconName='XMarkIcon'
tip='Close'
className='absolute flex w-full items-center gap-6 rounded-tl-2xl'
title='Edit profile'
action={closeModal}
>
<div className='ml-auto flex items-center gap-3'>
<Button
className='dark-bg-tab group relative p-2 hover:bg-light-primary/10
active:bg-light-primary/20 dark:hover:bg-dark-primary/10
dark:active:bg-dark-primary/10'
onClick={resetUserEditData}
disabled={loading}
>
<HeroIcon className='h-5 w-5' iconName={'ArrowPathIcon'} />
<ToolTip tip='Reset' />
</Button>
<Button
className='bg-light-primary px-4 py-1 font-bold text-white focus-visible:bg-light-primary/90
enabled:hover:bg-light-primary/90 enabled:active:bg-light-primary/80 disabled:brightness-75
dark:bg-light-border dark:text-light-primary dark:focus-visible:bg-light-border/90
dark:enabled:hover:bg-light-border/90 dark:enabled:active:bg-light-border/75'
onClick={updateData}
disabled={!!inputNameError}
loading={loading}
>
Save
</Button>
</div>
</MainHeader>
<section
className={cn(
'h-full overflow-y-auto transition-opacity',
loading && 'pointer-events-none opacity-50'
)}
>
<div className='group relative mt-[52px] h-36 xs:h-44 sm:h-48'>
<input
className='hidden'
type='file'
accept='image/*'
ref={coverInputFileRef}
onChange={editImage('cover')}
/>
<div className='h-full bg-light-line-reply dark:bg-dark-line-reply' />
<div className='absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 gap-4'>
<Button
className='group/inner relative bg-light-primary/60 p-2 hover:bg-image-preview-hover/50
focus-visible:bg-image-preview-hover/50'
onClick={handleClick('cover')}
>
<HeroIcon
className='hover-animation h-6 w-6 text-dark-primary group-hover:text-white'
iconName='CameraIcon'
/>
<ToolTip groupInner tip='Add photo' />
</Button>
</div>
</div>
<div className='relative flex flex-col gap-6 px-4 py-3'>
<div className='mb-8 xs:mb-12 sm:mb-14'>
<input
className='hidden'
type='file'
accept='image/*'
ref={profileInputFileRef}
onChange={editImage('profile')}
/>
<div
className='group absolute aspect-square w-24 -translate-y-1/2
overflow-hidden rounded-full xs:w-32 sm:w-36'
>
<NextImage
useSkeleton
className='h-full w-full bg-main-background inner:!m-1 inner:rounded-full'
imgClassName='rounded-full transition group-hover:brightness-75 duration-200
group-focus-within:brightness-75'
src={photoURL}
alt={name}
layout='fill'
/>
<Button
className='group/inner absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
bg-light-primary/60 p-2 hover:bg-image-preview-hover/50
focus-visible:bg-image-preview-hover/50'
onClick={handleClick('profile')}
>
<HeroIcon
className='hover-animation h-6 w-6 text-dark-primary group-hover:text-white'
iconName='CameraIcon'
/>
<ToolTip groupInner tip='Add photo' />
</Button>
</div>
</div>
{children}
<Button
className='accent-tab -mx-4 mb-4 flex cursor-not-allowed items-center justify-between rounded-none
py-2 hover:bg-light-primary/10 active:bg-light-primary/20 disabled:brightness-100
dark:hover:bg-dark-primary/10 dark:active:bg-dark-primary/20'
>
<span className='mx-2 text-xl'>Switch to professional</span>
<i>
<HeroIcon
className='h-6 w-6 text-light-secondary dark:text-dark-secondary'
iconName='ChevronRightIcon'
/>
</i>
</Button>
</div>
</section>
</>
);
}
================================================
FILE: src/components/modal/image-modal.tsx
================================================
/* eslint-disable react-hooks/exhaustive-deps */
import { useState, useEffect } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import cn from 'clsx';
import { preventBubbling } from '@lib/utils';
import { Button } from '@components/ui/button';
import { HeroIcon } from '@components/ui/hero-icon';
import { Loading } from '@components/ui/loading';
import { backdrop, modal } from './modal';
import type { VariantLabels } from 'framer-motion';
import type { ImageData } from '@lib/types/file';
import type { IconName } from '@components/ui/hero-icon';
type ImageModalProps = {
tweet?: boolean;
imageData: ImageData;
previewCount: number;
selectedIndex?: number;
handleNextIndex?: (type: 'prev' | 'next') => () => void;
};
type ArrowButton = ['prev' | 'next', string | null, IconName];
const arrowButtons: Readonly<ArrowButton[]> = [
['prev', null, 'ArrowLeftIcon'],
['next', 'order-1', 'ArrowRightIcon']
];
export function ImageModal({
tweet,
imageData,
previewCount,
selectedIndex,
handleNextIndex
}: ImageModalProps): JSX.Element {
const [indexes, setIndexes] = useState<number[]>([]);
const [loading, setLoading] = useState(true);
const { src, alt } = imageData;
const requireArrows = handleNextIndex && previewCount > 1;
useEffect(() => {
if (
tweet &&
selectedIndex !== undefined &&
!indexes.includes(selectedIndex)
) {
setLoading(true);
setIndexes([...indexes, selectedIndex]);
}
const image = new Image();
image.src = src;
image.onload = (): void => setLoading(false);
}, [...(tweet && previewCount > 1 ? [src] : [])]);
useEffect(() => {
if (!requireArrows) return;
const handleKeyDown = ({ key }: KeyboardEvent): void => {
const callback =
key === 'ArrowLeft'
? handleNextIndex('prev')
: key === 'ArrowRight'
? handleNextIndex('next')
: null;
if (callback) callback();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleNextIndex]);
return (
<>
{requireArrows &&
arrowButtons.map(([name, className, iconName]) => (
<Button
className={cn(
`absolute z-10 hover:bg-light-primary/10 active:bg-light-primary/20
dark:hover:bg-dark-primary/10 dark:active:bg-dark-primary/20`,
name === 'prev' ? 'left-2' : 'right-2',
className
)}
onClick={preventBubbling(handleNextIndex(name))}
key={name}
>
<HeroIcon iconName={iconName} />
</Button>
))}
<AnimatePresence mode='wait'>
{loading ? (
<motion.div
className='mx-auto'
{...backdrop}
exit={tweet ? (backdrop.exit as VariantLabels) : undefined}
transition={{ duration: 0.15 }}
>
<Loading iconClassName='w-20 h-20' />
</motion.div>
) : (
<motion.div className='relative mx-auto' {...modal} key={src}>
<picture className='group relative flex max-w-3xl'>
<source srcSet={src} type='image/*' />
<img
className='max-h-[75vh] rounded-md object-contain md:max-h-[80vh]'
src={src}
alt={alt}
onClick={preventBubbling()}
/>
<a
className='trim-alt accent-tab absolute bottom-0 right-0 mx-2 mb-2 translate-y-4
rounded-md bg-main-background/40 px-2 py-1 text-sm text-light-primary/80 opacity-0
transition hover:bg-main-accent hover:text-white focus-visible:translate-y-0
focus-visible:bg-main-accent focus-visible:text-white focus-visible:opacity-100
group-hover:translate-y-0 group-hover:opacity-100 dark:text-dark-primary/80'
href={src}
target='_blank'
rel='noreferrer'
onClick={preventBubbling(null, true)}
>
{alt}
</a>
</picture>
<a
className='custom-underline absolute left-0 -bottom-7 font-medium text-light-primary/80
decoration-transparent underline-offset-2 transition hover:text-light-primary hover:underline
hover:decoration-light-primary focus-visible:text-light-primary dark:text-dark-primary/80
dark:hover:text-dark-primary dark:hover:decoration-dark-primary dark:focus-visible:text-dark-primary'
href={src}
target='_blank'
rel='noreferrer'
onClick={preventBubbling(null, true)}
>
Open original
</a>
</motion.div>
)}
</AnimatePresence>
</>
);
}
================================================
FILE: src/components/modal/mobile-sidebar-modal.tsx
================================================
import { MainHeader } from '@components/home/main-header';
import { MobileSidebarLink } from '@components/sidebar/mobile-sidebar-link';
import { navLinks, type NavLink } from '@components/sidebar/sidebar';
import { Button } from '@components/ui/button';
import { HeroIcon } from '@components/ui/hero-icon';
import { NextImage } from '@components/ui/next-image';
import { UserAvatar } from '@components/user/user-avatar';
import { UserName } from '@components/user/user-name';
import { UserUsername } from '@components/user/user-username';
import { useAuth } from '@lib/context/auth-context';
import { useModal } from '@lib/hooks/useModal';
import type { UserFull } from '@lib/types/user';
import Link from 'next/link';
import { ActionModal } from './action-modal';
import { DisplayModal } from './display-modal';
import { Modal } from './modal';
import { SavePasskeyModal } from './save-passkey-modal';
export type MobileNavLink = Omit<NavLink, 'canBeHidden'>;
const bottomNavLinks: Readonly<MobileNavLink[]> = [];
type Stats = [string, string, number];
type MobileSidebarModalProps = Pick<
UserFull,
| 'name'
| 'username'
| 'verified'
| 'photoURL'
| 'following'
| 'followers'
| 'coverPhotoURL'
> & {
closeModal: () => void;
};
export function MobileSidebarModal({
name,
username,
verified,
photoURL,
following,
followers,
coverPhotoURL,
closeModal
}: MobileSidebarModalProps): JSX.Element {
const { signOut, userNotifications, resetNotifications, user } = useAuth();
const {
open: displayOpen,
openModal: displayOpenModal,
closeModal: displayCloseModal
} = useModal();
const {
open: logOutOpen,
openModal: logOutOpenModal,
closeModal: logOutCloseModal
} = useModal();
const {
open: isSavePasskeyModalOpen,
openModal: openSavePasskeyModal,
closeModal: closeSavePasskeyModal
} = useModal();
const allStats: Readonly<Stats[]> = [
['following', 'Following', following.length],
['followers', 'Followers', followers.length]
];
const userLink = `/user/${username}`;
return (
<>
<Modal
className='items-center justify-center xs:flex'
modalClassName='max-w-xl bg-main-background w-full p-8 rounded-2xl hover-animation'
open={displayOpen}
closeModal={displayCloseModal}
>
<DisplayModal closeModal={displayCloseModal} />
</Modal>
<Modal
modalClassName='max-w-xs bg-main-background w-full p-8 rounded-2xl'
open={isSavePasskeyModalOpen}
closeModal={closeSavePasskeyModal}
>
<SavePasskeyModal
closeSavePasskeyModal={closeSavePasskeyModal}
user={user}
></SavePasskeyModal>
</Modal>
<Modal
modalClassName='max-w-xs bg-main-background w-full p-8 rounded-2xl'
open={logOutOpen}
closeModal={logOutCloseModal}
>
<ActionModal
useIcon
focusOnMainBtn
title='Log out of Opencast?'
description='You can always log back in at any time. If you just want to switch accounts, you can do that by adding an existing account.'
mainBtnLabel='Log out'
action={() => {
signOut();
logOutCloseModal();
}}
closeModal={logOutCloseModal}
/>
</Modal>
<MainHeader
useActionButton
className='flex flex-row-reverse items-center justify-between'
iconName='XMarkIcon'
title='Account info'
tip='Close'
action={closeModal}
/>
<section className='mt-0.5 flex flex-col gap-2 px-4'>
{user?.keyPair && (
<>
<Link
href={userLink}
className='blur-picture relative h-20 rounded-md'
>
{coverPhotoURL ? (
<NextImage
useSkeleton
imgClassName='rounded-md'
src={coverPhotoURL}
alt={name}
layout='fill'
/>
) : (
<div className='h-full rounded-md bg-light-line-reply dark:bg-dark-line-reply' />
)}
</Link>
<div className='-mt-4 mb-8 ml-2'>
<UserAvatar
className='absolute -translate-y-1/2 bg-main-background p-1 hover:brightness-100
[&:hover>figure>span]:brightness-75
[&>figure>span]:[transition:200ms]'
username={username}
src={photoURL}
alt={name}
size={60}
/>
</div>
</>
)}
<div className='flex flex-col gap-4 rounded-xl bg-main-sidebar-background p-4'>
{user?.keyPair && (
<>
{' '}
<div className='flex flex-col'>
<UserName
name={name}
username={username}
verified={verified}
className='-mb-1'
/>
<UserUsername username={username} />
</div>
<div className='text-secondary flex gap-4'>
{allStats.map(([id, label, stat]) => (
<Link
href={`${userLink}/${id}`}
key={id}
className='hover-animation flex h-4 items-center gap-1 border-b border-b-transparent
outline-none hover:border-b-light-primary focus-visible:border-b-light-primary
dark:hover:border-b-dark-primary dark:focus-visible:border-b-dark-primary'
>
<p className='font-bold'>{stat}</p>
<p className='text-light-secondary dark:text-dark-secondary'>
{label}
</p>
</Link>
))}
<i className='h-0.5 bg-light-line-reply dark:bg-dark-line-reply' />
</div>
</>
)}
<nav className='flex flex-col'>
{user?.keyPair && (
<>
<MobileSidebarLink
href={`/user/${username}`}
iconName='UserIcon'
linkName='Profile'
/>
<div
onClick={() => {
resetNotifications();
}}
>
{userNotifications && (
<div className='absolute ml-6 mt-2 flex h-4 min-w-[16px] items-center rounded-full bg-main-accent text-white'>
<div className='mx-auto px-1 text-xs'>
{userNotifications < 100 ? userNotifications : '99+'}
</div>
</div>
)}
<MobileSidebarLink
href='https://warpcast.com/~/notifications'
iconName='BellIcon'
linkName={`Notifications`}
newTab
/>
</div>
</>
)}
{navLinks.map((linkData) => (
<MobileSidebarLink {...linkData} key={linkData.href} />
))}
</nav>
<i className='h-0.5 bg-light-line-reply dark:bg-dark-line-reply' />
<nav className='flex flex-col'>
{bottomNavLinks.map((linkData) => (
<MobileSidebarLink bottom {...linkData} key={linkData.href} />
))}
<Button
className='accent-tab accent-bg-tab flex items-center gap-2 rounded-md p-1.5 font-bold transition
hover:bg-light-primary/10 focus-visible:ring-2 first:focus-visible:ring-[#878a8c]
dark:hover:bg-dark-primary/10 dark:focus-visible:ring-white'
onClick={displayOpenModal}
>
<HeroIcon className='h-5 w-5' iconName='PaintBrushIcon' />
Display
</Button>
{user?.keyPair && (
<>
<Button
className='accent-tab accent-bg-tab flex items-center gap-2 rounded-md p-1.5 font-bold transition
hover:bg-light-primary/10 focus-visible:ring-2 first:focus-visible:ring-[#878a8c]
dark:hover:bg-dark-primary/10 dark:focus-visible:ring-white'
onClick={openSavePasskeyModal}
>
<HeroIcon className='h-5 w-5' iconName='KeyIcon' />
Save Signer Key
</Button>
<Button
className='accent-tab accent-bg-tab flex items-center gap-2 rounded-md p-1.5 font-bold transition
hover:bg-light-primary/10 focus-visible:ring-2 first:focus-visible:ring-[#878a8c]
dark:hover:bg-dark-primary/10 dark:focus-visible:ring-white'
onClick={logOutOpenModal}
>
<HeroIcon
className='h-5 w-5'
iconName='ArrowRightOnRectangleIcon'
/>
Log out
</Button>
</>
)}
</nav>
</div>
{!user?.keyPair && (
<Link
href='/login'
className='custom-button main-tab accent-tab right-4 mt-4 bg-main-accent text-center text-lg font-bold text-white
outline-none transition hover:brightness-90 active:brightness-75 xl:w-11/12'
>
<p>Login</p>
</Link>
)}
</section>
</>
);
}
================================================
FILE: src/components/modal/modal.tsx
================================================
import { AnimatePresence, motion } from 'framer-motion';
import { Dialog } from '@headlessui/react';
import cn from 'clsx';
import type { ReactNode } from 'react';
import type { Variants } from 'framer-motion';
type ModalProps = {
open: boolean;
children: ReactNode;
className?: string;
modalAnimation?: Variants;
modalClassName?: string;
closePanelOnClick?: boolean;
closeModal: () => void;
};
const variants: Variants[] = [
{
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 }
},
{
initial: { opacity: 0, scale: 0.8 },
animate: {
opacity: 1,
scale: 1,
transition: { type: 'spring', duration: 0.5, bounce: 0.4 }
},
exit: { opacity: 0, scale: 0.8, transition: { duration: 0.15 } }
}
];
export const [backdrop, modal] = variants;
export function Modal({
open,
children,
className,
modalAnimation,
modalClassName,
closePanelOnClick,
closeModal
}: ModalProps): JSX.Element {
return (
<AnimatePresence>
{open && (
<Dialog
className='relative z-50'
open={open}
onClose={closeModal}
static
>
<motion.div
className='hover-animation override-nav fixed inset-0 bg-black/40 dark:bg-[#5B7083]/40'
aria-hidden='true'
{...backdrop}
/>
<div
className={cn(
'fixed inset-0 overflow-y-auto p-4',
className ?? 'flex items-center justify-center'
)}
>
<Dialog.Panel
className={modalClassName}
as={motion.div}
{...(modalAnimation ?? modal)}
onClick={closePanelOnClick ? closeModal : undefined}
>
{children}
</Dialog.Panel>
</div>
</Dialog>
)}
</AnimatePresence>
);
}
================================================
FILE: src/components/modal/save-passkey-modal.tsx
================================================
import { useState } from 'react';
import { ActionModal } from './action-modal';
import { createNewPasskey, storeSignerLargeBlob } from '../../lib/passkeys';
import { User } from '../../lib/types/user';
import { UserWithKey } from '../../lib/context/auth-context';
export function SavePasskeyModal({
user,
closeSavePasskeyModal
}: {
user: UserWithKey | null;
closeSavePasskeyModal: () => void;
}) {
const [passkeyCreated, setPasskeyCreated] = useState(false);
return (
<ActionModal
useIcon
focusOnMainBtn
title='Save your Signer Key?'
description='This will save your Farcaster signer to a passkey which can be used to import your signer on other devices.'
mainBtnLabel='Load Passkey'
secondaryBtnLabel='Create New Passkey'
secondaryAction={
!passkeyCreated
? async () => {
if (!user?.keyPair) return;
createNewPasskey({
user,
onPasskeyCreated: setPasskeyCreated
});
}
: undefined
}
action={async () => {
if (!user?.keyPair) return;
storeSignerLargeBlob({
privateKey: user.keyPair.privateKey,
onSignerStored: (created) => {
if (created) {
closeSavePasskeyModal();
}
}
});
}}
closeModal={closeSavePasskeyModal}
/>
);
}
================================================
FILE: src/components/modal/sign-in-modal-wallet.tsx
================================================
import { Dialog } from '@headlessui/react';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import { encodeAbiParameters } from 'viem';
import {
useAccount,
useChainId,
useSwitchChain,
useWaitForTransactionReceipt,
useWriteContract
} from 'wagmi';
import { KEY_GATEWAY } from '../../contracts';
import { useAuth } from '../../lib/context/auth-context';
import { generateKeyPair } from '../../lib/crypto';
import { fetchJSON } from '../../lib/fetch';
import useFid from '../../lib/hooks/useConnectedWalletFid';
import { addKeyPair } from '../../lib/keys';
import { AppAuthResponse, AppAuthType } from '../../lib/types/app-auth';
import { KeyPair } from '../../lib/types/keypair';
import { User, UserResponse } from '../../lib/types/user';
import { truncateAddress } from '../../lib/utils';
import { Modal } from '../modal/modal';
import { Button } from '../ui/button';
import { Loading } from '../ui/loading';
import { UserAvatar } from '../user/user-avatar';
import { UserName } from '../user/user-name';
import { UserUsername } from '../user/user-username';
import { add } from 'lodash';
const KEY_METADATA_TYPE_1 = [
{
components: [
{
internalType: 'uint256',
name: 'requestFid',
type: 'uint256'
},
{
internalType: 'address',
name: 'requestSigner',
type: 'address'
},
{
internalType: 'bytes',
name: 'signature',
type: 'bytes'
},
{
internalType: 'uint256',
name: 'deadline',
type: 'uint256'
}
],
internalType: 'struct SignedKeyRequestValidator.SignedKeyRequestMetadata',
name: 'metadata',
type: 'tuple'
}
] as const;
const PENDING_KEY_REQUEST = '-opencast-pendingSignerRequest';
type KeyRequest =
| {
keyPair: KeyPair;
authorization: AppAuthType;
state: 'pending' | 'authorized' | 'completed';
}
| {
state: 'preparing';
keyPair: KeyPair;
};
const WalletSignInModal = ({
closeModal,
open
}: {
closeModal: () => void;
open: boolean;
}) => {
const { handleUserAuth } = useAuth();
const chainId = useChainId();
const { switchChain } = useSwitchChain();
const { address } = useAccount();
const { data: idOf } = useFid();
const { data: user, isValidating: loadingUser } = useSWR<User | null>(
idOf ? `/api/user/${idOf}?full=false` : null,
async (url) => (await fetchJSON<UserResponse>(url)).result || null,
{}
);
// const { data: appAuth, isValidating: appAuthLoading } =
// useSWR<AppAuthType | null>(
// keypair ? `/api/signer/${keypair.publicKey}/authorize` : null,
// async (url) => (await fetchJSON<AppAuthResponse>(url)).result || null
// );
const [appAuthLoading, setAppAuthLoading] = useState<boolean>(false);
const [pendingRequest, setPendingRequest] = useState<KeyRequest | null>(null);
const [polling, setPolling] = useState<boolean>(false);
const {
writeContract: addKey,
data: addKeyTxHash,
isPending: addKeySignPending,
isSuccess: addKeySignSuccess,
error: addKeyError
} = useWriteContract();
const { isSuccess: isAddKeyTxSuccess, isLoading: isAddKeyTxLoading } =
useWaitForTransactionReceipt({ hash: addKeyTxHash });
useEffect(() => {}, [addKeyTxHash]);
useEffect(() => {
if (pendingRequest) {
localStorage.setItem(PENDING_KEY_REQUEST, JSON.stringify(pendingRequest));
if (pendingRequest?.state === 'preparing') {
setAppAuthLoading(true);
fetchJSON<AppAuthResponse>(
`/api/signer/${pendingRequest.keyPair.publicKey}/authorize`
)
.then(({ result: authorizationResult, message }) => {
if (!authorizationResult) {
console.error('Error generating signature', message);
return;
}
setAppAuthLoading(false);
setPendingRequest({
...pendingRequest,
authorization: authorizationResult,
state: 'authorized'
});
})
.catch((e) => {
setAppAuthLoading(false);
});
} else if (pendingRequest.state === 'authorized' && addKeyTxHash) {
setPendingRequest({
...pendingRequest,
state: 'pending'
});
} else if (pendingRequest.state === 'pending') {
if (!polling) {
pollForSigner();
}
} else if (pendingRequest.state === 'completed') {
addKeyPair(pendingRequest.keyPair);
localStorage.removeItem(PENDING_KEY_REQUEST);
handleUserAuth(pendingRequest.keyPair);
closeModal?.();
}
}
}, [pendingRequest, addKeyTxHash]);
useEffect(() => {
// Load existing request
const pendingKeyRequest = localStorage.getItem(
PENDING_KEY_REQUEST
) as string;
let request: KeyRequest | undefined;
if (pendingKeyRequest) {
const parsed: KeyRequest = JSON.parse(pendingKeyRequest);
// Check that request is still valid
if (
parsed.state !== 'preparing' &&
parsed.authorization.deadline &&
parsed.authorization.deadline > Math.floor(Date.now() / 1000)
) {
request = parsed;
}
}
if (!request) {
newKeyPair();
return;
}
setPendingRequest(request);
}, []);
const newKeyPair = () => {
generateKeyPair().then((keypair) => {
setPendingRequest({
keyPair: keypair,
state: 'preparing'
});
});
};
const pollForSigner = async () => {
if (pendingRequest?.state !== 'pending') return;
setPolling(true);
let tries = 0;
// TODO: Loading indicators
while (true || tries < 40) {
tries += 1;
await new Promise((r) => setTimeout(r, 2000));
const { result } = await fetchJSON<UserResponse>(
`/api/signer/${pendingRequest.keyPair.publicKey}/user`
);
if (result?.id) {
break;
}
}
setPolling(false);
setPendingRequest({
...pendingRequest,
state: 'completed'
});
};
return (
<Modal
className='flex items-start justify-center'
modalClassName='bg-main-background rounded-2xl max-w-xl p-4 overflow-hidden flex justify-center'
open={open}
closeModal={closeModal}
>
<div>
<div className='flex flex-col gap-2'>
<div className='flex'>
<Dialog.Title className='flex-grow text-xl font-bold'>
Sign in with Ethereum Wallet
</Dialog.Title>
<button onClick={closeModal}>x</button>
</div>
<Dialog.Description className='text-light-secondary dark:text-dark-secondary'>
Connect your wallet below to get started.
</Dialog.Description>
</div>
<div className='flex flex-col justify-center gap-4 p-8 pb-4'>
<div className={`p-2 pl-0`} data-rk='data-rk'>
<ConnectButton
chainStatus={'icon'}
showBalance={true}
></ConnectButton>
</div>
{address && (
<>
{chainId !== 10 && (
<div>
<div>Please connect to the Optimism network</div>
<Button
className='accent-tab mt-2 flex items-center justify-center bg-main-accent font-bold text-white enabled:hover:bg-main-accent/90 enabled:active:bg-main-accent/75'
onClick={() => switchChain({ chainId: 10 })}
>
Switch to Optimism
</Button>
</div>
)}
{!idOf && chainId === 10 && (
<div>
This address is not registered on the Farcaster network.
</div>
)}
{idOf &&
chainId === 10 &&
(loadingUser || appAuthLoading ? (
<Loading />
) : (
user &&
pendingRequest?.state === 'authorized' && (
<div>
<div className='flex flex-wrap items-center gap-2'>
<div className='flex flex-grow gap-3 truncate'>
<UserAvatar
src={user.photoURL}
alt={user.name}
size={40}
/>
<div className='hidden truncate text-start leading-5 xl:block'>
<UserName
name={user.name}
className='start'
verified={user.verified}
/>
<UserUsername
username={user.username}
disableLink
/>
</div>
</div>
{addKeySignPending || isAddKeyTxLoading ? (
<Loading></Loading>
) : (
!isAddKeyTxSuccess &&
pendingRequest.state === 'authorized' &&
pendingRequest?.keyPair && (
<Button
disabled={addKeySignPending}
onClick={() => {
addKey({
...KEY_GATEWAY,
chainId: 10,
functionName: 'add',
args: [
1,
pendingRequest.keyPair.publicKey,
1,
encodeAbiParameters(KEY_METADATA_TYPE_1, [
{
requestFid: BigInt(
pendingRequest.authorization
.requestFid
),
requestSigner: pendingRequest
.authorization
.requestSigner as `0x${string}`,
signature: pendingRequest.authorization
.signature as `0x${string}`,
deadline: BigInt(
pendingRequest.authorization.deadline
)
}
])
]
});
}}
className='accent-tab flex-grow items-center justify-center bg-main-accent font-bold text-white enabled:hover:bg-main-accent/90 enabled:active:bg-main-accent/75'
>
Sign in
</Button>
)
)}
</div>
{pendingRequest?.keyPair && (
<div className='mt-2 break-all text-center text-sm text-light-secondary dark:text-dark-secondary'>
Authorizing{' '}
<span title={pendingRequest.keyPair.publicKey}>
{truncateAddress(pendingRequest.keyPair.publicKey)}{' '}
</span>
<button className='underline' onClick={newKeyPair}>
Reset
</button>
</div>
)}
{addKeyTxHash && (
<a
href={`https://optimistic.etherscan.io/tx/${addKeyTxHash}`}
target='_blank'
rel='noopener noreferrer'
className='text-center text-sm text-light-secondary underline dark:text-dark-secondary'
>
View transaction
</a>
)}
</div>
)
))}
</>
)}
</div>
</div>
</Modal>
);
};
export default WalletSignInModal;
================================================
FILE: src/components/modal/sign-in-modal-warpcast.tsx
================================================
import { Dialog } from '@headlessui/react';
import WarpcastAuthPopup from '../login/sign-in-with-warpcast';
import { Modal } from './modal';
export function WarpcastSignInModal({
open,
closeModal
}: {
open: boolean;
closeModal: () => void;
}) {
return (
<Modal
className='flex items-start justify-center'
modalClassName='bg-main-background rounded-2xl max-w-xl p-4 overflow-hidden flex justify-center'
open={open}
closeModal={closeModal}
>
<div>
<div className='flex flex-col gap-2'>
<div className='flex'>
<Dialog.Title className='flex-grow text-xl font-bold'>
Sign in with Warpcast
</Dialog.Title>
<button onClick={closeModal}>x</button>
</div>
<Dialog.Description className='text-light-secondary dark:text-dark-secondary'>
Scan the QR code with the camera app on your device with Warpcast
installed.
</Dialog.Description>
</div>
<div className='flex justify-center p-8'>
<WarpcastAuthPopup closeModal={closeModal}></WarpcastAuthPopup>
</div>
</div>
</Modal>
);
}
================================================
FILE: src/components/modal/tip-modal.tsx
================================================
import { Dialog } from '@headlessui/react';
import { ConnectButton } from '@rainbow-me/rainbowkit';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { toast } from 'react-hot-toast';
import { parseEther } from 'viem';
import { useAccount, useChainId, useSendTransaction } from 'wagmi';
import * as chains from 'wagmi/chains';
import { UserFull } from '../../lib/types/user';
import { truncateAddress } from '../../lib/utils';
import { Button } from '../ui/button';
import { HeroIcon } from '../ui/hero-icon';
import { Loading } from '../ui/loading';
import { Modal } from './modal';
interface TipModalProps {
tipUserOpen: boolean;
tipCloseModal: () => void;
isUserLoading: boolean;
user?: UserFull;
username: string;
}
export function TipModal({
tipCloseModal,
tipUserOpen,
isUserLoading,
user,
username
}: TipModalProps) {
const { address: currentUserAddress } = useAccount();
const chainId = useChainId();
const [tipAmount, setTipAmount] = useState<number>(0.001);
const {
data: tipTxHash,
isPending: tipTxResultLoading,
isSuccess: tipTxSuccess,
sendTransaction: sendTipTx
} = useSendTransaction();
useEffect(() => {
if (!tipTxSuccess) return;
tipCloseModal();
const chainById = Object.values(chains).reduce(
(acc: { [key: string]: chains.Chain }, cur) => {
if (cur.id) acc[cur.id] = cur;
return acc;
},
{}
);
const chain = chainById[chainId];
const explorerUrl = chain?.blockExplorers?.default;
const url = `${explorerUrl?.url}/tx/${tipTxHash}`;
if (!url) return;
toast.success(
() => (
<span className='flex gap-2'>
Your tip was sent
<Link
href={`${explorerUrl?.url}/tx/${tipTxHash}`}
className='custom-underline font-bold'
target='_blank'
>
View
</Link>
</span>
),
{ duration: 6000 }
);
}, [tipTxSuccess]);
return (
<Modal
modalClassName='max-w-sm bg-main-background w-full p-8 rounded-2xl'
open={tipUserOpen}
closeModal={tipCloseModal}
>
<div className='flex flex-col gap-6'>
<div className='flex flex-col gap-4'>
<div className='flex flex-col gap-2'>
<div className='flex items-center'>
<i className='inline pr-2'>
<HeroIcon iconName='BanknotesIcon' />
</i>
<Dialog.Title className='inline text-xl font-bold'>
Tip user
</Dialog.Title>
</div>
<Dialog.Description className='text-light-secondary dark:text-dark-secondary'>
Send @{username} some ETH
</Dialog.Description>
</div>
{isUserLoading ? (
<Loading />
) : (
<div className='flex flex-col'>
<div className={`p-2 pl-0`} data-rk='data-rk'>
<ConnectButton></ConnectButton>
</div>
{user?.address ? (
currentUserAddress && (
<div className='mt-4 flex flex-col gap-4'>
<div className='flex justify-center gap-2'>
{[0.0005, 0.001, 0.002].map((amount) => (
<button
key={amount}
onClick={() => setTipAmount(amount)}
className={`rounded-full p-2
${
tipAmount === amount
? 'border-2 border-main-accent font-bold text-main-accent'
: 'border border-gray-500 text-gray-500'
}`}
>
{amount}
</button>
))}
</div>
{!tipTxResultLoading && user.address ? (
<Button
className='accent-tab mt-2 flex items-center justify-center bg-main-accent font-bold text-white enabled:hover:bg-main-accent/90 enabled:active:bg-main-accent/75'
onClick={() => {
sendTipTx({
to: user?.address as `0x${string}`,
value: parseEther(tipAmount.toString())
});
}}
disabled={tipTxResultLoading || tipAmount === 0}
>
Send{' '}
{tipAmount.toLocaleString(undefined, {
maximumFractionDigits: 6
})}{' '}
ETH
</Button>
) : (
<Loading></Loading>
)}
<div className='w-full text-center text-gray-500'>
to @{user.username}{' '}
<span title={user.address}>
({truncateAddress(user.address)})
</span>
</div>
</div>
)
) : (
<div>User doesn't have an address connected</div>
)}
</div>
)}
</div>
</div>
</Modal>
);
}
================================================
FILE: src/components/modal/tweet-reply-modal.tsx
================================================
import { Input } from '@components/input/input';
import { Tweet } from '@components/tweet/tweet';
import type { TweetProps } from '@components/tweet/tweet';
type TweetReplyModalProps = {
tweet: TweetProps;
closeModal: () => void;
};
export function TweetReplyModal({
tweet,
closeModal
}: TweetReplyModalProps): JSX.Element {
return (
<Input
modal
replyModal
parent={{
id: tweet.id,
username: tweet.user.username,
userId: tweet.user.id
}}
closeModal={closeModal}
parentUrl={tweet.topicUrl || undefined}
>
<Tweet modal parentTweet {...tweet} />
</Input>
);
}
================================================
FILE: src/components/modal/tweet-stats-modal.tsx
================================================
import { MainHeader } from '@components/home/main-header';
import type { ReactNode } from 'react';
import type { StatsType } from '@components/view/view-tweet-stats';
type TweetStatsModalProps = {
children: ReactNode;
statsType: StatsType | null;
handleClose: () => void;
};
export function TweetStatsModal({
children,
statsType,
handleClose
}: TweetStatsModalProps): JSX.Element {
return (
<>
<MainHeader
useActionButton
disableSticky
tip='Close'
iconName='XMarkIcon'
className='absolute flex w-full items-center gap-6 rounded-tl-2xl'
title={`${statsType === 'likes' ? 'Liked' : 'Recasted'} by`}
action={handleClose}
/>
{children}
</>
);
}
================================================
FILE: src/components/modal/username-modal.tsx
================================================
import { Dialog } from '@headlessui/react';
import { CustomIcon } from '@components/ui/custom-icon';
import { Button } from '@components/ui/button';
import type { ReactNode, FormEvent } from 'react';
type UsernameModalProps = {
loading: boolean;
children: ReactNode;
available: boolean;
alreadySet: boolean;
changeUsername: (e: FormEvent<HTMLFormElement>) => Promise<void>;
cancelUpdateUsername: () => void;
};
const usernameModalData = [
{
title: 'What should we call you?',
description: 'Your @username is unique. You can always change it later.',
cancelLabel: 'Skip'
},
{
title: 'Change your username?',
description:
'Your @username is unique. You can always change it here again.',
cancelLabel: 'Cancel'
}
] as const;
export function UsernameModal({
loading,
children,
available,
alreadySet,
changeUsername,
cancelUpdateUsername
}: UsernameModalProps): JSX.Element {
const { title, description, cancelLabel } = usernameModalData[+alreadySet];
return (
<form
className='flex h-full flex-col justify-between'
onSubmit={changeUsername}
>
<div className='flex flex-col gap-6'>
<div className='flex flex-col gap-4'>
<i className='mx-auto'>
<CustomIcon className='h-10 w-10' iconName='TwitterIcon' />
</i>
<div className='flex flex-col gap-2'>
<Dialog.Title className='text-2xl font-bold xs:text-3xl sm:text-4xl'>
{title}
</Dialog.Title>
<Dialog.Description className='text-light-secondary dark:text-dark-secondary'>
{description}
</Dialog.Description>
</div>
</div>
{children}
</div>
<div className='flex flex-col gap-3 inner:py-2 inner:font-bold'>
<Button
className='bg-light-primary text-white transition focus-visible:bg-light-primary/90
enabled:hover:bg-light-primary/90 enabled:active:bg-light-primary/80
dark:bg-light-border dark:text-light-primary dark:focus-visible:bg-light-border/90
dark:enabled:hover:bg-light-border/90 dark:enabled:active:bg-light-border/75'
type='submit'
loading={loading}
disabled={!available}
>
Set username
</Button>
<Button
className='border border-light-line-reply hover:bg-light-primary/10 focus-visible:bg-light-primary/10
active:bg-light-primary/20 dark:border-light-secondary dark:text-light-border
dark:hover:bg-light-border/10 dark:focus-visible:bg-light-border/10
dark:active:bg-light-border/20'
onClick={cancelUpdateUsername}
>
{cancelLabel}
</Button>
</div>
</form>
);
}
================================================
FILE: src/components/search/search-topics.tsx
================================================
import { useState } from 'react';
import useSWR from 'swr';
import isURL from 'validator/lib/isURL';
import { fetchJSON } from '../../lib/fetch';
import { TopicType } from '../../lib/types/topic';
import { TrendsResponse } from '../../lib/types/trends';
import { SearchBar } from '../input/search-bar';
import { TopicView } from '../tweet/tweet-topic';
import { Loading } from '../ui/loading';
export function SearchTopics({
onSelectRawUrl,
onSelectTopic,
setShowing,
enabled = false
}: {
onSelectRawUrl: (topicUrl: string) => void;
onSelectTopic: (topic: TopicType) => void;
setShowing: (showing: boolean) => void;
enabled: boolean;
}) {
const [topicQuery, setTopicQuery] = useState('');
const { data: allTopics, isValidating: loadingAllTopics } = useSWR(
enabled ? `/api/trends?limit=50` : null,
async (url) => {
const res = await fetchJSON<TrendsResponse>(url);
return res.result;
},
{ revalidateOnFocus: false }
);
return (
<div>
<SearchBar
placeholder='Search topics or paste a link'
inputValue={topicQuery}
setInputValue={setTopicQuery}
onChange={(e) => setTopicQuery(e.target.value)}
/>
<div>
{loadingAllTopics && <Loading></Loading>}
{isURL(topicQuery) && (
<div
onClick={() => {
onSelectRawUrl(topicQuery);
setTopicQuery('');
setShowing(false);
}}
className='mt-2 cursor-pointer rounded-lg p-2 text-light-secondary hover:bg-main-accent/10 dark:text-dark-secondary'
>
Choose "{topicQuery}"
</div>
)}
<div className='mt-2 flex flex-wrap gap-2'>
{allTopics
?.filter(
({ topic }) =>
topic !== null &&
(topicQuery.length === 0 ||
topic?.name
.toLowerCase()
.includes(topicQuery.toLowerCase()) ||
topic?.url.toLowerCase().includes(topicQuery.toLowerCase()))
)
.slice(0, 5)
.map(({ topic }, i) => (
<div
onClick={() => {
onSelectTopic(topic as TopicType);
setTopicQuery('');
setShowing(false);
}}
key={i}
className='cursor-pointer rounded-lg p-2 text-light-secondary hover:bg-main-accent/10 dark:text-dark-secondary'
>
<TopicView topic={topic!} key={i} />
</div>
))}
</div>
</div>
</div>
);
}
================================================
FILE: src/components/search/user-search-result.tsx
================================================
import { User } from '../../lib/types/user';
import { NextImage } from '../ui/next-image';
export function UserSearchResult({
user,
callback
}: {
user: User;
callback?: () => void;
}) {
const { id, username, photoURL, name, verified } = user;
return (
<div
key={id}
className='flex w-full cursor-pointer p-3 hover:bg-accent-blue/10 focus-visible:bg-accent-blue/20'
onClick={callback}
>
<NextImage
useSkeleton
imgClassName='rounded-full'
width={48}
height={48}
src={photoURL}
alt={username}
key={photoURL}
/>
<div className='flex flex-col pl-2'>
<span className='text-light-primary dark:text-dark-primary'>
{name}
</span>
<span className='truncate text-light-secondary dark:text-dark-secondary'>
@{username}
</span>
</div>
</div>
);
}
================================================
FILE: src/components/sidebar/menu-link.tsx
================================================
import { forwardRef } from 'react';
import Link from 'next/link';
import type { ComponentPropsWithRef } from 'react';
type MenuLinkProps = ComponentPropsWithRef<'a'> & {
href: string;
};
export const MenuLink = forwardRef<HTMLAnchorElement, MenuLinkProps>(
({ href, children, ...rest }, ref) => (
<Link href={href} ref={ref} {...rest}>
{children}
</Link>
)
);
================================================
FILE: src/components/sidebar/mobile-sidebar-link.tsx
================================================
import Link from 'next/link';
import cn from 'clsx';
import { preventBubbling } from '@lib/utils';
import { HeroIcon } from '@components/ui/hero-icon';
import type { MobileNavLink } from '@components/modal/mobile-sidebar-modal';
type MobileSidebarLinkProps = MobileNavLink & {
bottom?: boolean;
};
export function MobileSidebarLink({
href,
bottom,
linkName,
iconName,
disabled,
newTab
}: MobileSidebarLinkProps): JSX.Element {
return (
<Link
href={href}
key={href}
className={cn(
`custom-button accent-tab accent-bg-tab flex items-center rounded-md font-bold
transition hover:bg-light-primary/10 focus-visible:ring-2 first:focus-visible:ring-[#878a8c]
dark:hover:bg-dark-primary/10 dark:focus-visible:ring-white`,
bottom ? 'gap-2 p-1.5 text-base' : 'gap-4 p-2 text-xl',
disabled && 'cursor-not-allowed'
)}
onClick={disabled ? preventBubbling() : undefined}
target={newTab ? '_blank' : undefined}
>
<HeroIcon
className={bottom ? 'h-5 w-5' : 'h-7 w-7'}
iconName={iconName}
/>
{linkName}
</Link>
);
}
================================================
FILE: src/components/sidebar/mobile-sidebar.tsx
================================================
import { useAuth } from '@lib/context/auth-context';
import { useModal } from '@lib/hooks/useModal';
import { Button } from '@components/ui/button';
import { Modal } from '@components/modal/modal';
import { MobileSidebarModal } from '@components/modal/mobile-sidebar-modal';
import { UserAvatar } from '@components/user/user-avatar';
import type { Variants } from 'framer-motion';
import type { User, UserFull } from '@lib/types/user';
import { HeroIcon } from '../ui/hero-icon';
const variant: Variants = {
initial: { x: '-100%', opacity: 0.8 },
animate: {
x: -8,
opacity: 1,
transition: { type: 'spring', duration: 0.8 }
},
exit: { x: '-100%', opacity: 0.8, transition: { duration: 0.4 } }
};
export function MobileSidebar(): JSX.Element {
const { user } = useAuth();
const { photoURL, name } = user!;
const { open, openModal, closeModal } = useModal();
return (
<>
<Modal
className='p-0'
modalAnimation={variant}
modalClassName='pb-4 pl-2 min-h-screen w-72 bg-main-background'
open={open}
closeModal={closeModal}
>
<MobileSidebarModal {...user!} closeModal={closeModal} />
</Modal>
<Button className='accent-tab p-0 xs:hidden' onClick={openModal}>
{user?.keyPair ? (
<UserAvatar src={photoURL} alt={name} size={30} />
) : (
<div className='py-2'>
<HeroIcon className={'h-7 w-7'} iconName={'UserIcon'} />
</div>
)}
</Button>
</>
);
}
================================================
FILE: src/components/sidebar/more-settings.tsx
================================================
import { DisplayModal } from '@components/modal/display-modal';
import { Modal } from '@components/modal/modal';
import { Button } from '@components/ui/button';
import { HeroIcon } from '@components/ui/hero-icon';
import { Menu } from '@headlessui/react';
import { useModal } from '@lib/hooks/useModal';
import { ConnectButton, useConnectModal } from '@rainbow-me/rainbowkit';
import cn from 'clsx';
import type { Variants } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import { useAccount } from 'wagmi';
export const variants: Variants = {
initial: { opacity: 0, y: 50 },
animate: {
opacity: 1,
y: 0,
transition: { type: 'spring', duration: 0.4 }
},
exit: { opacity: 0, y: 50, transition: { duration: 0.2 } }
};
export function MoreSettings(): JSX.Element {
const { open, openModal, closeModal } = useModal();
const { address } = useAccount();
const { openConnectModal } = useConnectModal();
return (
<>
<Modal
modalClassName='max-w-xl bg-main-background w-full p-8 rounded-2xl hover-animation'
open={open}
closeModal={closeModal}
>
<DisplayModal closeModal={closeModal} />
</Modal>
<Menu className='relative' as='div'>
{({ open }): JSX.Element => (
<>
<Menu.Button className='group relative flex w-full py-1 outline-none'>
<div
className={cn(
`custom-button flex gap-4 text-xl transition group-hover:bg-light-primary/10 group-focus-visible:ring-2
group-focus-visible:ring-[#878a8c] dark:group-hover:bg-dark-primary/10 dark:group-focus-visible:ring-white
xl:pr-5`,
open && 'bg-light-primary/10 dark:bg-dark-primary/10'
)}
>
<HeroIcon
className='h-7 w-7'
iconName='EllipsisHorizontalCircleIcon'
/>{' '}
<p className='hidden xl:block'>More</p>
</div>
</Menu.Button>
<AnimatePresence>
{open && (
<Menu.Items
className='menu-container absolute w-60 font-medium xl:w-11/12'
as={motion.div}
{...variants}
static
>
<Menu.Item>
{({ active }): JSX.Element => (
<Button
className={cn(
'flex w-full gap-3 rounded-none rounded-b-md p-4 duration-200',
active
gitextract_cyfq76i2/ ├── .eslintignore ├── .eslintrc.json ├── .eslintrc.json.bak ├── .github/ │ └── workflows/ │ └── deployment.yaml ├── .gitignore ├── .husky/ │ └── pre-commit.bak ├── .prettierignore ├── .prettierrc.json ├── .vscode/ │ └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── jest.config.js ├── next.config.js ├── nixpacks.toml ├── package.json ├── postcss.config.js ├── prisma/ │ └── schema.prisma ├── public/ │ └── site.webmanifest ├── src/ │ ├── app/ │ │ └── frames/ │ │ └── route.ts │ ├── components/ │ │ ├── aside/ │ │ │ ├── aside-footer.tsx │ │ │ ├── aside-trends.tsx │ │ │ ├── aside.tsx │ │ │ ├── search-bar.tsx │ │ │ ├── suggestions.tsx.bak │ │ │ └── trends.tsx │ │ ├── common/ │ │ │ ├── app-head.tsx │ │ │ ├── load-more.tsx │ │ │ ├── placeholder.tsx │ │ │ └── seo.tsx │ │ ├── feed/ │ │ │ └── tweet-feed.tsx │ │ ├── frames/ │ │ │ ├── Frame.tsx │ │ │ └── frame-ui.tsx │ │ ├── home/ │ │ │ ├── main-container.tsx │ │ │ ├── main-header.tsx │ │ │ └── update-username.tsx.bak │ │ ├── input/ │ │ │ ├── image-preview.tsx │ │ │ ├── input-accent-radio.tsx │ │ │ ├── input-field.tsx │ │ │ ├── input-form.tsx │ │ │ ├── input-options.tsx │ │ │ ├── input-theme-radio.tsx │ │ │ ├── input.tsx │ │ │ ├── progress-bar.tsx │ │ │ └── search-bar.tsx │ │ ├── layout/ │ │ │ ├── auth-layout.tsx │ │ │ ├── common-layout.tsx │ │ │ ├── main-layout.tsx │ │ │ ├── user-data-layout.tsx │ │ │ ├── user-follow-layout.tsx │ │ │ └── user-home-layout.tsx │ │ ├── login/ │ │ │ ├── login-footer.tsx │ │ │ ├── login-main.tsx │ │ │ └── sign-in-with-warpcast.tsx │ │ ├── modal/ │ │ │ ├── action-modal.tsx │ │ │ ├── display-modal.tsx │ │ │ ├── edit-profile-modal.tsx │ │ │ ├── image-modal.tsx │ │ │ ├── mobile-sidebar-modal.tsx │ │ │ ├── modal.tsx │ │ │ ├── save-passkey-modal.tsx │ │ │ ├── sign-in-modal-wallet.tsx │ │ │ ├── sign-in-modal-warpcast.tsx │ │ │ ├── tip-modal.tsx │ │ │ ├── tweet-reply-modal.tsx │ │ │ ├── tweet-stats-modal.tsx │ │ │ └── username-modal.tsx │ │ ├── search/ │ │ │ ├── search-topics.tsx │ │ │ └── user-search-result.tsx │ │ ├── sidebar/ │ │ │ ├── menu-link.tsx │ │ │ ├── mobile-sidebar-link.tsx │ │ │ ├── mobile-sidebar.tsx │ │ │ ├── more-settings.tsx │ │ │ ├── sidebar-link.tsx │ │ │ ├── sidebar-profile.tsx │ │ │ └── sidebar.tsx │ │ ├── sync/ │ │ │ └── sync-view.tsx │ │ ├── tweet/ │ │ │ ├── number-stats.tsx │ │ │ ├── stats-empty.tsx │ │ │ ├── tweet-actions.tsx │ │ │ ├── tweet-date.tsx │ │ │ ├── tweet-embed.tsx │ │ │ ├── tweet-option.tsx │ │ │ ├── tweet-parent.tsx │ │ │ ├── tweet-parent.tsx.bak │ │ │ ├── tweet-share.tsx │ │ │ ├── tweet-stats.tsx │ │ │ ├── tweet-status.tsx │ │ │ ├── tweet-text.tsx │ │ │ ├── tweet-topic.tsx │ │ │ ├── tweet-with-parent.tsx │ │ │ ├── tweet-with-parent.tsx.bak │ │ │ └── tweet.tsx │ │ ├── ui/ │ │ │ ├── button.tsx │ │ │ ├── caution-warn.tsx │ │ │ ├── custom-icon.tsx │ │ │ ├── error.tsx │ │ │ ├── feed-ordering-selector.tsx │ │ │ ├── follow-button.tsx │ │ │ ├── hero-icon.tsx │ │ │ ├── loading.tsx │ │ │ ├── menu-row.tsx │ │ │ ├── next-image.tsx │ │ │ ├── segmented-nav-link.tsx │ │ │ └── tooltip.tsx │ │ ├── user/ │ │ │ ├── user-avatar.tsx │ │ │ ├── user-card.tsx │ │ │ ├── user-cards.tsx │ │ │ ├── user-details.tsx │ │ │ ├── user-edit-profile.tsx │ │ │ ├── user-fid.tsx │ │ │ ├── user-follow-stats.tsx │ │ │ ├── user-follow.tsx │ │ │ ├── user-following.tsx │ │ │ ├── user-header.tsx │ │ │ ├── user-home-avatar.tsx │ │ │ ├── user-home-cover.tsx │ │ │ ├── user-known-followers.tsx │ │ │ ├── user-name.tsx │ │ │ ├── user-nav.tsx │ │ │ ├── user-share.tsx │ │ │ ├── user-tooltip.tsx │ │ │ └── user-username.tsx │ │ └── view/ │ │ ├── view-parent-tweet.tsx │ │ ├── view-tweet-stats.tsx │ │ └── view-tweet.tsx │ ├── contracts/ │ │ ├── id-registry.ts │ │ ├── index.ts │ │ ├── key-gateway.ts │ │ ├── key-registry.ts │ │ └── validator.ts │ ├── lib/ │ │ ├── api/ │ │ │ ├── auth.ts │ │ │ └── trends.ts │ │ ├── chains/ │ │ │ └── resolve-chain-icon.ts │ │ ├── context/ │ │ │ ├── auth-context.tsx │ │ │ ├── theme-context.tsx │ │ │ ├── user-context.tsx │ │ │ └── window-context.tsx │ │ ├── crypto.ts │ │ ├── date.ts │ │ ├── embeds.ts │ │ ├── env.ts │ │ ├── farcaster/ │ │ │ ├── index.ts │ │ │ └── utils.ts │ │ ├── fetch.ts │ │ ├── hooks/ │ │ │ ├── useConnectedWalletFid.tsx │ │ │ ├── useInfiniteScroll.tsx │ │ │ ├── useInfiniteScrollUsers.tsx │ │ │ ├── useModal.ts │ │ │ └── useRequireAuth.ts │ │ ├── imgur/ │ │ │ └── upload.ts │ │ ├── keys.ts │ │ ├── lru-cache.ts │ │ ├── merge.ts │ │ ├── paginated-reactions.ts │ │ ├── paginated-tweets.ts │ │ ├── passkeys.ts │ │ ├── prisma.ts │ │ ├── random.ts │ │ ├── signers.ts │ │ ├── topics/ │ │ │ └── resolve-topic.ts │ │ ├── types/ │ │ │ ├── app-auth.ts │ │ │ ├── available.ts │ │ │ ├── bookmark.ts │ │ │ ├── feed.ts │ │ │ ├── file.ts │ │ │ ├── keypair.ts │ │ │ ├── notifications.ts │ │ │ ├── online.ts │ │ │ ├── place.ts │ │ │ ├── responses.ts │ │ │ ├── signer.ts │ │ │ ├── stats.ts │ │ │ ├── theme.ts │ │ │ ├── topic.ts │ │ │ ├── trends.ts │ │ │ ├── tweet.ts │ │ │ └── user.ts │ │ ├── user/ │ │ │ └── resolve-user.ts │ │ ├── utils.ts │ │ └── validation.ts │ ├── pages/ │ │ ├── 404.tsx │ │ ├── [...redirect].tsx │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── api/ │ │ │ ├── embeds.ts │ │ │ ├── feed.ts │ │ │ ├── hub/ │ │ │ │ ├── batch.ts │ │ │ │ └── index.ts │ │ │ ├── online/ │ │ │ │ └── index.ts │ │ │ ├── search.ts │ │ │ ├── signer/ │ │ │ │ └── [pubKey]/ │ │ │ │ ├── authorize.ts │ │ │ │ └── user.ts │ │ │ ├── topic/ │ │ │ │ └── index.ts │ │ │ ├── trends/ │ │ │ │ └── index.ts │ │ │ ├── tweet/ │ │ │ │ ├── [id]/ │ │ │ │ │ ├── engagers.ts │ │ │ │ │ ├── index.ts │ │ │ │ │ └── replies.ts │ │ │ │ └── batch.ts │ │ │ └── user/ │ │ │ ├── [id]/ │ │ │ │ ├── index.ts │ │ │ │ ├── interests.ts │ │ │ │ ├── known-followers.ts │ │ │ │ ├── likes.ts │ │ │ │ ├── links.ts │ │ │ │ ├── notifications.ts │ │ │ │ ├── signers/ │ │ │ │ │ ├── [pubKey]/ │ │ │ │ │ │ ├── backup.ts │ │ │ │ │ │ ├── casts.ts │ │ │ │ │ │ └── index.ts │ │ │ │ │ └── index.ts │ │ │ │ ├── sync.ts │ │ │ │ └── tweets.ts │ │ │ └── resolve-usernames.ts │ │ ├── bookmarks.tsx.bak │ │ ├── home.tsx │ │ ├── index.tsx │ │ ├── login.tsx │ │ ├── notifications.tsx │ │ ├── people.tsx.bak │ │ ├── settings/ │ │ │ ├── index.tsx │ │ │ └── manage-signers/ │ │ │ ├── [pubKey].tsx │ │ │ └── index.tsx │ │ ├── topic/ │ │ │ └── index.tsx │ │ ├── trends.tsx │ │ ├── tweet/ │ │ │ └── [id].tsx │ │ └── user/ │ │ └── [id]/ │ │ ├── followers.tsx │ │ ├── following.tsx │ │ ├── index.tsx │ │ ├── likes.tsx │ │ ├── media.tsx.bak │ │ ├── with_replies.tsx │ │ └── with_replies.tsx.bak │ └── styles/ │ ├── fonts.scss │ └── globals.scss ├── tailwind.config.js └── tsconfig.json
SYMBOL INDEX (498 symbols across 199 files)
FILE: next.config.js
method headers (line 25) | async headers() {
FILE: src/components/aside/aside-footer.tsx
function AsideFooter (line 5) | function AsideFooter(): JSX.Element {
FILE: src/components/aside/aside-trends.tsx
type AsideTrendsProps (line 20) | type AsideTrendsProps = {
function AsideTrends (line 24) | function AsideTrends({ inTrendsPage }: AsideTrendsProps): JSX.Element {
FILE: src/components/aside/aside.tsx
type AsideProps (line 9) | type AsideProps = {
function Aside (line 13) | function Aside({ children }: AsideProps): JSX.Element | null {
FILE: src/components/aside/search-bar.tsx
type SearchBarProps (line 13) | type SearchBarProps<T> = {
function SearchBar (line 18) | function SearchBar<T>({
FILE: src/components/aside/trends.tsx
type AsideTrendsProps (line 19) | type AsideTrendsProps = {
function AsideTrends (line 23) | function AsideTrends({ inTrendsPage }: AsideTrendsProps): JSX.Element {
FILE: src/components/common/app-head.tsx
function AppHead (line 3) | function AppHead(): JSX.Element {
FILE: src/components/common/load-more.tsx
function LoadMoreSentinel (line 4) | function LoadMoreSentinel({
FILE: src/components/common/placeholder.tsx
function Placeholder (line 4) | function Placeholder(): JSX.Element {
FILE: src/components/common/seo.tsx
type MainLayoutProps (line 5) | type MainLayoutProps = {
function SEO (line 11) | function SEO({
FILE: src/components/feed/tweet-feed.tsx
type TweetFeedProps (line 16) | interface TweetFeedProps {
function TweetFeed (line 21) | function TweetFeed({ feedOrdering, apiEndpoint }: TweetFeedProps) {
FILE: src/components/frames/Frame.tsx
type FrameProps (line 21) | type FrameProps = {
function Frame (line 31) | function Frame({ frame, frameContext, url }: FrameProps) {
FILE: src/components/frames/frame-ui.tsx
type Props (line 7) | type Props = React.ComponentProps<
method Button (line 12) | Button(
method MessageTooltip (line 46) | MessageTooltip(props, stylingProps) {
method LoadingScreen (line 69) | LoadingScreen(props, stylingProps) {
method Image (line 76) | Image(props, stylingProps) {
function FrameUI (line 126) | function FrameUI(props: Props) {
FILE: src/components/home/main-container.tsx
type MainContainerProps (line 4) | type MainContainerProps = {
function MainContainer (line 9) | function MainContainer({
FILE: src/components/home/main-header.tsx
type HomeHeaderProps (line 10) | type HomeHeaderProps = {
function MainHeader (line 24) | function MainHeader({
FILE: src/components/input/image-preview.tsx
type ImagePreviewProps (line 14) | type ImagePreviewProps = {
type PostImageBorderRadius (line 33) | type PostImageBorderRadius = Record<number, string[]>;
function ImagePreview (line 42) | function ImagePreview({
FILE: src/components/input/input-accent-radio.tsx
type InputAccentRadioProps (line 6) | type InputAccentRadioProps = {
type InputAccentData (line 10) | type InputAccentData = Record<Accent, string>;
function InputAccentRadio (line 25) | function InputAccentRadio({ type }: InputAccentRadioProps): JSX.Element {
FILE: src/components/input/input-field.tsx
type InputFieldProps (line 5) | type InputFieldProps = {
function InputField (line 21) | function InputField({
FILE: src/components/input/input-form.tsx
type InputFormProps (line 18) | type InputFormProps = {
function InputForm (line 54) | function InputForm({
FILE: src/components/input/input-options.tsx
type Options (line 11) | type Options = {
type InputOptionsProps (line 18) | type InputOptionsProps = {
function InputOptions (line 31) | function InputOptions({
FILE: src/components/input/input-theme-radio.tsx
type InputThemeRadioProps (line 6) | type InputThemeRadioProps = {
type InputThemeData (line 11) | type InputThemeData = Record<
function InputThemeRadio (line 45) | function InputThemeRadio({
FILE: src/components/input/input.tsx
type InputProps (line 32) | type InputProps = {
function extractAndReplaceMentions (line 49) | function extractAndReplaceMentions(
function Input (line 89) | function Input({
FILE: src/components/input/progress-bar.tsx
type ProgressBarProps (line 4) | type ProgressBarProps = {
function ProgressBar (line 28) | function ProgressBar({
FILE: src/components/input/search-bar.tsx
function SearchBar (line 11) | function SearchBar({
FILE: src/components/layout/auth-layout.tsx
function AuthLayout (line 8) | function AuthLayout({
FILE: src/components/layout/common-layout.tsx
type LayoutProps (line 7) | type LayoutProps = {
function ProtectedLayout (line 11) | function ProtectedLayout({ children }: LayoutProps): JSX.Element {
function HomeLayout (line 19) | function HomeLayout({ children }: LayoutProps): JSX.Element {
function UserLayout (line 31) | function UserLayout({ children }: LayoutProps): JSX.Element {
function TrendsLayout (line 44) | function TrendsLayout({ children }: LayoutProps): JSX.Element {
function PeopleLayout (line 56) | function PeopleLayout({ children }: LayoutProps): JSX.Element {
FILE: src/components/layout/main-layout.tsx
function MainLayout (line 18) | function MainLayout({ children }: LayoutProps): JSX.Element {
FILE: src/components/layout/user-data-layout.tsx
function UserDataLayout (line 12) | function UserDataLayout({ children }: LayoutProps): JSX.Element {
FILE: src/components/layout/user-follow-layout.tsx
function UserFollowLayout (line 8) | function UserFollowLayout({ children }: LayoutProps): JSX.Element {
FILE: src/components/layout/user-home-layout.tsx
function UserHomeLayout (line 21) | function UserHomeLayout({ children }: LayoutProps): JSX.Element {
FILE: src/components/login/login-footer.tsx
function LoginFooter (line 23) | function LoginFooter(): JSX.Element {
FILE: src/components/login/login-main.tsx
function LoginMain (line 14) | function LoginMain(): JSX.Element {
FILE: src/components/login/sign-in-with-warpcast.tsx
constant PENDING_REQUEST_KEY (line 11) | const PENDING_REQUEST_KEY = '-opencast-pendingWarpcastRequest';
type WarpcastSignerResponseBase (line 13) | type WarpcastSignerResponseBase = {
type WarpcastRequest (line 21) | type WarpcastRequest =
FILE: src/components/modal/action-modal.tsx
type ActionModalProps (line 7) | type ActionModalProps = {
function ActionModal (line 23) | function ActionModal({
FILE: src/components/modal/display-modal.tsx
type DisplayModalProps (line 8) | type DisplayModalProps = {
function DisplayModal (line 27) | function DisplayModal({ closeModal }: DisplayModalProps): JSX.Element {
FILE: src/components/modal/edit-profile-modal.tsx
type EditProfileModalProps (line 11) | type EditProfileModalProps = Pick<
function EditProfileModal (line 27) | function EditProfileModal({
FILE: src/components/modal/image-modal.tsx
type ImageModalProps (line 15) | type ImageModalProps = {
type ArrowButton (line 23) | type ArrowButton = ['prev' | 'next', string | null, IconName];
function ImageModal (line 30) | function ImageModal({
FILE: src/components/modal/mobile-sidebar-modal.tsx
type MobileNavLink (line 19) | type MobileNavLink = Omit<NavLink, 'canBeHidden'>;
type Stats (line 23) | type Stats = [string, string, number];
type MobileSidebarModalProps (line 25) | type MobileSidebarModalProps = Pick<
function MobileSidebarModal (line 38) | function MobileSidebarModal({
FILE: src/components/modal/modal.tsx
type ModalProps (line 7) | type ModalProps = {
function Modal (line 36) | function Modal({
FILE: src/components/modal/save-passkey-modal.tsx
function SavePasskeyModal (line 7) | function SavePasskeyModal({
FILE: src/components/modal/sign-in-modal-wallet.tsx
constant KEY_METADATA_TYPE_1 (line 31) | const KEY_METADATA_TYPE_1 = [
constant PENDING_KEY_REQUEST (line 61) | const PENDING_KEY_REQUEST = '-opencast-pendingSignerRequest';
type KeyRequest (line 63) | type KeyRequest =
FILE: src/components/modal/sign-in-modal-warpcast.tsx
function WarpcastSignInModal (line 5) | function WarpcastSignInModal({
FILE: src/components/modal/tip-modal.tsx
type TipModalProps (line 16) | interface TipModalProps {
function TipModal (line 24) | function TipModal({
FILE: src/components/modal/tweet-reply-modal.tsx
type TweetReplyModalProps (line 5) | type TweetReplyModalProps = {
function TweetReplyModal (line 10) | function TweetReplyModal({
FILE: src/components/modal/tweet-stats-modal.tsx
type TweetStatsModalProps (line 5) | type TweetStatsModalProps = {
function TweetStatsModal (line 11) | function TweetStatsModal({
FILE: src/components/modal/username-modal.tsx
type UsernameModalProps (line 6) | type UsernameModalProps = {
function UsernameModal (line 29) | function UsernameModal({
FILE: src/components/search/search-topics.tsx
function SearchTopics (line 11) | function SearchTopics({
FILE: src/components/search/user-search-result.tsx
function UserSearchResult (line 4) | function UserSearchResult({
FILE: src/components/sidebar/menu-link.tsx
type MenuLinkProps (line 5) | type MenuLinkProps = ComponentPropsWithRef<'a'> & {
FILE: src/components/sidebar/mobile-sidebar-link.tsx
type MobileSidebarLinkProps (line 7) | type MobileSidebarLinkProps = MobileNavLink & {
function MobileSidebarLink (line 11) | function MobileSidebarLink({
FILE: src/components/sidebar/mobile-sidebar.tsx
function MobileSidebar (line 21) | function MobileSidebar(): JSX.Element {
FILE: src/components/sidebar/more-settings.tsx
function MoreSettings (line 23) | function MoreSettings(): JSX.Element {
FILE: src/components/sidebar/sidebar-link.tsx
type SidebarLinkProps (line 8) | type SidebarLinkProps = NavLink & {
function SidebarLink (line 12) | function SidebarLink({
FILE: src/components/sidebar/sidebar-profile.tsx
function SidebarProfile (line 18) | function SidebarProfile(): JSX.Element {
FILE: src/components/sidebar/sidebar.tsx
type NavLink (line 15) | type NavLink = {
function Sidebar (line 37) | function Sidebar(): JSX.Element {
FILE: src/components/sync/sync-view.tsx
function SyncView (line 4) | function SyncView({ userId }: { userId?: string }) {
FILE: src/components/tweet/number-stats.tsx
type NumberStatsProps (line 5) | type NumberStatsProps = {
function NumberStats (line 11) | function NumberStats({
FILE: src/components/tweet/stats-empty.tsx
type StatsEmptyProps (line 5) | type StatsEmptyProps = {
function StatsEmpty (line 12) | function StatsEmpty({
FILE: src/components/tweet/tweet-actions.tsx
type TweetActionsProps (line 45) | type TweetActionsProps = Pick<Tweet, 'createdBy'> & {
function TweetActions (line 56) | function TweetActions({
FILE: src/components/tweet/tweet-date.tsx
type TweetDateProps (line 7) | type TweetDateProps = Pick<Tweet, 'createdAt'> & {
function TweetDate (line 12) | function TweetDate({
FILE: src/components/tweet/tweet-embed.tsx
function TweetEmbeds (line 13) | function TweetEmbeds({
function TweetEmbed (line 108) | function TweetEmbed({
function FramePreview (line 215) | function FramePreview({
FILE: src/components/tweet/tweet-option.tsx
type TweetOption (line 8) | type TweetOption = {
function TweetOption (line 20) | function TweetOption({
FILE: src/components/tweet/tweet-parent.tsx
type TweetParentProps (line 10) | type TweetParentProps = {
function TweetParent (line 16) | function TweetParent({
FILE: src/components/tweet/tweet-share.tsx
type TweetShareProps (line 15) | type TweetShareProps = {
function TweetShare (line 21) | function TweetShare({
FILE: src/components/tweet/tweet-stats.tsx
type TweetStatsProps (line 16) | type TweetStatsProps = Pick<
function TweetStats (line 29) | function TweetStats({
FILE: src/components/tweet/tweet-status.tsx
type TweetStatusProps (line 7) | type TweetStatusProps = {
function TweetStatus (line 12) | function TweetStatus({ type, children }: TweetStatusProps): JSX.Element {
FILE: src/components/tweet/tweet-text.tsx
type TweetTextProps (line 9) | interface TweetTextProps {
function splitAndInsert (line 15) | function splitAndInsert(
function TweetText (line 45) | function TweetText({
FILE: src/components/tweet/tweet-topic.tsx
function TweetTopicLazy (line 7) | function TweetTopicLazy({ topicUrl }: { topicUrl: string }) {
function TweetTopicSkeleton (line 24) | function TweetTopicSkeleton() {
function TweetTopic (line 33) | function TweetTopic({ topic }: { topic: TopicType }) {
function TopicView (line 44) | function TopicView({ topic }: { topic: TopicType }) {
FILE: src/components/tweet/tweet-with-parent.tsx
type TweetWithParentProps (line 6) | type TweetWithParentProps = {
type LoadedParents (line 10) | type LoadedParents = Record<'parentId' | 'childId', string>[];
function TweetWithParent (line 12) | function TweetWithParent({ data }: TweetWithParentProps): JSX.Element {
FILE: src/components/tweet/tweet.tsx
type TweetProps (line 24) | type TweetProps = Tweet & {
function Tweet (line 40) | function Tweet(tweet: TweetProps): JSX.Element {
FILE: src/components/ui/button.tsx
type ButtonProps (line 6) | type ButtonProps = ComponentPropsWithRef<'button'> & {
FILE: src/components/ui/caution-warn.tsx
function CautionWarn (line 4) | function CautionWarn(): JSX.Element {
FILE: src/components/ui/custom-icon.tsx
type IconName (line 3) | type IconName = keyof typeof Icons;
type IconProps (line 5) | type IconProps = {
type CustomIconProps (line 9) | type CustomIconProps = IconProps & {
function CustomIcon (line 24) | function CustomIcon({
function TwitterIcon (line 33) | function TwitterIcon({ className }: IconProps): JSX.Element {
function FeatherIcon (line 51) | function FeatherIcon({ className }: IconProps): JSX.Element {
function SpinnerIcon (line 65) | function SpinnerIcon({ className }: IconProps): JSX.Element {
function GoogleIcon (line 90) | function GoogleIcon({ className }: IconProps): JSX.Element {
function AppleIcon (line 121) | function AppleIcon({ className }: IconProps): JSX.Element {
function TriangleIcon (line 131) | function TriangleIcon({ className }: IconProps): JSX.Element {
function PinIcon (line 141) | function PinIcon({ className }: IconProps): JSX.Element {
function PinOffIcon (line 163) | function PinOffIcon({ className }: IconProps): JSX.Element {
FILE: src/components/ui/error.tsx
type ErrorProps (line 3) | type ErrorProps = {
function Error (line 7) | function Error({ message }: ErrorProps): JSX.Element {
FILE: src/components/ui/feed-ordering-selector.tsx
type FeedOrderingSelectorProps (line 5) | interface FeedOrderingSelectorProps {
function FeedOrderingSelector (line 10) | function FeedOrderingSelector({
FILE: src/components/ui/follow-button.tsx
type FollowButtonProps (line 14) | type FollowButtonProps = {
function FollowButton (line 19) | function FollowButton({
FILE: src/components/ui/hero-icon.tsx
type IconName (line 4) | type IconName = keyof typeof SolidIcons | keyof typeof OutlineIcons;
type HeroIconProps (line 6) | type HeroIconProps = {
function HeroIcon (line 12) | function HeroIcon({
FILE: src/components/ui/loading.tsx
type LoadingProps (line 4) | type LoadingProps = {
function Loading (line 9) | function Loading({
FILE: src/components/ui/menu-row.tsx
type MenuLinkPropsBase (line 6) | type MenuLinkPropsBase = {
type MenuLinkPropsLink (line 14) | type MenuLinkPropsLink = MenuLinkPropsBase & {
type MenuLinkPropsButton (line 19) | type MenuLinkPropsButton = MenuLinkPropsBase & {
type MenuLinkProps (line 24) | type MenuLinkProps = MenuLinkPropsLink | MenuLinkPropsButton;
function MenuRowBase (line 26) | function MenuRowBase({
function MenuRow (line 68) | function MenuRow(props: MenuLinkProps) {
FILE: src/components/ui/next-image.tsx
type NextImageProps (line 7) | type NextImageProps = {
function NextImage (line 22) | function NextImage({
FILE: src/components/ui/segmented-nav-link.tsx
type SegmentedNavLinkProps (line 5) | type SegmentedNavLinkProps = {
function SegmentedNavLink (line 12) | function SegmentedNavLink({
FILE: src/components/ui/tooltip.tsx
type ToolTipProps (line 3) | type ToolTipProps = {
function ToolTip (line 10) | function ToolTip({
FILE: src/components/user/user-avatar.tsx
type UserAvatarProps (line 5) | type UserAvatarProps = {
function UserAvatar (line 13) | function UserAvatar({
FILE: src/components/user/user-card.tsx
type UserCardProps (line 12) | type UserCardProps = User & {
function UserCard (line 17) | function UserCard(user: UserCardProps): JSX.Element {
FILE: src/components/user/user-cards.tsx
type FollowType (line 11) | type FollowType = 'following' | 'followers';
type CombinedTypes (line 13) | type CombinedTypes = StatsType | FollowType;
type UserCardsProps (line 15) | type UserCardsProps = {
type NoStatsData (line 23) | type NoStatsData = Record<CombinedTypes, StatsEmptyProps>;
function UserCards (line 50) | function UserCards({
FILE: src/components/user/user-details.tsx
type UserDetailsProps (line 16) | type UserDetailsProps = Pick<
type DetailIcon (line 31) | type DetailIcon = [string | null, IconName];
function UserDetails (line 33) | function UserDetails({
FILE: src/components/user/user-edit-profile.tsx
type RequiredInputFieldProps (line 22) | type RequiredInputFieldProps = Omit<InputFieldProps, 'handleChange'> & {
type UserImages (line 26) | type UserImages = Record<
type TrimmedTexts (line 31) | type TrimmedTexts = Pick<
type UserEditProfileProps (line 36) | type UserEditProfileProps = {
function UserEditProfile (line 40) | function UserEditProfile({ hide }: UserEditProfileProps): JSX.Element {
FILE: src/components/user/user-fid.tsx
type UserFollowingProps (line 1) | type UserFollowingProps = {
function UserFid (line 5) | function UserFid({
FILE: src/components/user/user-follow-stats.tsx
type UserFollowStatsProps (line 9) | type UserFollowStatsProps = Pick<UserFull, 'following' | 'followers'>;
type Stats (line 10) | type Stats = [string, string, number, number];
function UserFollowStats (line 12) | function UserFollowStats({
FILE: src/components/user/user-follow.tsx
type UserFollowProps (line 7) | type UserFollowProps = {
function UserFollow (line 11) | function UserFollow({ type }: UserFollowProps): JSX.Element {
FILE: src/components/user/user-following.tsx
type UserFollowingProps (line 3) | type UserFollowingProps = {
function UserFollowing (line 7) | function UserFollowing({
FILE: src/components/user/user-header.tsx
function UserHeader (line 14) | function UserHeader(): JSX.Element {
FILE: src/components/user/user-home-avatar.tsx
type UserHomeAvatarProps (line 8) | type UserHomeAvatarProps = {
function UserHomeAvatar (line 12) | function UserHomeAvatar({
FILE: src/components/user/user-home-cover.tsx
type UserHomeCoverProps (line 8) | type UserHomeCoverProps = {
function UserHomeCover (line 12) | function UserHomeCover({ coverData }: UserHomeCoverProps): JSX.Element {
FILE: src/components/user/user-known-followers.tsx
function UserKnownFollowersLazy (line 7) | function UserKnownFollowersLazy({
function UserKnownFollowers (line 40) | function UserKnownFollowers({
FILE: src/components/user/user-name.tsx
type UserNameProps (line 5) | type UserNameProps = {
function UserName (line 14) | function UserName({
FILE: src/components/user/user-nav.tsx
type UserNavProps (line 6) | type UserNavProps = {
function UserNav (line 24) | function UserNav({ follow, userId }: UserNavProps): JSX.Element {
FILE: src/components/user/user-share.tsx
type UserShareProps (line 12) | type UserShareProps = {
function UserShare (line 16) | function UserShare({ username }: UserShareProps): JSX.Element {
FILE: src/components/user/user-tooltip.tsx
type UserTooltipProps (line 21) | type UserTooltipProps = Pick<
type Stats (line 30) | type Stats = [string, string, number];
function UserTooltip (line 32) | function UserTooltip({
FILE: src/components/user/user-username.tsx
type UserUsernameProps (line 4) | type UserUsernameProps = {
function UserUsername (line 10) | function UserUsername({
FILE: src/components/view/view-parent-tweet.tsx
type ViewParentTweetProps (line 7) | type ViewParentTweetProps = {
function ViewParentTweet (line 12) | function ViewParentTweet({
FILE: src/components/view/view-tweet-stats.tsx
type viewTweetStats (line 13) | type viewTweetStats = Pick<Tweet, 'userRetweets' | 'userLikes'> & {
type StatsType (line 24) | type StatsType = 'retweets' | 'likes';
type Stats (line 26) | type Stats = [string, StatsType | null, number, number];
function ViewTweetStats (line 28) | function ViewTweetStats({
FILE: src/components/view/view-tweet.tsx
type ViewTweetProps (line 24) | type ViewTweetProps = Tweet & {
function ViewTweet (line 30) | function ViewTweet(tweet: ViewTweetProps): JSX.Element {
FILE: src/contracts/id-registry.ts
constant ID_REGISTRY_ADDRESS (line 1) | const ID_REGISTRY_ADDRESS =
constant ID_REGISTRY_ABI (line 4) | const ID_REGISTRY_ABI = [
constant ID_REGISTRY (line 466) | const ID_REGISTRY = {
FILE: src/contracts/key-gateway.ts
constant KEY_GATEWAY_ADDRESS (line 1) | const KEY_GATEWAY_ADDRESS =
constant KEY_GATEWAY_ABI (line 4) | const KEY_GATEWAY_ABI = [
constant KEY_GATEWAY (line 293) | const KEY_GATEWAY = {
FILE: src/contracts/key-registry.ts
constant KEY_REGISTRY_ADDRESS (line 1) | const KEY_REGISTRY_ADDRESS =
constant KEY_REGISTRY_ABI (line 4) | const KEY_REGISTRY_ABI = [
constant KEY_REGISTRY (line 626) | const KEY_REGISTRY = {
FILE: src/contracts/validator.ts
constant VALIDATOR_ABI (line 1) | const VALIDATOR_ABI = [
FILE: src/lib/api/auth.ts
constant AUTH (line 1) | const AUTH: Readonly<RequestInit> = {
FILE: src/lib/api/trends.ts
type SwrHooksReturn (line 5) | type SwrHooksReturn = {
type UseTrendsReturn (line 10) | type UseTrendsReturn = SwrHooksReturn & {
type FilteredSuccessResponse (line 14) | type FilteredSuccessResponse = Omit<SuccessResponse, 'trends'> & {
type FilteredUseTrendsReturn (line 18) | type FilteredUseTrendsReturn = SwrHooksReturn & {
function useTrends (line 34) | function useTrends(
FILE: src/lib/chains/resolve-chain-icon.ts
function resolveChainIcon (line 3) | async function resolveChainIcon(chainId: number) {
function _resolveChainIcon (line 15) | async function _resolveChainIcon(chainId: number) {
FILE: src/lib/context/auth-context.tsx
type UserWithKey (line 22) | type UserWithKey = UserFull & { keyPair?: KeyPair };
type AuthContext (line 24) | type AuthContext = {
type AuthContextProviderProps (line 45) | type AuthContextProviderProps = {
function AuthContextProvider (line 49) | function AuthContextProvider({
function useAuth (line 243) | function useAuth(): AuthContext {
FILE: src/lib/context/theme-context.tsx
type ThemeContext (line 8) | type ThemeContext = {
type ThemeContextProviderProps (line 17) | type ThemeContextProviderProps = {
function setInitialTheme (line 21) | function setInitialTheme(): Theme {
function setInitialAccent (line 30) | function setInitialAccent(): Accent {
function ThemeContextProvider (line 38) | function ThemeContextProvider({
function useTheme (line 125) | function useTheme(): ThemeContext {
FILE: src/lib/context/user-context.tsx
type UserContext (line 5) | type UserContext = {
type UserContextProviderProps (line 12) | type UserContextProviderProps = {
function UserContextProvider (line 17) | function UserContextProvider({
function useUser (line 24) | function useUser(): UserContext {
FILE: src/lib/context/window-context.tsx
type WindowSize (line 4) | type WindowSize = {
type WindowContext (line 9) | type WindowContext = WindowSize & {
type WindowContextProviderProps (line 15) | type WindowContextProviderProps = {
function WindowContextProvider (line 18) | function WindowContextProvider({
function useWindow (line 57) | function useWindow(): WindowContext {
FILE: src/lib/crypto.ts
function generateKeyPair (line 5) | async function generateKeyPair(): Promise<KeyPair> {
function getKeyPair (line 15) | async function getKeyPair(privateKey: `0x${string}`): Promise<KeyPair> {
FILE: src/lib/date.ts
constant RELATIVE_TIME_FORMATTER (line 1) | const RELATIVE_TIME_FORMATTER = new Intl.RelativeTimeFormat('en-gb', {
type Units (line 6) | type Units = Readonly<Partial<Record<Intl.RelativeTimeFormatUnit, number...
constant UNITS (line 8) | const UNITS: Units = {
function formatDate (line 14) | function formatDate(
function formatNumber (line 27) | function formatNumber(number: number): string {
function getFullTime (line 34) | function getFullTime(date: Date): string {
function getPostTime (line 58) | function getPostTime(date: Date): string {
function getJoinedTime (line 73) | function getJoinedTime(date: Date): string {
function getShortTime (line 80) | function getShortTime(date: Date): string {
function getRelativeTime (line 94) | function getRelativeTime(date: Date): string {
function calculateRelativeTime (line 104) | function calculateRelativeTime(date: Date): string {
function isToday (line 118) | function isToday(date: Date): boolean {
function isYesterday (line 123) | function isYesterday(date: Date): boolean {
function isCurrentYear (line 129) | function isCurrentYear(date: Date): boolean {
FILE: src/lib/embeds.ts
constant KNOWN_HOSTS_MAP (line 6) | const KNOWN_HOSTS_MAP: {
function populateEmbed (line 24) | async function populateEmbed(
function populateTweetEmbeds (line 102) | async function populateTweetEmbeds(tweet: Tweet): Promise<Tweet> {
FILE: src/lib/farcaster/utils.ts
function getSigner (line 19) | function getSigner(privateKey: string): NobleEd25519Signer {
function getSignerFromStorage (line 24) | async function getSignerFromStorage(): Promise<NobleEd25519Signer> {
function createReactionMessage (line 33) | async function createReactionMessage({
function createCastMessage (line 70) | async function createCastMessage({
function createRemoveCastMessage (line 125) | async function createRemoveCastMessage({
function createFollowMessage (line 148) | async function createFollowMessage({
function makeMessage (line 177) | async function makeMessage(messageData: MessageData) {
function submitHubMessage (line 202) | async function submitHubMessage(message: Message) {
function batchSubmitHubMessages (line 214) | async function batchSubmitHubMessages(messages: Message[]) {
FILE: src/lib/fetch.ts
function fetchJSON (line 1) | async function fetchJSON<T>(
FILE: src/lib/hooks/useConnectedWalletFid.tsx
function useFid (line 5) | function useFid() {
FILE: src/lib/hooks/useInfiniteScroll.tsx
function useInfiniteScroll (line 9) | function useInfiniteScroll(
FILE: src/lib/hooks/useInfiniteScrollUsers.tsx
function useInfiniteScrollUsers (line 9) | function useInfiniteScrollUsers(
FILE: src/lib/hooks/useModal.ts
type Modal (line 3) | type Modal = {
function useModal (line 9) | function useModal(): Modal {
FILE: src/lib/hooks/useRequireAuth.ts
function useRequireAuth (line 6) | function useRequireAuth(redirectUrl?: string): User | null {
FILE: src/lib/keys.ts
constant KEYPAIRS_KEY (line 5) | const KEYPAIRS_KEY = 'keys';
constant ACTIVE_KEYPAIR_KEY (line 6) | const ACTIVE_KEYPAIR_KEY = 'activeKey';
constant PENDING_KEYPAIR_KEY (line 7) | const PENDING_KEYPAIR_KEY = 'pendingKey';
function getKeyPairs (line 9) | async function getKeyPairs() {
function addKeyPair (line 23) | function addKeyPair(keyPair: KeyPair) {
function removeKeyPair (line 32) | function removeKeyPair(keyPair: KeyPair) {
function setKeyPair (line 39) | function setKeyPair(keyPair: KeyPair) {
function getActiveKeyPair (line 43) | async function getActiveKeyPair(): Promise<KeyPair | null> {
FILE: src/lib/lru-cache.ts
constant LRU (line 7) | const LRU = globalForLRU.LRU ?? new LRUCache({ max: 1000 });
FILE: src/lib/merge.ts
type DataWithDate (line 3) | type DataWithDate<T> = T & { createdAt: Timestamp };
function mergeData (line 5) | function mergeData<T>(
FILE: src/lib/paginated-reactions.ts
type PaginatedUsersResponse (line 7) | interface PaginatedUsersResponse
function getReactionUsersPaginated (line 13) | async function getReactionUsersPaginated(
FILE: src/lib/paginated-tweets.ts
type PaginatedTweetsType (line 11) | type PaginatedTweetsType = {
type PaginatedTweetsResponse (line 17) | interface PaginatedTweetsResponse
type TweetsResponse (line 20) | interface TweetsResponse extends BaseResponse<{ tweets: Tweet[] }> {}
function getTweetsPaginatedRawSql (line 27) | async function getTweetsPaginatedRawSql(sql: Sql, ...args: any[]) {
function getTweetsPaginatedPrismaArgs (line 37) | async function getTweetsPaginatedPrismaArgs(
function convertAndCalculateCursor (line 51) | async function convertAndCalculateCursor(
type CastToTweetsReturnType (line 77) | type CastToTweetsReturnType = {
function castsToTweets (line 86) | async function castsToTweets(
FILE: src/lib/passkeys.ts
type UserWithKey (line 5) | type UserWithKey = UserFull & { keyPair?: KeyPair };
function storeSignerLargeBlob (line 7) | async function storeSignerLargeBlob({
function createNewPasskey (line 54) | async function createNewPasskey({
FILE: src/lib/random.ts
constant CHARS (line 1) | const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234...
function getRandomId (line 3) | function getRandomId(): string {
function getRandomInt (line 10) | function getRandomInt(min: number, max: number): number {
FILE: src/lib/signers.ts
function getSignerDetail (line 4) | async function getSignerDetail(
FILE: src/lib/topics/resolve-topic.ts
type TopicsMapType (line 18) | type TopicsMapType = { [key: string]: TopicType };
type FarcasterChannel (line 20) | type FarcasterChannel = {
function resolveTopic (line 31) | async function resolveTopic(url: string): Promise<TopicType | null> {
function cleanUrl (line 47) | function cleanUrl(url: string): string {
function getAllChannelsIndexed (line 58) | async function getAllChannelsIndexed() {
function getChannel (line 83) | async function getChannel(url: string) {
function _resolveTopic (line 89) | async function _resolveTopic(url: string): Promise<TopicType | null> {
function resolveTopicsMap (line 258) | async function resolveTopicsMap(urls: string[]): Promise<TopicsMapType> {
FILE: src/lib/types/app-auth.ts
type AppAuthResponse (line 3) | type AppAuthResponse = BaseResponse<AppAuthType>;
type AppAuthType (line 5) | type AppAuthType = {
FILE: src/lib/types/available.ts
type AvailablePlace (line 1) | type AvailablePlace = {
type PlaceType (line 11) | type PlaceType = {
type AvailablePlaces (line 16) | type AvailablePlaces = AvailablePlace[];
FILE: src/lib/types/bookmark.ts
type Bookmark (line 3) | type Bookmark = {
method toFirestore (line 9) | toFirestore(bookmark) {
method fromFirestore (line 12) | fromFirestore(snapshot, options) {
FILE: src/lib/types/feed.ts
type FeedOrderingType (line 1) | type FeedOrderingType = 'latest' | 'top';
FILE: src/lib/types/file.ts
type ImageData (line 1) | type ImageData = {
type ImagesPreview (line 6) | type ImagesPreview = (ImageData & {
type ImagePreview (line 10) | type ImagePreview = ImageData & { id: string };
type FileWithId (line 11) | type FileWithId = File & { id: string };
type FilesWithId (line 13) | type FilesWithId = (File & {
FILE: src/lib/types/keypair.ts
type KeyPair (line 1) | type KeyPair = {
FILE: src/lib/types/notifications.ts
type MessageMetadata (line 6) | type MessageMetadata = {
type ReactionQueryResult (line 13) | type ReactionQueryResult = casts &
type FollowerQueryResult (line 18) | type FollowerQueryResult = MessageMetadata;
type RepliesQueryResult (line 20) | type RepliesQueryResult = casts &
type MentionsQueryResult (line 23) | type MentionsQueryResult = casts &
type BasicNotification (line 28) | type BasicNotification = {
type BasicReaction (line 34) | type BasicReaction = BasicNotification & {
type BasicFollow (line 38) | type BasicFollow = BasicNotification;
type BasicReply (line 39) | type BasicReply = BasicNotification & {
type BasicMention (line 43) | type BasicMention = BasicNotification & { castId: string };
type NotificationsSummary (line 45) | type NotificationsSummary = {
type NotificationsResponseSummary (line 50) | type NotificationsResponseSummary = BaseResponse<NotificationsSummary>;
type AccumulatedReaction (line 52) | type AccumulatedReaction = BasicNotification & {
type AccumulatedFollow (line 59) | type AccumulatedFollow = BasicNotification & {
type NotificationsResponseFull (line 63) | type NotificationsResponseFull = BaseResponse<
FILE: src/lib/types/online.ts
type AppProfile (line 4) | type AppProfile = { pfp?: string; display?: string; username?: string };
type OnlineUsersResponse (line 6) | type OnlineUsersResponse = BaseResponse<{
FILE: src/lib/types/place.ts
type TrendsReturn (line 1) | type TrendsReturn = {
type TrendsResponse (line 6) | type TrendsResponse = SuccessResponse | ErrorResponse;
type SuccessResponse (line 8) | type SuccessResponse = {
type ErrorResponse (line 13) | type ErrorResponse = {
type TrendsData (line 22) | type TrendsData = [
type Location (line 31) | type Location = [
type Trend (line 38) | type Trend = {
type Trends (line 46) | type Trends = Trend[];
type FilteredTrends (line 48) | type FilteredTrends = (Trend & {
FILE: src/lib/types/responses.ts
type BaseResponse (line 1) | interface BaseResponse<T> {
FILE: src/lib/types/signer.ts
type SignerDetail (line 4) | type SignerDetail = {
type SignersResponse (line 21) | type SignersResponse = BaseResponse<SignerDetail[]>;
type SignerResponse (line 22) | type SignerResponse = BaseResponse<SignerDetail>;
type MessagesArchive (line 24) | type MessagesArchive = {
type MessagesArchiveResponse (line 29) | type MessagesArchiveResponse = BaseResponse<MessagesArchive>;
FILE: src/lib/types/stats.ts
type Stats (line 3) | type Stats = {
method toFirestore (line 10) | toFirestore(bookmark) {
method fromFirestore (line 13) | fromFirestore(snapshot, options) {
FILE: src/lib/types/theme.ts
type Theme (line 1) | type Theme = 'light' | 'dim' | 'dark';
type Accent (line 2) | type Accent = 'blue' | 'yellow' | 'pink' | 'purple' | 'orange' | 'green';
FILE: src/lib/types/topic.ts
type TopicResponse (line 3) | type TopicResponse = BaseResponse<TopicType>;
type TopicType (line 5) | type TopicType = {
FILE: src/lib/types/trends.ts
type TrendsResponse (line 4) | type TrendsResponse = BaseResponse<
FILE: src/lib/types/tweet.ts
type Mention (line 11) | type Mention = {
type ExternalEmbed (line 18) | type ExternalEmbed = {
type Tweet (line 29) | type Tweet = {
type TweetWithUsers (line 51) | type TweetWithUsers = Tweet & { users: UsersMapType<User> };
type TweetResponse (line 53) | type TweetResponse = BaseResponse<TweetWithUsers>;
type TweetRepliesResponse (line 54) | interface TweetRepliesResponse
method toTweet (line 118) | toTweet(cast: casts & { client?: string }): Tweet {
FILE: src/lib/types/user.ts
type User (line 6) | type User = {
type UserFull (line 15) | type UserFull = User & {
type EditableData (line 32) | type EditableData = Extract<
type EditableUserData (line 37) | type EditableUserData = Pick<UserFull, EditableData>;
type UserResponse (line 39) | type UserResponse = BaseResponse<UserFull | User>;
type UserFullResponse (line 41) | type UserFullResponse = BaseResponse<UserFull>;
type UsersMapType (line 43) | type UsersMapType<T> = { [key: string]: T };
type KnownFollowersResponse (line 45) | type KnownFollowersResponse = BaseResponse<{
method toUser (line 51) | toUser(user: any): User {
method toUserFull (line 62) | toUserFull(user: any): UserFull {
FILE: src/lib/user/resolve-user.ts
function getUserDataMap (line 14) | async function getUserDataMap(fid: bigint): Promise<
function resolveUserFromFid (line 64) | async function resolveUserFromFid(fid: bigint): Promise<User | null> {
function resolveUserFullFromFid (line 72) | async function resolveUserFullFromFid(
function resolveUserAmbiguous (line 128) | async function resolveUserAmbiguous(
function resolveUsers (line 156) | async function resolveUsers(
function resolveUsersMap (line 170) | async function resolveUsersMap(
function userInterests (line 189) | async function userInterests(fid: bigint): Promise<TopicType[]> {
FILE: src/lib/utils.ts
function preventBubbling (line 5) | function preventBubbling(
function hasAncestorWithClass (line 17) | function hasAncestorWithClass(element: HTMLElement, className: string) {
function delayScroll (line 31) | function delayScroll(ms: number) {
function sleep (line 35) | function sleep(ms: number): Promise<void> {
function getStatsMove (line 39) | function getStatsMove(movePixels: number): MotionProps {
function isPlural (line 60) | function isPlural(count: number): string {
function replaceOccurrencesMultiple (line 64) | function replaceOccurrencesMultiple(
type ParsedChainURL (line 75) | type ParsedChainURL = {
function parseChainURL (line 82) | function parseChainURL(url: string): ParsedChainURL | null {
function getHttpsUrls (line 101) | function getHttpsUrls(text: string): string[] {
function JSONStringify (line 143) | function JSONStringify<T>(data: T): string {
function JSONParse (line 147) | function JSONParse<T>(data: string): T {
function serialize (line 151) | function serialize<T>(data: T): T {
FILE: src/lib/validation.ts
constant IMAGE_EXTENSIONS (line 4) | const IMAGE_EXTENSIONS = [
type ImageExtensions (line 18) | type ImageExtensions = (typeof IMAGE_EXTENSIONS)[number];
function isValidImageExtension (line 20) | function isValidImageExtension(
function isValidImage (line 28) | function isValidImage(name: string, bytes: number): boolean {
function isValidUsername (line 32) | function isValidUsername(
type ImagesData (line 48) | type ImagesData = {
function getImagesData (line 53) | function getImagesData(
function renameFile (line 90) | function renameFile(
FILE: src/pages/404.tsx
function NotFound (line 5) | function NotFound(): JSX.Element {
FILE: src/pages/[...redirect].tsx
function Redirect (line 3) | function Redirect(): JSX.Element {
FILE: src/pages/_app.tsx
type NextPageWithLayout (line 17) | type NextPageWithLayout = NextPage & {
type AppPropsWithLayout (line 21) | type AppPropsWithLayout = AppProps & {
function App (line 37) | function App({
FILE: src/pages/_document.tsx
function Document (line 3) | function Document(): JSX.Element {
FILE: src/pages/api/embeds.ts
function handle (line 5) | async function handle(
FILE: src/pages/api/feed.ts
function handle (line 13) | async function handle(
FILE: src/pages/api/hub/batch.ts
function handle (line 6) | async function handle(
FILE: src/pages/api/hub/index.ts
function handle (line 6) | async function handle(
FILE: src/pages/api/online/index.ts
function handle (line 8) | async function handle(
FILE: src/pages/api/search.ts
function handle (line 7) | async function handle(
FILE: src/pages/api/signer/[pubKey]/authorize.ts
type SignerEndpointQuery (line 7) | type SignerEndpointQuery = {
constant SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN (line 11) | const SIGNED_KEY_REQUEST_VALIDATOR_EIP_712_DOMAIN = {
constant SIGNED_KEY_REQUEST_TYPE (line 18) | const SIGNED_KEY_REQUEST_TYPE = [
function handle (line 24) | async function handle(
FILE: src/pages/api/signer/[pubKey]/user.ts
type SignerEndpointQuery (line 7) | type SignerEndpointQuery = {
function signerUserEndpoint (line 11) | async function signerUserEndpoint(
FILE: src/pages/api/topic/index.ts
function topicIdEndpoint (line 6) | async function topicIdEndpoint(
FILE: src/pages/api/trends/index.ts
function handle (line 6) | async function handle(
FILE: src/pages/api/tweet/[id]/engagers.ts
function handle (line 7) | async function handle(
FILE: src/pages/api/tweet/[id]/index.ts
type TweetEndpointQuery (line 21) | type TweetEndpointQuery = {
function tweetIdEndpoint (line 25) | async function tweetIdEndpoint(
FILE: src/pages/api/tweet/[id]/replies.ts
function handle (line 7) | async function handle(
FILE: src/pages/api/tweet/batch.ts
function handle (line 12) | async function handle(
FILE: src/pages/api/user/[id]/index.ts
type UserEndpointQuery (line 5) | type UserEndpointQuery = {
function userIdEndpoint (line 10) | async function userIdEndpoint(
FILE: src/pages/api/user/[id]/interests.ts
function handle (line 5) | async function handle(
FILE: src/pages/api/user/[id]/known-followers.ts
function handle (line 6) | async function handle(
FILE: src/pages/api/user/[id]/likes.ts
function handle (line 9) | async function handle(
FILE: src/pages/api/user/[id]/links.ts
function handle (line 6) | async function handle(
FILE: src/pages/api/user/[id]/notifications.ts
function handle (line 23) | async function handle(
FILE: src/pages/api/user/[id]/signers/[pubKey]/backup.ts
type SignerEndpointQuery (line 8) | type SignerEndpointQuery = {
function handler (line 13) | async function handler(
FILE: src/pages/api/user/[id]/signers/[pubKey]/casts.ts
type Query (line 9) | type Query = {
function handle (line 14) | async function handle(
FILE: src/pages/api/user/[id]/signers/[pubKey]/index.ts
type SignerEndpointQuery (line 5) | type SignerEndpointQuery = {
function handler (line 10) | async function handler(
FILE: src/pages/api/user/[id]/signers/index.ts
function handle (line 6) | async function handle(
FILE: src/pages/api/user/[id]/sync.ts
type UserEndpointQuery (line 4) | type UserEndpointQuery = {
function handler (line 8) | async function handler(
FILE: src/pages/api/user/[id]/tweets.ts
function handle (line 9) | async function handle(
FILE: src/pages/api/user/resolve-usernames.ts
function handle (line 6) | async function handle(
FILE: src/pages/home.tsx
function Home (line 19) | function Home(): JSX.Element {
FILE: src/pages/index.tsx
function Landing (line 5) | function Landing(): JSX.Element {
FILE: src/pages/login.tsx
function Login (line 7) | function Login(): JSX.Element {
FILE: src/pages/notifications.tsx
function NotificationsPage (line 31) | function NotificationsPage(): JSX.Element {
FILE: src/pages/settings/index.tsx
function Settings (line 20) | function Settings(): JSX.Element {
FILE: src/pages/settings/manage-signers/[pubKey].tsx
function getSignerDescription (line 36) | function getSignerDescription(signer: SignerDetail) {
function SignerDetailPage (line 47) | function SignerDetailPage(): JSX.Element {
FILE: src/pages/settings/manage-signers/index.tsx
function ManageSigners (line 19) | function ManageSigners(): JSX.Element {
FILE: src/pages/topic/index.tsx
function TopicPage (line 18) | function TopicPage(): JSX.Element {
FILE: src/pages/trends.tsx
function Trends (line 16) | function Trends(): JSX.Element {
FILE: src/pages/tweet/[id].tsx
function TweetId (line 26) | function TweetId(): JSX.Element {
FILE: src/pages/user/[id]/followers.tsx
function UserFollowers (line 8) | function UserFollowers(): JSX.Element {
FILE: src/pages/user/[id]/following.tsx
function UserFollowing (line 8) | function UserFollowing(): JSX.Element {
FILE: src/pages/user/[id]/index.tsx
function UserTweets (line 13) | function UserTweets(): JSX.Element {
FILE: src/pages/user/[id]/likes.tsx
function UserLikes (line 14) | function UserLikes(): JSX.Element {
FILE: src/pages/user/[id]/with_replies.tsx
function UserWithReplies (line 16) | function UserWithReplies(): JSX.Element {
Condensed preview — 236 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (632K chars).
[
{
"path": ".eslintignore",
"chars": 115,
"preview": "# next config\nnext.config.js\n\n# tailwind config\ntailwind.config.js\npostcss.config.js\n\n# jest config\njest.config.js\n"
},
{
"path": ".eslintrc.json",
"chars": 228,
"preview": "{\n \"parser\": \"@typescript-eslint/parser\",\n \"parserOptions\": {\n \"project\": \"tsconfig.json\"\n },\n \"settings\": {\n "
},
{
"path": ".eslintrc.json.bak",
"chars": 1995,
"preview": "{\n \"parser\": \"@typescript-eslint/parser\",\n \"parserOptions\": {\n \"project\": \"tsconfig.json\"\n },\n \"plugins\": [\"@type"
},
{
"path": ".github/workflows/deployment.yaml",
"chars": 845,
"preview": "name: Deploy 🚀\n\non:\n push:\n branches: ['main']\n pull_request:\n branches: ['main']\n\njobs:\n prettier:\n name: 🧪"
},
{
"path": ".gitignore",
"chars": 426,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".husky/pre-commit.bak",
"chars": 87,
"preview": "#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\nexec 1> /dev/tty\n\nnpx lint-staged\n"
},
{
"path": ".prettierignore",
"chars": 145,
"preview": "# testing\n/coverage\n\n# next.js\n/.next/\n/.vercel/\n/out/\n\n# production\n/build\n\n# compiled js functions\n/functions/lib/\n\n# "
},
{
"path": ".prettierrc.json",
"chars": 79,
"preview": "{\n \"singleQuote\": true,\n \"jsxSingleQuote\": true,\n \"trailingComma\": \"none\"\n}\n"
},
{
"path": ".vscode/settings.json",
"chars": 42,
"preview": "{\n \"WillLuke.nextjs.hasPrompted\": true\n}\n"
},
{
"path": "Dockerfile",
"chars": 2324,
"preview": "# https://github.com/vercel/next.js/blob/canary/examples/with-docker/Dockerfile\nFROM node:18-alpine AS base\n\n# Install d"
},
{
"path": "LICENSE",
"chars": 1063,
"preview": "MIT License\n\nCopyright (c) 2022 ccrsxx\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof "
},
{
"path": "README.md",
"chars": 2155,
"preview": "# Opencast\n\nA fully open source Twitter flavoured Farcaster client. Originally a fork of [ccrsxx/twitter-clone](https://"
},
{
"path": "docker-compose.yml",
"chars": 2530,
"preview": "version: '3.8'\nservices:\n opencast:\n build: ./\n container_name: opencast\n restart: unless-stopped\n env_file"
},
{
"path": "jest.config.js",
"chars": 963,
"preview": "// jest.config.js\nconst nextJest = require('next/jest');\n\nconst createJestConfig = nextJest({\n // Provide the path to y"
},
{
"path": "next.config.js",
"chars": 1142,
"preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n reactStrictMode: true,\n swcMinify: true,\n output: \"sta"
},
{
"path": "nixpacks.toml",
"chars": 111,
"preview": "[phases.setup]\nnixPkgs = ['...', 'python3', 'gcc']\n\n[phases.install]\ncmds = ['yarn global add node-gyp', '...']"
},
{
"path": "package.json",
"chars": 2484,
"preview": "{\n \"name\": \"opencast\",\n \"version\": \"1.0.0\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev\",\n \"build\": \"nex"
},
{
"path": "postcss.config.js",
"chars": 81,
"preview": "module.exports = {\n plugins: {\n tailwindcss: {},\n autoprefixer: {}\n }\n};\n"
},
{
"path": "prisma/schema.prisma",
"chars": 6009,
"preview": "generator client {\n provider = \"prisma-client-js\"\n}\n\ndatasource db {\n provider = \"postgresql\"\n url = env(\"DATABA"
},
{
"path": "public/site.webmanifest",
"chars": 495,
"preview": "{\n \"name\": \"Opencast - Open Source Farcaster Client\",\n \"short_name\": \"Opencast\",\n \"description\": \"Fully open source T"
},
{
"path": "src/app/frames/route.ts",
"chars": 52,
"preview": "export { GET, POST } from '@frames.js/render/next';\n"
},
{
"path": "src/components/aside/aside-footer.tsx",
"chars": 754,
"preview": "const footerLinks = [\n ['GitHub', 'https://github.com/stephancill/twitter-farcaster-client']\n] as const;\n\nexport functi"
},
{
"path": "src/components/aside/aside-trends.tsx",
"chars": 3321,
"preview": "import Link from 'next/link';\nimport cn from 'clsx';\nimport { motion } from 'framer-motion';\nimport { formatNumber } fro"
},
{
"path": "src/components/aside/aside.tsx",
"chars": 1019,
"preview": "import { useWindow } from '@lib/context/window-context';\nimport Link from 'next/link';\nimport type { ReactNode } from 'r"
},
{
"path": "src/components/aside/search-bar.tsx",
"chars": 4745,
"preview": "import { Button } from '@components/ui/button';\nimport { HeroIcon } from '@components/ui/hero-icon';\nimport cn from 'cls"
},
{
"path": "src/components/aside/suggestions.tsx.bak",
"chars": 1968,
"preview": "import Link from 'next/link';\nimport { motion } from 'framer-motion';\nimport {\n doc,\n limit,\n query,\n where,\n order"
},
{
"path": "src/components/aside/trends.tsx",
"chars": 3290,
"preview": "import { Error } from '@components/ui/error';\nimport { Loading } from '@components/ui/loading';\nimport { formatNumber } "
},
{
"path": "src/components/common/app-head.tsx",
"chars": 379,
"preview": "import Head from 'next/head';\n\nexport function AppHead(): JSX.Element {\n return (\n <Head>\n <title>Opencast</tit"
},
{
"path": "src/components/common/load-more.tsx",
"chars": 750,
"preview": "import { useEffect, useRef } from 'react';\n\n// TODO: Replace other load more components with this one\nexport function Lo"
},
{
"path": "src/components/common/placeholder.tsx",
"chars": 512,
"preview": "import { CustomIcon } from '@components/ui/custom-icon';\nimport { SEO } from './seo';\n\nexport function Placeholder(): JS"
},
{
"path": "src/components/common/seo.tsx",
"chars": 753,
"preview": "import { useRouter } from 'next/router';\nimport Head from 'next/head';\nimport { siteURL } from '@lib/env';\n\ntype MainLay"
},
{
"path": "src/components/feed/tweet-feed.tsx",
"chars": 3416,
"preview": "import useSWR from 'swr';\nimport useSWRInfinite from 'swr/infinite';\nimport { useAuth } from '../../lib/context/auth-con"
},
{
"path": "src/components/frames/Frame.tsx",
"chars": 2874,
"preview": "'use client';\n\nimport {\n FarcasterFrameContext,\n FarcasterSigner,\n signFrameAction\n} from '@frames.js/render/farcaste"
},
{
"path": "src/components/frames/frame-ui.tsx",
"chars": 3642,
"preview": "import { FrameUI as BaseFrameUI } from '@frames.js/render/ui';\nimport Image from 'next/image';\nimport React from 'react'"
},
{
"path": "src/components/home/main-container.tsx",
"chars": 498,
"preview": "import cn from 'clsx';\nimport type { ReactNode } from 'react';\n\ntype MainContainerProps = {\n children: ReactNode;\n cla"
},
{
"path": "src/components/home/main-header.tsx",
"chars": 2510,
"preview": "import cn from 'clsx';\nimport { Button } from '@components/ui/button';\nimport { HeroIcon } from '@components/ui/hero-ico"
},
{
"path": "src/components/home/update-username.tsx.bak",
"chars": 3785,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\nimport { useState, useEffect } from 'react';\nimport { toast } from 're"
},
{
"path": "src/components/input/image-preview.tsx",
"chars": 4500,
"preview": "import { useEffect, useState } from 'react';\nimport cn from 'clsx';\nimport { useModal } from '@lib/hooks/useModal';\nimpo"
},
{
"path": "src/components/input/input-accent-radio.tsx",
"chars": 1709,
"preview": "import cn from 'clsx';\nimport { useTheme } from '@lib/context/theme-context';\nimport { HeroIcon } from '@components/ui/h"
},
{
"path": "src/components/input/input-field.tsx",
"chars": 3275,
"preview": "import cn from 'clsx';\nimport type { User, EditableData } from '@lib/types/user';\nimport type { KeyboardEvent, ChangeEve"
},
{
"path": "src/components/input/input-form.tsx",
"chars": 5659,
"preview": "import { useEffect } from 'react';\nimport TextArea from 'react-textarea-autosize';\nimport { motion } from 'framer-motion"
},
{
"path": "src/components/input/input-options.tsx",
"chars": 3510,
"preview": "import { useRef } from 'react';\nimport { motion } from 'framer-motion';\nimport { Button } from '@components/ui/button';\n"
},
{
"path": "src/components/input/input-theme-radio.tsx",
"chars": 2624,
"preview": "import cn from 'clsx';\nimport { useTheme } from '@lib/context/theme-context';\nimport { HeroIcon } from '@components/ui/h"
},
{
"path": "src/components/input/input.tsx",
"chars": 18184,
"preview": "import { UserAvatar } from '@components/user/user-avatar';\nimport { Message } from '@farcaster/hub-web';\nimport { useAut"
},
{
"path": "src/components/input/progress-bar.tsx",
"chars": 2831,
"preview": "import cn from 'clsx';\nimport { ToolTip } from '@components/ui/tooltip';\n\ntype ProgressBarProps = {\n modal?: boolean;\n "
},
{
"path": "src/components/input/search-bar.tsx",
"chars": 2279,
"preview": "import cn from 'clsx';\nimport {\n DetailedHTMLProps,\n InputHTMLAttributes,\n KeyboardEvent,\n useRef\n} from 'react';\nim"
},
{
"path": "src/components/layout/auth-layout.tsx",
"chars": 1009,
"preview": "import { useState, useEffect } from 'react';\nimport { useRouter } from 'next/router';\nimport { useAuth } from '@lib/cont"
},
{
"path": "src/components/layout/common-layout.tsx",
"chars": 1197,
"preview": "import { Aside } from '@components/aside/aside';\nimport { Placeholder } from '@components/common/placeholder';\nimport { "
},
{
"path": "src/components/layout/main-layout.tsx",
"chars": 991,
"preview": "import { SWRConfig } from 'swr';\nimport { Toaster } from 'react-hot-toast';\nimport { fetchJSON } from '@lib/fetch';\nimpo"
},
{
"path": "src/components/layout/user-data-layout.tsx",
"chars": 1286,
"preview": "import { SEO } from '@components/common/seo';\nimport { MainContainer } from '@components/home/main-container';\nimport { "
},
{
"path": "src/components/layout/user-follow-layout.tsx",
"chars": 1060,
"preview": "import { motion } from 'framer-motion';\nimport { useUser } from '@lib/context/user-context';\nimport { Loading } from '@c"
},
{
"path": "src/components/layout/user-home-layout.tsx",
"chars": 4175,
"preview": "import { SEO } from '@components/common/seo';\nimport { Button } from '@components/ui/button';\nimport { FollowButton } fr"
},
{
"path": "src/components/login/login-footer.tsx",
"chars": 1616,
"preview": "// const footerLinks = [\n// ['About', 'https://about.twitter.com'],\n// ['Help Center', 'https://help.twitter.com'],\n"
},
{
"path": "src/components/login/login-main.tsx",
"chars": 6503,
"preview": "import { Button } from '@components/ui/button';\nimport { CustomIcon } from '@components/ui/custom-icon';\nimport { NextIm"
},
{
"path": "src/components/login/sign-in-with-warpcast.tsx",
"chars": 6015,
"preview": "import { useEffect, useState } from 'react';\nimport { toast } from 'react-hot-toast';\nimport QRCode from 'react-qr-code'"
},
{
"path": "src/components/modal/action-modal.tsx",
"chars": 3564,
"preview": "import { useRef, useEffect } from 'react';\nimport cn from 'clsx';\nimport { Dialog } from '@headlessui/react';\nimport { B"
},
{
"path": "src/components/modal/display-modal.tsx",
"chars": 3491,
"preview": "import { UserAvatar } from '@components/user/user-avatar';\nimport { UserName } from '@components/user/user-name';\nimport"
},
{
"path": "src/components/modal/edit-profile-modal.tsx",
"chars": 6118,
"preview": "import { useRef } from 'react';\nimport cn from 'clsx';\nimport { MainHeader } from '@components/home/main-header';\nimport"
},
{
"path": "src/components/modal/image-modal.tsx",
"chars": 4982,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\nimport { useState, useEffect } from 'react';\nimport { AnimatePresence,"
},
{
"path": "src/components/modal/mobile-sidebar-modal.tsx",
"chars": 9657,
"preview": "import { MainHeader } from '@components/home/main-header';\nimport { MobileSidebarLink } from '@components/sidebar/mobile"
},
{
"path": "src/components/modal/modal.tsx",
"chars": 1884,
"preview": "import { AnimatePresence, motion } from 'framer-motion';\nimport { Dialog } from '@headlessui/react';\nimport cn from 'cls"
},
{
"path": "src/components/modal/save-passkey-modal.tsx",
"chars": 1415,
"preview": "import { useState } from 'react';\nimport { ActionModal } from './action-modal';\nimport { createNewPasskey, storeSignerLa"
},
{
"path": "src/components/modal/sign-in-modal-wallet.tsx",
"chars": 12601,
"preview": "import { Dialog } from '@headlessui/react';\nimport { ConnectButton } from '@rainbow-me/rainbowkit';\nimport { useEffect, "
},
{
"path": "src/components/modal/sign-in-modal-warpcast.tsx",
"chars": 1184,
"preview": "import { Dialog } from '@headlessui/react';\nimport WarpcastAuthPopup from '../login/sign-in-with-warpcast';\nimport { Mod"
},
{
"path": "src/components/modal/tip-modal.tsx",
"chars": 5363,
"preview": "import { Dialog } from '@headlessui/react';\nimport { ConnectButton } from '@rainbow-me/rainbowkit';\nimport Link from 'ne"
},
{
"path": "src/components/modal/tweet-reply-modal.tsx",
"chars": 649,
"preview": "import { Input } from '@components/input/input';\nimport { Tweet } from '@components/tweet/tweet';\nimport type { TweetPro"
},
{
"path": "src/components/modal/tweet-stats-modal.tsx",
"chars": 741,
"preview": "import { MainHeader } from '@components/home/main-header';\nimport type { ReactNode } from 'react';\nimport type { StatsTy"
},
{
"path": "src/components/modal/username-modal.tsx",
"chars": 2844,
"preview": "import { Dialog } from '@headlessui/react';\nimport { CustomIcon } from '@components/ui/custom-icon';\nimport { Button } f"
},
{
"path": "src/components/search/search-topics.tsx",
"chars": 2645,
"preview": "import { useState } from 'react';\nimport useSWR from 'swr';\nimport isURL from 'validator/lib/isURL';\nimport { fetchJSON "
},
{
"path": "src/components/search/user-search-result.tsx",
"chars": 911,
"preview": "import { User } from '../../lib/types/user';\nimport { NextImage } from '../ui/next-image';\n\nexport function UserSearchRe"
},
{
"path": "src/components/sidebar/menu-link.tsx",
"chars": 382,
"preview": "import { forwardRef } from 'react';\nimport Link from 'next/link';\nimport type { ComponentPropsWithRef } from 'react';\n\nt"
},
{
"path": "src/components/sidebar/mobile-sidebar-link.tsx",
"chars": 1147,
"preview": "import Link from 'next/link';\nimport cn from 'clsx';\nimport { preventBubbling } from '@lib/utils';\nimport { HeroIcon } f"
},
{
"path": "src/components/sidebar/mobile-sidebar.tsx",
"chars": 1523,
"preview": "import { useAuth } from '@lib/context/auth-context';\nimport { useModal } from '@lib/hooks/useModal';\nimport { Button } f"
},
{
"path": "src/components/sidebar/more-settings.tsx",
"chars": 4268,
"preview": "import { DisplayModal } from '@components/modal/display-modal';\nimport { Modal } from '@components/modal/modal';\nimport "
},
{
"path": "src/components/sidebar/sidebar-link.tsx",
"chars": 1574,
"preview": "import { useRouter } from 'next/router';\nimport Link from 'next/link';\nimport cn from 'clsx';\nimport { preventBubbling }"
},
{
"path": "src/components/sidebar/sidebar-profile.tsx",
"chars": 7903,
"preview": "import { ActionModal } from '@components/modal/action-modal';\nimport { Modal } from '@components/modal/modal';\nimport { "
},
{
"path": "src/components/sidebar/sidebar.tsx",
"chars": 5781,
"preview": "import { Input } from '@components/input/input';\nimport { Modal } from '@components/modal/modal';\nimport { Button } from"
},
{
"path": "src/components/sync/sync-view.tsx",
"chars": 1470,
"preview": "import { useEffect, useState } from 'react';\nimport useSWR from 'swr';\n\nexport function SyncView({ userId }: { userId?: "
},
{
"path": "src/components/tweet/number-stats.tsx",
"chars": 667,
"preview": "import { AnimatePresence, motion } from 'framer-motion';\nimport { getStatsMove } from '@lib/utils';\nimport { formatNumbe"
},
{
"path": "src/components/tweet/stats-empty.tsx",
"chars": 1045,
"preview": "import cn from 'clsx';\nimport { NextImage } from '@components/ui/next-image';\nimport type { ImageData } from '@lib/types"
},
{
"path": "src/components/tweet/tweet-actions.tsx",
"chars": 14948,
"preview": "import { ActionModal } from '@components/modal/action-modal';\nimport { Modal } from '@components/modal/modal';\nimport { "
},
{
"path": "src/components/tweet/tweet-date.tsx",
"chars": 1068,
"preview": "import Link from 'next/link';\nimport cn from 'clsx';\nimport { formatDate } from '@lib/date';\nimport { ToolTip } from '@c"
},
{
"path": "src/components/tweet/tweet-embed.tsx",
"chars": 7161,
"preview": "import Link from 'next/link';\r\nimport { useMemo, useState } from 'react';\r\nimport useSWR from 'swr';\r\nimport { ExternalE"
},
{
"path": "src/components/tweet/tweet-option.tsx",
"chars": 1399,
"preview": "import cn from 'clsx';\nimport { preventBubbling } from '@lib/utils';\nimport { HeroIcon } from '@components/ui/hero-icon'"
},
{
"path": "src/components/tweet/tweet-parent.tsx",
"chars": 1837,
"preview": "import { useMemo, useEffect } from 'react';\nimport { doc } from 'firebase/firestore';\nimport { getRandomId } from '@lib/"
},
{
"path": "src/components/tweet/tweet-parent.tsx.bak",
"chars": 1160,
"preview": "import { useMemo, useEffect } from 'react';\nimport { doc } from 'firebase/firestore';\nimport { useDocument } from '@lib/"
},
{
"path": "src/components/tweet/tweet-share.tsx",
"chars": 4652,
"preview": "import Link from 'next/link';\nimport cn from 'clsx';\nimport { Popover } from '@headlessui/react';\nimport { AnimatePresen"
},
{
"path": "src/components/tweet/tweet-stats.tsx",
"chars": 6749,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\nimport cn from 'clsx';\nimport { useEffect, useMemo, useState } from 'r"
},
{
"path": "src/components/tweet/tweet-status.tsx",
"chars": 946,
"preview": "import { motion } from 'framer-motion';\nimport { HeroIcon } from '@components/ui/hero-icon';\nimport { CustomIcon } from "
},
{
"path": "src/components/tweet/tweet-text.tsx",
"chars": 3182,
"preview": "import Link from 'next/link';\nimport { useMemo } from 'react';\nimport { ImagesPreview } from '../../lib/types/file';\nimp"
},
{
"path": "src/components/tweet/tweet-topic.tsx",
"chars": 1962,
"preview": "import Link from 'next/link';\nimport useSWR from 'swr';\nimport { fetchJSON } from '../../lib/fetch';\nimport { TopicRespo"
},
{
"path": "src/components/tweet/tweet-with-parent.tsx",
"chars": 1302,
"preview": "import type { Tweet as TweetType } from '@lib/types/tweet';\nimport { useState } from 'react';\nimport { Tweet } from './t"
},
{
"path": "src/components/tweet/tweet-with-parent.tsx.bak",
"chars": 1277,
"preview": "import { useState } from 'react';\nimport { Tweet } from './tweet';\nimport { TweetParent } from './tweet-parent.tsx.bak';"
},
{
"path": "src/components/tweet/tweet.tsx",
"chars": 8321,
"preview": "import { ImagePreview } from '@components/input/image-preview';\nimport { Modal } from '@components/modal/modal';\nimport "
},
{
"path": "src/components/ui/button.tsx",
"chars": 925,
"preview": "import { forwardRef } from 'react';\nimport cn from 'clsx';\nimport { Loading } from './loading';\nimport type { ComponentP"
},
{
"path": "src/components/ui/caution-warn.tsx",
"chars": 845,
"preview": "import cn from 'clsx';\nimport { HeroIcon } from './hero-icon';\n\nexport function CautionWarn(): JSX.Element {\n return (\n"
},
{
"path": "src/components/ui/custom-icon.tsx",
"chars": 6296,
"preview": "import cn from 'clsx';\n\ntype IconName = keyof typeof Icons;\n\ntype IconProps = {\n className?: string;\n};\n\ntype CustomIco"
},
{
"path": "src/components/ui/error.tsx",
"chars": 493,
"preview": "import { HeroIcon } from './hero-icon';\n\ntype ErrorProps = {\n message?: string;\n};\n\nexport function Error({ message }: "
},
{
"path": "src/components/ui/feed-ordering-selector.tsx",
"chars": 1082,
"preview": "import { useAuth } from '../../lib/context/auth-context';\nimport { FeedOrderingType } from '../../lib/types/feed';\nimpor"
},
{
"path": "src/components/ui/follow-button.tsx",
"chars": 3605,
"preview": "import { ActionModal } from '@components/modal/action-modal';\nimport { Modal } from '@components/modal/modal';\nimport { "
},
{
"path": "src/components/ui/hero-icon.tsx",
"chars": 506,
"preview": "import * as SolidIcons from '@heroicons/react/24/solid';\nimport * as OutlineIcons from '@heroicons/react/24/outline';\n\ne"
},
{
"path": "src/components/ui/loading.tsx",
"chars": 451,
"preview": "import cn from 'clsx';\nimport { CustomIcon } from './custom-icon';\n\ntype LoadingProps = {\n className?: string;\n iconCl"
},
{
"path": "src/components/ui/menu-row.tsx",
"chars": 1947,
"preview": "import Link from 'next/link';\nimport { HeroIcon, IconName } from './hero-icon';\nimport cn from 'clsx';\nimport { Loading "
},
{
"path": "src/components/ui/next-image.tsx",
"chars": 1502,
"preview": "import { useState } from 'react';\nimport Image from 'next/image';\nimport cn from 'clsx';\nimport type { ReactNode } from "
},
{
"path": "src/components/ui/segmented-nav-link.tsx",
"chars": 1165,
"preview": "import { useRouter } from 'next/router';\nimport Link from 'next/link';\nimport cn from 'clsx';\n\ntype SegmentedNavLinkProp"
},
{
"path": "src/components/ui/tooltip.tsx",
"chars": 1015,
"preview": "import cn from 'clsx';\n\ntype ToolTipProps = {\n tip: string;\n modal?: boolean;\n className?: string;\n groupInner?: boo"
},
{
"path": "src/components/user/user-avatar.tsx",
"chars": 988,
"preview": "import Link from 'next/link';\nimport cn from 'clsx';\nimport { NextImage } from '@components/ui/next-image';\n\ntype UserAv"
},
{
"path": "src/components/user/user-card.tsx",
"chars": 2139,
"preview": "import Link from 'next/link';\nimport { UserAvatar } from '@components/user/user-avatar';\nimport { FollowButton } from '@"
},
{
"path": "src/components/user/user-cards.tsx",
"chars": 2698,
"preview": "import cn from 'clsx';\nimport { AnimatePresence, motion } from 'framer-motion';\nimport { StatsEmpty } from '@components/"
},
{
"path": "src/components/user/user-details.tsx",
"chars": 3733,
"preview": "import type { IconName } from '@components/ui/hero-icon';\nimport { HeroIcon } from '@components/ui/hero-icon';\nimport { "
},
{
"path": "src/components/user/user-edit-profile.tsx",
"chars": 7039,
"preview": "import { useState, useEffect } from 'react';\nimport { toast } from 'react-hot-toast';\nimport cn from 'clsx';\nimport { us"
},
{
"path": "src/components/user/user-fid.tsx",
"chars": 335,
"preview": "type UserFollowingProps = {\n userId: string;\n};\n\nexport function UserFid({\n userId: userTargetId\n}: UserFollowingProps"
},
{
"path": "src/components/user/user-follow-stats.tsx",
"chars": 2317,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\nimport { useState, useEffect, useMemo } from 'react';\nimport Link from"
},
{
"path": "src/components/user/user-follow.tsx",
"chars": 1239,
"preview": "import { SEO } from '@components/common/seo';\nimport { UserCards } from '@components/user/user-cards';\nimport { useUser "
},
{
"path": "src/components/user/user-following.tsx",
"chars": 500,
"preview": "import { useAuth } from '@lib/context/auth-context';\n\ntype UserFollowingProps = {\n userTargetId: string;\n};\n\nexport fun"
},
{
"path": "src/components/user/user-header.tsx",
"chars": 2443,
"preview": "import { useUser } from '@lib/context/user-context';\nimport { isPlural } from '@lib/utils';\nimport type { Variants } fro"
},
{
"path": "src/components/user/user-home-avatar.tsx",
"chars": 1704,
"preview": "import { useModal } from '@lib/hooks/useModal';\nimport { Button } from '@components/ui/button';\nimport { NextImage } fro"
},
{
"path": "src/components/user/user-home-cover.tsx",
"chars": 1248,
"preview": "import { useModal } from '@lib/hooks/useModal';\nimport { Button } from '@components/ui/button';\nimport { NextImage } fro"
},
{
"path": "src/components/user/user-known-followers.tsx",
"chars": 2217,
"preview": "import useSWR from 'swr';\nimport { useAuth } from '../../lib/context/auth-context';\nimport { fetchJSON } from '../../lib"
},
{
"path": "src/components/user/user-name.tsx",
"chars": 1090,
"preview": "import cn from 'clsx';\nimport Link from 'next/link';\nimport { HeroIcon } from '@components/ui/hero-icon';\n\ntype UserName"
},
{
"path": "src/components/user/user-nav.tsx",
"chars": 1124,
"preview": "import { motion } from 'framer-motion';\nimport cn from 'clsx';\nimport { variants } from '@components/user/user-header';\n"
},
{
"path": "src/components/user/user-share.tsx",
"chars": 2306,
"preview": "import cn from 'clsx';\nimport { Popover } from '@headlessui/react';\nimport { AnimatePresence, motion } from 'framer-moti"
},
{
"path": "src/components/user/user-tooltip.tsx",
"chars": 6146,
"preview": "import { FollowButton } from '@components/ui/follow-button';\nimport { useWindow } from '@lib/context/window-context';\nim"
},
{
"path": "src/components/user/user-username.tsx",
"chars": 593,
"preview": "import Link from 'next/link';\nimport cn from 'clsx';\n\ntype UserUsernameProps = {\n username: string;\n className?: strin"
},
{
"path": "src/components/view/view-parent-tweet.tsx",
"chars": 1933,
"preview": "import { Tweet } from '@components/tweet/tweet';\nimport { RefObject, useEffect, useMemo } from 'react';\nimport useSWR fr"
},
{
"path": "src/components/view/view-tweet-stats.tsx",
"chars": 4120,
"preview": "import { Modal } from '@components/modal/modal';\nimport { TweetStatsModal } from '@components/modal/tweet-stats-modal';\n"
},
{
"path": "src/components/view/view-tweet.tsx",
"chars": 6639,
"preview": "import { ImagePreview } from '@components/input/image-preview';\nimport { Input } from '@components/input/input';\nimport "
},
{
"path": "src/contracts/id-registry.ts",
"chars": 12917,
"preview": "const ID_REGISTRY_ADDRESS =\n '0x00000000fc6c5f01fc30151999387bb99a9f489b' as `0x${string}`\n\nconst ID_REGISTRY_ABI = [\n "
},
{
"path": "src/contracts/index.ts",
"chars": 94,
"preview": "export * from './id-registry';\nexport * from './key-registry';\nexport * from './key-gateway';\n"
},
{
"path": "src/contracts/key-gateway.ts",
"chars": 7393,
"preview": "const KEY_GATEWAY_ADDRESS =\n '0x00000000fC56947c7E7183f8Ca4B62398CaAdf0B' as `0x${string}`;\n\nconst KEY_GATEWAY_ABI = [\n"
},
{
"path": "src/contracts/key-registry.ts",
"chars": 15705,
"preview": "const KEY_REGISTRY_ADDRESS =\n '0x00000000Fc1237824fb747aBDE0FF18990E59b7e' as `0x${string}`;\n\nconst KEY_REGISTRY_ABI = "
},
{
"path": "src/contracts/validator.ts",
"chars": 5200,
"preview": "const VALIDATOR_ABI = [\n {\n inputs: [\n { internalType: 'address', name: '_idRegistry', type: 'address' },\n "
},
{
"path": "src/lib/api/auth.ts",
"chars": 139,
"preview": "export const AUTH: Readonly<RequestInit> = {\n headers: {\n Authorization: `Bearer ${process.env.TWITTER_BEARER_TOKEN "
},
{
"path": "src/lib/api/trends.ts",
"chars": 1248,
"preview": "import useSWR from 'swr';\nimport type { SWRConfiguration } from 'swr';\nimport type { FilteredTrends, SuccessResponse } f"
},
{
"path": "src/lib/chains/resolve-chain-icon.ts",
"chars": 987,
"preview": "import { LRU } from '../lru-cache';\n\nexport async function resolveChainIcon(chainId: number) {\n const cacheName = `eip1"
},
{
"path": "src/lib/context/auth-context.tsx",
"chars": 6991,
"preview": "import { getRandomId } from '@lib/random';\nimport type { Bookmark } from '@lib/types/bookmark';\nimport type { UserFull, "
},
{
"path": "src/lib/context/theme-context.tsx",
"chars": 3714,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\nimport { useState, useEffect, createContext, useContext } from 'react'"
},
{
"path": "src/lib/context/user-context.tsx",
"chars": 751,
"preview": "import { createContext, useContext } from 'react';\nimport type { ReactNode } from 'react';\nimport type { User, UserFull "
},
{
"path": "src/lib/context/window-context.tsx",
"chars": 1544,
"preview": "import { createContext, useContext, useState, useEffect } from 'react';\nimport type { ReactNode } from 'react';\n\ntype Wi"
},
{
"path": "src/lib/crypto.ts",
"chars": 605,
"preview": "import * as ed from '@noble/ed25519';\nimport { KeyPair } from './types/keypair';\nimport { bytesToHex } from 'viem';\n\nexp"
},
{
"path": "src/lib/date.ts",
"chars": 3376,
"preview": "const RELATIVE_TIME_FORMATTER = new Intl.RelativeTimeFormat('en-gb', {\n style: 'short',\n numeric: 'auto'\n});\n\ntype Uni"
},
{
"path": "src/lib/embeds.ts",
"chars": 3242,
"preview": "import { getFrame } from 'frames.js';\r\nimport getMetaData from 'metadata-scraper';\r\nimport { LRU } from './lru-cache';\r\n"
},
{
"path": "src/lib/env.ts",
"chars": 199,
"preview": "export const isProduction = process.env.NODE_ENV === 'production';\nexport const isDevelopment = process.env.NODE_ENV ==="
},
{
"path": "src/lib/farcaster/index.ts",
"chars": 525,
"preview": "import {\n getInsecureHubRpcClient,\n getSSLHubRpcClient,\n HubRpcClient\n} from '@farcaster/hub-nodejs';\n\nconst globalFo"
},
{
"path": "src/lib/farcaster/utils.ts",
"chars": 4685,
"preview": "import {\n Embed,\n FarcasterNetwork,\n HashScheme,\n makeCastAdd,\n makeCastRemove,\n makeLinkAdd,\n makeLinkRemove,\n "
},
{
"path": "src/lib/fetch.ts",
"chars": 221,
"preview": "export async function fetchJSON<T>(\n resource: RequestInfo,\n init?: RequestInit | undefined\n): Promise<T> {\n const re"
},
{
"path": "src/lib/hooks/useConnectedWalletFid.tsx",
"chars": 445,
"preview": "import { useAccount, useReadContract } from 'wagmi';\nimport { ID_REGISTRY } from '../../contracts';\nimport { useEffect }"
},
{
"path": "src/lib/hooks/useInfiniteScroll.tsx",
"chars": 2732,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\nimport { motion } from 'framer-motion';\nimport { useCallback, useEffec"
},
{
"path": "src/lib/hooks/useInfiniteScrollUsers.tsx",
"chars": 2488,
"preview": "/* eslint-disable react-hooks/exhaustive-deps */\n\nimport { motion } from 'framer-motion';\nimport { useCallback, useEffec"
},
{
"path": "src/lib/hooks/useModal.ts",
"chars": 343,
"preview": "import { useState } from 'react';\n\ntype Modal = {\n open: boolean;\n openModal: () => void;\n closeModal: () => void;\n};"
},
{
"path": "src/lib/hooks/useRequireAuth.ts",
"chars": 500,
"preview": "import { useEffect } from 'react';\nimport { useRouter } from 'next/router';\nimport { useAuth } from '@lib/context/auth-c"
},
{
"path": "src/lib/imgur/upload.ts",
"chars": 561,
"preview": "export const uploadToImgur = async (file: File): Promise<string | null> => {\n const formData = new FormData();\n formDa"
},
{
"path": "src/lib/keys.ts",
"chars": 2016,
"preview": "import * as ed from '@noble/ed25519';\nimport { KeyPair } from './types/keypair';\nimport { bytesToHex } from 'viem';\n\nexp"
},
{
"path": "src/lib/lru-cache.ts",
"chars": 265,
"preview": "import { LRUCache } from 'lru-cache';\n\nconst globalForLRU = global as unknown as {\n LRU: LRUCache<string, any> | undefi"
},
{
"path": "src/lib/merge.ts",
"chars": 539,
"preview": "import type { Timestamp } from 'firebase/firestore';\n\ntype DataWithDate<T> = T & { createdAt: Timestamp };\n\nexport funct"
},
{
"path": "src/lib/paginated-reactions.ts",
"chars": 825,
"preview": "import { Prisma } from '@prisma/client';\nimport { prisma } from './prisma';\nimport { BaseResponse } from './types/respon"
},
{
"path": "src/lib/paginated-tweets.ts",
"chars": 6824,
"preview": "import { ReactionType } from '@farcaster/hub-web';\nimport { casts, Prisma } from '@prisma/client';\nimport { Sql } from '"
},
{
"path": "src/lib/passkeys.ts",
"chars": 2729,
"preview": "import type { UserFull } from './types/user';\nimport { KeyPair } from './types/keypair';\nimport { hexToBytes } from 'vie"
},
{
"path": "src/lib/prisma.ts",
"chars": 351,
"preview": "import 'dotenv/config';\nimport { PrismaClient } from '@prisma/client';\n\nconst globalForPrisma = global as unknown as {\n "
},
{
"path": "src/lib/random.ts",
"chars": 375,
"preview": "const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n\nexport function getRandomId(): string {"
},
{
"path": "src/lib/signers.ts",
"chars": 2329,
"preview": "import { prisma } from './prisma';\nimport { SignerDetail } from './types/signer';\n\nexport async function getSignerDetail"
},
{
"path": "src/lib/topics/resolve-topic.ts",
"chars": 6841,
"preview": "import { createPublicClient, http, createClient } from 'viem';\nimport * as chains from 'viem/chains';\nimport { resolveCh"
},
{
"path": "src/lib/types/app-auth.ts",
"chars": 222,
"preview": "import { BaseResponse } from './responses';\n\nexport type AppAuthResponse = BaseResponse<AppAuthType>;\n\nexport type AppAu"
},
{
"path": "src/lib/types/available.ts",
"chars": 324,
"preview": "export type AvailablePlace = {\n name: string;\n placeType: PlaceType;\n url: string;\n parentid: number;\n country: str"
},
{
"path": "src/lib/types/bookmark.ts",
"chars": 395,
"preview": "import type { Timestamp, FirestoreDataConverter } from 'firebase/firestore';\n\nexport type Bookmark = {\n id: string;\n c"
},
{
"path": "src/lib/types/feed.ts",
"chars": 49,
"preview": "export type FeedOrderingType = 'latest' | 'top';\n"
},
{
"path": "src/lib/types/file.ts",
"chars": 284,
"preview": "export type ImageData = {\n src: string;\n alt: string;\n};\n\nexport type ImagesPreview = (ImageData & {\n id: string;\n})["
},
{
"path": "src/lib/types/keypair.ts",
"chars": 84,
"preview": "export type KeyPair = {\n publicKey: `0x${string}`;\n privateKey: `0x${string}`;\n};\n"
},
{
"path": "src/lib/types/notifications.ts",
"chars": 1660,
"preview": "import { casts } from '@prisma/client';\nimport { BaseResponse } from './responses';\nimport { Tweet } from './tweet';\nimp"
},
{
"path": "src/lib/types/online.ts",
"chars": 332,
"preview": "import { BaseResponse } from './responses';\nimport { User } from './user';\n\nexport type AppProfile = { pfp?: string; dis"
},
{
"path": "src/lib/types/place.ts",
"chars": 768,
"preview": "export type TrendsReturn = {\n data: TrendsResponse;\n status: number;\n};\n\nexport type TrendsResponse = SuccessResponse "
},
{
"path": "src/lib/types/responses.ts",
"chars": 71,
"preview": "export interface BaseResponse<T> {\n result?: T;\n message?: string;\n}\n"
},
{
"path": "src/lib/types/signer.ts",
"chars": 787,
"preview": "import { Message } from '@farcaster/hub-web';\nimport { BaseResponse } from './responses';\n\nexport type SignerDetail = {\n"
},
{
"path": "src/lib/types/stats.ts",
"chars": 415,
"preview": "import type { Timestamp, FirestoreDataConverter } from 'firebase/firestore';\n\nexport type Stats = {\n likes: string[];\n "
},
{
"path": "src/lib/types/theme.ts",
"chars": 127,
"preview": "export type Theme = 'light' | 'dim' | 'dark';\nexport type Accent = 'blue' | 'yellow' | 'pink' | 'purple' | 'orange' | 'g"
},
{
"path": "src/lib/types/topic.ts",
"chars": 200,
"preview": "import { BaseResponse } from './responses';\n\nexport type TopicResponse = BaseResponse<TopicType>;\n\nexport type TopicType"
},
{
"path": "src/lib/types/trends.ts",
"chars": 187,
"preview": "import { TopicType } from './topic';\nimport { BaseResponse } from './responses';\n\nexport type TrendsResponse = BaseRespo"
},
{
"path": "src/lib/types/tweet.ts",
"chars": 4797,
"preview": "import { Embed } from '@farcaster/hub-web';\r\nimport { casts } from '@prisma/client';\r\nimport { Frame } from 'frames.js';"
},
{
"path": "src/lib/types/user.ts",
"chars": 2158,
"preview": "import { UserDataType } from '@farcaster/hub-web';\nimport { BaseResponse } from './responses';\nimport type { Accent, The"
},
{
"path": "src/lib/user/resolve-user.ts",
"chars": 5465,
"preview": "import { getHubRpcClient, UserDataType } from '@farcaster/hub-web';\nimport { prisma } from '../prisma';\nimport { User, u"
},
{
"path": "src/lib/utils.ts",
"chars": 3498,
"preview": "import type { SyntheticEvent } from 'react';\nimport type { MotionProps } from 'framer-motion';\nimport isURL from 'valida"
},
{
"path": "src/lib/validation.ts",
"chars": 2583,
"preview": "import { getRandomId } from './random';\nimport type { FilesWithId, FileWithId, ImagesPreview } from './types/file';\n\ncon"
},
{
"path": "src/pages/404.tsx",
"chars": 527,
"preview": "import Error from 'next/error';\nimport { useTheme } from '@lib/context/theme-context';\nimport { SEO } from '@components/"
},
{
"path": "src/pages/[...redirect].tsx",
"chars": 106,
"preview": "import NotFound from './404';\n\nexport default function Redirect(): JSX.Element {\n return <NotFound />;\n}\n"
},
{
"path": "src/pages/_app.tsx",
"chars": 1786,
"preview": "import '@rainbow-me/rainbowkit/styles.css';\nimport '@styles/globals.scss';\n\nimport { AppHead } from '@components/common/"
},
{
"path": "src/pages/_document.tsx",
"chars": 246,
"preview": "import { Html, Head, Main, NextScript } from 'next/document';\n\nexport default function Document(): JSX.Element {\n retur"
},
{
"path": "src/pages/api/embeds.ts",
"chars": 673,
"preview": "import { NextApiRequest, NextApiResponse } from 'next';\r\nimport { populateEmbed } from '../../lib/embeds';\r\nimport { Ext"
},
{
"path": "src/pages/api/feed.ts",
"chars": 6187,
"preview": "import { Prisma, casts } from '@prisma/client';\nimport { NextApiRequest, NextApiResponse } from 'next';\nimport {\n Pagin"
},
{
"path": "src/pages/api/hub/batch.ts",
"chars": 1062,
"preview": "import { Message } from '@farcaster/hub-nodejs';\nimport { NextApiRequest, NextApiResponse } from 'next';\nimport { hubCli"
},
{
"path": "src/pages/api/hub/index.ts",
"chars": 1353,
"preview": "import { Message } from '@farcaster/hub-nodejs';\nimport { NextApiRequest, NextApiResponse } from 'next';\nimport { hubCli"
},
{
"path": "src/pages/api/online/index.ts",
"chars": 4613,
"preview": "import { Prisma } from '@prisma/client';\nimport { NextApiRequest, NextApiResponse } from 'next';\nimport { prisma } from "
},
{
"path": "src/pages/api/search.ts",
"chars": 1443,
"preview": "import { UserDataType } from '@farcaster/hub-web';\nimport { NextApiRequest, NextApiResponse } from 'next';\nimport { pris"
},
{
"path": "src/pages/api/signer/[pubKey]/authorize.ts",
"chars": 1563,
"preview": "import type { NextApiRequest, NextApiResponse } from 'next';\nimport { mnemonicToAccount } from 'viem/accounts';\nimport {"
},
{
"path": "src/pages/api/signer/[pubKey]/user.ts",
"chars": 1005,
"preview": "import type { NextApiRequest, NextApiResponse } from 'next';\nimport { hexToBytes } from 'viem';\nimport { prisma } from '"
},
{
"path": "src/pages/api/topic/index.ts",
"chars": 615,
"preview": "import type { NextApiRequest, NextApiResponse } from 'next';\nimport { UserResponse } from '../../../lib/types/user';\nimp"
},
{
"path": "src/pages/api/trends/index.ts",
"chars": 1480,
"preview": "import { NextApiRequest, NextApiResponse } from 'next';\nimport { resolveTopic } from '../../../lib/topics/resolve-topic'"
},
{
"path": "src/pages/api/tweet/[id]/engagers.ts",
"chars": 1293,
"preview": "import { NextApiRequest, NextApiResponse } from 'next';\nimport {\n getReactionUsersPaginated,\n PaginatedUsersResponse\n}"
},
{
"path": "src/pages/api/tweet/[id]/index.ts",
"chars": 1887,
"preview": "import { ReactionType } from '@farcaster/hub-web';\nimport type { NextApiRequest, NextApiResponse } from 'next';\nimport {"
},
{
"path": "src/pages/api/tweet/[id]/replies.ts",
"chars": 1145,
"preview": "import { NextApiRequest, NextApiResponse } from 'next';\nimport {\n getTweetsPaginatedPrismaArgs,\n PaginatedTweetsRespon"
},
{
"path": "src/pages/api/tweet/batch.ts",
"chars": 2951,
"preview": "import { Prisma, casts } from '@prisma/client';\nimport { NextApiRequest, NextApiResponse } from 'next';\nimport {\n Pagin"
}
]
// ... and 36 more files (download for full content)
About this extraction
This page contains the full source code of the stephancill/opencast GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 236 files (582.2 KB), approximately 150.5k tokens, and a symbol index with 498 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.