chore(repo): green CI pass - lint, typecheck, tests, build, security hygiene
Some checks are pending
DegenFeed CI/CD / test (push) Waiting to run
DegenFeed CI/CD / deploy-worker (push) Blocked by required conditions
DegenFeed CI/CD / deploy-web (push) Blocked by required conditions

This commit is contained in:
Crypto Rug Munch 2026-07-06 17:23:51 +02:00
parent 8f7a2031ed
commit fd41354a12
91 changed files with 3185 additions and 1625 deletions

90
.github/workflows/ci.yml vendored Normal file
View 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
View file

@ -4,6 +4,7 @@
# ── Secrets (zero tolerance) ───────────────────────────────────
.env
.env.*
.dev.vars
!.env.example
!.env.template
*.pem

3
.mise.toml Normal file
View file

@ -0,0 +1,3 @@
[tools]
node = "22"
pnpm = "11.7.0"

View file

@ -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).

View file

@ -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
View 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

View file

@ -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

View file

@ -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
View 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

View file

@ -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'),
};
}
}

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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">&larr; 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"
>
&larr; 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>

View file

@ -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' },
},
];

View file

@ -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>
);

View file

@ -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">&larr; Back to feed</a>
<a href="/home" className="text-sm text-gray-600 hover:text-white transition-colors">
&larr; Back to feed
</a>
</div>
</div>
);

View file

@ -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],
);

View file

@ -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;

View file

@ -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>;

View file

@ -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>

View file

@ -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>;
}

View file

@ -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>
);
}

View file

@ -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>;
}

View file

@ -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 };
}

View file

@ -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,

View file

@ -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;
}

View file

@ -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';

View file

@ -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,

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}
/**

View file

@ -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,

View file

@ -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 ──────────────────────────────────────────

View file

@ -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: '',

View file

@ -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 }[] };

View file

@ -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;

View file

@ -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,

View file

@ -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);
});
});

View file

@ -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;
}

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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';

View file

@ -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[];
}

View file

@ -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;
}

View file

@ -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();
}

View file

@ -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';
}

View file

@ -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',

View file

@ -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}`,
}))
: [];

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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';
}

View file

@ -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}`}
>

View file

@ -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>
);
};

View file

@ -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>

View file

@ -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>
);
}

View file

@ -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', () => {

View file

@ -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';

View 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>"

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);

View file

@ -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();
});
});

View file

@ -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);
});
});

View file

@ -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);
}

View file

@ -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;

View file

@ -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);
}

View file

@ -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,
);
}

View file

@ -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),
},
});

View file

@ -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,
});
}

View file

@ -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);
}

View file

@ -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}`;
}
/**

View file

@ -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 || '',

View file

@ -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(/&quot;/g, '"')
.replace(/&apos;/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);
}

View file

@ -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 } });

View file

@ -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;

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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 });
}
}

View file

@ -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

View file

@ -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(() => {

View file

@ -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"