chore(repo): green CI pass - lint, typecheck, tests, build, security hygiene
This commit is contained in:
parent
8f7a2031ed
commit
fd41354a12
91 changed files with 3185 additions and 1625 deletions
90
.github/workflows/ci.yml
vendored
Normal file
90
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, feat/**, fix/**, chore/**, hotfix/**]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
install:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: install
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm test
|
||||
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, typecheck, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Build web
|
||||
run: pnpm --filter degenfeed-web build
|
||||
- name: Build worker (dry-run)
|
||||
run: pnpm --filter @degenfeed/api-worker build
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install gitleaks
|
||||
run: |
|
||||
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.21.2/gitleaks_8.21.2_linux_x64.tar.gz | tar -xz -C /usr/local/bin gitleaks
|
||||
- name: Scan for secrets
|
||||
run: gitleaks detect --source . --no-banner --exit-code 1
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,6 +4,7 @@
|
|||
# ── Secrets (zero tolerance) ───────────────────────────────────
|
||||
.env
|
||||
.env.*
|
||||
.dev.vars
|
||||
!.env.example
|
||||
!.env.template
|
||||
*.pem
|
||||
|
|
|
|||
3
.mise.toml
Normal file
3
.mise.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[tools]
|
||||
node = "22"
|
||||
pnpm = "11.7.0"
|
||||
69
AGENTS.md
69
AGENTS.md
|
|
@ -8,29 +8,46 @@
|
|||
canonical · owner=crmuncher · last_updated=2026-07-06
|
||||
|
||||
## What This Repo Is
|
||||
Next.js multi-protocol social aggregator client. Aggregates Nostr, Farcaster, Lens, and Bluesky into a single feed and composer.
|
||||
Next.js multi-protocol social aggregator client. Aggregates Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, and RSS into a single feed and composer.
|
||||
|
||||
> **This repo is NOT the deployed DegenFeed Mastodon instance** — that runs at `/srv/degenfeed/` as the `gotosocial` container behind `social.degenfeed.xyz`. This repo is the web client only.
|
||||
> **This repo is NOT the deployed DegenFeed social server** — GotoSocial runs separately at `/srv/degenfeed/` behind `social.degenfeed.xyz`. This repo is the web client, landing page, and API worker.
|
||||
|
||||
## Type
|
||||
`frontend` · language=TypeScript 5 + Next.js 14 (App Router) + React 19
|
||||
`frontend` · language=TypeScript 5 + Next.js 15 (App Router) + React 19
|
||||
|
||||
## Where It Lives
|
||||
- Source: `https://git.rugmunch.io/RugMunchMedia/degenfeed-web`
|
||||
- Deployed: static build → nginx (target TBD)
|
||||
- Monorepo: Turborepo + pnpm workspaces
|
||||
- Landing page: `https://degenfeed.xyz` (Cloudflare Pages, `apps/landing/`)
|
||||
- Web client: `https://app.degenfeed.xyz` (Cloudflare Pages, `apps/web/`)
|
||||
- Edge API: `https://degenfeed.xyz/api/*` and `https://app.degenfeed.xyz/api/*` (Cloudflare Worker `degenfeed-api`, `workers/api/`)
|
||||
- Social server: `https://social.degenfeed.xyz` (GotoSocial, separate deployment)
|
||||
- Port: n/a (serverless / edge)
|
||||
|
||||
## Built With
|
||||
- Standards: [git.rugmunch.io/RugMunchMedia/standards](https://git.rugmunch.io/RugMunchMedia/standards)
|
||||
- Toolchain: [TOOLCHAIN.md](https://git.rugmunch.io/RugMunchMedia/standards/raw/branch/main/TOOLCHAIN.md)
|
||||
- Conventions: [CONVENTIONS.md](https://git.rugmunch.io/RugMunchMedia/standards/raw/branch/main/CONVENTIONS.md)
|
||||
- Git topology: [GIT-ARCHITECTURE.md](https://git.rugmunch.io/RugMunchMedia/standards/raw/branch/main/GIT-ARCHITECTURE.md)
|
||||
|
||||
## Components
|
||||
- `apps/landing/` — static landing page (live at `degenfeed.xyz`)
|
||||
- `apps/web/` — Next.js client (scaffolding, not yet deployed)
|
||||
- `workers/` — edge workers (e.g. news aggregation)
|
||||
- `packages/` — shared protocol adapters (Nostr, Farcaster, Lens, Bluesky)
|
||||
- `infra/` — infra as code
|
||||
- `apps/web/` — Next.js 15 aggregator client (live at `app.degenfeed.xyz`)
|
||||
- `workers/api/` — Cloudflare Worker edge proxy + aggregator (`degenfeed-api`)
|
||||
- `packages/types/` — unified protocol types
|
||||
- `packages/feed-core/` — normalization, deduplication, and feed assembly
|
||||
- `packages/ranking/` — transparent feed scoring
|
||||
- `packages/ui/` — shared components
|
||||
- `packages/identity/` — cross-protocol identity resolution
|
||||
- `packages/auth/` — wallet sign-in and session logic
|
||||
- `packages/storage/` — client-side and edge storage helpers
|
||||
- `packages/nostr-sdk/` — Nostr adapter
|
||||
- `packages/farcaster-sdk/` — Farcaster adapter
|
||||
- `packages/lens-sdk/` — Lens adapter
|
||||
- `packages/bluesky-sdk/` — Bluesky adapter
|
||||
- `packages/mastodon-sdk/` — Mastodon/ActivityPub adapter
|
||||
- `packages/threads-sdk/` — Threads adapter
|
||||
- `packages/rss-sdk/` — RSS adapter
|
||||
- `infra/` — Cloudflare Pages / Worker infrastructure and wrangler configs
|
||||
|
||||
## Dependencies
|
||||
- Node ≥ 20
|
||||
|
|
@ -38,6 +55,7 @@ Next.js multi-protocol social aggregator client. Aggregates Nostr, Farcaster, Le
|
|||
- Turborepo 2
|
||||
- Biome (format + lint)
|
||||
- TypeScript 5.5+
|
||||
- Wrangler (Cloudflare Worker deploys)
|
||||
|
||||
## 🚨 CRITICAL RULES
|
||||
|
||||
|
|
@ -48,23 +66,38 @@ Next.js multi-protocol social aggregator client. Aggregates Nostr, Farcaster, Le
|
|||
5. **No mainnet RPC keys in source.** Use gopass and environment.
|
||||
6. **Update STATUS.md before committing.** Builders must know where we are.
|
||||
7. **Read DECISIONS.md before architectural changes.** Don't repeat rejected designs.
|
||||
8. **Use `make <target>`.** Don't run raw `pnpm` scripts when a Makefile target exists.
|
||||
9. **Conventional commits.** `feat(scope):`, `fix(scope):`, etc. Use `make commit`.
|
||||
10. **Never `--no-verify` on pre-commit.** If a hook fails, fix it.
|
||||
|
||||
## Commands
|
||||
```bash
|
||||
pnpm install # install deps
|
||||
pnpm dev # dev server (turbo)
|
||||
pnpm build # production build
|
||||
pnpm lint # biome check
|
||||
pnpm typecheck # tsc --noEmit
|
||||
pnpm test # run tests
|
||||
pnpm format # biome format --write
|
||||
make install # install deps (frozen lockfile)
|
||||
make dev # dev servers via Turbo
|
||||
make build # production build via Turbo
|
||||
make lint # Biome lint across monorepo
|
||||
make format # Biome format --write
|
||||
make check # fast check: lint + typecheck
|
||||
make typecheck # TypeScript --noEmit across monorepo
|
||||
make test # unit tests via vitest
|
||||
make test-cov # tests with coverage
|
||||
make security # gitleaks detect
|
||||
make ci # full local pipeline (lint + typecheck + test + build)
|
||||
make clean # remove build artifacts and node_modules
|
||||
```
|
||||
|
||||
## Live Features
|
||||
- `/news` — news aggregation from RSS and protocol feeds
|
||||
- Wallet sign-in — Solana + EVM wallet adapter integration
|
||||
|
||||
## Architecture
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md). Update it when components change.
|
||||
|
||||
## Plan
|
||||
[ROADMAP.md](ROADMAP.md) shows current sprint and upcoming work. Update weekly.
|
||||
|
||||
## Status
|
||||
See [PRODUCT.md](PRODUCT.md) and [ROADMAP.md](ROADMAP.md) for current state.
|
||||
See [PRODUCT.md](PRODUCT.md) for product state and acceptance criteria.
|
||||
|
||||
## Owners
|
||||
- Primary: crmuncher
|
||||
|
|
@ -72,4 +105,4 @@ See [PRODUCT.md](PRODUCT.md) and [ROADMAP.md](ROADMAP.md) for current state.
|
|||
|
||||
## Related Repos
|
||||
- `degenfeed/` — deployed GotoSocial instance (separate git repo, lives on Talos)
|
||||
- See [standards/PRODUCTS.md](https://git.rugmunch.io/RugMunchMedia/standards/raw/branch/main/PRODUCTS.md).
|
||||
- See [standards/PRODUCTS.md](https://git.rugmunch.io/RugMunchMedia/standards/raw/branch/main/PRODUCTS.md).
|
||||
|
|
|
|||
|
|
@ -1,32 +1,58 @@
|
|||
# DegenFeed Web — Architecture
|
||||
|
||||
**Status:** canonical
|
||||
**Last updated:** 2026-07-03
|
||||
**Status:** canonical
|
||||
**Last updated:** 2026-07-06
|
||||
|
||||
## Overview
|
||||
Monorepo-style Next.js application with shared packages for protocol adapters and UI.
|
||||
Turborepo monorepo with a Cloudflare Pages-hosted Next.js 15 client, a static landing page, and a Cloudflare Worker edge API. Shared packages provide protocol adapters, feed normalization, identity resolution, UI primitives, and wallet auth.
|
||||
|
||||
## Structure
|
||||
apps/landing static landing page at degenfeed.xyz
|
||||
apps/web Next.js aggregator client not yet deployed
|
||||
packages/types unified protocol types
|
||||
packages/feed-core normalization and dedupe
|
||||
packages/ranking transparent feed scoring
|
||||
packages/ui shared components
|
||||
packages/identity cross-protocol identity resolution
|
||||
packages/nostr-sdk/
|
||||
packages/farcaster-sdk/
|
||||
packages/lens-sdk/
|
||||
packages/bluesky-sdk/
|
||||
workers/api Cloudflare Worker proxy planned
|
||||
infra/cloudflare wrangler and KV setup
|
||||
```
|
||||
apps/
|
||||
landing/ static landing page at degenfeed.xyz
|
||||
web/ Next.js 15 aggregator client at app.degenfeed.xyz
|
||||
packages/
|
||||
types/ unified protocol types and interfaces
|
||||
feed-core/ feed normalization, deduplication, and assembly
|
||||
ranking/ transparent feed scoring algorithms
|
||||
ui/ shared React components
|
||||
identity/ cross-protocol identity resolution
|
||||
auth/ wallet sign-in and session management
|
||||
storage/ client and edge storage helpers
|
||||
nostr-sdk/ Nostr protocol adapter
|
||||
farcaster-sdk/ Farcaster protocol adapter
|
||||
lens-sdk/ Lens protocol adapter
|
||||
bluesky-sdk/ Bluesky protocol adapter
|
||||
mastodon-sdk/ Mastodon/ActivityPub adapter
|
||||
threads-sdk/ Threads protocol adapter
|
||||
rss-sdk/ RSS/Atom feed adapter
|
||||
workers/
|
||||
api/ Cloudflare Worker: edge proxy + aggregator (degenfeed-api)
|
||||
infra/
|
||||
cloudflare/ wrangler configs, Pages project, KV setup
|
||||
```
|
||||
|
||||
## Deployment note
|
||||
The deployed DegenFeed social server is Mastodon v4.3.7 at /srv/degenfeed/.
|
||||
It is NOT this repo. This repo is a planned future client.
|
||||
## Deployment
|
||||
| Component | Target | URL |
|
||||
|-----------|--------|-----|
|
||||
| Landing page | Cloudflare Pages | `https://degenfeed.xyz` |
|
||||
| Web client | Cloudflare Pages | `https://app.degenfeed.xyz` |
|
||||
| Edge API | Cloudflare Worker `degenfeed-api` | `https://degenfeed.xyz/api/*` and `https://app.degenfeed.xyz/api/*` |
|
||||
| Social server | GotoSocial (separate repo/deploy) | `https://social.degenfeed.xyz` |
|
||||
|
||||
## Current issues
|
||||
- apps/web not built
|
||||
- No CI
|
||||
- No pre-commit
|
||||
- Package structure is aspirational
|
||||
The deployed DegenFeed social server is **GotoSocial**, not Mastodon. It runs from `/srv/degenfeed/` on Talos and is independent of this repo.
|
||||
|
||||
## Key flows
|
||||
1. **Feed aggregation** — `workers/api` fetches and normalizes posts from configured protocols via the shared SDK packages, then serves them to `apps/web`.
|
||||
2. **News aggregation** — `/news` in `apps/web` sources headlines from RSS and protocol feeds using `packages/rss-sdk` and `packages/feed-core`.
|
||||
3. **Wallet sign-in** — `packages/auth` integrates Solana and EVM wallet adapters; sessions are managed client-side and verified at the edge worker.
|
||||
4. **Composer** — posts drafted in `apps/web` are routed through the relevant protocol SDK and published either directly or via the edge worker proxy.
|
||||
|
||||
## Tooling
|
||||
- **Build orchestration:** Turborepo + pnpm workspaces
|
||||
- **Lint / format:** Biome
|
||||
- **Type checking:** TypeScript `--noEmit`
|
||||
- **Tests:** vitest
|
||||
- **CI:** GitHub Actions (`.github/workflows/ci.yml`)
|
||||
- **Pre-commit:** `.pre-commit-config.yaml`
|
||||
- **Local entry point:** `Makefile`
|
||||
|
|
|
|||
59
Makefile
Normal file
59
Makefile
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# ── Makefile (TypeScript / pnpm / Turborepo) ───────────────────
|
||||
# Source of truth: standards/templates/Makefile.typescript.template
|
||||
# Customized for degenfeed-web monorepo.
|
||||
|
||||
.PHONY: help install precommit dev build lint format check typecheck test test-watch test-cov security ci audit clean
|
||||
|
||||
help: ## Show this help. Usage: make <target>
|
||||
@awk 'BEGIN {FS = ":.*?## "; printf "\nUsage: make <target>\n\nTargets:\n"} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
install: ## Install JS dependencies with pnpm (frozen lockfile)
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
precommit: ## Install pre-commit hooks
|
||||
pre-commit install
|
||||
pre-commit install --hook-type pre-push
|
||||
|
||||
dev: ## Run dev servers via Turbo
|
||||
pnpm dev
|
||||
|
||||
build: ## Build all packages/apps via Turbo
|
||||
pnpm build
|
||||
|
||||
lint: ## Lint all packages/apps via Turbo (Biome)
|
||||
pnpm lint
|
||||
|
||||
format: ## Format all files with Biome
|
||||
pnpm format
|
||||
|
||||
check: ## Fast check: lint + typecheck
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
|
||||
typecheck: ## TypeScript type check across monorepo
|
||||
pnpm typecheck
|
||||
|
||||
test: ## Run unit tests via Turbo (vitest)
|
||||
pnpm test
|
||||
|
||||
test-watch: ## Run tests in watch mode
|
||||
pnpm exec vitest
|
||||
|
||||
test-cov: ## Run tests with coverage
|
||||
pnpm exec vitest run --coverage
|
||||
|
||||
security: ## Security audit with gitleaks
|
||||
gitleaks detect --source . --no-banner
|
||||
|
||||
ci: ## Full CI pipeline (lint + typecheck + test + build)
|
||||
pnpm lint
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
|
||||
audit: ## Dependency audit with pnpm
|
||||
pnpm audit
|
||||
|
||||
clean: ## Remove build artifacts + dependencies
|
||||
rm -rf dist .next .vite .turbo .biome-cache coverage
|
||||
find . -type d -name node_modules -prune -exec rm -rf {} + 2>/dev/null || true
|
||||
50
PRODUCT.md
50
PRODUCT.md
|
|
@ -1,42 +1,52 @@
|
|||
# DegenFeed Web — Product Charter
|
||||
|
||||
**Product:** DegenFeed Web Client
|
||||
**Repo:** git.rugmunch.io/RugMunchMedia/degenfeed-web
|
||||
**Status:** Active development
|
||||
**Product:** DegenFeed Web Client
|
||||
**Repo:** git.rugmunch.io/RugMunchMedia/degenfeed-web
|
||||
**Status:** Live in production
|
||||
|
||||
## Problem
|
||||
Crypto-native users consume content across Nostr, Farcaster, Lens, and Bluesky. A single client reduces friction and keeps the community in one place.
|
||||
Crypto-native users consume content across Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, and RSS. A single client reduces friction and keeps the community in one place.
|
||||
|
||||
## User
|
||||
- Crypto users who want one feed
|
||||
- Crypto users who want one aggregated feed
|
||||
- DegenFeed community members
|
||||
- Content creators cross-posting
|
||||
- Content creators cross-posting across protocols
|
||||
|
||||
## Scope
|
||||
- Next.js web client
|
||||
- Protocol adapters: Nostr, Farcaster, Lens, Bluesky
|
||||
- Unified feed normalization and composer
|
||||
- Identity resolution across protocols
|
||||
- Next.js 15 web client deployed to Cloudflare Pages
|
||||
- Protocol adapters: Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, RSS
|
||||
- Unified feed normalization, deduplication, and ranking
|
||||
- Cross-protocol identity resolution
|
||||
- Wallet sign-in (Solana + EVM)
|
||||
- News aggregation (`/news`)
|
||||
- Cloudflare Worker edge API (`workers/api/`)
|
||||
- Static landing page at `degenfeed.xyz`
|
||||
|
||||
## Anti-scope
|
||||
- This repo does NOT run the DegenFeed Mastodon server
|
||||
- No backend database uses protocol-native storage
|
||||
- No token scanning lives in rmi-backend
|
||||
- No wallet generation lives in walletpress
|
||||
- This repo does NOT run the DegenFeed GotoSocial server
|
||||
- No backend database; protocol-native storage and edge KV only
|
||||
- No token scanning; that lives in rmi-backend
|
||||
- No wallet generation; that lives in walletpress
|
||||
|
||||
## Success criteria
|
||||
- apps/web deploys and renders a feed
|
||||
- `apps/web` deploys and renders a unified feed at `https://app.degenfeed.xyz`
|
||||
- Composer publishes to all configured protocols
|
||||
- Wallet sign-in works for Solana and EVM wallets
|
||||
- `/news` aggregates and renders protocol + RSS news
|
||||
- CI green
|
||||
- No source file greater than 500 lines
|
||||
|
||||
## Current reality
|
||||
- apps/landing is live
|
||||
- apps/web is scaffolding
|
||||
- No CI or pre-commit
|
||||
- No health endpoint
|
||||
- `apps/landing` is live at `https://degenfeed.xyz`
|
||||
- `apps/web` is live at `https://app.degenfeed.xyz`
|
||||
- `workers/api` is deployed as Cloudflare Worker `degenfeed-api`
|
||||
- CI and pre-commit are configured
|
||||
- Makefile is the entry point for local development
|
||||
- `/news` and wallet sign-in are live features
|
||||
- GotoSocial remains the canonical ActivityPub server at `https://social.degenfeed.xyz`
|
||||
|
||||
## Fleet dependencies
|
||||
- DegenFeed Mastodon at http://127.0.0.1:4002 separate deployment
|
||||
- DegenFeed GotoSocial at `https://social.degenfeed.xyz` (separate deployment)
|
||||
- Cloudflare Pages + Workers for hosting and edge API
|
||||
- Plausible analytics self-hosted on Talos
|
||||
- MeiliSearch self-hosted on Talos
|
||||
|
|
|
|||
51
ROADMAP.md
51
ROADMAP.md
|
|
@ -2,27 +2,40 @@
|
|||
|
||||
**Next 90 days.**
|
||||
|
||||
## Week 1 — Clarify and scaffold
|
||||
- Finalize whether this client replaces or supplements Mastodon
|
||||
- Add CI, pre-commit, Makefile
|
||||
- Add /health placeholder route
|
||||
## Completed
|
||||
- Upgrade to Next.js 15 + React 19
|
||||
- Turborepo + pnpm workspace structure (`apps/*`, `packages/*`, `workers/*`)
|
||||
- Static landing page at `degenfeed.xyz`
|
||||
- Deploy `apps/web` to Cloudflare Pages at `app.degenfeed.xyz`
|
||||
- Deploy `workers/api` as Cloudflare Worker `degenfeed-api`
|
||||
- CI pipeline, pre-commit hooks, and Makefile
|
||||
- Protocol SDK packages: Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, RSS
|
||||
- Core packages: types, feed-core, ranking, ui, identity, auth, storage
|
||||
- `/news` news aggregation
|
||||
- Wallet sign-in (Solana + EVM)
|
||||
|
||||
## Week 2 — Protocol adapters
|
||||
- Implement nostr-sdk package
|
||||
- Implement farcaster-sdk package
|
||||
- Add normalized feed-core types
|
||||
## Week 1 — Polish and observability
|
||||
- Add `/health` route to `workers/api` and `apps/web`
|
||||
- Wire Plausible analytics
|
||||
- Add error boundaries and loading states in `apps/web`
|
||||
|
||||
## Week 3 — Feed UI
|
||||
- Build apps/web feed page
|
||||
- Composer UI
|
||||
- Identity resolution UI
|
||||
## Week 2 — Composer and identity
|
||||
- Cross-protocol composer UI
|
||||
- Identity resolution UI (link profiles across protocols)
|
||||
- Publish flow through edge worker
|
||||
|
||||
## Week 4 — Deploy
|
||||
- Deploy apps/web to Cloudflare Pages
|
||||
- Wire health endpoint
|
||||
- Add observability
|
||||
## Week 3 — Feed quality
|
||||
- Ranking v2 with user signals
|
||||
- Feed filters and search
|
||||
- Infinite scroll and virtualization
|
||||
|
||||
## Week 4 — Integrations
|
||||
- MeiliSearch-backed global search
|
||||
- Notifications via GotoSocial
|
||||
- RSS ingest improvements
|
||||
|
||||
## Acceptance
|
||||
- apps/web renders a unified feed
|
||||
- CI green
|
||||
- Health endpoint returns 200
|
||||
- `app.degenfeed.xyz` renders a unified, ranked feed
|
||||
- Composer publishes to at least Nostr + Farcaster + Lens + Bluesky
|
||||
- CI green on every PR
|
||||
- `/health` returns 200 on both Pages and Worker
|
||||
|
|
|
|||
13
apps/web/.env.example
Normal file
13
apps/web/.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# ── DegenFeed Web .env.example ─────────────────────────────────
|
||||
# Source of truth: standards/templates/env.example.ts.template
|
||||
# Copy to .env and fill in values for local development.
|
||||
# DO NOT commit .env — it's in .gitignore.
|
||||
|
||||
# ── App URLs ───────────────────────────────────────────────────
|
||||
# Public URL of this Next.js app (used for OAuth redirects and metadata).
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
# Base URL of the DegenFeed API worker.
|
||||
# Note: apps/web/src/app/auth/callback/page.tsx currently reads NEXT_PUBLIC_API_URL.
|
||||
NEXT_PUBLIC_API_BASE=http://localhost:8787
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8787
|
||||
|
|
@ -59,20 +59,17 @@ const TICKER_TO_KR: Record<string, string> = {
|
|||
};
|
||||
|
||||
async function fetchCoingecko(ids: string[]) {
|
||||
const url =
|
||||
'https://api.coingecko.com/api/v3/simple/price?ids=' +
|
||||
ids.join(',') +
|
||||
'&vs_currencies=usd&include_24hr_change=true&include_7d_change=true&include_30d_change=true&include_market_cap=true';
|
||||
const url = `https://api.coingecko.com/api/v3/simple/price?ids=${ids.join(',')}&vs_currencies=usd&include_24hr_change=true&include_7d_change=true&include_30d_change=true&include_market_cap=true`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Record<string, any>;
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function fetchKraken(pairs: string[]) {
|
||||
const url = 'https://api.kraken.com/0/public/Ticker?pair=' + pairs.join(',');
|
||||
const url = `https://api.kraken.com/0/public/Ticker?pair=${pairs.join(',')}`;
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) return null;
|
||||
return res.json() as Record<string, any>;
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
|
|
@ -96,7 +93,16 @@ export async function GET(request: Request) {
|
|||
return NextResponse.json({ prices: {}, error: 'No known tickers' });
|
||||
}
|
||||
|
||||
const prices: Record<string, any> = {};
|
||||
const prices: Record<
|
||||
string,
|
||||
{
|
||||
usd?: number;
|
||||
usd_24h_change?: number;
|
||||
usd_7d_change?: number;
|
||||
usd_30d_change?: number;
|
||||
usd_market_cap?: number;
|
||||
}
|
||||
> = {};
|
||||
let source = '';
|
||||
|
||||
try {
|
||||
|
|
@ -107,12 +113,13 @@ export async function GET(request: Request) {
|
|||
for (const [id, data] of Object.entries(cgData)) {
|
||||
const ticker = idToTicker[id];
|
||||
if (ticker && data) {
|
||||
const d = data as Record<string, number | undefined>;
|
||||
prices[ticker] = {
|
||||
usd: data.usd,
|
||||
usd_24h_change: data.usd_24h_change,
|
||||
usd_7d_change: data.usd_7d_change,
|
||||
usd_30d_change: data.usd_30d_change,
|
||||
usd_market_cap: data.usd_market_cap,
|
||||
usd: d.usd,
|
||||
usd_24h_change: d.usd_24h_change,
|
||||
usd_7d_change: d.usd_7d_change,
|
||||
usd_30d_change: d.usd_30d_change,
|
||||
usd_market_cap: d.usd_market_cap,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -143,10 +150,10 @@ export async function GET(request: Request) {
|
|||
for (const [pair, data] of Object.entries(krData.result)) {
|
||||
const ticker = krakenSymbolMap[pair];
|
||||
if (ticker && data) {
|
||||
const tick = data as any;
|
||||
const tick = data as { c?: [string]; p?: [string, string] };
|
||||
prices[ticker] = {
|
||||
usd: Number.parseFloat(tick.c?.[0] || '0'),
|
||||
usd_24h_change: Number.parseFloat(tick.p?.[1] || '0'),
|
||||
usd: Number.parseFloat(tick.c?.[0] ?? '0'),
|
||||
usd_24h_change: Number.parseFloat(tick.p?.[1] ?? '0'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export default async function AuthCallbackPage({ searchParams }: AuthCallbackPro
|
|||
let exchangeError = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(apiBase + '/api/auth/gotosocial/callback', {
|
||||
const res = await fetch(`${apiBase}/api/auth/gotosocial/callback`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ code: params.code }),
|
||||
|
|
@ -80,7 +80,7 @@ export default async function AuthCallbackPage({ searchParams }: AuthCallbackPro
|
|||
if (exchangeOk) {
|
||||
// Store auth state and redirect to home — client-side code picks up the token
|
||||
return (
|
||||
<html>
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="mx-auto max-w-md px-4 py-20 text-center">
|
||||
<div className="mb-6 text-5xl text-degen-400">\u2713</div>
|
||||
|
|
@ -89,18 +89,9 @@ export default async function AuthCallbackPage({ searchParams }: AuthCallbackPro
|
|||
<p className="mb-8 text-gray-400">Redirecting to your feed...</p>
|
||||
</div>
|
||||
<script
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: server-generated JSON stringified token stored client-side
|
||||
dangerouslySetInnerHTML={{
|
||||
__html:
|
||||
'const token = ' +
|
||||
JSON.stringify(params.code) +
|
||||
';' +
|
||||
'try {' +
|
||||
' const stored = JSON.parse(localStorage.getItem("degenfeed_identities") || "[]");' +
|
||||
' stored.push({ protocol: "mastodon", instance: "https://social.degenfeed.xyz", token: token, createdAt: Date.now() });' +
|
||||
' localStorage.setItem("degenfeed_identities", JSON.stringify(stored));' +
|
||||
' localStorage.setItem("degenfeed_auth_updated", Date.now().toString());' +
|
||||
'} catch(e) { console.error("Token storage failed", e); }' +
|
||||
'window.location.href = "/home";',
|
||||
__html: `const token = ${JSON.stringify(params.code)};try { const stored = JSON.parse(localStorage.getItem("degenfeed_identities") || "[]"); stored.push({ protocol: "mastodon", instance: "https://social.degenfeed.xyz", token: token, createdAt: Date.now() }); localStorage.setItem("degenfeed_identities", JSON.stringify(stored)); localStorage.setItem("degenfeed_auth_updated", Date.now().toString());} catch(e) { console.error("Token storage failed", e); }window.location.href = "/home";`,
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,19 @@
|
|||
"use client";
|
||||
import { TipModal } from "../../../components/TipModal";
|
||||
'use client';
|
||||
import { TipModal } from '../../../components/TipModal';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { FeedShell } from "../../../components/FeedShell";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FeedShell } from '../../../components/FeedShell';
|
||||
|
||||
import { useAuth } from "../../../components/AuthProvider";
|
||||
import { FeedList, ComposeModal, SignInModal, NotificationBell, type UnifiedPost, type Notification } from "@degenfeed/ui";
|
||||
import { SECTIONS } from "../sections";
|
||||
import {
|
||||
ComposeModal,
|
||||
FeedList,
|
||||
type Notification,
|
||||
NotificationBell,
|
||||
SignInModal,
|
||||
type UnifiedPost,
|
||||
} from '@degenfeed/ui';
|
||||
import { useAuth } from '../../../components/AuthProvider';
|
||||
import { SECTIONS } from '../sections';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
|
|
@ -18,9 +25,12 @@ function SectionNav({ current }: { current: string }) {
|
|||
return (
|
||||
<div className="mb-4 flex gap-1 overflow-x-auto pb-2">
|
||||
{SECTIONS.map((s) => (
|
||||
<a key={s.id} href={"/home/" + s.id}
|
||||
className={"whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-all " + (s.id === current ? "text-white" : "text-gray-500 hover:text-gray-300")}
|
||||
style={s.id === current ? { backgroundColor: s.color + "22", color: s.color } : {}}>
|
||||
<a
|
||||
key={s.id}
|
||||
href={`/home/${s.id}`}
|
||||
className={`whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-all ${s.id === current ? 'text-white' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
style={s.id === current ? { backgroundColor: `${s.color}22`, color: s.color } : {}}
|
||||
>
|
||||
{s.icon} {s.label}
|
||||
</a>
|
||||
))}
|
||||
|
|
@ -29,10 +39,10 @@ function SectionNav({ current }: { current: string }) {
|
|||
}
|
||||
|
||||
function SectionContent({ section }: { section: string }) {
|
||||
const { signedInProtocols, openSignIn, engage } = useAuth();
|
||||
const { signedInProtocols, engage } = useAuth();
|
||||
const meta = SECTIONS.find((s) => s.id === section);
|
||||
const [posts, setPosts] = useState<UnifiedPost[]>([]);
|
||||
const [state, setState] = useState<"loading" | "loaded" | "empty" | "error">("loading");
|
||||
const [state, setState] = useState<'loading' | 'loaded' | 'empty' | 'error'>('loading');
|
||||
const [error, setError] = useState<string>();
|
||||
const [showCompose, setShowCompose] = useState(false);
|
||||
const [showSignIn, setShowSignIn] = useState(false);
|
||||
|
|
@ -45,11 +55,15 @@ function SectionContent({ section }: { section: string }) {
|
|||
if (!hasSignedIn) return;
|
||||
const fetchNotifs = async () => {
|
||||
try {
|
||||
const stored = localStorage.getItem("degenfeed_identities");
|
||||
const stored = localStorage.getItem('degenfeed_identities');
|
||||
if (!stored) return;
|
||||
const parsed = JSON.parse(stored);
|
||||
const auth = JSON.stringify({ mastodon: parsed.find((i: any) => i.protocol === "mastodon")?.token });
|
||||
const res = await fetch("/api/notifications?auth=" + encodeURIComponent(auth));
|
||||
const auth = JSON.stringify({
|
||||
mastodon: (parsed as Array<{ protocol: string; token?: string }>).find(
|
||||
(i) => i.protocol === 'mastodon',
|
||||
)?.token,
|
||||
});
|
||||
const res = await fetch(`/api/notifications?auth=${encodeURIComponent(auth)}`);
|
||||
const data = await res.json();
|
||||
setNotifications(data.notifications || []);
|
||||
} catch {}
|
||||
|
|
@ -63,37 +77,42 @@ function SectionContent({ section }: { section: string }) {
|
|||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setState("loading");
|
||||
setState('loading');
|
||||
try {
|
||||
const url = new URL(meta?.endpoint || "/api/feed/home", window.location.origin);
|
||||
url.searchParams.set("limit", String(meta?.limit || 25));
|
||||
const url = new URL(meta?.endpoint || '/api/feed/home', window.location.origin);
|
||||
url.searchParams.set('limit', String(meta?.limit || 25));
|
||||
if (meta?.params) {
|
||||
for (const [k, v] of Object.entries(meta.params)) url.searchParams.set(k, v);
|
||||
}
|
||||
let res = await fetch(url.toString());
|
||||
let data = await res.json() as { data?: UnifiedPost[] };
|
||||
if (!data.data?.length && section === "all") {
|
||||
const rssUrl = new URL("/api/feed/rss", window.location.origin);
|
||||
rssUrl.searchParams.set("source", "reddit");
|
||||
rssUrl.searchParams.set("handle", "CryptoCurrency");
|
||||
rssUrl.searchParams.set("limit", "25");
|
||||
let data = (await res.json()) as { data?: UnifiedPost[] };
|
||||
if (!data.data?.length && section === 'all') {
|
||||
const rssUrl = new URL('/api/feed/rss', window.location.origin);
|
||||
rssUrl.searchParams.set('source', 'reddit');
|
||||
rssUrl.searchParams.set('handle', 'CryptoCurrency');
|
||||
rssUrl.searchParams.set('limit', '25');
|
||||
res = await fetch(rssUrl.toString());
|
||||
data = await res.json() as { data?: UnifiedPost[] };
|
||||
data = (await res.json()) as { data?: UnifiedPost[] };
|
||||
}
|
||||
if (cancelled) return;
|
||||
setPosts(data.data || []);
|
||||
setState(data.data?.length ? "loaded" : "empty");
|
||||
setState(data.data?.length ? 'loaded' : 'empty');
|
||||
} catch (err) {
|
||||
if (!cancelled) { setError(err instanceof Error ? err.message : "Failed"); setState("error"); }
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
setState('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
}, [section, meta?.endpoint]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [section, meta]);
|
||||
|
||||
const displayName = meta?.label ?? section;
|
||||
const color = meta?.color ?? "#666";
|
||||
const icon = meta?.icon ?? "\u25CF";
|
||||
const color = meta?.color ?? '#666';
|
||||
const icon = meta?.icon ?? '●';
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-4">
|
||||
|
|
@ -101,23 +120,46 @@ function SectionContent({ section }: { section: string }) {
|
|||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl" style={{ color }}>{icon}</span>
|
||||
<div><h1 className="text-lg font-bold text-white">{displayName}</h1></div>
|
||||
<span className="rounded-full px-2 py-0.5 text-xs font-medium" style={{ backgroundColor: color + "22", color }}>{displayName}</span>
|
||||
<span className="text-2xl" style={{ color }}>
|
||||
{icon}
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-white">{displayName}</h1>
|
||||
</div>
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: `${color}22`, color }}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasSignedIn && (
|
||||
<NotificationBell
|
||||
notifications={notifications}
|
||||
unreadCount={notifications.filter(n => !n.read).length}
|
||||
unreadCount={notifications.filter((n) => !n.read).length}
|
||||
onFetch={() => {}}
|
||||
onMarkRead={(id) => setNotifications(notifications.map(n => n.id === id ? { ...n, read: true } : n))}
|
||||
onMarkRead={(id) =>
|
||||
setNotifications(notifications.map((n) => (n.id === id ? { ...n, read: true } : n)))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{hasSignedIn ? (
|
||||
<button type="button" className="btn-primary text-xs" onClick={() => setShowCompose(true)}>Compose</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-xs"
|
||||
onClick={() => setShowCompose(true)}
|
||||
>
|
||||
Compose
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn-secondary text-xs" onClick={() => setShowSignIn(true)}>Sign In</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary text-xs"
|
||||
onClick={() => setShowSignIn(true)}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -126,24 +168,42 @@ function SectionContent({ section }: { section: string }) {
|
|||
<div className="mb-3 flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="text-degen-400">Signed in:</span>
|
||||
{Object.keys(signedInProtocols).map((p) => (
|
||||
<span key={p} className="rounded-full px-2 py-0.5 font-medium" style={{ backgroundColor: getPC(p) + "22", color: getPC(p) }}>{p}</span>
|
||||
<span
|
||||
key={p}
|
||||
className="rounded-full px-2 py-0.5 font-medium"
|
||||
style={{ backgroundColor: `${getPC(p)}22`, color: getPC(p) }}
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
<a href="/profile" className="ml-auto text-degen-500 hover:underline">Profile</a>
|
||||
<a href="/profile" className="ml-auto text-degen-500 hover:underline">
|
||||
Profile
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FeedList state={state} posts={posts} error={error} protocolLabel={displayName}
|
||||
onPostClick={(id) => { window.location.href = "/post/" + encodeURIComponent(id); }}
|
||||
<FeedList
|
||||
state={state}
|
||||
posts={posts}
|
||||
error={error}
|
||||
protocolLabel={displayName}
|
||||
onPostClick={(id) => {
|
||||
window.location.href = `/post/${encodeURIComponent(id)}`;
|
||||
}}
|
||||
onEngage={(action, postId) => engage(action, postId, section)}
|
||||
onTip={(post) => setTipPost(post)}
|
||||
onRetry={() => setState("loading")}
|
||||
onRetry={() => setState('loading')}
|
||||
/>
|
||||
|
||||
{state === "empty" && !hasSignedIn && (
|
||||
{state === 'empty' && !hasSignedIn && (
|
||||
<div className="mt-6 rounded-xl border border-degen-500/20 bg-degen-500/5 p-6 text-center">
|
||||
<h3 className="mb-2 text-base font-bold text-degen-400">See the fediverse</h3>
|
||||
<p className="mb-4 text-sm text-gray-400">Create a free @degenfeed.xyz Mastodon account to see posts from the fediverse.</p>
|
||||
<a href="/api/auth/gotosocial/register" className="btn-primary text-sm">Create free account \u2192</a>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
Create a free @degenfeed.xyz Mastodon account to see posts from the fediverse.
|
||||
</p>
|
||||
<a href="/api/auth/gotosocial/register" className="btn-primary text-sm">
|
||||
Create free account →
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -154,42 +214,96 @@ function SectionContent({ section }: { section: string }) {
|
|||
recipientProtocol={tipPost.protocol}
|
||||
recipientAuthorId={tipPost.author.id}
|
||||
recipientDisplayName={tipPost.author.displayName}
|
||||
publicationId={tipPost.protocol === 'lens' ? (tipPost.raw as any)?.id : undefined}
|
||||
publicationId={
|
||||
tipPost.protocol === 'lens' ? (tipPost.raw as { id?: string })?.id : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SignInModal open={showSignIn} onClose={() => setShowSignIn(false)}
|
||||
<SignInModal
|
||||
open={showSignIn}
|
||||
onClose={() => setShowSignIn(false)}
|
||||
onSignIn={async (protocol, token) => {
|
||||
try {
|
||||
const h: Record<string, Function> = {
|
||||
nostr: () => import("@degenfeed/auth").then(m => m.signInWithNostrNsec(token || "", "auto")),
|
||||
bluesky: () => import("@degenfeed/auth").then(m => m.signInWithBluesky(prompt("Bluesky handle:") || "", prompt("App password:") || "", "auto")),
|
||||
threads: () => import("@degenfeed/auth").then(m => m.signInWithThreads(prompt("Threads identifier:") || "", prompt("App password:") || "", "auto")),
|
||||
mastodon: () => import("@degenfeed/auth").then(m => m.signInWithGtS(prompt("Instance URL:") || "https://social.degenfeed.xyz", prompt("Access token:") || "", "auto")),
|
||||
const h: Record<string, () => Promise<unknown>> = {
|
||||
nostr: () =>
|
||||
import('@degenfeed/auth').then((m) => m.signInWithNostrNsec(token || '', 'auto')),
|
||||
bluesky: () =>
|
||||
import('@degenfeed/auth').then((m) =>
|
||||
m.signInWithBluesky(
|
||||
prompt('Bluesky handle:') || '',
|
||||
prompt('App password:') || '',
|
||||
'auto',
|
||||
),
|
||||
),
|
||||
threads: () =>
|
||||
import('@degenfeed/auth').then((m) =>
|
||||
m.signInWithThreads(
|
||||
prompt('Threads identifier:') || '',
|
||||
prompt('App password:') || '',
|
||||
'auto',
|
||||
),
|
||||
),
|
||||
mastodon: () =>
|
||||
import('@degenfeed/auth').then((m) =>
|
||||
m.signInWithGtS(
|
||||
prompt('Instance URL:') || 'https://social.degenfeed.xyz',
|
||||
prompt('Access token:') || '',
|
||||
'auto',
|
||||
),
|
||||
),
|
||||
};
|
||||
await (h[protocol]?.() ?? Promise.resolve());
|
||||
setShowSignIn(false);
|
||||
window.location.reload();
|
||||
} catch (e) { alert("Sign-in failed: " + (e instanceof Error ? e.message : String(e))); }
|
||||
}} />
|
||||
<ComposeModal open={showCompose} onClose={() => setShowCompose(false)}
|
||||
} catch (e) {
|
||||
alert(`Sign-in failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ComposeModal
|
||||
open={showCompose}
|
||||
onClose={() => setShowCompose(false)}
|
||||
signedInProtocols={signedInProtocols}
|
||||
onPublish={async (text, targets) => {
|
||||
try {
|
||||
const res = await fetch("/api/publish", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, targets }) });
|
||||
const res = await fetch('/api/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text, targets }),
|
||||
});
|
||||
return (await res.json()).results ?? {};
|
||||
} catch { const r: Record<string, { ok: boolean; error?: string }> = {}; targets.forEach(t => r[t] = { ok: false, error: "Network error" }); return r; }
|
||||
}} />
|
||||
} catch {
|
||||
const r: Record<string, { ok: boolean; error?: string }> = {};
|
||||
for (const t of targets) {
|
||||
r[t] = { ok: false, error: 'Network error' };
|
||||
}
|
||||
return r;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getPC(p: string): string {
|
||||
const c: Record<string, string> = { nostr: "#8e30eb", farcaster: "#8a63d2", lens: "#abfe2c", bluesky: "#1185fe", threads: "#888", mastodon: "#6364ff", rss: "#ffa500" };
|
||||
return c[p] ?? "#666";
|
||||
const c: Record<string, string> = {
|
||||
nostr: '#8e30eb',
|
||||
farcaster: '#8a63d2',
|
||||
lens: '#abfe2c',
|
||||
bluesky: '#1185fe',
|
||||
threads: '#888',
|
||||
mastodon: '#6364ff',
|
||||
rss: '#ffa500',
|
||||
};
|
||||
return c[p] ?? '#666';
|
||||
}
|
||||
|
||||
export default async function SectionPage({ params }: Props) {
|
||||
const section = (await params).section;
|
||||
return <FeedShell><SectionContent section={section} /></FeedShell>;
|
||||
return (
|
||||
<FeedShell>
|
||||
<SectionContent section={section} />
|
||||
</FeedShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,84 +1,113 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { FundingCard, type FundingRound } from "@degenfeed/ui";
|
||||
import { FundingCard, type FundingRound } from '@degenfeed/ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function FundingPage() {
|
||||
const [rounds, setRounds] = useState<FundingRound[]>([]);
|
||||
const [state, setState] = useState<"loading" | "loaded" | "empty" | "error">("loading");
|
||||
const [chainFilter, setChainFilter] = useState<string>("");
|
||||
const [stageFilter, setStageFilter] = useState<string>("");
|
||||
const [state, setState] = useState<'loading' | 'loaded' | 'empty' | 'error'>('loading');
|
||||
const [chainFilter, setChainFilter] = useState<string>('');
|
||||
const [stageFilter, _setStageFilter] = useState<string>('');
|
||||
const [meta, setMeta] = useState<{ totalRaised?: number }>({});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setState("loading");
|
||||
setState('loading');
|
||||
try {
|
||||
const url = new URL("/api/feed/funding", window.location.origin);
|
||||
if (chainFilter) url.searchParams.set("chain", chainFilter);
|
||||
if (stageFilter) url.searchParams.set("stage", stageFilter);
|
||||
url.searchParams.set("limit", "50");
|
||||
const url = new URL('/api/feed/funding', window.location.origin);
|
||||
if (chainFilter) url.searchParams.set('chain', chainFilter);
|
||||
if (stageFilter) url.searchParams.set('stage', stageFilter);
|
||||
url.searchParams.set('limit', '50');
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error("API error");
|
||||
const data = await res.json() as { data?: FundingRound[]; meta?: any };
|
||||
if (!res.ok) throw new Error('API error');
|
||||
const data = (await res.json()) as {
|
||||
data?: FundingRound[];
|
||||
meta?: { totalRaised?: number };
|
||||
};
|
||||
if (cancelled) return;
|
||||
if (data.data?.length) {
|
||||
setRounds(data.data);
|
||||
setMeta(data.meta || {});
|
||||
setState("loaded");
|
||||
setState('loaded');
|
||||
} else {
|
||||
setState("empty");
|
||||
setState('empty');
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setState("error");
|
||||
if (!cancelled) setState('error');
|
||||
}
|
||||
}
|
||||
load();
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chainFilter, stageFilter]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl px-4 py-6">
|
||||
<div className="mb-6">
|
||||
<a href="/home" className="mb-4 inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-white">← All sections</a>
|
||||
<a
|
||||
href="/home"
|
||||
className="mb-4 inline-flex items-center gap-1 text-sm text-gray-500 transition-colors hover:text-white"
|
||||
>
|
||||
← All sections
|
||||
</a>
|
||||
<h1 className="text-2xl font-bold text-white">Funding Tracker</h1>
|
||||
<p className="text-sm text-gray-500">Crypto raises, rounds, and investments</p>
|
||||
{meta.totalRaised ? (
|
||||
<p className="mt-1 text-xs text-degen-500">${meta.totalRaised.toFixed(1)}M total raised (this page)</p>
|
||||
<p className="mt-1 text-xs text-degen-500">
|
||||
${meta.totalRaised.toFixed(1)}M total raised (this page)
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
{["", "ethereum", "solana", "bitcoin"].map(c => (
|
||||
<button key={c} type="button"
|
||||
className={"rounded-full px-3 py-1 text-xs font-medium transition-all " + (chainFilter === c ? "bg-degen-500/20 text-degen-500" : "bg-gray-800 text-gray-400 hover:text-white")}
|
||||
onClick={() => setChainFilter(c)}>
|
||||
{c || "All Chains"}
|
||||
{['', 'ethereum', 'solana', 'bitcoin'].map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
chainFilter === c
|
||||
? 'bg-degen-500/20 text-degen-500'
|
||||
: 'bg-gray-800 text-gray-400 hover:text-white'
|
||||
}`}
|
||||
onClick={() => setChainFilter(c)}
|
||||
>
|
||||
{c || 'All Chains'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{state === "loading" && (
|
||||
{state === 'loading' && (
|
||||
<div className="space-y-4">
|
||||
{[1,2,3].map(i => <div key={i} className="card h-32 animate-pulse" />)}
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="card h-32 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{state === "loaded" && (
|
||||
{state === 'loaded' && (
|
||||
<div className="space-y-4">
|
||||
{rounds.map(r => <FundingCard key={r.id} round={r} />)}
|
||||
{rounds.map((r) => (
|
||||
<FundingCard key={r.id} round={r} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{state === "empty" && (
|
||||
{state === 'empty' && (
|
||||
<div className="card py-12 text-center">
|
||||
<div className="mb-4 text-4xl text-gray-600">?</div>
|
||||
<p className="text-gray-500">No funding rounds found</p>
|
||||
</div>
|
||||
)}
|
||||
{state === "error" && (
|
||||
{state === 'error' && (
|
||||
<div className="card py-12 text-center">
|
||||
<p className="text-red-400">Failed to load funding data</p>
|
||||
<button type="button" className="btn-primary mt-4 text-sm" onClick={() => setState("loading")}>Retry</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary mt-4 text-sm"
|
||||
onClick={() => setState('loading')}
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,23 +10,189 @@ export interface FeedSection {
|
|||
}
|
||||
|
||||
export const SECTIONS: FeedSection[] = [
|
||||
{ id: "all", label: "DegenFeed", icon: "\u25C6", color: "#22c55e", description: "Unified feed across all networks", endpoint: "/api/feed/home", limit: 25, params: { protocols: "nostr,farcaster,lens,bluesky,threads" } },
|
||||
{ id: "funding", label: "Funding", icon: "\uD83D\uDCB0", color: "#10b981", description: "Crypto raises, rounds, and investments", endpoint: "/api/feed/funding", limit: 25 },
|
||||
{ id: "top", label: "Best of Web3", icon: "\u2605", color: "#f59e0b", description: "Top posts by cross-protocol engagement (24h)", endpoint: "/api/feed/top", limit: 50 },
|
||||
{ id: "trending", label: "Trending", icon: "\u26A1", color: "#ef4444", description: "What\u2019s hot right now across all networks", endpoint: "/api/feed/trending", limit: 25 },
|
||||
{ id: "newsletters", label: "Newsletters", icon: "\uD83D\uDCD0", color: "#8b5cf6", description: "All newsletters", endpoint: "/api/feed/newsletters", limit: 25 },
|
||||
{ id: "headlines", label: "Headlines", icon: "H", color: "#ef4444", description: "Daily crypto news: CoinDesk, CoinTelegraph, The Block, Decrypt", endpoint: "/api/feed/newsletters", limit: 25, params: { category: "headlines" } },
|
||||
{ id: "deep-dives", label: "Deep Dives", icon: "D", color: "#f59e0b", description: "Bankless, Week in Eth, Delphi, Messari", endpoint: "/api/feed/newsletters", limit: 25, params: { category: "deep_dives" } },
|
||||
{ id: "onchain", label: "On-Chain", icon: "O", color: "#10b981", description: "Dune, Glassnode, Token Terminal, Nansen, Artemis", endpoint: "/api/feed/newsletters", limit: 25, params: { category: "onchain_data" } },
|
||||
{ id: "defi", label: "DeFi", icon: "D", color: "#6366f1", description: "DeFi Pulse, Prime, Llama, Yearn, Uniswap", endpoint: "/api/feed/newsletters", limit: 25, params: { category: "defi" } },
|
||||
{ id: "ai-crypto", label: "AI x Crypto", icon: "AI", color: "#ec4899", description: "AI/ML in crypto, The Sequence, Modular", endpoint: "/api/feed/newsletters", limit: 25, params: { category: "ai_crypto" } },
|
||||
{ id: "nostr", label: "Nostr", icon: "\u2630", color: "#8e30eb", description: "Decentralized notes", endpoint: "/api/feed/home", limit: 25, params: { protocols: "nostr" } },
|
||||
{ id: "farcaster", label: "Farcaster", icon: "\u25C8", color: "#8a63d2", description: "Sufficiently decentralized social", endpoint: "/api/feed/home", limit: 25, params: { protocols: "farcaster" } },
|
||||
{ id: "lens", label: "Lens", icon: "\u25CE", color: "#abfe2c", description: "User-owned social graph", endpoint: "/api/feed/home", limit: 25, params: { protocols: "lens" } },
|
||||
{ id: "bluesky", label: "Bluesky", icon: "\u2601", color: "#1185fe", description: "AT Protocol social", endpoint: "/api/feed/home", limit: 25, params: { protocols: "bluesky" } },
|
||||
{ id: "threads", label: "Threads", icon: "+", color: "#888", description: "Instagram AT Protocol network", endpoint: "/api/feed/threads", limit: 25 },
|
||||
{ id: "mastodon", label: "Mastodon", icon: "M", color: "#6364ff", description: "Fediverse (GotoSocial)", endpoint: "/api/feed/activitypub", limit: 20, params: { instance: "https://social.degenfeed.xyz", type: "public" } },
|
||||
{ id: "articles", label: "Reddit", icon: "R", color: "#ff4500", description: "Reddit CryptoCurrency hot posts", endpoint: "/api/feed/rss", limit: 25, params: { source: "reddit", handle: "CryptoCurrency" } },
|
||||
{ id: "defi-reddit", label: "DeFi Reddit", icon: "D", color: "#6366f1", description: "Reddit DeFi hot posts", endpoint: "/api/feed/rss", limit: 25, params: { source: "reddit", handle: "defi" } },
|
||||
{ id: "eth-reddit", label: "Eth Reddit", icon: "E", color: "#8b5cf6", description: "Reddit Ethereum hot posts", endpoint: "/api/feed/rss", limit: 25, params: { source: "reddit", handle: "ethereum" } },
|
||||
];
|
||||
{
|
||||
id: 'all',
|
||||
label: 'DegenFeed',
|
||||
icon: '\u25C6',
|
||||
color: '#22c55e',
|
||||
description: 'Unified feed across all networks',
|
||||
endpoint: '/api/feed/home',
|
||||
limit: 25,
|
||||
params: { protocols: 'nostr,farcaster,lens,bluesky,threads' },
|
||||
},
|
||||
{
|
||||
id: 'funding',
|
||||
label: 'Funding',
|
||||
icon: '\uD83D\uDCB0',
|
||||
color: '#10b981',
|
||||
description: 'Crypto raises, rounds, and investments',
|
||||
endpoint: '/api/feed/funding',
|
||||
limit: 25,
|
||||
},
|
||||
{
|
||||
id: 'top',
|
||||
label: 'Best of Web3',
|
||||
icon: '\u2605',
|
||||
color: '#f59e0b',
|
||||
description: 'Top posts by cross-protocol engagement (24h)',
|
||||
endpoint: '/api/feed/top',
|
||||
limit: 50,
|
||||
},
|
||||
{
|
||||
id: 'trending',
|
||||
label: 'Trending',
|
||||
icon: '\u26A1',
|
||||
color: '#ef4444',
|
||||
description: 'What\u2019s hot right now across all networks',
|
||||
endpoint: '/api/feed/trending',
|
||||
limit: 25,
|
||||
},
|
||||
{
|
||||
id: 'newsletters',
|
||||
label: 'Newsletters',
|
||||
icon: '\uD83D\uDCD0',
|
||||
color: '#8b5cf6',
|
||||
description: 'All newsletters',
|
||||
endpoint: '/api/feed/newsletters',
|
||||
limit: 25,
|
||||
},
|
||||
{
|
||||
id: 'headlines',
|
||||
label: 'Headlines',
|
||||
icon: 'H',
|
||||
color: '#ef4444',
|
||||
description: 'Daily crypto news: CoinDesk, CoinTelegraph, The Block, Decrypt',
|
||||
endpoint: '/api/feed/newsletters',
|
||||
limit: 25,
|
||||
params: { category: 'headlines' },
|
||||
},
|
||||
{
|
||||
id: 'deep-dives',
|
||||
label: 'Deep Dives',
|
||||
icon: 'D',
|
||||
color: '#f59e0b',
|
||||
description: 'Bankless, Week in Eth, Delphi, Messari',
|
||||
endpoint: '/api/feed/newsletters',
|
||||
limit: 25,
|
||||
params: { category: 'deep_dives' },
|
||||
},
|
||||
{
|
||||
id: 'onchain',
|
||||
label: 'On-Chain',
|
||||
icon: 'O',
|
||||
color: '#10b981',
|
||||
description: 'Dune, Glassnode, Token Terminal, Nansen, Artemis',
|
||||
endpoint: '/api/feed/newsletters',
|
||||
limit: 25,
|
||||
params: { category: 'onchain_data' },
|
||||
},
|
||||
{
|
||||
id: 'defi',
|
||||
label: 'DeFi',
|
||||
icon: 'D',
|
||||
color: '#6366f1',
|
||||
description: 'DeFi Pulse, Prime, Llama, Yearn, Uniswap',
|
||||
endpoint: '/api/feed/newsletters',
|
||||
limit: 25,
|
||||
params: { category: 'defi' },
|
||||
},
|
||||
{
|
||||
id: 'ai-crypto',
|
||||
label: 'AI x Crypto',
|
||||
icon: 'AI',
|
||||
color: '#ec4899',
|
||||
description: 'AI/ML in crypto, The Sequence, Modular',
|
||||
endpoint: '/api/feed/newsletters',
|
||||
limit: 25,
|
||||
params: { category: 'ai_crypto' },
|
||||
},
|
||||
{
|
||||
id: 'nostr',
|
||||
label: 'Nostr',
|
||||
icon: '\u2630',
|
||||
color: '#8e30eb',
|
||||
description: 'Decentralized notes',
|
||||
endpoint: '/api/feed/home',
|
||||
limit: 25,
|
||||
params: { protocols: 'nostr' },
|
||||
},
|
||||
{
|
||||
id: 'farcaster',
|
||||
label: 'Farcaster',
|
||||
icon: '\u25C8',
|
||||
color: '#8a63d2',
|
||||
description: 'Sufficiently decentralized social',
|
||||
endpoint: '/api/feed/home',
|
||||
limit: 25,
|
||||
params: { protocols: 'farcaster' },
|
||||
},
|
||||
{
|
||||
id: 'lens',
|
||||
label: 'Lens',
|
||||
icon: '\u25CE',
|
||||
color: '#abfe2c',
|
||||
description: 'User-owned social graph',
|
||||
endpoint: '/api/feed/home',
|
||||
limit: 25,
|
||||
params: { protocols: 'lens' },
|
||||
},
|
||||
{
|
||||
id: 'bluesky',
|
||||
label: 'Bluesky',
|
||||
icon: '\u2601',
|
||||
color: '#1185fe',
|
||||
description: 'AT Protocol social',
|
||||
endpoint: '/api/feed/home',
|
||||
limit: 25,
|
||||
params: { protocols: 'bluesky' },
|
||||
},
|
||||
{
|
||||
id: 'threads',
|
||||
label: 'Threads',
|
||||
icon: '+',
|
||||
color: '#888',
|
||||
description: 'Instagram AT Protocol network',
|
||||
endpoint: '/api/feed/threads',
|
||||
limit: 25,
|
||||
},
|
||||
{
|
||||
id: 'mastodon',
|
||||
label: 'Mastodon',
|
||||
icon: 'M',
|
||||
color: '#6364ff',
|
||||
description: 'Fediverse (GotoSocial)',
|
||||
endpoint: '/api/feed/activitypub',
|
||||
limit: 20,
|
||||
params: { instance: 'https://social.degenfeed.xyz', type: 'public' },
|
||||
},
|
||||
{
|
||||
id: 'articles',
|
||||
label: 'Reddit',
|
||||
icon: 'R',
|
||||
color: '#ff4500',
|
||||
description: 'Reddit CryptoCurrency hot posts',
|
||||
endpoint: '/api/feed/rss',
|
||||
limit: 25,
|
||||
params: { source: 'reddit', handle: 'CryptoCurrency' },
|
||||
},
|
||||
{
|
||||
id: 'defi-reddit',
|
||||
label: 'DeFi Reddit',
|
||||
icon: 'D',
|
||||
color: '#6366f1',
|
||||
description: 'Reddit DeFi hot posts',
|
||||
endpoint: '/api/feed/rss',
|
||||
limit: 25,
|
||||
params: { source: 'reddit', handle: 'defi' },
|
||||
},
|
||||
{
|
||||
id: 'eth-reddit',
|
||||
label: 'Eth Reddit',
|
||||
icon: 'E',
|
||||
color: '#8b5cf6',
|
||||
description: 'Reddit Ethereum hot posts',
|
||||
endpoint: '/api/feed/rss',
|
||||
limit: 25,
|
||||
params: { source: 'reddit', handle: 'ethereum' },
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { Metadata, Viewport } from 'next';
|
||||
import './globals.css';
|
||||
import { ErrorTracker } from '../components/ErrorTracker';
|
||||
import { AuthProvider } from '../components/AuthProvider';
|
||||
import { SolanaProvider } from "../components/SolanaProvider";
|
||||
import { ErrorTracker } from '../components/ErrorTracker';
|
||||
import { SolanaProvider } from '../components/SolanaProvider';
|
||||
import { Web3Provider } from '../components/Web3Provider';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -39,9 +39,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||
Skip to content
|
||||
</a>
|
||||
<ErrorTracker />
|
||||
<SolanaProvider><Web3Provider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</Web3Provider></SolanaProvider>
|
||||
<SolanaProvider>
|
||||
<Web3Provider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</Web3Provider>
|
||||
</SolanaProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useAuth } from "../../components/AuthProvider";
|
||||
import { useWeb3 } from "../../components/Web3Provider";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../components/AuthProvider';
|
||||
import { useWeb3 } from '../../components/Web3Provider';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
|
|
@ -17,34 +17,34 @@ interface ResolvedIdentity {
|
|||
}
|
||||
|
||||
const PROTOCOL_COLORS: Record<string, string> = {
|
||||
nostr: "#8e30eb",
|
||||
farcaster: "#8a63d2",
|
||||
lens: "#abfe2c",
|
||||
bluesky: "#1185fe",
|
||||
threads: "#888888",
|
||||
mastodon: "#6364ff",
|
||||
ethereum: "#627eea",
|
||||
solana: "#9945ff",
|
||||
nostr: '#8e30eb',
|
||||
farcaster: '#8a63d2',
|
||||
lens: '#abfe2c',
|
||||
bluesky: '#1185fe',
|
||||
threads: '#888888',
|
||||
mastodon: '#6364ff',
|
||||
ethereum: '#627eea',
|
||||
solana: '#9945ff',
|
||||
};
|
||||
|
||||
const PROTOCOL_ICONS: Record<string, string> = {
|
||||
nostr: "☰",
|
||||
farcaster: "◈",
|
||||
lens: "◎",
|
||||
bluesky: "☁",
|
||||
threads: "⊞",
|
||||
mastodon: "🐘",
|
||||
ethereum: "◆",
|
||||
solana: "◎",
|
||||
nostr: '☰',
|
||||
farcaster: '◈',
|
||||
lens: '◎',
|
||||
bluesky: '☁',
|
||||
threads: '⊞',
|
||||
mastodon: '🐘',
|
||||
ethereum: '◆',
|
||||
solana: '◎',
|
||||
};
|
||||
|
||||
function shortenAddress(addr: string): string {
|
||||
if (addr.length < 10) return addr;
|
||||
return addr.slice(0, 6) + "..." + addr.slice(-4);
|
||||
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { signedInProtocols, tokens, walletAddress, ready, openSignIn } = useAuth();
|
||||
const { signedInProtocols, walletAddress, ready, openSignIn } = useAuth();
|
||||
const { account: wagmiAddress, isConnected } = useWeb3();
|
||||
const [identities, setIdentities] = useState<ResolvedIdentity[]>([]);
|
||||
const hasSignedIn = Object.values(signedInProtocols).some(Boolean) || isConnected;
|
||||
|
|
@ -55,44 +55,51 @@ export default function ProfilePage() {
|
|||
|
||||
// From localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem("degenfeed_identities");
|
||||
const stored = localStorage.getItem('degenfeed_identities');
|
||||
if (stored) {
|
||||
const storedIds: Array<{ protocol: string; displayName?: string; handle?: string; avatarUrl?: string }> = JSON.parse(stored);
|
||||
const storedIds: Array<{
|
||||
protocol: string;
|
||||
displayName?: string;
|
||||
handle?: string;
|
||||
avatarUrl?: string;
|
||||
}> = JSON.parse(stored);
|
||||
for (const id of storedIds) {
|
||||
resolved.push({
|
||||
protocol: id.protocol,
|
||||
displayName: id.displayName,
|
||||
handle: id.handle,
|
||||
avatarUrl: id.avatarUrl,
|
||||
color: PROTOCOL_COLORS[id.protocol] || "#666",
|
||||
color: PROTOCOL_COLORS[id.protocol] || '#666',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Wallet address
|
||||
const addr = walletAddress || wagmiAddress;
|
||||
if (addr) {
|
||||
resolved.push({
|
||||
protocol: "ethereum",
|
||||
displayName: "Ethereum Wallet",
|
||||
protocol: 'ethereum',
|
||||
displayName: 'Ethereum Wallet',
|
||||
handle: shortenAddress(addr),
|
||||
address: addr,
|
||||
color: PROTOCOL_COLORS.ethereum ?? "#627eea",
|
||||
color: PROTOCOL_COLORS.ethereum ?? '#627eea',
|
||||
});
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
const seen = new Set<string>();
|
||||
const unique = resolved.filter(r => {
|
||||
const key = r.protocol + ":" + (r.address || r.handle || "");
|
||||
const unique = resolved.filter((r) => {
|
||||
const key = `${r.protocol}:${r.address || r.handle || ''}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
|
||||
setIdentities(unique);
|
||||
}, [signedInProtocols, walletAddress, wagmiAddress, isConnected]);
|
||||
}, [walletAddress, wagmiAddress]);
|
||||
|
||||
if (!ready) {
|
||||
return <div className="mx-auto max-w-2xl px-4 py-20 text-center text-gray-500">Loading...</div>;
|
||||
|
|
@ -104,17 +111,16 @@ export default function ProfilePage() {
|
|||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-degen-500/30 to-purple-500/30 text-3xl">
|
||||
{identities.length > 0
|
||||
? (identities[0]?.displayName ?? "?").charAt(0).toUpperCase()
|
||||
: "?"
|
||||
}
|
||||
? (identities[0]?.displayName ?? '?').charAt(0).toUpperCase()
|
||||
: '?'}
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">
|
||||
{identities.length > 0 ? (identities[0]?.displayName ?? "Unnamed") : "DegenFeed User"}
|
||||
{identities.length > 0 ? (identities[0]?.displayName ?? 'Unnamed') : 'DegenFeed User'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{hasSignedIn
|
||||
? identities.length + " connection" + (identities.length !== 1 ? "s" : "")
|
||||
: "No accounts connected"}
|
||||
? `${identities.length} connection${identities.length !== 1 ? 's' : ''}`
|
||||
: 'No accounts connected'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -134,16 +140,23 @@ export default function ProfilePage() {
|
|||
Connected Identities
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{identities.map((id, i) => (
|
||||
<div key={i} className="flex items-center gap-4 rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3">
|
||||
{identities.map((id) => (
|
||||
<div
|
||||
key={`${id.protocol}-${id.handle || id.address || id.displayName || Math.random()}`}
|
||||
className="flex items-center gap-4 rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3"
|
||||
>
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full text-lg"
|
||||
style={{ backgroundColor: id.color + "22", color: id.color }}
|
||||
style={{ backgroundColor: `${id.color}22`, color: id.color }}
|
||||
>
|
||||
{id.avatarUrl ? (
|
||||
<img src={id.avatarUrl} alt="" className="h-10 w-10 rounded-full object-cover" />
|
||||
<img
|
||||
src={id.avatarUrl}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
PROTOCOL_ICONS[id.protocol] || "?"
|
||||
PROTOCOL_ICONS[id.protocol] || '?'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -153,16 +166,18 @@ export default function ProfilePage() {
|
|||
</span>
|
||||
<span
|
||||
className="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: id.color + "22", color: id.color }}
|
||||
style={{ backgroundColor: `${id.color}22`, color: id.color }}
|
||||
>
|
||||
{id.protocol}
|
||||
</span>
|
||||
</div>
|
||||
{id.address ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs text-gray-400 font-mono">{shortenAddress(id.address)}</code>
|
||||
<code className="text-xs text-gray-400 font-mono">
|
||||
{shortenAddress(id.address)}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(id.address!)}
|
||||
onClick={() => navigator.clipboard.writeText(id.address ?? '')}
|
||||
className="text-[10px] text-gray-600 hover:text-gray-400 transition-colors"
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -170,7 +185,7 @@ export default function ProfilePage() {
|
|||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-500 truncate">{id.handle || ""}</div>
|
||||
<div className="text-xs text-gray-500 truncate">{id.handle || ''}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -186,11 +201,11 @@ export default function ProfilePage() {
|
|||
<div className="space-y-3">
|
||||
{/* QR Code for primary wallet */}
|
||||
{(() => {
|
||||
const tipAddr = identities.find(i => i.address)?.address;
|
||||
if (!tipAddr || typeof window === "undefined") return null;
|
||||
const tipUrl = tipAddr.startsWith("0x")
|
||||
? "ethereum:" + tipAddr
|
||||
: "solana:" + tipAddr;
|
||||
const tipAddr = identities.find((i) => i.address)?.address;
|
||||
if (!tipAddr || typeof window === 'undefined') return null;
|
||||
const tipUrl = tipAddr.startsWith('0x')
|
||||
? `ethereum:${tipAddr}`
|
||||
: `solana:${tipAddr}`;
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-xl border border-degen-500/20 bg-degen-500/5 p-4">
|
||||
<h3 className="mb-2 text-xs font-semibold text-degen-400">Scan to Tip</h3>
|
||||
|
|
@ -204,48 +219,73 @@ export default function ProfilePage() {
|
|||
);
|
||||
})()}
|
||||
{/* Wallet addresses (if linked) */}
|
||||
{identities.filter(i => i.address).length > 0 && (
|
||||
{identities.filter((i) => i.address).length > 0 && (
|
||||
<div className="rounded-xl border border-degen-500/20 bg-degen-500/5 p-4">
|
||||
<h3 className="mb-2 text-xs font-semibold text-degen-400">Direct Crypto</h3>
|
||||
<p className="mb-3 text-xs text-gray-400">
|
||||
Anyone can send you tips on these chains — no DegenFeed account needed.
|
||||
</p>
|
||||
{identities.filter(i => i.address).map((id, i) => (
|
||||
<div key={i} className="mb-2 flex items-center justify-between rounded-lg bg-gray-900/50 px-3 py-2">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="hidden sm:block rounded-lg bg-white p-1">
|
||||
{typeof window !== "undefined" && (
|
||||
<QRCodeSVG
|
||||
value={(id.address || "").startsWith("0x") ? "ethereum:" + id.address : "solana:" + id.address}
|
||||
size={40}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ color: id.color }}>{PROTOCOL_ICONS[id.protocol]}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-white">{id.protocol === 'ethereum' ? 'EVM Chain' : 'Solana'}</div>
|
||||
<code className="text-xs font-mono text-gray-400 truncate block">{shortenAddress(id.address || "")}</code>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(id.address || "")}
|
||||
className="shrink-0 rounded-lg bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-400 hover:bg-gray-700 hover:text-white transition-colors"
|
||||
type="button"
|
||||
{identities
|
||||
.filter((i) => i.address)
|
||||
.map((id) => (
|
||||
<div
|
||||
key={`${id.protocol}-${id.address}`}
|
||||
className="mb-2 flex items-center justify-between rounded-lg bg-gray-900/50 px-3 py-2"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="hidden sm:block rounded-lg bg-white p-1">
|
||||
{typeof window !== 'undefined' && (
|
||||
<QRCodeSVG
|
||||
value={
|
||||
(id.address || '').startsWith('0x')
|
||||
? `ethereum:${id.address}`
|
||||
: `solana:${id.address}`
|
||||
}
|
||||
size={40}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span style={{ color: id.color }}>{PROTOCOL_ICONS[id.protocol]}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-white">
|
||||
{id.protocol === 'ethereum' ? 'EVM Chain' : 'Solana'}
|
||||
</div>
|
||||
<code className="text-xs font-mono text-gray-400 truncate block">
|
||||
{shortenAddress(id.address || '')}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(id.address ?? '')}
|
||||
className="shrink-0 rounded-lg bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-400 hover:bg-gray-700 hover:text-white transition-colors"
|
||||
type="button"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">Ethereum</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">Base</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">Optimism</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">Arbitrum</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">Polygon</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">Solana</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
|
||||
Ethereum
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
|
||||
Base
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
|
||||
Optimism
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
|
||||
Arbitrum
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
|
||||
Polygon
|
||||
</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-[10px] text-gray-500">
|
||||
Solana
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const addr = identities.find(i => i.address)?.address;
|
||||
const addr = identities.find((i) => i.address)?.address;
|
||||
if (addr) navigator.clipboard.writeText(addr);
|
||||
}}
|
||||
className="rounded-full bg-degen-500/20 px-2 py-0.5 text-[10px] text-degen-400 hover:bg-degen-500/30 transition-colors"
|
||||
|
|
@ -264,27 +304,40 @@ export default function ProfilePage() {
|
|||
<h3 className="text-xs font-semibold text-yellow-400">Platform Tipping</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{identities.map((id, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-lg bg-gray-900/50 px-3 py-2">
|
||||
{identities.map((id) => (
|
||||
<div
|
||||
key={`${id.protocol}-${id.handle || id.address || id.displayName || Math.random()}`}
|
||||
className="flex items-center justify-between rounded-lg bg-gray-900/50 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ color: id.color }}>{PROTOCOL_ICONS[id.protocol]}</span>
|
||||
<div>
|
||||
<div className="text-xs text-white">{id.protocol === 'ethereum' ? 'Ethereum Wallet' : id.protocol.charAt(0).toUpperCase() + id.protocol.slice(1)}</div>
|
||||
<div className="text-xs text-white">
|
||||
{id.protocol === 'ethereum'
|
||||
? 'Ethereum Wallet'
|
||||
: id.protocol.charAt(0).toUpperCase() + id.protocol.slice(1)}
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-600">
|
||||
{id.protocol === 'nostr' ? 'Lightning Zaps available' :
|
||||
id.protocol === 'lens' ? 'Lens Collect available' :
|
||||
id.protocol === 'farcaster' ? 'Farcaster Frames available' :
|
||||
id.protocol === 'ethereum' || id.protocol === 'solana' ? 'Direct crypto transfer' :
|
||||
'Coming soon'}
|
||||
{id.protocol === 'nostr'
|
||||
? 'Lightning Zaps available'
|
||||
: id.protocol === 'lens'
|
||||
? 'Lens Collect available'
|
||||
: id.protocol === 'farcaster'
|
||||
? 'Farcaster Frames available'
|
||||
: id.protocol === 'ethereum' || id.protocol === 'solana'
|
||||
? 'Direct crypto transfer'
|
||||
: 'Coming soon'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{id.protocol === 'nostr' && (
|
||||
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 text-[10px] text-yellow-400">⚡ Zap</span>
|
||||
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 text-[10px] text-yellow-400">
|
||||
⚡ Zap
|
||||
</span>
|
||||
)}
|
||||
{(id.protocol === 'ethereum' || id.protocol === 'solana') && id.address && (
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(id.address!)}
|
||||
onClick={() => navigator.clipboard.writeText(id.address ?? '')}
|
||||
className="rounded-lg bg-gray-800 px-2 py-1 text-[10px] text-gray-500 hover:text-white transition-colors"
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -298,7 +351,9 @@ export default function ProfilePage() {
|
|||
|
||||
{/* Instructions */}
|
||||
<div className="rounded-lg bg-gray-900/30 p-3">
|
||||
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-600">How it works</h4>
|
||||
<h4 className="mb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-600">
|
||||
How it works
|
||||
</h4>
|
||||
<ul className="space-y-1 text-[10px] text-gray-500">
|
||||
<li>• Tips are sent directly — DegenFeed never holds funds</li>
|
||||
<li>• Each connected protocol enables its own tipping method</li>
|
||||
|
|
@ -323,7 +378,9 @@ export default function ProfilePage() {
|
|||
)}
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a href="/home" className="text-sm text-gray-600 hover:text-white transition-colors">← Back to feed</a>
|
||||
<a href="/home" className="text-sm text-gray-600 hover:text-white transition-colors">
|
||||
← Back to feed
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { SignInModal, ComposeModal, type UnifiedPost } from "@degenfeed/ui";
|
||||
import { useWalletSignIn } from "../hooks/useWalletSignIn";
|
||||
import { useWeb3 } from "../components/Web3Provider";
|
||||
import { ComposeModal, SignInModal } from '@degenfeed/ui';
|
||||
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { useWeb3 } from '../components/Web3Provider';
|
||||
import { useWalletSignIn } from '../hooks/useWalletSignIn';
|
||||
|
||||
export interface AuthContextValue {
|
||||
signedInProtocols: Record<string, boolean>;
|
||||
|
|
@ -11,7 +11,10 @@ export interface AuthContextValue {
|
|||
walletAddress?: string;
|
||||
openSignIn: () => void;
|
||||
openCompose: () => void;
|
||||
publish: (text: string, targets: string[]) => Promise<Record<string, { ok: boolean; error?: string }>>;
|
||||
publish: (
|
||||
text: string,
|
||||
targets: string[],
|
||||
) => Promise<Record<string, { ok: boolean; error?: string }>>;
|
||||
engage: (action: string, postId: string, protocol: string) => Promise<void>;
|
||||
ready: boolean;
|
||||
}
|
||||
|
|
@ -20,7 +23,7 @@ const AuthContext = createContext<AuthContextValue | null>(null);
|
|||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
|
||||
if (!ctx) throw new Error('useAuth must be used within an AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
|
|
@ -38,7 +41,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
// Load stored identities on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem("degenfeed_identities");
|
||||
const stored = localStorage.getItem('degenfeed_identities');
|
||||
if (stored) {
|
||||
const identities: Array<{ protocol: string; token?: string }> = JSON.parse(stored);
|
||||
const protocols: Record<string, boolean> = {};
|
||||
|
|
@ -50,9 +53,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
setSignedInProtocols(protocols);
|
||||
setTokens(toks);
|
||||
}
|
||||
const storedWallet = localStorage.getItem("degenfeed_wallet");
|
||||
const storedWallet = localStorage.getItem('degenfeed_wallet');
|
||||
if (storedWallet) setWalletAddress(storedWallet);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setReady(true);
|
||||
}, []);
|
||||
|
||||
|
|
@ -60,106 +65,117 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
useEffect(() => {
|
||||
if (isConnected && wagmiAddress) {
|
||||
setWalletAddress(wagmiAddress);
|
||||
localStorage.setItem("degenfeed_wallet", wagmiAddress);
|
||||
localStorage.setItem('degenfeed_wallet', wagmiAddress);
|
||||
}
|
||||
}, [isConnected, wagmiAddress]);
|
||||
|
||||
const handleSignIn = useCallback(async (protocol: string, token?: string) => {
|
||||
try {
|
||||
if (protocol === "ethereum") {
|
||||
const session = await signInWithEthereum();
|
||||
if (session) {
|
||||
setWalletAddress(session.address);
|
||||
localStorage.setItem("degenfeed_wallet", session.address);
|
||||
setSignedInProtocols((prev) => ({ ...prev, ethereum: true }));
|
||||
if (session.accessToken) {
|
||||
setTokens((prev) => ({ ...prev, ethereum: session.accessToken }));
|
||||
}
|
||||
setShowSignIn(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (protocol) {
|
||||
case "nostr": {
|
||||
const { signInWithNostrNsec } = await import("@degenfeed/auth");
|
||||
if (!token) throw new Error("Nostr nsec required");
|
||||
await signInWithNostrNsec(token, "auto-unlock-passphrase");
|
||||
break;
|
||||
}
|
||||
case "farcaster": {
|
||||
const { signInWithFarcasterSigner } = await import("@degenfeed/auth");
|
||||
const fid = prompt("Farcaster FID (numeric):");
|
||||
const username = prompt("Farcaster username:");
|
||||
const signerKey = prompt("Signer private key hex:");
|
||||
if (!fid || !username || !signerKey) throw new Error("Farcaster credentials required");
|
||||
await signInWithFarcasterSigner(signerKey, Number(fid), username, "auto");
|
||||
break;
|
||||
}
|
||||
case "lens": {
|
||||
const { signInWithLens } = await import("@degenfeed/auth");
|
||||
await signInWithLens({ wallet: (window as any).ethereum });
|
||||
break;
|
||||
}
|
||||
case "bluesky": {
|
||||
const { signInWithBluesky } = await import("@degenfeed/auth");
|
||||
const handle = prompt("Bluesky handle (user.bsky.social):");
|
||||
const appPass = prompt("Bluesky app password:");
|
||||
if (!handle || !appPass) throw new Error("Bluesky credentials required");
|
||||
await signInWithBluesky(handle, appPass, "auto-unlock");
|
||||
break;
|
||||
}
|
||||
case "threads": {
|
||||
const { signInWithThreads } = await import("@degenfeed/auth");
|
||||
const identifier = prompt("Threads/AT Protocol identifier:");
|
||||
const apPass = prompt("App password:");
|
||||
if (!identifier || !apPass) throw new Error("Threads credentials required");
|
||||
await signInWithThreads(identifier, apPass, "auto-unlock");
|
||||
break;
|
||||
}
|
||||
case "mastodon": {
|
||||
const { signInWithGtS } = await import("@degenfeed/auth");
|
||||
const instance = prompt("Mastodon instance URL (https://social.degenfeed.xyz):") || "https://social.degenfeed.xyz";
|
||||
const accessToken = prompt("Access token:");
|
||||
if (!accessToken) throw new Error("Access token required");
|
||||
await signInWithGtS(instance, accessToken, "auto-unlock");
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error("Unknown protocol: " + protocol);
|
||||
}
|
||||
setSignedInProtocols((prev) => ({ ...prev, [protocol]: true }));
|
||||
const handleSignIn = useCallback(
|
||||
async (protocol: string, token?: string) => {
|
||||
try {
|
||||
const stored = localStorage.getItem("degenfeed_identities");
|
||||
if (stored) {
|
||||
const identities: Array<{ protocol: string; token?: string }> = JSON.parse(stored);
|
||||
const toks: Record<string, string> = {};
|
||||
for (const id of identities) {
|
||||
if (id.token) toks[id.protocol] = id.token;
|
||||
if (protocol === 'ethereum') {
|
||||
const session = await signInWithEthereum();
|
||||
if (session) {
|
||||
setWalletAddress(session.address);
|
||||
localStorage.setItem('degenfeed_wallet', session.address);
|
||||
setSignedInProtocols((prev) => ({ ...prev, ethereum: true }));
|
||||
if (session.accessToken) {
|
||||
setTokens((prev) => ({ ...prev, ethereum: session.accessToken }));
|
||||
}
|
||||
setShowSignIn(false);
|
||||
}
|
||||
setTokens((prev) => ({ ...prev, ...toks }));
|
||||
return;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
setShowSignIn(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Sign-in failed";
|
||||
alert("Sign-in failed: " + message);
|
||||
}
|
||||
}, [signInWithEthereum]);
|
||||
|
||||
switch (protocol) {
|
||||
case 'nostr': {
|
||||
const { signInWithNostrNsec } = await import('@degenfeed/auth');
|
||||
if (!token) throw new Error('Nostr nsec required');
|
||||
await signInWithNostrNsec(token, 'auto-unlock-passphrase');
|
||||
break;
|
||||
}
|
||||
case 'farcaster': {
|
||||
const { signInWithFarcasterSigner } = await import('@degenfeed/auth');
|
||||
const fid = prompt('Farcaster FID (numeric):');
|
||||
const username = prompt('Farcaster username:');
|
||||
const signerKey = prompt('Signer private key hex:');
|
||||
if (!fid || !username || !signerKey) throw new Error('Farcaster credentials required');
|
||||
await signInWithFarcasterSigner(signerKey, Number(fid), username, 'auto');
|
||||
break;
|
||||
}
|
||||
case 'lens': {
|
||||
const { signInWithLens } = await import('@degenfeed/auth');
|
||||
if (!window.ethereum) throw new Error('No Ethereum wallet detected');
|
||||
await signInWithLens({ wallet: window.ethereum });
|
||||
break;
|
||||
}
|
||||
case 'bluesky': {
|
||||
const { signInWithBluesky } = await import('@degenfeed/auth');
|
||||
const handle = prompt('Bluesky handle (user.bsky.social):');
|
||||
const appPass = prompt('Bluesky app password:');
|
||||
if (!handle || !appPass) throw new Error('Bluesky credentials required');
|
||||
await signInWithBluesky(handle, appPass, 'auto-unlock');
|
||||
break;
|
||||
}
|
||||
case 'threads': {
|
||||
const { signInWithThreads } = await import('@degenfeed/auth');
|
||||
const identifier = prompt('Threads/AT Protocol identifier:');
|
||||
const apPass = prompt('App password:');
|
||||
if (!identifier || !apPass) throw new Error('Threads credentials required');
|
||||
await signInWithThreads(identifier, apPass, 'auto-unlock');
|
||||
break;
|
||||
}
|
||||
case 'mastodon': {
|
||||
const { signInWithGtS } = await import('@degenfeed/auth');
|
||||
const instance =
|
||||
prompt('Mastodon instance URL (https://social.degenfeed.xyz):') ||
|
||||
'https://social.degenfeed.xyz';
|
||||
const accessToken = prompt('Access token:');
|
||||
if (!accessToken) throw new Error('Access token required');
|
||||
await signInWithGtS(instance, accessToken, 'auto-unlock');
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown protocol: ${protocol}`);
|
||||
}
|
||||
setSignedInProtocols((prev) => ({ ...prev, [protocol]: true }));
|
||||
try {
|
||||
const stored = localStorage.getItem('degenfeed_identities');
|
||||
if (stored) {
|
||||
const identities: Array<{ protocol: string; token?: string }> = JSON.parse(stored);
|
||||
const toks: Record<string, string> = {};
|
||||
for (const id of identities) {
|
||||
if (id.token) toks[id.protocol] = id.token;
|
||||
}
|
||||
setTokens((prev) => ({ ...prev, ...toks }));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setShowSignIn(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Sign-in failed';
|
||||
alert(`Sign-in failed: ${message}`);
|
||||
}
|
||||
},
|
||||
[signInWithEthereum],
|
||||
);
|
||||
|
||||
const handlePublish = useCallback(
|
||||
async (text: string, targets: string[]): Promise<Record<string, { ok: boolean; error?: string }>> => {
|
||||
async (
|
||||
text: string,
|
||||
targets: string[],
|
||||
): Promise<Record<string, { ok: boolean; error?: string }>> => {
|
||||
try {
|
||||
const res = await fetch("/api/publish", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
const res = await fetch('/api/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ text, targets, auth: tokens }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.results ?? {};
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
const results: Record<string, { ok: boolean; error?: string }> = {};
|
||||
for (const t of targets) results[t] = { ok: false, error: "Network error" };
|
||||
for (const t of targets) results[t] = { ok: false, error: 'Network error' };
|
||||
return results;
|
||||
}
|
||||
},
|
||||
|
|
@ -169,12 +185,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
const handleEngage = useCallback(
|
||||
async (action: string, postId: string, protocol: string) => {
|
||||
try {
|
||||
await fetch("/api/engage", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
await fetch('/api/engage', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action, postId, protocol, auth: tokens[protocol] }),
|
||||
});
|
||||
} catch { /* silently fail */ }
|
||||
} catch {
|
||||
/* silently fail */
|
||||
}
|
||||
},
|
||||
[tokens],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function ErrorTracker() {
|
||||
useEffect(() => {
|
||||
const onError = (event: ErrorEvent) => {
|
||||
console.error("[DegenFeed Error]", {
|
||||
console.error('[DegenFeed Error]', {
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
|
|
@ -15,14 +15,14 @@ export function ErrorTracker() {
|
|||
// Future: POST to /api/error-log
|
||||
};
|
||||
const onRejection = (event: PromiseRejectionEvent) => {
|
||||
console.error("[DegenFeed Unhandled Promise]", { reason: event.reason });
|
||||
console.error('[DegenFeed Unhandled Promise]', { reason: event.reason });
|
||||
};
|
||||
window.addEventListener("error", onError);
|
||||
window.addEventListener("unhandledrejection", onRejection);
|
||||
console.log("[DegenFeed] App initialized", new Date().toISOString());
|
||||
window.addEventListener('error', onError);
|
||||
window.addEventListener('unhandledrejection', onRejection);
|
||||
console.log('[DegenFeed] App initialized', new Date().toISOString());
|
||||
return () => {
|
||||
window.removeEventListener("error", onError);
|
||||
window.removeEventListener("unhandledrejection", onRejection);
|
||||
window.removeEventListener('error', onError);
|
||||
window.removeEventListener('unhandledrejection', onRejection);
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* DegenFeed — Main App Shell
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
* Wraps the entire /home area with AuthProvider.
|
||||
*/
|
||||
|
||||
import { AuthProvider } from "./AuthProvider";
|
||||
import { AuthProvider } from './AuthProvider';
|
||||
|
||||
export function FeedShell({ children }: { children: React.ReactNode }) {
|
||||
return <AuthProvider>{children}</AuthProvider>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
interface Step {
|
||||
title: string;
|
||||
|
|
@ -11,24 +11,27 @@ interface Step {
|
|||
|
||||
const STEPS: Step[] = [
|
||||
{
|
||||
title: "See every network",
|
||||
description: "Nostr, Farcaster, Lens, Bluesky, Threads, Mastodon, Reddit, RSS \u2014 all in one feed. Zero accounts needed to read.",
|
||||
icon: "\u25C6",
|
||||
action: "Scroll the feed below \u2193",
|
||||
title: 'See every network',
|
||||
description:
|
||||
'Nostr, Farcaster, Lens, Bluesky, Threads, Mastodon, Reddit, RSS — all in one feed. Zero accounts needed to read.',
|
||||
icon: '◆',
|
||||
action: 'Scroll the feed below ↓',
|
||||
},
|
||||
{
|
||||
title: "Sign in to engage",
|
||||
description: "Connect any protocol you already use. Like, reply, and repost natively. Your keys stay encrypted in your browser.",
|
||||
icon: "\uD83D\uDD11",
|
||||
action: "Click Sign In in the header",
|
||||
title: 'Sign in to engage',
|
||||
description:
|
||||
'Connect any protocol you already use. Like, reply, and repost natively. Your keys stay encrypted in your browser.',
|
||||
icon: '🔑',
|
||||
action: 'Click Sign In in the header',
|
||||
},
|
||||
{
|
||||
title: "Get a free @degenfeed.xyz account",
|
||||
description: "No keys yet? Create a Mastodon account on our server in 30 seconds. Username + password, that is it.",
|
||||
icon: "\u2728",
|
||||
title: 'Get a free @degenfeed.xyz account',
|
||||
description:
|
||||
'No keys yet? Create a Mastodon account on our server in 30 seconds. Username + password, that is it.',
|
||||
icon: '✨',
|
||||
action: (
|
||||
<a href="/api/auth/gotosocial/register" className="btn-primary mt-2 inline-block text-sm">
|
||||
Create free account \u2192
|
||||
Create free account →
|
||||
</a>
|
||||
),
|
||||
},
|
||||
|
|
@ -36,14 +39,18 @@ const STEPS: Step[] = [
|
|||
|
||||
export function OnboardingWizard({ onDismiss }: { onDismiss: () => void }) {
|
||||
const [step, setStep] = useState(0);
|
||||
const current = STEPS[step]; if (!current) return null;
|
||||
const current = STEPS[step];
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="mx-4 w-full max-w-md rounded-2xl border border-gray-800 bg-gray-950 p-8 shadow-2xl">
|
||||
<div className="mb-6 flex justify-center gap-2">
|
||||
{STEPS.map((_, i) => (
|
||||
<div key={i} className={"h-1.5 w-8 rounded-full transition-colors " + (i <= step ? "bg-degen-500" : "bg-gray-800")} />
|
||||
{STEPS.map((s, i) => (
|
||||
<div
|
||||
key={s.title}
|
||||
className={`h-1.5 w-8 rounded-full transition-colors ${i <= step ? 'bg-degen-500' : 'bg-gray-800'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
@ -55,11 +62,26 @@ export function OnboardingWizard({ onDismiss }: { onDismiss: () => void }) {
|
|||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<button type="button" className="text-sm text-gray-600 hover:text-white transition-colors" onClick={onDismiss}>Skip</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-gray-500 hover:text-white transition-colors disabled:opacity-0"
|
||||
disabled={step === 0}
|
||||
onClick={() => setStep((s) => s - 1)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{step < STEPS.length - 1 ? (
|
||||
<button type="button" className="btn-primary text-sm" onClick={() => setStep(step + 1)}>Next \u2192</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm"
|
||||
onClick={() => setStep((s) => s + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="btn-primary text-sm" onClick={onDismiss}>Start browsing \u2192</button>
|
||||
<button type="button" className="btn-primary text-sm" onClick={onDismiss}>
|
||||
Get started
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,25 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
interface SolanaWalletProvider {
|
||||
isPhantom?: boolean;
|
||||
isSolflare?: boolean;
|
||||
isBackpack?: boolean;
|
||||
connect: () => Promise<{ publicKey?: { toString(): string } }>;
|
||||
disconnect: () => void;
|
||||
publicKey?: { toString(): string };
|
||||
signTransaction: (tx: unknown) => Promise<{ serialize(): Uint8Array }>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
phantom?: { solana?: SolanaWalletProvider };
|
||||
solflare?: SolanaWalletProvider;
|
||||
backpack?: { solana?: SolanaWalletProvider };
|
||||
solana?: SolanaWalletProvider;
|
||||
}
|
||||
}
|
||||
|
||||
interface SolanaContextValue {
|
||||
account: string | null;
|
||||
|
|
@ -14,9 +33,13 @@ interface SolanaContextValue {
|
|||
const DEFAULT_SOLANA: SolanaContextValue = {
|
||||
account: null,
|
||||
isConnected: false,
|
||||
connect: async () => { throw new Error('No Solana wallet'); },
|
||||
connect: async () => {
|
||||
throw new Error('No Solana wallet');
|
||||
},
|
||||
disconnect: () => {},
|
||||
signAndSendTransaction: async () => { throw new Error('No Solana wallet'); },
|
||||
signAndSendTransaction: async () => {
|
||||
throw new Error('No Solana wallet');
|
||||
},
|
||||
providerName: '',
|
||||
};
|
||||
|
||||
|
|
@ -26,20 +49,24 @@ export function useSolana(): SolanaContextValue {
|
|||
return useContext(SolanaContext);
|
||||
}
|
||||
|
||||
function getSolanaProvider(): { provider: any; name: string } | null {
|
||||
function getSolanaProvider(): { provider: SolanaWalletProvider; name: string } | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// Phantom
|
||||
const phantom = (window as any).phantom?.solana;
|
||||
if (phantom?.isPhantom) return { provider: phantom, name: 'Phantom' };
|
||||
const phantom = window.phantom?.solana;
|
||||
if (phantom?.isPhantom) return { provider: phantom as SolanaWalletProvider, name: 'Phantom' };
|
||||
// Solflare
|
||||
const solflare = (window as any).solflare;
|
||||
if (solflare?.isSolflare) return { provider: solflare, name: 'Solflare' };
|
||||
const solflare = window.solflare;
|
||||
if (solflare?.isSolflare) return { provider: solflare as SolanaWalletProvider, name: 'Solflare' };
|
||||
// Backpack
|
||||
const backpack = (window as any).backpack?.solana;
|
||||
if (backpack?.isBackpack) return { provider: backpack, name: 'Backpack' };
|
||||
const backpack = window.backpack?.solana;
|
||||
if (backpack?.isBackpack) return { provider: backpack as SolanaWalletProvider, name: 'Backpack' };
|
||||
// Fallback: window.solana
|
||||
const fallback = (window as any).solana;
|
||||
if (fallback) return { provider: fallback, name: fallback.isPhantom ? 'Phantom' : 'Solana Wallet' };
|
||||
const fallback = window.solana;
|
||||
if (fallback)
|
||||
return {
|
||||
provider: fallback as SolanaWalletProvider,
|
||||
name: fallback.isPhantom ? 'Phantom' : 'Solana Wallet',
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -60,14 +87,15 @@ export function SolanaProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
const connect = useCallback(async () => {
|
||||
const provider = getProvider();
|
||||
if (!provider) throw new Error('No Solana wallet detected. Install Phantom, Solflare, or Backpack.');
|
||||
if (!provider)
|
||||
throw new Error('No Solana wallet detected. Install Phantom, Solflare, or Backpack.');
|
||||
try {
|
||||
const resp = await provider.connect();
|
||||
const pubKey = resp.publicKey?.toString() || provider.publicKey?.toString() || '';
|
||||
if (pubKey) setAccount(pubKey);
|
||||
return pubKey;
|
||||
} catch (err: any) {
|
||||
throw new Error(err.message || 'User rejected Solana connection');
|
||||
} catch (err) {
|
||||
throw new Error(err instanceof Error ? err.message : 'User rejected Solana connection');
|
||||
}
|
||||
}, [getProvider]);
|
||||
|
||||
|
|
@ -77,33 +105,38 @@ export function SolanaProvider({ children }: { children: ReactNode }) {
|
|||
setAccount(null);
|
||||
}, [getProvider]);
|
||||
|
||||
const signAndSendTransaction = useCallback(async (to: string, amount: number): Promise<{ signature: string }> => {
|
||||
const provider = getProvider();
|
||||
if (!provider || !account) throw new Error('No Solana wallet connected');
|
||||
const signAndSendTransaction = useCallback(
|
||||
async (to: string, amount: number): Promise<{ signature: string }> => {
|
||||
const provider = getProvider();
|
||||
if (!provider || !account) throw new Error('No Solana wallet connected');
|
||||
|
||||
const { LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction, Connection } = await import('@solana/web3.js');
|
||||
const { LAMPORTS_PER_SOL, PublicKey, SystemProgram, Transaction, Connection } = await import(
|
||||
'@solana/web3.js'
|
||||
);
|
||||
|
||||
const connection = new Connection('https://api.mainnet-beta.solana.com');
|
||||
const fromPubkey = new PublicKey(account);
|
||||
const toPubkey = new PublicKey(to);
|
||||
const connection = new Connection('https://api.mainnet-beta.solana.com');
|
||||
const fromPubkey = new PublicKey(account);
|
||||
const toPubkey = new PublicKey(to);
|
||||
|
||||
const tx = new Transaction().add(
|
||||
SystemProgram.transfer({
|
||||
fromPubkey,
|
||||
toPubkey,
|
||||
lamports: amount * LAMPORTS_PER_SOL,
|
||||
})
|
||||
);
|
||||
const tx = new Transaction().add(
|
||||
SystemProgram.transfer({
|
||||
fromPubkey,
|
||||
toPubkey,
|
||||
lamports: amount * LAMPORTS_PER_SOL,
|
||||
}),
|
||||
);
|
||||
|
||||
const { blockhash } = await connection.getLatestBlockhash();
|
||||
tx.recentBlockhash = blockhash;
|
||||
tx.feePayer = fromPubkey;
|
||||
const { blockhash } = await connection.getLatestBlockhash();
|
||||
tx.recentBlockhash = blockhash;
|
||||
tx.feePayer = fromPubkey;
|
||||
|
||||
const signed = await provider.signTransaction(tx);
|
||||
const signature = await connection.sendRawTransaction(signed.serialize());
|
||||
const signed = await provider.signTransaction(tx);
|
||||
const signature = await connection.sendRawTransaction(signed.serialize());
|
||||
|
||||
return { signature };
|
||||
}, [account, getProvider]);
|
||||
return { signature };
|
||||
},
|
||||
[account, getProvider],
|
||||
);
|
||||
|
||||
const value: SolanaContextValue = {
|
||||
account,
|
||||
|
|
@ -118,9 +151,5 @@ export function SolanaProvider({ children }: { children: ReactNode }) {
|
|||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<SolanaContext.Provider value={value}>
|
||||
{children}
|
||||
</SolanaContext.Provider>
|
||||
);
|
||||
return <SolanaContext.Provider value={value}>{children}</SolanaContext.Provider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useTip, type TipMethod } from '../hooks/useTip';
|
||||
import { useWeb3 } from './Web3Provider';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTip } from '../hooks/useTip';
|
||||
import { useSolana } from './SolanaProvider';
|
||||
import { useWeb3 } from './Web3Provider';
|
||||
|
||||
interface TipModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -15,20 +15,27 @@ interface TipModalProps {
|
|||
publicationId?: string;
|
||||
}
|
||||
|
||||
export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId, recipientDisplayName, publicationId }: TipModalProps) {
|
||||
export function TipModal({
|
||||
open,
|
||||
onClose,
|
||||
recipientProtocol,
|
||||
recipientAuthorId,
|
||||
recipientDisplayName,
|
||||
publicationId,
|
||||
}: TipModalProps) {
|
||||
const { state, resolveMethods, createTip, fetchLightningInvoice, setTipStatus } = useTip();
|
||||
const { account, connect: connectWallet } = useWeb3();
|
||||
const { account: solanaAccount, connect: connectSolana, signAndSendTransaction } = useSolana();
|
||||
const [amount, setAmount] = useState('0.001');
|
||||
const [selectedMethod, setSelectedMethod] = useState<string | null>(null);
|
||||
const [invoice, setInvoice] = useState<string | null>(null);
|
||||
const [_invoice, setInvoice] = useState<string | null>(null);
|
||||
|
||||
// Resolve tipping methods on open
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setInvoice(null);
|
||||
resolveMethods(recipientProtocol, recipientAuthorId).then(methods => {
|
||||
const firstAvailable = methods.find(m => m.available);
|
||||
resolveMethods(recipientProtocol, recipientAuthorId).then((methods) => {
|
||||
const firstAvailable = methods.find((m) => m.available);
|
||||
if (firstAvailable) setSelectedMethod(firstAvailable.method);
|
||||
});
|
||||
}
|
||||
|
|
@ -48,16 +55,23 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
if (!ethAccount) ethAccount = await connectWallet();
|
||||
if (!ethAccount) throw new Error('No Ethereum wallet connected');
|
||||
|
||||
const intent = await createTip(recipientAuthorId, recipientProtocol, selectedMethod, amount, publicationId);
|
||||
const intent = await createTip(
|
||||
recipientAuthorId,
|
||||
recipientProtocol,
|
||||
selectedMethod,
|
||||
amount,
|
||||
publicationId,
|
||||
);
|
||||
if (!intent) return;
|
||||
|
||||
setTipStatus('signing');
|
||||
const valueHex = '0x' + (BigInt(Math.floor(parseFloat(amount) * 1e18))).toString(16);
|
||||
const txHash = await (window as any).ethereum.request({
|
||||
const valueHex = `0x${BigInt(Math.floor(Number.parseFloat(amount) * 1e18)).toString(16)}`;
|
||||
if (!window.ethereum) throw new Error('No Ethereum wallet detected');
|
||||
const txHash = await window.ethereum.request({
|
||||
method: 'eth_sendTransaction',
|
||||
params: [{ to: intent.intent.to, value: valueHex }],
|
||||
});
|
||||
setTipStatus('sent', txHash);
|
||||
setTipStatus('sent', txHash as string);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -66,11 +80,20 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
if (!solAccount) solAccount = await connectSolana();
|
||||
if (!solAccount) throw new Error('No Solana wallet connected');
|
||||
|
||||
const intent = await createTip(recipientAuthorId, recipientProtocol, selectedMethod, amount, publicationId);
|
||||
const intent = await createTip(
|
||||
recipientAuthorId,
|
||||
recipientProtocol,
|
||||
selectedMethod,
|
||||
amount,
|
||||
publicationId,
|
||||
);
|
||||
if (!intent) return;
|
||||
|
||||
setTipStatus('signing');
|
||||
const { signature } = await signAndSendTransaction(intent.intent.to, parseFloat(amount));
|
||||
const { signature } = await signAndSendTransaction(
|
||||
intent.intent.to,
|
||||
Number.parseFloat(amount),
|
||||
);
|
||||
setTipStatus('sent', signature);
|
||||
return;
|
||||
}
|
||||
|
|
@ -86,57 +109,91 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
|
||||
// Lens Collect
|
||||
if (selectedMethod === 'lens_collect') {
|
||||
const intent = await createTip(recipientAuthorId, recipientProtocol, selectedMethod, amount, publicationId);
|
||||
const intent = await createTip(
|
||||
recipientAuthorId,
|
||||
recipientProtocol,
|
||||
selectedMethod,
|
||||
amount,
|
||||
publicationId,
|
||||
);
|
||||
if (!intent) return;
|
||||
|
||||
setTipStatus('signing');
|
||||
const provider = (window as any).ethereum;
|
||||
const provider = window.ethereum;
|
||||
if (!provider) throw new Error('No Ethereum wallet detected');
|
||||
|
||||
const txHash = await provider.request({
|
||||
method: 'eth_sendTransaction',
|
||||
params: [{
|
||||
from: account,
|
||||
to: intent.intent.to,
|
||||
data: intent.intent.data,
|
||||
value: intent.intent.value,
|
||||
chainId: '0x' + (intent.intent.chainId ?? 137).toString(16),
|
||||
}],
|
||||
params: [
|
||||
{
|
||||
from: account,
|
||||
to: intent.intent.to,
|
||||
data: intent.intent.data,
|
||||
value: intent.intent.value,
|
||||
chainId: `0x${(intent.intent.chainId ?? 137).toString(16)}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
setTipStatus('sent', txHash);
|
||||
setTipStatus('sent', txHash as string);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: Frame / other methods
|
||||
const intent = await createTip(recipientAuthorId, recipientProtocol, selectedMethod, amount, publicationId);
|
||||
const intent = await createTip(
|
||||
recipientAuthorId,
|
||||
recipientProtocol,
|
||||
selectedMethod,
|
||||
amount,
|
||||
publicationId,
|
||||
);
|
||||
if (!intent) return;
|
||||
setTipStatus('sent', intent.actionUrl || 'ok');
|
||||
|
||||
} catch (err: any) {
|
||||
setTipStatus('failed', undefined, err.message || String(err));
|
||||
} catch (err) {
|
||||
setTipStatus('failed', undefined, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}, [selectedMethod, amount, account, solanaAccount, connectWallet, connectSolana, signAndSendTransaction, createTip, fetchLightningInvoice, setTipStatus, recipientAuthorId, recipientProtocol]);
|
||||
}, [
|
||||
selectedMethod,
|
||||
amount,
|
||||
account,
|
||||
solanaAccount,
|
||||
connectWallet,
|
||||
connectSolana,
|
||||
signAndSendTransaction,
|
||||
createTip,
|
||||
fetchLightningInvoice,
|
||||
setTipStatus,
|
||||
recipientAuthorId,
|
||||
recipientProtocol,
|
||||
publicationId,
|
||||
]);
|
||||
|
||||
const quickAmounts = selectedMethod === 'lightning_zap'
|
||||
? ['1000', '5000', '10000', '50000', '100000']
|
||||
: ['0.001', '0.005', '0.01', '0.05', '0.1'];
|
||||
const quickAmounts =
|
||||
selectedMethod === 'lightning_zap'
|
||||
? ['1000', '5000', '10000', '50000', '100000']
|
||||
: ['0.001', '0.005', '0.01', '0.05', '0.1'];
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const availableMethods = state.methods.filter(m => m.available);
|
||||
const selected = state.methods.find(m => m.method === selectedMethod);
|
||||
const availableMethods = state.methods.filter((m) => m.available);
|
||||
const selected = state.methods.find((m) => m.method === selectedMethod);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
<dialog
|
||||
open
|
||||
className="fixed inset-0 z-50 m-0 flex items-center justify-center bg-black/70 p-0 backdrop-blur-sm open:m-0 open:p-0"
|
||||
onClick={handleClose}
|
||||
role="dialog"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
}}
|
||||
aria-modal="true"
|
||||
aria-label={"Tip " + recipientDisplayName}
|
||||
aria-label={`Tip ${recipientDisplayName}`}
|
||||
>
|
||||
<div
|
||||
<section
|
||||
className="w-full max-w-sm rounded-2xl border border-gray-800 bg-gray-950 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="document"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-800 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-white">Tip {recipientDisplayName}</h2>
|
||||
|
|
@ -146,8 +203,20 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
type="button"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-labelledby="closeTitle"
|
||||
>
|
||||
<title id="closeTitle">Close</title>
|
||||
<path
|
||||
d="M12 4L4 12M4 4l8 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -161,24 +230,30 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
) : state.methods.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="mb-2 text-gray-500">No tipping methods available for this creator</p>
|
||||
<p className="text-xs text-gray-600">They may not have linked any wallets or payment accounts yet.</p>
|
||||
<p className="text-xs text-gray-600">
|
||||
They may not have linked any wallets or payment accounts yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-widest text-gray-500">Method</label>
|
||||
<label
|
||||
htmlFor="tip-method"
|
||||
className="mb-2 block text-xs font-medium uppercase tracking-widest text-gray-500"
|
||||
>
|
||||
Method
|
||||
</label>
|
||||
<div className="space-y-1.5">
|
||||
{availableMethods.map(m => (
|
||||
{availableMethods.map((m) => (
|
||||
<button
|
||||
key={m.method}
|
||||
type="button"
|
||||
onClick={() => setSelectedMethod(m.method)}
|
||||
className={
|
||||
'w-full flex items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all ' +
|
||||
(selectedMethod === m.method
|
||||
className={`w-full flex items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all ${
|
||||
selectedMethod === m.method
|
||||
? 'border-degen-500 bg-degen-500/10'
|
||||
: 'border-gray-800 bg-gray-900/50 hover:border-gray-700')
|
||||
}
|
||||
: 'border-gray-800 bg-gray-900/50 hover:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{m.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
|
@ -196,32 +271,43 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-xs font-medium uppercase tracking-widest text-gray-500">Amount</label>
|
||||
<label
|
||||
htmlFor="tip-amount"
|
||||
className="mb-2 block text-xs font-medium uppercase tracking-widest text-gray-500"
|
||||
>
|
||||
Amount
|
||||
</label>
|
||||
<div className="relative mb-2">
|
||||
<input
|
||||
id="tip-amount"
|
||||
type="text"
|
||||
value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="w-full rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3 text-lg font-mono text-white outline-none focus:border-degen-500 transition-colors"
|
||||
placeholder={selectedMethod === 'lightning_zap' ? '1000' : '0.001'}
|
||||
aria-label="Tip amount"
|
||||
/>
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-sm text-gray-500">
|
||||
{selected?.method === 'lightning_zap' ? 'sats' :
|
||||
selected?.method === 'ethereum' ? 'ETH' :
|
||||
selected?.method === 'solana' ? 'SOL' : ''}
|
||||
{selected?.method === 'lightning_zap'
|
||||
? 'sats'
|
||||
: selected?.method === 'ethereum'
|
||||
? 'ETH'
|
||||
: selected?.method === 'solana'
|
||||
? 'SOL'
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{quickAmounts.map(a => (
|
||||
{quickAmounts.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
onClick={() => setAmount(a)}
|
||||
className={
|
||||
'rounded-lg px-3 py-1 text-xs font-medium transition-colors border ' +
|
||||
(amount === a ? 'border-degen-500 bg-degen-500/20 text-degen-400' : 'border-gray-800 text-gray-500 hover:border-gray-700')
|
||||
}
|
||||
className={`rounded-lg px-3 py-1 text-xs font-medium transition-colors border ${
|
||||
amount === a
|
||||
? 'border-degen-500 bg-degen-500/20 text-degen-400'
|
||||
: 'border-gray-800 text-gray-500 hover:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
|
|
@ -231,27 +317,38 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
|
||||
{selectedMethod === 'ethereum' && !account && (
|
||||
<div className="mb-4 rounded-lg border border-yellow-900/50 bg-yellow-900/10 p-3">
|
||||
<p className="text-xs text-yellow-400">Connect your Ethereum wallet (MetaMask, Rabby, Rainbow) to send this tip.</p>
|
||||
<p className="text-xs text-yellow-400">
|
||||
Connect your Ethereum wallet (MetaMask, Rabby, Rainbow) to send this tip.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedMethod === 'solana' && !solanaAccount && (
|
||||
<div className="mb-4 rounded-lg border border-purple-900/50 bg-purple-900/10 p-3">
|
||||
<p className="text-xs text-purple-400">Connect your Solana wallet (Phantom, Solflare, Backpack) to send this tip.</p>
|
||||
<p className="text-xs text-purple-400">
|
||||
Connect your Solana wallet (Phantom, Solflare, Backpack) to send this tip.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedMethod || ['creating', 'signing', 'sending'].includes(state.status)}
|
||||
disabled={
|
||||
!selectedMethod || ['creating', 'signing', 'sending'].includes(state.status)
|
||||
}
|
||||
onClick={handleSend}
|
||||
className="btn-primary w-full text-sm disabled:opacity-50"
|
||||
>
|
||||
{state.status === 'signing' ? 'Sign in wallet...' :
|
||||
state.status === 'creating' ? 'Preparing...' :
|
||||
state.status === 'sending' ? 'Sending...' :
|
||||
state.status === 'sent' ? '✓ Sent!' :
|
||||
state.status === 'failed' ? 'Failed - Try Again' :
|
||||
'Send Tip'}
|
||||
{state.status === 'signing'
|
||||
? 'Sign in wallet...'
|
||||
: state.status === 'creating'
|
||||
? 'Preparing...'
|
||||
: state.status === 'sending'
|
||||
? 'Sending...'
|
||||
: state.status === 'sent'
|
||||
? '✓ Sent!'
|
||||
: state.status === 'failed'
|
||||
? 'Failed - Try Again'
|
||||
: 'Send Tip'}
|
||||
</button>
|
||||
|
||||
{state.status === 'failed' && state.error && (
|
||||
|
|
@ -264,19 +361,21 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
<>
|
||||
<p className="mb-2 text-xs text-green-400">Lightning Invoice Ready</p>
|
||||
<div className="mb-2 flex justify-center">
|
||||
{typeof window !== "undefined" && (
|
||||
{typeof window !== 'undefined' && (
|
||||
<div className="rounded-xl bg-white p-2">
|
||||
<QRCodeSVG value={"lightning:" + state.txHash} size={160} />
|
||||
<QRCodeSVG value={`lightning:${state.txHash}`} size={160} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-2 text-center text-[10px] text-gray-500">Scan with any Lightning wallet</p>
|
||||
<p className="mb-2 text-center text-[10px] text-gray-500">
|
||||
Scan with any Lightning wallet
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<code className="flex-1 rounded bg-gray-900 px-2 py-1 text-[10px] text-gray-400 font-mono truncate">
|
||||
{state.txHash.slice(0, 40)}...
|
||||
</code>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(state.txHash || "")}
|
||||
onClick={() => navigator.clipboard.writeText(state.txHash || '')}
|
||||
className="shrink-0 rounded-lg bg-gray-800 px-2 py-1 text-[10px] text-gray-400 hover:text-white transition-colors"
|
||||
type="button"
|
||||
>
|
||||
|
|
@ -284,7 +383,7 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
</button>
|
||||
</div>
|
||||
<a
|
||||
href={"lightning:" + state.txHash}
|
||||
href={`lightning:${state.txHash}`}
|
||||
className="mt-2 block text-center text-xs text-degen-500 hover:text-degen-400"
|
||||
>
|
||||
Open in wallet
|
||||
|
|
@ -298,10 +397,10 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
selectedMethod === 'ethereum'
|
||||
? `https://etherscan.io/tx/${state.txHash}`
|
||||
: selectedMethod === 'solana'
|
||||
? `https://solscan.io/tx/${state.txHash}`
|
||||
: selectedMethod === 'lens_collect'
|
||||
? `https://polygonscan.com/tx/${state.txHash}`
|
||||
: '#'
|
||||
? `https://solscan.io/tx/${state.txHash}`
|
||||
: selectedMethod === 'lens_collect'
|
||||
? `https://polygonscan.com/tx/${state.txHash}`
|
||||
: '#'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
|
@ -318,9 +417,11 @@ export function TipModal({ open, onClose, recipientProtocol, recipientAuthorId,
|
|||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 px-4 py-3">
|
||||
<p className="text-center text-[10px] text-gray-600">Powered by DegenFeed • Cross-platform tipping • No platform fees</p>
|
||||
<p className="text-center text-[10px] text-gray-600">
|
||||
Powered by DegenFeed • Cross-platform tipping • No platform fees
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
|
||||
interface EIP1193Provider {
|
||||
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;
|
||||
on: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
removeListener: (event: string, handler: (...args: unknown[]) => void) => void;
|
||||
selectedAddress?: string;
|
||||
isMetaMask?: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ethereum?: EIP1193Provider;
|
||||
}
|
||||
}
|
||||
|
||||
interface Web3ContextValue {
|
||||
account: string | null;
|
||||
|
|
@ -16,9 +30,13 @@ const DEFAULT_CONTEXT: Web3ContextValue = {
|
|||
account: null,
|
||||
chainId: null,
|
||||
isConnected: false,
|
||||
connect: async () => { throw new Error('Web3 not available'); },
|
||||
connect: async () => {
|
||||
throw new Error('Web3 not available');
|
||||
},
|
||||
disconnect: () => {},
|
||||
signMessage: async () => { throw new Error('Web3 not available'); },
|
||||
signMessage: async () => {
|
||||
throw new Error('Web3 not available');
|
||||
},
|
||||
isMetaMask: false,
|
||||
};
|
||||
|
||||
|
|
@ -28,15 +46,15 @@ export function useWeb3(): Web3ContextValue {
|
|||
return useContext(Web3Context);
|
||||
}
|
||||
|
||||
function getProvider() {
|
||||
if (typeof window === 'undefined' || !(window as any).ethereum) return null;
|
||||
return (window as any).ethereum;
|
||||
function getProvider(): EIP1193Provider | null {
|
||||
if (typeof window === 'undefined' || !window.ethereum) return null;
|
||||
return window.ethereum;
|
||||
}
|
||||
|
||||
export function Web3Provider({ children }: { children: ReactNode }) {
|
||||
const [account, setAccount] = useState<string | null>(null);
|
||||
const [chainId, setChainId] = useState<number | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [_mounted, setMounted] = useState(false);
|
||||
|
||||
const isConnected = !!account;
|
||||
|
||||
|
|
@ -46,21 +64,24 @@ export function Web3Provider({ children }: { children: ReactNode }) {
|
|||
const provider = getProvider();
|
||||
if (!provider) return;
|
||||
try {
|
||||
const accounts = await provider.request({ method: 'eth_accounts' });
|
||||
const accounts = (await provider.request({ method: 'eth_accounts' })) as string[];
|
||||
if (accounts?.[0]) {
|
||||
setAccount(accounts[0]);
|
||||
const id = await provider.request({ method: 'eth_chainId' });
|
||||
setChainId(parseInt(id, 16));
|
||||
setChainId(Number.parseInt(id as string, 16));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
check();
|
||||
|
||||
const handleAccountsChanged = (accounts: string[]) => {
|
||||
setAccount(accounts[0] || null);
|
||||
const handleAccountsChanged = (accounts: unknown) => {
|
||||
const addrs = Array.isArray(accounts) ? (accounts as string[]) : [];
|
||||
setAccount(addrs[0] || null);
|
||||
};
|
||||
const handleChainChanged = (chainIdHex: string) => {
|
||||
setChainId(parseInt(chainIdHex, 16));
|
||||
const handleChainChanged = (chainIdHex: unknown) => {
|
||||
setChainId(Number.parseInt(String(chainIdHex), 16));
|
||||
};
|
||||
|
||||
const provider = getProvider();
|
||||
|
|
@ -78,14 +99,14 @@ export function Web3Provider({ children }: { children: ReactNode }) {
|
|||
const provider = getProvider();
|
||||
if (!provider) throw new Error('No Ethereum wallet detected');
|
||||
try {
|
||||
const accounts = await provider.request({ method: 'eth_requestAccounts' });
|
||||
const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[];
|
||||
const selected = accounts?.[0] || provider.selectedAddress;
|
||||
if (selected) setAccount(selected);
|
||||
const id = await provider.request({ method: 'eth_chainId' });
|
||||
setChainId(parseInt(id, 16));
|
||||
setChainId(Number.parseInt(id as string, 16));
|
||||
return selected || '';
|
||||
} catch (err: any) {
|
||||
throw new Error(err.message || 'User rejected connection');
|
||||
} catch (err) {
|
||||
throw new Error(err instanceof Error ? err.message : 'User rejected connection');
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -94,15 +115,18 @@ export function Web3Provider({ children }: { children: ReactNode }) {
|
|||
setChainId(null);
|
||||
}, []);
|
||||
|
||||
const signMessage = useCallback(async (message: string): Promise<string> => {
|
||||
const provider = getProvider();
|
||||
if (!provider || !account) throw new Error('No wallet connected');
|
||||
const signature = await provider.request({
|
||||
method: 'personal_sign',
|
||||
params: [message, account],
|
||||
});
|
||||
return signature;
|
||||
}, [account]);
|
||||
const signMessage = useCallback(
|
||||
async (message: string): Promise<string> => {
|
||||
const provider = getProvider();
|
||||
if (!provider || !account) throw new Error('No wallet connected');
|
||||
const signature = (await provider.request({
|
||||
method: 'personal_sign',
|
||||
params: [message, account],
|
||||
})) as string;
|
||||
return signature;
|
||||
},
|
||||
[account],
|
||||
);
|
||||
|
||||
const value: Web3ContextValue = {
|
||||
account,
|
||||
|
|
@ -111,12 +135,8 @@ export function Web3Provider({ children }: { children: ReactNode }) {
|
|||
connect,
|
||||
disconnect,
|
||||
signMessage,
|
||||
isMetaMask: typeof window !== 'undefined' && !!(window as any)?.ethereum?.isMetaMask,
|
||||
isMetaMask: typeof window !== 'undefined' && !!window.ethereum?.isMetaMask,
|
||||
};
|
||||
|
||||
return (
|
||||
<Web3Context.Provider value={value}>
|
||||
{children}
|
||||
</Web3Context.Provider>
|
||||
);
|
||||
return <Web3Context.Provider value={value}>{children}</Web3Context.Provider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface TipMethod {
|
||||
method: string;
|
||||
|
|
@ -56,72 +56,100 @@ export function useTip() {
|
|||
|
||||
/** Resolve available tipping methods for a recipient. */
|
||||
const resolveMethods = useCallback(async (protocol: string, authorId: string) => {
|
||||
setState(s => ({ ...s, status: 'resolving' }));
|
||||
setState((s) => ({ ...s, status: 'resolving' }));
|
||||
try {
|
||||
const res = await fetch(`/api/tip/resolve?protocol=${encodeURIComponent(protocol)}&authorId=${encodeURIComponent(authorId)}`);
|
||||
const data = await res.json();
|
||||
const res = await fetch(
|
||||
`/api/tip/resolve?protocol=${encodeURIComponent(protocol)}&authorId=${encodeURIComponent(authorId)}`,
|
||||
);
|
||||
const data = (await res.json()) as { methods?: TipMethod[] };
|
||||
const methods: TipMethod[] = data.methods || [];
|
||||
|
||||
setState(s => ({
|
||||
setState((s) => ({
|
||||
...s,
|
||||
methods,
|
||||
selectedMethod: methods.find(m => m.available)?.method || null,
|
||||
selectedMethod: methods.find((m) => m.available)?.method || null,
|
||||
status: 'idle',
|
||||
}));
|
||||
|
||||
return methods;
|
||||
} catch (err) {
|
||||
setState(s => ({ ...s, status: 'failed', error: String(err) }));
|
||||
setState((s) => ({ ...s, status: 'failed', error: String(err) }));
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Create a tip intent on the server. The caller must handle signing. */
|
||||
const createTip = useCallback(async (
|
||||
recipient: string,
|
||||
recipientProtocol: string,
|
||||
method: string,
|
||||
amount: string,
|
||||
publicationId?: string,
|
||||
): Promise<TipIntent | null> => {
|
||||
setState(s => ({ ...s, status: 'creating', selectedMethod: method, amount }));
|
||||
const createTip = useCallback(
|
||||
async (
|
||||
recipient: string,
|
||||
recipientProtocol: string,
|
||||
method: string,
|
||||
amount: string,
|
||||
publicationId?: string,
|
||||
): Promise<TipIntent | null> => {
|
||||
setState((s) => ({ ...s, status: 'creating', selectedMethod: method, amount }));
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tip/create', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ recipient, recipientProtocol, method, amount, publicationId }),
|
||||
});
|
||||
const intent: TipIntent = await res.json();
|
||||
if (!res.ok) throw new Error((intent as any).error || 'Failed to create tip');
|
||||
setState(s => ({ ...s, status: 'idle' }));
|
||||
return intent;
|
||||
} catch (err: any) {
|
||||
setState(s => ({ ...s, status: 'failed', error: err.message || String(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
try {
|
||||
const res = await fetch('/api/tip/create', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ recipient, recipientProtocol, method, amount, publicationId }),
|
||||
});
|
||||
const intent: TipIntent = await res.json();
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({ error: 'Failed to create tip' }))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(err.error || 'Failed to create tip');
|
||||
}
|
||||
setState((s) => ({ ...s, status: 'idle' }));
|
||||
return intent;
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
status: 'failed',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetch a Lightning zap invoice for a Nostr recipient.
|
||||
* Amount is expected in BTC (e.g. 0.00001).
|
||||
*/
|
||||
const fetchLightningInvoice = useCallback(async (pubkey: string, amount: string, comment?: string) => {
|
||||
try {
|
||||
const params = new URLSearchParams({ pubkey, amount, comment: comment || 'Tip from DegenFeed' });
|
||||
const res = await fetch('/api/tip/lightning/invoice?' + params.toString());
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to get invoice');
|
||||
return data as { invoice: string; amount: number; expiresAt: number };
|
||||
} catch (err: any) {
|
||||
setState(s => ({ ...s, status: 'failed', error: err.message || String(err) }));
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
const fetchLightningInvoice = useCallback(
|
||||
async (pubkey: string, amount: string, comment?: string) => {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
pubkey,
|
||||
amount,
|
||||
comment: comment || 'Tip from DegenFeed',
|
||||
});
|
||||
const res = await fetch(`/api/tip/lightning/invoice?${params.toString()}`);
|
||||
const data = (await res.json()) as { methods?: TipMethod[]; error?: string };
|
||||
if (!res.ok) throw new Error(data.error || 'Failed to get invoice');
|
||||
return data as { invoice: string; amount: number; expiresAt: number };
|
||||
} catch (err) {
|
||||
setState((s) => ({
|
||||
...s,
|
||||
status: 'failed',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setTipStatus = useCallback((status: TipState['status'], txHash?: string, error?: string) => {
|
||||
setState(s => ({ ...s, status, txHash, error }));
|
||||
}, []);
|
||||
const setTipStatus = useCallback(
|
||||
(status: TipState['status'], txHash?: string, error?: string) => {
|
||||
setState((s) => ({ ...s, status, txHash, error }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return { state, reset, resolveMethods, createTip, fetchLightningInvoice, setTipStatus };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { buildSiweMessage, requestWalletNonce, verifyWalletSignature } from '@degenfeed/auth';
|
||||
import type { WalletSession } from '@degenfeed/auth';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useWeb3 } from '../components/Web3Provider';
|
||||
import { requestWalletNonce, buildSiweMessage, verifyWalletSignature } from '@degenfeed/auth';
|
||||
import type { WalletSession } from '@degenfeed/auth';
|
||||
|
||||
export function useWalletSignIn() {
|
||||
const { account, isConnected, connect, signMessage } = useWeb3();
|
||||
|
|
@ -11,11 +11,12 @@ export function useWalletSignIn() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const signInWithEthereum = useCallback(async (): Promise<WalletSession | null> => {
|
||||
if (!account) {
|
||||
let address = account?.toLowerCase();
|
||||
if (!address) {
|
||||
try {
|
||||
await connect();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Wallet connection failed');
|
||||
address = (await connect()).toLowerCase();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Wallet connection failed');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -24,26 +25,20 @@ export function useWalletSignIn() {
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const nonce = await requestWalletNonce(account!.toLowerCase());
|
||||
const message = buildSiweMessage(account!.toLowerCase(), nonce);
|
||||
const nonce = await requestWalletNonce(address);
|
||||
const message = buildSiweMessage(address, nonce);
|
||||
const signature = await signMessage(message);
|
||||
|
||||
const session = await verifyWalletSignature(
|
||||
account!.toLowerCase(),
|
||||
signature,
|
||||
message,
|
||||
'ethereum',
|
||||
nonce,
|
||||
);
|
||||
const session = await verifyWalletSignature(address, signature, message, 'ethereum', nonce);
|
||||
|
||||
return session;
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Ethereum sign-in failed');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Ethereum sign-in failed');
|
||||
return null;
|
||||
} finally {
|
||||
setSigningIn(false);
|
||||
}
|
||||
}, [account, isConnected, connect, signMessage]);
|
||||
}, [account, connect, signMessage]);
|
||||
|
||||
return {
|
||||
address: account,
|
||||
|
|
|
|||
|
|
@ -1,28 +1,3 @@
|
|||
/**
|
||||
* @degenfeed/auth
|
||||
*
|
||||
* All sign-in methods. Each method returns a StoredIdentity that the
|
||||
* caller persists via @degenfeed/storage. The auth layer is responsible
|
||||
* for encryption-at-rest (AES-GCM with PBKDF2 key derivation).
|
||||
*
|
||||
* Threat model summary:
|
||||
* - Keys never leave the browser in plaintext.
|
||||
* - The user chooses a passphrase; we never see it.
|
||||
* - The passphrase is held in memory only.
|
||||
* - Encrypted blobs go to IndexedDB via @degenfeed/storage.
|
||||
* - The "active session" is also memory-only.
|
||||
*/
|
||||
|
||||
import {
|
||||
type StoredIdentity,
|
||||
clearActiveSession,
|
||||
deleteIdentity,
|
||||
getIdentity,
|
||||
saveIdentity,
|
||||
setActiveSession,
|
||||
} from '@degenfeed/storage';
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
|
||||
// ─── Encryption layer ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
@ -101,4 +76,3 @@ function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
|
|||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
// ─── @degenfeed/auth — barrel exports ──────────────────────────
|
||||
export { signInWithNostrNsec, signInWithNostrExtension, signInWithNostrRemoteSigner } from './providers/nostr';
|
||||
export {
|
||||
signInWithNostrNsec,
|
||||
signInWithNostrExtension,
|
||||
signInWithNostrRemoteSigner,
|
||||
} from './providers/nostr';
|
||||
export { signInWithFarcaster, signInWithFarcasterSigner } from './providers/farcaster';
|
||||
export { signInWithLens } from './providers/lens';
|
||||
export { signInWithBluesky } from './providers/bluesky';
|
||||
|
|
@ -7,7 +11,12 @@ export { signInWithThreads } from './providers/threads';
|
|||
export { signInWithGtS } from './providers/mastodon';
|
||||
export { unlockIdentity, lockIdentity, forgetIdentity } from './session';
|
||||
export { encryptSecret, decryptSecret } from './crypto';
|
||||
export { requestWalletNonce, buildSiweMessage, buildSolanaMessage, verifyWalletSignature } from './providers/wallet';
|
||||
export {
|
||||
requestWalletNonce,
|
||||
buildSiweMessage,
|
||||
buildSolanaMessage,
|
||||
verifyWalletSignature,
|
||||
} from './providers/wallet';
|
||||
export type { WalletSession } from './providers/wallet';
|
||||
export type { EncryptedBlob } from './crypto';
|
||||
export type { StoredIdentity, ActiveSession } from '@degenfeed/storage';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Protocol } from '@degenfeed/types';
|
||||
import type { StoredIdentity } from '@degenfeed/storage';
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
import { encryptSecret } from './crypto';
|
||||
|
||||
export async function buildIdentityRecord(args: {
|
||||
|
|
@ -24,7 +24,7 @@ export async function buildIdentityRecord(args: {
|
|||
iv = blob.iv;
|
||||
}
|
||||
return {
|
||||
id: args.protocol + ':' + args.publicId,
|
||||
id: `${args.protocol}:${args.publicId}`,
|
||||
protocol: args.protocol,
|
||||
publicId: args.publicId,
|
||||
handle: args.handle,
|
||||
|
|
|
|||
|
|
@ -13,16 +13,8 @@
|
|||
* - The "active session" is also memory-only.
|
||||
*/
|
||||
|
||||
import { type StoredIdentity, saveIdentity } from '@degenfeed/storage';
|
||||
import { buildIdentityRecord } from '../persistence';
|
||||
import {
|
||||
type StoredIdentity,
|
||||
clearActiveSession,
|
||||
deleteIdentity,
|
||||
getIdentity,
|
||||
saveIdentity,
|
||||
setActiveSession,
|
||||
} from '@degenfeed/storage';
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
|
||||
// ─── 4. Bluesky sign-in ─────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -87,4 +79,3 @@ export async function signInWithBluesky(
|
|||
await saveIdentity(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,8 @@
|
|||
* - The "active session" is also memory-only.
|
||||
*/
|
||||
|
||||
import { type StoredIdentity, saveIdentity } from '@degenfeed/storage';
|
||||
import { buildIdentityRecord } from '../persistence';
|
||||
import {
|
||||
type StoredIdentity,
|
||||
clearActiveSession,
|
||||
deleteIdentity,
|
||||
getIdentity,
|
||||
saveIdentity,
|
||||
setActiveSession,
|
||||
} from '@degenfeed/storage';
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
|
||||
// ─── 2. Farcaster sign-in ───────────────────────────────────────────────
|
||||
|
||||
|
|
@ -91,4 +83,3 @@ export async function signInWithFarcasterSigner(
|
|||
await saveIdentity(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,8 @@
|
|||
* - The "active session" is also memory-only.
|
||||
*/
|
||||
|
||||
import { type StoredIdentity, saveIdentity } from '@degenfeed/storage';
|
||||
import { buildIdentityRecord } from '../persistence';
|
||||
import {
|
||||
type StoredIdentity,
|
||||
clearActiveSession,
|
||||
deleteIdentity,
|
||||
getIdentity,
|
||||
saveIdentity,
|
||||
setActiveSession,
|
||||
} from '@degenfeed/storage';
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
|
||||
// ─── 3. Lens sign-in ────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -132,4 +124,3 @@ export async function signInWithLens(opts: {
|
|||
await saveIdentity(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,8 @@
|
|||
* - The "active session" is also memory-only.
|
||||
*/
|
||||
|
||||
import { type StoredIdentity, saveIdentity } from '@degenfeed/storage';
|
||||
import { buildIdentityRecord } from '../persistence';
|
||||
import {
|
||||
type StoredIdentity,
|
||||
clearActiveSession,
|
||||
deleteIdentity,
|
||||
getIdentity,
|
||||
saveIdentity,
|
||||
setActiveSession,
|
||||
} from '@degenfeed/storage';
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
|
||||
// ─── 6. GotoSocial / ActivityPub sign-in ───────────────────────────────
|
||||
|
||||
|
|
@ -52,7 +44,7 @@ export async function signInWithGtS(
|
|||
publicId: account.id,
|
||||
handle: account.acct.includes('@')
|
||||
? account.acct
|
||||
: account.acct + '@' + new URL(instanceUrl).hostname,
|
||||
: `${account.acct}@${new URL(instanceUrl).hostname}`,
|
||||
displayName: account.display_name || account.username,
|
||||
avatarUrl: account.avatar,
|
||||
secret: accessToken,
|
||||
|
|
@ -61,4 +53,3 @@ export async function signInWithGtS(
|
|||
await saveIdentity(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,8 @@
|
|||
* - The "active session" is also memory-only.
|
||||
*/
|
||||
|
||||
import { type StoredIdentity, saveIdentity } from '@degenfeed/storage';
|
||||
import { buildIdentityRecord } from '../persistence';
|
||||
import {
|
||||
type StoredIdentity,
|
||||
clearActiveSession,
|
||||
deleteIdentity,
|
||||
getIdentity,
|
||||
saveIdentity,
|
||||
setActiveSession,
|
||||
} from '@degenfeed/storage';
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
|
||||
// ─── 1. Nostr sign-in ───────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -114,4 +106,3 @@ export async function signInWithNostrRemoteSigner(opts: {
|
|||
await saveIdentity(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,16 +13,8 @@
|
|||
* - The "active session" is also memory-only.
|
||||
*/
|
||||
|
||||
import { type StoredIdentity, saveIdentity } from '@degenfeed/storage';
|
||||
import { buildIdentityRecord } from '../persistence';
|
||||
import {
|
||||
type StoredIdentity,
|
||||
clearActiveSession,
|
||||
deleteIdentity,
|
||||
getIdentity,
|
||||
saveIdentity,
|
||||
setActiveSession,
|
||||
} from '@degenfeed/storage';
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
|
||||
// ─── 5. Threads sign-in ──────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -87,4 +79,3 @@ export async function signInWithThreads(
|
|||
await saveIdentity(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,8 @@
|
|||
/**
|
||||
* @degenfeed/auth — Web3 Wallet Authentication
|
||||
*
|
||||
* Sign in with Ethereum (SIWE) or Solana wallet.
|
||||
* Uses the Worker's /api/auth/wallet/* endpoints for nonce exchange and verification.
|
||||
*/
|
||||
|
||||
import type { StoredIdentity } from '@degenfeed/storage';
|
||||
|
||||
export interface WalletSession {
|
||||
address: string;
|
||||
chain: 'ethereum' | 'solana';
|
||||
accessToken: string;
|
||||
profile?: Record<string, any> | null;
|
||||
profile?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,15 +5,20 @@
|
|||
* Uses AES-GCM encrypted blobs stored via @degenfeed/storage.
|
||||
*/
|
||||
|
||||
import {
|
||||
clearActiveSession,
|
||||
deleteIdentity,
|
||||
getIdentity,
|
||||
setActiveSession,
|
||||
} from '@degenfeed/storage';
|
||||
import { decryptSecret } from './crypto';
|
||||
import { getIdentity, deleteIdentity, setActiveSession, clearActiveSession } from '@degenfeed/storage';
|
||||
|
||||
export async function unlockIdentity(identityId: string, passphrase: string): Promise<void> {
|
||||
const record = await getIdentity(identityId);
|
||||
if (!record) throw new Error('Identity not found: ' + identityId);
|
||||
if (!record) throw new Error(`Identity not found: ${identityId}`);
|
||||
|
||||
const secret = await decryptSecret(
|
||||
{ ciphertext: record.encryptedSecret ?? "", salt: record.salt, iv: record.iv },
|
||||
{ ciphertext: record.encryptedSecret ?? '', salt: record.salt, iv: record.iv },
|
||||
passphrase,
|
||||
);
|
||||
const parsed = JSON.parse(secret) as Record<string, string | undefined>;
|
||||
|
|
@ -21,7 +26,7 @@ export async function unlockIdentity(identityId: string, passphrase: string): Pr
|
|||
const session: Record<string, unknown> = {
|
||||
identityId: record.id,
|
||||
protocol: record.protocol,
|
||||
publicId: record.publicId ?? "",
|
||||
publicId: record.publicId ?? '',
|
||||
handle: record.handle,
|
||||
displayName: record.displayName,
|
||||
avatarUrl: record.avatarUrl,
|
||||
|
|
|
|||
|
|
@ -33,11 +33,11 @@ export type { Protocol, UnifiedIdentity, UnifiedPost };
|
|||
|
||||
// ─── Placeholder hub URL ────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_HUB_URL = "https://hub.farcaster.standardcrypto.vc:2281";
|
||||
export const DEFAULT_HUB_URL = 'https://hub.farcaster.standardcrypto.vc:2281';
|
||||
export const FALLBACK_HUB_URLS = [
|
||||
DEFAULT_HUB_URL,
|
||||
"https://hub-grpc.pinata.cloud",
|
||||
"https://hoyt.farcaster.standardcrypto.vc:2281",
|
||||
'https://hub-grpc.pinata.cloud',
|
||||
'https://hoyt.farcaster.standardcrypto.vc:2281',
|
||||
];
|
||||
|
||||
// ─── Farcaster-specific types ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -63,7 +63,15 @@ export function emptyIdentity(): UnifiedIdentity {
|
|||
return {
|
||||
id: '',
|
||||
primary: { protocol: 'nostr', id: '' },
|
||||
handles: { nostr: null, farcaster: null, lens: null, bluesky: null, threads: null, rss: null, mastodon: null },
|
||||
handles: {
|
||||
nostr: null,
|
||||
farcaster: null,
|
||||
lens: null,
|
||||
bluesky: null,
|
||||
threads: null,
|
||||
rss: null,
|
||||
mastodon: null,
|
||||
},
|
||||
displayName: '',
|
||||
bio: '',
|
||||
avatarUrl: '',
|
||||
|
|
|
|||
|
|
@ -184,20 +184,11 @@ async function resolveLensHandle(
|
|||
}
|
||||
|
||||
async function resolveThreadsHandle(
|
||||
handle: string,
|
||||
fetchFn: typeof fetch,
|
||||
_handle: string,
|
||||
_fetchFn: typeof fetch,
|
||||
): Promise<HandleResolution | null> {
|
||||
try {
|
||||
const url =
|
||||
'https://public.threads.net/xrpc/com.atproto.identity.resolveHandle?handle=' +
|
||||
encodeURIComponent(handle);
|
||||
const res = await fetchFn(url);
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as { did: string };
|
||||
return { protocol: 'threads', id: json.did, handle: 'threads/' + handle };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// TODO: Threads resolution not yet wired in resolveHandle dispatcher.
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveBlueskyHandle(
|
||||
|
|
@ -229,12 +220,11 @@ export async function resolveFediverseHandle(
|
|||
// Format: user@instance.com
|
||||
const atIndex = handle.indexOf('@');
|
||||
if (atIndex <= 0 || atIndex === handle.length - 1) return null;
|
||||
const username = handle.slice(0, atIndex);
|
||||
const _username = handle.slice(0, atIndex);
|
||||
const domain = handle.slice(atIndex + 1);
|
||||
|
||||
try {
|
||||
const webfingerUrl =
|
||||
'https://' + domain + '/.well-known/webfinger?resource=acct:' + encodeURIComponent(handle);
|
||||
const webfingerUrl = `https://${domain}/.well-known/webfinger?resource=acct:${encodeURIComponent(handle)}`;
|
||||
const res = await fetchFn(webfingerUrl);
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as { links?: { rel: string; href: string; type?: string }[] };
|
||||
|
|
|
|||
|
|
@ -29,11 +29,8 @@ import type { Protocol, UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
|
|||
|
||||
export type { Protocol, UnifiedIdentity, UnifiedPost };
|
||||
|
||||
export const LENS_API_URL = "https://api.lens.xyz/graphql";
|
||||
export const FALLBACK_LENS_URLS = [
|
||||
LENS_API_URL,
|
||||
"https://api-v2.lens.dev/graphql",
|
||||
];
|
||||
export const LENS_API_URL = 'https://api.lens.xyz/graphql';
|
||||
export const FALLBACK_LENS_URLS = [LENS_API_URL, 'https://api-v2.lens.dev/graphql'];
|
||||
|
||||
export interface LensPublication {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ export class GtSClient {
|
|||
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
|
||||
}
|
||||
const headers: Record<string, string> = { accept: 'application/json' };
|
||||
if (this.accessToken) headers.authorization = 'Bearer ' + this.accessToken;
|
||||
if (this.accessToken) headers.authorization = `Bearer ${this.accessToken}`;
|
||||
if (body && method !== 'GET') headers['content-type'] = 'application/json';
|
||||
const res = await fetch(url.toString(), {
|
||||
method,
|
||||
|
|
@ -176,11 +176,7 @@ export class GtSClient {
|
|||
if (!res.ok) {
|
||||
const errText = await res.text().catch(() => '');
|
||||
throw new Error(
|
||||
'GtS API error: ' +
|
||||
res.status +
|
||||
' ' +
|
||||
res.statusText +
|
||||
(errText ? ' - ' + errText.slice(0, 200) : ''),
|
||||
`GtS API error: ${res.status} ${res.statusText}${errText ? ` - ${errText.slice(0, 200)}` : ''}`,
|
||||
);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
|
|
@ -219,7 +215,7 @@ export class GtSClient {
|
|||
|
||||
/** Get a hashtag timeline. */
|
||||
async getHashtagTimeline(hashtag: string, limit = 20): Promise<GtSStatus[]> {
|
||||
return this.get<GtSStatus[]>('/api/v1/timelines/tag/' + encodeURIComponent(hashtag), { limit });
|
||||
return this.get<GtSStatus[]>(`/api/v1/timelines/tag/${encodeURIComponent(hashtag)}`, { limit });
|
||||
}
|
||||
|
||||
// ─── Account endpoints ─────────────────────────────────────────────
|
||||
|
|
@ -229,7 +225,7 @@ export class GtSClient {
|
|||
}
|
||||
|
||||
async getAccount(accountId: string): Promise<GtSAccount> {
|
||||
return this.get<GtSAccount>('/api/v1/accounts/' + encodeURIComponent(accountId));
|
||||
return this.get<GtSAccount>(`/api/v1/accounts/${encodeURIComponent(accountId)}`);
|
||||
}
|
||||
|
||||
async lookupAccount(acct: string): Promise<GtSAccount> {
|
||||
|
|
@ -246,7 +242,7 @@ export class GtSClient {
|
|||
if (excludeReplies) params.exclude_replies = 'true';
|
||||
if (maxId) params.max_id = maxId;
|
||||
return this.get<GtSStatus[]>(
|
||||
'/api/v1/accounts/' + encodeURIComponent(accountId) + '/statuses',
|
||||
`/api/v1/accounts/${encodeURIComponent(accountId)}/statuses`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
|
@ -254,14 +250,14 @@ export class GtSClient {
|
|||
// ─── Status endpoints ──────────────────────────────────────────────
|
||||
|
||||
async getStatus(statusId: string): Promise<GtSStatus> {
|
||||
return this.get<GtSStatus>('/api/v1/statuses/' + encodeURIComponent(statusId));
|
||||
return this.get<GtSStatus>(`/api/v1/statuses/${encodeURIComponent(statusId)}`);
|
||||
}
|
||||
|
||||
async getStatusContext(
|
||||
statusId: string,
|
||||
): Promise<{ ancestors: GtSStatus[]; descendants: GtSStatus[] }> {
|
||||
return this.get<{ ancestors: GtSStatus[]; descendants: GtSStatus[] }>(
|
||||
'/api/v1/statuses/' + encodeURIComponent(statusId) + '/context',
|
||||
`/api/v1/statuses/${encodeURIComponent(statusId)}/context`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -287,23 +283,23 @@ export class GtSClient {
|
|||
}
|
||||
|
||||
async like(statusId: string): Promise<GtSStatus> {
|
||||
return this.post<GtSStatus>('/api/v1/statuses/' + encodeURIComponent(statusId) + '/favourite');
|
||||
return this.post<GtSStatus>(`/api/v1/statuses/${encodeURIComponent(statusId)}/favourite`);
|
||||
}
|
||||
|
||||
async repost(statusId: string): Promise<GtSStatus> {
|
||||
return this.post<GtSStatus>('/api/v1/statuses/' + encodeURIComponent(statusId) + '/reblog');
|
||||
return this.post<GtSStatus>(`/api/v1/statuses/${encodeURIComponent(statusId)}/reblog`);
|
||||
}
|
||||
|
||||
async bookmark(statusId: string): Promise<GtSStatus> {
|
||||
return this.post<GtSStatus>('/api/v1/statuses/' + encodeURIComponent(statusId) + '/bookmark');
|
||||
return this.post<GtSStatus>(`/api/v1/statuses/${encodeURIComponent(statusId)}/bookmark`);
|
||||
}
|
||||
|
||||
async follow(accountId: string): Promise<GtSAccount> {
|
||||
return this.post<GtSAccount>('/api/v1/accounts/' + encodeURIComponent(accountId) + '/follow');
|
||||
return this.post<GtSAccount>(`/api/v1/accounts/${encodeURIComponent(accountId)}/follow`);
|
||||
}
|
||||
|
||||
async unfollow(accountId: string): Promise<GtSAccount> {
|
||||
return this.post<GtSAccount>('/api/v1/accounts/' + encodeURIComponent(accountId) + '/unfollow');
|
||||
return this.post<GtSAccount>(`/api/v1/accounts/${encodeURIComponent(accountId)}/unfollow`);
|
||||
}
|
||||
|
||||
// ─── Search ────────────────────────────────────────────────────────
|
||||
|
|
@ -365,11 +361,11 @@ export function statusToUnifiedPost(
|
|||
});
|
||||
}
|
||||
|
||||
const finalText = original.spoiler_text ? '[CW: ' + original.spoiler_text + ']\n\n' + text : text;
|
||||
const finalText = original.spoiler_text ? `[CW: ${original.spoiler_text}]\n\n${text}` : text;
|
||||
|
||||
const niches = detectNiches(finalText + ' ' + original.tags.map((t) => t.name).join(' '));
|
||||
const _niches = detectNiches(`${finalText} ${original.tags.map((t) => t.name).join(' ')}`);
|
||||
|
||||
const nativeId = isBoost ? 'boost:' + status.id + ':' + original.id : original.id;
|
||||
const nativeId = isBoost ? `boost:${status.id}:${original.id}` : original.id;
|
||||
|
||||
return buildUnifiedPost({
|
||||
protocol: 'mastodon',
|
||||
|
|
@ -400,10 +396,10 @@ export function accountToIdentity(
|
|||
existing?: UnifiedIdentity,
|
||||
): UnifiedIdentity {
|
||||
const hostname = new URL(instanceUrl).hostname;
|
||||
const handle = account.acct.includes('@') ? account.acct : account.acct + '@' + hostname;
|
||||
const handle = account.acct.includes('@') ? account.acct : `${account.acct}@${hostname}`;
|
||||
|
||||
const identity: UnifiedIdentity = {
|
||||
id: existing?.id ?? 'mastodon:' + account.id + '@' + hostname,
|
||||
id: existing?.id ?? `mastodon:${account.id}@${hostname}`,
|
||||
primary: existing?.primary ?? { protocol: 'mastodon', id: account.id },
|
||||
handles: {
|
||||
nostr: null,
|
||||
|
|
|
|||
|
|
@ -59,14 +59,14 @@ describe('@degenfeed/rss-sdk', () => {
|
|||
const posts = feedToUnifiedPosts(mockSource);
|
||||
expect(posts).toHaveLength(1);
|
||||
|
||||
const post = posts[0]!;
|
||||
expect(post.protocol).toBe('rss');
|
||||
expect(post.text).toContain('Bitcoin');
|
||||
expect(post.id).toContain('rss:reddit:');
|
||||
expect(post.author.displayName).toBe('satoshi');
|
||||
expect(post.niches).toContain('crypto');
|
||||
expect(post.embeds.length).toBeGreaterThanOrEqual(1); // link embed
|
||||
expect(post.createdAt).toBe(new Date('2026-07-04T10:00:00.000Z').getTime());
|
||||
const post = posts[0];
|
||||
expect(post?.protocol).toBe('rss');
|
||||
expect(post?.text).toContain('Bitcoin');
|
||||
expect(post?.id).toContain('rss:reddit:');
|
||||
expect(post?.author.displayName).toBe('satoshi');
|
||||
expect(post?.niches).toContain('crypto');
|
||||
expect(post?.embeds.length).toBeGreaterThanOrEqual(1); // link embed
|
||||
expect(post?.createdAt).toBe(new Date('2026-07-04T10:00:00.000Z').getTime());
|
||||
});
|
||||
|
||||
it('should handle feeds with no items', async () => {
|
||||
|
|
@ -104,9 +104,9 @@ describe('@degenfeed/rss-sdk', () => {
|
|||
expect(posts).toHaveLength(1);
|
||||
|
||||
// Should have image embed + link embed
|
||||
const imageEmbeds = posts[0]!.embeds.filter((e) => e.kind === 'image');
|
||||
expect(imageEmbeds.length).toBeGreaterThanOrEqual(1);
|
||||
expect(imageEmbeds[0]?.url).toBe('https://mirror.xyz/image.png');
|
||||
const imageEmbeds = posts[0]?.embeds?.filter((e) => e.kind === 'image');
|
||||
expect(imageEmbeds?.length).toBeGreaterThanOrEqual(1);
|
||||
expect(imageEmbeds?.[0]?.url).toBe('https://mirror.xyz/image.png');
|
||||
});
|
||||
|
||||
it('should handle feeds with content:encoded', async () => {
|
||||
|
|
@ -133,8 +133,8 @@ describe('@degenfeed/rss-sdk', () => {
|
|||
|
||||
const posts = feedToUnifiedPosts(mockSource);
|
||||
expect(posts).toHaveLength(1);
|
||||
expect(posts[0]!.author.displayName).toBe('Crypto Native Author');
|
||||
expect(posts[0]!.niches).toContain('crypto');
|
||||
expect(posts[0]?.author.displayName).toBe('Crypto Native Author');
|
||||
expect(posts[0]?.niches).toContain('crypto');
|
||||
});
|
||||
|
||||
it('should handle dates in various formats', async () => {
|
||||
|
|
@ -156,10 +156,10 @@ describe('@degenfeed/rss-sdk', () => {
|
|||
const posts = feedToUnifiedPosts(mockSource);
|
||||
expect(posts).toHaveLength(3);
|
||||
// No date → falls back to now (reasonable default)
|
||||
expect(posts[0]!.createdAt).toBeGreaterThan(ts - 1000);
|
||||
expect(posts[0]?.createdAt).toBeGreaterThan(ts - 1000);
|
||||
// ISO date
|
||||
expect(posts[1]!.createdAt).toBe(new Date('2026-07-04T12:00:00.000Z').getTime());
|
||||
expect(posts[1]?.createdAt).toBe(new Date('2026-07-04T12:00:00.000Z').getTime());
|
||||
// Invalid date → falls back to now
|
||||
expect(posts[2]!.createdAt).toBeGreaterThan(ts - 1000);
|
||||
expect(posts[2]?.createdAt).toBeGreaterThan(ts - 1000);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export interface FetchOpts {
|
|||
*/
|
||||
export async function fetchFeed(
|
||||
url: string,
|
||||
opts: FetchOpts = {},
|
||||
_opts: FetchOpts = {},
|
||||
): Promise<Parser.Output<Record<string, unknown>>> {
|
||||
const feed = await feedParser.parseURL(url);
|
||||
return feed as unknown as Parser.Output<Record<string, unknown>>;
|
||||
|
|
@ -180,7 +180,7 @@ export interface FeedMeta {
|
|||
/**
|
||||
* Build a stable author identity string from feed metadata.
|
||||
*/
|
||||
function buildAuthorId(meta: FeedMeta, authorName: string, feedLink: string): string {
|
||||
function buildAuthorId(meta: FeedMeta, authorName: string, _feedLink: string): string {
|
||||
const parts = ['rss', meta.source];
|
||||
if (meta.handle) parts.push(meta.handle);
|
||||
if (meta.subreddit) parts.push('r', meta.subreddit);
|
||||
|
|
@ -206,7 +206,7 @@ function buildPostId(meta: FeedMeta, guid: string, link: string, title: string):
|
|||
function parseDate(dateStr: string | undefined): number {
|
||||
if (!dateStr) return Date.now();
|
||||
const parsed = new Date(dateStr).getTime();
|
||||
return isNaN(parsed) ? Date.now() : parsed;
|
||||
return Number.isNaN(parsed) ? Date.now() : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -349,9 +349,10 @@ function stripHtml(html: string): string {
|
|||
function extractImageUrls(html: string): string[] {
|
||||
const urls: string[] = [];
|
||||
const imgRe = /<img[^>]+src\s*=\s*["']([^"']+)["']/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = imgRe.exec(html)) !== null) {
|
||||
let match: RegExpExecArray | null = imgRe.exec(html);
|
||||
while (match !== null) {
|
||||
if (match[1]) urls.push(match[1]);
|
||||
match = imgRe.exec(html);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,13 @@
|
|||
/**
|
||||
* @degenfeed/storage
|
||||
*
|
||||
* Typed IndexedDB wrapper for all client-side persistence.
|
||||
* Uses `idb` for a clean promise-based API without raw IDB transactions.
|
||||
*
|
||||
* Stores:
|
||||
* identities — Encrypted identity records (protocol keys)
|
||||
* follows — Follow graph per identity
|
||||
* drafts — Composer drafts (per-protocol, per-identity)
|
||||
* cached-posts — LRU cache of recently fetched posts
|
||||
* muted — Muted identities and words
|
||||
* prefs — User preferences (ranking weights, theme, etc.)
|
||||
*
|
||||
* Encryption is handled by @degenfeed/auth; this package only persists
|
||||
* the pre-encrypted blobs and decryptable fields.
|
||||
*/
|
||||
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
import { type IDBPDatabase, openDB } from 'idb';
|
||||
import type { IDBPDatabase } from 'idb';
|
||||
|
||||
// ─── Cached Posts ──────────────────────────────────────────────────────
|
||||
|
||||
import { getDb } from './db';
|
||||
/**
|
||||
* Save a post to the cache. Replaces if exists.
|
||||
* Cache has a max size (LRU eviction).
|
||||
*/
|
||||
import { StoredCachedPost } from './types';
|
||||
import { getDb } from './db';
|
||||
import type { StoredCachedPost } from './types';
|
||||
export async function cachePost(postId: string, data: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
const entry: StoredCachedPost = {
|
||||
|
|
@ -62,4 +43,3 @@ export async function clearCache(): Promise<void> {
|
|||
const db = await getDb();
|
||||
await db.clear('cached-posts');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,22 +1,3 @@
|
|||
/**
|
||||
* @degenfeed/storage
|
||||
*
|
||||
* Typed IndexedDB wrapper for all client-side persistence.
|
||||
* Uses `idb` for a clean promise-based API without raw IDB transactions.
|
||||
*
|
||||
* Stores:
|
||||
* identities — Encrypted identity records (protocol keys)
|
||||
* follows — Follow graph per identity
|
||||
* drafts — Composer drafts (per-protocol, per-identity)
|
||||
* cached-posts — LRU cache of recently fetched posts
|
||||
* muted — Muted identities and words
|
||||
* prefs — User preferences (ranking weights, theme, etc.)
|
||||
*
|
||||
* Encryption is handled by @degenfeed/auth; this package only persists
|
||||
* the pre-encrypted blobs and decryptable fields.
|
||||
*/
|
||||
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
import { type IDBPDatabase, openDB } from 'idb';
|
||||
|
||||
// ─── Database ──────────────────────────────────────────────────────────
|
||||
|
|
@ -86,4 +67,3 @@ export async function getDb(): Promise<IDBPDatabase<unknown>> {
|
|||
}
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,9 @@
|
|||
/**
|
||||
* @degenfeed/storage
|
||||
*
|
||||
* Typed IndexedDB wrapper for all client-side persistence.
|
||||
* Uses `idb` for a clean promise-based API without raw IDB transactions.
|
||||
*
|
||||
* Stores:
|
||||
* identities — Encrypted identity records (protocol keys)
|
||||
* follows — Follow graph per identity
|
||||
* drafts — Composer drafts (per-protocol, per-identity)
|
||||
* cached-posts — LRU cache of recently fetched posts
|
||||
* muted — Muted identities and words
|
||||
* prefs — User preferences (ranking weights, theme, etc.)
|
||||
*
|
||||
* Encryption is handled by @degenfeed/auth; this package only persists
|
||||
* the pre-encrypted blobs and decryptable fields.
|
||||
*/
|
||||
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
import { type IDBPDatabase, openDB } from 'idb';
|
||||
import type { IDBPDatabase } from 'idb';
|
||||
|
||||
// ─── Drafts CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
import { StoredDraft } from './types';
|
||||
import { getDb } from './db';
|
||||
import type { StoredDraft } from './types';
|
||||
export async function saveDraft(draft: StoredDraft): Promise<void> {
|
||||
draft.updatedAt = Date.now();
|
||||
const db = await getDb();
|
||||
|
|
@ -41,4 +22,3 @@ export async function getDrafts(identityId: string): Promise<StoredDraft[]> {
|
|||
.store.index('identityId');
|
||||
return (await index.getAll(identityId)) as StoredDraft[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,9 @@
|
|||
/**
|
||||
* @degenfeed/storage
|
||||
*
|
||||
* Typed IndexedDB wrapper for all client-side persistence.
|
||||
* Uses `idb` for a clean promise-based API without raw IDB transactions.
|
||||
*
|
||||
* Stores:
|
||||
* identities — Encrypted identity records (protocol keys)
|
||||
* follows — Follow graph per identity
|
||||
* drafts — Composer drafts (per-protocol, per-identity)
|
||||
* cached-posts — LRU cache of recently fetched posts
|
||||
* muted — Muted identities and words
|
||||
* prefs — User preferences (ranking weights, theme, etc.)
|
||||
*
|
||||
* Encryption is handled by @degenfeed/auth; this package only persists
|
||||
* the pre-encrypted blobs and decryptable fields.
|
||||
*/
|
||||
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
import { type IDBPDatabase, openDB } from 'idb';
|
||||
import type { IDBPDatabase } from 'idb';
|
||||
|
||||
// ─── Follows CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
import { StoredFollow } from './types';
|
||||
import { getDb } from './db';
|
||||
import type { StoredFollow } from './types';
|
||||
export async function saveFollow(follow: StoredFollow): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('follows', follow);
|
||||
|
|
@ -46,4 +27,3 @@ export async function isFollowing(identityId: string, targetId: string): Promise
|
|||
const result = await db.get('follows', [identityId, targetId]);
|
||||
return result !== undefined;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { Protocol } from '@degenfeed/types';
|
||||
import { StoredIdentity } from './types';
|
||||
import { getDb } from './db';
|
||||
import type { StoredIdentity } from './types';
|
||||
|
||||
export async function saveIdentity(identity: StoredIdentity): Promise<void> {
|
||||
const db = await getDb();
|
||||
|
|
@ -34,8 +34,8 @@ export async function setPrimaryIdentity(id: string): Promise<void> {
|
|||
const all = await tx.store.getAll();
|
||||
await Promise.all(
|
||||
all.map((identity: StoredIdentity) =>
|
||||
tx.store.put({ ...identity, isPrimary: identity.id === id })
|
||||
)
|
||||
tx.store.put({ ...identity, isPrimary: identity.id === id }),
|
||||
),
|
||||
);
|
||||
await tx.done;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,39 @@
|
|||
// ─── @degenfeed/storage — barrel exports ──────────────────────────
|
||||
export type { StoredIdentity, StoredFollow, StoredDraft, StoredCachedPost, StoredMutedIdentity, StoredMutedWord, UserPreferences } from './types';
|
||||
export type {
|
||||
StoredIdentity,
|
||||
StoredFollow,
|
||||
StoredDraft,
|
||||
StoredCachedPost,
|
||||
StoredMutedIdentity,
|
||||
StoredMutedWord,
|
||||
UserPreferences,
|
||||
} from './types';
|
||||
export type { ActiveSession } from './session';
|
||||
export { setActiveSession, getActiveSession, clearActiveSession, clearAllSessions } from './session';
|
||||
export {
|
||||
setActiveSession,
|
||||
getActiveSession,
|
||||
clearActiveSession,
|
||||
clearAllSessions,
|
||||
} from './session';
|
||||
export { getDb } from './db';
|
||||
export { saveIdentity, getIdentity, getAllIdentities, getIdentitiesByProtocol, getPrimaryIdentity, setPrimaryIdentity, deleteIdentity } from './identities';
|
||||
export {
|
||||
saveIdentity,
|
||||
getIdentity,
|
||||
getAllIdentities,
|
||||
getIdentitiesByProtocol,
|
||||
getPrimaryIdentity,
|
||||
setPrimaryIdentity,
|
||||
deleteIdentity,
|
||||
} from './identities';
|
||||
export { saveFollow, deleteFollow, getFollows, isFollowing } from './follows';
|
||||
export { saveDraft, deleteDraft, getDrafts } from './drafts';
|
||||
export { cachePost, getCachedPost, clearCache } from './cache';
|
||||
export { muteIdentity, unmuteIdentity, getMutedIdentities, muteWord, unmuteWord, getMutedWords } from './muting';
|
||||
export {
|
||||
muteIdentity,
|
||||
unmuteIdentity,
|
||||
getMutedIdentities,
|
||||
muteWord,
|
||||
unmuteWord,
|
||||
getMutedWords,
|
||||
} from './muting';
|
||||
export { getPreferences, savePreferences, getDefaultPreferences } from './preferences';
|
||||
|
|
|
|||
|
|
@ -1,28 +1,9 @@
|
|||
/**
|
||||
* @degenfeed/storage
|
||||
*
|
||||
* Typed IndexedDB wrapper for all client-side persistence.
|
||||
* Uses `idb` for a clean promise-based API without raw IDB transactions.
|
||||
*
|
||||
* Stores:
|
||||
* identities — Encrypted identity records (protocol keys)
|
||||
* follows — Follow graph per identity
|
||||
* drafts — Composer drafts (per-protocol, per-identity)
|
||||
* cached-posts — LRU cache of recently fetched posts
|
||||
* muted — Muted identities and words
|
||||
* prefs — User preferences (ranking weights, theme, etc.)
|
||||
*
|
||||
* Encryption is handled by @degenfeed/auth; this package only persists
|
||||
* the pre-encrypted blobs and decryptable fields.
|
||||
*/
|
||||
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
import { type IDBPDatabase, openDB } from 'idb';
|
||||
import type { IDBPDatabase } from 'idb';
|
||||
|
||||
// ─── Muting ────────────────────────────────────────────────────────────
|
||||
|
||||
import { StoredMutedIdentity, StoredMutedWord } from './types';
|
||||
import { getDb } from './db';
|
||||
import type { StoredMutedIdentity, StoredMutedWord } from './types';
|
||||
export async function muteIdentity(
|
||||
targetIdentityId: string,
|
||||
targetHandle: string,
|
||||
|
|
@ -85,4 +66,3 @@ export async function getMutedWords(mutedByIdentityId: string): Promise<StoredMu
|
|||
.store.index('mutedBy');
|
||||
return (await index.getAll(mutedByIdentityId)) as StoredMutedWord[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,29 +1,14 @@
|
|||
/**
|
||||
* @degenfeed/storage
|
||||
*
|
||||
* Typed IndexedDB wrapper for all client-side persistence.
|
||||
* Uses `idb` for a clean promise-based API without raw IDB transactions.
|
||||
*
|
||||
* Stores:
|
||||
* identities — Encrypted identity records (protocol keys)
|
||||
* follows — Follow graph per identity
|
||||
* drafts — Composer drafts (per-protocol, per-identity)
|
||||
* cached-posts — LRU cache of recently fetched posts
|
||||
* muted — Muted identities and words
|
||||
* prefs — User preferences (ranking weights, theme, etc.)
|
||||
*
|
||||
* Encryption is handled by @degenfeed/auth; this package only persists
|
||||
* the pre-encrypted blobs and decryptable fields.
|
||||
*/
|
||||
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
import { type IDBPDatabase, openDB } from 'idb';
|
||||
|
||||
// ─── Preferences ───────────────────────────────────────────────────────
|
||||
|
||||
import { StoredIdentity, StoredFollow, UserPreferences, StoredMutedIdentity, StoredMutedWord } from './types';
|
||||
import { getDb, dbPromise as _dbPromise } from './db';
|
||||
import { getDb } from './db';
|
||||
import { clearAllSessions } from './session';
|
||||
import type {
|
||||
StoredFollow,
|
||||
StoredIdentity,
|
||||
StoredMutedIdentity,
|
||||
StoredMutedWord,
|
||||
UserPreferences,
|
||||
} from './types';
|
||||
export async function savePreferences(prefs: UserPreferences): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('prefs', prefs);
|
||||
|
|
@ -106,5 +91,5 @@ export async function wipeAll(): Promise<void> {
|
|||
* Close the database connection. Call on app unmount.
|
||||
*/
|
||||
export function closeDb(): void {
|
||||
const dbPromise = null;
|
||||
const _dbPromise = null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,6 @@
|
|||
/**
|
||||
* @degenfeed/storage
|
||||
*
|
||||
* Typed IndexedDB wrapper for all client-side persistence.
|
||||
* Uses `idb` for a clean promise-based API without raw IDB transactions.
|
||||
*
|
||||
* Stores:
|
||||
* identities — Encrypted identity records (protocol keys)
|
||||
* follows — Follow graph per identity
|
||||
* drafts — Composer drafts (per-protocol, per-identity)
|
||||
* cached-posts — LRU cache of recently fetched posts
|
||||
* muted — Muted identities and words
|
||||
* prefs — User preferences (ranking weights, theme, etc.)
|
||||
*
|
||||
* Encryption is handled by @degenfeed/auth; this package only persists
|
||||
* the pre-encrypted blobs and decryptable fields.
|
||||
*/
|
||||
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
import { type IDBPDatabase, openDB } from 'idb';
|
||||
|
||||
// ─── Active Session (in-memory only) ───────────────────────────────────
|
||||
|
||||
import { StoredIdentity } from './types';
|
||||
import type { StoredIdentity } from './types';
|
||||
export interface ActiveSession {
|
||||
identity: StoredIdentity;
|
||||
/** Decrypted key material. NEVER written to disk. */
|
||||
|
|
@ -46,4 +25,3 @@ export function clearActiveSession(identityId: string): void {
|
|||
export function clearAllSessions(): void {
|
||||
activeSessions.clear();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
*/
|
||||
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
import { type IDBPDatabase, openDB } from 'idb';
|
||||
|
||||
// ─── Schema Types ──────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -97,4 +96,3 @@ export interface UserPreferences {
|
|||
reducedMotion: boolean;
|
||||
fontSize: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ describe('@degenfeed/threads-sdk', () => {
|
|||
});
|
||||
|
||||
it('should normalize post to UnifiedPost', async () => {
|
||||
const { ThreadsClient, THREADS_PUBLIC_URL } = await import('./index');
|
||||
const { ThreadsClient } = await import('./index');
|
||||
const client = new ThreadsClient();
|
||||
const mockPost = {
|
||||
uri: 'at://did:plc:test/app.bsky.feed.post/abc123',
|
||||
|
|
|
|||
|
|
@ -107,11 +107,11 @@ export class ThreadsClient {
|
|||
/** Get a single post by AT URI. */
|
||||
async getPost(uri: string): Promise<ThreadsPostView> {
|
||||
const baseUrl = this.bsky instanceof BskyClient ? THREADS_PUBLIC_URL : THREADS_PUBLIC_URL;
|
||||
const url = new URL(baseUrl + '/xrpc/app.bsky.feed.getPostThread');
|
||||
const url = new URL(`${baseUrl}/xrpc/app.bsky.feed.getPostThread`);
|
||||
url.searchParams.set('uri', uri);
|
||||
url.searchParams.set('depth', '0');
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error('Threads API error: ' + res.status);
|
||||
if (!res.ok) throw new Error(`Threads API error: ${res.status}`);
|
||||
const json = (await res.json()) as { thread: { post: ThreadsPostView } };
|
||||
return json.thread.post;
|
||||
}
|
||||
|
|
@ -119,10 +119,10 @@ export class ThreadsClient {
|
|||
/** Resolve a handle to its AT Protocol DID. */
|
||||
async resolveHandle(handle: string): Promise<string> {
|
||||
const baseUrl = this.bsky instanceof BskyClient ? THREADS_PUBLIC_URL : THREADS_PUBLIC_URL;
|
||||
const url = new URL(baseUrl + '/xrpc/com.atproto.identity.resolveHandle');
|
||||
const url = new URL(`${baseUrl}/xrpc/com.atproto.identity.resolveHandle`);
|
||||
url.searchParams.set('handle', handle);
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error('Failed to resolve Threads handle: ' + res.status);
|
||||
if (!res.ok) throw new Error(`Failed to resolve Threads handle: ${res.status}`);
|
||||
const json = (await res.json()) as { did: string };
|
||||
return json.did;
|
||||
}
|
||||
|
|
@ -140,10 +140,10 @@ export class ThreadsClient {
|
|||
postsCount?: number;
|
||||
}> {
|
||||
const baseUrl = this.bsky instanceof BskyClient ? THREADS_PUBLIC_URL : THREADS_PUBLIC_URL;
|
||||
const url = new URL(baseUrl + '/xrpc/app.bsky.actor.getProfile');
|
||||
const url = new URL(`${baseUrl}/xrpc/app.bsky.actor.getProfile`);
|
||||
url.searchParams.set('actor', did);
|
||||
const res = await fetch(url.toString());
|
||||
if (!res.ok) throw new Error('Threads profile error: ' + res.status);
|
||||
if (!res.ok) throw new Error(`Threads profile error: ${res.status}`);
|
||||
return res.json() as Promise<{
|
||||
did: string;
|
||||
handle: string;
|
||||
|
|
@ -177,7 +177,7 @@ export class ThreadsClient {
|
|||
const imageEmbeds = post.record.embed?.images
|
||||
? post.record.embed.images.map((img) => ({
|
||||
kind: 'image' as const,
|
||||
url: 'https://threads.net' + img.image.ref,
|
||||
url: `https://threads.net${img.image.ref}`,
|
||||
}))
|
||||
: [];
|
||||
|
||||
|
|
|
|||
|
|
@ -18,10 +18,9 @@
|
|||
* Signed in (1+ protocols) → toggles show available protocols
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { getProtocolColor } from './ProtocolBadge';
|
||||
|
||||
|
||||
interface ComposeTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
@ -48,7 +47,10 @@ export interface ComposeModalProps {
|
|||
/** Map of protocol IDs the user is signed into. */
|
||||
signedInProtocols: Record<string, boolean>;
|
||||
/** Called when the user publishes. Returns per-protocol results. */
|
||||
onPublish: (text: string, targets: string[]) => Promise<Record<string, { ok: boolean; error?: string }>>;
|
||||
onPublish: (
|
||||
text: string,
|
||||
targets: string[],
|
||||
) => Promise<Record<string, { ok: boolean; error?: string }>>;
|
||||
}
|
||||
|
||||
const DRAFT_KEY = 'degenfeed_compose_draft';
|
||||
|
|
@ -62,7 +64,7 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
enabled: !!signedInProtocols[t.id],
|
||||
connected: !!signedInProtocols[t.id],
|
||||
status: 'idle' as const,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const publishingRef = useRef(false);
|
||||
|
|
@ -75,7 +77,9 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
try {
|
||||
const saved = localStorage.getItem(DRAFT_KEY);
|
||||
if (saved) setText(saved);
|
||||
} catch { /* noop */ }
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
setTimeout(() => textareaRef.current?.focus(), 100);
|
||||
}, [open]);
|
||||
|
||||
|
|
@ -83,7 +87,11 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setTimeout(() => {
|
||||
try { localStorage.setItem(DRAFT_KEY, text); } catch { /* noop */ }
|
||||
try {
|
||||
localStorage.setItem(DRAFT_KEY, text);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [text, open]);
|
||||
|
|
@ -94,7 +102,7 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
prev.map((t) => ({
|
||||
...t,
|
||||
connected: !!signedInProtocols[t.id],
|
||||
}))
|
||||
})),
|
||||
);
|
||||
}, [signedInProtocols]);
|
||||
|
||||
|
|
@ -106,18 +114,21 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
setShowSlashMenu(lastLine.startsWith('/') && lastLine.length > 1 && !lastLine.includes(' '));
|
||||
}, []);
|
||||
|
||||
const insertSlashCommand = useCallback((cmd: string) => {
|
||||
const lines = text.split('\n');
|
||||
const lastLineIdx = lines.length - 1;
|
||||
const beforeSlash = text.slice(0, text.length - (lines[lastLineIdx]?.length ?? 0));
|
||||
setText(beforeSlash + cmd + ' ');
|
||||
setShowSlashMenu(false);
|
||||
textareaRef.current?.focus();
|
||||
}, [text]);
|
||||
const insertSlashCommand = useCallback(
|
||||
(cmd: string) => {
|
||||
const lines = text.split('\n');
|
||||
const lastLineIdx = lines.length - 1;
|
||||
const beforeSlash = text.slice(0, text.length - (lines[lastLineIdx]?.length ?? 0));
|
||||
setText(`${beforeSlash + cmd} `);
|
||||
setShowSlashMenu(false);
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
[text],
|
||||
);
|
||||
|
||||
const toggleTarget = useCallback((id: string) => {
|
||||
setTargets((prev) =>
|
||||
prev.map((t) => (t.id === id && t.connected ? { ...t, enabled: !t.enabled } : t))
|
||||
prev.map((t) => (t.id === id && t.connected ? { ...t, enabled: !t.enabled } : t)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
|
|
@ -141,18 +152,24 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
status: res?.ok ? ('sent' as const) : ('failed' as const),
|
||||
error: res?.error,
|
||||
};
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const allOk = selectedTargets.every((id) => results[id]?.ok);
|
||||
if (allOk) {
|
||||
// Clear draft on success
|
||||
try { localStorage.removeItem(DRAFT_KEY); } catch { /* noop */ }
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_KEY);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
setTimeout(() => onClose(), 1500);
|
||||
}
|
||||
} catch {
|
||||
setTargets((prev) =>
|
||||
prev.map((t) => (t.enabled ? { ...t, status: 'failed' as const, error: 'Network error' } : t))
|
||||
prev.map((t) =>
|
||||
t.enabled ? { ...t, status: 'failed' as const, error: 'Network error' } : t,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
publishingRef.current = false;
|
||||
|
|
@ -180,33 +197,46 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
const hasSignedIn = Object.values(signedInProtocols).some(Boolean);
|
||||
|
||||
return (
|
||||
<div
|
||||
<section
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 backdrop-blur-sm sm:items-center"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
onKeyDown={(e) => e.key === 'Escape' && onClose()}
|
||||
aria-modal="true"
|
||||
aria-label="Compose post"
|
||||
>
|
||||
<div
|
||||
className="flex w-full max-w-lg flex-col rounded-t-2xl border border-gray-800 bg-gray-950 shadow-2xl sm:rounded-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
style={{ maxHeight: '90vh' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-gray-800 px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-white">Compose</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
Ctrl+Enter to post
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">Ctrl+Enter to post</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Close icon"
|
||||
>
|
||||
<title>Close</title>
|
||||
<path
|
||||
d="M12 4L4 12M4 4l8 8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -230,7 +260,7 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
handleTextChange(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = el.scrollHeight + 'px';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}}
|
||||
placeholder="What's happening across the open social graph?"
|
||||
className="w-full resize-none bg-transparent text-base text-white placeholder-gray-600 outline-none"
|
||||
|
|
@ -247,7 +277,7 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
key={cmd}
|
||||
type="button"
|
||||
className="w-full rounded px-3 py-1.5 text-left text-sm text-gray-300 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
onClick={() => insertSlashCommand('/' + cmd)}
|
||||
onClick={() => insertSlashCommand(`/${cmd}`)}
|
||||
>
|
||||
/{cmd}
|
||||
</button>
|
||||
|
|
@ -268,20 +298,21 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
type="button"
|
||||
disabled={!target.connected}
|
||||
onClick={() => toggleTarget(target.id)}
|
||||
className={
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all border ' +
|
||||
(target.enabled
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all border ${
|
||||
target.enabled
|
||||
? 'border-current opacity-100'
|
||||
: 'border-gray-800 opacity-40 hover:opacity-70')
|
||||
}
|
||||
: 'border-gray-800 opacity-40 hover:opacity-70'
|
||||
}`}
|
||||
style={{
|
||||
color: target.connected ? getProtocolColor(target.id) : '#666',
|
||||
backgroundColor: target.enabled ? getProtocolColor(target.id) + '15' : 'transparent',
|
||||
backgroundColor: target.enabled
|
||||
? `${getProtocolColor(target.id)}15`
|
||||
: 'transparent',
|
||||
}}
|
||||
title={
|
||||
!target.connected
|
||||
? 'Not connected — sign in with ' + target.label
|
||||
: target.label + ' (' + text.length + '/' + target.charLimit + ')'
|
||||
? `Not connected — sign in with ${target.label}`
|
||||
: `${target.label} (${text.length}/${target.charLimit})`
|
||||
}
|
||||
>
|
||||
{target.status === 'sending' && '\u23F3'}
|
||||
|
|
@ -298,7 +329,13 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
{/* Publish button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={'text-xs ' + (text.length > Math.max(...TARGETS.map(t => t.charLimit)) ? 'text-red-400' : 'text-gray-500')}>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
text.length > Math.max(...TARGETS.map((t) => t.charLimit))
|
||||
? 'text-red-400'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{text.length}/{MAX_CHARS}
|
||||
</span>
|
||||
{targets.some((t) => t.status === 'failed') && (
|
||||
|
|
@ -308,16 +345,19 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
|
|||
<button
|
||||
type="button"
|
||||
className="btn-primary text-sm disabled:opacity-50"
|
||||
disabled={!text.trim() || publishing || selectedCount === 0 || text.length > MAX_CHARS}
|
||||
disabled={
|
||||
!text.trim() || publishing || selectedCount === 0 || text.length > MAX_CHARS
|
||||
}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{publishing ? '\u23F3 Posting...' : 'Post' + (selectedCount > 0 ? ' (' + selectedCount + ')' : '')}
|
||||
{publishing
|
||||
? '\u23F3 Posting...'
|
||||
: `Post${selectedCount > 0 ? ` (${selectedCount})` : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -38,9 +38,15 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|||
<div className="mx-auto max-w-lg px-4 py-20 text-center">
|
||||
<div className="mb-6 text-5xl text-red-400">!</div>
|
||||
<h2 className="mb-2 text-xl font-bold text-white">Something went wrong</h2>
|
||||
<p className="mb-4 text-sm text-gray-400">{this.state.error.message || 'An unexpected error occurred'}</p>
|
||||
<button type="button" className="btn-primary text-sm" onClick={this.handleRetry}>Try again</button>
|
||||
<p className="mt-4 text-xs text-gray-600">If this persists, check the console for details.</p>
|
||||
<p className="mb-4 text-sm text-gray-400">
|
||||
{this.state.error.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button type="button" className="btn-primary text-sm" onClick={this.handleRetry}>
|
||||
Try again
|
||||
</button>
|
||||
<p className="mt-4 text-xs text-gray-600">
|
||||
If this persists, check the console for details.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,9 @@
|
|||
* error → Error message + retry button
|
||||
*/
|
||||
|
||||
import { PostCard } from './PostCard';
|
||||
import { ProtocolBadge } from './ProtocolBadge';
|
||||
import type { UnifiedPost } from '@degenfeed/types';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { PostCard } from './PostCard';
|
||||
|
||||
export type { UnifiedPost };
|
||||
|
||||
|
|
@ -73,7 +72,7 @@ function EmptyState({ protocolLabel }: { protocolLabel?: string }) {
|
|||
<div className="card py-16 text-center">
|
||||
<div className="mb-4 text-5xl opacity-30">{protocolLabel ? '○' : '◆'}</div>
|
||||
<p className="mb-2 text-gray-500">
|
||||
{protocolLabel ? 'No ' + protocolLabel + ' posts yet' : 'No posts yet'}
|
||||
{protocolLabel ? `No ${protocolLabel} posts yet` : 'No posts yet'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
Posts will appear here as they are published across connected networks.
|
||||
|
|
@ -96,8 +95,20 @@ function ErrorState({ message, onRetry }: { message?: string; onRetry?: () => vo
|
|||
);
|
||||
}
|
||||
|
||||
export function FeedList({ state, posts, error, protocolLabel, protocolColor, onTip, onRetry, onPostClick, onEngage, hasMore, onLoadMore }: FeedListProps) {
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
export function FeedList({
|
||||
state,
|
||||
posts,
|
||||
error,
|
||||
protocolLabel,
|
||||
|
||||
onTip,
|
||||
onRetry,
|
||||
onPostClick,
|
||||
onEngage,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
}: FeedListProps) {
|
||||
const sentinelRef = useRef<HTMLLIElement>(null);
|
||||
|
||||
// Infinite scroll observer
|
||||
useEffect(() => {
|
||||
|
|
@ -118,9 +129,11 @@ export function FeedList({ state, posts, error, protocolLabel, protocolColor, on
|
|||
// Loading state
|
||||
if (state === 'loading') {
|
||||
return (
|
||||
<div className="space-y-4" role="status" aria-label="Loading feed">
|
||||
{[1,2,3,4,5].map((i) => <SkeletonCard key={i} />)}
|
||||
</div>
|
||||
<output className="space-y-4" aria-label="Loading feed">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</output>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -136,21 +149,20 @@ export function FeedList({ state, posts, error, protocolLabel, protocolColor, on
|
|||
|
||||
// Loaded state
|
||||
return (
|
||||
<div className="space-y-4" role="feed" aria-label={protocolLabel ? protocolLabel + ' feed' : 'Feed'}>
|
||||
<ul
|
||||
className="space-y-4 list-none"
|
||||
aria-label={protocolLabel ? `${protocolLabel} feed` : 'Feed'}
|
||||
>
|
||||
{posts.map((post) => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
onClick={onPostClick}
|
||||
onTip={onTip}
|
||||
onEngage={onEngage}
|
||||
/>
|
||||
<li key={post.id}>
|
||||
<PostCard post={post} onClick={onPostClick} onTip={onTip} onEngage={onEngage} />
|
||||
</li>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="py-4 text-center text-sm text-gray-600">
|
||||
<li ref={sentinelRef} className="py-4 text-center text-sm text-gray-600">
|
||||
Loading more...
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</div>
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
export interface FundingRound {
|
||||
id: string;
|
||||
|
|
@ -15,16 +15,16 @@ export interface FundingRound {
|
|||
}
|
||||
|
||||
const STAGE_COLORS: Record<string, string> = {
|
||||
"seed": "#8b5cf6",
|
||||
"pre-seed": "#a78bfa",
|
||||
"series a": "#3b82f6",
|
||||
"series b": "#2563eb",
|
||||
"series c": "#1d4ed8",
|
||||
"strategic": "#f59e0b",
|
||||
"private": "#10b981",
|
||||
"public": "#ef4444",
|
||||
"grant": "#6366f1",
|
||||
"angel": "#ec4899",
|
||||
seed: '#8b5cf6',
|
||||
'pre-seed': '#a78bfa',
|
||||
'series a': '#3b82f6',
|
||||
'series b': '#2563eb',
|
||||
'series c': '#1d4ed8',
|
||||
strategic: '#f59e0b',
|
||||
private: '#10b981',
|
||||
public: '#ef4444',
|
||||
grant: '#6366f1',
|
||||
angel: '#ec4899',
|
||||
};
|
||||
|
||||
function getStageColor(stage: string): string {
|
||||
|
|
@ -32,19 +32,25 @@ function getStageColor(stage: string): string {
|
|||
for (const [k, v] of Object.entries(STAGE_COLORS)) {
|
||||
if (key.includes(k)) return v;
|
||||
}
|
||||
return "#6b7280";
|
||||
return '#6b7280';
|
||||
}
|
||||
|
||||
export function FundingCard({ round }: { round: FundingRound }) {
|
||||
const stageColor = getStageColor(round.stage);
|
||||
|
||||
|
||||
return (
|
||||
<div className="card border-l-4 transition-all hover:bg-gray-800/30" style={{ borderLeftColor: stageColor }}>
|
||||
<div
|
||||
className="card border-l-4 transition-all hover:bg-gray-800/30"
|
||||
style={{ borderLeftColor: stageColor }}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="rounded-full px-2 py-0.5 text-xs font-medium" style={{ backgroundColor: stageColor + "22", color: stageColor }}>
|
||||
{round.stage || "Round"}
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{ backgroundColor: `${stageColor}22`, color: stageColor }}
|
||||
>
|
||||
{round.stage || 'Round'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{round.chain || "Multi-chain"}</span>
|
||||
<span className="text-xs text-gray-500">{round.chain || 'Multi-chain'}</span>
|
||||
<span className="ml-auto text-xs font-mono font-bold text-degen-500">{round.amount}</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -52,17 +58,28 @@ export function FundingCard({ round }: { round: FundingRound }) {
|
|||
<p className="mb-3 text-sm text-gray-400 line-clamp-2">{round.description}</p>
|
||||
|
||||
<div className="mb-2 flex flex-wrap gap-1">
|
||||
{round.investors.slice(0, 4).map((inv, i) => (
|
||||
<span key={i} className="rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-300">{inv}</span>
|
||||
{round.investors.slice(0, 4).map((inv) => (
|
||||
<span key={inv} className="rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-300">
|
||||
{inv}
|
||||
</span>
|
||||
))}
|
||||
{round.investors.length > 4 && (
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-500">+{round.investors.length - 4}</span>
|
||||
<span className="rounded-full bg-gray-800 px-2 py-0.5 text-xs text-gray-500">
|
||||
+{round.investors.length - 4}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-600">
|
||||
<span>{new Date(round.announcedAt).toLocaleDateString()}</span>
|
||||
<a href={round.sourceUrl} target="_blank" rel="noopener noreferrer" className="text-degen-500 hover:underline">{round.source}</a>
|
||||
<a
|
||||
href={round.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-degen-500 hover:underline"
|
||||
>
|
||||
{round.source}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
|
|
@ -19,17 +19,19 @@ function timeAgo(ts: number): string {
|
|||
const diff = Date.now() - ts;
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return 'now';
|
||||
if (m < 60) return m + 'm';
|
||||
if (m < 60) return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return h + 'h';
|
||||
if (h < 24) return `${h}h`;
|
||||
const d = Math.floor(h / 24);
|
||||
return d + 'd';
|
||||
return `${d}d`;
|
||||
}
|
||||
|
||||
function getTypeLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
like: 'liked your post', repost: 'reposted your post',
|
||||
reply: 'replied to you', follow: 'followed you',
|
||||
like: 'liked your post',
|
||||
repost: 'reposted your post',
|
||||
reply: 'replied to you',
|
||||
follow: 'followed you',
|
||||
mention: 'mentioned you',
|
||||
};
|
||||
return labels[type] || 'interacted with you';
|
||||
|
|
@ -42,7 +44,12 @@ export interface NotificationBellProps {
|
|||
onMarkRead: (id: string) => void;
|
||||
}
|
||||
|
||||
export function NotificationBell({ notifications, unreadCount, onFetch, onMarkRead }: NotificationBellProps) {
|
||||
export function NotificationBell({
|
||||
notifications,
|
||||
unreadCount,
|
||||
onFetch,
|
||||
onMarkRead,
|
||||
}: NotificationBellProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -68,9 +75,19 @@ export function NotificationBell({ notifications, unreadCount, onFetch, onMarkRe
|
|||
type="button"
|
||||
className="relative rounded-lg p-2 text-gray-500 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
onClick={handleToggle}
|
||||
aria-label={unreadCount > 0 ? unreadCount + ' unread notifications' : 'Notifications'}
|
||||
aria-label={unreadCount > 0 ? `${unreadCount} unread notifications` : 'Notifications'}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
role="img"
|
||||
aria-label="Notifications icon"
|
||||
>
|
||||
<title>Notifications</title>
|
||||
<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 01-3.46 0" />
|
||||
</svg>
|
||||
|
|
@ -91,26 +108,36 @@ export function NotificationBell({ notifications, unreadCount, onFetch, onMarkRe
|
|||
<div className="py-8 text-center text-sm text-gray-500">No notifications yet</div>
|
||||
) : (
|
||||
notifications.slice(0, 30).map((n) => (
|
||||
<div
|
||||
<button
|
||||
key={n.id}
|
||||
className={'flex items-start gap-3 border-b border-gray-800/50 px-4 py-3 transition-colors hover:bg-gray-800/30 cursor-pointer ' + (n.read ? 'opacity-50' : '')}
|
||||
type="button"
|
||||
className={`flex w-full items-start gap-3 border-b border-gray-800/50 px-4 py-3 text-left transition-colors hover:bg-gray-800/30 ${n.read ? 'opacity-50' : ''}`}
|
||||
onClick={() => onMarkRead(n.id)}
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-800 text-xs font-bold"
|
||||
style={{ color: getProtocolColor(n.protocol) }}>
|
||||
{(n.protocol[0] || "").toUpperCase()}
|
||||
<div
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-800 text-xs font-bold"
|
||||
style={{ color: getProtocolColor(n.protocol) }}
|
||||
>
|
||||
{(n.protocol[0] || '').toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-white truncate">{n.actorName}</div>
|
||||
<div className="text-xs text-gray-500">{getTypeLabel(n.type)}</div>
|
||||
{n.postText && <div className="mt-1 text-xs text-gray-600 truncate">{n.postText}</div>}
|
||||
{n.postText && (
|
||||
<div className="mt-1 text-xs text-gray-600 truncate">{n.postText}</div>
|
||||
)}
|
||||
<div className="mt-0.5 text-[10px] text-gray-700">{timeAgo(n.createdAt)}</div>
|
||||
</div>
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{ backgroundColor: getProtocolColor(n.protocol) + '22', color: getProtocolColor(n.protocol) }}>
|
||||
<span
|
||||
className="rounded-full px-1.5 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: `${getProtocolColor(n.protocol)}22`,
|
||||
color: getProtocolColor(n.protocol),
|
||||
}}
|
||||
>
|
||||
{n.protocol}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -121,6 +148,14 @@ export function NotificationBell({ notifications, unreadCount, onFetch, onMarkRe
|
|||
}
|
||||
|
||||
function getProtocolColor(p: string): string {
|
||||
const c: Record<string, string> = { nostr: '#8e30eb', farcaster: '#8a63d2', lens: '#abfe2c', bluesky: '#1185fe', threads: '#888', mastodon: '#6364ff', rss: '#ffa500' };
|
||||
const c: Record<string, string> = {
|
||||
nostr: '#8e30eb',
|
||||
farcaster: '#8a63d2',
|
||||
lens: '#abfe2c',
|
||||
bluesky: '#1185fe',
|
||||
threads: '#888',
|
||||
mastodon: '#6364ff',
|
||||
rss: '#ffa500',
|
||||
};
|
||||
return c[p] ?? '#666';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ProtocolBadge, getProtocolColor } from './ProtocolBadge';
|
||||
import type { UnifiedPost } from '@degenfeed/types';
|
||||
import { useState } from 'react';
|
||||
import { ProtocolBadge, getProtocolColor } from './ProtocolBadge';
|
||||
|
||||
export type { UnifiedPost };
|
||||
|
||||
|
|
@ -19,39 +19,46 @@ function formatTime(ts: number): string {
|
|||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'now';
|
||||
if (mins < 60) return mins + 'm';
|
||||
if (mins < 60) return `${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return hours + 'h';
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return days + 'd';
|
||||
if (days < 7) return `${days}d`;
|
||||
return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export function PostCard({ post, onEngage, onClick, expanded, onTip }: PostCardProps) {
|
||||
const [showFullText, setShowFullText] = useState(expanded ?? false);
|
||||
const needsTruncation = !expanded && post.text.length > MAX_TEXT_LENGTH;
|
||||
const displayText = needsTruncation && !showFullText ? post.text.slice(0, MAX_TEXT_LENGTH) + '...' : post.text;
|
||||
const displayText =
|
||||
needsTruncation && !showFullText ? `${post.text.slice(0, MAX_TEXT_LENGTH)}...` : post.text;
|
||||
|
||||
return (
|
||||
<article
|
||||
className="card cursor-pointer border-l-2 transition-all hover:border-l-4 hover:bg-gray-800/30"
|
||||
style={{ borderLeftColor: getProtocolColor(post.protocol) }}
|
||||
onClick={() => onClick?.(post.id)}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), onClick?.(post.id))}
|
||||
tabIndex={0}
|
||||
role="article"
|
||||
aria-label={'Post by ' + post.author.displayName}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
|
||||
e.preventDefault();
|
||||
onClick(post.id);
|
||||
}
|
||||
}}
|
||||
aria-label={`Post by ${post.author.displayName}`}
|
||||
>
|
||||
{/* Author row */}
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<img
|
||||
src={post.author.avatarUrl || 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E%3Crect width=%2232%22 height=%2232%22 fill=%22%23333%22/%3E%3Ctext x=%2216%22 y=%2222%22 text-anchor=%22middle%22 fill=%22%23999%22 font-size=%2216%22%3E%40%3C/text%3E%3C/svg%3E'}
|
||||
src={
|
||||
post.author.avatarUrl ||
|
||||
'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22%3E%3Crect width=%2232%22 height=%2232%22 fill=%22%23333%22/%3E%3Ctext x=%2216%22 y=%2222%22 text-anchor=%22middle%22 fill=%22%23999%22 font-size=%2216%22%3E%40%3C/text%3E%3C/svg%3E'
|
||||
}
|
||||
alt={post.author.displayName}
|
||||
className="h-8 w-8 rounded-full bg-gray-800 object-cover"
|
||||
loading="lazy"
|
||||
|
|
@ -64,9 +71,14 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip }: PostCardP
|
|||
<ProtocolBadge protocol={post.protocol} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="truncate">{post.author.handles[post.protocol] ?? post.author.id.slice(0, 16)}</span>
|
||||
<span className="truncate">
|
||||
{post.author.handles[post.protocol] ?? post.author.id.slice(0, 16)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<time dateTime={new Date(post.createdAt).toISOString()} title={new Date(post.createdAt).toLocaleString()}>
|
||||
<time
|
||||
dateTime={new Date(post.createdAt).toISOString()}
|
||||
title={new Date(post.createdAt).toLocaleString()}
|
||||
>
|
||||
{formatTime(post.createdAt)}
|
||||
</time>
|
||||
</div>
|
||||
|
|
@ -80,7 +92,10 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip }: PostCardP
|
|||
<button
|
||||
type="button"
|
||||
className="ml-1 text-degen-500 hover:text-degen-400 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); setShowFullText(true); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowFullText(true);
|
||||
}}
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
|
|
@ -90,29 +105,31 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip }: PostCardP
|
|||
{/* Embeds */}
|
||||
{post.embeds.length > 0 && (
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{post.embeds.slice(0, 4).map((embed, i) => (
|
||||
{post.embeds.slice(0, 4).map((embed) =>
|
||||
embed.kind === 'image' ? (
|
||||
<img
|
||||
key={i}
|
||||
key={`img-${embed.url}`}
|
||||
src={embed.url}
|
||||
alt=""
|
||||
className="max-h-48 rounded-lg object-cover"
|
||||
loading="lazy"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : embed.kind === 'link' ? (
|
||||
<a
|
||||
key={i}
|
||||
key={`link-${embed.url}`}
|
||||
href={embed.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full rounded-lg border border-gray-700 bg-gray-800/50 p-2 text-xs text-degen-500 hover:text-degen-400 transition-colors truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{embed.url.replace(/^https?:\/\//, '').slice(0, 60)}
|
||||
</a>
|
||||
) : null
|
||||
))}
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -120,25 +137,34 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip }: PostCardP
|
|||
<div className="flex items-center gap-4 text-xs text-gray-500">
|
||||
<button
|
||||
type="button"
|
||||
className={'flex items-center gap-1 transition-colors hover:text-red-400 ' + (post.engagement.viewerLiked ? 'text-red-400' : '')}
|
||||
onClick={(e) => { e.stopPropagation(); onEngage?.('like', post.id); }}
|
||||
aria-label={post.metrics.likes + ' likes'}
|
||||
className={`flex items-center gap-1 transition-colors hover:text-red-400 ${post.engagement.viewerLiked ? 'text-red-400' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEngage?.('like', post.id);
|
||||
}}
|
||||
aria-label={`${post.metrics.likes} likes`}
|
||||
>
|
||||
{post.engagement.viewerLiked ? '❤' : '♡'} {formatCount(post.metrics.likes)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={'flex items-center gap-1 transition-colors hover:text-green-400 ' + (post.engagement.viewerReposted ? 'text-green-400' : '')}
|
||||
onClick={(e) => { e.stopPropagation(); onEngage?.('repost', post.id); }}
|
||||
aria-label={post.metrics.reposts + ' reposts'}
|
||||
className={`flex items-center gap-1 transition-colors hover:text-green-400 ${post.engagement.viewerReposted ? 'text-green-400' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEngage?.('repost', post.id);
|
||||
}}
|
||||
aria-label={`${post.metrics.reposts} reposts`}
|
||||
>
|
||||
↻ {formatCount(post.metrics.reposts)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 transition-colors hover:text-blue-400"
|
||||
onClick={(e) => { e.stopPropagation(); onEngage?.('reply', post.id); }}
|
||||
aria-label={post.metrics.replies + ' replies'}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEngage?.('reply', post.id);
|
||||
}}
|
||||
aria-label={`${post.metrics.replies} replies`}
|
||||
>
|
||||
↩ {formatCount(post.metrics.replies)}
|
||||
</button>
|
||||
|
|
@ -147,7 +173,10 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip }: PostCardP
|
|||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 transition-colors hover:text-yellow-400 ml-auto"
|
||||
onClick={(e) => { e.stopPropagation(); onTip(post); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTip(post);
|
||||
}}
|
||||
aria-label="Tip this author"
|
||||
title={`Tip ${post.author.displayName}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface PriceData {
|
||||
usd: number;
|
||||
|
|
@ -21,9 +21,9 @@ async function fetchPrices(tickers: string[]): Promise<Record<string, PriceData>
|
|||
return priceCache;
|
||||
}
|
||||
try {
|
||||
const url = "/api/prices?tickers=" + tickers.join(",");
|
||||
const url = `/api/prices?tickers=${tickers.join(',')}`;
|
||||
const res = await fetch(url);
|
||||
const data = await res.json() as { prices?: Record<string, PriceData> };
|
||||
const data = (await res.json()) as { prices?: Record<string, PriceData> };
|
||||
if (data.prices) {
|
||||
priceCache = { ...priceCache, ...data.prices };
|
||||
lastFetch = now;
|
||||
|
|
@ -33,57 +33,66 @@ async function fetchPrices(tickers: string[]): Promise<Record<string, PriceData>
|
|||
}
|
||||
|
||||
// Regex to find $TICKER patterns in text
|
||||
const TICKER_RE = /\$([A-Za-z]{2,10})(?:|[^a-zA-Z])/g;
|
||||
const TICKER_RE = /\$([A-Za-z]{2,10})(?:\b|[^a-zA-Z])/g;
|
||||
|
||||
export function PricePill({ ticker, data }: { ticker: string; data?: PriceData }) {
|
||||
if (!data) return null;
|
||||
const change = data.usd_24h_change;
|
||||
const isUp = change !== undefined && change >= 0;
|
||||
const color = isUp ? "#22c55e" : "#ef4444";
|
||||
const arrow = isUp ? "\u25B2" : "\u25BC";
|
||||
const color = isUp ? '#22c55e' : '#ef4444';
|
||||
const arrow = isUp ? '▲' : '▼';
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-mono cursor-help transition-colors hover:opacity-80"
|
||||
style={{ backgroundColor: color + "15", color }}
|
||||
title={
|
||||
"24h: " + (change !== undefined ? change.toFixed(1) + "%" : "N/A") +
|
||||
"\n7d: " + (data.usd_7d_change !== undefined ? data.usd_7d_change.toFixed(1) + "%" : "N/A") +
|
||||
"\n30d: " + (data.usd_30d_change !== undefined ? data.usd_30d_change.toFixed(1) + "%" : "N/A") +
|
||||
"\nMarket Cap: $" + (data.usd_market_cap ? (data.usd_market_cap / 1e9).toFixed(2) + "B" : "N/A")
|
||||
}
|
||||
style={{ backgroundColor: `${color}15`, color }}
|
||||
title={`24h: ${change !== undefined ? `${change.toFixed(1)}%` : 'N/A'}\n7d: ${data.usd_7d_change !== undefined ? `${data.usd_7d_change.toFixed(1)}%` : 'N/A'}\n30d: ${data.usd_30d_change !== undefined ? `${data.usd_30d_change.toFixed(1)}%` : 'N/A'}\nMarket Cap: $${data.usd_market_cap ? `${(data.usd_market_cap / 1e9).toFixed(2)}B` : 'N/A'}`}
|
||||
>
|
||||
{arrow} ${ticker} ${data.usd > 1 ? "$" + data.usd.toLocaleString() : "$" + data.usd.toFixed(6)}
|
||||
{arrow} ${ticker} $
|
||||
{data.usd > 1 ? `$${data.usd.toLocaleString()}` : `$${data.usd.toFixed(6)}`}
|
||||
{change !== undefined && (
|
||||
<span className="text-[10px]">({change >= 0 ? "+" : ""}{change.toFixed(1)}%)</span>
|
||||
<span className="text-[10px]">
|
||||
({change >= 0 ? '+' : ''}
|
||||
{change.toFixed(1)}%)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Hook to extract tickers from text and fetch prices
|
||||
export function usePricesForText(text: string): { PricePills: () => React.ReactNode; loading: boolean } {
|
||||
export function usePricesForText(text: string): {
|
||||
PricePills: () => React.ReactNode;
|
||||
loading: boolean;
|
||||
} {
|
||||
const [prices, setPrices] = useState<Record<string, PriceData>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const tickers = [...new Set(
|
||||
(text ? Array.from(text.matchAll(TICKER_RE)).filter(m => m[1]) : []).map(m => m[1]!.toUpperCase())
|
||||
)].slice(0, 5); // Max 5 price pills per post
|
||||
const tickers = [
|
||||
...new Set(
|
||||
(text ? Array.from(text.matchAll(TICKER_RE)).filter((m) => m[1]) : [])
|
||||
.map((m) => m[1]?.toUpperCase())
|
||||
.filter((t): t is string => !!t),
|
||||
),
|
||||
].slice(0, 5); // Max 5 price pills per post
|
||||
|
||||
useEffect(() => {
|
||||
if (tickers.length === 0) return;
|
||||
setLoading(true);
|
||||
fetchPrices(tickers).then(data => {
|
||||
fetchPrices(tickers).then((data) => {
|
||||
setPrices(data);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [text]);
|
||||
}, [tickers, tickers.length]);
|
||||
|
||||
const PricePills = () => {
|
||||
if (tickers.length === 0 || Object.keys(prices).length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{tickers.map(t => { const d = prices[t as string]; return d ? <PricePill key={t} ticker={t} data={d} /> : null; })}
|
||||
{tickers.map((t) => {
|
||||
const d = prices[t];
|
||||
return d ? <PricePill key={t} ticker={t} data={d} /> : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -64,8 +64,8 @@ export function ProtocolBadge({ protocol, size = 'sm', showLabel = true }: Proto
|
|||
|
||||
return (
|
||||
<span
|
||||
className={'inline-flex items-center gap-1 rounded-full font-medium ' + sizeClass}
|
||||
style={{ backgroundColor: color + '22', color }}
|
||||
className={`inline-flex items-center gap-1 rounded-full font-medium ${sizeClass}`}
|
||||
style={{ backgroundColor: `${color}22`, color }}
|
||||
title={label}
|
||||
>
|
||||
<span className="text-[0.7em]">{icon}</span>
|
||||
|
|
|
|||
|
|
@ -88,7 +88,11 @@ const PROTOCOLS: ProtocolOption[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function WalletSection({ onSignIn, walletAddress, walletSigningIn }: {
|
||||
function WalletSection({
|
||||
onSignIn,
|
||||
walletAddress,
|
||||
walletSigningIn,
|
||||
}: {
|
||||
onSignIn: (protocol: string) => void;
|
||||
walletAddress?: string;
|
||||
walletSigningIn?: boolean;
|
||||
|
|
@ -131,7 +135,13 @@ function WalletSection({ onSignIn, walletAddress, walletSigningIn }: {
|
|||
);
|
||||
}
|
||||
|
||||
export function SignInModal({ open, onClose, onSignIn, walletAddress, walletSigningIn }: SignInModalProps) {
|
||||
export function SignInModal({
|
||||
open,
|
||||
onClose,
|
||||
onSignIn,
|
||||
walletAddress,
|
||||
walletSigningIn,
|
||||
}: SignInModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const handleProtocolClick = (protocol: string) => {
|
||||
|
|
@ -143,17 +153,18 @@ export function SignInModal({ open, onClose, onSignIn, walletAddress, walletSign
|
|||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<section
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Sign in to DegenFeed"
|
||||
>
|
||||
<div
|
||||
className="mx-4 w-full max-w-lg rounded-2xl border border-gray-800 bg-gray-950 p-6 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -168,25 +179,44 @@ export function SignInModal({ open, onClose, onSignIn, walletAddress, walletSign
|
|||
aria-label="Close"
|
||||
type="button"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<path d="M15 5L5 15M5 5l10 10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Close icon"
|
||||
>
|
||||
<title>Close</title>
|
||||
<path
|
||||
d="M15 5L5 15M5 5l10 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 space-y-2">
|
||||
{/* Social protocols */}
|
||||
<h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">Social Protocols</h3>
|
||||
{PROTOCOLS.filter(p => p.method !== 'wallet').map((proto) => (
|
||||
<h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
Social Protocols
|
||||
</h3>
|
||||
{PROTOCOLS.filter((p) => p.method !== 'wallet').map((proto) => (
|
||||
<button
|
||||
key={proto.id}
|
||||
onClick={() => handleProtocolClick(proto.id)}
|
||||
className="flex w-full items-center gap-4 rounded-xl border border-gray-800 bg-gray-900/50 px-4 py-3 text-left transition-all hover:border-gray-600 hover:bg-gray-800/50 group"
|
||||
type="button"
|
||||
>
|
||||
<span className="text-2xl" style={{ color: proto.color }}>{proto.icon}</span>
|
||||
<span className="text-2xl" style={{ color: proto.color }}>
|
||||
{proto.icon}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-white group-hover:text-degen-400 transition-colors">{proto.label}</div>
|
||||
<div className="text-sm font-semibold text-white group-hover:text-degen-400 transition-colors">
|
||||
{proto.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{proto.description}</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600">→</span>
|
||||
|
|
@ -196,7 +226,11 @@ export function SignInModal({ open, onClose, onSignIn, walletAddress, walletSign
|
|||
|
||||
{/* Wallet section */}
|
||||
<div className="mb-6">
|
||||
<WalletSection onSignIn={(p) => onSignIn(p)} walletAddress={walletAddress} walletSigningIn={walletSigningIn} />
|
||||
<WalletSection
|
||||
onSignIn={(p) => onSignIn(p)}
|
||||
walletAddress={walletAddress}
|
||||
walletSigningIn={walletSigningIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6">
|
||||
|
|
@ -222,9 +256,11 @@ export function SignInModal({ open, onClose, onSignIn, walletAddress, walletSign
|
|||
|
||||
<p className="mt-6 text-center text-xs text-gray-600">
|
||||
No account needed to browse.{' '}
|
||||
<a href="/home" className="text-degen-500 hover:underline">Start reading now</a>
|
||||
<a href="/home" className="text-degen-500 hover:underline">
|
||||
Start reading now
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,15 @@
|
|||
* @degenfeed/ui — component tests
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { PROTOCOL_COLORS, PROTOCOL_LABELS, PROTOCOL_ICONS, getProtocolColor, getProtocolLabel, getProtocolIcon } from './ProtocolBadge';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PROTOCOL_COLORS,
|
||||
PROTOCOL_ICONS,
|
||||
PROTOCOL_LABELS,
|
||||
getProtocolColor,
|
||||
getProtocolIcon,
|
||||
getProtocolLabel,
|
||||
} from './ProtocolBadge';
|
||||
|
||||
describe('ProtocolBadge', () => {
|
||||
it('should have colors for all 7 protocols', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
export { ProtocolBadge, PROTOCOL_COLORS, PROTOCOL_LABELS, PROTOCOL_ICONS, getProtocolColor, getProtocolLabel, getProtocolIcon } from './ProtocolBadge';
|
||||
export {
|
||||
ProtocolBadge,
|
||||
PROTOCOL_COLORS,
|
||||
PROTOCOL_LABELS,
|
||||
PROTOCOL_ICONS,
|
||||
getProtocolColor,
|
||||
getProtocolLabel,
|
||||
getProtocolIcon,
|
||||
} from './ProtocolBadge';
|
||||
export { PostCard } from './PostCard';
|
||||
export { FeedList } from './FeedList';
|
||||
export { ComposeModal } from './ComposeModal';
|
||||
|
|
|
|||
20
workers/api/.dev.vars.example
Normal file
20
workers/api/.dev.vars.example
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# ── DegenFeed API Worker .dev.vars.example ─────────────────────
|
||||
# Copy to .dev.vars and fill in real values for local development.
|
||||
# .dev.vars is NOT committed — it holds secrets only.
|
||||
# For production, use `wrangler secret put <NAME>`.
|
||||
|
||||
# Cloudflare API token used by Wrangler for deploys and local dev auth.
|
||||
# Not used by the worker runtime itself.
|
||||
CLOUDFLARE_API_TOKEN=
|
||||
|
||||
# GotoSocial OAuth2 client secret.
|
||||
# Register an application at your GTS instance to obtain this.
|
||||
GTS_CLIENT_SECRET=
|
||||
|
||||
# ── Cloudflare platform bindings ───────────────────────────────
|
||||
# CACHE is a KV namespace binding used by workers/api/src/routes/news.ts.
|
||||
# It is configured in wrangler.toml, NOT in .dev.vars:
|
||||
# [[kv_namespaces]]
|
||||
# binding = "CACHE"
|
||||
# id = "<your-kv-namespace-id>"
|
||||
# preview_id = "<your-preview-kv-namespace-id>"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@degenfeed/nostr-sdk', () => ({
|
||||
RelayPool: vi.fn().mockImplementation(() => ({
|
||||
|
|
@ -27,14 +27,15 @@ vi.mock('@degenfeed/mastodon-sdk', () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
import { handleTopFeed, handleNewsletterFeed } from '../routes/curated';
|
||||
import type { Env } from '../index';
|
||||
import { handleNewsletterFeed, handleTopFeed } from '../routes/curated';
|
||||
|
||||
describe('handleTopFeed', () => {
|
||||
it('should return top posts with heat scores', async () => {
|
||||
const req = new Request('http://localhost/api/feed/top?limit=10');
|
||||
const res = await handleTopFeed(req, {} as any);
|
||||
const res = await handleTopFeed(req, {} as Env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as any;
|
||||
const body = (await res.json()) as { meta: Record<string, unknown> };
|
||||
expect(body.meta).toBeDefined();
|
||||
expect(body.meta.type).toBe('top');
|
||||
});
|
||||
|
|
@ -43,7 +44,7 @@ describe('handleTopFeed', () => {
|
|||
describe('handleNewsletterFeed', () => {
|
||||
it('should fetch from newsletter sources', async () => {
|
||||
const req = new Request('http://localhost/api/feed/newsletters?limit=5');
|
||||
const res = await handleNewsletterFeed(req, {} as any);
|
||||
const res = await handleNewsletterFeed(req, {} as Env);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock the nostr-sdk before importing
|
||||
vi.mock('@degenfeed/nostr-sdk', () => ({
|
||||
|
|
@ -12,7 +12,9 @@ vi.mock('@degenfeed/nostr-sdk', () => ({
|
|||
vi.mock('@degenfeed/farcaster-sdk', () => ({
|
||||
HubClient: vi.fn().mockImplementation(() => ({
|
||||
getCastsByFid: vi.fn().mockResolvedValue([]),
|
||||
castToUnifiedPost: vi.fn().mockReturnValue({ id: 'fc:test', protocol: 'farcaster', text: 'test' }),
|
||||
castToUnifiedPost: vi
|
||||
.fn()
|
||||
.mockReturnValue({ id: 'fc:test', protocol: 'farcaster', text: 'test' }),
|
||||
})),
|
||||
}));
|
||||
|
||||
|
|
@ -29,17 +31,17 @@ vi.mock('@degenfeed/bluesky-sdk', () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
import { jsonResponse } from '../index';
|
||||
import type { Env } from '../index';
|
||||
import { handleHomeFeed } from '../routes/feed';
|
||||
|
||||
describe('handleHomeFeed', () => {
|
||||
const mockEnv: any = {};
|
||||
const mockEnv: Env = {};
|
||||
|
||||
it('should return a response with data array', async () => {
|
||||
const req = new Request('http://localhost/api/feed/home?limit=5');
|
||||
const res = await handleHomeFeed(req, mockEnv);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as any;
|
||||
const body = (await res.json()) as { data: unknown[]; meta: Record<string, unknown> };
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(body.meta).toBeDefined();
|
||||
expect(body.meta.limit).toBe(5);
|
||||
|
|
@ -48,7 +50,7 @@ describe('handleHomeFeed', () => {
|
|||
it('should default to nostr,farcaster protocols', async () => {
|
||||
const req = new Request('http://localhost/api/feed/home');
|
||||
const res = await handleHomeFeed(req, mockEnv);
|
||||
const body = await res.json() as any;
|
||||
const body = (await res.json()) as { data: unknown[]; meta: Record<string, unknown> };
|
||||
expect(body.meta).toBeDefined();
|
||||
});
|
||||
|
||||
|
|
@ -56,7 +58,7 @@ describe('handleHomeFeed', () => {
|
|||
const req = new Request('http://localhost/api/feed/home?protocols=nostr');
|
||||
const res = await handleHomeFeed(req, mockEnv);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as any;
|
||||
const body = (await res.json()) as { data: unknown[]; meta: Record<string, unknown> };
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,20 +1,32 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const sockets: any[] = [];
|
||||
vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => {
|
||||
const ws: any = { onopen: null, onclose: null, onerror: null, close: vi.fn(), readyState: 0 };
|
||||
sockets.push(ws);
|
||||
return ws;
|
||||
}));
|
||||
const sockets: Array<{
|
||||
onopen: (() => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
onerror: ((event?: unknown) => void) | null;
|
||||
close: () => void;
|
||||
readyState: number;
|
||||
}> = [];
|
||||
vi.stubGlobal(
|
||||
'WebSocket',
|
||||
vi.fn().mockImplementation(() => {
|
||||
const ws = { onopen: null, onclose: null, onerror: null, close: vi.fn(), readyState: 0 };
|
||||
sockets.push(ws);
|
||||
return ws;
|
||||
}),
|
||||
);
|
||||
|
||||
import type { Env } from '../index';
|
||||
import { handleRelaysHealth } from '../routes/health';
|
||||
|
||||
describe('handleRelaysHealth (configured)', () => {
|
||||
it('should return relay status', { timeout: 5000 }, async () => {
|
||||
const env: any = { RELAY_URLS: 'wss://relay.damus.io' };
|
||||
const env: Env = { RELAY_URLS: 'wss://relay.damus.io' };
|
||||
const resPromise = handleRelaysHealth(env);
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
for (const ws of sockets) { if (typeof ws.onopen === 'function') ws.onopen(); }
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
for (const ws of sockets) {
|
||||
if (typeof ws.onopen === 'function') ws.onopen();
|
||||
}
|
||||
sockets.length = 0;
|
||||
const res = await resPromise;
|
||||
expect(res.status).toBe(200);
|
||||
|
|
|
|||
|
|
@ -1,24 +1,36 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const sockets: any[] = [];
|
||||
vi.stubGlobal('WebSocket', vi.fn().mockImplementation(() => {
|
||||
const ws: any = { onopen: null, onclose: null, onerror: null, close: vi.fn(), readyState: 0 };
|
||||
sockets.push(ws);
|
||||
return ws;
|
||||
}));
|
||||
const sockets: Array<{
|
||||
onopen: (() => void) | null;
|
||||
onclose: (() => void) | null;
|
||||
onerror: ((event?: unknown) => void) | null;
|
||||
close: () => void;
|
||||
readyState: number;
|
||||
}> = [];
|
||||
vi.stubGlobal(
|
||||
'WebSocket',
|
||||
vi.fn().mockImplementation(() => {
|
||||
const ws = { onopen: null, onclose: null, onerror: null, close: vi.fn(), readyState: 0 };
|
||||
sockets.push(ws);
|
||||
return ws;
|
||||
}),
|
||||
);
|
||||
|
||||
import type { Env } from '../index';
|
||||
import { handleRelaysHealth } from '../routes/health';
|
||||
|
||||
describe('handleRelaysHealth (defaults)', () => {
|
||||
it('should use DEFAULT_RELAYS when empty', { timeout: 15000 }, async () => {
|
||||
const env: any = {};
|
||||
const env: Env = {};
|
||||
const resPromise = handleRelaysHealth(env);
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
for (const ws of sockets) { if (typeof ws.onopen === 'function') ws.onopen(); }
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
for (const ws of sockets) {
|
||||
if (typeof ws.onopen === 'function') ws.onopen();
|
||||
}
|
||||
sockets.length = 0;
|
||||
const res = await resPromise;
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as any;
|
||||
const body = (await res.json()) as { relays: unknown[] };
|
||||
expect(body.relays).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@degenfeed/nostr-sdk', () => ({
|
||||
RelayPool: vi.fn().mockImplementation(() => ({
|
||||
|
|
@ -7,21 +7,22 @@ vi.mock('@degenfeed/nostr-sdk', () => ({
|
|||
})),
|
||||
}));
|
||||
|
||||
import type { Env } from '../index';
|
||||
import { handleNotifications } from '../routes/notifications';
|
||||
|
||||
describe('handleNotifications', () => {
|
||||
it('should return notifications array', async () => {
|
||||
const req = new Request('http://localhost/api/notifications?auth=%7B%7D');
|
||||
const res = await handleNotifications(req, {} as any);
|
||||
const res = await handleNotifications(req, {} as Env);
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as any;
|
||||
const body = (await res.json()) as { notifications: unknown[] };
|
||||
expect(Array.isArray(body.notifications)).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle Nostr pubkey auth', async () => {
|
||||
const auth = encodeURIComponent(JSON.stringify({ nostr_pubkey: 'abc123' }));
|
||||
const req = new Request('http://localhost/api/notifications?auth=' + auth);
|
||||
const res = await handleNotifications(req, {} as any);
|
||||
const req = new Request(`http://localhost/api/notifications?auth=${auth}`);
|
||||
const res = await handleNotifications(req, {} as Env);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,23 +6,29 @@
|
|||
* re-exported for route module use.
|
||||
*/
|
||||
|
||||
import { handleRelaysHealth } from './routes/health';
|
||||
import { handleHomeFeed } from './routes/feed';
|
||||
import { handleActivityPubFeed, handleThreadsFeed } from './routes/protocols';
|
||||
import { handleNewsFeed } from './routes/news';
|
||||
import { handleRssFeed } from './routes/rss';
|
||||
import { handleGtSAuthorize, handleGtSCallback, handleGtSRegister } from './routes/auth';
|
||||
import { handleTipResolve, handleTipCreate, handleTipFrame, handleTipFrameTx, handleTipFrameCallback, handleLightningInvoice } from './routes/tip';
|
||||
import { handlePublish, handleEngage } from './routes/publish';
|
||||
import { handleFundingFeed } from './routes/funding';
|
||||
import { handleNotifications } from './routes/notifications';
|
||||
import { handleResolveIdentity } from './routes/identity';
|
||||
import { handleTopFeed } from './routes/curated';
|
||||
import { handleNewsletterFeed } from './routes/curated';
|
||||
import { handleHomeFeed } from './routes/feed';
|
||||
import { handleTrendingFeed } from './routes/feed';
|
||||
import { handleFundingFeed } from './routes/funding';
|
||||
import { handleRelaysHealth } from './routes/health';
|
||||
import { handleResolveIdentity } from './routes/identity';
|
||||
import { handleNewsFeed } from './routes/news';
|
||||
import { handleNotifications } from './routes/notifications';
|
||||
import { handlePrices } from './routes/prices';
|
||||
import { handleActivityPubFeed, handleThreadsFeed } from './routes/protocols';
|
||||
import { handleEngage, handlePublish } from './routes/publish';
|
||||
import { handleRssFeed } from './routes/rss';
|
||||
import {
|
||||
handleLightningInvoice,
|
||||
handleTipCreate,
|
||||
handleTipFrame,
|
||||
handleTipFrameCallback,
|
||||
handleTipFrameTx,
|
||||
handleTipResolve,
|
||||
} from './routes/tip';
|
||||
import { handleWalletNonce, handleWalletVerify } from './routes/wallet';
|
||||
import type { Protocol } from '@degenfeed/types';
|
||||
|
||||
// ─── Environment variables ───────────────────────────────────────────
|
||||
|
||||
|
|
@ -44,8 +50,17 @@ export interface Env {
|
|||
|
||||
// ─── Shared constants ────────────────────────────────────────────────
|
||||
|
||||
export const DEFAULT_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.nostr.band', 'wss://nostr.wine'];
|
||||
export const FARCASTER_HUBS = ['https://hub.farcaster.standardcrypto.vc:2281', 'https://hub-grpc.pinata.cloud'];
|
||||
export const DEFAULT_RELAYS = [
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nostr.wine',
|
||||
];
|
||||
export const FARCASTER_HUBS = [
|
||||
'https://hub.farcaster.standardcrypto.vc:2281',
|
||||
'https://hub-grpc.pinata.cloud',
|
||||
];
|
||||
export const LENS_APIS = ['https://api.lens.xyz/graphql', 'https://api-v2.lens.dev/graphql'];
|
||||
export const BSKY_PUBLIC = 'https://public.api.bsky.app';
|
||||
export const THREADS_PUBLIC = 'https://public.threads.net';
|
||||
|
|
@ -56,7 +71,11 @@ const requestLog = new Map<string, { count: number; resetAt: number }>();
|
|||
const DEFAULT_RATE_WINDOW = 5000;
|
||||
const DEFAULT_RATE_MAX = 20;
|
||||
|
||||
export function rateLimit(ip: string, maxReq: number = DEFAULT_RATE_MAX, windowMs: number = DEFAULT_RATE_WINDOW): { allowed: boolean; retryAfter: number } {
|
||||
export function rateLimit(
|
||||
ip: string,
|
||||
maxReq: number = DEFAULT_RATE_MAX,
|
||||
windowMs: number = DEFAULT_RATE_WINDOW,
|
||||
): { allowed: boolean; retryAfter: number } {
|
||||
const now = Date.now();
|
||||
// Inline cleanup: purge expired entries
|
||||
if (requestLog.size > 10000) {
|
||||
|
|
@ -81,8 +100,8 @@ export function rateLimit(ip: string, maxReq: number = DEFAULT_RATE_MAX, windowM
|
|||
// ─── GotoSocial config ───────────────────────────────────────────────
|
||||
|
||||
export const GTS_AUTH_CONFIG = {
|
||||
clientId: '', // Must be set via GTS_CLIENT_ID env var
|
||||
clientSecret: '', // Must be set via GTS_CLIENT_SECRET env var
|
||||
clientId: '', // Must be set via GTS_CLIENT_ID env var
|
||||
clientSecret: '', // Must be set via GTS_CLIENT_SECRET env var
|
||||
instanceUrl: 'https://social.degenfeed.xyz',
|
||||
apiBase: 'http://127.0.0.1:8082',
|
||||
redirectUri: 'https://degenfeed.xyz/auth/callback',
|
||||
|
|
@ -105,7 +124,11 @@ export function getGtsConfig(env: Env) {
|
|||
export function jsonResponse(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'cache-control': 'public, max-age=30' },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'access-control-allow-origin': '*',
|
||||
'cache-control': 'public, max-age=30',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -117,14 +140,20 @@ export default {
|
|||
const path = url.pathname;
|
||||
|
||||
// Rate limiting
|
||||
const ip = request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for') || 'unknown';
|
||||
const ip =
|
||||
request.headers.get('cf-connecting-ip') ||
|
||||
request.headers.get('x-forwarded-for') ||
|
||||
'unknown';
|
||||
const maxReq = Number(env.RATE_MAX_REQ) || DEFAULT_RATE_MAX;
|
||||
const windowMs = Number(env.RATE_WINDOW_MS) || DEFAULT_RATE_WINDOW;
|
||||
const rl = rateLimit(ip, maxReq, windowMs);
|
||||
if (!rl.allowed) {
|
||||
return new Response(JSON.stringify({ error: 'Rate limit', retryAfterMs: rl.retryAfter }), {
|
||||
status: 429,
|
||||
headers: { 'content-type': 'application/json', 'retry-after': String(Math.ceil(rl.retryAfter / 1000)) },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'retry-after': String(Math.ceil(rl.retryAfter / 1000)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -148,7 +177,7 @@ export default {
|
|||
if (path === '/api/feed/news') {
|
||||
return handleNewsFeed(request, env);
|
||||
}
|
||||
if (path === '/api/bridge/rss-to-nostr') {
|
||||
if (path === '/api/bridge/rss-to-nostr') {
|
||||
const { handleRssToNostrBridge } = await import('./routes/publish');
|
||||
return handleRssToNostrBridge(request, env);
|
||||
}
|
||||
|
|
@ -170,20 +199,48 @@ export default {
|
|||
if (path === '/api/engage') {
|
||||
return handleEngage(request, env);
|
||||
}
|
||||
if (path === '/api/feed/top') { return handleTopFeed(request, env); }
|
||||
if (path === '/api/feed/newsletters') { return handleNewsletterFeed(request, env); }
|
||||
if (path === '/api/feed/trending') { return handleTrendingFeed(request, env); }
|
||||
if (path === '/api/prices') { return handlePrices(request, env); }
|
||||
if (path === '/api/auth/wallet/nonce') { return handleWalletNonce(request, env); }
|
||||
if (path === '/api/auth/wallet/verify') { return handleWalletVerify(request, env); }
|
||||
if (path === '/api/tip/lightning/invoice') { return handleLightningInvoice(request, env); }
|
||||
if (path.startsWith('/api/tip/frame/tx/')) { return handleTipFrameTx(request, env); }
|
||||
if (path.startsWith('/api/tip/frame/callback/')) { return handleTipFrameCallback(request, env); }
|
||||
if (path.startsWith('/api/tip/frame/')) { return handleTipFrame(request, env); }
|
||||
if (path === '/api/tip/resolve') { return handleTipResolve(request, env); }
|
||||
if (path === '/api/tip/create') { return handleTipCreate(request, env); }
|
||||
if (path === '/api/feed/funding') { return handleFundingFeed(request, env); }
|
||||
if (path === '/api/notifications') { return handleNotifications(request, env); }
|
||||
if (path === '/api/feed/top') {
|
||||
return handleTopFeed(request, env);
|
||||
}
|
||||
if (path === '/api/feed/newsletters') {
|
||||
return handleNewsletterFeed(request, env);
|
||||
}
|
||||
if (path === '/api/feed/trending') {
|
||||
return handleTrendingFeed(request, env);
|
||||
}
|
||||
if (path === '/api/prices') {
|
||||
return handlePrices(request, env);
|
||||
}
|
||||
if (path === '/api/auth/wallet/nonce') {
|
||||
return handleWalletNonce(request, env);
|
||||
}
|
||||
if (path === '/api/auth/wallet/verify') {
|
||||
return handleWalletVerify(request, env);
|
||||
}
|
||||
if (path === '/api/tip/lightning/invoice') {
|
||||
return handleLightningInvoice(request, env);
|
||||
}
|
||||
if (path.startsWith('/api/tip/frame/tx/')) {
|
||||
return handleTipFrameTx(request, env);
|
||||
}
|
||||
if (path.startsWith('/api/tip/frame/callback/')) {
|
||||
return handleTipFrameCallback(request, env);
|
||||
}
|
||||
if (path.startsWith('/api/tip/frame/')) {
|
||||
return handleTipFrame(request, env);
|
||||
}
|
||||
if (path === '/api/tip/resolve') {
|
||||
return handleTipResolve(request, env);
|
||||
}
|
||||
if (path === '/api/tip/create') {
|
||||
return handleTipCreate(request, env);
|
||||
}
|
||||
if (path === '/api/feed/funding') {
|
||||
return handleFundingFeed(request, env);
|
||||
}
|
||||
if (path === '/api/notifications') {
|
||||
return handleNotifications(request, env);
|
||||
}
|
||||
if (path === '/api/identity/resolve') {
|
||||
return handleResolveIdentity(request, env);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type Env, jsonResponse, getGtsConfig } from '../index';
|
||||
import { type Env, getGtsConfig, jsonResponse } from '../index';
|
||||
|
||||
export async function handleGtSAuthorize(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
|
@ -49,7 +49,7 @@ export async function handleGtSCallback(request: Request, env: Env): Promise<Res
|
|||
// Fetch user profile
|
||||
const accessToken = tokenData.access_token as string;
|
||||
const userRes = await fetch(`${getGtsConfig(env).apiBase}/api/v1/accounts/verify_credentials`, {
|
||||
headers: { authorization: 'Bearer ' + accessToken },
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
const profile = userRes.ok ? ((await userRes.json()) as Record<string, unknown>) : null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,31 @@
|
|||
import { type Env, jsonResponse } from '../index';
|
||||
import type { GtSStatus } from '@degenfeed/mastodon-sdk';
|
||||
import type { UnifiedPost } from '@degenfeed/types';
|
||||
import { type Env, jsonResponse } from '../index';
|
||||
|
||||
// ─── Heat Score ────────────────────────────────────────────────
|
||||
// Scores a post based on engagement, recency, and source quality
|
||||
function heatScore(post: UnifiedPost, now: number): number {
|
||||
const ageHrs = (now - post.createdAt) / 3600000;
|
||||
const recency = Math.pow(0.5, ageHrs / 24); // 24h half-life
|
||||
|
||||
const recency = 0.5 ** (ageHrs / 24); // 24h half-life
|
||||
|
||||
const likes = post.metrics?.likes ?? 0;
|
||||
const reposts = post.metrics?.reposts ?? 0;
|
||||
const replies = post.metrics?.replies ?? 0;
|
||||
|
||||
const engagementScore = (likes * 1) + (reposts * 2) + (replies * 3);
|
||||
|
||||
|
||||
const engagementScore = likes * 1 + reposts * 2 + replies * 3;
|
||||
|
||||
// Source quality multiplier
|
||||
const quality: Record<string, number> = {
|
||||
lens: 1.0, farcaster: 1.0, bluesky: 1.0,
|
||||
nostr: 0.8, mastodon: 0.9, threads: 0.7, rss: 0.6,
|
||||
lens: 1.0,
|
||||
farcaster: 1.0,
|
||||
bluesky: 1.0,
|
||||
nostr: 0.8,
|
||||
mastodon: 0.9,
|
||||
threads: 0.7,
|
||||
rss: 0.6,
|
||||
};
|
||||
const q = quality[post.protocol] ?? 0.5;
|
||||
|
||||
|
||||
return engagementScore * recency * q;
|
||||
}
|
||||
|
||||
|
|
@ -27,8 +33,11 @@ function heatScore(post: UnifiedPost, now: number): number {
|
|||
// Fetches from all protocols, scores by heat, returns top 50 in last 24h
|
||||
|
||||
const FALLBACK_RELAYS = [
|
||||
'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band', 'wss://nostr.wine',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nostr.wine',
|
||||
];
|
||||
|
||||
async function fetchNostrTop(limit: number): Promise<UnifiedPost[]> {
|
||||
|
|
@ -40,14 +49,40 @@ async function fetchNostrTop(limit: number): Promise<UnifiedPost[]> {
|
|||
pool.disconnectAll();
|
||||
for (const ev of events) {
|
||||
posts.push({
|
||||
id: 'nostr:' + ev.id, protocol: 'nostr' as any,
|
||||
author: { id: 'nostr:' + ev.pubkey, primary: { protocol: 'nostr' as const, id: ev.pubkey },
|
||||
handles: { nostr: ev.pubkey.slice(0, 12), farcaster: null, lens: null, bluesky: null, threads: null, rss: null, mastodon: null },
|
||||
displayName: ev.pubkey.slice(0, 12), bio: '', avatarUrl: '', verified: [], followerCount: 0, followingCount: 0, links: [], keys: {} },
|
||||
text: ev.content, embeds: [], createdAt: ev.created_at * 1000,
|
||||
id: `nostr:${ev.id}`,
|
||||
protocol: 'nostr' as const,
|
||||
author: {
|
||||
id: `nostr:${ev.pubkey}`,
|
||||
primary: { protocol: 'nostr' as const, id: ev.pubkey },
|
||||
handles: {
|
||||
nostr: ev.pubkey.slice(0, 12),
|
||||
farcaster: null,
|
||||
lens: null,
|
||||
bluesky: null,
|
||||
threads: null,
|
||||
rss: null,
|
||||
mastodon: null,
|
||||
},
|
||||
displayName: ev.pubkey.slice(0, 12),
|
||||
bio: '',
|
||||
avatarUrl: '',
|
||||
verified: [],
|
||||
followerCount: 0,
|
||||
followingCount: 0,
|
||||
links: [],
|
||||
keys: {},
|
||||
},
|
||||
text: ev.content,
|
||||
embeds: [],
|
||||
createdAt: ev.created_at * 1000,
|
||||
metrics: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
|
||||
engagement: { viewerLiked: false, viewerReposted: false, raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 } },
|
||||
raw: ev, niches: [],
|
||||
engagement: {
|
||||
viewerLiked: false,
|
||||
viewerReposted: false,
|
||||
raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
|
||||
},
|
||||
raw: ev,
|
||||
niches: [],
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -61,7 +96,7 @@ async function fetchFarcasterTop(limit: number): Promise<UnifiedPost[]> {
|
|||
const client = new HubClient('https://hub.farcaster.standardcrypto.vc:2281');
|
||||
const fids = [1, 2, 3, 5650, 1234, 11276, 215, 320];
|
||||
const results = await Promise.allSettled(
|
||||
fids.slice(0, 4).map((fid) => client.getCastsByFid(fid, Math.ceil(limit / 2)))
|
||||
fids.slice(0, 4).map((fid) => client.getCastsByFid(fid, Math.ceil(limit / 2))),
|
||||
);
|
||||
for (const r of results) {
|
||||
if (r.status === 'fulfilled') {
|
||||
|
|
@ -93,51 +128,84 @@ async function fetchMastodonTop(limit: number): Promise<UnifiedPost[]> {
|
|||
const { GtSClient } = await import('@degenfeed/mastodon-sdk');
|
||||
const client = new GtSClient('https://social.degenfeed.xyz');
|
||||
const statuses = await client.getPublicTimeline(limit);
|
||||
for (const s of statuses) {
|
||||
for (const s of statuses as GtSStatus[]) {
|
||||
posts.push({
|
||||
id: 'mastodon:' + s.id, protocol: 'mastodon' as any,
|
||||
author: { id: 'mastodon:' + s.account.id, primary: { protocol: 'mastodon' as const, id: s.account.id },
|
||||
handles: { mastodon: s.account.acct, nostr: null, farcaster: null, lens: null, bluesky: null, threads: null, rss: null },
|
||||
displayName: s.account.display_name || s.account.username, bio: '', avatarUrl: s.account.avatar, verified: [], followerCount: s.account.followers_count, followingCount: s.account.following_count, links: [], keys: {} },
|
||||
text: s.content.replace(/<[^>]*>/g, ''), embeds: [], createdAt: new Date(s.created_at).getTime(),
|
||||
metrics: { likes: s.favourites_count, reposts: s.reblogs_count, replies: s.replies_count, quotes: 0 },
|
||||
engagement: { viewerLiked: false, viewerReposted: false, raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 } },
|
||||
raw: s, niches: [],
|
||||
id: `mastodon:${s.id}`,
|
||||
protocol: 'mastodon' as const,
|
||||
author: {
|
||||
id: `mastodon:${s.account.id}`,
|
||||
primary: { protocol: 'mastodon' as const, id: s.account.id },
|
||||
handles: {
|
||||
mastodon: s.account.acct,
|
||||
nostr: null,
|
||||
farcaster: null,
|
||||
lens: null,
|
||||
bluesky: null,
|
||||
threads: null,
|
||||
rss: null,
|
||||
},
|
||||
displayName: s.account.display_name || s.account.username,
|
||||
bio: '',
|
||||
avatarUrl: s.account.avatar,
|
||||
verified: [],
|
||||
followerCount: s.account.followers_count,
|
||||
followingCount: s.account.following_count,
|
||||
links: [],
|
||||
keys: {},
|
||||
},
|
||||
text: s.content.replace(/<[^>]*>/g, ''),
|
||||
embeds: [],
|
||||
createdAt: new Date(s.created_at).getTime(),
|
||||
metrics: {
|
||||
likes: s.favourites_count,
|
||||
reposts: s.reblogs_count,
|
||||
replies: s.replies_count,
|
||||
quotes: 0,
|
||||
},
|
||||
engagement: {
|
||||
viewerLiked: false,
|
||||
viewerReposted: false,
|
||||
raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
|
||||
},
|
||||
raw: s,
|
||||
niches: [],
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
return posts;
|
||||
}
|
||||
|
||||
export async function handleTopFeed(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleTopFeed(request: Request, _env: Env): Promise<Response> {
|
||||
const limit = Number(new URL(request.url).searchParams.get('limit')) || 50;
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
const [nostrPosts, fcPosts, bskyPosts, mastodonPosts] = await Promise.all([
|
||||
fetchNostrTop(limit).catch(() => []),
|
||||
fetchFarcasterTop(limit).catch(() => []),
|
||||
fetchBlueskyTop(limit).catch(() => []),
|
||||
fetchMastodonTop(limit).catch(() => []),
|
||||
]);
|
||||
|
||||
|
||||
const allPosts = [...nostrPosts, ...fcPosts, ...bskyPosts, ...mastodonPosts];
|
||||
const scored = allPosts
|
||||
.map(p => ({ post: p, score: heatScore(p, now) }))
|
||||
.map((p) => ({ post: p, score: heatScore(p, now) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.filter(p => p.score > 0)
|
||||
.filter((p) => p.score > 0)
|
||||
.slice(0, limit);
|
||||
|
||||
|
||||
// Dedup by content similarity
|
||||
const seen = new Set<string>();
|
||||
const deduped = scored.filter(s => {
|
||||
const key = s.post.text.slice(0, 60);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
}).slice(0, limit);
|
||||
|
||||
const deduped = scored
|
||||
.filter((s) => {
|
||||
const key = s.post.text.slice(0, 60);
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
})
|
||||
.slice(0, limit);
|
||||
|
||||
return jsonResponse({
|
||||
data: deduped.map(s => s.post),
|
||||
data: deduped.map((s) => s.post),
|
||||
meta: { total: deduped.length, limit, type: 'top' },
|
||||
});
|
||||
}
|
||||
|
|
@ -145,7 +213,11 @@ export async function handleTopFeed(request: Request, env: Env): Promise<Respons
|
|||
// ─── 2. Newsletter Feed — /api/feed/newsletters ───────────────
|
||||
|
||||
const NEWSLETTER_SOURCES = [
|
||||
{ name: 'CoinDesk', url: 'https://www.coindesk.com/arc/outboundfeeds/rss/', category: 'headlines' },
|
||||
{
|
||||
name: 'CoinDesk',
|
||||
url: 'https://www.coindesk.com/arc/outboundfeeds/rss/',
|
||||
category: 'headlines',
|
||||
},
|
||||
{ name: 'CoinTelegraph', url: 'https://cointelegraph.com/rss', category: 'headlines' },
|
||||
{ name: 'The Block', url: 'https://www.theblockcrypto.com/rss.xml', category: 'headlines' },
|
||||
{ name: 'Decrypt', url: 'https://decrypt.co/feed', category: 'headlines' },
|
||||
|
|
@ -156,9 +228,17 @@ const NEWSLETTER_SOURCES = [
|
|||
{ name: 'Week in Ethereum', url: 'https://weekinethereumnews.com/feed', category: 'deep_dives' },
|
||||
{ name: 'ETH Daily', url: 'https://ethdaily.substack.com/feed', category: 'deep_dives' },
|
||||
{ name: 'Messari', url: 'https://messari.io/rss', category: 'deep_dives' },
|
||||
{ name: 'Delphi Digital', url: 'https://members.delphidigital.com/feed/', category: 'deep_dives' },
|
||||
{
|
||||
name: 'Delphi Digital',
|
||||
url: 'https://members.delphidigital.com/feed/',
|
||||
category: 'deep_dives',
|
||||
},
|
||||
{ name: 'Kerman Kohli', url: 'https://kermankohli.substack.com/feed', category: 'deep_dives' },
|
||||
{ name: 'The DeFi Investor', url: 'https://thedefiinvestor.substack.com/feed', category: 'deep_dives' },
|
||||
{
|
||||
name: 'The DeFi Investor',
|
||||
url: 'https://thedefiinvestor.substack.com/feed',
|
||||
category: 'deep_dives',
|
||||
},
|
||||
{ name: 'Dune Analytics', url: 'https://dune.com/feed', category: 'onchain_data' },
|
||||
{ name: 'Glassnode', url: 'https://glassnode.com/blog/feed', category: 'onchain_data' },
|
||||
{ name: 'Token Terminal', url: 'https://tokenterminal.com/feed', category: 'onchain_data' },
|
||||
|
|
@ -171,7 +251,11 @@ const NEWSLETTER_SOURCES = [
|
|||
{ name: 'Uniswap', url: 'https://uniswap.org/feed.xml', category: 'defi' },
|
||||
{ name: 'The Sequence', url: 'https://thesequence.substack.com/feed', category: 'ai_crypto' },
|
||||
{ name: 'AI in Crypto', url: 'https://aicrypto.substack.com/feed', category: 'ai_crypto' },
|
||||
{ name: 'Crypto AI Daily', url: 'https://cryptoaidaily.substack.com/feed', category: 'ai_crypto' },
|
||||
{
|
||||
name: 'Crypto AI Daily',
|
||||
url: 'https://cryptoaidaily.substack.com/feed',
|
||||
category: 'ai_crypto',
|
||||
},
|
||||
{ name: 'Modular', url: 'https://modular.substack.com/feed', category: 'ai_crypto' },
|
||||
{ name: 'SEC Crypto', url: 'https://www.sec.gov/rss/crypto', category: 'regulation' },
|
||||
{ name: 'CFTC', url: 'https://www.cftc.gov/rss', category: 'regulation' },
|
||||
|
|
@ -181,21 +265,24 @@ const NEWSLETTER_SOURCES = [
|
|||
{ name: 'Degen News', url: 'https://degennews.substack.com/feed', category: 'alpha_leaks' },
|
||||
];
|
||||
|
||||
export async function handleNewsletterFeed(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleNewsletterFeed(request: Request, _env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get('limit')) || 25;
|
||||
const category = url.searchParams.get('category') || '';
|
||||
const allPosts: UnifiedPost[] = [];
|
||||
|
||||
const sources = category
|
||||
? NEWSLETTER_SOURCES.filter(s => s.category === category)
|
||||
|
||||
const sources = category
|
||||
? NEWSLETTER_SOURCES.filter((s) => s.category === category)
|
||||
: NEWSLETTER_SOURCES;
|
||||
|
||||
|
||||
if (sources.length === 0) {
|
||||
return jsonResponse({ data: [], meta: { total: 0, category, available: Object.keys(NEWSLETTER_SOURCES).filter(Boolean) } });
|
||||
return jsonResponse({
|
||||
data: [],
|
||||
meta: { total: 0, category, available: Object.keys(NEWSLETTER_SOURCES).filter(Boolean) },
|
||||
});
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
const _results = await Promise.allSettled(
|
||||
sources.slice(0, 15).map(async (src) => {
|
||||
try {
|
||||
const { GenericRssSource, feedToUnifiedPosts } = await import('@degenfeed/rss-sdk');
|
||||
|
|
@ -203,15 +290,15 @@ export async function handleNewsletterFeed(request: Request, env: Env): Promise<
|
|||
const posts = feedToUnifiedPosts(s);
|
||||
allPosts.push(...posts);
|
||||
} catch {}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
allPosts.sort((a, b) => b.createdAt - a.createdAt);
|
||||
|
||||
|
||||
// Enhanced niche detection from combined content
|
||||
const nicheCounts: Record<string, number> = {};
|
||||
for (const post of allPosts.slice(0, 50)) {
|
||||
const text = (post.text + ' ' + (post.niches || []).join(' ')).toLowerCase();
|
||||
const text = `${post.text} ${(post.niches || []).join(' ')}`.toLowerCase();
|
||||
const keywords: Record<string, string[]> = {
|
||||
bitcoin: ['bitcoin', 'btc', 'satoshi'],
|
||||
ethereum: ['ethereum', 'eth', 'vitalik'],
|
||||
|
|
@ -226,18 +313,18 @@ export async function handleNewsletterFeed(request: Request, env: Env): Promise<
|
|||
gaming: ['gaming', 'gamefi', 'metaverse', 'play to earn'],
|
||||
};
|
||||
for (const [niche, terms] of Object.entries(keywords)) {
|
||||
if (terms.some(t => text.includes(t))) {
|
||||
if (terms.some((t) => text.includes(t))) {
|
||||
nicheCounts[niche] = (nicheCounts[niche] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Sort niches by frequency
|
||||
const trending = Object.entries(nicheCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([topic, count]) => ({ topic, count }));
|
||||
|
||||
|
||||
return jsonResponse({
|
||||
data: allPosts.slice(0, limit),
|
||||
meta: {
|
||||
|
|
@ -265,6 +352,8 @@ export async function handleTrendingFeed(request: Request, env: Env): Promise<Re
|
|||
|
||||
// Delegate to the home feed handler for ranking
|
||||
const { handleHomeFeed } = await import('./feed');
|
||||
const fakeReq = new Request('http://localhost/api/feed/home?protocols=nostr,farcaster&limit=' + limit);
|
||||
const fakeReq = new Request(
|
||||
`http://localhost/api/feed/home?protocols=nostr,farcaster&limit=${limit}`,
|
||||
);
|
||||
return handleHomeFeed(fakeReq, env);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
import { type Env, jsonResponse, DEFAULT_RELAYS } from '../index';
|
||||
import type { Protocol, UnifiedPost } from '@degenfeed/types';
|
||||
import { RelayPool } from '@degenfeed/nostr-sdk';
|
||||
import { rank, type RankingContext } from '@degenfeed/ranking';
|
||||
import { BskyClient } from '@degenfeed/bluesky-sdk';
|
||||
import { HubClient } from '@degenfeed/farcaster-sdk';
|
||||
import { LensClient } from '@degenfeed/lens-sdk';
|
||||
import { BskyClient } from '@degenfeed/bluesky-sdk';
|
||||
import { RelayPool } from '@degenfeed/nostr-sdk';
|
||||
import { type RankingContext, rank } from '@degenfeed/ranking';
|
||||
import type { Protocol, UnifiedPost } from '@degenfeed/types';
|
||||
import { type Env, jsonResponse } from '../index';
|
||||
|
||||
const FALLBACK_RELAYS = [
|
||||
'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band', 'wss://nostr.wine',
|
||||
'wss://relay.damus.io',
|
||||
'wss://nos.lol',
|
||||
'wss://relay.primal.net',
|
||||
'wss://relay.nostr.band',
|
||||
'wss://nostr.wine',
|
||||
];
|
||||
const FALLBACK_HUBS = [
|
||||
'https://hub.farcaster.standardcrypto.vc:2281',
|
||||
|
|
@ -19,7 +22,9 @@ const BSKY_FALLBACK = 'https://public.api.bsky.app';
|
|||
|
||||
export async function handleHomeFeed(request: Request, env: Env): Promise<Response> {
|
||||
const params = new URL(request.url).searchParams;
|
||||
const requestedProtocols = (params.get('protocols') ?? 'nostr,farcaster').split(',') as Protocol[];
|
||||
const requestedProtocols = (params.get('protocols') ?? 'nostr,farcaster').split(
|
||||
',',
|
||||
) as Protocol[];
|
||||
const limit = Number(params.get('limit')) || 25;
|
||||
|
||||
const allPosts: UnifiedPost[] = [];
|
||||
|
|
@ -29,87 +34,175 @@ export async function handleHomeFeed(request: Request, env: Env): Promise<Respon
|
|||
if (requestedProtocols.includes('nostr')) {
|
||||
try {
|
||||
const relayUrls = (env.RELAY_URLS ?? '').split(',').filter(Boolean);
|
||||
const pool = new RelayPool(relayUrls.length > 0 ? relayUrls : FALLBACK_RELAYS, { eoseTimeout: 2000 });
|
||||
const pool = new RelayPool(relayUrls.length > 0 ? relayUrls : FALLBACK_RELAYS, {
|
||||
eoseTimeout: 2000,
|
||||
});
|
||||
const events = await pool.list({ kinds: [1], limit: limit * 3 }, 3000);
|
||||
pool.disconnectAll();
|
||||
for (const ev of events) {
|
||||
allPosts.push({
|
||||
id: 'nostr:' + ev.id, protocol: 'nostr' as Protocol,
|
||||
author: { id: 'nostr:' + ev.pubkey, primary: { protocol: 'nostr' as const, id: ev.pubkey },
|
||||
handles: { nostr: ev.pubkey, farcaster: null, lens: null, bluesky: null, threads: null, rss: null, mastodon: null },
|
||||
displayName: ev.pubkey.slice(0, 12), bio: '', avatarUrl: '', verified: [], followerCount: 0, followingCount: 0, links: [], keys: { nostrPubkey: ev.pubkey } },
|
||||
text: ev.content, embeds: [], createdAt: ev.created_at * 1000,
|
||||
id: `nostr:${ev.id}`,
|
||||
protocol: 'nostr' as Protocol,
|
||||
author: {
|
||||
id: `nostr:${ev.pubkey}`,
|
||||
primary: { protocol: 'nostr' as const, id: ev.pubkey },
|
||||
handles: {
|
||||
nostr: ev.pubkey,
|
||||
farcaster: null,
|
||||
lens: null,
|
||||
bluesky: null,
|
||||
threads: null,
|
||||
rss: null,
|
||||
mastodon: null,
|
||||
},
|
||||
displayName: ev.pubkey.slice(0, 12),
|
||||
bio: '',
|
||||
avatarUrl: '',
|
||||
verified: [],
|
||||
followerCount: 0,
|
||||
followingCount: 0,
|
||||
links: [],
|
||||
keys: { nostrPubkey: ev.pubkey },
|
||||
},
|
||||
text: ev.content,
|
||||
embeds: [],
|
||||
createdAt: ev.created_at * 1000,
|
||||
metrics: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
|
||||
engagement: { viewerLiked: false, viewerReposted: false, raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 } },
|
||||
raw: ev, niches: [],
|
||||
engagement: {
|
||||
viewerLiked: false,
|
||||
viewerReposted: false,
|
||||
raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
|
||||
},
|
||||
raw: ev,
|
||||
niches: [],
|
||||
});
|
||||
}
|
||||
} catch (e) { errors.push('nostr: ' + (e instanceof Error ? e.message : 'unknown')); }
|
||||
} catch (e) {
|
||||
errors.push(`nostr: ${e instanceof Error ? e.message : 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Farcaster
|
||||
if (requestedProtocols.includes('farcaster')) {
|
||||
try {
|
||||
const hubUrl = env.FARCASTER_HUB_URL || FALLBACK_HUBS[0];
|
||||
const hubUrl = env.FARCASTER_HUB_URL || FALLBACK_HUBS[0];
|
||||
if (hubUrl) {
|
||||
const client = new HubClient(hubUrl);
|
||||
const sampleFids = [1, 2, 3, 5650, 1234];
|
||||
const results = await Promise.allSettled(sampleFids.slice(0, 3).map((fid) => client.getCastsByFid(fid, Math.ceil(limit / 3))));
|
||||
const results = await Promise.allSettled(
|
||||
sampleFids.slice(0, 3).map((fid) => client.getCastsByFid(fid, Math.ceil(limit / 3))),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
for (const cast of result.value) { allPosts.push(client.castToUnifiedPost(cast)); }
|
||||
for (const cast of result.value) {
|
||||
allPosts.push(client.castToUnifiedPost(cast));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { errors.push('farcaster: ' + (e instanceof Error ? e.message : 'unknown')); }
|
||||
} catch (e) {
|
||||
errors.push(`farcaster: ${e instanceof Error ? e.message : 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Lens
|
||||
if (requestedProtocols.includes('lens')) {
|
||||
try {
|
||||
const client = new LensClient(LENS_FALLBACK[0]);
|
||||
const client = new LensClient(LENS_FALLBACK[0]);
|
||||
const pubs = await client.getPublications('0x01', Math.ceil(limit / 2));
|
||||
for (const pub of pubs) {
|
||||
allPosts.push({
|
||||
id: 'lens:' + pub.id, protocol: 'lens' as Protocol,
|
||||
author: { id: 'lens:' + pub.by.id, primary: { protocol: 'lens' as const, id: pub.by.id },
|
||||
handles: { nostr: null, farcaster: null, lens: pub.by.handle, bluesky: null, threads: null, rss: null, mastodon: null },
|
||||
displayName: pub.by.metadata?.displayName ?? pub.by.handle, bio: '', avatarUrl: pub.by.metadata?.picture ?? '', verified: [], followerCount: 0, followingCount: 0, links: [], keys: {} },
|
||||
id: `lens:${pub.id}`,
|
||||
protocol: 'lens' as Protocol,
|
||||
author: {
|
||||
id: `lens:${pub.by.id}`,
|
||||
primary: { protocol: 'lens' as const, id: pub.by.id },
|
||||
handles: {
|
||||
nostr: null,
|
||||
farcaster: null,
|
||||
lens: pub.by.handle,
|
||||
bluesky: null,
|
||||
threads: null,
|
||||
rss: null,
|
||||
mastodon: null,
|
||||
},
|
||||
displayName: pub.by.metadata?.displayName ?? pub.by.handle,
|
||||
bio: '',
|
||||
avatarUrl: pub.by.metadata?.picture ?? '',
|
||||
verified: [],
|
||||
followerCount: 0,
|
||||
followingCount: 0,
|
||||
links: [],
|
||||
keys: {},
|
||||
},
|
||||
text: pub.metadata.content.slice(0, 500),
|
||||
embeds: pub.metadata.media?.map((m) => ({ kind: 'image' as const, url: m.original.url })) ?? [],
|
||||
embeds:
|
||||
pub.metadata.media?.map((m) => ({ kind: 'image' as const, url: m.original.url })) ?? [],
|
||||
createdAt: new Date(pub.createdAt).getTime(),
|
||||
metrics: { likes: pub.stats.totalAmountOfReactions, reposts: pub.stats.totalAmountOfMirrors, replies: pub.stats.totalAmountOfComments, quotes: 0 },
|
||||
engagement: { viewerLiked: false, viewerReposted: false, raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 } },
|
||||
raw: pub, niches: [],
|
||||
metrics: {
|
||||
likes: pub.stats.totalAmountOfReactions,
|
||||
reposts: pub.stats.totalAmountOfMirrors,
|
||||
replies: pub.stats.totalAmountOfComments,
|
||||
quotes: 0,
|
||||
},
|
||||
engagement: {
|
||||
viewerLiked: false,
|
||||
viewerReposted: false,
|
||||
raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
|
||||
},
|
||||
raw: pub,
|
||||
niches: [],
|
||||
});
|
||||
}
|
||||
} catch (e) { errors.push('lens: ' + (e instanceof Error ? e.message : 'unknown')); }
|
||||
} catch (e) {
|
||||
errors.push(`lens: ${e instanceof Error ? e.message : 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Bluesky
|
||||
if (requestedProtocols.includes('bluesky')) {
|
||||
try {
|
||||
const client = new BskyClient(BSKY_FALLBACK);
|
||||
const client = new BskyClient(BSKY_FALLBACK);
|
||||
const { posts: bskyPosts } = await client.getTimeline(limit);
|
||||
for (const post of bskyPosts) { allPosts.push(client.postToUnifiedPost(post)); }
|
||||
} catch (e) { errors.push('bluesky: ' + (e instanceof Error ? e.message : 'unknown')); }
|
||||
for (const post of bskyPosts) {
|
||||
allPosts.push(client.postToUnifiedPost(post));
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`bluesky: ${e instanceof Error ? e.message : 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Rank
|
||||
let ranked = allPosts;
|
||||
if (ranked.length > 1) {
|
||||
const ctx: RankingContext = { viewer: null, recentAuthors: new Map(), sourceQuality: new Map(), viewerNiches: new Set(), followed: new Set(), now: Date.now() };
|
||||
const ctx: RankingContext = {
|
||||
viewer: null,
|
||||
recentAuthors: new Map(),
|
||||
sourceQuality: new Map(),
|
||||
viewerNiches: new Set(),
|
||||
followed: new Set(),
|
||||
now: Date.now(),
|
||||
};
|
||||
ranked = rank(ranked, ctx);
|
||||
}
|
||||
|
||||
if (ranked.length === 0 && errors.length > 0) {
|
||||
return jsonResponse({ data: [], meta: { total: 0, limit, errors, note: 'All sources failed' } }, 200);
|
||||
return jsonResponse(
|
||||
{ data: [], meta: { total: 0, limit, errors, note: 'All sources failed' } },
|
||||
200,
|
||||
);
|
||||
}
|
||||
return jsonResponse({ data: ranked.slice(0, limit), meta: { total: ranked.length, limit, errors: errors.length > 0 ? errors : undefined } });
|
||||
return jsonResponse({
|
||||
data: ranked.slice(0, limit),
|
||||
meta: { total: ranked.length, limit, errors: errors.length > 0 ? errors : undefined },
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleTrendingFeed(request: Request, env: Env): Promise<Response> {
|
||||
const limit = Number(new URL(request.url).searchParams.get('limit')) || 25;
|
||||
// Trending = home feed with broader fetch
|
||||
return handleHomeFeed(new Request(request.url + '&protocols=nostr,farcaster,lens,bluesky,threads'), env);
|
||||
const _limit = Number(new URL(request.url).searchParams.get('limit')) || 25;
|
||||
// Trending = home feed with broader fetch
|
||||
return handleHomeFeed(
|
||||
new Request(`${request.url}&protocols=nostr,farcaster,lens,bluesky,threads`),
|
||||
env,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,28 +24,41 @@ const FUNDING_SOURCES = [
|
|||
];
|
||||
|
||||
// Pattern: "raises $X million", "secured $X", "Series X", "led by Investor"
|
||||
const FUNDING_RE = /(\w[\w\s]+?)\s+(?:raises?|secured|lands|closes|announces?|gets)\s+[$$]\s*([\d,.]+)\s*(million|billion)?(?:\s+in\s+)?(seed|series\s+\w|strategic|private|public|pre-seed|angel|grant)?.*?(?:led\s+by\s+([\w\s,&]+))?/gi;
|
||||
const _FUNDING_RE =
|
||||
/(\w[\w\s]+?)\s+(?:raises?|secured|lands|closes|announces?|gets)\s+[$$]\s*([\d,.]+)\s*(million|billion)?(?:\s+in\s+)?(seed|series\s+\w|strategic|private|public|pre-seed|angel|grant)?.*?(?:led\s+by\s+([\w\s,&]+))?/gi;
|
||||
|
||||
const STAGE_RE = /(seed|pre-seed|series\s+[abcd]|strategic|private|public\s+sale|angel|grant)/gi;
|
||||
const AMOUNT_RE = /[$$]\s*([\d,.]+)\s*(million|billion|m|b)?/gi;
|
||||
const INVESTOR_RE = /led\s+by\s+([\w\s,&.]+?)(?:\.|,|\sat\s|$)/gi;
|
||||
const CHAIN_RE = /(ethereum|solana|polygon|avalanche|arbitrum|optimism|base|near|aptos|sui|bitcoin|multi-chain)/gi;
|
||||
const CHAIN_RE =
|
||||
/\b(ethereum|solana|polygon|avalanche|arbitrum|optimism|base|near|aptos|sui|bitcoin|multi-chain)\b/gi;
|
||||
|
||||
function extractFunding(text: string): Partial<FundingRound> | null {
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
|
||||
// Check if this is likely a funding announcement
|
||||
const triggerWords = ['raises', 'secured', 'funding', 'round', 'series', 'investors', 'led by', 'million', 'billion'];
|
||||
if (!triggerWords.some(w => lower.includes(w))) return null;
|
||||
const triggerWords = [
|
||||
'raises',
|
||||
'secured',
|
||||
'funding',
|
||||
'round',
|
||||
'series',
|
||||
'investors',
|
||||
'led by',
|
||||
'million',
|
||||
'billion',
|
||||
];
|
||||
if (!triggerWords.some((w) => lower.includes(w))) return null;
|
||||
|
||||
const result: Partial<FundingRound> = {};
|
||||
|
||||
|
||||
// Amount
|
||||
const amtMatch = lower.match(AMOUNT_RE);
|
||||
if (amtMatch) {
|
||||
const m = amtMatch[0].match(/[\d,.]+/)?.[0];
|
||||
const unit = amtMatch[0].toLowerCase().includes('billion') || amtMatch[0].includes('b') ? 'B' : 'M';
|
||||
result.amount = '$' + (m || '') + unit;
|
||||
const unit =
|
||||
amtMatch[0].toLowerCase().includes('billion') || amtMatch[0].includes('b') ? 'B' : 'M';
|
||||
result.amount = `$${m || ''}${unit}`;
|
||||
}
|
||||
|
||||
// Stage
|
||||
|
|
@ -55,7 +68,11 @@ function extractFunding(text: string): Partial<FundingRound> | null {
|
|||
// Investors
|
||||
const invMatch = lower.match(INVESTOR_RE);
|
||||
if (invMatch) {
|
||||
result.investors = invMatch[0].replace(/led\s+by\s+/i, '').split(/[,&]/).map(s => s.trim()).filter(Boolean);
|
||||
result.investors = invMatch[0]
|
||||
.replace(/led\s+by\s+/i, '')
|
||||
.split(/[,&]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Chain
|
||||
|
|
@ -65,28 +82,28 @@ function extractFunding(text: string): Partial<FundingRound> | null {
|
|||
return Object.keys(result).length > 0 ? result : null;
|
||||
}
|
||||
|
||||
export async function handleFundingFeed(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleFundingFeed(request: Request, _env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get('limit')) || 25;
|
||||
const chainFilter = url.searchParams.get('chain') || '';
|
||||
const stageFilter = url.searchParams.get('stage') || '';
|
||||
|
||||
|
||||
const allRounds: FundingRound[] = [];
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
const _results = await Promise.allSettled(
|
||||
FUNDING_SOURCES.slice(0, 6).map(async (src) => {
|
||||
try {
|
||||
// We don't have the RSS SDK imported statically, so use dynamic import
|
||||
const mod = await import('@degenfeed/rss-sdk');
|
||||
const source = await mod.GenericRssSource.fetch(src.url);
|
||||
const posts = mod.feedToUnifiedPosts(source);
|
||||
|
||||
|
||||
for (const post of posts.slice(0, 10)) {
|
||||
try {
|
||||
const extracted = extractFunding(post.text);
|
||||
if (extracted) {
|
||||
allRounds.push({
|
||||
id: 'funding:' + Buffer.from(post.text.slice(0, 50)).toString('base64').slice(0, 20),
|
||||
id: `funding:${Buffer.from(post.text.slice(0, 50)).toString('base64').slice(0, 20)}`,
|
||||
project: post.author.displayName || 'Unknown',
|
||||
description: post.text.slice(0, 300),
|
||||
amount: extracted.amount || 'Unknown',
|
||||
|
|
@ -102,13 +119,15 @@ export async function handleFundingFeed(request: Request, env: Env): Promise<Res
|
|||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
// Filter
|
||||
let filtered = allRounds;
|
||||
if (chainFilter) filtered = filtered.filter(r => r.chain?.toLowerCase().includes(chainFilter.toLowerCase()));
|
||||
if (stageFilter) filtered = filtered.filter(r => r.stage?.toLowerCase().includes(stageFilter.toLowerCase()));
|
||||
if (chainFilter)
|
||||
filtered = filtered.filter((r) => r.chain?.toLowerCase().includes(chainFilter.toLowerCase()));
|
||||
if (stageFilter)
|
||||
filtered = filtered.filter((r) => r.stage?.toLowerCase().includes(stageFilter.toLowerCase()));
|
||||
|
||||
// Sort by date, newest first
|
||||
filtered.sort((a, b) => b.announcedAt - a.announcedAt);
|
||||
|
|
@ -121,8 +140,8 @@ export async function handleFundingFeed(request: Request, env: Env): Promise<Res
|
|||
chainFilter: chainFilter || 'all',
|
||||
stageFilter: stageFilter || 'all',
|
||||
totalRaised: filtered.slice(0, limit).reduce((acc, r) => {
|
||||
const num = parseFloat(r.amount.replace(/[$,]/g, ''));
|
||||
return acc + (isNaN(num) ? 0 : num);
|
||||
const num = Number.parseFloat(r.amount.replace(/[$,]/g, ''));
|
||||
return acc + (Number.isNaN(num) ? 0 : num);
|
||||
}, 0),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { type Env, jsonResponse, DEFAULT_RELAYS } from '../index';
|
||||
import { DEFAULT_RELAYS, type Env, jsonResponse } from '../index';
|
||||
|
||||
export async function handleRelaysHealth(env: Env): Promise<Response> {
|
||||
const relayUrls = (env.RELAY_URLS ?? '').split(',').filter(Boolean);
|
||||
const urls = relayUrls.length > 0 ? relayUrls : DEFAULT_RELAYS;
|
||||
|
||||
|
||||
const results: Record<string, string> = {};
|
||||
for (const url of urls.slice(0, 5)) {
|
||||
try {
|
||||
|
|
@ -11,7 +11,10 @@ export async function handleRelaysHealth(env: Env): Promise<Response> {
|
|||
const timeout = setTimeout(() => controller.abort(), 3000);
|
||||
const ws = new WebSocket(url);
|
||||
await new Promise((resolve, reject) => {
|
||||
ws.onopen = () => { ws.close(); resolve(true); };
|
||||
ws.onopen = () => {
|
||||
ws.close();
|
||||
resolve(true);
|
||||
};
|
||||
ws.onerror = () => reject(new Error('connect failed'));
|
||||
setTimeout(() => reject(new Error('timeout')), 3000);
|
||||
});
|
||||
|
|
@ -21,6 +24,9 @@ export async function handleRelaysHealth(env: Env): Promise<Response> {
|
|||
results[url] = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse({ relays: results, healthy: Object.values(results).filter(r => r === 'ok').length });
|
||||
|
||||
return jsonResponse({
|
||||
relays: results,
|
||||
healthy: Object.values(results).filter((r) => r === 'ok').length,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { type Env, jsonResponse } from '../index';
|
||||
import { resolveHandle } from '@degenfeed/identity';
|
||||
import { type Env, jsonResponse } from '../index';
|
||||
|
||||
export async function handleResolveIdentity(request: Request, _env: Env): Promise<Response> {
|
||||
const handle = new URL(request.url).searchParams.get('handle');
|
||||
|
|
@ -8,7 +8,7 @@ export async function handleResolveIdentity(request: Request, _env: Env): Promis
|
|||
}
|
||||
|
||||
// Dynamic import to avoid worker size issues
|
||||
const result = await resolveHandle(handle);
|
||||
const result = await resolveHandle(handle);
|
||||
if (!result) {
|
||||
return jsonResponse({ error: 'Handle not found', handle }, 404);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,9 @@
|
|||
import { keccak_256 } from '@noble/hashes/sha3';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||
import { bytesToHex } from '@noble/hashes/utils';
|
||||
|
||||
// LensHub v1 proxy on Polygon mainnet
|
||||
export const LENS_HUB_ADDRESS = '0xDb46d1Dc155634FbC732f92E853b10B288AD5a1d';
|
||||
|
||||
function strip0x(hex: string): string {
|
||||
return hex.startsWith('0x') ? hex.slice(2) : hex;
|
||||
}
|
||||
|
||||
function pad32(hex: string): string {
|
||||
const stripped = strip0x(hex);
|
||||
return stripped.padStart(64, '0').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a uint256 value as 32-byte hex string.
|
||||
*/
|
||||
|
|
@ -24,11 +15,14 @@ function encodeUint256(value: bigint | number | string): string {
|
|||
/**
|
||||
* Parse a Lens v1 publication id like \"0x01-0x01\" into profileId and pubId.
|
||||
*/
|
||||
export function parsePublicationId(publicationId: string): { profileId: string; pubId: string } | null {
|
||||
export function parsePublicationId(
|
||||
publicationId: string,
|
||||
): { profileId: string; pubId: string } | null {
|
||||
const parts = publicationId.split('-');
|
||||
if (parts.length !== 2) return null;
|
||||
const profileId = parts[0]!.trim();
|
||||
const pubId = parts[1]!.trim();
|
||||
const profileId = parts[0]?.trim();
|
||||
const pubId = parts[1]?.trim();
|
||||
if (!profileId || !pubId) return null;
|
||||
if (!/^0x[0-9a-fA-F]+$/.test(profileId) || !/^0x[0-9a-fA-F]+$/.test(pubId)) return null;
|
||||
return { profileId, pubId };
|
||||
}
|
||||
|
|
@ -43,14 +37,11 @@ export function buildLensCollectCalldata(profileId: string, pubId: string): stri
|
|||
|
||||
// ABI encoding for collect(uint256,uint256,bytes)
|
||||
// profileId (32), pubId (32), bytes offset (32 = 0x60), bytes length (32 = 0)
|
||||
const calldata =
|
||||
selector +
|
||||
encodeUint256(profileId) +
|
||||
encodeUint256(pubId) +
|
||||
'0000000000000000000000000000000000000000000000000000000000000060' +
|
||||
'0000000000000000000000000000000000000000000000000000000000000000';
|
||||
const calldata = `${
|
||||
selector + encodeUint256(profileId) + encodeUint256(pubId)
|
||||
}00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000`;
|
||||
|
||||
return '0x' + calldata;
|
||||
return `0x${calldata}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import { bytesToHex } from '@noble/hashes/utils';
|
|||
*/
|
||||
|
||||
export interface ZapInvoice {
|
||||
pr: string; // Bolt11 invoice
|
||||
pr: string; // Bolt11 invoice
|
||||
description: string;
|
||||
description_hash: string;
|
||||
amount: number; // msat
|
||||
amount: number; // msat
|
||||
expires_at: number;
|
||||
// LNURL-pay response extras
|
||||
allowsNsec?: boolean;
|
||||
|
|
@ -21,10 +21,36 @@ export interface ZapInvoice {
|
|||
reason?: string;
|
||||
}
|
||||
|
||||
interface NostrProfileResponse {
|
||||
profile?: { content?: string };
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface LnurlPayParams {
|
||||
status?: string;
|
||||
callback: string;
|
||||
minSendable?: number;
|
||||
maxSendable?: number;
|
||||
commentAllowed?: number;
|
||||
allowsNsec?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface LnurlInvoiceResponse {
|
||||
status?: string;
|
||||
pr?: string;
|
||||
description?: string;
|
||||
description_hash?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve LNURL from a Nostr pubkey by fetching their kind 0 metadata.
|
||||
*/
|
||||
export async function resolveLnurlFromPubkey(pubkey: string, relays: string[]): Promise<string | null> {
|
||||
export async function resolveLnurlFromPubkey(
|
||||
pubkey: string,
|
||||
relays: string[],
|
||||
): Promise<string | null> {
|
||||
// Try popular relay aggregators first (faster)
|
||||
const aggregators = [
|
||||
`https://api.nostr.best/nprofile/${pubkey}`,
|
||||
|
|
@ -35,13 +61,15 @@ export async function resolveLnurlFromPubkey(pubkey: string, relays: string[]):
|
|||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
||||
if (!res.ok) continue;
|
||||
const data = await res.json() as any;
|
||||
const data = (await res.json()) as NostrProfileResponse;
|
||||
const metadata = data?.profile?.content || data?.content;
|
||||
if (metadata) {
|
||||
const lnurl = extractLnurl(metadata);
|
||||
if (lnurl) return lnurl;
|
||||
}
|
||||
} catch { /* try next */ }
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: subscribe to relays via WebSocket for kind 0 metadata
|
||||
|
|
@ -62,7 +90,7 @@ function extractLnurl(metadataStr: string): string | null {
|
|||
if (lnurl) return lnurl.toString();
|
||||
|
||||
// Check in nip05 field (sometimes contains LNURL)
|
||||
if (raw.nip05 && raw.nip05.includes('@')) {
|
||||
if (raw.nip05?.includes('@')) {
|
||||
// Convert user@domain.com to lnurl
|
||||
return lnaddressToLnurl(raw.nip05);
|
||||
}
|
||||
|
|
@ -82,7 +110,6 @@ function lnaddressToLnurl(address: string): string {
|
|||
* Fetch an invoice from an LNURL-pay endpoint.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Fetch LNURL from a Nostr relay by subscribing to kind 0 metadata.
|
||||
* Uses WebSocket; works in Cloudflare Workers.
|
||||
|
|
@ -105,7 +132,11 @@ function fetchLnurlFromRelay(pubkey: string, relay: string): Promise<string | nu
|
|||
const cleanup = () => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
try {
|
||||
ws.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -120,14 +151,17 @@ function fetchLnurlFromRelay(pubkey: string, relay: string): Promise<string | nu
|
|||
|
||||
ws.addEventListener('message', (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
if (msg[0] === 'EVENT' && msg[1] === subId && msg[2]?.content) {
|
||||
const lnurl = extractLnurl(msg[2].content);
|
||||
if (lnurl) {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve(lnurl);
|
||||
return;
|
||||
const msg = JSON.parse(event.data as string) as unknown[];
|
||||
if (msg[0] === 'EVENT' && msg[1] === subId) {
|
||||
const content = (msg[2] as { content?: string } | undefined)?.content;
|
||||
if (content) {
|
||||
const lnurl = extractLnurl(content);
|
||||
if (lnurl) {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve(lnurl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (msg[0] === 'EOSE' && msg[1] === subId) {
|
||||
|
|
@ -135,7 +169,9 @@ function fetchLnurlFromRelay(pubkey: string, relay: string): Promise<string | nu
|
|||
cleanup();
|
||||
resolve(null);
|
||||
}
|
||||
} catch { /* ignore malformed */ }
|
||||
} catch {
|
||||
/* ignore malformed */
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('error', () => {
|
||||
|
|
@ -164,7 +200,7 @@ function bech32Polymod(values: number[]): number {
|
|||
const b = chk >> 25;
|
||||
chk = ((chk & 0x1ffffff) << 5) ^ v;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
chk ^= ((b >> i) & 1) ? GEN[i]! : 0;
|
||||
chk ^= (b >> i) & 1 ? (GEN[i] ?? 0) : 0;
|
||||
}
|
||||
}
|
||||
return chk;
|
||||
|
|
@ -186,7 +222,8 @@ function bech32Decode(bech: string): Uint8Array {
|
|||
let hasLower = false;
|
||||
let hasUpper = false;
|
||||
for (let i = 0; i < bech.length; i++) {
|
||||
const c = bech[i]!;
|
||||
const c = bech[i];
|
||||
if (c === undefined) continue;
|
||||
if (c >= 'a' && c <= 'z') hasLower = true;
|
||||
if (c >= 'A' && c <= 'Z') hasUpper = true;
|
||||
}
|
||||
|
|
@ -203,7 +240,9 @@ function bech32Decode(bech: string): Uint8Array {
|
|||
|
||||
const data = new Uint8Array(dataPart.length);
|
||||
for (let i = 0; i < dataPart.length; i++) {
|
||||
const idx = CHARSET.indexOf(dataPart[i]!);
|
||||
const c = dataPart[i];
|
||||
if (c === undefined) throw new Error('Invalid bech32 character');
|
||||
const idx = CHARSET.indexOf(c);
|
||||
if (idx < 0) throw new Error('Invalid bech32 character');
|
||||
data[i] = idx;
|
||||
values.push(idx);
|
||||
|
|
@ -237,9 +276,7 @@ export async function fetchZapInvoice(
|
|||
): Promise<ZapInvoice | { status: 'ERROR'; reason: string }> {
|
||||
try {
|
||||
// If it's a lightning address, convert to URL first
|
||||
const url = lnurl.includes('@') && !lnurl.startsWith('http')
|
||||
? lnaddressToLnurl(lnurl)
|
||||
: lnurl;
|
||||
const url = lnurl.includes('@') && !lnurl.startsWith('http') ? lnaddressToLnurl(lnurl) : lnurl;
|
||||
|
||||
// Check if it's already a bech32 LNURL
|
||||
let endpoint: string;
|
||||
|
|
@ -256,7 +293,7 @@ export async function fetchZapInvoice(
|
|||
if (!paramsRes.ok) {
|
||||
return { status: 'ERROR', reason: 'LNURL endpoint unreachable' };
|
||||
}
|
||||
const params = await paramsRes.json() as any;
|
||||
const params = (await paramsRes.json()) as LnurlPayParams;
|
||||
|
||||
if (params.status === 'ERROR') {
|
||||
return { status: 'ERROR', reason: params.reason || 'LNURL error' };
|
||||
|
|
@ -288,12 +325,16 @@ export async function fetchZapInvoice(
|
|||
if (!invoiceRes.ok) {
|
||||
return { status: 'ERROR', reason: 'Invoice endpoint unreachable' };
|
||||
}
|
||||
const invoice = await invoiceRes.json() as any;
|
||||
const invoice = (await invoiceRes.json()) as LnurlInvoiceResponse;
|
||||
|
||||
if (invoice.status === 'ERROR') {
|
||||
return { status: 'ERROR', reason: invoice.reason || 'Invoice error' };
|
||||
}
|
||||
|
||||
if (!invoice.pr) {
|
||||
return { status: 'ERROR', reason: 'No payment request in invoice response' };
|
||||
}
|
||||
|
||||
return {
|
||||
pr: invoice.pr,
|
||||
description: invoice.description || '',
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
* Top-tier crypto / AI / tech news aggregator.
|
||||
*/
|
||||
|
||||
import type { UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
|
||||
import { type Env, jsonResponse } from '../index';
|
||||
import type { UnifiedPost, UnifiedIdentity } from '@degenfeed/types';
|
||||
|
||||
interface NewsSource {
|
||||
name: string;
|
||||
|
|
@ -18,40 +18,250 @@ interface NewsSource {
|
|||
}
|
||||
|
||||
const NEWS_SOURCES: NewsSource[] = [
|
||||
{ name: 'CoinDesk', type: 'rss', url: 'https://www.coindesk.com/arc/outboundfeeds/rss/', tier: 1, mustRead: true, weight: 1.5 },
|
||||
{ name: 'CoinTelegraph', type: 'rss', url: 'https://cointelegraph.com/rss', tier: 1, mustRead: true, weight: 1.5 },
|
||||
{ name: 'The Block', type: 'rss', url: 'https://www.theblockcrypto.com/rss.xml', tier: 1, mustRead: true, weight: 1.5 },
|
||||
{ name: 'Decrypt', type: 'rss', url: 'https://decrypt.co/feed', tier: 1, mustRead: true, weight: 1.4 },
|
||||
{ name: 'Blockworks', type: 'rss', url: 'https://blockworks.co/feed', tier: 1, mustRead: true, weight: 1.4 },
|
||||
{ name: 'Bitcoin Magazine', type: 'rss', url: 'https://bitcoinmagazine.com/feed', tier: 1, mustRead: true, weight: 1.3 },
|
||||
{ name: 'CryptoSlate', type: 'rss', url: 'https://cryptoslate.com/feed/', tier: 1, mustRead: false, weight: 1.1 },
|
||||
{
|
||||
name: 'CoinDesk',
|
||||
type: 'rss',
|
||||
url: 'https://www.coindesk.com/arc/outboundfeeds/rss/',
|
||||
tier: 1,
|
||||
mustRead: true,
|
||||
weight: 1.5,
|
||||
},
|
||||
{
|
||||
name: 'CoinTelegraph',
|
||||
type: 'rss',
|
||||
url: 'https://cointelegraph.com/rss',
|
||||
tier: 1,
|
||||
mustRead: true,
|
||||
weight: 1.5,
|
||||
},
|
||||
{
|
||||
name: 'The Block',
|
||||
type: 'rss',
|
||||
url: 'https://www.theblockcrypto.com/rss.xml',
|
||||
tier: 1,
|
||||
mustRead: true,
|
||||
weight: 1.5,
|
||||
},
|
||||
{
|
||||
name: 'Decrypt',
|
||||
type: 'rss',
|
||||
url: 'https://decrypt.co/feed',
|
||||
tier: 1,
|
||||
mustRead: true,
|
||||
weight: 1.4,
|
||||
},
|
||||
{
|
||||
name: 'Blockworks',
|
||||
type: 'rss',
|
||||
url: 'https://blockworks.co/feed',
|
||||
tier: 1,
|
||||
mustRead: true,
|
||||
weight: 1.4,
|
||||
},
|
||||
{
|
||||
name: 'Bitcoin Magazine',
|
||||
type: 'rss',
|
||||
url: 'https://bitcoinmagazine.com/feed',
|
||||
tier: 1,
|
||||
mustRead: true,
|
||||
weight: 1.3,
|
||||
},
|
||||
{
|
||||
name: 'CryptoSlate',
|
||||
type: 'rss',
|
||||
url: 'https://cryptoslate.com/feed/',
|
||||
tier: 1,
|
||||
mustRead: false,
|
||||
weight: 1.1,
|
||||
},
|
||||
|
||||
{ name: 'Bankless', type: 'rss', url: 'https://bankless.substack.com/feed', tier: 2, mustRead: true, weight: 1.3 },
|
||||
{ name: 'The Defiant', type: 'rss', url: 'https://thedefiant.io/feed', tier: 2, mustRead: true, weight: 1.3 },
|
||||
{ name: 'Week in Ethereum', type: 'rss', url: 'https://weekinethereumnews.com/feed', tier: 2, mustRead: true, weight: 1.2 },
|
||||
{ name: 'ETH Daily', type: 'rss', url: 'https://ethdaily.substack.com/feed', tier: 2, mustRead: false, weight: 1.1 },
|
||||
{ name: 'Messari', type: 'rss', url: 'https://messari.io/rss', tier: 2, mustRead: false, weight: 1.1 },
|
||||
{ name: 'Unchained', type: 'rss', url: 'https://unchainedcrypto.com/feed/', tier: 2, mustRead: false, weight: 1.1 },
|
||||
{ name: 'Milk Road', type: 'rss', url: 'https://milkroad.substack.com/feed', tier: 2, mustRead: false, weight: 1.0 },
|
||||
{ name: 'The DeFi Edge', type: 'rss', url: 'https://thedefiedge.substack.com/feed', tier: 2, mustRead: false, weight: 1.0 },
|
||||
{
|
||||
name: 'Bankless',
|
||||
type: 'rss',
|
||||
url: 'https://bankless.substack.com/feed',
|
||||
tier: 2,
|
||||
mustRead: true,
|
||||
weight: 1.3,
|
||||
},
|
||||
{
|
||||
name: 'The Defiant',
|
||||
type: 'rss',
|
||||
url: 'https://thedefiant.io/feed',
|
||||
tier: 2,
|
||||
mustRead: true,
|
||||
weight: 1.3,
|
||||
},
|
||||
{
|
||||
name: 'Week in Ethereum',
|
||||
type: 'rss',
|
||||
url: 'https://weekinethereumnews.com/feed',
|
||||
tier: 2,
|
||||
mustRead: true,
|
||||
weight: 1.2,
|
||||
},
|
||||
{
|
||||
name: 'ETH Daily',
|
||||
type: 'rss',
|
||||
url: 'https://ethdaily.substack.com/feed',
|
||||
tier: 2,
|
||||
mustRead: false,
|
||||
weight: 1.1,
|
||||
},
|
||||
{
|
||||
name: 'Messari',
|
||||
type: 'rss',
|
||||
url: 'https://messari.io/rss',
|
||||
tier: 2,
|
||||
mustRead: false,
|
||||
weight: 1.1,
|
||||
},
|
||||
{
|
||||
name: 'Unchained',
|
||||
type: 'rss',
|
||||
url: 'https://unchainedcrypto.com/feed/',
|
||||
tier: 2,
|
||||
mustRead: false,
|
||||
weight: 1.1,
|
||||
},
|
||||
{
|
||||
name: 'Milk Road',
|
||||
type: 'rss',
|
||||
url: 'https://milkroad.substack.com/feed',
|
||||
tier: 2,
|
||||
mustRead: false,
|
||||
weight: 1.0,
|
||||
},
|
||||
{
|
||||
name: 'The DeFi Edge',
|
||||
type: 'rss',
|
||||
url: 'https://thedefiedge.substack.com/feed',
|
||||
tier: 2,
|
||||
mustRead: false,
|
||||
weight: 1.0,
|
||||
},
|
||||
|
||||
{ name: 'Dune Analytics', type: 'rss', url: 'https://dune.com/feed', tier: 3, mustRead: false, weight: 1.0 },
|
||||
{ name: 'Glassnode', type: 'rss', url: 'https://glassnode.com/blog/feed', tier: 3, mustRead: false, weight: 1.0 },
|
||||
{ name: 'Nansen', type: 'rss', url: 'https://nansen.substack.com/feed', tier: 3, mustRead: false, weight: 1.0 },
|
||||
{ name: 'Token Terminal', type: 'rss', url: 'https://tokenterminal.com/feed', tier: 3, mustRead: false, weight: 1.0 },
|
||||
{ name: 'DeFiLlama', type: 'rss', url: 'https://defillama.com/feed', tier: 3, mustRead: false, weight: 1.0 },
|
||||
{ name: 'L2Beat', type: 'rss', url: 'https://l2beat.com/feed.xml', tier: 3, mustRead: false, weight: 1.0 },
|
||||
{ name: 'Rekt News', type: 'rss', url: 'https://rekt.news/feed.xml', tier: 3, mustRead: true, weight: 1.2 },
|
||||
{ name: 'Immunefi', type: 'rss', url: 'https://medium.com/feed/immunefi', tier: 3, mustRead: false, weight: 1.0 },
|
||||
{ name: 'SlowMist', type: 'rss', url: 'https://slowmist.medium.com/feed', tier: 3, mustRead: false, weight: 1.0 },
|
||||
{
|
||||
name: 'Dune Analytics',
|
||||
type: 'rss',
|
||||
url: 'https://dune.com/feed',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 1.0,
|
||||
},
|
||||
{
|
||||
name: 'Glassnode',
|
||||
type: 'rss',
|
||||
url: 'https://glassnode.com/blog/feed',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 1.0,
|
||||
},
|
||||
{
|
||||
name: 'Nansen',
|
||||
type: 'rss',
|
||||
url: 'https://nansen.substack.com/feed',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 1.0,
|
||||
},
|
||||
{
|
||||
name: 'Token Terminal',
|
||||
type: 'rss',
|
||||
url: 'https://tokenterminal.com/feed',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 1.0,
|
||||
},
|
||||
{
|
||||
name: 'DeFiLlama',
|
||||
type: 'rss',
|
||||
url: 'https://defillama.com/feed',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 1.0,
|
||||
},
|
||||
{
|
||||
name: 'L2Beat',
|
||||
type: 'rss',
|
||||
url: 'https://l2beat.com/feed.xml',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 1.0,
|
||||
},
|
||||
{
|
||||
name: 'Rekt News',
|
||||
type: 'rss',
|
||||
url: 'https://rekt.news/feed.xml',
|
||||
tier: 3,
|
||||
mustRead: true,
|
||||
weight: 1.2,
|
||||
},
|
||||
{
|
||||
name: 'Immunefi',
|
||||
type: 'rss',
|
||||
url: 'https://medium.com/feed/immunefi',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 1.0,
|
||||
},
|
||||
{
|
||||
name: 'SlowMist',
|
||||
type: 'rss',
|
||||
url: 'https://slowmist.medium.com/feed',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 1.0,
|
||||
},
|
||||
|
||||
{ name: 'TechCrunch', type: 'rss', url: 'https://techcrunch.com/feed/', tier: 4, mustRead: false, weight: 0.9 },
|
||||
{ name: 'The Verge', type: 'rss', url: 'https://www.theverge.com/rss/index.xml', tier: 4, mustRead: false, weight: 0.9 },
|
||||
{
|
||||
name: 'TechCrunch',
|
||||
type: 'rss',
|
||||
url: 'https://techcrunch.com/feed/',
|
||||
tier: 4,
|
||||
mustRead: false,
|
||||
weight: 0.9,
|
||||
},
|
||||
{
|
||||
name: 'The Verge',
|
||||
type: 'rss',
|
||||
url: 'https://www.theverge.com/rss/index.xml',
|
||||
tier: 4,
|
||||
mustRead: false,
|
||||
weight: 0.9,
|
||||
},
|
||||
|
||||
{ name: 'r/CryptoCurrency', type: 'reddit', url: 'https://old.reddit.com/r/CryptoCurrency/hot.rss?limit=25', tier: 3, mustRead: false, weight: 0.8 },
|
||||
{ name: 'r/ethfinance', type: 'reddit', url: 'https://old.reddit.com/r/ethfinance/hot.rss?limit=25', tier: 3, mustRead: false, weight: 0.8 },
|
||||
{ name: 'r/solana', type: 'reddit', url: 'https://old.reddit.com/r/solana/hot.rss?limit=25', tier: 3, mustRead: false, weight: 0.8 },
|
||||
{ name: 'r/ethdev', type: 'reddit', url: 'https://old.reddit.com/r/ethdev/hot.rss?limit=25', tier: 3, mustRead: false, weight: 0.8 },
|
||||
{
|
||||
name: 'r/CryptoCurrency',
|
||||
type: 'reddit',
|
||||
url: 'https://old.reddit.com/r/CryptoCurrency/hot.rss?limit=25',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 0.8,
|
||||
},
|
||||
{
|
||||
name: 'r/ethfinance',
|
||||
type: 'reddit',
|
||||
url: 'https://old.reddit.com/r/ethfinance/hot.rss?limit=25',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 0.8,
|
||||
},
|
||||
{
|
||||
name: 'r/solana',
|
||||
type: 'reddit',
|
||||
url: 'https://old.reddit.com/r/solana/hot.rss?limit=25',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 0.8,
|
||||
},
|
||||
{
|
||||
name: 'r/ethdev',
|
||||
type: 'reddit',
|
||||
url: 'https://old.reddit.com/r/ethdev/hot.rss?limit=25',
|
||||
tier: 3,
|
||||
mustRead: false,
|
||||
weight: 0.8,
|
||||
},
|
||||
|
||||
{ name: 'Hacker News', type: 'hackernews', url: '', tier: 4, mustRead: false, weight: 1.1 },
|
||||
];
|
||||
|
|
@ -90,7 +300,7 @@ function decodeXmlEntities(str: string): string {
|
|||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d)))
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_m, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_m, h) => String.fromCodePoint(Number.parseInt(h, 16)))
|
||||
.trim();
|
||||
}
|
||||
|
||||
|
|
@ -103,9 +313,10 @@ function extractText(tag: string, xml: string): string {
|
|||
function extractAll(tag: string, xml: string): string[] {
|
||||
const re = new RegExp(`<${tag}(?:[^>]*)>([\\s\\S]*?)<\\/${tag}>`, 'gi');
|
||||
const out: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(xml)) !== null) {
|
||||
let m: RegExpExecArray | null = re.exec(xml);
|
||||
while (m !== null) {
|
||||
out.push(decodeXmlEntities(m[1] || ''));
|
||||
m = re.exec(xml);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -113,15 +324,18 @@ function extractAll(tag: string, xml: string): string[] {
|
|||
function parseRss(xml: string): RssItem[] {
|
||||
const items: RssItem[] = [];
|
||||
const itemRe = /<item([^>]*)>([\s\S]*?)<\/item>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
let m: RegExpExecArray | null = itemRe.exec(xml);
|
||||
|
||||
while ((m = itemRe.exec(xml)) !== null) {
|
||||
while (m !== null) {
|
||||
const itemXml = m[2] || '';
|
||||
const title = extractText('title', itemXml);
|
||||
const link = extractText('link', itemXml) || extractText('guid', itemXml);
|
||||
const description = extractText('description', itemXml);
|
||||
const content = extractText('content:encoded', itemXml) || extractText('content', itemXml);
|
||||
const pubDate = extractText('pubDate', itemXml) || extractText('dc:date', itemXml) || extractText('published', itemXml);
|
||||
const pubDate =
|
||||
extractText('pubDate', itemXml) ||
|
||||
extractText('dc:date', itemXml) ||
|
||||
extractText('published', itemXml);
|
||||
const creator = extractText('dc:creator', itemXml) || extractText('author', itemXml);
|
||||
const categories = extractAll('category', itemXml);
|
||||
|
||||
|
|
@ -136,6 +350,7 @@ function parseRss(xml: string): RssItem[] {
|
|||
if (title && link) {
|
||||
items.push({ title, link, description, pubDate, creator, content, image, categories });
|
||||
}
|
||||
m = itemRe.exec(xml);
|
||||
}
|
||||
|
||||
return items;
|
||||
|
|
@ -144,9 +359,9 @@ function parseRss(xml: string): RssItem[] {
|
|||
function parseAtom(xml: string): RssItem[] {
|
||||
const items: RssItem[] = [];
|
||||
const entryRe = /<entry([^>]*)>([\s\S]*?)<\/entry>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
const m: RegExpExecArray | null = entryRe.exec(xml);
|
||||
|
||||
while ((m = entryRe.exec(xml)) !== null) {
|
||||
while (m !== null) {
|
||||
const entryXml = m[2] || '';
|
||||
const title = extractText('title', entryXml);
|
||||
const linkMatch = entryXml.match(/<link[^>]+href\s*=\s*["']([^"']+)["']/i);
|
||||
|
|
@ -216,9 +431,11 @@ async function fetchHackerNews(): Promise<RssItem[]> {
|
|||
try {
|
||||
const res = await fetchWithTimeout(url, 6000);
|
||||
if (!res?.ok) continue;
|
||||
const data = await res.json() as { hits?: HnHit[] };
|
||||
const data = (await res.json()) as { hits?: HnHit[] };
|
||||
if (data.hits) hits.push(...data.hits);
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return hits.map((h) => ({
|
||||
|
|
@ -243,7 +460,9 @@ function normalizeUrl(url: string): string {
|
|||
try {
|
||||
const u = new URL(url);
|
||||
u.hash = '';
|
||||
['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'ref', 'source'].forEach(p => u.searchParams.delete(p));
|
||||
for (const p of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'ref', 'source']) {
|
||||
u.searchParams.delete(p);
|
||||
}
|
||||
return u.toString();
|
||||
} catch {
|
||||
return url;
|
||||
|
|
@ -255,7 +474,7 @@ function toUnifiedPost(item: RssItem, source: NewsSource): UnifiedPost {
|
|||
const text = stripTags(item.content || item.description || item.title).slice(0, 600);
|
||||
const html = item.content || item.description || '';
|
||||
const pubDate = item.pubDate ? new Date(item.pubDate).getTime() : Date.now();
|
||||
const createdAt = isNaN(pubDate) ? Date.now() : pubDate;
|
||||
const createdAt = Number.isNaN(pubDate) ? Date.now() : pubDate;
|
||||
|
||||
const embeds: { kind: 'image' | 'link' | 'video'; url: string }[] = [];
|
||||
if (item.image) embeds.push({ kind: 'image', url: item.image });
|
||||
|
|
@ -265,7 +484,15 @@ function toUnifiedPost(item: RssItem, source: NewsSource): UnifiedPost {
|
|||
const author: UnifiedIdentity = {
|
||||
id: authorId,
|
||||
primary: { protocol: 'rss', id: authorId },
|
||||
handles: { nostr: null, farcaster: null, lens: null, bluesky: null, threads: null, rss: null, mastodon: null },
|
||||
handles: {
|
||||
nostr: null,
|
||||
farcaster: null,
|
||||
lens: null,
|
||||
bluesky: null,
|
||||
threads: null,
|
||||
rss: null,
|
||||
mastodon: null,
|
||||
},
|
||||
displayName: source.name,
|
||||
bio: '',
|
||||
avatarUrl: '',
|
||||
|
|
@ -276,7 +503,9 @@ function toUnifiedPost(item: RssItem, source: NewsSource): UnifiedPost {
|
|||
keys: {},
|
||||
};
|
||||
|
||||
const categories = [...new Set([...(source.category ? [source.category] : []), ...item.categories])];
|
||||
const categories = [
|
||||
...new Set([...(source.category ? [source.category] : []), ...item.categories]),
|
||||
];
|
||||
const niches = detectNiches(`${text} ${categories.join(' ')} ${source.name}`);
|
||||
|
||||
return {
|
||||
|
|
@ -288,9 +517,13 @@ function toUnifiedPost(item: RssItem, source: NewsSource): UnifiedPost {
|
|||
embeds,
|
||||
createdAt,
|
||||
metrics: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
|
||||
engagement: { viewerLiked: false, viewerReposted: false, raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 } },
|
||||
engagement: {
|
||||
viewerLiked: false,
|
||||
viewerReposted: false,
|
||||
raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
|
||||
},
|
||||
raw: { item, source },
|
||||
niches: [...new Set([...niches, ...categories.map(c => c.toLowerCase())])],
|
||||
niches: [...new Set([...niches, ...categories.map((c) => c.toLowerCase())])],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -300,9 +533,27 @@ const NICHE_KEYWORDS: Record<string, string[]> = {
|
|||
solana: ['solana', 'sol ', 'solana-based'],
|
||||
defi: ['defi', 'lending', 'yield', 'liquidity', 'amm', 'dex', 'swap', 'staking', 'restaking'],
|
||||
nft: ['nft', 'nfts', 'collectible', 'punk', 'azuki', 'opensea', 'blur'],
|
||||
ai: ['ai ', 'artificial intelligence', 'machine learning', 'llm', 'gpt', 'openai', 'anthropic', 'deepseek'],
|
||||
ai: [
|
||||
'ai ',
|
||||
'artificial intelligence',
|
||||
'machine learning',
|
||||
'llm',
|
||||
'gpt',
|
||||
'openai',
|
||||
'anthropic',
|
||||
'deepseek',
|
||||
],
|
||||
regulation: ['sec', 'regulation', 'mica', 'cftc', 'congress', 'senate', 'policy', 'compliance'],
|
||||
security: ['hack', 'exploit', 'vulnerability', 'audit', 'phishing', 'rug pull', 'rekt', 'immunefi'],
|
||||
security: [
|
||||
'hack',
|
||||
'exploit',
|
||||
'vulnerability',
|
||||
'audit',
|
||||
'phishing',
|
||||
'rug pull',
|
||||
'rekt',
|
||||
'immunefi',
|
||||
],
|
||||
macro: ['fed', 'fomc', 'inflation', 'interest rate', 'dollar', 'recession', 'nasdaq', 'spx'],
|
||||
stablecoin: ['stablecoin', 'usdt', 'usdc', 'tether', 'circle'],
|
||||
layer2: ['layer 2', 'l2', 'rollup', 'arbitrum', 'optimism', 'base', 'zk-', 'zero knowledge'],
|
||||
|
|
@ -315,7 +566,7 @@ function detectNiches(text: string): string[] {
|
|||
const lower = text.toLowerCase();
|
||||
const niches: string[] = [];
|
||||
for (const [niche, terms] of Object.entries(NICHE_KEYWORDS)) {
|
||||
if (terms.some(t => lower.includes(t))) niches.push(niche);
|
||||
if (terms.some((t) => lower.includes(t))) niches.push(niche);
|
||||
}
|
||||
return [...new Set(niches)];
|
||||
}
|
||||
|
|
@ -344,7 +595,9 @@ function extractTrending(posts: UnifiedPost[], limit = 12): TrendingTopic[] {
|
|||
counts[niche].score += 1;
|
||||
}
|
||||
const text = `${post.text} ${post.author.displayName}`.toUpperCase();
|
||||
const tickers = Array.from(text.matchAll(/\$([A-Z]{2,5})\b/g)).map(m => m[1]).filter((t): t is string => !!t);
|
||||
const tickers = Array.from(text.matchAll(/\$([A-Z]{2,5})\b/g))
|
||||
.map((m) => m[1])
|
||||
.filter((t): t is string => !!t);
|
||||
for (const t of tickers) {
|
||||
if (!counts[t]) counts[t] = { count: 0, score: 0 };
|
||||
counts[t].count++;
|
||||
|
|
@ -372,14 +625,16 @@ export async function handleNewsFeed(request: Request, env: Env): Promise<Respon
|
|||
const parsed = JSON.parse(cached);
|
||||
return jsonResponse(parsed);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
NEWS_SOURCES.map(async (source) => {
|
||||
const items = await fetchSource(source);
|
||||
return items.map(i => ({ item: i, source }));
|
||||
return items.map((i) => ({ item: i, source }));
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -406,28 +661,35 @@ export async function handleNewsFeed(request: Request, env: Env): Promise<Respon
|
|||
if (mustRead && !source.mustRead) continue;
|
||||
|
||||
if (query) {
|
||||
const haystack = `${post.text} ${post.author.displayName} ${post.niches.join(' ')}`.toLowerCase();
|
||||
const haystack =
|
||||
`${post.text} ${post.author.displayName} ${post.niches.join(' ')}`.toLowerCase();
|
||||
if (!haystack.includes(query)) continue;
|
||||
}
|
||||
|
||||
const score = rankPost(post, source, now);
|
||||
posts.push({ post, source, score });
|
||||
} catch { /* skip */ }
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
|
||||
posts.sort((a, b) => b.score - a.score);
|
||||
|
||||
for (const p of posts) {
|
||||
(p.post.raw as any).mustRead = p.source.mustRead;
|
||||
(p.post.raw as any).tier = p.source.tier;
|
||||
const raw = p.post.raw as Record<string, unknown>;
|
||||
raw.mustRead = p.source.mustRead;
|
||||
raw.tier = p.source.tier;
|
||||
}
|
||||
|
||||
const topPosts = posts.slice(0, limit).map(p => p.post);
|
||||
const trending = extractTrending(posts.map(p => p.post));
|
||||
const sourceCounts = NEWS_SOURCES.reduce((acc, s) => {
|
||||
acc[s.name] = posts.filter(p => p.source.name === s.name).length;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const topPosts = posts.slice(0, limit).map((p) => p.post);
|
||||
const trending = extractTrending(posts.map((p) => p.post));
|
||||
const sourceCounts = NEWS_SOURCES.reduce(
|
||||
(acc, s) => {
|
||||
acc[s.name] = posts.filter((p) => p.source.name === s.name).length;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
const response = {
|
||||
data: topPosts,
|
||||
|
|
@ -446,7 +708,9 @@ export async function handleNewsFeed(request: Request, env: Env): Promise<Respon
|
|||
|
||||
try {
|
||||
await env.CACHE?.put(cacheKey, JSON.stringify(response), { expirationTtl: 900 });
|
||||
} catch { /* ignore */ }
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
return jsonResponse(response);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,31 +13,64 @@ interface Notification {
|
|||
read: boolean;
|
||||
}
|
||||
|
||||
export { type Notification };
|
||||
export type { Notification };
|
||||
|
||||
interface MastodonAccount {
|
||||
id: string;
|
||||
display_name?: string;
|
||||
username?: string;
|
||||
acct?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
interface MastodonStatus {
|
||||
id: string;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface MastodonNotification {
|
||||
id: string;
|
||||
type: string;
|
||||
created_at: string;
|
||||
account?: MastodonAccount;
|
||||
status?: MastodonStatus;
|
||||
}
|
||||
|
||||
async function fetchNostrNotifications(pubkey: string): Promise<Notification[]> {
|
||||
const results: Notification[] = [];
|
||||
try {
|
||||
const mod = await import('@degenfeed/nostr-sdk');
|
||||
const pool = new mod.RelayPool(['wss://relay.damus.io', 'wss://nos.lol'], { eoseTimeout: 3000 });
|
||||
const pool = new mod.RelayPool(['wss://relay.damus.io', 'wss://nos.lol'], {
|
||||
eoseTimeout: 3000,
|
||||
});
|
||||
const reactions = await pool.list({ kinds: [7], limit: 20 }, 3000);
|
||||
for (const ev of reactions) {
|
||||
if (!ev.tags.some((t: string[]) => t[0] === 'p' && t[1] === pubkey)) continue;
|
||||
results.push({
|
||||
id: 'nostr:reaction:' + ev.id, type: 'like' as const, protocol: 'nostr',
|
||||
actorName: ev.pubkey.slice(0, 12), actorHandle: ev.pubkey.slice(0, 12),
|
||||
id: `nostr:reaction:${ev.id}`,
|
||||
type: 'like' as const,
|
||||
protocol: 'nostr',
|
||||
actorName: ev.pubkey.slice(0, 12),
|
||||
actorHandle: ev.pubkey.slice(0, 12),
|
||||
postId: ev.tags.find((t: string[]) => t[0] === 'e')?.[1],
|
||||
createdAt: ev.created_at * 1000, read: false,
|
||||
createdAt: ev.created_at * 1000,
|
||||
read: false,
|
||||
});
|
||||
}
|
||||
const mentions = await pool.list({ kinds: [1], limit: 20 }, 3000);
|
||||
for (const ev of mentions) {
|
||||
if (ev.pubkey === pubkey || !ev.tags.some((t: string[]) => t[0] === 'p' && t[1] === pubkey)) continue;
|
||||
if (ev.pubkey === pubkey || !ev.tags.some((t: string[]) => t[0] === 'p' && t[1] === pubkey))
|
||||
continue;
|
||||
results.push({
|
||||
id: 'nostr:mention:' + ev.id, type: 'mention' as const, protocol: 'nostr',
|
||||
actorName: ev.pubkey.slice(0, 12), actorHandle: ev.pubkey.slice(0, 12),
|
||||
postText: ev.content.slice(0, 100), postId: ev.id,
|
||||
createdAt: ev.created_at * 1000, read: false,
|
||||
id: `nostr:mention:${ev.id}`,
|
||||
type: 'mention' as const,
|
||||
protocol: 'nostr',
|
||||
actorName: ev.pubkey.slice(0, 12),
|
||||
actorHandle: ev.pubkey.slice(0, 12),
|
||||
postText: ev.content.slice(0, 100),
|
||||
postId: ev.id,
|
||||
createdAt: ev.created_at * 1000,
|
||||
read: false,
|
||||
});
|
||||
}
|
||||
pool.disconnectAll();
|
||||
|
|
@ -45,45 +78,64 @@ async function fetchNostrNotifications(pubkey: string): Promise<Notification[]>
|
|||
return results;
|
||||
}
|
||||
|
||||
async function fetchMastodonNotifications(instance: string, token: string): Promise<Notification[]> {
|
||||
async function fetchMastodonNotifications(
|
||||
instance: string,
|
||||
token: string,
|
||||
): Promise<Notification[]> {
|
||||
const results: Notification[] = [];
|
||||
try {
|
||||
const res = await fetch(instance + '/api/v1/notifications?limit=30', {
|
||||
headers: { authorization: 'Bearer ' + token },
|
||||
const res = await fetch(`${instance}/api/v1/notifications?limit=30`, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return results;
|
||||
const data: any[] = await res.json();
|
||||
const data = (await res.json()) as MastodonNotification[];
|
||||
for (const n of data) {
|
||||
const typeMap: Record<string, 'like' | 'repost' | 'reply' | 'follow' | 'mention'> = {
|
||||
favourite: 'like', reblog: 'repost', mention: 'mention', follow: 'follow',
|
||||
favourite: 'like',
|
||||
reblog: 'repost',
|
||||
mention: 'mention',
|
||||
follow: 'follow',
|
||||
};
|
||||
results.push({
|
||||
id: 'mastodon:' + n.id, type: typeMap[n.type] || 'mention', protocol: 'mastodon',
|
||||
id: `mastodon:${n.id}`,
|
||||
type: typeMap[n.type] || 'mention',
|
||||
protocol: 'mastodon',
|
||||
actorName: n.account?.display_name || n.account?.username || '',
|
||||
actorHandle: n.account?.acct || '',
|
||||
actorAvatar: n.account?.avatar,
|
||||
postId: n.status?.id,
|
||||
postText: n.status?.content?.replace(/<[^>]*>/g, '').slice(0, 100),
|
||||
createdAt: new Date(n.created_at).getTime(), read: false,
|
||||
createdAt: new Date(n.created_at).getTime(),
|
||||
read: false,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function handleNotifications(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleNotifications(request: Request, _env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
let auth: Record<string, string> = {};
|
||||
try { auth = JSON.parse(decodeURIComponent(url.searchParams.get('auth') || '{}')); } catch {}
|
||||
try {
|
||||
auth = JSON.parse(decodeURIComponent(url.searchParams.get('auth') || '{}'));
|
||||
} catch {}
|
||||
|
||||
const all: Notification[] = [];
|
||||
|
||||
const npub = auth.nostr_pubkey || '';
|
||||
if (npub) { try { all.push(...await fetchNostrNotifications(npub)); } catch {} }
|
||||
if (npub) {
|
||||
try {
|
||||
all.push(...(await fetchNostrNotifications(npub)));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const mToken = auth.mastodon || '';
|
||||
const mInst = auth.mastodon_instance || 'https://social.degenfeed.xyz';
|
||||
if (mToken) { try { all.push(...await fetchMastodonNotifications(mInst, mToken)); } catch {} }
|
||||
if (mToken) {
|
||||
try {
|
||||
all.push(...(await fetchMastodonNotifications(mInst, mToken)));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
all.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return jsonResponse({ notifications: all.slice(0, 50), meta: { total: all.length } });
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type Env, jsonResponse } from '../index';
|
||||
import type { Env } from '../index';
|
||||
|
||||
export async function handlePrices(request: Request, env: Env): Promise<Response> {
|
||||
export async function handlePrices(request: Request, _env: Env): Promise<Response> {
|
||||
// Pass through to origin server - Cloudflare won't re-trigger this Worker
|
||||
const response = await fetch(request);
|
||||
return response;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type Env, jsonResponse } from '../index';
|
||||
import { GtSClient, statusToUnifiedPost } from '@degenfeed/mastodon-sdk';
|
||||
import { GtSClient, type GtSStatus, statusToUnifiedPost } from '@degenfeed/mastodon-sdk';
|
||||
import { ThreadsClient } from '@degenfeed/threads-sdk';
|
||||
import { type Env, jsonResponse } from '../index';
|
||||
|
||||
export async function handleActivityPubFeed(request: Request, _env: Env): Promise<Response> {
|
||||
const params = new URL(request.url).searchParams;
|
||||
|
|
@ -11,7 +11,7 @@ export async function handleActivityPubFeed(request: Request, _env: Env): Promis
|
|||
|
||||
try {
|
||||
const client = new GtSClient(instance);
|
||||
let statuses;
|
||||
let statuses: unknown[];
|
||||
if (type === 'hashtag' && hashtag) {
|
||||
statuses = await client.getHashtagTimeline(hashtag, limit);
|
||||
} else if (type === 'federated') {
|
||||
|
|
@ -19,7 +19,7 @@ export async function handleActivityPubFeed(request: Request, _env: Env): Promis
|
|||
} else {
|
||||
statuses = await client.getPublicTimeline(limit);
|
||||
}
|
||||
const unified = statuses.map((s: any) => statusToUnifiedPost(s, instance));
|
||||
const unified = statuses.map((s: unknown) => statusToUnifiedPost(s as GtSStatus, instance));
|
||||
return jsonResponse({ data: unified, meta: { total: unified.length, limit, instance } });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'ActivityPub fetch failed';
|
||||
|
|
@ -36,7 +36,10 @@ export async function handleThreadsFeed(request: Request, _env: Env): Promise<Re
|
|||
const client = new ThreadsClient();
|
||||
const { posts: rawPosts, cursor } = await client.getAuthorFeed(actor, limit);
|
||||
const unified = rawPosts.map((p) => client.postToUnifiedPost(p));
|
||||
return jsonResponse({ data: unified.slice(0, limit), meta: { total: unified.length, limit, cursor } });
|
||||
return jsonResponse({
|
||||
data: unified.slice(0, limit),
|
||||
meta: { total: unified.length, limit, cursor },
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Threads fetch failed';
|
||||
return jsonResponse({ error: message }, 502);
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
import {
|
||||
RelayPool,
|
||||
createTextNote,
|
||||
generateSecretKey,
|
||||
getEventHash,
|
||||
getPublicKey,
|
||||
signEvent,
|
||||
} from '@degenfeed/nostr-sdk';
|
||||
import { type Env, jsonResponse } from '../index';
|
||||
import { generateSecretKey, getPublicKey, getEventHash, signEvent, createTextNote, RelayPool } from '@degenfeed/nostr-sdk';
|
||||
|
||||
// Helper: hex string to Uint8Array
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array((hex.match(/.{2}/g) || []).map(b => parseInt(b, 16)));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
|
||||
// ─── RSS-to-Nostr bridge ─────────────────────────────────────────────
|
||||
// Fetches an RSS feed, converts each item to a Nostr text event,
|
||||
// signs with the provided key, and publishes to Nostr relays.
|
||||
|
||||
export async function handleRssToNostrBridge(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleRssToNostrBridge(request: Request, _env: Env): Promise<Response> {
|
||||
try {
|
||||
const body: Record<string, string> = await request.json() as any;
|
||||
const body = (await request.json()) as Record<string, string>;
|
||||
const feedUrl = body.feedUrl;
|
||||
const nsec = body.nsec;
|
||||
if (!feedUrl || !nsec) {
|
||||
|
|
@ -31,19 +31,21 @@ export async function handleRssToNostrBridge(request: Request, env: Env): Promis
|
|||
const nostr = await import('@degenfeed/nostr-sdk');
|
||||
// nsec is a hex string — nostr-sdk functions expect hex strings
|
||||
const pk = nostr.getPublicKey(nsec);
|
||||
const pool = new (nostr.RelayPool)(['wss://relay.damus.io', 'wss://nos.lol'], { eoseTimeout: 3000 });
|
||||
const pool = new nostr.RelayPool(['wss://relay.damus.io', 'wss://nos.lol'], {
|
||||
eoseTimeout: 3000,
|
||||
});
|
||||
|
||||
const results: Array<{ title: string; ok: boolean; error?: string }> = [];
|
||||
const maxItems = Math.min(posts.length, 5); // Limit to 5 to avoid spam
|
||||
|
||||
for (const post of posts.slice(0, maxItems)) {
|
||||
try {
|
||||
const linkUrl = post.embeds.find(e => e.kind === 'link');
|
||||
const text = post.protocol + ': ' + post.text.slice(0, 200) + (linkUrl ? '\n\n' + linkUrl.url : '');
|
||||
const ev: any = nostr.createTextNote(text, nsec, []);
|
||||
const linkUrl = post.embeds.find((e) => e.kind === 'link');
|
||||
const text = `${post.protocol}: ${post.text.slice(0, 200)}${linkUrl ? `\n\n${linkUrl.url}` : ''}`;
|
||||
const ev = nostr.createTextNote(text, nsec, []);
|
||||
ev.pubkey = pk;
|
||||
ev.id = nostr.getEventHash(ev);
|
||||
nostr.signEvent(ev, nsec as any);
|
||||
nostr.signEvent(ev, nsec);
|
||||
await pool.publish(ev);
|
||||
results.push({ title: post.text.slice(0, 50), ok: true });
|
||||
} catch (e) {
|
||||
|
|
@ -69,10 +71,10 @@ export async function handlePublish(request: Request, env: Env): Promise<Respons
|
|||
typedDataId?: string;
|
||||
signature?: string;
|
||||
}
|
||||
const body: PublishRequest = await request.json() as PublishRequest;
|
||||
const body: PublishRequest = (await request.json()) as PublishRequest;
|
||||
if (!body.text?.trim()) return jsonResponse({ error: 'Text required' }, 400);
|
||||
if (!body.targets?.length) return jsonResponse({ error: 'Targets required' }, 400);
|
||||
const auth = body.auth ?? {} as Record<string, string>;
|
||||
const auth = body.auth ?? ({} as Record<string, string>);
|
||||
|
||||
const results: Record<string, { ok: boolean; error?: string; hash?: string }> = {};
|
||||
|
||||
|
|
@ -81,13 +83,15 @@ export async function handlePublish(request: Request, env: Env): Promise<Respons
|
|||
switch (target) {
|
||||
case 'nostr': {
|
||||
const skHex: string = auth.nostr ?? '';
|
||||
const sk: any = skHex ? hexToBytes(skHex) : generateSecretKey();
|
||||
const pk = getPublicKey(sk as any);
|
||||
const ev: any = createTextNote(body.text, sk as string, []);
|
||||
const sk: string = skHex || generateSecretKey();
|
||||
const pk = getPublicKey(sk);
|
||||
const ev = createTextNote(body.text, sk, []);
|
||||
ev.pubkey = pk;
|
||||
ev.id = getEventHash(ev);
|
||||
signEvent(ev, sk as any);
|
||||
const pool = new RelayPool(['wss://relay.damus.io', 'wss://nos.lol'], { eoseTimeout: 2000 });
|
||||
signEvent(ev, sk);
|
||||
const pool = new RelayPool(['wss://relay.damus.io', 'wss://nos.lol'], {
|
||||
eoseTimeout: 2000,
|
||||
});
|
||||
await pool.publish(ev);
|
||||
pool.disconnectAll();
|
||||
results[target] = { ok: true };
|
||||
|
|
@ -102,21 +106,28 @@ export async function handlePublish(request: Request, env: Env): Promise<Respons
|
|||
const signedMessage = body.signedMessage || body.auth?.farcaster_message;
|
||||
if (signedMessage) {
|
||||
// Submit pre-signed message to Hub
|
||||
const submitRes = await fetch(hubUrl + '/v1/submitMessage', {
|
||||
const submitRes = await fetch(`${hubUrl}/v1/submitMessage`, {
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/octet-stream' },
|
||||
body: signedMessage,
|
||||
});
|
||||
if (submitRes.ok) {
|
||||
const submitData = await submitRes.json() as Record<string, unknown>;
|
||||
results[target] = { ok: true, hash: String(submitData.hash || "") || undefined };
|
||||
const submitData = (await submitRes.json()) as Record<string, unknown>;
|
||||
results[target] = { ok: true, hash: String(submitData.hash || '') || undefined };
|
||||
} else {
|
||||
const errText = await submitRes.text();
|
||||
results[target] = { ok: false, error: 'Farcaster Hub rejected: ' + errText.slice(0, 100) };
|
||||
results[target] = {
|
||||
ok: false,
|
||||
error: `Farcaster Hub rejected: ${errText.slice(0, 100)}`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// No pre-signed message — return stub asking client to sign
|
||||
results[target] = { ok: false, error: 'Client must sign cast using @farcaster/hub-web, then submit signedMessage in publish request.' };
|
||||
results[target] = {
|
||||
ok: false,
|
||||
error:
|
||||
'Client must sign cast using @farcaster/hub-web, then submit signedMessage in publish request.',
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -132,20 +143,34 @@ export async function handlePublish(request: Request, env: Env): Promise<Respons
|
|||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: 'mutation Broadcast($request: BroadcastRequest!) { broadcast(request: $request) { ... on RelayerResult { txHash } ... on RelayError { reason } } }',
|
||||
query:
|
||||
'mutation Broadcast($request: BroadcastRequest!) { broadcast(request: $request) { ... on RelayerResult { txHash } ... on RelayError { reason } } }',
|
||||
variables: { request: { id: typedDataId, signature } },
|
||||
}),
|
||||
});
|
||||
if (broadcastRes.ok) {
|
||||
const broadcastData = await broadcastRes.json() as Record<string, unknown>;
|
||||
const result = (broadcastData as any)?.data?.broadcast;
|
||||
results[target] = { ok: !!(result as any)?.txHash, hash: (result as any)?.txHash as string | undefined, error: (result as any)?.reason as string | undefined };
|
||||
const broadcastData = (await broadcastRes.json()) as Record<string, unknown>;
|
||||
const result = (broadcastData.data as Record<string, unknown>)?.broadcast as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
results[target] = {
|
||||
ok: !!result?.txHash,
|
||||
hash: result?.txHash as string | undefined,
|
||||
error: result?.reason as string | undefined,
|
||||
};
|
||||
} else {
|
||||
const errText = await broadcastRes.text();
|
||||
results[target] = { ok: false, error: 'Lens broadcast failed: ' + errText.slice(0, 100) };
|
||||
results[target] = {
|
||||
ok: false,
|
||||
error: `Lens broadcast failed: ${errText.slice(0, 100)}`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
results[target] = { ok: false, error: 'Client must sign typed data using wallet, then submit typedDataId + signature in publish request.' };
|
||||
results[target] = {
|
||||
ok: false,
|
||||
error:
|
||||
'Client must sign typed data using wallet, then submit typedDataId + signature in publish request.',
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -154,17 +179,21 @@ export async function handlePublish(request: Request, env: Env): Promise<Respons
|
|||
case 'threads': {
|
||||
// AT Protocol repo.createRecord
|
||||
// Requires PDS session token (accessJwt) stored during sign-in
|
||||
const pdsUrl = target === 'bluesky' ? (env.BSKY_PUBLIC_URL || 'https://public.api.bsky.app') : (env.THREADS_PDS_URL || 'https://public.threads.net');
|
||||
const accessJwt = auth[target + '_jwt'] || auth[target + '_accessJwt'] || auth.accessJwt;
|
||||
const pdsUrl =
|
||||
target === 'bluesky'
|
||||
? env.BSKY_PUBLIC_URL || 'https://public.api.bsky.app'
|
||||
: env.THREADS_PDS_URL || 'https://public.threads.net';
|
||||
const accessJwt =
|
||||
auth[`${target}_jwt`] || auth[`${target}_accessJwt`] || auth.accessJwt;
|
||||
if (!accessJwt) {
|
||||
results[target] = { ok: false, error: 'Not signed into ' + target };
|
||||
results[target] = { ok: false, error: `Not signed into ${target}` };
|
||||
break;
|
||||
}
|
||||
// Create record via XRPC — uses the DID from auth or extracts from JWT
|
||||
const repoDid = auth[target + '_did'] || '';
|
||||
const recordRes = await fetch(pdsUrl + '/xrpc/com.atproto.repo.createRecord', {
|
||||
const repoDid = auth[`${target}_did`] || '';
|
||||
const recordRes = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.createRecord`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'authorization': 'Bearer ' + accessJwt },
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${accessJwt}` },
|
||||
body: JSON.stringify({
|
||||
repo: repoDid,
|
||||
collection: 'app.bsky.feed.post',
|
||||
|
|
@ -175,7 +204,10 @@ export async function handlePublish(request: Request, env: Env): Promise<Respons
|
|||
results[target] = { ok: true };
|
||||
} else {
|
||||
const errText = await recordRes.text();
|
||||
results[target] = { ok: false, error: target + ' publish failed: ' + errText.slice(0, 100) };
|
||||
results[target] = {
|
||||
ok: false,
|
||||
error: `${target} publish failed: ${errText.slice(0, 100)}`,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -195,7 +227,7 @@ export async function handlePublish(request: Request, env: Env): Promise<Respons
|
|||
}
|
||||
|
||||
default:
|
||||
results[target] = { ok: false, error: target + ' not supported' };
|
||||
results[target] = { ok: false, error: `${target} not supported` };
|
||||
}
|
||||
} catch (e) {
|
||||
results[target] = { ok: false, error: String(e) };
|
||||
|
|
@ -207,7 +239,7 @@ export async function handlePublish(request: Request, env: Env): Promise<Respons
|
|||
}
|
||||
}
|
||||
|
||||
export async function handleEngage(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleEngage(request: Request, _env: Env): Promise<Response> {
|
||||
if (request.method !== 'POST') return jsonResponse({ error: 'Method not allowed' }, 405);
|
||||
try {
|
||||
interface EngageRequest {
|
||||
|
|
@ -217,11 +249,11 @@ export async function handleEngage(request: Request, env: Env): Promise<Response
|
|||
auth?: Record<string, string>;
|
||||
authToken?: string;
|
||||
}
|
||||
const body: EngageRequest = await request.json() as EngageRequest;
|
||||
const body: EngageRequest = (await request.json()) as EngageRequest;
|
||||
const action = body.action ?? '';
|
||||
const postId = body.postId ?? '';
|
||||
const protocol = body.protocol ?? '';
|
||||
const auth = body.auth;
|
||||
const auth = body.auth as Record<string, string> | undefined;
|
||||
|
||||
if (!action || !postId || !protocol) {
|
||||
return jsonResponse({ error: 'action, postId, protocol required' }, 400);
|
||||
|
|
@ -229,14 +261,16 @@ export async function handleEngage(request: Request, env: Env): Promise<Response
|
|||
|
||||
switch (protocol) {
|
||||
case 'nostr': {
|
||||
// Nostr reaction via signed event
|
||||
results: { nostr: 'Reaction signing requires user key — coming soon' };
|
||||
break;
|
||||
return jsonResponse({
|
||||
ok: false,
|
||||
error: 'Reaction signing requires user key — coming soon',
|
||||
});
|
||||
}
|
||||
case 'mastodon': {
|
||||
// Mastodon like/repost via GtSClient
|
||||
const instance = auth?.mastodon_instance || 'https://social.degenfeed.xyz';
|
||||
const token = body.authToken as string || auth?.mastodon || '';
|
||||
const token =
|
||||
((body as Record<string, unknown>).authToken as string) || auth?.mastodon || '';
|
||||
if (!token) return jsonResponse({ error: 'Not signed into Mastodon' }, 401);
|
||||
const { GtSClient } = await import('@degenfeed/mastodon-sdk');
|
||||
const client = new GtSClient(instance, token);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { type Env, jsonResponse } from '../index';
|
||||
import { GenericRssSource, RedditSource, MirrorSource, SubstackSource, MediumSource, ParagraphSource, feedToUnifiedPosts } from '@degenfeed/rss-sdk';
|
||||
import {
|
||||
GenericRssSource,
|
||||
MediumSource,
|
||||
MirrorSource,
|
||||
ParagraphSource,
|
||||
RedditSource,
|
||||
SubstackSource,
|
||||
feedToUnifiedPosts,
|
||||
} from '@degenfeed/rss-sdk';
|
||||
import type { SourcePosts } from '@degenfeed/rss-sdk';
|
||||
import { type Env, jsonResponse } from '../index';
|
||||
|
||||
export async function handleRssFeed(request: Request, _env: Env): Promise<Response> {
|
||||
const params = new URL(request.url).searchParams;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { type Env, jsonResponse } from '../index';
|
||||
import { resolveLnurlFromPubkey, fetchZapInvoice } from './lightning';
|
||||
import { buildLensCollectIntent } from './lens';
|
||||
import { fetchZapInvoice, resolveLnurlFromPubkey } from './lightning';
|
||||
|
||||
interface ResolvedTipMethod {
|
||||
method: string;
|
||||
|
|
@ -101,7 +101,7 @@ export async function handleTipResolve(request: Request, env: Env): Promise<Resp
|
|||
return jsonResponse({ methods, resolved: true });
|
||||
}
|
||||
|
||||
export async function handleTipCreate(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleTipCreate(request: Request, _env: Env): Promise<Response> {
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
recipient: string;
|
||||
|
|
@ -126,7 +126,8 @@ export async function handleTipCreate(request: Request, env: Env): Promise<Respo
|
|||
value: body.amount,
|
||||
chain: body.chain || 'ethereum',
|
||||
},
|
||||
instructions: 'Sign with your Ethereum wallet (MetaMask, Rabby, Rainbow, etc.) to send the tip',
|
||||
instructions:
|
||||
'Sign with your Ethereum wallet (MetaMask, Rabby, Rainbow, etc.) to send the tip',
|
||||
});
|
||||
|
||||
case 'solana':
|
||||
|
|
@ -137,7 +138,8 @@ export async function handleTipCreate(request: Request, env: Env): Promise<Respo
|
|||
amount: body.amount,
|
||||
chain: 'solana',
|
||||
},
|
||||
instructions: 'Sign with your Solana wallet (Phantom, Solflare, Backpack, etc.) to send the tip',
|
||||
instructions:
|
||||
'Sign with your Solana wallet (Phantom, Solflare, Backpack, etc.) to send the tip',
|
||||
});
|
||||
|
||||
case 'lightning_zap':
|
||||
|
|
@ -150,7 +152,8 @@ export async function handleTipCreate(request: Request, env: Env): Promise<Respo
|
|||
note: 'Tip from DegenFeed',
|
||||
zapEndpoint: '/api/tip/lightning/invoice',
|
||||
},
|
||||
instructions: 'Pay the Lightning invoice with your Nostr-compatible wallet (Alby, Wallet of Satoshi, etc.)',
|
||||
instructions:
|
||||
'Pay the Lightning invoice with your Nostr-compatible wallet (Alby, Wallet of Satoshi, etc.)',
|
||||
});
|
||||
|
||||
case 'farcaster_frame':
|
||||
|
|
@ -180,7 +183,7 @@ export async function handleTipCreate(request: Request, env: Env): Promise<Respo
|
|||
}
|
||||
|
||||
default:
|
||||
return jsonResponse({ error: 'Unknown tipping method: ' + body.method }, 400);
|
||||
return jsonResponse({ error: `Unknown tipping method: ${body.method}` }, 400);
|
||||
}
|
||||
} catch (err) {
|
||||
return jsonResponse({ error: String(err) }, 500);
|
||||
|
|
@ -193,7 +196,7 @@ export async function handleTipCreate(request: Request, env: Env): Promise<Respo
|
|||
export async function handleLightningInvoice(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const pubkey = url.searchParams.get('pubkey') || '';
|
||||
const amountSats = parseFloat(url.searchParams.get('amount') || '1000');
|
||||
const amountSats = Number.parseFloat(url.searchParams.get('amount') || '1000');
|
||||
const comment = url.searchParams.get('comment') || 'Tip from DegenFeed';
|
||||
|
||||
if (!pubkey) {
|
||||
|
|
@ -229,7 +232,7 @@ export async function handleLightningInvoice(request: Request, env: Env): Promis
|
|||
* Returns Open Graph meta tags that Farcaster clients render as a tipping Frame.
|
||||
* recipientId should be the creator's Ethereum address.
|
||||
*/
|
||||
export async function handleTipFrame(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleTipFrame(request: Request, _env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const parts = url.pathname.split('/');
|
||||
const recipientId = parts[parts.length - 1] || '';
|
||||
|
|
@ -262,7 +265,7 @@ export async function handleTipFrame(request: Request, env: Env): Promise<Respon
|
|||
* Farcaster Frame transaction endpoint.
|
||||
* Receives a POST from the Farcaster client and returns eth_sendTransaction params.
|
||||
*/
|
||||
export async function handleTipFrameTx(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleTipFrameTx(request: Request, _env: Env): Promise<Response> {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const parts = url.pathname.split('/');
|
||||
|
|
@ -271,8 +274,9 @@ export async function handleTipFrameTx(request: Request, env: Env): Promise<Resp
|
|||
|
||||
// In production, validate the FrameActionPayload signature against a Farcaster hub.
|
||||
// For now we accept the client's signed message and rely on the user approving the tx in their wallet.
|
||||
const body = (await request.json()) as any;
|
||||
const fid = body?.untrustedData?.fid || body?.fid;
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const fid =
|
||||
((body.untrustedData as Record<string, unknown>)?.fid as number) || (body.fid as number);
|
||||
if (!fid) {
|
||||
return jsonResponse({ error: 'Invalid Frame action: missing fid' }, 400);
|
||||
}
|
||||
|
|
@ -282,7 +286,7 @@ export async function handleTipFrameTx(request: Request, env: Env): Promise<Resp
|
|||
return jsonResponse({ error: 'Invalid recipient address' }, 400);
|
||||
}
|
||||
|
||||
const valueHex = '0x' + (BigInt(Math.floor(parseFloat(amount) * 1e18))).toString(16);
|
||||
const valueHex = `0x${BigInt(Math.floor(Number.parseFloat(amount) * 1e18)).toString(16)}`;
|
||||
|
||||
return jsonResponse({
|
||||
chainId: 'eip155:1',
|
||||
|
|
@ -301,15 +305,16 @@ export async function handleTipFrameTx(request: Request, env: Env): Promise<Resp
|
|||
* Farcaster Frame callback endpoint.
|
||||
* Receives the transaction hash after the user signs in their wallet.
|
||||
*/
|
||||
export async function handleTipFrameCallback(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleTipFrameCallback(request: Request, _env: Env): Promise<Response> {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const parts = url.pathname.split('/');
|
||||
const recipientId = parts[parts.length - 1] || '';
|
||||
const amount = url.searchParams.get('amount') || '0.001';
|
||||
const _recipientId = parts[parts.length - 1] || '';
|
||||
const _amount = url.searchParams.get('amount') || '0.001';
|
||||
|
||||
const body = (await request.json()) as any;
|
||||
const txHash = (body?.untrustedData?.transactionId || body?.txHash) as string | undefined;
|
||||
const body = (await request.json()) as Record<string, unknown>;
|
||||
const txHash = (((body.untrustedData as Record<string, unknown>)?.transactionId as string) ||
|
||||
(body.txHash as string)) as string | undefined;
|
||||
const success = !!txHash;
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
|
|
@ -321,7 +326,7 @@ export async function handleTipFrameCallback(request: Request, env: Env): Promis
|
|||
<meta property="og:title" content="Tip on DegenFeed" />
|
||||
</head>
|
||||
<body>
|
||||
<p>${success ? 'Tip sent! Tx: ' + txHash : 'Tip could not be completed.'}</p>
|
||||
<p>${success ? `Tip sent! Tx: ${txHash}` : 'Tip could not be completed.'}</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
|
|
@ -329,7 +334,7 @@ export async function handleTipFrameCallback(request: Request, env: Env): Promis
|
|||
status: 200,
|
||||
headers: { 'content-type': 'text/html; charset=utf-8' },
|
||||
});
|
||||
} catch (err) {
|
||||
} catch (_err) {
|
||||
return new Response('Error', { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { type Env, jsonResponse } from '../index';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { ed25519 } from '@noble/curves/ed25519';
|
||||
import { secp256k1 } from '@noble/curves/secp256k1';
|
||||
import { keccak_256 } from '@noble/hashes/sha3';
|
||||
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
|
||||
import { type Env, jsonResponse } from '../index';
|
||||
|
||||
const nonces = new Map<string, { nonce: string; expiresAt: number }>();
|
||||
const GTS_SCOPES = 'read write follow push';
|
||||
|
|
@ -12,7 +12,7 @@ function generateNonce(): string {
|
|||
return bytesToHex(bytes);
|
||||
}
|
||||
|
||||
export async function handleWalletNonce(request: Request, env: Env): Promise<Response> {
|
||||
export async function handleWalletNonce(request: Request, _env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const address = url.searchParams.get('address')?.toLowerCase() || '';
|
||||
if (!address) return jsonResponse({ error: 'Address required' }, 400);
|
||||
|
|
@ -31,22 +31,28 @@ export async function handleWalletNonce(request: Request, env: Env): Promise<Res
|
|||
return jsonResponse({ nonce });
|
||||
}
|
||||
|
||||
function verifyEthereumSignature(message: string, signature: string, expectedAddress: string): boolean {
|
||||
function verifyEthereumSignature(
|
||||
message: string,
|
||||
signature: string,
|
||||
expectedAddress: string,
|
||||
): boolean {
|
||||
try {
|
||||
const prefix = '\x19Ethereum Signed Message:\n' + message.length;
|
||||
const prefix = `\x19Ethereum Signed Message:\n${message.length}`;
|
||||
const msgHash = keccak_256(new TextEncoder().encode(prefix + message));
|
||||
|
||||
const sig = signature.startsWith('0x') ? signature.slice(2) : signature;
|
||||
const rHex = sig.slice(0, 64);
|
||||
const sHex = sig.slice(64, 128);
|
||||
const v = parseInt(sig.slice(128, 130), 16);
|
||||
const v = Number.parseInt(sig.slice(128, 130), 16);
|
||||
const recovery = v - 27;
|
||||
if (recovery < 0 || recovery > 3) return false;
|
||||
|
||||
const sigObj = secp256k1.Signature.fromCompact(hexToBytes(rHex + sHex)).addRecoveryBit(recovery);
|
||||
const sigObj = secp256k1.Signature.fromCompact(hexToBytes(rHex + sHex)).addRecoveryBit(
|
||||
recovery,
|
||||
);
|
||||
const pubKey = sigObj.recoverPublicKey(msgHash);
|
||||
const pubBytes = pubKey.toRawBytes(false).slice(1);
|
||||
const address = '0x' + bytesToHex(keccak_256(pubBytes).slice(-20));
|
||||
const address = `0x${bytesToHex(keccak_256(pubBytes).slice(-20))}`;
|
||||
|
||||
return address.toLowerCase() === expectedAddress.toLowerCase();
|
||||
} catch {
|
||||
|
|
@ -54,7 +60,11 @@ function verifyEthereumSignature(message: string, signature: string, expectedAdd
|
|||
}
|
||||
}
|
||||
|
||||
function verifySolanaSignature(message: string, signature: string, expectedAddress: string): boolean {
|
||||
function verifySolanaSignature(
|
||||
message: string,
|
||||
signature: string,
|
||||
expectedAddress: string,
|
||||
): boolean {
|
||||
try {
|
||||
const msgBytes = new TextEncoder().encode(message);
|
||||
const sigBytes = hexToBytes(signature);
|
||||
|
|
@ -65,7 +75,9 @@ function verifySolanaSignature(message: string, signature: string, expectedAddre
|
|||
}
|
||||
}
|
||||
|
||||
async function tryGtsAuth(env: Env): Promise<{ accessToken: string; clientId: string; clientSecret: string } | null> {
|
||||
async function tryGtsAuth(
|
||||
env: Env,
|
||||
): Promise<{ accessToken: string; clientId: string; clientSecret: string } | null> {
|
||||
// If static GTS credentials are configured in env vars, use them.
|
||||
// Otherwise do NOT create a new OAuth app on every request — that pollutes
|
||||
// the instance with stale apps. Wallet-only auth still works without GTS.
|
||||
|
|
@ -84,7 +96,9 @@ async function tryGtsAuth(env: Env): Promise<{ accessToken: string; clientId: st
|
|||
}).toString(),
|
||||
});
|
||||
if (!tokenRes.ok) return null;
|
||||
const token = await tokenRes.json() as any;
|
||||
const token = (await tokenRes.json()) as { access_token?: string; token_type?: string };
|
||||
|
||||
if (!token.access_token) return null;
|
||||
|
||||
return {
|
||||
accessToken: token.access_token,
|
||||
|
|
@ -147,8 +161,8 @@ export async function handleWalletVerify(request: Request, env: Env): Promise<Re
|
|||
identity: {
|
||||
protocol: body.chain,
|
||||
id: body.address.toLowerCase(),
|
||||
displayName: body.address.slice(0, 6) + '...' + body.address.slice(-4),
|
||||
handle: body.address.slice(0, 6) + '...' + body.address.slice(-4),
|
||||
displayName: `${body.address.slice(0, 6)}...${body.address.slice(-4)}`,
|
||||
handle: `${body.address.slice(0, 6)}...${body.address.slice(-4)}`,
|
||||
avatarUrl: null,
|
||||
},
|
||||
// GTS link is optional — user can set up later
|
||||
|
|
|
|||
|
|
@ -4,11 +4,10 @@
|
|||
*/
|
||||
|
||||
// Mock fetch — each test sets up its own responses
|
||||
import { vi, afterEach } from "vitest";
|
||||
import { afterEach, vi } from 'vitest';
|
||||
|
||||
// Mock global fetch
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = vi.fn().mockImplementation(async (url: string | URL, opts?: RequestInit) => {
|
||||
globalThis.fetch = vi.fn().mockImplementation(async (_url: string | URL, _opts?: RequestInit) => {
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
|
|
@ -20,15 +19,18 @@ class MockWebSocket {
|
|||
url: string;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onerror: ((event: any) => void) | null = null;
|
||||
onerror: ((event: ErrorEvent) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
readyState = 3; // CLOSED
|
||||
constructor(url: string) { this.url = url; }
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
close() {}
|
||||
send() {}
|
||||
}
|
||||
|
||||
(globalThis as any).WebSocket = MockWebSocket;
|
||||
(globalThis as unknown as { WebSocket: typeof WebSocket }).WebSocket =
|
||||
MockWebSocket as unknown as typeof WebSocket;
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ routes = [
|
|||
{ pattern = "app.degenfeed.xyz/api/*", zone_id = "116a9676c3358def6796910ccb96f1fc" }
|
||||
]
|
||||
|
||||
# GTS_CLIENT_SECRET is a secret. Set it via:
|
||||
# wrangler secret put GTS_CLIENT_SECRET
|
||||
# Do not add it to [vars].
|
||||
[vars]
|
||||
GTS_CLIENT_ID = "01X3AAWH39P71TXZVKMEGAKMVC"
|
||||
GTS_CLIENT_SECRET = "b9f786b1-bc65-4fb8-87db-335024f9d3e5"
|
||||
GTS_INSTANCE_URL = "https://social.degenfeed.xyz"
|
||||
GTS_API_BASE = "https://social.degenfeed.xyz"
|
||||
GTS_REDIRECT_URI = "https://app.degenfeed.xyz/auth/callback"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue