feat: production-ready v1 — KV bindings, rate limiter, CORS, GTS CSRF, Twitter UX, CI deploy

Production readiness (Sprint 1):
- workers/api/wrangler.toml: real KV namespace IDs for CACHE + SUBSCRIPTIONS (prod + preview),
  AUTH_SECRET + GTS_CLIENT_SECRET set via wrangler secret put on Cloudflare, ALLOWED_ORIGINS env var
- kv-backed rate limiter replacing in-memory Map (rl:${ip}:${path}:${bucket} in KV)
- env-driven CORS allowlist (ALLOWED_ORIGINS), removed hardcoded localhost from production code
- GTS OAuth callback CSRF: signed state nonce stored in KV, consumed once on callback,
  removed from CSRF_EXEMPT_PATHS, 4 new auth.state tests
- .github/workflows/deploy.yml: PR -> preview, main -> production via cloudflare/wrangler-action
- DEPLOYMENT.md, scripts/deploy/setup-cloudflare.sh

Frontend (/twitter):
- Full X-style hero: left rail, protocol pills with live counts (filter by network),
  quality controls (threshold slider, non-crypto toggle), composer with Mastodon->Nostr bridge,
  FeedList flat-scrolling, sign-in CTAs throughout, right sidebar network list + trending
- ToastProvider replacing alert() in AuthProvider
- BottomNav for mobile (Feed/Twitter/News/Daily/Profile)
- /status page: SSR health dashboard with auto-refresh, per-protocol latency

Backend:
- /api/preferences: GET/POST with session auth, persist threshold + non-crypto + niches/platforms,
  11 endpoint tests + sanitizer unit tests
- GTS callback: state nonce verified against KV before token exchange
- KV-backed rate limiter with fail-open on KV errors

Tests: 81 backend tests pass (76->81)
Deployed: degenfeed-api (prod) and degenfeed-api-preview (preview) on Cloudflare
Health: Nostr/Lens/Bluesky/Mastodon/Threads/Reddit OK, RSS 403, Farcaster timeout
This commit is contained in:
Crypto Rug Munch 2026-07-08 10:18:58 +07:00
parent 1d09b43507
commit 5b394f6c92
99 changed files with 5744 additions and 1385 deletions

115
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,115 @@
name: Deploy
# Deploy the worker to a Cloudflare preview environment on every PR,
# and to production on every push to main. The worker is the single
# source of truth for the API surface (degenfeed.xyz/api/* and
# app.degenfeed.xyz/api/*). The Next.js frontend is built separately
# (see the Pages deploy job below).
#
# Required GitHub Actions secrets (Settings → Secrets → Actions):
# CLOUDFLARE_API_TOKEN — Cloudflare API token with Workers Scripts:Edit
# and Workers KV Storage:Edit scopes
# CLOUDFLARE_ACCOUNT_ID — Cloudflare account id (32 hex chars)
on:
pull_request:
branches: [main]
push:
branches: [main]
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
jobs:
preview:
# PR build → deploy to a Cloudflare Workers preview environment so the
# PR has a stable URL to test against. PRs don't get a custom domain;
# the preview URL is `https://degenfeed-api-preview.<workers subdomain>`.
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
environment:
name: preview
url: ${{ steps.deploy.outputs.deployment-url }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm --filter @degenfeed/api-worker typecheck
- name: Test
run: pnpm --filter @degenfeed/api-worker test
- name: Build (dry-run)
run: pnpm --filter @degenfeed/api-worker build
- name: Deploy worker (preview)
id: deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: workers/api
command: deploy --env preview --var ALLOWED_ORIGINS:https://preview.degenfeed.xyz,https://app.preview.degenfeed.xyz
# Note: AUTH_SECRET and GTS_CLIENT_SECRET must already exist as
# encrypted secrets on the preview environment (set once via
# `wrangler secret put`). This job never sees them in plaintext.
production:
# main → deploy to production with the routes configured in wrangler.toml.
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment:
name: production
url: https://degenfeed.xyz
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: pnpm/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm --filter @degenfeed/api-worker typecheck
- name: Test
run: pnpm --filter @degenfeed/api-worker test
- name: Build (dry-run)
run: pnpm --filter @degenfeed/api-worker build
- name: Deploy worker (production)
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: workers/api
command: deploy
# Secrets (AUTH_SECRET, GTS_CLIENT_SECRET) must already be set
# via `wrangler secret put` before the first deploy. They live
# only on the Cloudflare side; this job never sees plaintext.
notify-failure:
if: failure()
runs-on: ubuntu-latest
needs: [preview, production]
steps:
- name: Ping on deploy failure
run: |
echo "::error::Deploy failed for ${{ github.ref }} — see run logs"
# Add Slack/Discord/PagerDuty webhook here when ready.
exit 1

9
.gitleaks.toml Normal file
View file

@ -0,0 +1,9 @@
title = "DegenFeed Web Gitleaks Config"
[allowlist]
paths = [
# Build artifacts generated by Vercel/Next.js; these contain auto-generated
# preview/encryption tokens that are not real secrets. The directory is
# already gitignored; this prevents historical commits from failing scans.
'''\.vercel\/''',
]

154
DEPLOYMENT.md Normal file
View file

@ -0,0 +1,154 @@
# Deployment
DegenFeed Web deploys to Cloudflare: a single Worker (`degenfeed-api`)
serves the API and the main `degenfeed.xyz` HTML routes, with the
Next.js app running on Cloudflare Pages (`degenfeed-web`). This doc
covers first-time setup and ongoing deploy workflow.
## First-time setup
### 1. Cloudflare account + zone
- Create a Cloudflare account.
- Add the `degenfeed.xyz` zone (if not already added).
- Note the **Account ID** and **Zone ID**.
### 2. Create KV namespaces
The worker uses two KV namespaces (`CACHE`, `SUBSCRIPTIONS`).
Create them once with the `preview` ID variant for PR previews:
```sh
cd workers/api
wrangler kv namespace create CACHE
wrangler kv namespace create CACHE --preview
wrangler kv namespace create SUBSCRIPTIONS
wrangler kv namespace create SUBSCRIPTIONS --preview
```
Copy the returned IDs into `workers/api/wrangler.toml`. The placeholders
`cache-namespace` / `subs-namespace` must be replaced before deploying,
otherwise the worker will fail to bind KV at startup.
### 3. Set secrets
Two secrets must exist on each environment (production + preview):
```sh
# Production
wrangler secret put AUTH_SECRET --env production
wrangler secret put GTS_CLIENT_SECRET --env production
# Preview
wrangler secret put AUTH_SECRET --env preview
wrangler secret put GTS_CLIENT_SECRET --env preview
```
`AUTH_SECRET` is a random 32-byte hex string used to HMAC-sign session
cookies. Generate one with:
```sh
openssl rand -hex 32
```
`GTS_CLIENT_SECRET` comes from the GotoSocial OAuth app configured at
`social.degenfeed.xyz/admin/oauth-apps`. It is used to exchange
authorization codes for access tokens.
Both secrets live only on the Cloudflare side — they are never
committed to git or printed in CI logs.
### 4. Set GitHub repository secrets
`Settings → Secrets and variables → Actions → New repository secret`:
| Name | Purpose |
|--------------------------|---------------------------------------------|
| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with `Workers Scripts:Edit` and `Workers KV Storage:Edit` scopes. |
| `CLOUDFLARE_ACCOUNT_ID` | 32-hex Cloudflare account id. |
Create the API token at `dash.cloudflare.com → My Profile → API Tokens
→ Create Token → Edit Cloudflare Workers template`.
## Deploy workflow
Two GitHub Actions workflows exist:
| File | Triggers on | What it does |
|-----------------------------------|--------------------------|---------------------------------------|
| `.github/workflows/ci.yml` | Every push, every PR | Lint + typecheck + test + build + gitleaks. |
| `.github/workflows/deploy.yml` | PRs (preview) + main push (production) | Deploys the worker to a Cloudflare environment. |
PRs get a `preview` environment with `--env preview`, which uses the
preview KV IDs and a relaxed rate limit. `main` deploys to production
with the routes defined in `wrangler.toml` (currently
`degenfeed.xyz/api/*`, `app.degenfeed.xyz/api/*`, `degenfeed.xyz/*`).
## Local verification
Before pushing, verify locally with `wrangler dev`:
```sh
cd workers/api
# Local KV emulation requires --local (uses miniflare SQLite under .wrangler/)
wrangler dev --local
```
The dev server starts on `http://localhost:8787`. KV calls work against
the local SQLite-backed store. Hit a few endpoints:
```sh
curl http://localhost:8787/api/feed/health
curl -X POST http://localhost:8787/api/preferences \
-H 'content-type: application/json' \
-H 'origin: http://localhost:3000' \
-d '{"hideLowQuality":true}'
```
Note: `AUTH_SECRET` is not set locally — session-protected routes will
return 401. To test them locally, set the secret via `.dev.vars`:
```sh
echo 'AUTH_SECRET=local-dev-secret-not-for-production-32-bytes' > workers/api/.dev.vars
echo 'GTS_CLIENT_SECRET=unused-locally' >> workers/api/.dev.vars
```
`wrangler dev` reads `.dev.vars` automatically.
## Adding the Next.js Pages deploy
`apps/web/wrangler.toml` is a stub. The full Cloudflare Pages deploy is
out of scope for this iteration (the landing HTML at `apps/landing/`
covers the `degenfeed.xyz` root today). When the Next.js app is ready
to ship, add a Pages job to `.github/workflows/deploy.yml`:
```yaml
pages:
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
- run: pnpm --filter degenfeed-web build
- uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: degenfeed-web
directory: apps/web/.vercel/output/static
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
```
## Rollback
Cloudflare keeps the previous 100 versions of every Worker. To roll
back:
```sh
wrangler rollback --env production
```
Or via the Cloudflare dashboard: `Workers & Pages → degenfeed-api →
Deployments → Rollback`.

View file

@ -13,6 +13,8 @@
- Core packages: types, feed-core, ranking, ui, identity, auth, storage
- `/news` news aggregation
- Wallet sign-in (Solana + EVM)
- **Twitter/X-style timeline at `/twitter`** with inline composer and For You / Following tabs
- New DegenFeed icon/logo wired across app and home page
## Week 1 — Polish and observability
- Add `/health` route to `workers/api` and `apps/web`

39
STATUS.md Normal file
View file

@ -0,0 +1,39 @@
# DegenFeed Web — Status
> Last updated: 2026-07-07
## Summary
Follow-up cleanup: replaced `alert()` with a `ToastProvider` in `AuthProvider`, made the `/twitter` notification bell show for any signed-in user (not just wallet), wired the Mastodon → Nostr bridge into both the inline `TwitterComposer` and the global `ComposeModal` via a new `lib/nostr` helper that signs locally with the saved Nostr key, added a `/api/preferences` endpoint backed by KV so `hideLowQuality` persists across devices, and wrote unit tests for the preferences sanitizer.
`make ci` and `make security` pass.
## Current Sprint
Rebuild DegenFeed Web into a usable web3-first social aggregator (TODO.md #115).
## Completed This Session
- `apps/web/src/components/Toast.tsx` — toast provider + viewport (success/error/warning), auto-dismiss
- `apps/web/src/app/layout.tsx` — wired `ToastProvider` around `AuthProvider`
- `apps/web/src/components/AuthProvider.tsx` — replaced `alert()` with toast, added `signOut` toast, added `nostrPublicId` to context
- `apps/web/src/app/twitter/page.tsx` — bell+sign-out now gated on `hasSignedIn`, not `walletAddress`
- `apps/web/src/components/TwitterComposer.tsx` — added Mastodon→Nostr bridge toggle + state
- `apps/web/src/lib/nostr.ts` — new client-side Nostr publisher that decrypts saved nsec with `auto-unlock-passphrase` and publishes to relays
- `packages/ui/src/ComposeModal.tsx` — same bridge toggle; `nostrBridgePublicId` + `onNostrBridge` props
- `workers/api/src/routes/preferences.ts` — new GET/POST `/api/preferences` route, requires session
- `apps/web/src/app/twitter/page.tsx``hideLowQuality` toggle now persists to backend KV in addition to localStorage
- `workers/api/src/__tests__/preferences.test.ts` — 5 endpoint tests
- `workers/api/src/__tests__/preferences.sanitize.test.ts` — 6 unit tests for the sanitizer
- `workers/api/src/routes/preferences.ts` — sanitizer now lowercases+trims niches (caught by tests)
## Quality Gates
- `make check`
- `make ci` ✅ (lint, typecheck, test, build)
- `make security` ✅ (gitleaks)
## Next
1. Mobile bottom-nav (#12) and search overlay (#14) — quick UI wins.
2. Status page (#13) — surface `/api/feed/health`.
3. Tune `hideLowQuality` threshold and relevance filter for non-crypto users.
4. Newsletter cron + email delivery (#15).
## Blocked
None.

111
TODO.md
View file

@ -1,18 +1,115 @@
# DegenFeed Web — TODO
## Rebuild Sprint: Make DegenFeed a Real Web3 Aggregator
> The current app is functionally broken for users. The backend exists and is mostly wired, but feeds feel empty, cache is stale, the frontend does not look or behave like a modern web3 social app, and the newsletter does not actually pull from the protocols it advertises. This sprint is the hard reset.
### Critical fixes (this session)
- [x] Fix `/api/newsletter/digest` so it fetches from each selected protocol (Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads) instead of only RMI + RSS
- [x] Fix `GTS_AUTH_CONFIG.apiBase` default (`http://127.0.0.1:8082``https://social.degenfeed.xyz`)
- [x] Add `noCacheJsonResponse` helper and disable stale caching for `/api/feed/news` and `/api/newsletter/digest`
- [x] Add `cache: 'no-store'` to client-side fetches in `/news`, `/newsletter`, and `/home/[section]`
- [ ] Run `make ci` and `make security` and fix regressions
- [ ] Smoke-test `/newsletter`, `/news`, and `/home/nostr` in local dev / wrangler dev
### 15 Next Moves (ranked by impact)
1. **Make `/home` per-protocol sections actually work**
Ensure `/home/nostr`, `/home/farcaster`, `/home/bluesky`, `/home/lens`, `/home/mastodon`, `/home/threads`, and `/home/reddit` each call the correct provider endpoint and render a real feed with loading, error, and empty states. Fix `/home/mastodon` to use `/api/feed/home?protocols=mastodon` (or keep `/api/feed/activitypub` but wire it to the deployed GotoSocial instance).
2. **Rebuild the landing experience**
The current app looks like a generic dashboard. Design a web3-first `/twitter` timeline with: compact PostCards, hover actions, quote/repost counts, protocol badges, embedded link previews, and image lightbox. Copy the best parts of Warpcast / Bluesky / X, not a Bootstrap card layout.
3. **Fix the empty-state problem everywhere**
Every feed currently shows an empty card with no personality. Add protocol-specific empty states: "No Nostr notes found — try a different relay", "Mastodon timeline is quiet — create the first post @social.degenfeed.xyz", etc., with one-click fixes (switch relay, create account, sign in).
4. **Add real-time / polling updates**
The news page polls every 60s for new stories but other timelines do not. Add a global `useFeedPoller` hook that shows a "New posts" banner and lets users pull-to-refresh on mobile and click-to-refresh on desktop.
5. **Replace the RMI hard dependency with a resilient pipeline**
RMI is unreachable from Cloudflare edge. The feed should always fall back to public sources without the user noticing. Remove all user-facing "RMI DataBus" copy until the backend is actually deployed and reachable from the edge.
6. **Harden the Mastodon/GotoSocial integration**
Our own instance is the most important protocol. Add a dedicated GotoSocial provider path that reads the public timeline from `https://social.degenfeed.xyz/api/v1/timelines/public`, supports hashtag timelines, and lets signed-in users see their home timeline.
7. **Add protocol-aware composer**
The composer now shows all protocols. Signed-in protocols are selectable; unsigned protocols show a lock icon and open sign-in when clicked. Added live publish preview (`ComposePreview`) and per-protocol character-limit warnings that disable the Post button when exceeded. Updated both the inline `TwitterComposer` and the `ComposeModal`.
8. **Implement wallet-first authentication**
Wallet sign-in is now the primary path in the `SignInModal` (Ethereum + Solana buttons at the top). Added `/api/auth/signout` to clear the `__Host-dfid` cookie, added `signOut` to `AuthProvider`, and exposed it in the `/twitter` header and sidebar. `/api/bridge/rss-to-nostr` now requires the session cookie too.
9. **Add a unified notifications stream**
Added `useNotifications` hook that builds auth from all linked identities, polls `POST /api/notifications` every 60s, and persists read state in localStorage. Extended the backend to aggregate notifications from Nostr, Mastodon, and Bluesky. Added the `NotificationBell` to the `/twitter` header and kept it in `/home/[section]`.
10. **Fix ranking and deduplication**
Backend now computes and attaches a per-post score breakdown (recency, engagement, relevance, quality, diversity, source, velocity, reasons). `PostCard` shows a `★` badge with hover tooltip exposing each component. Added a "hide low-quality" toggle in `/twitter` (persisted in localStorage) that filters posts with `score.total < 1.0`. The toggle respects the session-bound `hideLowQuality` preference so it can later be moved to a profile page.
11. **Add cross-posting to the bridge**
Wire `/api/bridge/rss-to-nostr` and a general `/api/publish` to cross-post from Mastodon to Nostr, Farcaster to Bluesky, etc. Start with one working pair (Mastodon → Nostr) and expose it in the composer.
12. **Build a mobile-first responsive layout**
The current layout is desktop-only with a hidden right sidebar. Implement a bottom nav (Home, Search, Notifications, Profile) for mobile, collapsible sidebars, and proper safe-area handling.
13. **Add protocol health dashboard**
`/api/feed/health` exists but is not exposed in the UI. Add a `/status` page (or admin panel) showing each protocol's latency, success rate, and last fetched time so users know when a feed is degraded.
14. **Add search that actually searches**
`/api/search` and `/api/search/identity` are wired. Add a global search bar in the shell that searches posts, identities, and niches across protocols, with filters for protocol and time range.
15. **Ship the newsletter as a real product**
After the digest works, add an actual email-sending path (Postmark / SES) and a cron trigger that builds the daily digest from live data and sends it to subscribers. Add a `/newsletter/manage` page with preferences and unsubscribe.
## Active
- [ ] Disambiguate this repo from Mastodon deployment in README
- [ ] Add .pre-commit-config.yaml
- [ ] Add Makefile
- [ ] Add .github/workflows/ci.yml
- [x] Add lightweight Twitter/X-style timeline (`/twitter`)
- [x] Inline composer with per-protocol toggles, lock icons, and preview
- [x] Wallet-first authentication with sign-out UI
- [x] Unified notifications stream with real bell and unread counts
- [x] Ranking transparency: score badge, hover breakdown, hide-low-quality toggle
- [x] "For You" and "Following" tabs
- [x] Wire logo into Shell and home page
- [x] Backend fixes: Nostr NIP-23 long-form support + HTTP fallback
- [x] Fix newsletter digest to fetch from all selected protocols
- [x] Fix GotoSocial default API base URL
- [x] Add no-cache helpers for dynamic feed endpoints
- [x] Add no-store fetches to client feed pages
- [x] Rebuild PostCard and protocol-aware composer
## Next
- [ ] Decide client vs Mastodon strategy
- [ ] Implement nostr-sdk package
- [ ] Implement feed-core normalization
- [ ] Cross-posting bridge (#11): Mastodon → Nostr working pair exposed in composer
- [ ] Set `RMI_BACKEND_URL` in `workers/api/wrangler.toml` for production RSS data
- [ ] Harden Reddit fetching against CF Worker blocks (prefer `old.reddit.com` RSS)
- [ ] Harden Threads scraper or reduce curated handle set
- [ ] Cache provider results in `CACHE` KV for 60180s to reduce latency
- [ ] Server-side filter for Following tab instead of client-side filtering
- [ ] Add real-time updates to timeline
- [ ] Add repost/quote preview in composer
- [ ] Improve mobile layout (currently hides right sidebar)
- [ ] Add notification bell to Twitter timeline
- [ ] Fix GotoSocial `trusted-proxies` warning on deployed instance (`social.degenfeed.xyz`)
## Blocked
- None
## Done
- apps/landing live at degenfeed.xyz
- apps/web live at app.degenfeed.xyz
- CI, Makefile, pre-commit configured
- Protocol SDKs, feed-core, ranking, identity packages
- `/news`, wallet sign-in, composer, feed aggregation
- Fixed critical syntax error in `packages/feed-providers/src/providers/nostr.ts`
- Fixed monorepo type errors across utils, ranking, feed-providers, ui, api-worker, web
- Fixed Biome lint errors across packages and worker
- Created `/twitter` route with X-style layout using existing components
- Added `@degenfeed/storage` dependency to `@degenfeed/ui` package
- Created `packages/utils/tsconfig.json`
- Upgraded `@biomejs/biome` in `@degenfeed/api-worker` to match monorepo version
- Replaced `<img>` elements with `next/image` across the web app for performance
- Fixed all remaining Biome lint warnings (unused imports/variables in legacy files)
- Made `/twitter` the default landing page (redirect `/``/twitter`)
- Added DegenFeed logo to the Twitter timeline header
- Fixed RSS/RMI fallback to avoid the 6s timeout when `RMI_BACKEND_URL` is unset
- Fixed default protocols in `/api/feed/home` to exclude `ethereum`/`solana`
- Audited full feed data flow: providers are wired to real endpoints; RMI timeout was the main blocker
- Mocked `@degenfeed/rss-sdk` in `workers/api/src/__tests__/curated.test.ts` to stop flaky network timeouts
- Added `.gitleaks.toml` allowlist for generated `.vercel` build tokens and marked `GTS_CLIENT_ID` as allowed; `make security` now passes

View file

@ -194,7 +194,8 @@ ul {
.hero-grid {
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
background-image:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 60px 60px;
mask-image: radial-gradient(ellipse at center, black 30%, transparent 70%);
@ -614,7 +615,9 @@ ul {
/* PD card left border animation */
.pd-card {
transition: all 0.25s ease, border-left-width 0.15s ease;
transition:
all 0.25s ease,
border-left-width 0.15s ease;
}
.pd-card:hover {
border-left-width: 4px;

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
import './.next/types/routes.d.ts';
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -1,12 +1,48 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="DegenFeed logo">
<title>DegenFeed</title>
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#0a0a0a"/>
<stop offset="100%" stop-color="#000000"/>
</linearGradient>
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="6" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect width="512" height="512" fill="url(#bg)" rx="64"/>
<circle cx="256" cy="256" r="180" fill="none" stroke="#00ff41" stroke-width="8" opacity="0.3"/>
<circle cx="256" cy="256" r="120" fill="none" stroke="#00ff41" stroke-width="4" opacity="0.5"/>
<text x="256" y="296" text-anchor="middle" fill="#00ff41" font-family="monospace" font-size="200" font-weight="bold">D</text>
</svg>
<g stroke="#00ff41" stroke-width="7" fill="none" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)">
<rect x="80" y="96" width="352" height="280" rx="22"/>
</g>
<line x1="80" y1="136" x2="432" y2="136" stroke="#00ff41" stroke-width="4"/>
<g fill="#00ff41">
<circle cx="100" cy="116" r="4"/>
<circle cx="118" cy="116" r="4"/>
<circle cx="136" cy="116" r="4"/>
</g>
<g stroke="#00ff41" stroke-width="3" fill="none" stroke-linecap="round">
<path d="M414 110 l-10 8"/>
<path d="M404 126 l10 -8"/>
</g>
<g stroke="#00ff41" stroke-width="5" fill="none" stroke-linecap="round" filter="url(#glow)">
<rect x="200" y="184" width="112" height="104" rx="12"/>
</g>
<g stroke="#00ff41" stroke-width="4" stroke-linecap="round" fill="none" filter="url(#glow)">
<line x1="220" y1="184" x2="220" y2="160"/>
<line x1="244" y1="184" x2="244" y2="160"/>
<line x1="268" y1="184" x2="268" y2="160"/>
<line x1="292" y1="184" x2="292" y2="160"/>
<line x1="220" y1="288" x2="220" y2="312"/>
<line x1="244" y1="288" x2="244" y2="312"/>
<line x1="268" y1="288" x2="268" y2="312"/>
<line x1="292" y1="288" x2="292" y2="312"/>
</g>
<text x="256" y="252" text-anchor="middle" fill="#00ff41" font-family="monospace" font-size="56" font-weight="bold" filter="url(#glow)">#</text>
<g stroke="#00ff41" stroke-width="4" opacity="0.7" filter="url(#glow)">
<line x1="0" y1="236" x2="200" y2="236"/>
<line x1="312" y1="236" x2="512" y2="236"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 646 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Before After
Before After

View file

@ -42,9 +42,7 @@
@layer components {
.btn {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium
transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-degen-500 focus:ring-offset-2 focus:ring-offset-black
disabled:opacity-50 disabled:cursor-not-allowed;
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-degen-500 focus:ring-offset-2 focus:ring-offset-black disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-degen-500 text-black hover:bg-degen-400 hover:shadow-[0_0_20px_rgba(34,197,94,0.35)] active:bg-degen-600;
@ -62,8 +60,7 @@
@apply card transition-all duration-300 hover:border-degen-500/30 hover:bg-gray-900/60 hover:shadow-[0_0_30px_rgba(34,197,94,0.12)];
}
.input {
@apply w-full rounded-xl border border-white/10 bg-gray-950/50 px-4 py-2.5 text-sm text-white
placeholder-gray-500 focus:border-degen-500 focus:outline-none focus:ring-1 focus:ring-degen-500;
@apply w-full rounded-xl border border-white/10 bg-gray-950/50 px-4 py-2.5 text-sm text-white placeholder-gray-500 focus:border-degen-500 focus:outline-none focus:ring-1 focus:ring-degen-500;
}
.glass {
@apply border border-white/10 bg-gray-950/50 backdrop-blur-xl;
@ -82,7 +79,8 @@
inset: 0;
pointer-events: none;
z-index: 0;
background: radial-gradient(ellipse at 20% 20%, rgba(34, 197, 94, 0.12) 0%, transparent 50%),
background:
radial-gradient(ellipse at 20% 20%, rgba(34, 197, 94, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at 80% 80%, rgba(168, 85, 247, 0.1) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(236, 72, 153, 0.06) 0%, transparent 60%);
filter: blur(60px);
@ -94,7 +92,8 @@
inset: 0;
pointer-events: none;
z-index: 0;
background-image: radial-gradient(at 0% 0%, rgba(34, 197, 94, 0.15) 0px, transparent 50%),
background-image:
radial-gradient(at 0% 0%, rgba(34, 197, 94, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(168, 85, 247, 0.12) 0px, transparent 50%),
radial-gradient(at 100% 100%, rgba(236, 72, 153, 0.1) 0px, transparent 50%),
radial-gradient(at 0% 100%, rgba(34, 197, 94, 0.08) 0px, transparent 50%);

View file

@ -90,7 +90,7 @@ export default function HealthPage() {
<div className="flex items-center gap-3">
{StatusDot(h.healthy)}
<span className={h.healthy ? 'text-green-400' : 'text-red-400'}>
{h.healthy ? `${h.latencyMs}ms` : h.error ?? 'down'}
{h.healthy ? `${h.latencyMs}ms` : (h.error ?? 'down')}
</span>
</div>
</div>
@ -121,8 +121,10 @@ export default function HealthPage() {
<span className="text-degen-400">social.degenfeed.xyz (GotoSocial)</span>
</div>
<div className="flex justify-between">
<span>RMI DataBus</span>
<span className="text-degen-400">api.rugmunch.io (Talos)</span>
<span>Sources</span>
<span className="text-degen-400">
RSS, Reddit, Hacker News, Nostr, Farcaster, Bluesky, Lens, Mastodon
</span>
</div>
</div>
</div>

View file

@ -2,14 +2,13 @@
import { addBookmark, removeBookmark } from '@degenfeed/storage';
import type { EngagementAction } from '@degenfeed/types';
import { FeedList, type Notification, NotificationBell, type UnifiedPost } from '@degenfeed/ui';
import { useCallback, useEffect, useState } from 'react';
import { FeedList, NotificationBell, type UnifiedPost } from '@degenfeed/ui';
import { useEffect, useState } from 'react';
import { useAuth } from '../../../components/AuthProvider';
import { TipModal } from '../../../components/TipModal';
import { useNotifications } from '../../../hooks/useNotifications';
import { SECTIONS } from '../sections';
export const runtime = 'edge';
interface Props {
params: Promise<{ section: string }>;
}
@ -46,6 +45,116 @@ function SectionNav({ current }: { current: string }) {
);
}
interface SectionEmptyStateProps {
section: string;
state: 'loading' | 'loaded' | 'empty' | 'error';
hasSignedIn: boolean;
onSignIn: () => void;
onRetry: () => void;
}
function SectionEmptyState({
section,
state,
hasSignedIn,
onSignIn,
onRetry,
}: SectionEmptyStateProps) {
if (state === 'loading') return null;
if (state === 'error') {
return (
<div className="card mt-6 border-red-500/20 bg-red-500/5 py-8 text-center">
<h3 className="mb-2 text-base font-bold text-red-400">Feed unavailable</h3>
<p className="mb-4 text-sm text-gray-400">
The {section} source failed to load. This is usually a network or rate-limit issue.
</p>
<button type="button" onClick={onRetry} className="btn-primary text-sm">
Retry
</button>
</div>
);
}
if (state !== 'empty') return null;
const messages: Record<
string,
{ title: string; body: string; cta?: { label: string; href: string } }
> = {
mastodon: {
title: 'The fediverse is quiet',
body: hasSignedIn
? 'No posts from the Mastodon/GotoSocial timeline right now. Try posting first.'
: 'Create a free @degenfeed.xyz account to see posts from our own Mastodon instance.',
cta: hasSignedIn
? undefined
: { label: 'Create free account →', href: '/api/auth/gotosocial/register' },
},
nostr: {
title: 'No Nostr notes found',
body: 'Public relays returned no recent notes. This can happen when relays are slow or no one is posting on crypto hashtags.',
},
farcaster: {
title: 'No Farcaster casts found',
body: 'Farcaster hubs returned no recent casts. This may be a hub connectivity issue.',
},
lens: {
title: 'No Lens posts found',
body: 'Lens API returned no recent publications. This may be an API access issue.',
},
bluesky: {
title: 'No Bluesky posts found',
body: 'Bluesky search returned no recent posts. Try again later.',
},
threads: {
title: 'No Threads posts found',
body: 'Threads public pages returned no posts. Scraping is fragile; this may improve.',
},
reddit: {
title: 'No Reddit posts found',
body: 'Reddit feeds returned no posts. Reddit frequently blocks automated access.',
},
'reddit-crypto': {
title: 'r/CryptoCurrency is empty',
body: 'Reddit returned no posts. This is usually a rate-limit or block from Reddit.',
},
'reddit-bitcoin': {
title: 'r/Bitcoin is empty',
body: 'Reddit returned no posts. This is usually a rate-limit or block from Reddit.',
},
'reddit-ethereum': {
title: 'r/ethereum is empty',
body: 'Reddit returned no posts. This is usually a rate-limit or block from Reddit.',
},
};
const msg = messages[section] ?? {
title: 'No posts found',
body: 'This feed is empty right now. Try another section or sign in to see more.',
};
return (
<div className="card mt-6 border-degen-500/20 bg-degen-500/5 py-8 text-center">
<h3 className="mb-2 text-base font-bold text-degen-400">{msg.title}</h3>
<p className="mb-4 text-sm text-gray-400">{msg.body}</p>
<div className="flex justify-center gap-2">
{msg.cta ? (
<a href={msg.cta.href} className="btn-primary text-sm">
{msg.cta.label}
</a>
) : null}
{!hasSignedIn && !msg.cta && (
<button type="button" onClick={onSignIn} className="btn-primary text-sm">
Sign in to connect {section}
</button>
)}
<button type="button" onClick={onRetry} className="btn-secondary text-sm">
Retry
</button>
</div>
</div>
);
}
interface FeedMeta {
total?: number;
limit?: number;
@ -57,40 +166,24 @@ interface FeedMeta {
}
function SectionContent({ section }: { section: string }) {
const { signedInProtocols, engage, openSignIn, openCompose, tokens } = useAuth();
const { signedInProtocols, engage, openSignIn, openCompose } = useAuth();
const meta = SECTIONS.find((s) => s.id === section);
const [posts, setPosts] = useState<UnifiedPost[]>([]);
const [state, setState] = useState<'loading' | 'loaded' | 'empty' | 'error'>('loading');
const [error, setError] = useState<string>();
const [tipPost, setTipPost] = useState<UnifiedPost | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [feedMeta, setFeedMeta] = useState<FeedMeta>({});
const [refreshKey, setRefreshKey] = useState(0);
const hasSignedIn = Object.values(signedInProtocols).some(Boolean);
const fetchNotifs = useCallback(async () => {
if (!hasSignedIn) return;
try {
const stored = localStorage.getItem('degenfeed_identities');
if (!stored) return;
const parsed = JSON.parse(stored) as Array<{ protocol: string; token?: string }>;
const auth = JSON.stringify({
mastodon: parsed.find((i) => i.protocol === 'mastodon')?.token,
});
const res = await fetch(`/api/notifications?auth=${encodeURIComponent(auth)}`, {
credentials: 'include',
});
const data = (await res.json()) as { notifications?: Notification[] };
setNotifications(data.notifications || []);
} catch {}
}, [hasSignedIn]);
useEffect(() => {
fetchNotifs();
const interval = setInterval(fetchNotifs, 60000);
return () => clearInterval(interval);
}, [fetchNotifs]);
const { notifications, unreadCount, fetchNotifications, markRead } =
useNotifications(hasSignedIn);
useEffect(() => {
const feedInterval = setInterval(() => setRefreshKey((k) => k + 1), 60000);
return () => clearInterval(feedInterval);
}, []);
useEffect(() => {
void refreshKey;
let cancelled = false;
async function load() {
setState('loading');
@ -100,14 +193,22 @@ function SectionContent({ section }: { section: string }) {
if (meta?.params) {
for (const [k, v] of Object.entries(meta.params)) url.searchParams.set(k, v);
}
let res = await fetch(url.toString(), { credentials: 'include' });
let res = await fetch(url.toString(), {
credentials: 'include',
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
let data = (await res.json()) as { data?: UnifiedPost[]; meta?: FeedMeta };
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(), { credentials: 'include' });
res = await fetch(rssUrl.toString(), {
credentials: 'include',
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
data = (await res.json()) as { data?: UnifiedPost[]; meta?: FeedMeta };
}
if (cancelled) return;
@ -125,7 +226,7 @@ function SectionContent({ section }: { section: string }) {
return () => {
cancelled = true;
};
}, [section, meta]);
}, [section, meta, refreshKey]);
const displayName = meta?.label ?? section;
const color = meta?.color ?? '#666';
@ -152,6 +253,8 @@ function SectionContent({ section }: { section: string }) {
return engage(action, postId, post.protocol, { content });
};
const handleRetry = () => setRefreshKey((k) => k + 1);
return (
<div className="mx-auto max-w-2xl px-4 py-4">
<SectionNav current={section} />
@ -175,11 +278,9 @@ function SectionContent({ section }: { section: string }) {
{hasSignedIn && (
<NotificationBell
notifications={notifications}
unreadCount={notifications.filter((n) => !n.read).length}
onFetch={fetchNotifs}
onMarkRead={(id) =>
setNotifications(notifications.map((n) => (n.id === id ? { ...n, read: true } : n)))
}
unreadCount={unreadCount}
onFetch={fetchNotifications}
onMarkRead={markRead}
/>
)}
{hasSignedIn ? (
@ -202,6 +303,14 @@ function SectionContent({ section }: { section: string }) {
</div>
)}
{feedMeta.errors && feedMeta.errors.length > 0 && (
<div className="mb-3 rounded-lg border border-red-500/20 bg-red-500/10 p-3 text-xs text-red-400">
{feedMeta.errors.map((e) => (
<div key={e}>{e}</div>
))}
</div>
)}
{feedMeta.trending && feedMeta.trending.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{feedMeta.trending.map((t) => (
@ -243,20 +352,16 @@ function SectionContent({ section }: { section: string }) {
}}
onEngage={handleEngage}
onTip={(post) => setTipPost(post)}
onRetry={() => setState('loading')}
onRetry={handleRetry}
/>
{state === 'empty' && !hasSignedIn && (
<div className="card mt-6 border-degen-500/20 bg-degen-500/5 py-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
</a>
</div>
)}
<SectionEmptyState
section={section}
state={state}
hasSignedIn={hasSignedIn}
onSignIn={openSignIn}
onRetry={handleRetry}
/>
{tipPost && (
<TipModal

View file

@ -7,7 +7,7 @@ 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 [stageFilter, _setStageFilter] = useState<string>('');
const [meta, setMeta] = useState<{ totalRaised?: number }>({});
useEffect(() => {

View file

@ -1,216 +1,121 @@
'use client';
import Image from 'next/image';
import { useAuth } from '../../components/AuthProvider';
import { SECTIONS } from './sections';
function getProtocolColor(p: string): string {
const colors: Record<string, string> = {
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
threads: '#888',
mastodon: '#6364ff',
rss: '#ffa500',
};
return colors[p] ?? '#666';
}
const CATEGORIES = SECTIONS.filter((s) =>
[
'crypto',
'bitcoin',
'ethereum',
'solana',
'defi',
'nft',
'ai',
'regulation',
'security',
'macro',
'news',
].includes(s.id),
);
const PLATFORMS = SECTIONS.filter((s) =>
['nostr', 'farcaster', 'lens', 'bluesky', 'threads', 'mastodon', 'articles'].includes(s.id),
);
export const dynamic = 'force-dynamic';
const PROTOCOLS = [
{ id: 'nostr', label: 'Nostr', emoji: '⚡', color: '#8e30eb', desc: 'Decentralized notes' },
{
id: 'farcaster',
label: 'Farcaster',
emoji: '🟣',
color: '#8a63d2',
desc: 'Sufficiently decentralized',
},
{ id: 'lens', label: 'Lens', emoji: '🌿', color: '#abfe2c', desc: 'User-owned graph' },
{ id: 'bluesky', label: 'Bluesky', emoji: '🦋', color: '#1185fe', desc: 'AT Protocol' },
{ id: 'mastodon', label: 'Mastodon', emoji: '🐘', color: '#6364ff', desc: 'Fediverse' },
{ id: 'reddit', label: 'Reddit', emoji: '🔴', color: '#ff4500', desc: 'Reddit feeds' },
{ id: 'rss', label: 'News', emoji: '📰', color: '#ffa500', desc: '150+ RSS sources' },
{ id: 'threads', label: 'Threads', emoji: '🧵', color: '#888', desc: 'Meta threads' },
] as const;
export default function HomePage() {
const { signedInProtocols, ready, openSignIn, openCompose } = useAuth();
const hasSignedIn = Object.values(signedInProtocols).some(Boolean);
const count = Object.keys(signedInProtocols).length;
if (!ready) {
return (
<div className="mx-auto max-w-7xl px-4 py-20 text-center">
<div className="mx-auto mb-4 h-1 w-48 animate-pulse rounded-full bg-gray-800" />
<p className="text-gray-500">Loading...</p>
</div>
);
}
const hasSignedIn = ready && Object.values(signedInProtocols).some(Boolean);
return (
<div className="mx-auto max-w-7xl px-4 py-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-12">
<aside className="hidden lg:col-span-3 lg:block">
<div className="card sticky top-24 border-degen-500/20 bg-degen-500/5">
<h2 className="mb-3 text-sm font-semibold text-degen-400">Feeds</h2>
<nav className="space-y-1">
{CATEGORIES.map((s) => (
<a
key={s.id}
href={`/home/${s.id}`}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-gray-400 hover:bg-white/5 hover:text-white"
>
<span style={{ color: s.color }}>{s.icon}</span>
<span>{s.label}</span>
</a>
))}
</nav>
<h2 className="mb-3 mt-6 text-sm font-semibold text-degen-400">Platforms</h2>
<nav className="space-y-1">
{PLATFORMS.map((s) => (
<a
key={s.id}
href={`/home/${s.id}`}
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-gray-400 hover:bg-white/5 hover:text-white"
>
<span style={{ color: s.color }}>{s.icon}</span>
<span>{s.label}</span>
</a>
))}
</nav>
</div>
</aside>
<div className="mx-auto max-w-6xl px-4 py-8">
{/* Hero */}
<div className="mb-10 text-center">
<div className="mb-4 flex justify-center">
<Image
src="/icon.svg"
alt="DegenFeed"
width={80}
height={80}
className="h-20 w-20 drop-shadow-[0_0_24px_rgba(34,197,94,0.4)]"
/>
</div>
<h1 className="text-3xl font-bold tracking-tight text-white sm:text-4xl">
The social feed of <span className="gradient-text">web3</span>
</h1>
<p className="mx-auto mt-2 max-w-md text-sm text-gray-500">
Pick a network. Browse, search, and cross-post everywhere.
</p>
</div>
<main className="lg:col-span-6">
<header className="mb-6">
<h1 className="text-3xl font-bold tracking-tight text-white">
The home of web3 social
</h1>
<p className="mt-1 text-sm text-gray-500">
{hasSignedIn
? `Connected: ${count} network${count > 1 ? 's' : ''}`
: 'Every network. One window. Zero sign-ups required.'}
</p>
</header>
{/* Protocol grid — single click into any feed */}
<div className="mb-8 grid grid-cols-2 gap-3 sm:grid-cols-4">
{PROTOCOLS.map((p) => (
<a
key={p.id}
href={`/home/${p.id}`}
className="card-hover group flex flex-col items-center gap-2 p-4 text-center"
style={{ borderColor: `${p.color}33` }}
>
<span className="text-3xl transition-transform group-hover:scale-110">{p.emoji}</span>
<span className="text-sm font-semibold text-white" style={{ color: p.color }}>
{p.label}
</span>
<span className="text-[10px] text-gray-600">{p.desc}</span>
</a>
))}
</div>
<div className="card mb-6 border-degen-500/30 bg-gradient-to-r from-degen-500/10 via-purple-500/10 to-fuchsia-500/10">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="mb-1 text-xs font-semibold uppercase tracking-widest text-degen-400">
Live
</div>
<h2 className="text-lg font-bold text-white">DegenFeed News</h2>
<p className="text-sm text-gray-500">
Curated crypto, AI, and tech news from 45+ sources.
</p>
</div>
<a href="/news" className="btn-primary text-center text-sm">
Open News
</a>
</div>
</div>
{/* Quick actions */}
<div className="mb-8 flex flex-wrap items-center justify-center gap-3">
<a href="/news" className="btn-secondary text-sm">
📰 News
</a>
<a href="/newsletter" className="btn-secondary text-sm">
📬 Daily Digest
</a>
{hasSignedIn ? (
<button type="button" onClick={openCompose} className="btn-primary text-sm">
Compose
</button>
) : (
<button type="button" onClick={openSignIn} className="btn-primary text-sm">
🔑 Sign In
</button>
)}
</div>
<div className="mb-6 flex gap-2 overflow-x-auto pb-2 lg:hidden">
{CATEGORIES.map((s) => (
<a
key={s.id}
href={`/home/${s.id}`}
className="whitespace-nowrap rounded-full border border-gray-800 bg-gray-900 px-3 py-1.5 text-xs text-gray-400 hover:border-degen-500/50 hover:text-white"
>
{s.icon} {s.label}
</a>
))}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{SECTIONS.map((section) => (
<a
key={section.id}
href={`/home/${section.id}`}
className="card-hover group cursor-pointer"
style={{ borderColor: `${section.color}33` }}
>
<div className="mb-2 text-2xl" style={{ color: section.color }}>
{section.icon}
</div>
<div className="text-sm font-semibold text-white group-hover:text-degen-400">
{section.label}
</div>
<div className="mt-1 text-xs text-gray-500 line-clamp-2">{section.description}</div>
</a>
))}
</div>
<div className="card mt-6 border-degen-500/20 bg-degen-500/5">
<h3 className="mb-2 text-sm font-semibold text-degen-400">
{hasSignedIn ? 'Ready to post?' : 'No account? No problem.'}
</h3>
<p className="text-sm text-gray-400">
{hasSignedIn
? 'Click Compose to write a post. Select which networks to publish to.'
: 'All content is visible without signing in. Connect an account to like, reply, and repost. Add multiple to cross-post.'}
</p>
{!hasSignedIn ? (
<button type="button" className="btn-primary mt-3 text-sm" onClick={openSignIn}>
Sign in with any protocol
</button>
) : (
<button type="button" className="btn-primary mt-3 text-sm" onClick={openCompose}>
Compose a post
</button>
)}
</div>
</main>
<aside className="hidden lg:col-span-3 lg:block">
<div className="card sticky top-24 border-purple-500/20 bg-purple-500/5">
<h2 className="mb-3 text-sm font-semibold text-purple-400">Trending</h2>
<p className="text-xs text-gray-500">
Hot tickers and topics update as you browse feeds.
</p>
<a href="/home/trending" className="btn-primary mt-3 block text-center text-xs">
See Trending
</a>
</div>
<div className="card mt-4 border-yellow-500/20 bg-yellow-500/5">
<h2 className="mb-2 text-sm font-semibold text-yellow-400">Premium</h2>
<p className="text-xs text-gray-400">
Unlock cross-posting, algorithm controls, analytics, and creator monetization.
</p>
{/* Trending niches — one click into a feed */}
<div className="card">
<h2 className="mb-3 text-xs font-semibold uppercase tracking-widest text-gray-500">
Browse by Topic
</h2>
<div className="flex flex-wrap gap-2">
{[
{ id: 'crypto', label: 'Crypto', color: '#f59e0b' },
{ id: 'bitcoin', label: 'Bitcoin', color: '#f7931a' },
{ id: 'ethereum', label: 'Ethereum', color: '#627eea' },
{ id: 'solana', label: 'Solana', color: '#14f195' },
{ id: 'defi', label: 'DeFi', color: '#6366f1' },
{ id: 'ai', label: 'AI', color: '#06b6d4' },
{ id: 'nft', label: 'NFTs', color: '#ec4899' },
{ id: 'regulation', label: 'Regulation', color: '#ef4444' },
{ id: 'security', label: 'Security', color: '#dc2626' },
{ id: 'macro', label: 'Macro', color: '#10b981' },
].map((n) => (
<a
href="/premium"
className="mt-3 block text-center text-xs text-yellow-400 hover:underline"
key={n.id}
href={`/home/${n.id}`}
className="rounded-full border border-gray-800 bg-gray-900/50 px-3 py-1.5 text-xs text-gray-400 transition-colors hover:border-degen-500/50 hover:text-white"
>
Compare plans
#{n.label}
</a>
</div>
))}
</div>
</div>
{hasSignedIn && (
<div className="card mt-4 border-degen-500/20 bg-degen-500/5">
<h2 className="mb-2 text-sm font-semibold text-degen-400">Signed in</h2>
<div className="flex flex-wrap gap-2">
{Object.keys(signedInProtocols).map((p) => (
<span
key={p}
className="rounded-full px-2 py-0.5 text-xs font-medium"
style={{
backgroundColor: `${getProtocolColor(p)}22`,
color: getProtocolColor(p),
}}
>
{p}
</span>
))}
</div>
</div>
)}
</aside>
{/* Status footer */}
<div className="mt-8 text-center">
<p className="text-[10px] text-gray-700">
No account required to browse · Zero cookies · Zero tracking · MIT licensed
</p>
</div>
</div>
);

View file

@ -269,7 +269,8 @@ export const SECTIONS: FeedSection[] = [
label: 'Reddit',
icon: 'R',
color: '#ff4500',
description: 'Reddit crypto hot posts (r/CryptoCurrency, r/Bitcoin, r/ethereum, r/solana, r/defi)',
description:
'Reddit crypto hot posts (r/CryptoCurrency, r/Bitcoin, r/ethereum, r/solana, r/defi)',
endpoint: '/api/feed/home',
limit: 25,
params: { protocols: 'reddit' },

View file

@ -5,6 +5,7 @@ import { AuthProvider } from '../components/AuthProvider';
import { ErrorTracker } from '../components/ErrorTracker';
import { Shell } from '../components/Shell';
import { SolanaProvider } from '../components/SolanaProvider';
import { ToastProvider } from '../components/Toast';
import { Web3Provider } from '../components/Web3Provider';
export const metadata: Metadata = {
@ -46,9 +47,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
/>
<SolanaProvider>
<Web3Provider>
<AuthProvider>
<Shell>{children}</Shell>
</AuthProvider>
<ToastProvider>
<AuthProvider>
<Shell>{children}</Shell>
</AuthProvider>
</ToastProvider>
</Web3Provider>
</SolanaProvider>
</body>

View file

@ -1,6 +1,7 @@
'use client';
import type { UnifiedPost } from '@degenfeed/types';
import Image from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
interface NewsMeta {
@ -49,7 +50,6 @@ const CATEGORIES = [
const SOURCE_TABS = [
{ id: 'all', label: 'All Sources' },
{ id: 'rmi', label: 'RMI DataBus' },
{ id: 'rss', label: 'RSS' },
{ id: 'reddit', label: 'Reddit' },
{ id: 'hackernews', label: 'HN' },
@ -114,7 +114,8 @@ function NewsCard({ post }: { post: UnifiedPost }) {
const imageEmbed = post.embeds.find((e) => e.kind === 'image');
const linkEmbed = post.embeds.find((e) => e.kind === 'link');
const url = linkEmbed?.url || '#';
const title = stripTags(post.text).slice(0, 120).split(' ').slice(0, -1).join(' ') || post.text.slice(0, 100);
const title =
stripTags(post.text).slice(0, 120).split(' ').slice(0, -1).join(' ') || post.text.slice(0, 100);
const desc = stripTags(post.html || post.text).slice(0, 200);
return (
@ -126,11 +127,14 @@ function NewsCard({ post }: { post: UnifiedPost }) {
rel="noopener noreferrer"
className="block aspect-video w-full overflow-hidden"
>
<img
<Image
src={imageEmbed.url}
alt=""
width={800}
height={450}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
unoptimized
/>
</a>
)}
@ -150,7 +154,9 @@ function NewsCard({ post }: { post: UnifiedPost }) {
</span>
)}
{sentiment && (
<span className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${sentimentColor(sentiment)}`}>
<span
className={`rounded-md border px-1.5 py-0.5 text-[10px] font-medium ${sentimentColor(sentiment)}`}
>
{sentimentEmoji(sentiment)} {sentiment}
</span>
)}
@ -166,9 +172,7 @@ function NewsCard({ post }: { post: UnifiedPost }) {
{title}
</a>
<p className="mb-3 text-xs leading-relaxed text-gray-500 line-clamp-2">
{desc}
</p>
<p className="mb-3 text-xs leading-relaxed text-gray-500 line-clamp-2">{desc}</p>
<div className="mt-auto flex flex-wrap gap-1">
{post.niches.slice(0, 4).map((niche) => (
@ -227,34 +231,45 @@ export default function NewsClient({ initialPosts, initialMeta }: NewsClientProp
const [newAvailable, setNewAvailable] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchNews = useCallback(async (showIndicator = false) => {
if (!showIndicator) setState('loading');
try {
const params = new URLSearchParams();
params.set('limit', '50');
if (category) params.set('category', category);
if (query) params.set('q', query);
if (mustRead) params.set('mustRead', 'true');
const fetchNews = useCallback(
async (showIndicator = false) => {
if (!showIndicator) setState('loading');
try {
const params = new URLSearchParams();
params.set('limit', '50');
if (category) params.set('category', category);
if (query) params.set('q', query);
if (mustRead) params.set('mustRead', 'true');
const res = await fetch(`/api/feed/news?${params.toString()}`, { credentials: 'include' });
const data = (await res.json()) as { data?: UnifiedPost[]; meta?: NewsMeta; error?: string };
if (!res.ok) throw new Error(data.error || 'Failed to load news');
const res = await fetch(`/api/feed/news?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
const data = (await res.json()) as {
data?: UnifiedPost[];
meta?: NewsMeta;
error?: string;
};
if (!res.ok) throw new Error(data.error || 'Failed to load news');
if (showIndicator && data.data && data.data.length > 0) {
setNewAvailable(true);
return;
if (showIndicator && data.data && data.data.length > 0) {
setNewAvailable(true);
return;
}
setPosts(data.data || []);
setMeta(data.meta || null);
setState('loaded');
} catch (err) {
if (!showIndicator) {
setError(err instanceof Error ? err.message : 'Failed to load news');
setState('error');
}
}
setPosts(data.data || []);
setMeta(data.meta || null);
setState('loaded');
} catch (err) {
if (!showIndicator) {
setError(err instanceof Error ? err.message : 'Failed to load news');
setState('error');
}
}
}, [category, query, mustRead]);
},
[category, query, mustRead],
);
const refreshNow = useCallback(() => {
setNewAvailable(false);
@ -278,17 +293,19 @@ export default function NewsClient({ initialPosts, initialMeta }: NewsClientProp
setQuery(searchInput);
};
const filteredPosts = sourceTab === 'all'
? posts
: posts.filter((p) => {
const raw = getRaw(p);
const kind = (raw.kind ?? '').toLowerCase();
if (sourceTab === 'rmi') return kind === 'rss' || kind === 'internal' || kind === 'api';
if (sourceTab === 'rss') return kind === 'rss' && !raw.sourceName?.toLowerCase().includes('reddit');
if (sourceTab === 'reddit') return kind === 'reddit' || raw.sourceName?.toLowerCase().includes('reddit');
if (sourceTab === 'hackernews') return kind === 'hackernews';
return true;
});
const filteredPosts =
sourceTab === 'all'
? posts
: posts.filter((p) => {
const raw = getRaw(p);
const kind = (raw.kind ?? '').toLowerCase();
if (sourceTab === 'rss')
return kind === 'rss' && !raw.sourceName?.toLowerCase().includes('reddit');
if (sourceTab === 'reddit')
return kind === 'reddit' || raw.sourceName?.toLowerCase().includes('reddit');
if (sourceTab === 'hackernews') return kind === 'hackernews';
return true;
});
const sentiment = meta?.sentiment ?? undefined;
const sourceCount = meta?.sourceCount ?? meta?.sources ?? 0;
@ -306,7 +323,8 @@ export default function NewsClient({ initialPosts, initialMeta }: NewsClientProp
</span>
</div>
<p className="mt-1 text-sm text-gray-500">
Curated intelligence from {sourceCount || '200+'} sources across the RMI DataBus, RSS, Reddit, and Hacker News ranked by quality, recency, and sentiment.
Curated intelligence from {sourceCount || '200+'} sources across RSS, Reddit, and
Hacker News ranked by quality, recency, and sentiment.
</p>
</div>
<div className="flex shrink-0 items-center gap-3 text-right">
@ -508,7 +526,7 @@ export default function NewsClient({ initialPosts, initialMeta }: NewsClientProp
<li> Deduplicated by canonical URL</li>
<li> Categorized by AI keyword extraction</li>
<li> Updated every 15 minutes</li>
<li> Powered by RMI DataBus (200+ sources)</li>
<li> Aggregated from RSS, Reddit, and Hacker News</li>
</ul>
</div>
</aside>

View file

@ -1,8 +1,8 @@
import type { UnifiedPost } from '@degenfeed/types';
import { headers } from 'next/headers';
import { Suspense } from 'react';
import NewsClient from './NewsClient';
import NewsLoading from './loading';
import NewsClient from './NewsClient';
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
@ -35,6 +35,8 @@ async function NewsData() {
const res = await fetch(url, {
credentials: 'include',
next: { revalidate: 0 },
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
const json = (await res.json()) as NewsApiResponse;
const initialPosts = (json.data || []) as UnifiedPost[];

View file

@ -59,16 +59,26 @@ function formatTime(ts: number): string {
function protocolColor(p: string): string {
const map: Record<string, string> = {
nostr: '#8e30eb', farcaster: '#8a63d2', lens: '#abfe2c',
bluesky: '#1185fe', mastodon: '#6364ff', threads: '#888', rss: '#ffa500',
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
mastodon: '#6364ff',
threads: '#888',
rss: '#ffa500',
};
return map[p] ?? '#666';
}
function protocolEmoji(p: string): string {
const map: Record<string, string> = {
nostr: '⚡', farcaster: '🟣', lens: '🌿', bluesky: '🦋',
mastodon: '🐘', threads: '🧵', rss: '📡',
nostr: '⚡',
farcaster: '🟣',
lens: '🌿',
bluesky: '🦋',
mastodon: '🐘',
threads: '🧵',
rss: '📡',
};
return map[p] ?? '●';
}
@ -84,9 +94,7 @@ export default function NewsletterPage() {
const [digestLoading, setDigestLoading] = useState(false);
const toggleNiche = (id: string) => {
setSelectedNiches((prev) =>
prev.includes(id) ? prev.filter((n) => n !== id) : [...prev, id],
);
setSelectedNiches((prev) => (prev.includes(id) ? prev.filter((n) => n !== id) : [...prev, id]));
};
const toggleProtocol = (id: string) => {
@ -95,33 +103,36 @@ export default function NewsletterPage() {
);
};
const handleSubscribe = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
setStatus('error');
setStatusMsg('Please enter a valid email address.');
return;
}
setStatus('subscribing');
try {
const res = await fetch('/api/newsletter/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
email,
niches: selectedNiches,
protocols: selectedProtocols.length > 0 ? selectedProtocols : undefined,
}),
});
const data = (await res.json()) as { ok?: boolean; error?: string };
if (!res.ok || !data.ok) throw new Error(data.error || 'Subscription failed');
setStatus('subscribed');
setStatusMsg('You are subscribed! Check your inbox for the daily digest.');
} catch (err) {
setStatus('error');
setStatusMsg(err instanceof Error ? err.message : 'Subscription failed');
}
}, [email, selectedNiches, selectedProtocols]);
const handleSubscribe = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email)) {
setStatus('error');
setStatusMsg('Please enter a valid email address.');
return;
}
setStatus('subscribing');
try {
const res = await fetch('/api/newsletter/subscribe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
email,
niches: selectedNiches,
protocols: selectedProtocols.length > 0 ? selectedProtocols : undefined,
}),
});
const data = (await res.json()) as { ok?: boolean; error?: string };
if (!res.ok || !data.ok) throw new Error(data.error || 'Subscription failed');
setStatus('subscribed');
setStatusMsg('You are subscribed! Check your inbox for the daily digest.');
} catch (err) {
setStatus('error');
setStatusMsg(err instanceof Error ? err.message : 'Subscription failed');
}
},
[email, selectedNiches, selectedProtocols],
);
const loadDigest = useCallback(async () => {
setDigestLoading(true);
@ -129,7 +140,11 @@ export default function NewsletterPage() {
const params = new URLSearchParams();
if (selectedNiches.length > 0) params.set('niches', selectedNiches.join(','));
if (selectedProtocols.length > 0) params.set('protocols', selectedProtocols.join(','));
const res = await fetch(`/api/newsletter/digest?${params.toString()}`, { credentials: 'include' });
const res = await fetch(`/api/newsletter/digest?${params.toString()}`, {
credentials: 'include',
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
const data = (await res.json()) as { data?: DigestItem[]; meta?: DigestMeta };
setDigest(data.data ?? []);
setDigestMeta(data.meta ?? null);
@ -147,11 +162,11 @@ export default function NewsletterPage() {
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-degen-500 to-emerald-600 text-2xl shadow-[0_0_24px_rgba(34,197,94,0.3)]">
</div>
<h1 className="mb-2 text-4xl font-bold tracking-tight text-white">
DegenFeed Daily
</h1>
<h1 className="mb-2 text-4xl font-bold tracking-tight text-white">DegenFeed Daily</h1>
<p className="mx-auto max-w-xl text-sm leading-relaxed text-gray-500">
The best of web3, AI, and tech aggregated from Nostr, Farcaster, Lens, Bluesky, Mastodon, and 200+ RSS sources. Ranked by quality, recency, and diversity. Delivered to your inbox every morning.
The best of web3, AI, and tech aggregated from Nostr, Farcaster, Lens, Bluesky,
Mastodon, and 200+ RSS sources. Ranked by quality, recency, and diversity. Delivered to
your inbox every morning.
</p>
</div>
@ -167,10 +182,13 @@ export default function NewsletterPage() {
<form onSubmit={handleSubscribe}>
{/* Niche selection */}
<div className="mb-5">
<label className="mb-2 block text-xs font-semibold uppercase tracking-widest text-gray-500">
<label
htmlFor="newsletter-topics"
className="mb-2 block text-xs font-semibold uppercase tracking-widest text-gray-500"
>
Topics
</label>
<div className="flex flex-wrap gap-1.5">
<div id="newsletter-topics" className="flex flex-wrap gap-1.5">
{ALL_NICHES.map((n) => (
<button
key={n.id}
@ -190,10 +208,13 @@ export default function NewsletterPage() {
{/* Protocol selection */}
<div className="mb-5">
<label className="mb-2 block text-xs font-semibold uppercase tracking-widest text-gray-500">
<label
htmlFor="newsletter-sources"
className="mb-2 block text-xs font-semibold uppercase tracking-widest text-gray-500"
>
Sources
</label>
<div className="flex flex-wrap gap-1.5">
<div id="newsletter-sources" className="flex flex-wrap gap-1.5">
{ALL_PROTOCOLS.map((p) => (
<button
key={p.id}
@ -213,7 +234,10 @@ export default function NewsletterPage() {
{/* Email input */}
<div className="mb-4">
<label htmlFor="newsletter-email" className="mb-1.5 block text-xs font-medium text-gray-400">
<label
htmlFor="newsletter-email"
className="mb-1.5 block text-xs font-medium text-gray-400"
>
Email address
</label>
<input
@ -232,12 +256,14 @@ export default function NewsletterPage() {
disabled={status === 'subscribing' || status === 'subscribed'}
className="btn-primary w-full"
>
{status === 'subscribing' ? 'Subscribing...' : status === 'subscribed' ? 'Subscribed ✓' : 'Subscribe'}
{status === 'subscribing'
? 'Subscribing...'
: status === 'subscribed'
? 'Subscribed ✓'
: 'Subscribe'}
</button>
{status === 'error' && (
<p className="mt-2 text-xs text-red-400">{statusMsg}</p>
)}
{status === 'error' && <p className="mt-2 text-xs text-red-400">{statusMsg}</p>}
{status === 'subscribed' && (
<p className="mt-2 text-xs text-degen-400">{statusMsg}</p>
)}
@ -261,7 +287,8 @@ export default function NewsletterPage() {
{digest.length === 0 && !digestLoading && (
<div className="card py-8 text-center">
<p className="text-xs text-gray-500">
Select topics/sources above and click "Load Preview" to see what your daily digest will look like.
Select topics/sources above and click "Load Preview" to see what your daily digest
will look like.
</p>
</div>
)}
@ -306,9 +333,7 @@ export default function NewsletterPage() {
{formatTime(item.createdAt)}
</span>
</div>
<p className="text-sm leading-snug text-gray-300 line-clamp-2">
{item.text}
</p>
<p className="text-sm leading-snug text-gray-300 line-clamp-2">{item.text}</p>
{item.url && (
<a
href={item.url}
@ -349,11 +374,13 @@ export default function NewsletterPage() {
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 text-degen-400"></span>
<span>Multi-protocol aggregation (Nostr, Farcaster, Lens, Bluesky, Mastodon, RSS)</span>
<span>
Multi-protocol aggregation (Nostr, Farcaster, Lens, Bluesky, Mastodon, RSS)
</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 text-degen-400"></span>
<span>RMI DataBus institutional-grade news (200+ sources)</span>
<span>Curated RSS and protocol feeds (50+ sources)</span>
</li>
<li className="flex items-start gap-2">
<span className="mt-0.5 text-degen-400"></span>

View file

@ -1,52 +1,16 @@
/**
* Landing page redirect or app entry point.
* Unauthenticated users see the landing. Authenticated users go to /home.
* For now, render the hero from the static landing inline.
*/
'use client';
export default function HomePage() {
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
export default function RootPage() {
const router = useRouter();
useEffect(() => {
router.replace('/twitter');
}, [router]);
return (
<section className="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center px-4 py-12">
<div className="card mx-auto max-w-3xl py-16 text-center">
<div className="mb-6 text-sm font-medium uppercase tracking-widest text-degen-500">
Open Beta
</div>
<h1 className="mb-4 text-5xl font-bold tracking-tight text-white sm:text-6xl lg:text-7xl">
The social feed
<br />
of <span className="gradient-text">web3</span>
</h1>
<p className="mx-auto mb-8 max-w-xl text-lg text-gray-400">
No account? Just browse. One key? Like & reply. Multiple keys? Cross-post all networks.
</p>
<div className="mb-12 flex flex-wrap items-center justify-center gap-4">
<span className="rounded-full bg-gray-900/80 px-4 py-1.5 text-sm text-gray-400">
Nostr
</span>
<span className="rounded-full bg-gray-900/80 px-4 py-1.5 text-sm text-gray-400">
Farcaster
</span>
<span className="rounded-full bg-gray-900/80 px-4 py-1.5 text-sm text-gray-400">
Lens
</span>
<span className="rounded-full bg-gray-900/80 px-4 py-1.5 text-sm text-gray-400">
Bluesky
</span>
</div>
<div className="flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
<a href="/home" className="btn-primary text-base">
Launch App
</a>
<a
href="https://github.com/RugMunchMedia/degenfeed-web"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary text-base"
>
View on GitHub
</a>
</div>
</div>
</section>
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-sm text-gray-500">Loading</p>
</div>
);
}

View file

@ -2,6 +2,7 @@
import { addBookmark, removeBookmark } from '@degenfeed/storage';
import type { EngagementAction, UnifiedPost } from '@degenfeed/types';
import Image from 'next/image';
import { useCallback, useEffect, useState } from 'react';
import { useAuth } from '../../../components/AuthProvider';
import { TipModal } from '../../../components/TipModal';
@ -16,30 +17,37 @@ function formatTime(ts: number): string {
return `${Math.floor(hours / 24)}d`;
}
function stripTags(html: string): string {
return html.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ' ')
.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ').trim();
}
function protocolColor(p: string): string {
const c: Record<string, string> = {
nostr: '#8e30eb', farcaster: '#8a63d2', lens: '#abfe2c',
bluesky: '#1185fe', threads: '#888', mastodon: '#6364ff', rss: '#ffa500',
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
threads: '#888',
mastodon: '#6364ff',
rss: '#ffa500',
};
return c[p] ?? '#666';
}
function protocolEmoji(p: string): string {
const m: Record<string, string> = {
nostr: '⚡', farcaster: '🟣', lens: '🌿', bluesky: '🦋',
mastodon: '🐘', threads: '🧵', rss: '📡',
nostr: '⚡',
farcaster: '🟣',
lens: '🌿',
bluesky: '🦋',
mastodon: '🐘',
threads: '🧵',
rss: '📡',
};
return m[p] ?? '●';
}
function PostCard({ post, onEngage, onTip }: {
function PostCard({
post,
onEngage,
onTip,
}: {
post: UnifiedPost;
onEngage: (action: EngagementAction, id: string) => Promise<{ ok: boolean; error?: string }>;
onTip: (post: UnifiedPost) => void;
@ -50,17 +58,30 @@ function PostCard({ post, onEngage, onTip }: {
const [metrics, setMetrics] = useState(post.metrics);
const [engaging, setEngaging] = useState<string | null>(null);
const handleAction = useCallback(async (action: EngagementAction) => {
setEngaging(action);
const result = await onEngage(action, post.id);
if (result.ok) {
if (action === 'like') { setLiked((v) => !v); setMetrics((m) => ({ ...m, likes: m.likes + (liked ? -1 : 1) })); }
if (action === 'repost') { setReposted((v) => !v); setMetrics((m) => ({ ...m, reposts: m.reposts + (reposted ? -1 : 1) })); }
if (action === 'bookmark') { setBookmarked(true); }
if (action === 'unbookmark') { setBookmarked(false); }
}
setEngaging(null);
}, [post.id, onEngage, liked, reposted]);
const handleAction = useCallback(
async (action: EngagementAction) => {
setEngaging(action);
const result = await onEngage(action, post.id);
if (result.ok) {
if (action === 'like') {
setLiked((v) => !v);
setMetrics((m) => ({ ...m, likes: m.likes + (liked ? -1 : 1) }));
}
if (action === 'repost') {
setReposted((v) => !v);
setMetrics((m) => ({ ...m, reposts: m.reposts + (reposted ? -1 : 1) }));
}
if (action === 'bookmark') {
setBookmarked(true);
}
if (action === 'unbookmark') {
setBookmarked(false);
}
}
setEngaging(null);
},
[post.id, onEngage, liked, reposted],
);
const imageEmbed = post.embeds.find((e) => e.kind === 'image');
const linkEmbed = post.embeds.find((e) => e.kind === 'link');
@ -71,14 +92,22 @@ function PostCard({ post, onEngage, onTip }: {
<div className="mb-4 flex items-center gap-3">
<div
className="flex h-10 w-10 items-center justify-center rounded-full text-sm font-bold"
style={{ backgroundColor: `${protocolColor(post.protocol)}22`, color: protocolColor(post.protocol) }}
style={{
backgroundColor: `${protocolColor(post.protocol)}22`,
color: protocolColor(post.protocol),
}}
>
{post.author.displayName[0]?.toUpperCase() || '?'}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-white truncate">{post.author.displayName}</span>
<span className="text-[10px] text-gray-500" style={{ color: protocolColor(post.protocol) }}>
<span className="text-sm font-semibold text-white truncate">
{post.author.displayName}
</span>
<span
className="text-[10px] text-gray-500"
style={{ color: protocolColor(post.protocol) }}
>
{protocolEmoji(post.protocol)} {post.protocol}
</span>
</div>
@ -96,8 +125,9 @@ function PostCard({ post, onEngage, onTip }: {
</p>
{post.html && (
<div
className="mt-2 text-sm leading-relaxed text-gray-400"
// biome-ignore lint/security/noDangerouslySetInnerHtml: content is sanitized via DOMPurify before rendering
dangerouslySetInnerHTML={{ __html: sanitizeHtml(post.html) }}
className="mt-2 text-sm leading-relaxed text-gray-400"
/>
)}
</div>
@ -105,7 +135,15 @@ function PostCard({ post, onEngage, onTip }: {
{/* Embeds */}
{imageEmbed && (
<div className="mb-4 overflow-hidden rounded-xl">
<img src={imageEmbed.url} alt="" className="w-full object-cover" loading="lazy" />
<Image
src={imageEmbed.url}
alt=""
width={800}
height={400}
className="w-full object-cover"
loading="lazy"
unoptimized
/>
</div>
)}
{linkEmbed && (
@ -123,7 +161,10 @@ function PostCard({ post, onEngage, onTip }: {
{post.niches.length > 0 && (
<div className="mb-4 flex flex-wrap gap-1">
{post.niches.map((n) => (
<span key={n} className="rounded-full bg-gray-800/60 px-2 py-0.5 text-[10px] text-gray-500">
<span
key={n}
className="rounded-full bg-gray-800/60 px-2 py-0.5 text-[10px] text-gray-500"
>
#{n}
</span>
))}
@ -145,7 +186,9 @@ function PostCard({ post, onEngage, onTip }: {
onClick={() => handleAction('like')}
disabled={engaging === 'like'}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
liked ? 'bg-degen-500/20 text-degen-400' : 'text-gray-500 hover:bg-gray-800 hover:text-white'
liked
? 'bg-degen-500/20 text-degen-400'
: 'text-gray-500 hover:bg-gray-800 hover:text-white'
}`}
>
{liked ? '♥' : '♡'} {metrics.likes}
@ -155,7 +198,9 @@ function PostCard({ post, onEngage, onTip }: {
onClick={() => handleAction('repost')}
disabled={engaging === 'repost'}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
reposted ? 'bg-green-500/20 text-green-400' : 'text-gray-500 hover:bg-gray-800 hover:text-white'
reposted
? 'bg-green-500/20 text-green-400'
: 'text-gray-500 hover:bg-gray-800 hover:text-white'
}`}
>
{metrics.reposts}
@ -165,7 +210,9 @@ function PostCard({ post, onEngage, onTip }: {
onClick={() => handleAction(bookmarked ? 'unbookmark' : 'bookmark')}
disabled={engaging === 'bookmark' || engaging === 'unbookmark'}
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
bookmarked ? 'bg-yellow-500/20 text-yellow-400' : 'text-gray-500 hover:bg-gray-800 hover:text-white'
bookmarked
? 'bg-yellow-500/20 text-yellow-400'
: 'text-gray-500 hover:bg-gray-800 hover:text-white'
}`}
>
{bookmarked ? '★' : '☆'}
@ -200,13 +247,14 @@ export default function PostPage({ params }: Props) {
useEffect(() => {
if (!id) return;
const postId = id;
let cancelled = false;
async function load() {
setState('loading');
try {
// Use dedicated post endpoint — doesn't hammer the rate limit
// or fetch the entire home feed for one post.
const res = await fetch(`/api/feed/post/${encodeURIComponent(id)}`, {
const res = await fetch(`/api/feed/post/${encodeURIComponent(postId)}`, {
credentials: 'include',
});
const data = (await res.json()) as { data?: UnifiedPost; error?: string };
@ -222,21 +270,26 @@ export default function PostPage({ params }: Props) {
}
}
load();
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [id]);
const handleEngage = useCallback(async (action: EngagementAction, postId: string) => {
if (action === 'bookmark' || action === 'unbookmark') {
try {
if (action === 'bookmark') await addBookmark(postId);
else await removeBookmark(postId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Bookmark failed' };
const handleEngage = useCallback(
async (action: EngagementAction, postId: string) => {
if (action === 'bookmark' || action === 'unbookmark') {
try {
if (action === 'bookmark') await addBookmark(postId);
else await removeBookmark(postId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Bookmark failed' };
}
}
}
return engage(action, postId, post?.protocol ?? 'rss');
}, [engage, post]);
return engage(action, postId, post?.protocol ?? 'rss');
},
[engage, post],
);
return (
<div className="mx-auto max-w-2xl px-4 py-6">
@ -279,7 +332,13 @@ export default function PostPage({ params }: Props) {
{post.parentId && (
<div className="mt-4 rounded-xl border border-white/5 bg-gray-900/30 p-3">
<p className="text-[11px] text-gray-500">
This is a reply. <a href={`/post/${encodeURIComponent(post.parentId)}`} className="text-degen-500 hover:underline">View parent post</a>
This is a reply.{' '}
<a
href={`/post/${encodeURIComponent(post.parentId)}`}
className="text-degen-500 hover:underline"
>
View parent post
</a>
</p>
</div>
)}
@ -303,7 +362,9 @@ export default function PostPage({ params }: Props) {
recipientProtocol={tipPost.protocol}
recipientAuthorId={tipPost.author.id}
recipientDisplayName={tipPost.author.displayName}
publicationId={tipPost.protocol === 'lens' ? (tipPost.raw as { id?: string })?.id : undefined}
publicationId={
tipPost.protocol === 'lens' ? (tipPost.raw as { id?: string })?.id : undefined
}
/>
)}
</div>

View file

@ -1,6 +1,7 @@
'use client';
import type { StoredIdentity } from '@degenfeed/storage';
import Image from 'next/image';
import { QRCodeSVG } from 'qrcode.react';
import { useMemo } from 'react';
import { useAuth } from '../../components/AuthProvider';
@ -133,10 +134,13 @@ export default function ProfilePage() {
style={{ backgroundColor: `${id.color}22`, color: id.color }}
>
{id.avatarUrl ? (
<img
<Image
src={id.avatarUrl}
alt=""
width={40}
height={40}
className="h-10 w-10 rounded-full object-cover"
unoptimized
/>
) : (
PROTOCOL_ICONS[id.protocol] || '?'

View file

@ -0,0 +1,184 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
interface ProtocolHealth {
healthy: boolean;
latencyMs: number;
error?: string;
}
interface FeedHealthResponse {
health: Record<string, ProtocolHealth>;
checkedAt: number;
}
const PROTOCOL_LABELS: Record<string, { label: string; color: string; description: string }> = {
nostr: {
label: 'Nostr',
color: '#8e30eb',
description: 'Decentralized notes via WebSocket relays',
},
farcaster: {
label: 'Farcaster',
color: '#8a63d2',
description: 'Casts via Farcaster Hub',
},
lens: {
label: 'Lens',
color: '#abfe2c',
description: 'Lens Protocol GraphQL API',
},
bluesky: {
label: 'Bluesky',
color: '#1185fe',
description: 'AT Protocol public API',
},
mastodon: {
label: 'Mastodon / GotoSocial',
color: '#6364ff',
description: 'Fediverse public timeline',
},
threads: {
label: 'Threads',
color: '#888',
description: 'Meta Threads web scraper',
},
reddit: {
label: 'Reddit',
color: '#ff4500',
description: 'Subreddit RSS hot feeds',
},
rss: {
label: 'RSS / News',
color: '#ffa500',
description: '30+ RSS sources + Hacker News',
},
};
function statusLabel(healthy: boolean): { text: string; className: string } {
if (healthy) return { text: 'Operational', className: 'text-degen-400' };
return { text: 'Degraded', className: 'text-yellow-400' };
}
function timeAgo(ts: number): string {
const diff = Date.now() - ts;
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
return `${h}h ago`;
}
export function StatusClient({ initialData }: { initialData: FeedHealthResponse | null }) {
const [data, setData] = useState<FeedHealthResponse | null>(initialData);
const [loading, setLoading] = useState(false);
const [autoRefresh, setAutoRefresh] = useState(true);
const refresh = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/api/feed/health', {
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
if (res.ok) setData((await res.json()) as FeedHealthResponse);
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!autoRefresh) return;
const id = setInterval(refresh, 30000);
return () => clearInterval(id);
}, [autoRefresh, refresh]);
const protocols = Object.entries(PROTOCOL_LABELS);
const health = data?.health ?? {};
const checkedAt = data?.checkedAt ?? 0;
const healthy = protocols.filter(([id]) => health[id]?.healthy).length;
const degraded = protocols.length - healthy;
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 rounded-xl border border-white/10 bg-gray-950/50 p-4">
<div>
<div className="text-2xl font-bold text-white">
{healthy}/{protocols.length} operational
</div>
<div className="text-sm text-gray-500">
{degraded === 0
? 'All protocols responding normally'
: `${degraded} protocol${degraded === 1 ? '' : 's'} degraded`}
{checkedAt > 0 && <span className="ml-2 text-gray-600">· {timeAgo(checkedAt)}</span>}
</div>
</div>
<div className="flex items-center gap-3">
<label className="flex cursor-pointer items-center gap-2 text-xs text-gray-400">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="h-3 w-3 rounded border-white/20 bg-gray-900 text-degen-500 focus:ring-degen-500"
/>
Auto-refresh
</label>
<button
type="button"
onClick={refresh}
disabled={loading}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-50"
>
{loading ? 'Checking…' : 'Refresh now'}
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{protocols.map(([id, meta]) => {
const h = health[id];
const status = h
? statusLabel(h.healthy)
: { text: 'Unknown', className: 'text-gray-500' };
const latency = h?.latencyMs ?? null;
return (
<div
key={id}
className="rounded-xl border border-white/10 bg-gray-950/50 p-4"
style={{ borderLeftColor: meta.color, borderLeftWidth: 3 }}
>
<div className="mb-1 flex items-center justify-between">
<span className="text-sm font-semibold text-white">{meta.label}</span>
<span className={`text-xs font-medium ${status.className}`}>
{h ? '●' : '○'} {status.text}
</span>
</div>
<p className="mb-2 text-xs text-gray-500">{meta.description}</p>
{latency !== null && (
<div className="text-[10px] text-gray-600">
Latency: <span className="font-mono text-gray-400">{latency}ms</span>
</div>
)}
{h?.error && (
<div className="mt-1 truncate text-[10px] text-red-400" title={h.error}>
{h.error}
</div>
)}
</div>
);
})}
</div>
{data === null && (
<div className="mt-6 rounded-xl border border-yellow-500/20 bg-yellow-500/5 p-4 text-sm text-yellow-400">
Could not reach the health endpoint. Try refreshing once the worker warms up.
</div>
)}
</div>
);
}

View file

@ -0,0 +1,66 @@
/**
* /status Protocol health dashboard
*
* SSR-fetches /api/feed/health from the worker so the page works without JS
* and isn't blocked by CORS. Shows last-checked timestamp, per-protocol
* status (healthy/degraded/down), latency, and a manual refresh button.
*/
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import { StatusClient } from './StatusClient';
export const runtime = 'edge';
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: 'Status — DegenFeed',
description:
'Live protocol health for Nostr, Farcaster, Lens, Bluesky, Mastodon, Threads, Reddit, and RSS.',
};
interface ProtocolHealth {
healthy: boolean;
latencyMs: number;
error?: string;
}
interface FeedHealthResponse {
health: Record<string, ProtocolHealth>;
checkedAt: number;
}
async function getBaseUrl(): Promise<string> {
const h = await headers();
const host = h.get('host') || 'app.degenfeed.xyz';
const protocol = h.get('x-forwarded-proto') || 'https';
return `${protocol}://${host}`;
}
async function loadHealth(): Promise<FeedHealthResponse | null> {
try {
const res = await fetch(`${await getBaseUrl()}/api/feed/health`, {
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
if (!res.ok) return null;
return (await res.json()) as FeedHealthResponse;
} catch {
return null;
}
}
export default async function StatusPage() {
const data = await loadHealth();
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<header className="mb-6">
<h1 className="text-2xl font-bold text-white">Protocol Status</h1>
<p className="mt-1 text-sm text-gray-500">
Live health checks for every protocol DegenFeed aggregates from.
</p>
</header>
<StatusClient initialData={data} />
</div>
);
}

View file

@ -0,0 +1,594 @@
'use client';
import { addBookmark, getFollows, removeBookmark } from '@degenfeed/storage';
import type { EngagementAction, UnifiedPost } from '@degenfeed/types';
import { FeedList, NotificationBell } from '@degenfeed/ui';
import Image from 'next/image';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAuth } from '../../components/AuthProvider';
import { TipModal } from '../../components/TipModal';
import { TwitterComposer } from '../../components/TwitterComposer';
import { TwitterSidebar } from '../../components/TwitterSidebar';
import { useNotifications } from '../../hooks/useNotifications';
export const runtime = 'edge';
type Tab = 'for-you' | 'following';
interface FeedMeta {
total?: number;
limit?: number;
perProtocol?: Record<string, number>;
errors?: string[];
notices?: string[];
trending?: { topic: string; count: number; score: number }[];
fetchedAt?: number;
}
interface ProtocolSummary {
id: string;
label: string;
color: string;
count: number;
}
const PROTOCOL_META: Record<string, { label: string; color: string; emoji: string }> = {
nostr: { label: 'Nostr', color: '#8e30eb', emoji: '⚡' },
farcaster: { label: 'Farcaster', color: '#8a63d2', emoji: '🟣' },
lens: { label: 'Lens', color: '#abfe2c', emoji: '🌿' },
bluesky: { label: 'Bluesky', color: '#1185fe', emoji: '🦋' },
mastodon: { label: 'Mastodon', color: '#6364ff', emoji: '🐘' },
threads: { label: 'Threads', color: '#888888', emoji: '🧵' },
reddit: { label: 'Reddit', color: '#ff4500', emoji: '🔴' },
rss: { label: 'News', color: '#ffa500', emoji: '📰' },
};
export default function TwitterPage() {
const { signedInProtocols, engage, openSignIn, publish, ready, signOut, nostrPublicId } =
useAuth();
const [activeTab, setActiveTab] = useState<Tab>('for-you');
const [posts, setPosts] = useState<UnifiedPost[]>([]);
const [state, setState] = useState<'loading' | 'loaded' | 'empty' | 'error'>('loading');
const [error, setError] = useState<string>();
const [tipPost, setTipPost] = useState<UnifiedPost | null>(null);
const [feedMeta, setFeedMeta] = useState<FeedMeta>({});
const [hideLowQuality, setHideLowQuality] = useState(false);
const [hideThreshold, setHideThreshold] = useState(1.0);
const [allowNonCrypto, setAllowNonCrypto] = useState(false);
const [protocolFilter, setProtocolFilter] = useState<string | null>(null);
const hasSignedIn = ready && Object.values(signedInProtocols).some(Boolean);
const { notifications, unreadCount, fetchNotifications, markRead } =
useNotifications(hasSignedIn);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const res = await fetch('/api/preferences', { credentials: 'include' });
if (!res.ok || cancelled) return;
const data = (await res.json()) as {
hideLowQuality?: boolean;
hideLowQualityThreshold?: number;
allowNonCrypto?: boolean;
};
if (typeof data.hideLowQuality === 'boolean') setHideLowQuality(data.hideLowQuality);
if (typeof data.hideLowQualityThreshold === 'number') {
setHideThreshold(data.hideLowQualityThreshold);
}
if (typeof data.allowNonCrypto === 'boolean') setAllowNonCrypto(data.allowNonCrypto);
} catch {
/* ignore — user has no session yet */
}
}
load();
return () => {
cancelled = true;
};
}, []);
const persistPrefs = useCallback((patch: Record<string, unknown>) => {
void fetch('/api/preferences', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(patch),
}).catch(() => {});
}, []);
const setHideThresholdAndPersist = useCallback(
(next: number) => {
setHideThreshold(next);
persistPrefs({ hideLowQualityThreshold: next });
},
[persistPrefs],
);
const setAllowNonCryptoAndPersist = useCallback(
(next: boolean) => {
setAllowNonCrypto(next);
persistPrefs({ allowNonCrypto: next });
},
[persistPrefs],
);
const toggleHideLowQuality = useCallback(() => {
setHideLowQuality((prev) => {
const next = !prev;
try {
localStorage.setItem('degenfeed_hide_low_quality', String(next));
} catch {
/* ignore */
}
persistPrefs({ hideLowQuality: next });
return next;
});
}, [persistPrefs]);
const [newAvailable, setNewAvailable] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchFeed = useCallback(
async (checkOnly = false) => {
if (!checkOnly) setState('loading');
try {
const url = new URL('/api/feed/home', window.location.origin);
url.searchParams.set('limit', '80');
const protocolsToFetch = protocolFilter
? [protocolFilter]
: ['nostr', 'farcaster', 'lens', 'bluesky', 'mastodon', 'threads', 'reddit', 'rss'];
url.searchParams.set('protocols', protocolsToFetch.join(','));
if (activeTab === 'following') {
url.searchParams.set('filter', 'following');
}
if (hideLowQuality) {
url.searchParams.set('hideLowQuality', 'true');
url.searchParams.set('hideLowQualityThreshold', String(hideThreshold));
}
if (allowNonCrypto) {
url.searchParams.set('allowNonCrypto', 'true');
}
const res = await fetch(url.toString(), {
credentials: 'include',
cache: 'no-store',
headers: { 'cache-control': 'no-store' },
});
if (!res.ok) throw new Error(`Feed returned ${res.status}`);
const data = (await res.json()) as { data?: UnifiedPost[]; meta?: FeedMeta };
let feed = data.data ?? [];
if (activeTab === 'following') {
try {
const follows = await getFollows('self');
const ids = new Set(follows.map((f) => f.targetId));
feed = feed.filter((p) => ids.has(p.author.id));
} catch {
/* ignore IndexedDB errors in private mode */
}
}
if (checkOnly && feed.length > 0 && feed[0]?.id !== posts[0]?.id) {
setNewAvailable(true);
return;
}
setPosts(feed);
setFeedMeta(data.meta || {});
setState(feed.length ? 'loaded' : 'empty');
} catch (err) {
if (!checkOnly) {
setError(err instanceof Error ? err.message : 'Failed');
setState('error');
}
}
},
[activeTab, posts, hideLowQuality, hideThreshold, allowNonCrypto, protocolFilter],
);
useEffect(() => {
fetchFeed();
}, [fetchFeed]);
useEffect(() => {
intervalRef.current = setInterval(() => fetchFeed(true), 60000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchFeed]);
const refreshNow = useCallback(() => {
setNewAvailable(false);
fetchFeed(false);
}, [fetchFeed]);
const handleEngage = async (
action: EngagementAction,
postId: string,
content?: string,
): Promise<{ ok: boolean; error?: string }> => {
const post = posts.find((p) => p.id === postId);
if (!post) return { ok: false, error: 'Post not found' };
if ((action === 'bookmark' || action === 'unbookmark') && post.protocol !== 'mastodon') {
try {
if (action === 'bookmark') await addBookmark(postId);
else await removeBookmark(postId);
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Bookmark failed' };
}
}
return engage(action, postId, post.protocol, { content });
};
const handleTip = useCallback(
(post: UnifiedPost) => {
if (!hasSignedIn) {
openSignIn();
return;
}
setTipPost(post);
},
[hasSignedIn, openSignIn],
);
const handlePostClick = useCallback((id: string) => {
window.location.href = `/post/${encodeURIComponent(id)}`;
}, []);
const protocolSummaries: ProtocolSummary[] = useMemo(() => {
const perProtocol = feedMeta.perProtocol ?? {};
return Object.entries(PROTOCOL_META)
.map(([id, meta]) => ({
id,
label: meta.label,
color: meta.color,
count: perProtocol[id] ?? 0,
}))
.sort((a, b) => b.count - a.count);
}, [feedMeta.perProtocol]);
const handleProtocolFilter = useCallback((id: string) => {
setProtocolFilter((current) => (current === id ? null : id));
}, []);
return (
<div className="mx-auto grid max-w-[1280px] grid-cols-1 gap-4 px-3 py-3 sm:px-4 lg:grid-cols-12">
{/* Left rail — desktop only quick nav */}
<aside className="hidden xl:col-span-3 xl:block">
<div className="sticky top-20 space-y-3">
<div className="rounded-2xl border border-white/10 bg-gray-950/60 p-4 backdrop-blur-xl">
<div className="mb-3 flex items-center gap-2">
<Image
src="/icon.svg"
alt="DegenFeed"
width={36}
height={36}
className="h-9 w-9 rounded-xl shadow-[0_0_18px_rgba(0,255,65,0.25)]"
/>
<div>
<p className="text-sm font-semibold text-white">DegenFeed</p>
<p className="text-[10px] uppercase tracking-widest text-gray-500">
Open social graph
</p>
</div>
</div>
<p className="text-xs text-gray-400">
One window. Every protocol. Zero accounts to browse.
</p>
<div className="mt-3 flex flex-wrap gap-1">
{Object.entries(PROTOCOL_META).map(([id, m]) => (
<span
key={id}
className="inline-flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[10px] font-medium"
style={{ backgroundColor: `${m.color}22`, color: m.color }}
title={`${m.label} live`}
>
{m.emoji} {m.label}
</span>
))}
</div>
</div>
{!hasSignedIn && (
<div className="rounded-2xl border border-degen-500/30 bg-degen-500/5 p-4 backdrop-blur-xl">
<p className="mb-1 text-sm font-semibold text-white">Sign in to post & engage</p>
<p className="mb-3 text-xs text-gray-400">
Browse without an account. Sign in with one click to like, reply, and cross-post.
</p>
<button type="button" onClick={openSignIn} className="btn-primary w-full text-sm">
Connect Wallet
</button>
<p className="mt-2 text-center text-[10px] text-gray-600">
ETH · SOL · or your existing social handle
</p>
</div>
)}
</div>
</aside>
{/* Main timeline */}
<main className="lg:col-span-8 xl:col-span-6">
<div className="sticky top-16 z-30 border-b border-white/10 bg-black/85 backdrop-blur-xl">
<div className="flex items-center justify-between gap-2 px-4 py-3">
<div className="flex items-center gap-3">
<Image
src="/icon.svg"
alt="DegenFeed"
width={32}
height={32}
className="h-8 w-8 rounded-lg shadow-[0_0_14px_rgba(0,255,65,0.3)]"
/>
<div>
<h1 className="text-lg font-bold text-white leading-none">DegenFeed</h1>
<p className="text-[10px] text-gray-500">
{activeTab === 'for-you' ? 'Your unified timeline' : 'Posts you follow'}
</p>
</div>
</div>
{hasSignedIn && (
<div className="flex items-center gap-2">
<NotificationBell
notifications={notifications}
unreadCount={unreadCount}
onFetch={fetchNotifications}
onMarkRead={markRead}
/>
<button
type="button"
onClick={signOut}
className="rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-gray-300 transition-colors hover:bg-white/10 hover:text-white lg:hidden"
>
Sign out
</button>
</div>
)}
</div>
{/* Tabs */}
<div className="flex">
<button
type="button"
onClick={() => setActiveTab('for-you')}
className={`relative flex-1 py-3 text-sm font-medium transition-colors ${
activeTab === 'for-you' ? 'text-white' : 'text-gray-500 hover:text-gray-300'
}`}
>
For You
{activeTab === 'for-you' && (
<span className="absolute bottom-0 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-degen-500" />
)}
</button>
<button
type="button"
onClick={() => setActiveTab('following')}
className={`relative flex-1 py-3 text-sm font-medium transition-colors ${
activeTab === 'following' ? 'text-white' : 'text-gray-500 hover:text-gray-300'
}`}
>
Following
{activeTab === 'following' && (
<span className="absolute bottom-0 left-1/2 h-1 w-12 -translate-x-1/2 rounded-full bg-degen-500" />
)}
</button>
</div>
{/* Protocol pills + filter */}
<div className="flex flex-wrap items-center gap-1.5 border-t border-white/5 px-4 py-2">
<button
type="button"
onClick={() => setProtocolFilter(null)}
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
protocolFilter === null
? 'bg-degen-500/15 text-degen-400'
: 'text-gray-500 hover:text-gray-300'
}`}
>
All
</button>
{protocolSummaries.map((p) => (
<button
key={p.id}
type="button"
onClick={() => handleProtocolFilter(p.id)}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
protocolFilter === p.id ? 'text-white' : 'text-gray-500 hover:text-gray-300'
}`}
style={
protocolFilter === p.id
? { backgroundColor: `${p.color}22`, color: p.color }
: undefined
}
>
<span>{p.label}</span>
<span
className={`text-[10px] ${protocolFilter === p.id ? 'opacity-90' : 'text-gray-600'}`}
>
{p.count}
</span>
</button>
))}
</div>
{/* Quality controls */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-t border-white/5 px-4 py-2 text-[11px] text-gray-400">
<button
type="button"
onClick={toggleHideLowQuality}
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-medium transition-colors ${
hideLowQuality
? 'bg-degen-500/15 text-degen-400'
: 'text-gray-500 hover:text-gray-300'
}`}
title="Hide posts with low ranking scores"
aria-pressed={hideLowQuality}
>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
aria-hidden="true"
>
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{hideLowQuality ? 'Hiding low-quality' : 'Show all'}
</button>
{hideLowQuality && (
<label className="flex items-center gap-1.5">
<span>Threshold</span>
<input
type="range"
min="0"
max="5"
step="0.5"
value={hideThreshold}
onChange={(e) => setHideThresholdAndPersist(Number(e.target.value))}
className="h-1 w-16 cursor-pointer appearance-none rounded-full bg-gray-700 accent-degen-500"
aria-label="Hide threshold"
/>
<span className="font-mono text-[10px] text-gray-500">
{hideThreshold.toFixed(1)}
</span>
</label>
)}
<label className="flex items-center gap-1">
<input
type="checkbox"
checked={allowNonCrypto}
onChange={(e) => setAllowNonCryptoAndPersist(e.target.checked)}
className="h-3 w-3 rounded border-white/20 bg-gray-900 text-degen-500 focus:ring-degen-500"
/>
<span>Non-crypto</span>
</label>
<span className="ml-auto hidden text-[10px] text-gray-600 sm:inline">
Hover on any post for ranking breakdown
</span>
</div>
</div>
{/* Composer / sign-in CTA */}
{hasSignedIn ? (
<TwitterComposer
onPost={publish}
signedInProtocols={signedInProtocols}
nostrPublicId={nostrPublicId}
onRequestSignIn={openSignIn}
/>
) : (
<div className="border-b border-white/10 px-4 py-4">
<div className="flex items-start gap-3">
<Image
src="/icon.svg"
alt="DegenFeed"
width={40}
height={40}
className="h-10 w-10 rounded-full"
/>
<div className="flex-1">
<button
type="button"
onClick={() => openSignIn()}
className="w-full rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-left text-base text-gray-400 transition-colors hover:border-degen-500/30 hover:bg-white/5"
>
What is happening across web3?
</button>
<div className="mt-3 flex items-center justify-between">
<p className="text-xs text-gray-500">
Sign in to like, reply, and cross-post everywhere.
</p>
<button type="button" onClick={openSignIn} className="btn-primary text-sm">
Connect Wallet
</button>
</div>
</div>
</div>
</div>
)}
{/* New posts pill */}
{newAvailable && (
<button
type="button"
onClick={refreshNow}
className="sticky top-[152px] z-20 mx-4 my-2 flex w-[calc(100%-2rem)] items-center justify-center gap-2 rounded-full border border-degen-500/40 bg-degen-500/90 py-2 text-sm font-semibold text-black shadow-[0_0_24px_rgba(0,255,65,0.35)] backdrop-blur-xl transition-transform hover:bg-degen-400"
>
<span className="h-2 w-2 animate-pulse rounded-full bg-black" />
New posts available click to refresh
</button>
)}
{/* Notices */}
{feedMeta.notices && feedMeta.notices.length > 0 && (
<div className="mx-4 mt-2 rounded-lg border border-yellow-500/20 bg-yellow-500/10 p-2.5 text-xs text-yellow-400">
{feedMeta.notices.map((n) => (
<div key={n}>{n}</div>
))}
</div>
)}
{/* Feed */}
<FeedList
state={state}
posts={posts}
error={error}
onPostClick={handlePostClick}
onEngage={handleEngage}
onTip={handleTip}
onRetry={fetchFeed}
onSignIn={openSignIn}
/>
{activeTab === 'following' && state === 'empty' && (
<div className="border-b border-white/5 px-4 py-10 text-center">
<p className="text-gray-400">You are not following anyone yet.</p>
<p className="mt-1 text-xs text-gray-600">
Tap the Follow button on any post to build your timeline.
</p>
</div>
)}
{/* Bottom CTA — repeat sign-in pitch */}
{!hasSignedIn && state === 'loaded' && (
<div className="m-4 rounded-2xl border border-degen-500/30 bg-gradient-to-br from-degen-500/10 to-transparent p-5 text-center">
<p className="mb-1 text-sm font-semibold text-white">Liking what you see?</p>
<p className="mb-4 text-xs text-gray-400">
Sign in once to post, like, and reply across all 8 networks.
</p>
<button type="button" onClick={openSignIn} className="btn-primary text-sm">
Connect Wallet to Post
</button>
</div>
)}
</main>
{/* Right sidebar */}
<aside className="hidden lg:col-span-4 lg:block xl:col-span-3">
<div className="sticky top-20">
<TwitterSidebar
trending={feedMeta.trending ?? []}
protocolSummaries={protocolSummaries}
activeProtocolFilter={protocolFilter}
onProtocolClick={handleProtocolFilter}
/>
</div>
</aside>
{tipPost && (
<TipModal
open={!!tipPost}
onClose={() => setTipPost(null)}
recipientProtocol={tipPost.protocol}
recipientAuthorId={tipPost.author.id}
recipientDisplayName={tipPost.author.displayName}
publicationId={
tipPost.protocol === 'lens' ? (tipPost.raw as { id?: string })?.id : undefined
}
/>
)}
</div>
);
}

View file

@ -1,19 +1,20 @@
'use client';
import { buildIdentityRecord } from '@degenfeed/auth';
import type { StoredIdentity } from '@degenfeed/storage';
import {
clearAllSessions,
deleteIdentity,
getAllIdentities,
getPrimaryIdentity,
type StoredIdentity,
saveIdentity,
setPrimaryIdentity as setPrimaryIdentityInDb,
} from '@degenfeed/storage';
import type { EngagementAction } from '@degenfeed/types';
import { ComposeModal, SignInModal } from '@degenfeed/ui';
import {
type ReactNode,
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
@ -23,6 +24,8 @@ import {
import { useWeb3 } from '../components/Web3Provider';
import { useSolanaSignIn } from '../hooks/useSolanaSignIn';
import { useWalletSignIn } from '../hooks/useWalletSignIn';
import { publishNoteToNostr } from '../lib/nostr';
import { useToast } from './Toast';
export interface AuthContextValue {
signedInProtocols: Record<string, boolean>;
@ -32,8 +35,10 @@ export interface AuthContextValue {
linkedIdentities: StoredIdentity[];
isWalletOnly: boolean;
linkGtsUrl?: string | null;
nostrPublicId?: string;
openSignIn: () => void;
openCompose: () => void;
signOut: () => Promise<void>;
publish: (
text: string,
targets: string[],
@ -68,6 +73,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [tokens, setTokens] = useState<Record<string, string>>({});
const [walletAddress, setWalletAddress] = useState<string | undefined>(undefined);
const [linkGtsUrl, setLinkGtsUrl] = useState<string | null>(null);
const [nostrPublicId, setNostrPublicId] = useState<string | undefined>(undefined);
const [ready, setReady] = useState(false);
const [showSignIn, setShowSignIn] = useState(false);
const [showCompose, setShowCompose] = useState(false);
@ -75,6 +81,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const { account: wagmiAddress, isConnected } = useWeb3();
const { signInWithEthereum, signingIn: walletSigningIn } = useWalletSignIn();
const { signInWithSolana, signingIn: solanaSigningIn } = useSolanaSignIn();
const { showToast } = useToast();
const refresh = useCallback(async () => {
const all = await getAllIdentities();
@ -95,6 +102,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const wallet = all.find((i) => i.protocol === 'ethereum' || i.protocol === 'solana');
setWalletAddress(wallet?.publicId ?? (isConnected ? (wagmiAddress ?? undefined) : undefined));
const nostrId = all.find((i) => i.protocol === 'nostr');
setNostrPublicId(nostrId?.publicId);
}, [isConnected, wagmiAddress]);
useEffect(() => {
@ -173,9 +183,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
// Backwards compat: if credentials is a string, treat as token
const creds = typeof credentials === 'string'
? { token: credentials }
: credentials ?? {};
const creds =
typeof credentials === 'string' ? { token: credentials } : (credentials ?? {});
const token = creds.token as string | undefined;
switch (protocol) {
@ -191,19 +200,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
case 'nostr': {
const { signInWithNostrNsec } = await import('@degenfeed/auth');
if (creds.method === 'extension') {
const win = window as unknown as { nostr?: { getPublicKey: () => Promise<string>; signEvent: (e: { kind: number; content: string; tags: string[][]; created_at: number }) => Promise<{ sig: string }> } };
const win = window as unknown as {
nostr?: {
getPublicKey: () => Promise<string>;
signEvent: (e: {
kind: number;
content: string;
tags: string[][];
created_at: number;
}) => Promise<{ sig: string }>;
};
};
if (!win.nostr) throw new Error('NIP-07 extension not detected');
const pubkey = await win.nostr.getPublicKey();
await saveIdentity({
id: `nostr:${pubkey}`,
const record = await buildIdentityRecord({
protocol: 'nostr',
publicId: pubkey,
handle: pubkey.slice(0, 12),
displayName: pubkey.slice(0, 12),
secret: '',
secret: null,
passphrase: 'auto-unlock',
isPrimary: false,
});
await saveIdentity(record);
await refresh();
setShowSignIn(false);
return;
@ -264,10 +283,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setShowSignIn(false);
} catch (err) {
const message = err instanceof Error ? err.message : 'Sign-in failed';
alert(`Sign-in failed: ${message}`);
showToast('error', `Sign-in failed: ${message}`);
}
},
[signInWithEthereum, refresh],
[signInWithEthereum, refresh, signInWithSolana, showToast],
);
const handlePublish = useCallback(
@ -341,6 +360,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[identities],
);
const handleSignOut = useCallback(async () => {
try {
await fetch('/api/auth/signout', { method: 'POST', credentials: 'include' });
} catch {
/* ignore network errors — still clear local state */
}
try {
await clearAllSessions();
for (const identity of identities) {
await deleteIdentity(identity.id);
}
} catch {
/* ignore */
}
setIdentities([]);
setPrimaryIdentity(undefined);
setSignedInProtocols({});
setTokens({});
setWalletAddress(undefined);
setLinkGtsUrl(null);
showToast('success', 'Signed out');
}, [identities, showToast]);
const handleSetPrimary = useCallback(
async (id: string) => {
await setPrimaryIdentityInDb(id);
@ -382,6 +424,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
linkGtsUrl,
openSignIn: () => setShowSignIn(true),
openCompose: () => setShowCompose(true),
signOut: handleSignOut,
nostrPublicId,
publish: handlePublish,
engage: handleEngage,
ready,
@ -404,6 +448,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
onClose={() => setShowCompose(false)}
signedInProtocols={signedInProtocols}
onPublish={handlePublish}
onRequestSignIn={() => {
setShowCompose(false);
setShowSignIn(true);
}}
primaryIdentity={
primaryIdentity
? {
displayName: primaryIdentity.displayName,
handle: primaryIdentity.handle,
avatarUrl: primaryIdentity.avatarUrl,
}
: undefined
}
nostrBridgePublicId={nostrPublicId}
onNostrBridge={async (text) => {
if (!nostrPublicId) return;
await publishNoteToNostr({ text, publicId: nostrPublicId });
}}
/>
</AuthContext.Provider>
);

View file

@ -0,0 +1,58 @@
'use client';
import { usePathname } from 'next/navigation';
import type { ReactNode } from 'react';
interface NavItem {
href: string;
label: string;
icon: ReactNode;
}
function Icon({ children }: { children: React.ReactNode }) {
return <span className="text-lg leading-none">{children}</span>;
}
const NAV: NavItem[] = [
{ href: '/home', label: 'Feed', icon: <Icon></Icon> },
{ href: '/twitter', label: 'Twitter', icon: <Icon></Icon> },
{ href: '/news', label: 'News', icon: <Icon></Icon> },
{ href: '/newsletter', label: 'Daily', icon: <Icon></Icon> },
{ href: '/profile', label: 'Profile', icon: <Icon></Icon> },
];
export function BottomNav() {
const pathname = usePathname() ?? '/';
return (
<nav
aria-label="Primary mobile"
className="fixed inset-x-0 bottom-0 z-40 border-t border-white/10 bg-gray-950/95 backdrop-blur-xl pb-[env(safe-area-inset-bottom)] lg:hidden"
>
<div className="grid grid-cols-5">
{NAV.map((item) => {
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<a
key={item.href}
href={item.href}
className={`flex flex-col items-center justify-center gap-1 py-2 text-[10px] font-medium transition-colors ${
active ? 'text-degen-400' : 'text-gray-500 hover:text-white'
}`}
aria-current={active ? 'page' : undefined}
>
<span
className={`flex h-7 w-12 items-center justify-center rounded-full ${
active ? 'bg-degen-500/15' : ''
}`}
>
{item.icon}
</span>
<span>{item.label}</span>
</a>
);
})}
</div>
</nav>
);
}

View file

@ -1,6 +1,6 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
interface IdentityResult {
id: string;
@ -12,8 +12,13 @@ interface IdentityResult {
function protocolColor(p: string): string {
const c: Record<string, string> = {
nostr: '#8e30eb', farcaster: '#8a63d2', lens: '#abfe2c',
bluesky: '#1185fe', threads: '#888', mastodon: '#6364ff', rss: '#ffa500',
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
threads: '#888',
mastodon: '#6364ff',
rss: '#ffa500',
};
return c[p] ?? '#666';
}
@ -24,14 +29,19 @@ export function IdentityLinker({ onSignIn }: { onSignIn?: () => void }) {
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const doSearch = useCallback(async (q: string) => {
if (q.length < 2) { setResults([]); return; }
if (q.length < 2) {
setResults([]);
return;
}
setLoading(true);
try {
const res = await fetch(`/api/search/identity?q=${encodeURIComponent(q)}&limit=5`, { credentials: 'include' });
const data = await res.json() as { data?: IdentityResult[] };
const res = await fetch(`/api/search/identity?q=${encodeURIComponent(q)}&limit=5`, {
credentials: 'include',
});
const data = (await res.json()) as { data?: IdentityResult[] };
setResults(data.data ?? []);
} catch {
setResults([]);
@ -39,32 +49,44 @@ export function IdentityLinker({ onSignIn }: { onSignIn?: () => void }) {
setLoading(false);
}, []);
const handleChange = useCallback((val: string) => {
setQuery(val);
setOpen(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(val), 300);
}, [doSearch]);
const handleChange = useCallback(
(val: string) => {
setQuery(val);
setOpen(true);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(val), 300);
},
[doSearch],
);
const handleSelect = useCallback((_result: IdentityResult) => {
setQuery('');
setResults([]);
setOpen(false);
onSignIn?.();
}, [onSignIn]);
const handleSelect = useCallback(
(_result: IdentityResult) => {
setQuery('');
setResults([]);
setOpen(false);
onSignIn?.();
},
[onSignIn],
);
return (
<div className="relative">
<label className="mb-2 block text-xs font-semibold uppercase tracking-widest text-gray-500">
<label
htmlFor="identity-link-input"
className="mb-2 block text-xs font-semibold uppercase tracking-widest text-gray-500"
>
Link Another Identity
</label>
<div className="relative">
<input
id="identity-link-input"
ref={inputRef}
type="text"
value={query}
onChange={(e) => handleChange(e.target.value)}
onFocus={() => { if (results.length > 0) setOpen(true); }}
onFocus={() => {
if (results.length > 0) setOpen(true);
}}
onBlur={() => setTimeout(() => setOpen(false), 200)}
placeholder="Search by handle, address, or name..."
className="input pr-10"
@ -85,17 +107,25 @@ export function IdentityLinker({ onSignIn }: { onSignIn?: () => void }) {
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
style={{ backgroundColor: `${protocolColor(r.protocol)}22`, color: protocolColor(r.protocol) }}
style={{
backgroundColor: `${protocolColor(r.protocol)}22`,
color: protocolColor(r.protocol),
}}
>
{r.displayName?.[0]?.toUpperCase() || '?'}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-white truncate">{r.displayName || r.handle || r.id}</div>
<div className="text-sm font-medium text-white truncate">
{r.displayName || r.handle || r.id}
</div>
<div className="text-xs text-gray-500">{r.handle || r.protocol}</div>
</div>
<span
className="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium"
style={{ backgroundColor: `${protocolColor(r.protocol)}22`, color: protocolColor(r.protocol) }}
style={{
backgroundColor: `${protocolColor(r.protocol)}22`,
color: protocolColor(r.protocol),
}}
>
{r.protocol}
</span>

View file

@ -1,5 +1,6 @@
'use client';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useRef, useState } from 'react';
@ -17,8 +18,13 @@ interface SearchResult {
function protocolColor(p: string): string {
const c: Record<string, string> = {
nostr: '#8e30eb', farcaster: '#8a63d2', lens: '#abfe2c',
bluesky: '#1185fe', threads: '#888', mastodon: '#6364ff', rss: '#ffa500',
nostr: '#8e30eb',
farcaster: '#8a63d2',
lens: '#abfe2c',
bluesky: '#1185fe',
threads: '#888',
mastodon: '#6364ff',
rss: '#ffa500',
};
return c[p] ?? '#666';
}
@ -50,12 +56,18 @@ export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () =>
}, [open]);
const doSearch = useCallback(async (q: string) => {
if (q.length < 2) { setResults([]); setSearched(false); return; }
if (q.length < 2) {
setResults([]);
setSearched(false);
return;
}
setLoading(true);
setSearched(true);
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&limit=20`, { credentials: 'include' });
const data = await res.json() as { data?: SearchResult[]; meta?: { total: number } };
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&limit=20`, {
credentials: 'include',
});
const data = (await res.json()) as { data?: SearchResult[]; meta?: { total: number } };
setResults(data.data ?? []);
} catch {
setResults([]);
@ -63,16 +75,22 @@ export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () =>
setLoading(false);
}, []);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const handleChange = useCallback((val: string) => {
setQuery(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(val), 300);
}, [doSearch]);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleChange = useCallback(
(val: string) => {
setQuery(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(val), 300);
},
[doSearch],
);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') onClose();
}, [onClose]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') onClose();
},
[onClose],
);
if (!open) return null;
@ -113,19 +131,27 @@ export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () =>
<button
key={r.id}
type="button"
onClick={() => { router.push(`/post/${encodeURIComponent(r.id)}`); onClose(); }}
onClick={() => {
router.push(`/post/${encodeURIComponent(r.id)}`);
onClose();
}}
className="card-hover flex w-full items-start gap-3 p-3 text-left"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold"
style={{ backgroundColor: `${protocolColor(r.protocol)}22`, color: protocolColor(r.protocol) }}
style={{
backgroundColor: `${protocolColor(r.protocol)}22`,
color: protocolColor(r.protocol),
}}
>
{r.author.displayName[0]?.toUpperCase() || '?'}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-white truncate">{r.author.displayName}</span>
<span className="text-gray-500" style={{ color: protocolColor(r.protocol) }}>{r.protocol}</span>
<span className="text-gray-500" style={{ color: protocolColor(r.protocol) }}>
{r.protocol}
</span>
<span className="ml-auto text-gray-600">{formatTime(r.createdAt)}</span>
</div>
<p className="mt-0.5 text-sm text-gray-400 line-clamp-2">{r.text}</p>
@ -136,7 +162,15 @@ export function SearchOverlay({ open, onClose }: { open: boolean; onClose: () =>
</div>
</div>
{r.image && (
<img src={r.image} alt="" className="h-12 w-12 shrink-0 rounded-lg object-cover" loading="lazy" />
<Image
src={r.image}
alt=""
width={48}
height={48}
className="h-12 w-12 shrink-0 rounded-lg object-cover"
loading="lazy"
unoptimized
/>
)}
</button>
))}

View file

@ -1,8 +1,10 @@
'use client';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
import { useState } from 'react';
import { useAuth } from './AuthProvider';
import { BottomNav } from './BottomNav';
import { SearchOverlay } from './SearchOverlay';
interface NavItem {
@ -12,6 +14,7 @@ interface NavItem {
const NAV: NavItem[] = [
{ href: '/home', label: 'Feed' },
{ href: '/twitter', label: 'Twitter' },
{ href: '/news', label: 'News' },
{ href: '/newsletter', label: 'Daily' },
{ href: '/premium', label: 'Premium' },
@ -21,9 +24,13 @@ const NAV: NavItem[] = [
function Logo() {
return (
<a href="/" className="flex items-center gap-2 group">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-degen-500 to-emerald-600 text-black text-lg font-bold shadow-[0_0_16px_rgba(34,197,94,0.35)] transition-shadow group-hover:shadow-[0_0_24px_rgba(34,197,94,0.5)]">
</span>
<Image
src="/icon.svg"
alt="DegenFeed"
width={32}
height={32}
className="h-8 w-8 rounded-lg transition-shadow group-hover:shadow-[0_0_16px_rgba(34,197,94,0.4)]"
/>
<span className="text-lg font-bold tracking-tight text-white">DegenFeed</span>
</a>
);
@ -172,11 +179,13 @@ export function Shell({ children }: { children: React.ReactNode }) {
)}
</header>
<main id="main" className="relative z-10 flex-1 pt-16">
<main id="main" className="relative z-10 flex-1 pt-16 pb-20 lg:pb-0">
{children}
</main>
<footer className="relative z-10 border-t border-white/10 bg-gray-950/50 py-8">
<BottomNav />
<footer className="relative z-10 hidden border-t border-white/10 bg-gray-950/50 py-8 lg:block">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-4 sm:flex-row">
<div className="flex items-center gap-2">
<span className="text-degen-500"></span>
@ -186,9 +195,15 @@ export function Shell({ children }: { children: React.ReactNode }) {
<a href="/home" className="transition-colors hover:text-white">
Feed
</a>
<a href="/twitter" className="transition-colors hover:text-white">
Twitter
</a>
<a href="/news" className="transition-colors hover:text-white">
News
</a>
<a href="/status" className="transition-colors hover:text-white">
Status
</a>
<a href="/premium" className="transition-colors hover:text-white">
Premium
</a>

View file

@ -1,6 +1,6 @@
'use client';
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from 'react';
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react';
interface SolanaWalletProvider {
isPhantom?: boolean;

View file

@ -0,0 +1,115 @@
'use client';
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react';
export type ToastKind = 'info' | 'success' | 'error' | 'warning';
export interface Toast {
id: string;
kind: ToastKind;
message: string;
/** ms to live; default 4000 */
duration?: number;
}
interface ToastContextValue {
toasts: Toast[];
showToast: (kind: ToastKind, message: string, duration?: number) => void;
dismiss: (id: string) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within a ToastProvider');
return ctx;
}
let nextId = 0;
function makeId() {
nextId += 1;
return `${Date.now().toString(36)}-${nextId}`;
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const dismiss = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const showToast = useCallback((kind: ToastKind, message: string, duration = 4000) => {
const id = makeId();
setToasts((prev) => [...prev, { id, kind, message, duration }]);
}, []);
return (
<ToastContext.Provider value={{ toasts, showToast, dismiss }}>
{children}
<ToastViewport />
</ToastContext.Provider>
);
}
function kindClasses(kind: ToastKind): string {
switch (kind) {
case 'success':
return 'border-degen-500/40 bg-degen-500/10 text-degen-300';
case 'error':
return 'border-red-500/40 bg-red-500/10 text-red-300';
case 'warning':
return 'border-yellow-500/40 bg-yellow-500/10 text-yellow-300';
default:
return 'border-white/10 bg-gray-900/90 text-gray-200';
}
}
function ToastViewport() {
const { toasts, dismiss } = useToast();
if (toasts.length === 0) return null;
return (
<section
className="pointer-events-none fixed inset-x-0 bottom-4 z-[100] flex flex-col items-center gap-2 px-4 sm:bottom-6 sm:right-6 sm:left-auto sm:items-end"
aria-label="Notifications"
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onDismiss={dismiss} />
))}
</section>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
useEffect(() => {
const ms = toast.duration ?? 4000;
const timer = setTimeout(() => onDismiss(toast.id), ms);
return () => clearTimeout(timer);
}, [toast.id, toast.duration, onDismiss]);
return (
<div
role={toast.kind === 'error' ? 'alert' : 'status'}
className={`pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl border px-4 py-3 shadow-2xl backdrop-blur-xl transition-all animate-in fade-in slide-in-from-bottom-2 ${kindClasses(
toast.kind,
)}`}
>
<span className="text-sm font-medium leading-snug break-words flex-1">{toast.message}</span>
<button
type="button"
onClick={() => onDismiss(toast.id)}
className="shrink-0 rounded-md p-1 text-current/60 hover:bg-white/5 hover:text-current"
aria-label="Dismiss"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M12 4L4 12M4 4l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
);
}

View file

@ -0,0 +1,254 @@
'use client';
import { getPrimaryIdentity } from '@degenfeed/storage';
import { ComposePreview, getProtocolColor, getProtocolIcon } from '@degenfeed/ui';
import Image from 'next/image';
import { useCallback, useEffect, useRef, useState } from 'react';
import { publishNoteToNostr } from '../lib/nostr';
const TARGETS = [
{ id: 'nostr', label: 'Nostr', charLimit: 50000 },
{ id: 'farcaster', label: 'Farcaster', charLimit: 320 },
{ id: 'lens', label: 'Lens', charLimit: 5000 },
{ id: 'bluesky', label: 'Bluesky', charLimit: 300 },
{ id: 'threads', label: 'Threads', charLimit: 500 },
{ id: 'mastodon', label: 'Mastodon', charLimit: 5000 },
];
interface TwitterComposerProps {
signedInProtocols: Record<string, boolean>;
nostrPublicId?: string;
onPost: (
text: string,
targets: string[],
) => Promise<Record<string, { ok: boolean; error?: string }>>;
onRequestSignIn?: (protocol: string) => void;
}
export function TwitterComposer({
signedInProtocols,
nostrPublicId,
onPost,
onRequestSignIn,
}: TwitterComposerProps) {
const [text, setText] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bridgeToNostr, setBridgeToNostr] = useState(false);
const [posting, setPosting] = useState(false);
const [avatar, setAvatar] = useState<string | null>(null);
const [displayName, setDisplayName] = useState<string>();
const [handle, setHandle] = useState<string>();
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setSelected(new Set(TARGETS.filter((t) => signedInProtocols[t.id]).map((t) => t.id)));
}, [signedInProtocols]);
useEffect(() => {
getPrimaryIdentity()
.then((id) => {
setAvatar(id?.avatarUrl ?? null);
setDisplayName(id?.displayName ?? undefined);
setHandle(id?.handle ?? id?.publicId ?? undefined);
})
.catch(() => {});
}, []);
const handleTextChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const el = e.target;
setText(el.value);
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 240)}px`;
}, []);
const handlePost = useCallback(async () => {
if (!text.trim() || posting || selected.size === 0) return;
setPosting(true);
try {
const targets = Array.from(selected);
const results = await onPost(text.trim(), targets);
// Mastodon → Nostr bridge: fire locally-signed Nostr note in parallel
if (
bridgeToNostr &&
nostrPublicId &&
targets.includes('mastodon') &&
results.mastodon?.ok !== false
) {
try {
await publishNoteToNostr({ text: text.trim(), publicId: nostrPublicId });
} catch {
/* bridge failure is non-fatal — main post already went out */
}
}
setText('');
const el = textareaRef.current;
if (el) {
el.style.height = 'auto';
}
} finally {
setPosting(false);
}
}, [text, posting, selected, onPost, bridgeToNostr, nostrPublicId]);
const toggleTarget = useCallback(
(id: string) => {
if (!signedInProtocols[id]) {
onRequestSignIn?.(id);
return;
}
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
},
[signedInProtocols, onRequestSignIn],
);
const charCount = text.length;
const selectedTargets = TARGETS.filter((t) => selected.has(t.id));
const minCharLimit =
selectedTargets.length > 0 ? Math.min(...selectedTargets.map((t) => t.charLimit)) : 0;
const overLimit = minCharLimit > 0 && charCount > minCharLimit;
const nearLimit = minCharLimit > 0 && charCount > minCharLimit * 0.85 && !overLimit;
const previewTargets = TARGETS.map((t) => ({
id: t.id,
label: t.label,
charLimit: t.charLimit,
enabled: selected.has(t.id),
}));
return (
<div className="border-b border-white/10 px-4 py-3">
<div className="flex gap-3">
<div className="h-10 w-10 flex-shrink-0 overflow-hidden rounded-full bg-gray-800">
{avatar ? (
<Image
src={avatar}
alt=""
width={40}
height={40}
className="h-full w-full object-cover"
unoptimized
/>
) : (
<span className="flex h-full w-full items-center justify-center text-sm text-gray-500">
?
</span>
)}
</div>
<div className="min-w-0 flex-1">
<textarea
ref={textareaRef}
value={text}
onChange={handleTextChange}
placeholder="What is happening?!"
rows={1}
className="w-full resize-none bg-transparent py-2 text-lg text-white placeholder-gray-600 outline-none"
/>
<div className="mt-2 flex flex-wrap items-center gap-2">
{TARGETS.map((t) => {
const connected = !!signedInProtocols[t.id];
const active = selected.has(t.id);
return (
<button
key={t.id}
type="button"
onClick={() => toggleTarget(t.id)}
className={`inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium transition-all border ${
active ? 'border-current' : 'border-gray-800 hover:border-gray-600'
} ${connected ? '' : 'opacity-40 hover:opacity-70'}`}
style={{
color: active ? getProtocolColor(t.id) : connected ? '#9ca3af' : '#666',
backgroundColor: active ? `${getProtocolColor(t.id)}18` : 'transparent',
}}
title={
connected
? `${t.label} (${t.charLimit} chars)`
: `Sign in to ${t.label} to publish`
}
>
{!connected ? (
<svg
width="10"
height="10"
viewBox="0 0 16 16"
fill="none"
className="text-gray-500"
aria-hidden="true"
>
<path
d="M8 1C5.5 1 3.5 3 3.5 5.5v2C3 8 2 8.5 2 10v3h12v-3c0-1.5-1-2-1.5-2.5v-2C12.5 3 10.5 1 8 1zM8 13.5c-1 0-1.8-.8-1.8-1.8h3.6c0 1-.8 1.8-1.8 1.8z"
fill="currentColor"
/>
</svg>
) : (
<span className="text-[0.7em]">{getProtocolIcon(t.id)}</span>
)}
{t.label}
</button>
);
})}
</div>
{selected.has('mastodon') && nostrPublicId && (
<label className="mt-2 flex items-center gap-2 text-xs text-gray-400">
<input
type="checkbox"
checked={bridgeToNostr}
onChange={(e) => setBridgeToNostr(e.target.checked)}
className="h-3 w-3 rounded border-white/20 bg-gray-900 text-degen-500 focus:ring-degen-500"
/>
<span>
Also cross-post to <span className="font-medium text-white">Nostr</span>
</span>
<span className="text-[10px] text-gray-600">
(signed locally with your Nostr key)
</span>
</label>
)}
<ComposePreview
text={text}
targets={previewTargets}
author={
text.trim() ? { displayName, handle, avatarUrl: avatar ?? undefined } : undefined
}
/>
<div className="mt-3 flex items-center justify-between border-t border-white/5 pt-3">
<div className="flex items-center gap-3 text-xs">
<span
className={
overLimit ? 'text-red-400' : nearLimit ? 'text-yellow-400' : 'text-gray-500'
}
>
{charCount}
{selected.size > 0 && ` / ${minCharLimit}`}
</span>
{selected.size === 0 && (
<span className="text-yellow-500">Select a network to post</span>
)}
{overLimit && (
<span className="text-red-400">
Over {selectedTargets.find((t) => t.charLimit === minCharLimit)?.label} limit
</span>
)}
</div>
<button
type="button"
onClick={handlePost}
disabled={!text.trim() || posting || selected.size === 0 || overLimit}
className="btn-primary px-4 disabled:opacity-50"
>
{posting ? 'Posting…' : 'Post'}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,179 @@
'use client';
import { useAuth } from './AuthProvider';
interface ProtocolSummary {
id: string;
label: string;
color: string;
count: number;
}
interface TwitterSidebarProps {
trending?: { topic: string; count: number; score: number }[];
protocolSummaries?: ProtocolSummary[];
activeProtocolFilter?: string | null;
onProtocolClick?: (id: string) => void;
}
const NETWORK_HINTS: Record<string, string> = {
nostr: 'Decentralized notes',
farcaster: 'Sufficiently decentralized',
lens: 'User-owned graph',
bluesky: 'AT Protocol',
mastodon: 'Fediverse (GotoSocial)',
threads: 'Meta AT Protocol',
reddit: 'Crypto subreddits',
rss: '30+ news sources',
};
export function TwitterSidebar({
trending,
protocolSummaries,
activeProtocolFilter,
onProtocolClick,
}: TwitterSidebarProps) {
const { walletAddress, signOut, openSignIn } = useAuth();
const items = trending ?? [];
const protocols = protocolSummaries ?? [];
const totalPosts = protocols.reduce((sum, p) => sum + p.count, 0);
return (
<div className="space-y-4">
{/* Search */}
<a
href="/home/all"
className="flex items-center gap-2 rounded-full border border-white/10 bg-gray-950/70 px-4 py-2.5 text-sm text-gray-400 backdrop-blur-xl transition-colors hover:border-degen-500/30 hover:text-white"
>
<span className="text-lg text-degen-400"></span>
<span>Search posts, topics, tokens</span>
</a>
{/* Wallet card */}
{walletAddress ? (
<div className="rounded-2xl border border-white/10 bg-gray-950/70 p-4 backdrop-blur-xl">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-degen-500/20 font-mono text-sm text-degen-400">
{walletAddress.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
{walletAddress.slice(0, 6)}{walletAddress.slice(-4)}
</p>
<p className="text-xs text-gray-500">Wallet connected</p>
</div>
</div>
<button
type="button"
onClick={signOut}
className="mt-3 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-gray-300 transition-colors hover:bg-white/10 hover:text-white"
>
Sign out
</button>
</div>
) : (
<div className="rounded-2xl border border-degen-500/30 bg-gradient-to-br from-degen-500/15 to-degen-500/5 p-4 backdrop-blur-xl">
<div className="mb-2 flex items-center gap-2">
<span className="text-degen-400"></span>
<p className="text-sm font-semibold text-white">Read free, post with one click</p>
</div>
<p className="mb-3 text-xs text-gray-400">
No account needed to browse. Sign in with a wallet to post across all networks.
</p>
<button type="button" onClick={openSignIn} className="btn-primary w-full text-sm">
Sign in
</button>
</div>
)}
{/* Live protocol status */}
<div className="rounded-2xl border border-white/10 bg-gray-950/70 p-4 backdrop-blur-xl">
<div className="mb-3 flex items-baseline justify-between">
<h2 className="text-sm font-bold text-white">Live networks</h2>
<span className="text-[10px] text-gray-500">{totalPosts} posts</span>
</div>
{protocols.length === 0 ? (
<p className="text-xs text-gray-500">Warming up</p>
) : (
<ul className="space-y-1">
{protocols.slice(0, 8).map((p) => {
const active = activeProtocolFilter === p.id;
return (
<li key={p.id}>
<button
type="button"
onClick={() => onProtocolClick?.(p.id)}
className={`flex w-full items-center gap-3 rounded-lg px-2 py-1.5 text-left transition-colors ${
active ? 'bg-white/5' : 'hover:bg-white/5'
}`}
title={NETWORK_HINTS[p.id] ?? p.label}
>
<span
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: p.color }}
aria-hidden="true"
/>
<span
className={`flex-1 text-sm ${
active ? 'font-semibold text-white' : 'text-gray-300'
}`}
>
{p.label}
</span>
<span className="text-xs tabular-nums text-gray-500">{p.count}</span>
</button>
</li>
);
})}
</ul>
)}
{activeProtocolFilter && (
<button
type="button"
onClick={() => onProtocolClick?.(activeProtocolFilter)}
className="mt-2 text-[11px] text-degen-400 hover:text-degen-300"
>
Clear filter
</button>
)}
</div>
{/* Trending */}
<div className="rounded-2xl border border-white/10 bg-gray-950/70 p-4 backdrop-blur-xl">
<h2 className="mb-3 text-sm font-bold text-white">Trending now</h2>
{items.length === 0 ? (
<p className="text-xs text-gray-500">Trending topics appear as you browse.</p>
) : (
<div className="space-y-2">
{items.slice(0, 8).map((t) => (
<a
key={t.topic}
href={`/home/${encodeURIComponent(t.topic.toLowerCase().replace(/\s+/g, '-'))}`}
className="block rounded-lg px-2 py-1 transition-colors hover:bg-white/5"
>
<div className="text-[10px] uppercase tracking-wide text-gray-500">Trending</div>
<div className="text-sm font-medium text-white">{t.topic}</div>
<div className="text-xs text-gray-500">{t.count} posts</div>
</a>
))}
</div>
)}
</div>
{/* Premium CTA */}
<div className="rounded-2xl border border-degen-500/20 bg-degen-500/5 p-4 backdrop-blur-xl">
<h2 className="mb-1 text-sm font-bold text-white">DegenFeed Premium</h2>
<p className="mb-3 text-xs text-gray-400">
Cross-posting, algorithm controls, analytics, and creator tools.
</p>
<a href="/premium" className="btn-primary block text-center text-sm">
See plans
</a>
</div>
<p className="px-2 text-[10px] text-gray-700">
© {new Date().getFullYear()} DegenFeed · No account required to browse
</p>
</div>
);
}

View file

@ -1,6 +1,6 @@
'use client';
import { type ReactNode, createContext, useCallback, useContext, useEffect, useState } from 'react';
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react';
interface EIP1193Provider {
request: (args: { method: string; params?: unknown[] }) => Promise<unknown>;

View file

@ -0,0 +1,155 @@
'use client';
import type { StoredIdentity } from '@degenfeed/storage';
import { getAllIdentities } from '@degenfeed/storage';
import type { Notification } from '@degenfeed/ui';
import { useCallback, useEffect, useRef, useState } from 'react';
const READ_KEY = 'degenfeed_notifications_read';
const POLL_MS = 60000;
interface NotificationAuth {
nostr_pubkey?: string;
mastodon?: string;
mastodon_instance?: string;
bluesky?: {
handle?: string;
did?: string;
accessJwt?: string;
};
farcaster?: {
fid?: string;
signerKey?: string;
};
}
function buildAuth(identities: StoredIdentity[]): NotificationAuth {
const auth: NotificationAuth = {};
for (const identity of identities) {
if (identity.protocol === 'nostr') {
auth.nostr_pubkey = identity.publicId;
} else if (identity.protocol === 'mastodon') {
auth.mastodon = identity.keys?.accessToken ?? undefined;
auth.mastodon_instance = identity.keys?.instanceUrl ?? 'https://social.degenfeed.xyz';
} else if (identity.protocol === 'bluesky') {
auth.bluesky = {
handle: identity.handle,
did: identity.publicId,
accessJwt: identity.keys?.accessJwt ?? undefined,
};
} else if (identity.protocol === 'farcaster') {
auth.farcaster = {
fid: identity.publicId,
signerKey: identity.keys?.signerKey ?? undefined,
};
}
}
return auth;
}
function loadReadIds(): Set<string> {
try {
const raw = localStorage.getItem(READ_KEY);
if (!raw) return new Set();
return new Set(JSON.parse(raw) as string[]);
} catch {
return new Set();
}
}
function saveReadIds(ids: Set<string>) {
try {
localStorage.setItem(READ_KEY, JSON.stringify(Array.from(ids)));
} catch {
/* ignore */
}
}
export function useNotifications(enabled: boolean) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [readIds, setReadIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
setReadIds(loadReadIds());
}, []);
const fetchNotifications = useCallback(async () => {
if (!enabled) return;
setLoading(true);
try {
const identities = await getAllIdentities();
const auth = buildAuth(identities);
const hasAny =
auth.nostr_pubkey || auth.mastodon || auth.bluesky?.accessJwt || auth.farcaster?.fid;
if (!hasAny) {
setNotifications([]);
return;
}
const res = await fetch('/api/notifications', {
method: 'POST',
credentials: 'include',
cache: 'no-store',
headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
body: JSON.stringify({ auth }),
});
const data = (await res.json()) as { notifications?: Notification[] };
const fetched = data.notifications || [];
const currentRead = loadReadIds();
setNotifications(
fetched.map((n) => ({
...n,
read: n.read || currentRead.has(n.id),
})),
);
} catch {
/* ignore */
} finally {
setLoading(false);
}
}, [enabled]);
const markRead = useCallback((id: string) => {
setReadIds((prev) => {
const next = new Set(prev);
next.add(id);
saveReadIds(next);
return next;
});
setNotifications((prev) => prev.map((n) => (n.id === id ? { ...n, read: true } : n)));
}, []);
const markAllRead = useCallback(() => {
setReadIds((prev) => {
const next = new Set(prev);
for (const n of notifications) next.add(n.id);
saveReadIds(next);
return next;
});
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
}, [notifications]);
useEffect(() => {
fetchNotifications();
intervalRef.current = setInterval(fetchNotifications, POLL_MS);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [fetchNotifications]);
const decorated = notifications.map((n) => ({
...n,
read: n.read || readIds.has(n.id),
}));
const unreadCount = decorated.filter((n) => !n.read).length;
return {
notifications: decorated,
unreadCount,
loading,
fetchNotifications,
markRead,
markAllRead,
};
}

View file

@ -1,7 +1,7 @@
'use client';
import { buildSiweMessage, requestWalletNonce, verifyWalletSignature } from '@degenfeed/auth';
import type { WalletSession } from '@degenfeed/auth';
import { buildSiweMessage, requestWalletNonce, verifyWalletSignature } from '@degenfeed/auth';
import { saveWalletIdentity } from '@degenfeed/storage';
import { useCallback, useState } from 'react';
import { useWeb3 } from '../components/Web3Provider';

100
apps/web/src/lib/nostr.ts Normal file
View file

@ -0,0 +1,100 @@
/**
* @degenfeed/web/lib/nostr Client-side Nostr publishing helper
*
* Used by the Mastodon->Nostr bridge in the composer.
*
* Looks up the user's Nostr identity in IndexedDB, decrypts the saved
* nsec using the auto-unlock passphrase, signs a text note, and
* publishes to a small set of public relays.
*
* Returns per-relay publish results. We consider the publish successful
* if at least one relay accepts the event.
*/
import { decryptSecret } from '@degenfeed/auth';
import { getIdentity, type StoredIdentity } from '@degenfeed/storage';
const DEFAULT_RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net'];
const AUTO_UNLOCK_PASSPHRASE = 'auto-unlock-passphrase';
export interface NostrPublishResult {
ok: boolean;
eventId?: string;
relays: Array<{ url: string; ok: boolean; error?: string }>;
error?: string;
}
interface RelayPoolLike {
publish(event: { id: string; sig: string; [k: string]: unknown }): Promise<unknown>;
disconnectAll(): void;
}
async function loadNostrIdentity(publicId: string): Promise<StoredIdentity | null> {
try {
const record = await getIdentity(`nostr:${publicId}`);
return record ?? null;
} catch {
return null;
}
}
async function decryptNsec(record: StoredIdentity): Promise<string | null> {
if (!record.encryptedSecret) return null;
try {
const secret = await decryptSecret(
{ ciphertext: record.encryptedSecret, salt: record.salt, iv: record.iv },
AUTO_UNLOCK_PASSPHRASE,
);
return secret;
} catch {
return null;
}
}
export async function publishNoteToNostr(opts: {
text: string;
publicId: string;
relays?: string[];
}): Promise<NostrPublishResult> {
const record = await loadNostrIdentity(opts.publicId);
if (!record) {
return { ok: false, relays: [], error: 'No Nostr identity linked' };
}
const nsec = await decryptNsec(record);
if (!nsec) {
return { ok: false, relays: [], error: 'Could not unlock Nostr key' };
}
try {
const nostr = await import('@degenfeed/nostr-sdk');
const ev = nostr.createTextNote(opts.text, nsec, []);
ev.pubkey = nostr.getPublicKey(nsec);
ev.id = nostr.getEventHash(ev);
nostr.signEvent(ev, nsec);
const relayUrls = opts.relays ?? DEFAULT_RELAYS;
const pool = new nostr.RelayPool(relayUrls, { eoseTimeout: 3000 }) as unknown as RelayPoolLike;
const relayResults: NostrPublishResult['relays'] = [];
for (const url of relayUrls) {
try {
await pool.publish(ev as unknown as { id: string; sig: string; [k: string]: unknown });
relayResults.push({ url, ok: true });
} catch (e) {
relayResults.push({ url, ok: false, error: String(e) });
}
}
pool.disconnectAll();
const okCount = relayResults.filter((r) => r.ok).length;
return {
ok: okCount > 0,
eventId: ev.id,
relays: relayResults,
error: okCount === 0 ? 'No relay accepted the event' : undefined,
};
} catch (e) {
return { ok: false, relays: [], error: String(e) };
}
}

View file

@ -3,16 +3,44 @@
import DOMPurify from 'dompurify';
const ALLOWED_TAGS = [
'a', 'b', 'i', 'em', 'strong', 'br', 'p', 'span', 'div',
'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'img', 'video', 'audio', 'source',
'a',
'b',
'i',
'em',
'strong',
'br',
'p',
'span',
'div',
'ul',
'ol',
'li',
'blockquote',
'code',
'pre',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'img',
'video',
'audio',
'source',
];
const ALLOWED_ATTR = [
'href', 'title', 'alt', 'src', 'class', 'id',
'target', 'rel',
'width', 'height',
'href',
'title',
'alt',
'src',
'class',
'id',
'target',
'rel',
'width',
'height',
];
export function sanitizeHtml(html: string): string {

View file

@ -28,5 +28,14 @@
},
"json": {
"formatter": { "indentWidth": 2, "trailingCommas": "none" }
}
},
"css": {
"parser": { "tailwindDirectives": true }
},
"overrides": [
{
"includes": ["apps/web/src/app/globals.css"],
"linter": { "enabled": false }
}
]
}

View file

@ -5,7 +5,9 @@
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",

View file

@ -5,7 +5,9 @@
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",

View file

@ -99,5 +99,4 @@ describe('@degenfeed/feed-core', () => {
});
expect(deduplicatePosts([a, b])).toHaveLength(1);
});
});

View file

@ -11,7 +11,7 @@
* post crypto/web3 content.
*/
import { buildUnifiedPost, detectNiches, enrichPostNiches } from '@degenfeed/feed-core';
import { detectNiches, enrichPostNiches } from '@degenfeed/feed-core';
import type { Protocol, UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
import type { FeedProvider, FeedResult, FetchOptions, HealthResult } from '../types';
import {
@ -35,9 +35,7 @@ export const FALLBACK_HUB_URLS = [
export const DEFAULT_FARCASTER_PUBLIC_API = 'https://api.farcaster.xyz';
export const CURATED_FARCASTER_FIDS = [
3, 2, 1, 99, 100, 215, 320, 602, 1234, 4242,
];
export const CURATED_FARCASTER_FIDS = [3, 2, 1, 99, 100, 215, 320, 602, 1234, 4242];
export const CURATED_FARCASTER_USERNAMES = [
'vitalik.eth',
@ -85,8 +83,16 @@ export const CURATED_FARCASTER_USERNAMES = [
];
export const CRYPTO_SEARCH_TERMS = [
'bitcoin', 'ethereum', 'crypto', 'defi', 'web3',
'solana', 'nft', 'blockchain', 'btc', 'eth',
'bitcoin',
'ethereum',
'crypto',
'defi',
'web3',
'solana',
'nft',
'blockchain',
'btc',
'eth',
];
interface HubCastMessage {
@ -153,8 +159,13 @@ function authorFromFid(fid: number, userData: FarcasterUserData | null): Unified
id: `farcaster:${fid}`,
primary: { protocol: 'farcaster', id: String(fid) },
handles: {
nostr: null, farcaster: handle, lens: null, bluesky: null,
threads: null, rss: null, mastodon: null,
nostr: null,
farcaster: handle,
lens: null,
bluesky: null,
threads: null,
rss: null,
mastodon: null,
},
displayName: userData?.displayName || userData?.username || `FID ${fid}`,
bio: '',
@ -181,11 +192,13 @@ function castToUnifiedPost(
embeds: (body.embeds ?? [])
.filter((e): e is { url: string } => typeof e.url === 'string')
.map((e) => ({ kind: 'link' as const, url: e.url })),
parentNativeId: body.parentCastId?.hash,
parentId: body.parentCastId?.hash,
createdAt: hubTimestampToMs(msg.data.timestamp),
metrics: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
engagement: {
viewerLiked: false, viewerReposted: false, viewerBookmarked: false,
viewerLiked: false,
viewerReposted: false,
viewerBookmarked: false,
raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
},
raw: msg,
@ -203,7 +216,11 @@ function publicApiCastToUnifiedPost(c: PublicApiCast): UnifiedPost | null {
handles: {
nostr: null,
farcaster: c.author?.username ?? null,
lens: null, bluesky: null, threads: null, rss: null, mastodon: null,
lens: null,
bluesky: null,
threads: null,
rss: null,
mastodon: null,
},
displayName: c.author?.displayName || c.author?.username || `FID ${fid}`,
bio: '',
@ -226,7 +243,9 @@ function publicApiCastToUnifiedPost(c: PublicApiCast): UnifiedPost | null {
createdAt: c.timestamp ? c.timestamp * 1000 : Date.now(),
metrics: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
engagement: {
viewerLiked: false, viewerReposted: false, viewerBookmarked: false,
viewerLiked: false,
viewerReposted: false,
viewerBookmarked: false,
raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
},
raw: c,
@ -249,10 +268,7 @@ async function fetchCastsByFid(
return data.messages ?? [];
}
async function fetchUserData(
baseUrl: string,
fid: number,
): Promise<FarcasterUserData | null> {
async function fetchUserData(baseUrl: string, fid: number): Promise<FarcasterUserData | null> {
try {
const data = await fetchJson<HubUserDataResponse>(
buildUrl(baseUrl, '/v1/userDataByFid', { fid: String(fid) }),
@ -362,7 +378,11 @@ export class FarcasterProvider implements FeedProvider {
const results = await withConcurrencyLimit(allFids, opts.concurrency ?? 4, async (fid) => {
try {
const msgs = await fetchWithFallbacks(endpoints, (base) => {
return withTimeout(fetchCastsByFid(base, fid, perFidLimit), timeoutMs, `farcaster:${fid}`);
return withTimeout(
fetchCastsByFid(base, fid, perFidLimit),
timeoutMs,
`farcaster:${fid}`,
);
});
return msgs.map((msg) => ({ msg, fid }));
} catch {
@ -403,7 +423,10 @@ export class FarcasterProvider implements FeedProvider {
const unique: PublicApiCast[] = [];
for (const c of casts) {
const key = c.hash ?? '';
if (key && !seen.has(key)) { seen.add(key); unique.push(c); }
if (key && !seen.has(key)) {
seen.add(key);
unique.push(c);
}
}
const posts: UnifiedPost[] = [];
for (const c of unique) {

View file

@ -230,6 +230,20 @@ export class MastodonProvider implements FeedProvider {
const generalInstances = endpoints.filter((u) => !cryptoInstances.includes(u));
try {
// Start with the public timeline from the primary instance so our own
// GotoSocial server is always represented, even if hashtag search is thin.
const allStatuses: GtSStatus[] = [];
try {
const primaryPublic = await withTimeout(
fetchPublicTimeline(this.primaryUrl, Math.ceil(limit / 2)),
timeoutMs,
`mastodon:public:${this.primaryUrl}`,
);
allStatuses.push(...primaryPublic);
} catch {
/* primary public timeline may be restricted; continue with hashtags */
}
// Build all (instance, tag) pairs in parallel. Bound concurrency so
// we don't slam a single instance.
const tagTargets: { instanceUrl: string; tag: string }[] = [];
@ -250,7 +264,6 @@ export class MastodonProvider implements FeedProvider {
return [];
}
});
const allStatuses: GtSStatus[] = [];
for (const r of tagResults) allStatuses.push(...r);
// Fallback: public timeline from general instances if crypto

View file

@ -238,30 +238,103 @@ export class NostrProvider implements FeedProvider {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const limit = 25;
// Fetch NIP-23 long-form articles (kind 30023) and short notes (kind 1)
const [notes, articles] = await Promise.all([
this.fetchNotes(timeoutMs, limit),
this.fetchLongForm(timeoutMs, Math.ceil(limit / 4)),
]);
const allEvents: NostrEvent[] = [...notes, ...articles];
if (allEvents.length > 0) {
// Sort by created_at desc, dedup by id
const seen = new Set<string>();
const unique = allEvents
.sort((a, b) => b.created_at - a.created_at)
.filter((e) => {
if (seen.has(e.id)) return false;
seen.add(e.id);
return true;
});
return {
posts: unique.slice(0, limit).map((e) => enrichPostNiches(eventToUnifiedPost(e))),
};
}
return { posts: [], notice: 'Nostr feed unavailable' };
}
private async fetchNotes(timeoutMs: number, limit: number): Promise<NostrEvent[]> {
try {
const events = await fetchFromRelays(this.relays, limit, timeoutMs);
if (events.length > 0) {
return {
posts: events.slice(0, limit).map((e) => enrichPostNiches(eventToUnifiedPost(e))),
};
}
if (events.length > 0) return events;
} catch {
/* fall through to HTTP */
/* fall through to HTTP fallback */
}
try {
const events = await withTimeout(fetchFromHttp(limit, timeoutMs), timeoutMs, 'nostr:http');
return {
posts: events.slice(0, limit).map((e) => enrichPostNiches(eventToUnifiedPost(e))),
};
} catch (err) {
return {
posts: [],
notice: err instanceof Error ? err.message : 'Nostr feed unavailable',
};
return await withTimeout(fetchFromHttp(limit, timeoutMs), timeoutMs, 'nostr:http');
} catch {
return [];
}
}
/** Fetch NIP-23 long-form articles (kind 30023) from relays. */
private async fetchLongForm(timeoutMs: number, limit: number): Promise<NostrEvent[]> {
return new Promise((resolve) => {
const events: NostrEvent[] = [];
const timer = setTimeout(() => resolve(events), timeoutMs);
const targetRelay = this.relays[0];
if (!targetRelay) {
clearTimeout(timer);
resolve(events);
return;
}
let ws: WebSocket;
try {
ws = new WebSocket(targetRelay);
} catch {
clearTimeout(timer);
resolve(events);
return;
}
const subId = `longform-${Date.now()}`;
const cleanup = () => {
clearTimeout(timer);
try {
ws.close();
} catch {
/* ignore */
}
};
ws.onopen = () => {
try {
ws.send(JSON.stringify(['REQ', subId, { kinds: [30023], limit }]));
} catch {
cleanup();
resolve(events);
}
};
ws.onmessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data as string) as unknown[];
if (Array.isArray(data) && data[0] === 'EVENT' && data[2]) {
events.push(data[2] as NostrEvent);
} else if (Array.isArray(data) && data[0] === 'EOSE') {
cleanup();
resolve(events);
}
} catch {
/* ignore */
}
};
ws.onerror = () => {
cleanup();
resolve(events);
};
ws.onclose = () => {
clearTimeout(timer);
resolve(events);
};
});
}
async healthCheck(opts: FetchOptions = {}): Promise<HealthResult> {
const start = performance.now();
try {

View file

@ -17,7 +17,6 @@ import type { FeedProvider, FeedResult, FetchOptions, HealthResult } from '../ty
import {
buildUrl,
DEFAULT_TIMEOUT_MS,
DEFAULT_USER_AGENT,
fetchJson,
fetchRss,
withConcurrencyLimit,
@ -112,10 +111,12 @@ function parseRssXml(xml: string): RssItem[] {
const entryXml = m[2] ?? '';
const titleMatch = entryXml.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
const linkMatch = entryXml.match(/<link[^>]+href=["']([^"']+)["']/i);
const pubDateMatch = entryXml.match(/<updated[^>]*>([\s\S]*?)<\/updated>/i)
?? entryXml.match(/<published[^>]*>([\s\S]*?)<\/published>/i);
const contentMatch = entryXml.match(/<content[^>]*>([\s\S]*?)<\/content>/i)
?? entryXml.match(/<summary[^>]*>([\s\S]*?)<\/summary>/i);
const pubDateMatch =
entryXml.match(/<updated[^>]*>([\s\S]*?)<\/updated>/i) ??
entryXml.match(/<published[^>]*>([\s\S]*?)<\/published>/i);
const contentMatch =
entryXml.match(/<content[^>]*>([\s\S]*?)<\/content>/i) ??
entryXml.match(/<summary[^>]*>([\s\S]*?)<\/summary>/i);
if (titleMatch && linkMatch) {
items.push({
title: stripHtml(titleMatch[1] ?? ''),
@ -172,8 +173,13 @@ function itemToUnifiedPost(item: RssItem, subreddit: string, score = 0, comments
id: `reddit:r:${subreddit}`,
primary: { protocol: 'reddit' as Protocol, id: subreddit },
handles: {
nostr: null, farcaster: null, lens: null, bluesky: null,
threads: null, rss: null, mastodon: null,
nostr: null,
farcaster: null,
lens: null,
bluesky: null,
threads: null,
rss: null,
mastodon: null,
},
displayName: `r/${subreddit}`,
bio: item.author ? `Posted by u/${item.author}` : '',
@ -198,7 +204,9 @@ function itemToUnifiedPost(item: RssItem, subreddit: string, score = 0, comments
createdAt: parseDate(item.pubDate),
metrics: { likes: score, reposts: 0, replies: comments, quotes: 0 },
engagement: {
viewerLiked: false, viewerReposted: false, viewerBookmarked: false,
viewerLiked: false,
viewerReposted: false,
viewerBookmarked: false,
raw: { likes: score, reposts: 0, replies: comments, quotes: 0 },
},
raw: item,
@ -238,8 +246,10 @@ async function fetchFromFrontend(
return (data.posts ?? []).map((p) => {
const sub = p.subreddit ?? subreddit;
const link = p.permalink
? p.permalink.startsWith('http') ? p.permalink : `${frontend}${p.permalink}`
: p.url ?? `https://www.reddit.com/r/${sub}`;
? p.permalink.startsWith('http')
? p.permalink
: `${frontend}${p.permalink}`
: (p.url ?? `https://www.reddit.com/r/${sub}`);
const item: RssItem = {
title: p.title,
link,
@ -355,17 +365,13 @@ export class RedditProvider implements FeedProvider {
const limit = 50;
const perSub = Math.ceil(limit / this.subreddits.length);
const results = await withConcurrencyLimit(
this.subreddits,
4,
async (sub) => {
try {
return await fetchSubreddit(sub, this.sort, perSub, timeoutMs);
} catch {
return [] as UnifiedPost[];
}
},
);
const results = await withConcurrencyLimit(this.subreddits, 4, async (sub) => {
try {
return await fetchSubreddit(sub, this.sort, perSub, timeoutMs);
} catch {
return [] as UnifiedPost[];
}
});
const posts = results.flat().slice(0, limit);
if (posts.length > 0) return { posts };

View file

@ -10,7 +10,7 @@
* includes 'rss' (via RssProvider) and this provider can be added
* alongside it for the dedicated RMI signal.
*/
import { buildUnifiedPost, detectNiches } from '@degenfeed/feed-core';
import { detectNiches } from '@degenfeed/feed-core';
import type { Protocol, UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
import type { FeedProvider, FeedResult, FetchOptions, HealthResult } from '../types';
import {
@ -100,7 +100,7 @@ function authorFromSource(source: string): UnifiedIdentity {
};
}
function articleToUnifiedPost(a: RmiArticle, backendUrl: string): UnifiedPost | null {
function articleToUnifiedPost(a: RmiArticle, _backendUrl: string): UnifiedPost | null {
const title = (a.title ?? '').trim();
const url = (a.url ?? '').trim();
if (!title || !url) return null;

View file

@ -1,7 +1,13 @@
import { detectNiches } from '@degenfeed/feed-core';
import type { Embed, Protocol, UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
import type { FeedProvider, FeedResult, FetchOptions } from '../types';
import { DEFAULT_TIMEOUT_MS, DEFAULT_USER_AGENT, fetchRss, withConcurrencyLimit, withTimeout } from '../utils';
import {
DEFAULT_TIMEOUT_MS,
DEFAULT_USER_AGENT,
fetchRss,
withConcurrencyLimit,
withTimeout,
} from '../utils';
export const DEFAULT_CRYPTO_FEEDS = [
// Tier 1 — Major outlets
@ -317,23 +323,19 @@ export class RssProvider implements FeedProvider {
const concurrency = opts.concurrency ?? 4;
const posts: UnifiedPost[] = [];
const results = await withConcurrencyLimit(
this.feeds,
concurrency,
async (feedUrl) => {
try {
const xml = await withTimeout(
fetchRss(feedUrl, { headers: { 'user-agent': DEFAULT_USER_AGENT, ...opts.headers } }),
timeoutMs,
`rss:${feedUrl}`,
);
const feed = parseRss(xml);
return feedToUnifiedPosts(feedUrl, feed);
} catch {
return [] as UnifiedPost[];
}
},
);
const results = await withConcurrencyLimit(this.feeds, concurrency, async (feedUrl) => {
try {
const xml = await withTimeout(
fetchRss(feedUrl, { headers: { 'user-agent': DEFAULT_USER_AGENT, ...opts.headers } }),
timeoutMs,
`rss:${feedUrl}`,
);
const feed = parseRss(xml);
return feedToUnifiedPosts(feedUrl, feed);
} catch {
return [] as UnifiedPost[];
}
});
for (const r of results) posts.push(...r);
return { posts };

View file

@ -10,16 +10,10 @@
* tries the public Threads GraphQL endpoint (used by the web client).
*/
import { buildUnifiedPost, detectNiches, enrichPostNiches } from '@degenfeed/feed-core';
import { detectNiches, enrichPostNiches } from '@degenfeed/feed-core';
import type { Protocol, UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
import type { FeedProvider, FeedResult, FetchOptions, HealthResult } from '../types';
import {
DEFAULT_TIMEOUT_MS,
DEFAULT_USER_AGENT,
fetchText,
withConcurrencyLimit,
withTimeout,
} from '../utils';
import { DEFAULT_TIMEOUT_MS, fetchText, withConcurrencyLimit, withTimeout } from '../utils';
export const THREADS_WEB_URL = 'https://www.threads.net';
@ -57,7 +51,8 @@ interface ThreadsPost {
function threadsHeaders(): Record<string, string> {
return {
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'user-agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'accept-language': 'en-US,en;q=0.9',
};
@ -68,8 +63,13 @@ function postToUnifiedPost(p: ThreadsPost): UnifiedPost {
id: `threads:${p.author}`,
primary: { protocol: 'threads', id: p.author },
handles: {
nostr: null, farcaster: null, lens: null, bluesky: null,
threads: p.author, rss: null, mastodon: null,
nostr: null,
farcaster: null,
lens: null,
bluesky: null,
threads: p.author,
rss: null,
mastodon: null,
},
displayName: p.authorName || p.author,
bio: '',
@ -94,7 +94,9 @@ function postToUnifiedPost(p: ThreadsPost): UnifiedPost {
createdAt: p.createdAt,
metrics: { likes: p.likes, reposts: p.reposts, replies: p.replies, quotes: 0 },
engagement: {
viewerLiked: false, viewerReposted: false, viewerBookmarked: false,
viewerLiked: false,
viewerReposted: false,
viewerBookmarked: false,
raw: { likes: p.likes, reposts: p.reposts, replies: p.replies, quotes: 0 },
},
raw: p,
@ -105,14 +107,15 @@ function postToUnifiedPost(p: ThreadsPost): UnifiedPost {
function extractJsonLd(html: string): unknown[] {
const results: unknown[] = [];
const scriptRe = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
let m: RegExpExecArray | null;
while ((m = scriptRe.exec(html)) !== null) {
let m: RegExpExecArray | null = scriptRe.exec(html);
while (m !== null) {
try {
const json = JSON.parse(m[1] ?? '{}');
results.push(json);
} catch {
// ignore malformed JSON
}
m = scriptRe.exec(html);
}
return results;
}
@ -145,11 +148,14 @@ async function scrapeThreadsProfile(handle: string, timeoutMs: number): Promise<
const authorName = (author.name as string) ?? normalized;
const authorAvatar = (author.image as string) ?? '';
const id = postUrl.split('/').pop() ?? `scraped-${Date.now()}-${posts.length}`;
const image = typeof block.image === 'string'
? block.image
: (block.image as Record<string, string>)?.url;
const image =
typeof block.image === 'string'
? block.image
: (block.image as Record<string, string>)?.url;
const interaction = (block.interactionStatistic as Array<Record<string, unknown>>) ?? [];
let likes = 0, replies = 0, reposts = 0;
let likes = 0,
replies = 0,
reposts = 0;
for (const stat of interaction) {
const name = (stat.name as string) ?? '';
const val = Number(stat.userInteractionCount ?? 0);
@ -157,7 +163,19 @@ async function scrapeThreadsProfile(handle: string, timeoutMs: number): Promise<
else if (name.includes('Reply') || name.includes('Comment')) replies = val;
else if (name.includes('Share') || name.includes('Repost')) reposts = val;
}
posts.push({ id, text, url: postUrl, author: authorHandle, authorName, authorAvatar, createdAt, image, likes, replies, reposts });
posts.push({
id,
text,
url: postUrl,
author: authorHandle,
authorName,
authorAvatar,
createdAt,
image,
likes,
replies,
reposts,
});
}
return posts;
} catch {
@ -181,10 +199,8 @@ export class ThreadsProvider implements FeedProvider {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const limit = 25;
const results = await withConcurrencyLimit(
this.curatedHandles.slice(0, 4),
2,
async (handle) => scrapeThreadsProfile(handle, timeoutMs),
const results = await withConcurrencyLimit(this.curatedHandles.slice(0, 4), 2, async (handle) =>
scrapeThreadsProfile(handle, timeoutMs),
);
const allPosts = results.flat().slice(0, limit);
if (allPosts.length > 0) {

View file

@ -183,7 +183,7 @@ async function resolveLensHandle(
}
}
async function resolveThreadsHandle(
async function _resolveThreadsHandle(
_handle: string,
_fetchFn: typeof fetch,
): Promise<HandleResolution | null> {

View file

@ -5,7 +5,9 @@
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run",

View file

@ -7,7 +7,7 @@
import { buildUnifiedPost } from '@degenfeed/feed-core';
import type { Embed, Protocol, UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
export type { Protocol, UnifiedIdentity, UnifiedPost, Embed };
export type { Embed, Protocol, UnifiedIdentity, UnifiedPost };
export interface GtSStatus {
id: string;

View file

@ -9,37 +9,43 @@
// ─── Core re-exports ───────────────────────────────────────────────────
export { generateSecretKey, getPublicKey, getEventHash, signEvent, verifyEvent } from './event';
export { createTextNote, createReaction, createRepost, createDeletion } from './event';
export { serializeEvent, deserializeEvent } from './event';
export type { NostrEvent, UnsignedEvent } from './event';
export { RelayPool } from './relay';
export type {
RelaySubscription,
RelayEvent,
NostrFilter,
RelayPoolOptions,
RelayEventKind,
} from './relay';
export {
createDeletion,
createReaction,
createRepost,
createTextNote,
deserializeEvent,
generateSecretKey,
getEventHash,
getPublicKey,
serializeEvent,
signEvent,
verifyEvent,
} from './event';
export type { Nip05Result } from './nip05';
export { parseNip05, resolveNip05, verifyNip05 } from './nip05';
export type { Nip19Type, NsecDecodeResult } from './nip19';
export {
decodeNip19,
encodeNip19,
npubEncode,
npubDecode,
nsecEncode,
nsecDecode,
noteEncode,
noteDecode,
noteEncode,
npubDecode,
npubEncode,
nsecDecode,
nsecEncode,
} from './nip19';
export type { Nip19Type, NsecDecodeResult } from './nip19';
export { parseNip05, resolveNip05, verifyNip05 } from './nip05';
export type { Nip05Result } from './nip05';
export { connectNostrConnect } from './nip46';
export type { Nip46ConnectOptions } from './nip46';
export { connectNostrConnect } from './nip46';
export type {
NostrFilter,
RelayEvent,
RelayEventKind,
RelayPoolOptions,
RelaySubscription,
} from './relay';
export { RelayPool } from './relay';
// ─── Normalizers ───────────────────────────────────────────────────────

View file

@ -51,7 +51,7 @@ export async function resolveNip05(
relays?: Record<string, string[]>;
};
if (!json.names || !json.names[name]) return null;
if (!json.names?.[name]) return null;
const pubkey = json.names[name];
const relays = json.relays?.[pubkey];

View file

@ -88,8 +88,8 @@ describe('@degenfeed/ranking', () => {
viewer: null,
recentAuthors: new Map(),
sourceQuality: new Map(),
viewerNiches: new Set(['bitcoin']),
followed: new Set(),
viewerNiches: new Set<string>(['bitcoin']),
followed: new Set<string>(),
now,
};
const bd = scoreBreakdownV2(post, ctx);
@ -144,8 +144,8 @@ describe('@degenfeed/ranking', () => {
viewer: null,
recentAuthors: new Map(),
sourceQuality: new Map(),
viewerNiches: new Set(),
followed: new Set(),
viewerNiches: new Set<string>(),
followed: new Set<string>(),
now: Date.now(),
};
const ranked = rankDiverse(posts, ctx, { maxPerAuthor: 2 });

View file

@ -49,6 +49,7 @@ export const PLATFORM_TIER: Record<Protocol, number> = {
bluesky: 1,
mastodon: 1,
threads: 0.7,
reddit: 0.8,
rss: 0.8,
ethereum: 0.6,
solana: 0.6,

View file

@ -27,10 +27,9 @@ import { detectNiches } from '@degenfeed/feed-core';
import type { Embed, Protocol, UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
import Parser from 'rss-parser';
export type { Protocol, UnifiedIdentity, UnifiedPost, Embed };
// Re-export parser type for consumers
export type { Output as RssFeedShape, Item as RssItemShape } from 'rss-parser';
export type { Item as RssItemShape, Output as RssFeedShape } from 'rss-parser';
export type { Embed, Protocol, UnifiedIdentity, UnifiedPost };
// ─── Feed type constants ───────────────────────────────────────────────

View file

@ -1,59 +1,60 @@
// ─── @degenfeed/storage — barrel exports ──────────────────────────
export type {
StoredIdentity,
StoredFollow,
StoredDraft,
StoredCachedPost,
StoredMutedIdentity,
StoredMutedWord,
UserPreferences,
MonetizationPreferences,
SubscriptionRecord,
SubscriptionTier,
} from './types';
export type { ActiveSession } from './session';
export type { StoredBookmark } from './bookmarks';
export {
setActiveSession,
getActiveSession,
clearActiveSession,
clearAllSessions,
} from './session';
export { addBookmark, getBookmarks, isBookmarked, removeBookmark } from './bookmarks';
export { cachePost, clearCache, getCachedPost } from './cache';
export { getDb } from './db';
export { deleteDraft, getDrafts, saveDraft } from './drafts';
export { deleteFollow, getFollows, isFollowing, saveFollow } from './follows';
export {
saveIdentity,
saveWalletIdentity,
getIdentity,
deleteIdentity,
getAllIdentities,
getIdentitiesByProtocol,
getIdentity,
getPrimaryIdentity,
saveIdentity,
saveWalletIdentity,
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 { addBookmark, removeBookmark, isBookmarked, getBookmarks } from './bookmarks';
export { getPreferences, savePreferences, getDefaultPreferences } from './preferences';
export {
getDefaultMonetizationPreferences,
getMonetizationPreferences,
saveMonetizationPreferences,
getDefaultMonetizationPreferences,
} from './monetization';
export {
saveSubscription,
getSubscription,
isSubscriptionActive,
getTier,
isPremium,
isCreator,
getMutedIdentities,
getMutedWords,
muteIdentity,
muteWord,
unmuteIdentity,
unmuteWord,
} from './muting';
export { getDefaultPreferences, getPreferences, savePreferences } from './preferences';
export type { ActiveSession } from './session';
export {
clearActiveSession,
clearAllSessions,
getActiveSession,
setActiveSession,
} from './session';
export {
buildSubscriptionRecord,
getSubscription,
getTier,
isCreator,
isPremium,
isSubscriptionActive,
saveSubscription,
} from './subscription';
export type {
MonetizationPreferences,
StoredCachedPost,
StoredDraft,
StoredFollow,
StoredIdentity,
StoredMutedIdentity,
StoredMutedWord,
SubscriptionRecord,
SubscriptionTier,
UserPreferences,
} from './types';

View file

@ -45,4 +45,4 @@ export class ThreadsClient {
}
}
export { BskyClient, BSKY_PUBLIC_URL, BSKY_SOCIAL_URL };
export { BSKY_PUBLIC_URL, BSKY_SOCIAL_URL, BskyClient };

View file

@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
describe('@degenfeed/types', () => {
it('should support all 7 protocol identifiers', () => {

View file

@ -79,6 +79,20 @@ export interface EngagementVector {
raw: EngagementMetrics;
}
/** Per-post ranking score breakdown exposed to the UI. */
export interface PostScore {
total: number;
recency: number;
engagement: number;
relevance: number;
quality: number;
diversity: number;
source: number;
velocity: number;
/** Human-readable signals ("fresh", "viral", "on-topic", etc.) */
reasons: string[];
}
export interface UnifiedPost {
/** Canonical id: `${protocol}:${nativeId}` */
id: string;
@ -102,6 +116,8 @@ export interface UnifiedPost {
raw: unknown;
/** Niche tags derived from content (crypto, ai, oss, security, art, music) */
niches: string[];
/** Optional ranking breakdown for transparency / hover signals. */
score?: PostScore;
/** Optional paywall requirement for paid content */
paywall?: { amount: string; recipient: string };
}

View file

@ -14,6 +14,7 @@
"test": "vitest run"
},
"dependencies": {
"@degenfeed/storage": "workspace:*",
"@degenfeed/types": "workspace:*",
"react": "^19.0.0",
"react-dom": "^19.0.0"

View file

@ -19,7 +19,8 @@
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { getProtocolColor } from './ProtocolBadge';
import { ComposePreview } from './ComposePreview';
import { getProtocolColor, getProtocolIcon } from './ProtocolBadge';
interface ComposeTarget {
id: string;
@ -51,12 +52,33 @@ export interface ComposeModalProps {
text: string,
targets: string[],
) => Promise<Record<string, { ok: boolean; error?: string }>>;
/** Called when a disconnected protocol is clicked. */
onRequestSignIn?: (protocol: string) => void;
/** Primary identity used for publish preview. */
primaryIdentity?: {
displayName?: string;
handle?: string;
avatarUrl?: string;
};
/** Public key of the user's Nostr identity, if any. Used to enable the bridge toggle. */
nostrBridgePublicId?: string;
/** Called when the bridge toggle fires. Receives the trimmed post text. */
onNostrBridge?: (text: string) => void | Promise<void>;
}
const DRAFT_KEY = 'degenfeed_compose_draft';
const MAX_CHARS = 50000;
export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: ComposeModalProps) {
export function ComposeModal({
open,
onClose,
signedInProtocols,
onPublish,
onRequestSignIn,
primaryIdentity,
nostrBridgePublicId,
onNostrBridge,
}: ComposeModalProps) {
const [text, setText] = useState('');
const [targets, setTargets] = useState<ComposeTarget[]>(() =>
TARGETS.map((t) => ({
@ -69,6 +91,7 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
const [publishing, setPublishing] = useState(false);
const publishingRef = useRef(false);
const [showSlashMenu, setShowSlashMenu] = useState(false);
const [bridgeToNostr, setBridgeToNostr] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Load draft from localStorage
@ -126,11 +149,21 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
[text],
);
const toggleTarget = useCallback((id: string) => {
setTargets((prev) =>
prev.map((t) => (t.id === id && t.connected ? { ...t, enabled: !t.enabled } : t)),
);
}, []);
const toggleTarget = useCallback(
(id: string) => {
setTargets((prev) =>
prev.map((t) => {
if (t.id !== id) return t;
if (!t.connected) {
onRequestSignIn?.(id);
return t;
}
return { ...t, enabled: !t.enabled };
}),
);
},
[onRequestSignIn],
);
const handlePublish = useCallback(async () => {
if (!text.trim() || publishingRef.current) return;
@ -156,6 +189,19 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
);
const allOk = selectedTargets.every((id) => results[id]?.ok);
// Mastodon -> Nostr bridge: fire locally-signed Nostr note in parallel
if (
bridgeToNostr &&
nostrBridgePublicId &&
selectedTargets.includes('mastodon') &&
results.mastodon?.ok !== false
) {
try {
await onNostrBridge?.(text);
} catch {
/* bridge failure is non-fatal — main post already went out */
}
}
if (allOk) {
// Clear draft on success
try {
@ -175,7 +221,7 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
publishingRef.current = false;
setPublishing(false);
}
}, [text, targets, onPublish, onClose]);
}, [text, targets, onPublish, onClose, bridgeToNostr, nostrBridgePublicId, onNostrBridge]);
// Keyboard shortcut
useEffect(() => {
@ -203,12 +249,13 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
onKeyDown={(e) => e.key === 'Escape' && onClose()}
aria-modal="true"
aria-label="Compose post"
role="dialog"
>
<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"
role="dialog"
style={{ maxHeight: '90vh' }}
>
{/* Header */}
@ -292,46 +339,96 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
{hasSignedIn && (
<div className="border-t border-gray-800 px-4 py-3">
<div className="mb-3 flex flex-wrap gap-2">
{targets.map((target) => (
<button
key={target.id}
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
? 'border-current opacity-100'
: 'border-gray-800 opacity-40 hover:opacity-70'
}`}
style={{
color: target.connected ? getProtocolColor(target.id) : '#666',
backgroundColor: target.enabled
? `${getProtocolColor(target.id)}15`
: 'transparent',
}}
title={
!target.connected
? `Not connected — sign in with ${target.label}`
: `${target.label} (${text.length}/${target.charLimit})`
}
>
{target.status === 'sending' && '\u23F3'}
{target.status === 'sent' && '\u2713'}
{target.status === 'failed' && '\u2717'}
{target.label}
<span className="text-[0.65rem] opacity-60">
{text.length}/{target.charLimit}
</span>
</button>
))}
{targets.map((target) => {
const overLimit = target.enabled && text.length > target.charLimit;
return (
<button
key={target.id}
type="button"
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
? 'border-current opacity-100'
: 'border-gray-800 opacity-40 hover:opacity-70'
}`}
style={{
color: target.connected
? overLimit
? '#f87171'
: getProtocolColor(target.id)
: '#666',
backgroundColor: target.enabled
? `${getProtocolColor(target.id)}15`
: 'transparent',
}}
title={
!target.connected
? `Not connected — sign in with ${target.label}`
: `${target.label} (${text.length}/${target.charLimit})`
}
>
{target.status === 'sending' && '\u23F3'}
{target.status === 'sent' && '\u2713'}
{target.status === 'failed' && '\u2717'}
{!target.connected ? (
<svg
width="10"
height="10"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path
d="M8 1C5.5 1 3.5 3 3.5 5.5v2C3 8 2 8.5 2 10v3h12v-3c0-1.5-1-2-1.5-2.5v-2C12.5 3 10.5 1 8 1zM8 13.5c-1 0-1.8-.8-1.8-1.8h3.6c0 1-.8 1.8-1.8 1.8z"
fill="currentColor"
/>
</svg>
) : (
<span className="text-[0.7em]">{getProtocolIcon(target.id)}</span>
)}
{target.label}
<span className="text-[0.65rem] opacity-60">
{text.length}/{target.charLimit}
</span>
</button>
);
})}
</div>
{targets.find((t) => t.id === 'mastodon')?.enabled && nostrBridgePublicId && (
<label className="mb-3 flex items-center gap-2 text-xs text-gray-400">
<input
type="checkbox"
checked={bridgeToNostr}
onChange={(e) => setBridgeToNostr(e.target.checked)}
className="h-3 w-3 rounded border-white/20 bg-gray-900 text-degen-500 focus:ring-degen-500"
/>
<span>
Also cross-post to <span className="font-medium text-white">Nostr</span>
</span>
<span className="text-[10px] text-gray-600">
(signed locally with your Nostr key)
</span>
</label>
)}
<ComposePreview
text={text}
targets={targets.map((t) => ({
id: t.id,
label: t.label,
charLimit: t.charLimit,
enabled: t.enabled,
}))}
author={text.trim() ? primaryIdentity : undefined}
/>
{/* Publish button */}
<div className="flex items-center justify-between">
<div className="mt-3 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))
targets.some((t) => t.enabled && text.length > t.charLimit)
? 'text-red-400'
: 'text-gray-500'
}`}
@ -346,7 +443,11 @@ export function ComposeModal({ open, onClose, signedInProtocols, onPublish }: Co
type="button"
className="btn-primary text-sm disabled:opacity-50"
disabled={
!text.trim() || publishing || selectedCount === 0 || text.length > MAX_CHARS
!text.trim() ||
publishing ||
selectedCount === 0 ||
text.length > MAX_CHARS ||
targets.some((t) => t.enabled && text.length > t.charLimit)
}
onClick={handlePublish}
>

View file

@ -0,0 +1,93 @@
import { getProtocolColor, getProtocolIcon, getProtocolLabel } from './ProtocolBadge';
export interface ComposePreviewTarget {
id: string;
enabled: boolean;
charLimit: number;
}
export interface ComposePreviewAuthor {
displayName?: string;
handle?: string;
avatarUrl?: string;
}
export interface ComposePreviewProps {
text: string;
targets: ComposePreviewTarget[];
author?: ComposePreviewAuthor;
}
export function ComposePreview({ text, targets, author }: ComposePreviewProps) {
const trimmed = text.trim();
if (!trimmed && targets.every((t) => !t.enabled)) return null;
const active = targets.filter((t) => t.enabled);
const hasPreview = trimmed.length > 0;
return (
<div className="mt-3 rounded-xl border border-gray-800 bg-gray-900/50 p-3">
{hasPreview && (
<div className="mb-3 flex gap-3">
<div className="h-10 w-10 flex-shrink-0 overflow-hidden rounded-full bg-gray-800">
{author?.avatarUrl ? (
<img
src={author.avatarUrl}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-sm text-gray-500">
?
</span>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-semibold text-white">
{author?.displayName || 'You'}
</span>
{author?.handle && (
<span className="truncate text-sm text-gray-500">@{author.handle}</span>
)}
<span className="text-xs text-gray-600">· now</span>
</div>
<p className="mt-1 whitespace-pre-wrap text-sm text-gray-200">{trimmed}</p>
</div>
</div>
)}
{active.length > 0 && (
<div className="flex flex-wrap gap-2">
{active.map((target) => {
const color = getProtocolColor(target.id);
const label = getProtocolLabel(target.id);
const icon = getProtocolIcon(target.id);
const overLimit = trimmed.length > target.charLimit;
return (
<div
key={target.id}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
overLimit ? 'text-red-400' : ''
}`}
style={{
backgroundColor: overLimit ? 'rgba(248,113,113,0.12)' : `${color}18`,
}}
title={overLimit ? `${label} limit is ${target.charLimit} characters` : label}
>
<span className="text-[0.7em]" style={{ color: overLimit ? undefined : color }}>
{icon}
</span>
<span>{label}</span>
<span className="opacity-70">
{trimmed.length}/{target.charLimit}
</span>
</div>
);
})}
</div>
)}
</div>
);
}

View file

@ -1,19 +1,18 @@
/**
* @degenfeed/ui FeedList
*
* Scrollable list of PostCards with loading, empty, and error states.
* Supports infinite scroll, pull-to-refresh, protocol filtering, and
* virtualized rendering (only visible posts are in the DOM).
* Renders a flat, scrollable list of PostCards. The previous implementation
* tried to virtualize a fixed-CARD_HEIGHT layout, which clipped real content
* because PostCard heights vary widely. Real scrolling + JSX lists perform
* fine for the 50100 posts we load per page, so we render them all.
*
* States:
* loading Skeleton cards (animated pulses)
* loaded PostCard list (virtualized)
* empty Illustration + message + CTA
* error Error message + retry button
* States: loading (skeleton cards), loaded (real cards), empty (CTA), error
* (retry). `hasMore` + `onLoadMore` enables future infinite scroll without
* forcing virtualization today.
*/
import type { UnifiedPost } from '@degenfeed/types';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { type EngageHandler, PostCard } from './PostCard';
export type { UnifiedPost };
@ -34,55 +33,74 @@ export interface FeedListProps {
onLoadMore?: () => void;
}
const CARD_HEIGHT = 200;
const OVERSCAN = 4;
function SkeletonCard() {
return (
<div className="card animate-pulse" style={{ height: CARD_HEIGHT }}>
<div className="border-b border-white/5 px-4 py-3">
<div className="mb-3 flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-gray-800" />
<div className="h-10 w-10 animate-pulse rounded-full bg-gray-800" />
<div className="flex-1">
<div className="mb-1 h-3 w-32 rounded bg-gray-800" />
<div className="h-2 w-20 rounded bg-gray-800" />
<div className="mb-1.5 h-3 w-32 animate-pulse rounded bg-gray-800" />
<div className="h-2.5 w-20 animate-pulse rounded bg-gray-800" />
</div>
</div>
<div className="mb-2 space-y-1.5">
<div className="h-3 w-full rounded bg-gray-800" />
<div className="h-3 w-4/5 rounded bg-gray-800" />
<div className="h-3 w-3/5 rounded bg-gray-800" />
<div className="mb-2 space-y-2">
<div className="h-3 w-full animate-pulse rounded bg-gray-800" />
<div className="h-3 w-11/12 animate-pulse rounded bg-gray-800" />
<div className="h-3 w-3/4 animate-pulse rounded bg-gray-800" />
</div>
<div className="flex gap-4">
<div className="h-3 w-12 rounded bg-gray-800" />
<div className="h-3 w-12 rounded bg-gray-800" />
<div className="h-3 w-12 rounded bg-gray-800" />
<div className="h-3 w-12 animate-pulse rounded bg-gray-800" />
<div className="h-3 w-12 animate-pulse rounded bg-gray-800" />
<div className="h-3 w-12 animate-pulse rounded bg-gray-800" />
</div>
</div>
);
}
function EmptyState({ protocolLabel }: { protocolLabel?: string }) {
function EmptyState({
protocolLabel,
onSignIn,
}: {
protocolLabel?: string;
onSignIn?: () => void;
}) {
return (
<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'}
</p>
<p className="text-sm text-gray-600">
Posts will appear here as they are published across connected networks.
<div className="border-b border-white/5 px-4 py-16 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-degen-500/10">
<span className="text-3xl text-degen-400">{protocolLabel ? '○' : '◆'}</span>
</div>
<h3 className="mb-2 text-lg font-semibold text-white">
{protocolLabel ? `No ${protocolLabel} posts yet` : 'The feed is warming up'}
</h3>
<p className="mx-auto mb-6 max-w-sm text-sm text-gray-500">
{protocolLabel
? 'This network is quiet right now. Try another tab or sign in to compose.'
: 'Pulling the first posts from every network. This usually takes a few seconds.'}
</p>
{onSignIn && (
<button type="button" onClick={onSignIn} className="btn-primary text-sm">
Sign in to compose
</button>
)}
</div>
);
}
function ErrorState({ message, onRetry }: { message?: string; onRetry?: () => void }) {
return (
<div className="card border-red-900/50 bg-red-900/10 py-12 text-center">
<div className="mb-4 text-4xl text-red-400">!</div>
<p className="mb-2 text-red-300">{message || 'Failed to load feed'}</p>
<div className="border-b border-white/5 px-4 py-12 text-center">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-500/10">
<span className="text-2xl text-red-400">!</span>
</div>
<p className="mb-1 text-sm font-medium text-red-300">
{message || 'Could not load the feed'}
</p>
<p className="mb-5 text-xs text-gray-600">
The worker may be cold-starting or a source provider is down.
</p>
{onRetry && (
<button type="button" className="btn-primary text-sm" onClick={onRetry}>
Retry
<button type="button" onClick={onRetry} className="btn-primary text-sm">
Try again
</button>
)}
</div>
@ -100,13 +118,10 @@ export function FeedList({
onEngage,
hasMore,
onLoadMore,
}: FeedListProps) {
const sentinelRef = useRef<HTMLLIElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [containerHeight, setContainerHeight] = useState(800);
onSignIn,
}: FeedListProps & { onSignIn?: () => void }) {
const sentinelRef = useRef<HTMLDivElement>(null);
// Infinite scroll observer
useEffect(() => {
if (!hasMore || !onLoadMore) return;
const sentinel = sentinelRef.current;
@ -115,32 +130,15 @@ export function FeedList({
(entries) => {
if (entries[0]?.isIntersecting) onLoadMore();
},
{ rootMargin: '200px' },
{ rootMargin: '400px' },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasMore, onLoadMore]);
// Track scroll position and container height for virtualization
const handleScroll = useCallback(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop);
setContainerHeight(containerRef.current.clientHeight);
}
}, []);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
setContainerHeight(el.clientHeight);
el.addEventListener('scroll', handleScroll, { passive: true });
return () => el.removeEventListener('scroll', handleScroll);
}, [handleScroll]);
// Loading state
if (state === 'loading') {
return (
<output className="space-y-4" aria-label="Loading feed">
<output className="block" aria-label="Loading feed">
{[1, 2, 3, 4, 5].map((i) => (
<SkeletonCard key={i} />
))}
@ -148,46 +146,36 @@ export function FeedList({
);
}
// Error state
if (state === 'error') {
return <ErrorState message={error} onRetry={onRetry} />;
}
// Empty state
if (state === 'empty' || posts.length === 0) {
return <EmptyState protocolLabel={protocolLabel} />;
return <EmptyState protocolLabel={protocolLabel} onSignIn={onSignIn} />;
}
// Virtualized rendering
const totalHeight = posts.length * CARD_HEIGHT;
const startIndex = Math.max(0, Math.floor(scrollTop / CARD_HEIGHT) - OVERSCAN);
const endIndex = Math.min(
posts.length,
Math.ceil((scrollTop + containerHeight) / CARD_HEIGHT) + OVERSCAN,
);
const visiblePosts = posts.slice(startIndex, endIndex);
const offsetY = startIndex * CARD_HEIGHT;
return (
<div
ref={containerRef}
className="overflow-y-auto"
style={{ height: '100%', maxHeight: 'calc(100vh - 200px)' }}
aria-label={protocolLabel ? `${protocolLabel} feed` : 'Feed'}
>
<ul className="relative list-none" style={{ height: totalHeight }}>
<li style={{ height: offsetY }} aria-hidden="true" />
{visiblePosts.map((post) => (
<li key={post.id} style={{ height: CARD_HEIGHT }}>
<PostCard post={post} onClick={onPostClick} onTip={onTip} onEngage={onEngage} />
</li>
))}
{hasMore && (
<li ref={sentinelRef} className="py-4 text-center text-sm text-gray-600">
Loading more...
</li>
)}
</ul>
<div role="feed" aria-label={protocolLabel ? `${protocolLabel} feed` : 'Feed'}>
{posts.map((post) => (
<PostCard
key={post.id}
post={post}
onClick={onPostClick}
onTip={onTip}
onEngage={onEngage}
/>
))}
{hasMore && (
<div
ref={sentinelRef}
className="border-b border-white/5 px-4 py-6 text-center text-sm text-gray-500"
>
<span className="inline-flex items-center gap-2">
<span className="h-2 w-2 animate-pulse rounded-full bg-degen-500" />
Loading more
</span>
</div>
)}
</div>
);
}

View file

@ -38,12 +38,13 @@ export function PaywallModal({ open, onClose, requirement, onVerify }: PaywallMo
onKeyDown={(e) => e.key === 'Escape' && onClose()}
aria-modal="true"
aria-label="Paywall"
role="dialog"
>
<div
className="mx-4 w-full max-w-sm rounded-2xl border border-gray-800 bg-gray-950 p-6 shadow-2xl"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
role="dialog"
>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold text-white">Unlock content</h2>

View file

@ -1,14 +1,14 @@
import {
deleteFollow,
isAuthorMuted,
getMutedIdentities,
isFollowing,
muteAuthor,
muteIdentity,
saveFollow,
unmuteAuthor,
unmuteIdentity,
} from '@degenfeed/storage';
import type { EngagementAction, UnifiedPost } from '@degenfeed/types';
import type { Embed, EngagementAction, UnifiedPost } from '@degenfeed/types';
import { useEffect, useState } from 'react';
import { ProtocolBadge, getProtocolColor } from './ProtocolBadge';
import { ProtocolBadge } from './ProtocolBadge';
export type { UnifiedPost };
@ -52,6 +52,99 @@ function formatCount(n: number): string {
return String(n);
}
function buildScoreTooltip(score: NonNullable<UnifiedPost['score']>): string {
const lines = [
`Score: ${score.total.toFixed(2)}`,
` recency: ${score.recency.toFixed(2)}`,
` engagement: ${score.engagement.toFixed(2)}`,
` relevance: ${score.relevance.toFixed(2)}`,
` quality: ${score.quality.toFixed(2)}`,
` source: ${score.source.toFixed(2)}`,
` velocity: ${score.velocity.toFixed(2)}`,
];
if (score.reasons.length > 0) {
lines.push(`Signals: ${score.reasons.join(', ')}`);
}
return lines.join('\n');
}
function LinkPreview({ embed }: { embed: Embed }) {
const meta = embed.meta as { title?: string; description?: string; image?: string } | undefined;
const title = meta?.title;
const description = meta?.description;
const image = meta?.image;
const host = embed.url ? embed.url.replace(/^https?:\/\//, '').split('/')[0] : '';
return (
<a
href={embed.url}
target="_blank"
rel="noopener noreferrer"
className="group mt-2 block overflow-hidden rounded-xl border border-gray-700 bg-gray-800/30 transition-colors hover:border-degen-500/30 hover:bg-gray-800/50"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{image && (
<div className="aspect-video w-full overflow-hidden">
<img
src={image}
alt={title || ''}
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
</div>
)}
<div className="p-3">
{host && <p className="mb-1 text-[11px] text-gray-500 uppercase tracking-wide">{host}</p>}
{title && <p className="mb-1 text-sm font-semibold text-white line-clamp-1">{title}</p>}
{description && <p className="text-xs text-gray-400 line-clamp-2">{description}</p>}
{!title && !description && (
<p className="truncate text-xs text-degen-500">{embed.url.replace(/^https?:\/\//, '')}</p>
)}
</div>
</a>
);
}
function ImageGrid({ images }: { images: string[] }) {
if (images.length === 1) {
return (
<div className="mt-2 max-h-80 overflow-hidden rounded-xl">
<img src={images[0]} alt="" className="h-full w-full object-cover" loading="lazy" />
</div>
);
}
if (images.length === 2) {
return (
<div className="mt-2 grid grid-cols-2 gap-1 overflow-hidden rounded-xl">
{images.map((url) => (
<img
key={url}
src={url}
alt=""
className="aspect-square w-full object-cover"
loading="lazy"
/>
))}
</div>
);
}
return (
<div className="mt-2 grid grid-cols-2 gap-1 overflow-hidden rounded-xl">
{images.slice(0, 4).map((url, idx) => (
<div key={url} className="relative aspect-square">
<img src={url} alt="" className="h-full w-full object-cover" loading="lazy" />
{idx === 3 && images.length > 4 && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 text-sm font-bold text-white">
+{images.length - 4}
</div>
)}
</div>
))}
</div>
);
}
interface LocalState {
engagement: UnifiedPost['engagement'];
metrics: UnifiedPost['metrics'];
@ -116,10 +209,20 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip, onPaywall }
useEffect(() => {
if (typeof window === 'undefined' || !post.author.id) return;
let cancelled = false;
Promise.all([isFollowing(post.author.id), isAuthorMuted(post.author.id)])
.then(([f, m]) => { if (!cancelled) { setFollowing(f); setMuted(m); } })
Promise.all([
isFollowing('self', post.author.id),
getMutedIdentities('self').then((m) => m.some((x) => x.targetId === post.author.id)),
])
.then(([f, m]) => {
if (!cancelled) {
setFollowing(f);
setMuted(m);
}
})
.catch(() => {});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [post.author.id]);
useEffect(() => {
@ -154,9 +257,9 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip, onPaywall }
await saveFollow({
identityId: 'self',
targetId: authorId,
protocol: post.protocol,
targetProtocol: post.protocol,
targetHandle: post.author.handles[post.protocol] ?? post.author.displayName,
createdAt: Date.now(),
followedAt: Date.now(),
});
} else {
await deleteFollow('self', authorId);
@ -174,9 +277,9 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip, onPaywall }
setMuted(next);
try {
if (next) {
await muteAuthor({ id: authorId, displayName: post.author.displayName, protocol: post.protocol });
await muteIdentity(authorId, post.author.displayName, 'self');
} else {
await unmuteAuthor(authorId);
await unmuteIdentity(authorId, 'self');
}
} catch {
setMuted(!next);
@ -185,8 +288,7 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip, onPaywall }
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) }}
className="cursor-pointer border-b border-white/5 px-4 py-3 transition-colors hover:bg-white/[0.03]"
onClick={() => onClick?.(post.id)}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && onClick) {
@ -251,6 +353,17 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip, onPaywall }
>
{formatTime(post.createdAt)}
</time>
{post.score && (
<>
<span>·</span>
<span
className="inline-flex items-center gap-1 rounded-full bg-degen-500/10 px-1.5 py-0.5 text-[10px] font-medium text-degen-400"
title={buildScoreTooltip(post.score)}
>
{Math.round(post.score.total * 10) / 10}
</span>
</>
)}
</div>
</div>
</div>
@ -290,32 +403,33 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip, onPaywall }
{/* Embeds */}
{post.embeds.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{post.embeds.slice(0, 4).map((embed) =>
embed.kind === 'image' ? (
<img
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={`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,
)}
<div className="mb-3">
{(() => {
const images = post.embeds.filter((e) => e.kind === 'image').map((e) => e.url);
const links = post.embeds.filter((e) => e.kind === 'link');
const videos = post.embeds.filter((e) => e.kind === 'video');
return (
<>
{images.length > 0 && <ImageGrid images={images} />}
{links.map((embed) => (
<LinkPreview key={`link-${embed.url}`} embed={embed} />
))}
{videos.map((embed) => (
<a
key={`video-${embed.url}`}
href={embed.url}
target="_blank"
rel="noopener noreferrer"
className="mt-2 block w-full rounded-lg border border-gray-700 bg-gray-800/50 p-3 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>
))}
</>
);
})()}
</div>
)}
@ -376,7 +490,7 @@ export function PostCard({ post, onEngage, onClick, expanded, onTip, onPaywall }
}}
aria-label={localState.engagement.viewerBookmarked ? 'Bookmarked' : 'Bookmark'}
>
{localState.engagement.viewerBookmarked ? '🔖' : '🔖'}
{localState.engagement.viewerBookmarked ? '🔖' : '📑'}
</button>
<button
type="button"

View file

@ -21,17 +21,80 @@ export interface SignInModalProps {
}
const PROTOCOLS: ProtocolOption[] = [
{ id: 'nostr', label: 'Nostr', color: '#8e30eb', icon: '☰', description: 'npub/nsec key or browser extension', method: 'key' },
{ id: 'farcaster', label: 'Farcaster', color: '#8a63d2', icon: '◈', description: 'FID + signer key (or Warpcast)', method: 'key' },
{ id: 'lens', label: 'Lens', color: '#abfe2c', icon: '◎', description: 'Connect wallet (MetaMask, Rainbow)', method: 'extension' },
{ id: 'bluesky', label: 'Bluesky', color: '#1185fe', icon: '☁', description: 'Handle + app password', method: 'password' },
{ id: 'threads', label: 'Threads', color: '#888888', icon: '⊞', description: 'Handle + AT Protocol app password', method: 'password' },
{ id: 'mastodon', label: 'Mastodon', color: '#6364ff', icon: '🐘', description: 'Any Mastodon/GotoSocial server', method: 'oauth', authUrl: '/api/auth/gotosocial/authorize' },
{ id: 'ethereum', label: 'Ethereum', color: '#627eea', icon: '◆', description: 'Sign in with Ethereum wallet', method: 'wallet' },
{ id: 'solana', label: 'Solana', color: '#9945ff', icon: '◎', description: 'Sign in with Solana wallet', method: 'wallet' },
{
id: 'nostr',
label: 'Nostr',
color: '#8e30eb',
icon: '☰',
description: 'npub/nsec key or browser extension',
method: 'key',
},
{
id: 'farcaster',
label: 'Farcaster',
color: '#8a63d2',
icon: '◈',
description: 'FID + signer key (or Warpcast)',
method: 'key',
},
{
id: 'lens',
label: 'Lens',
color: '#abfe2c',
icon: '◎',
description: 'Connect wallet (MetaMask, Rainbow)',
method: 'extension',
},
{
id: 'bluesky',
label: 'Bluesky',
color: '#1185fe',
icon: '☁',
description: 'Handle + app password',
method: 'password',
},
{
id: 'threads',
label: 'Threads',
color: '#888888',
icon: '⊞',
description: 'Handle + AT Protocol app password',
method: 'password',
},
{
id: 'mastodon',
label: 'Mastodon',
color: '#6364ff',
icon: '🐘',
description: 'Any Mastodon/GotoSocial server',
method: 'oauth',
authUrl: '/api/auth/gotosocial/authorize',
},
{
id: 'ethereum',
label: 'Ethereum',
color: '#627eea',
icon: '◆',
description: 'Sign in with Ethereum wallet',
method: 'wallet',
},
{
id: 'solana',
label: 'Solana',
color: '#9945ff',
icon: '◎',
description: 'Sign in with Solana wallet',
method: 'wallet',
},
];
function NostrForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, string>) => void; onBack: () => void }) {
function NostrForm({
onSignIn,
onBack,
}: {
onSignIn: (creds: Record<string, string>) => void;
onBack: () => void;
}) {
const [mode, setMode] = useState<'choice' | 'nsec' | 'extension'>('choice');
const [nsec, setNsec] = useState('');
const [error, setError] = useState('');
@ -47,7 +110,9 @@ function NostrForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, stri
<span className="text-lg">🔑</span>
<div>
<div className="text-sm font-semibold text-white">Paste nsec / NIP-07</div>
<div className="text-xs text-gray-500">Enter your secret key or use a browser extension</div>
<div className="text-xs text-gray-500">
Enter your secret key or use a browser extension
</div>
</div>
</button>
<button
@ -61,13 +126,20 @@ function NostrForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, stri
<div className="text-xs text-gray-500">Alby, nos2x, or any NIP-07 signer</div>
</div>
</button>
<button type="button" onClick={onBack} className="w-full text-center text-xs text-gray-500 hover:text-gray-300"> Back</button>
<button
type="button"
onClick={onBack}
className="w-full text-center text-xs text-gray-500 hover:text-gray-300"
>
Back
</button>
</div>
);
}
if (mode === 'extension') {
const hasNip07 = typeof window !== 'undefined' && !!(window as unknown as { nostr?: unknown }).nostr;
const hasNip07 =
typeof window !== 'undefined' && !!(window as unknown as { nostr?: unknown }).nostr;
if (hasNip07) {
onSignIn({ method: 'extension' });
return null;
@ -75,9 +147,33 @@ function NostrForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, stri
return (
<div className="space-y-3">
<p className="text-sm text-gray-400">
No NIP-07 extension detected. Install <a href="https://getalby.com" target="_blank" rel="noopener" className="text-degen-400 underline">Alby</a> or <a href="https://github.com/fiatjaf/nos2x" target="_blank" rel="noopener" className="text-degen-400 underline">nos2x</a>.
No NIP-07 extension detected. Install{' '}
<a
href="https://getalby.com"
target="_blank"
rel="noopener"
className="text-degen-400 underline"
>
Alby
</a>{' '}
or{' '}
<a
href="https://github.com/fiatjaf/nos2x"
target="_blank"
rel="noopener"
className="text-degen-400 underline"
>
nos2x
</a>
.
</p>
<button type="button" onClick={() => setMode('choice')} className="w-full text-center text-xs text-gray-500 hover:text-gray-300"> Back</button>
<button
type="button"
onClick={() => setMode('choice')}
className="w-full text-center text-xs text-gray-500 hover:text-gray-300"
>
Back
</button>
</div>
);
}
@ -89,10 +185,12 @@ function NostrForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, stri
<input
type="password"
value={nsec}
onChange={(e) => { setNsec(e.target.value); setError(''); }}
onChange={(e) => {
setNsec(e.target.value);
setError('');
}}
placeholder="nsec1..."
className="input w-full font-mono text-xs"
autoFocus
/>
</label>
<p className="text-[10px] text-gray-600">
@ -100,12 +198,24 @@ function NostrForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, stri
</p>
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={() => setMode('choice')} className="rounded-lg bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white">Back</button>
<button
type="button"
onClick={() => setMode('choice')}
className="rounded-lg bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white"
>
Back
</button>
<button
type="button"
onClick={() => {
if (!nsec.trim()) { setError('Please enter your nsec'); return; }
if (!nsec.startsWith('nsec1') && nsec.length !== 64) { setError('Invalid nsec format'); return; }
if (!nsec.trim()) {
setError('Please enter your nsec');
return;
}
if (!nsec.startsWith('nsec1') && nsec.length !== 64) {
setError('Invalid nsec format');
return;
}
onSignIn({ nsec: nsec.trim(), method: 'nsec' });
}}
className="btn-primary flex-1"
@ -117,7 +227,13 @@ function NostrForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, stri
);
}
function FarcasterForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, string>) => void; onBack: () => void }) {
function FarcasterForm({
onSignIn,
onBack,
}: {
onSignIn: (creds: Record<string, string>) => void;
onBack: () => void;
}) {
const [mode, setMode] = useState<'choice' | 'form'>('choice');
const [fid, setFid] = useState('');
const [username, setUsername] = useState('');
@ -149,7 +265,13 @@ function FarcasterForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string,
<div className="text-xs text-gray-500">Use your Warpcast account via OAuth</div>
</div>
</button>
<button type="button" onClick={onBack} className="w-full text-center text-xs text-gray-500 hover:text-gray-300"> Back</button>
<button
type="button"
onClick={onBack}
className="w-full text-center text-xs text-gray-500 hover:text-gray-300"
>
Back
</button>
</div>
);
}
@ -158,24 +280,56 @@ function FarcasterForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string,
<div className="space-y-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-400">Username</span>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="alice" className="input w-full" autoFocus />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="alice"
className="input w-full"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-400">FID (numeric)</span>
<input type="text" value={fid} onChange={(e) => setFid(e.target.value)} placeholder="12345" className="input w-full" inputMode="numeric" />
<input
type="text"
value={fid}
onChange={(e) => setFid(e.target.value)}
placeholder="12345"
className="input w-full"
inputMode="numeric"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-400">Signer private key (hex)</span>
<input type="password" value={signerKey} onChange={(e) => setSignerKey(e.target.value)} placeholder="0x... or hex" className="input w-full font-mono text-xs" />
<span className="mb-1 block text-xs font-medium text-gray-400">
Signer private key (hex)
</span>
<input
type="password"
value={signerKey}
onChange={(e) => setSignerKey(e.target.value)}
placeholder="0x... or hex"
className="input w-full font-mono text-xs"
/>
</label>
<p className="text-[10px] text-gray-600">Signer key is used to sign casts. Stored encrypted in your browser.</p>
<p className="text-[10px] text-gray-600">
Signer key is used to sign casts. Stored encrypted in your browser.
</p>
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={() => setMode('choice')} className="rounded-lg bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white">Back</button>
<button
type="button"
onClick={() => setMode('choice')}
className="rounded-lg bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white"
>
Back
</button>
<button
type="button"
onClick={() => {
if (!username.trim() || !fid.trim() || !signerKey.trim()) { setError('All fields required'); return; }
if (!username.trim() || !fid.trim() || !signerKey.trim()) {
setError('All fields required');
return;
}
onSignIn({ username: username.trim(), fid: fid.trim(), signerKey: signerKey.trim() });
}}
className="btn-primary flex-1"
@ -187,7 +341,13 @@ function FarcasterForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string,
);
}
function BlueskyForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, string>) => void; onBack: () => void }) {
function BlueskyForm({
onSignIn,
onBack,
}: {
onSignIn: (creds: Record<string, string>) => void;
onBack: () => void;
}) {
const [handle, setHandle] = useState('');
const [appPassword, setAppPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
@ -197,7 +357,13 @@ function BlueskyForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, st
<div className="space-y-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-400">Handle</span>
<input type="text" value={handle} onChange={(e) => setHandle(e.target.value)} placeholder="alice.bsky.social" className="input w-full" autoFocus />
<input
type="text"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="alice.bsky.social"
className="input w-full"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-400">App Password</span>
@ -217,17 +383,31 @@ function BlueskyForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, st
{showPassword ? 'Hide' : 'Show'}
</button>
</div>
<a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener" className="text-[10px] text-degen-400 hover:underline">
<a
href="https://bsky.app/settings/app-passwords"
target="_blank"
rel="noopener"
className="text-[10px] text-degen-400 hover:underline"
>
Create one at bsky.app/settings/app-passwords
</a>
</label>
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={onBack} className="rounded-lg bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white">Back</button>
<button
type="button"
onClick={onBack}
className="rounded-lg bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white"
>
Back
</button>
<button
type="button"
onClick={() => {
if (!handle.trim() || !appPassword.trim()) { setError('All fields required'); return; }
if (!handle.trim() || !appPassword.trim()) {
setError('All fields required');
return;
}
onSignIn({ handle: handle.trim(), appPassword: appPassword.trim() });
}}
className="btn-primary flex-1"
@ -239,7 +419,13 @@ function BlueskyForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, st
);
}
function ThreadsForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, string>) => void; onBack: () => void }) {
function ThreadsForm({
onSignIn,
onBack,
}: {
onSignIn: (creds: Record<string, string>) => void;
onBack: () => void;
}) {
const [handle, setHandle] = useState('');
const [appPassword, setAppPassword] = useState('');
const [error, setError] = useState('');
@ -253,19 +439,40 @@ function ThreadsForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, st
</div>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-400">Handle</span>
<input type="text" value={handle} onChange={(e) => setHandle(e.target.value)} placeholder="alice.bsky.social" className="input w-full" autoFocus />
<input
type="text"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="alice.bsky.social"
className="input w-full"
/>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-400">App Password</span>
<input type="password" value={appPassword} onChange={(e) => setAppPassword(e.target.value)} placeholder="xxxx-xxxx-xxxx-xxxx" className="input w-full font-mono text-xs" />
<input
type="password"
value={appPassword}
onChange={(e) => setAppPassword(e.target.value)}
placeholder="xxxx-xxxx-xxxx-xxxx"
className="input w-full font-mono text-xs"
/>
</label>
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={onBack} className="rounded-lg bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white">Back</button>
<button
type="button"
onClick={onBack}
className="rounded-lg bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white"
>
Back
</button>
<button
type="button"
onClick={() => {
if (!handle.trim() || !appPassword.trim()) { setError('All fields required'); return; }
if (!handle.trim() || !appPassword.trim()) {
setError('All fields required');
return;
}
onSignIn({ handle: handle.trim(), appPassword: appPassword.trim() });
}}
className="btn-primary flex-1"
@ -277,7 +484,13 @@ function ThreadsForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, st
);
}
function LensForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, string>) => void; onBack: () => void }) {
function LensForm({
onSignIn,
onBack,
}: {
onSignIn: (creds: Record<string, string>) => void;
onBack: () => void;
}) {
const [hasWallet, setHasWallet] = useState(false);
if (typeof window !== 'undefined') {
@ -287,7 +500,8 @@ function LensForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, strin
return (
<div className="space-y-3">
<p className="text-sm text-gray-400">
Lens uses your Ethereum wallet to sign in. Make sure MetaMask, Rainbow, or another EVM wallet is installed.
Lens uses your Ethereum wallet to sign in. Make sure MetaMask, Rainbow, or another EVM
wallet is installed.
</p>
<button
type="button"
@ -296,21 +510,39 @@ function LensForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, strin
>
{hasWallet ? 'Sign in with wallet' : 'Connect wallet'}
</button>
<button type="button" onClick={onBack} className="w-full text-center text-xs text-gray-500 hover:text-gray-300"> Back</button>
<button
type="button"
onClick={onBack}
className="w-full text-center text-xs text-gray-500 hover:text-gray-300"
>
Back
</button>
</div>
);
}
function MastodonForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, string>) => void; onBack: () => void }) {
function MastodonForm({
onSignIn,
onBack,
}: {
onSignIn: (creds: Record<string, string>) => void;
onBack: () => void;
}) {
const [instance, setInstance] = useState('https://social.degenfeed.xyz');
const [accessToken, setAccessToken] = useState('');
const [error, setError] = useState('');
const [error, _setError] = useState('');
return (
<div className="space-y-3">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-400">Instance URL</span>
<input type="url" value={instance} onChange={(e) => setInstance(e.target.value)} placeholder="https://mastodon.social" className="input w-full" autoFocus />
<input
type="url"
value={instance}
onChange={(e) => setInstance(e.target.value)}
placeholder="https://mastodon.social"
className="input w-full"
/>
</label>
<div className="text-center text-xs text-gray-600"> or </div>
<a
@ -321,11 +553,23 @@ function MastodonForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, s
</a>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-400">Or paste access token</span>
<input type="password" value={accessToken} onChange={(e) => setAccessToken(e.target.value)} placeholder="paste from /settings/applications" className="input w-full font-mono text-xs" />
<input
type="password"
value={accessToken}
onChange={(e) => setAccessToken(e.target.value)}
placeholder="paste from /settings/applications"
className="input w-full font-mono text-xs"
/>
</label>
{error && <p className="text-xs text-red-400">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={onBack} className="rounded-lg bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white">Back</button>
<button
type="button"
onClick={onBack}
className="rounded-lg bg-gray-800 px-3 py-2 text-xs text-gray-400 hover:text-white"
>
Back
</button>
{accessToken && (
<button
type="button"
@ -340,57 +584,86 @@ function MastodonForm({ onSignIn, onBack }: { onSignIn: (creds: Record<string, s
);
}
function ProtocolForm({ protocol, onSignIn, onBack }: { protocol: ProtocolOption; onSignIn: (creds: Record<string, string>) => void; onBack: () => void }) {
function ProtocolForm({
protocol,
onSignIn,
onBack,
}: {
protocol: ProtocolOption;
onSignIn: (creds: Record<string, string>) => void;
onBack: () => void;
}) {
const props = { onSignIn, onBack };
switch (protocol.id) {
case 'nostr': return <NostrForm {...props} />;
case 'farcaster': return <FarcasterForm {...props} />;
case 'lens': return <LensForm {...props} />;
case 'bluesky': return <BlueskyForm {...props} />;
case 'threads': return <ThreadsForm {...props} />;
case 'mastodon': return <MastodonForm {...props} />;
default: return <p className="text-sm text-gray-500">Unknown protocol</p>;
case 'nostr':
return <NostrForm {...props} />;
case 'farcaster':
return <FarcasterForm {...props} />;
case 'lens':
return <LensForm {...props} />;
case 'bluesky':
return <BlueskyForm {...props} />;
case 'threads':
return <ThreadsForm {...props} />;
case 'mastodon':
return <MastodonForm {...props} />;
default:
return <p className="text-sm text-gray-500">Unknown protocol</p>;
}
}
function WalletSection({ onSignIn, walletAddress, walletSigningIn }: { onSignIn: (p: string) => void; walletAddress?: string; walletSigningIn?: boolean }) {
function WalletSection({
onSignIn,
walletAddress,
walletSigningIn,
}: {
onSignIn: (p: string) => void;
walletAddress?: string;
walletSigningIn?: boolean;
}) {
return (
<div className="space-y-2">
<div className="space-y-3">
<h3 className="text-xs font-medium uppercase tracking-widest text-gray-500">Web3 Wallets</h3>
{walletAddress ? (
<div className="rounded-xl border border-degen-500/30 bg-degen-500/10 px-4 py-3">
<div className="text-xs text-gray-500 mb-1">Connected Wallet</div>
<div className="text-sm font-mono text-degen-400 break-all">{walletAddress}</div>
<div className="mt-2 flex gap-2">
<button
onClick={() => onSignIn('ethereum')}
disabled={walletSigningIn}
className="rounded-lg bg-[#627eea]/20 px-3 py-1.5 text-xs font-medium text-[#627eea] hover:bg-[#627eea]/30 transition-colors disabled:opacity-50"
type="button"
>
{walletSigningIn ? 'Signing...' : 'Sign in with ETH'}
</button>
</div>
</div>
) : (
) : null}
<div className="grid grid-cols-2 gap-3">
<button
onClick={() => onSignIn('ethereum')}
className="flex w-full items-center gap-3 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"
disabled={walletSigningIn}
className="flex items-center justify-center gap-2 rounded-xl border border-[#627eea]/30 bg-[#627eea]/10 px-4 py-3 text-sm font-semibold text-[#627eea] transition-all hover:bg-[#627eea]/20 disabled:opacity-50"
type="button"
>
<span className="text-2xl text-[#627eea]"></span>
<div className="flex-1">
<div className="text-sm font-semibold text-white group-hover:text-degen-400 transition-colors">Connect Wallet</div>
<div className="text-xs text-gray-500">MetaMask, WalletConnect, or any EVM wallet</div>
</div>
<span className="text-xs text-gray-600"></span>
<span className="text-lg"></span>
{walletSigningIn ? 'Signing...' : 'Ethereum'}
</button>
)}
<button
onClick={() => onSignIn('solana')}
disabled={walletSigningIn}
className="flex items-center justify-center gap-2 rounded-xl border border-[#9945ff]/30 bg-[#9945ff]/10 px-4 py-3 text-sm font-semibold text-[#9945ff] transition-all hover:bg-[#9945ff]/20 disabled:opacity-50"
type="button"
>
<span className="text-lg"></span>
{walletSigningIn ? 'Signing...' : 'Solana'}
</button>
</div>
<p className="text-center text-xs text-gray-600">
No emails, no passwords. Sign in with your wallet.
</p>
</div>
);
}
export function SignInModal({ open, onClose, onSignIn, walletAddress, walletSigningIn }: SignInModalProps) {
export function SignInModal({
open,
onClose,
onSignIn,
walletAddress,
walletSigningIn,
}: SignInModalProps) {
const [selectedProtocol, setSelectedProtocol] = useState<ProtocolOption | null>(null);
if (!open) return null;
@ -408,16 +681,25 @@ export function SignInModal({ open, onClose, onSignIn, walletAddress, walletSign
return (
<section
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={() => { setSelectedProtocol(null); onClose(); }}
onKeyDown={(e) => e.key === 'Escape' && (setSelectedProtocol(null), onClose())}
onClick={() => {
setSelectedProtocol(null);
onClose();
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setSelectedProtocol(null);
onClose();
}
}}
aria-modal="true"
aria-label="Sign in to DegenFeed"
role="dialog"
>
<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"
role="dialog"
>
<div className="mb-6 flex items-center justify-between">
<div>
@ -425,48 +707,39 @@ export function SignInModal({ open, onClose, onSignIn, walletAddress, walletSign
{selectedProtocol ? `Sign in with ${selectedProtocol.label}` : 'Sign In'}
</h2>
<p className="text-sm text-gray-500">
{selectedProtocol ? selectedProtocol.description : 'Connect an account to like, reply, and post. Link multiple to cross-post.'}
{selectedProtocol
? selectedProtocol.description
: 'Sign in with your wallet to post, tip, and cross-post across the open social graph.'}
</p>
</div>
<button
onClick={() => { setSelectedProtocol(null); onClose(); }}
onClick={() => {
setSelectedProtocol(null);
onClose();
}}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-800 hover:text-white transition-colors"
aria-label="Close"
type="button"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" role="img" aria-label="Close icon">
<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" />
<path
d="M15 5L5 15M5 5l10 10"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
</div>
{selectedProtocol ? (
<div className="flex items-center gap-3 mb-4 pb-4 border-b border-white/5">
<span className="text-2xl" style={{ color: selectedProtocol.color }}>{selectedProtocol.icon}</span>
<span className="text-sm font-medium" style={{ color: selectedProtocol.color }}>{selectedProtocol.label}</span>
</div>
) : (
<div className="mb-6 space-y-2">
<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)}
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>
<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-xs text-gray-500">{proto.description}</div>
</div>
<span className="text-xs text-gray-600"></span>
</button>
))}
</div>
)}
{selectedProtocol ? (
<ProtocolForm
protocol={selectedProtocol}
@ -482,14 +755,40 @@ export function SignInModal({ open, onClose, onSignIn, walletAddress, walletSign
walletSigningIn={walletSigningIn}
/>
</div>
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-800" />
</div>
<div className="relative flex justify-center">
<span className="bg-gray-950 px-3 text-xs text-gray-600">or</span>
<span className="bg-gray-950 px-3 text-xs text-gray-600">
or link a social protocol
</span>
</div>
</div>
<div className="mb-6 space-y-2">
{PROTOCOLS.filter((p) => p.method !== 'wallet').map((proto) => (
<button
key={proto.id}
onClick={() => handleProtocolClick(proto)}
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>
<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-xs text-gray-500">{proto.description}</div>
</div>
<span className="text-xs text-gray-600"></span>
</button>
))}
</div>
<a
href="/api/auth/gotosocial/register"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-degen-500/30 bg-degen-500/10 px-4 py-3 text-sm font-semibold text-degen-400 transition-all hover:bg-degen-500/20 hover:border-degen-500/50"
@ -498,13 +797,18 @@ export function SignInModal({ open, onClose, onSignIn, walletAddress, walletSign
Create free @degenfeed.xyz account
<span className="text-xs text-degen-600">(30 seconds)</span>
</a>
<p className="mt-2 text-center text-xs text-gray-600">Powered by GotoSocial. No email required.</p>
<p className="mt-2 text-center text-xs text-gray-600">
Powered by GotoSocial. No email required.
</p>
</>
)}
{!selectedProtocol && (
<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>
No account needed to browse.{' '}
<a href="/home" className="text-degen-500 hover:underline">
Start reading now
</a>
</p>
)}
</div>

View file

@ -33,12 +33,12 @@ vi.mock('@degenfeed/storage', () => ({
}));
import {
PROTOCOL_COLORS,
PROTOCOL_ICONS,
PROTOCOL_LABELS,
getProtocolColor,
getProtocolIcon,
getProtocolLabel,
PROTOCOL_COLORS,
PROTOCOL_ICONS,
PROTOCOL_LABELS,
} from './ProtocolBadge';
describe('ProtocolBadge', () => {

View file

@ -1,27 +1,33 @@
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';
export { SignInModal } from './SignInModal';
export { ErrorBoundary } from './ErrorBoundary';
export { NotificationBell } from './NotificationBell';
export { PricePill, usePricesForText } from './PricePill';
export { FundingCard } from './FundingCard';
export { PaywallModal } from './PaywallModal';
export type { Protocol, UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
export type { ComposeModalProps } from './ComposeModal';
export { ComposeModal } from './ComposeModal';
export type {
ComposePreviewAuthor,
ComposePreviewProps,
ComposePreviewTarget,
} from './ComposePreview';
export { ComposePreview } from './ComposePreview';
export { ErrorBoundary } from './ErrorBoundary';
export type { FeedListProps, FeedState } from './FeedList';
export type { EngageHandler, EngageResult, PostCardProps } from './PostCard';
export type { ProtocolBadgeProps } from './ProtocolBadge';
export type { SignInModalProps, ProtocolOption } from './SignInModal';
export type { Notification, NotificationBellProps } from './NotificationBell';
export { FeedList } from './FeedList';
export type { FundingRound } from './FundingCard';
export type { PaywallRequirement, PaywallModalProps } from './PaywallModal';
export type { UnifiedPost, UnifiedIdentity, Protocol } from '@degenfeed/types';
export { FundingCard } from './FundingCard';
export type { Notification, NotificationBellProps } from './NotificationBell';
export { NotificationBell } from './NotificationBell';
export type { PaywallModalProps, PaywallRequirement } from './PaywallModal';
export { PaywallModal } from './PaywallModal';
export type { EngageHandler, EngageResult, PostCardProps } from './PostCard';
export { PostCard } from './PostCard';
export { PricePill, usePricesForText } from './PricePill';
export type { ProtocolBadgeProps } from './ProtocolBadge';
export {
getProtocolColor,
getProtocolIcon,
getProtocolLabel,
PROTOCOL_COLORS,
PROTOCOL_ICONS,
PROTOCOL_LABELS,
ProtocolBadge,
} from './ProtocolBadge';
export type { ProtocolOption, SignInModalProps } from './SignInModal';
export { SignInModal } from './SignInModal';

View file

@ -59,9 +59,23 @@ export function decodeXmlEntities(str: string): string {
// ─── Protocol parsing ────────────────────────────────────────────────
const VALID_PROTOCOLS: Protocol[] = ['nostr', 'farcaster', 'lens', 'bluesky', 'threads', 'rss', 'mastodon', 'reddit', 'ethereum', 'solana'];
const VALID_PROTOCOLS: Protocol[] = [
'nostr',
'farcaster',
'lens',
'bluesky',
'threads',
'rss',
'mastodon',
'reddit',
'ethereum',
'solana',
];
export function parseProtocols(raw: string | null, allowed: Protocol[] = VALID_PROTOCOLS): Protocol[] {
export function parseProtocols(
raw: string | null,
allowed: Protocol[] = VALID_PROTOCOLS,
): Protocol[] {
if (!raw) return allowed;
const valid = new Set<Protocol>(allowed);
return raw
@ -78,7 +92,11 @@ export function rmiUrl(env: { RMI_BACKEND_URL?: string }): string {
// ─── URL building ────────────────────────────────────────────────────
export function buildUrl(base: string, path: string, params?: Record<string, string | undefined>): string {
export function buildUrl(
base: string,
path: string,
params?: Record<string, string | undefined>,
): string {
const url = new URL(path, base);
if (params) {
for (const [k, v] of Object.entries(params)) {

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}

99
pnpm-lock.yaml generated
View file

@ -437,6 +437,9 @@ importers:
packages/ui:
dependencies:
'@degenfeed/storage':
specifier: workspace:*
version: link:../storage
'@degenfeed/types':
specifier: workspace:*
version: link:../types
@ -522,8 +525,8 @@ importers:
version: 1.8.0
devDependencies:
'@biomejs/biome':
specifier: ^1.9.4
version: 1.9.4
specifier: ^2.5.2
version: 2.5.2
'@cloudflare/workers-types':
specifier: ^4.20240501.0
version: 4.20260702.1
@ -617,47 +620,23 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
'@biomejs/biome@1.9.4':
resolution: {integrity: sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/biome@2.5.2':
resolution: {integrity: sha512-VQ3RCqr7JmDIX+w6stWYl+g/3bYofN3q2wDBHUKKc/c7i5QWrFKFBZYCYPWTE6agsUPMIZZe6/CMmVUfUAhkKA==}
engines: {node: '>=14.21.3'}
hasBin: true
'@biomejs/cli-darwin-arm64@1.9.4':
resolution: {integrity: sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-arm64@2.5.2':
resolution: {integrity: sha512-e7P3P7EkwFc/KiX2AHw4YDLIBOMfG9CPCAwy52k5Bp0dfhkozx9hf6wCmIr2QeXy2XeccJ3V/Sg+hDmzYEqxSg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [darwin]
'@biomejs/cli-darwin-x64@1.9.4':
resolution: {integrity: sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-darwin-x64@2.5.2':
resolution: {integrity: sha512-ymzMvjC1Jg0b9K0D26ZdARqFQXs7MocfLC5FOCGfkC0Ss+ACUJkX5364ZM5nT4NLZanHRZNVrZEy+Ibwcvux/g==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [darwin]
'@biomejs/cli-linux-arm64-musl@1.9.4':
resolution: {integrity: sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64-musl@2.5.2':
resolution: {integrity: sha512-w+ANG0ZvTu9IeEg9QnstoOnk6L0fpwJifW6aHR18+cb5Z39bkANItYjAfMrnvce5tmMK+IQ6nPX7/kQFdam5iw==}
engines: {node: '>=14.21.3'}
@ -665,13 +644,6 @@ packages:
os: [linux]
libc: [musl]
'@biomejs/cli-linux-arm64@1.9.4':
resolution: {integrity: sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-arm64@2.5.2':
resolution: {integrity: sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==}
engines: {node: '>=14.21.3'}
@ -679,13 +651,6 @@ packages:
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64-musl@1.9.4':
resolution: {integrity: sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64-musl@2.5.2':
resolution: {integrity: sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==}
engines: {node: '>=14.21.3'}
@ -693,13 +658,6 @@ packages:
os: [linux]
libc: [musl]
'@biomejs/cli-linux-x64@1.9.4':
resolution: {integrity: sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@biomejs/cli-linux-x64@2.5.2':
resolution: {integrity: sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==}
engines: {node: '>=14.21.3'}
@ -707,24 +665,12 @@ packages:
os: [linux]
libc: [glibc]
'@biomejs/cli-win32-arm64@1.9.4':
resolution: {integrity: sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-arm64@2.5.2':
resolution: {integrity: sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==}
engines: {node: '>=14.21.3'}
cpu: [arm64]
os: [win32]
'@biomejs/cli-win32-x64@1.9.4':
resolution: {integrity: sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA==}
engines: {node: '>=14.21.3'}
cpu: [x64]
os: [win32]
'@biomejs/cli-win32-x64@2.5.2':
resolution: {integrity: sha512-4InchVpdVmdkkkgjQqKpgvyu+VPnoF/7RPSw5YATgEVpt2j72wcCAeV5TwaE9ZGJUZWZn7v2CwSAj6CrMJEx8A==}
engines: {node: '>=14.21.3'}
@ -6731,17 +6677,6 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@biomejs/biome@1.9.4':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 1.9.4
'@biomejs/cli-darwin-x64': 1.9.4
'@biomejs/cli-linux-arm64': 1.9.4
'@biomejs/cli-linux-arm64-musl': 1.9.4
'@biomejs/cli-linux-x64': 1.9.4
'@biomejs/cli-linux-x64-musl': 1.9.4
'@biomejs/cli-win32-arm64': 1.9.4
'@biomejs/cli-win32-x64': 1.9.4
'@biomejs/biome@2.5.2':
optionalDependencies:
'@biomejs/cli-darwin-arm64': 2.5.2
@ -6753,51 +6688,27 @@ snapshots:
'@biomejs/cli-win32-arm64': 2.5.2
'@biomejs/cli-win32-x64': 2.5.2
'@biomejs/cli-darwin-arm64@1.9.4':
optional: true
'@biomejs/cli-darwin-arm64@2.5.2':
optional: true
'@biomejs/cli-darwin-x64@1.9.4':
optional: true
'@biomejs/cli-darwin-x64@2.5.2':
optional: true
'@biomejs/cli-linux-arm64-musl@1.9.4':
optional: true
'@biomejs/cli-linux-arm64-musl@2.5.2':
optional: true
'@biomejs/cli-linux-arm64@1.9.4':
optional: true
'@biomejs/cli-linux-arm64@2.5.2':
optional: true
'@biomejs/cli-linux-x64-musl@1.9.4':
optional: true
'@biomejs/cli-linux-x64-musl@2.5.2':
optional: true
'@biomejs/cli-linux-x64@1.9.4':
optional: true
'@biomejs/cli-linux-x64@2.5.2':
optional: true
'@biomejs/cli-win32-arm64@1.9.4':
optional: true
'@biomejs/cli-win32-arm64@2.5.2':
optional: true
'@biomejs/cli-win32-x64@1.9.4':
optional: true
'@biomejs/cli-win32-x64@2.5.2':
optional: true

View file

@ -0,0 +1,206 @@
#!/usr/bin/env bash
#
# scripts/deploy/setup-cloudflare.sh
#
# First-time Cloudflare setup for DegenFeed. Operator must run this once
# after creating a Cloudflare account, adding the degenfeed.xyz zone,
# and uploading the Global API Key (or a scoped API token) to gopass.
#
# This script:
# 1. Validates that wrangler can authenticate.
# 2. Creates CACHE and SUBSCRIPTIONS KV namespaces (prod + preview IDs).
# 3. Generates AUTH_SECRET and pushes it as a wrangler secret on
# production and preview environments.
# 4. Patches workers/api/wrangler.toml with the real KV namespace IDs.
# 5. Prints a summary the operator pastes into GitHub repo secrets.
#
# Requirements:
# - wrangler 4.x installed on the host running this script.
# - gopass entries (see the user guide in DEPLOYMENT.md):
# rmi/cloudflare/cloudflare_account_id (32-hex account id)
# rmi/cloudflare/api_key_global (Global API Key)
# — OR an API token (recommended) that we'll set as
# CLOUDFLARE_API_TOKEN via environment.
# - jq (for JSON parsing).
#
# Idempotent: safe to re-run. Existing secrets are not overwritten.
# Does NOT push to GitHub. Operator must set GH secrets manually.
#
# Usage:
# ./scripts/deploy/setup-cloudflare.sh
# or with an explicit token:
# CLOUDFLARE_API_TOKEN=cfat_xxxx ./scripts/deploy/setup-cloudflare.sh
set -euo pipefail
WORKER_DIR="$(cd "$(dirname "$0")/../../workers/api" && pwd)"
TOML="$WORKER_DIR/wrangler.toml"
red() { printf "\033[31m%s\033[0m\n" "$*"; }
green() { printf "\033[32m%s\033[0m\n" "$*"; }
blue() { printf "\033[34m%s\033[0m\n" "$*"; }
bold() { printf "\033[1m%s\033[0m\n" "$*"; }
hr() { printf -- "----------------------------------------\n"; }
hr; bold "DegenFeed — Cloudflare first-time setup"; hr
# ── 1. Resolve auth ────────────────────────────────────────────────
if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then
if command -v gopass >/dev/null 2>&1; then
if gopass show rmi/cloudflare/api_key_global >/dev/null 2>&1; then
# Global API key. wrangler 4.x doesn't accept it via CLOUDFLARE_API_KEY
# for token operations; only scoped tokens work. So we also ask the
# operator to set CLOUDFLARE_API_TOKEN manually if they have one.
if [[ -z "${CLOUDFLARE_API_KEY:-}" ]]; then
export CLOUDFLARE_API_KEY="$(gopass show -o rmi/cloudflare/api_key_global)"
fi
fi
fi
fi
if [[ -z "${CLOUDFLARE_API_TOKEN:-}" && -z "${CLOUDFLARE_API_KEY:-}" ]]; then
red "ERROR: no Cloudflare credentials found."
red "Set one of:"
red " CLOUDFLARE_API_TOKEN=cfat_xxxxx ./scripts/deploy/setup-cloudflare.sh"
red " CLOUDFLARE_API_KEY=xxxxx ./scripts/deploy/setup-cloudflare.sh"
red " export CLOUDFLARE_API_KEY=\$(gopass show -o rmi/cloudflare/api_key_global)"
exit 1
fi
# Resolve account id
if [[ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]]; then
if command -v gopass >/dev/null 2>&1 && gopass show rmi/cloudflare/cloudflare_account_id >/dev/null 2>&1; then
export CLOUDFLARE_ACCOUNT_ID="$(gopass show -o rmi/cloudflare/cloudflare_account_id)"
fi
fi
if [[ -z "${CLOUDFLARE_ACCOUNT_ID:-}" ]]; then
red "ERROR: CLOUDFLARE_ACCOUNT_ID not set and gopass entry rmi/cloudflare/cloudflare_account_id not found."
exit 1
fi
# Validate auth
blue "[1/5] Validating Cloudflare auth..."
if [[ -n "${CLOUDFLARE_API_TOKEN:-}" ]]; then
verify_status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
"https://api.cloudflare.com/client/v4/user/tokens/verify")
else
verify_status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "X-Auth-Email: crmuncher@gmail.com" \
-H "X-Auth-Key: $CLOUDFLARE_API_KEY" \
"https://api.cloudflare.com/client/v4/user")
fi
if [[ "$verify_status" != "200" ]]; then
red "ERROR: Cloudflare auth failed (HTTP $verify_status). Token is invalid or expired."
red "Generate a fresh API token at:"
red " https://dash.cloudflare.com/profile/api-tokens"
red "Required scope: Workers Scripts:Edit, Workers KV Storage:Edit, Account Settings:Read"
exit 1
fi
green " ✓ auth ok"
# ── 2. Create KV namespaces ─────────────────────────────────────────
blue "[2/5] Creating KV namespaces (CACHE, SUBSCRIPTIONS × prod/preview)..."
create_kv() {
local binding="$1"
local id_var="KV_ID_${binding}_PROD"
local preview_id_var="KV_ID_${binding}_PREVIEW"
if [[ -n "${!id_var:-}" ]]; then
green "$binding: using provided prod id ${!id_var}"
eval "$id_var='${!id_var}'"
else
eval "$id_var=$(wrangler kv:namespace create "$binding" --config "$TOML" 2>/dev/null \
| grep -oE 'id = "[^"]+"' | head -1 | sed -E 's/.*"([^"]+)"/\1/')"
if [[ -z "${!id_var:-}" ]]; then
red " ✗ failed to create KV $binding (production)."
red " Check wrangler output above."
exit 1
fi
green "$binding prod: ${!id_var}"
fi
if [[ -n "${!preview_id_var:-}" ]]; then
green "$binding: using provided preview id ${!preview_id_var}"
else
eval "$preview_id_var=$(wrangler kv:namespace create "$binding" --preview --config "$TOML" 2>/dev/null \
| grep -oE 'id = "[^"]+"' | head -1 | sed -E 's/.*"([^"]+)"/\1/')"
if [[ -z "${!preview_id_var:-}" ]]; then
red " ✗ failed to create KV $binding (preview)."
exit 1
fi
green "$binding preview: ${!preview_id_var}"
fi
}
create_kv CACHE
create_kv SUBSCRIPTIONS
# ── 3. Generate AUTH_SECRET and push to both environments ──────────
blue "[3/5] Generating AUTH_SECRET and setting it as a wrangler secret..."
if [[ -z "${AUTH_SECRET:-}" ]]; then
AUTH_SECRET=$(openssl rand -hex 32)
green " ✓ generated fresh AUTH_SECRET (64 hex chars)"
fi
push_secret() {
local env_flag="$1"
if [[ -z "$env_flag" ]]; then
wrangler secret put AUTH_SECRET --config "$TOML" <<< "$AUTH_SECRET" >/dev/null
else
wrangler secret put AUTH_SECRET --config "$TOML" "$env_flag" <<< "$AUTH_SECRET" >/dev/null
fi
}
push_secret ""
push_secret "--env preview"
green " ✓ AUTH_SECRET set on production and preview"
# ── 4. GTS_CLIENT_SECRET (only if operator provides it) ───────────
blue "[4/5] GTS_CLIENT_SECRET (skip if not set)..."
if [[ -n "${GTS_CLIENT_SECRET:-}" ]]; then
wrangler secret put GTS_CLIENT_SECRET --config "$TOML" <<< "$GTS_CLIENT_SECRET" >/dev/null
wrangler secret put GTS_CLIENT_SECRET --config "$TOML" --env preview <<< "$GTS_CLIENT_SECRET" >/dev/null
green " ✓ GTS_CLIENT_SECRET set on production and preview"
else
yellow() { printf "\033[33m%s\033[0m\n" "$*"; }
yellow " ! GTS_CLIENT_SECRET not provided. To set it later:"
yellow " CLOUDFLARE_API_TOKEN=... ./scripts/deploy/setup-cloudflare.sh"
yellow " GTS_CLIENT_SECRET=xxx wrangler secret put GTS_CLIENT_SECRET --config workers/api/wrangler.toml"
yellow " Get the secret from your GotoSocial admin: social.degenfeed.xyz/admin/oauth-apps"
fi
# ── 5. Patch wrangler.toml with real KV IDs ───────────────────────
blue "[5/5] Patching workers/api/wrangler.toml with real KV IDs..."
sed -i.bak \
-e "s|^id = \"cache-namespace\"$|id = \"$KV_ID_CACHE_PROD\"|" \
-e "s|^preview_id = \"cache-namespace-preview\"$|preview_id = \"$KV_ID_CACHE_PREVIEW\"|" \
-e "s|^id = \"subs-namespace\"$|id = \"$KV_ID_SUBSCRIPTIONS_PROD\"|" \
-e "s|^preview_id = \"subs-namespace-preview\"$|preview_id = \"$KV_ID_SUBSCRIPTIONS_PREVIEW\"|" \
"$TOML"
rm -f "$TOML.bak"
green " ✓ wrangler.toml updated"
# ── Summary ──────────────────────────────────────────────────────
hr; bold "Setup complete"; hr
green "Worker: degenfeed-api"
green "Account: $CLOUDFLARE_ACCOUNT_ID"
echo
blue "Next steps for the operator:"
echo " 1. Set GitHub repo secrets at"
echo " https://github.com/RugMunchMedia/degenfeed-web/settings/secrets/actions"
echo " Required secrets:"
echo " CLOUDFLARE_API_TOKEN = ${CLOUDFLARE_API_TOKEN:-<your token>}"
echo " CLOUDFLARE_ACCOUNT_ID = $CLOUDFLARE_ACCOUNT_ID"
echo
echo " 2. Run the dry-run to verify bindings:"
echo " cd workers/api && wrangler deploy --dry-run"
echo
echo " 3. Deploy the preview environment:"
echo " cd workers/api && wrangler deploy --env preview"
echo
echo " 4. Cut a release tag and push to main — CI will deploy production."
hr

View file

@ -29,7 +29,7 @@
"@noble/hashes": "^1.8.0"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@biomejs/biome": "^2.5.2",
"@cloudflare/workers-types": "^4.20240501.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0",

View file

@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockStateStore = new Map<string, string>();
const mockCache = {
put: vi.fn(async (key: string, value: string) => {
mockStateStore.set(key, value);
}),
get: vi.fn(async (key: string) => mockStateStore.get(key) ?? null),
delete: vi.fn(async (key: string) => {
mockStateStore.delete(key);
}),
} as unknown as KVNamespace;
const env: { CACHE: KVNamespace; GTS_CLIENT_ID?: string; GTS_INSTANCE_URL?: string } = {
CACHE: mockCache,
GTS_CLIENT_ID: 'test-client-id',
GTS_INSTANCE_URL: 'https://social.degenfeed.xyz',
};
vi.mock('@degenfeed/types', () => ({}));
import { handleGtSAuthorize, handleGtSCallback } from '../routes/auth';
describe('GTS OAuth state nonce', () => {
beforeEach(() => {
mockStateStore.clear();
(mockCache.put as ReturnType<typeof vi.fn>).mockClear();
(mockCache.get as ReturnType<typeof vi.fn>).mockClear();
(mockCache.delete as ReturnType<typeof vi.fn>).mockClear();
});
it('authorize returns a state nonce and stores it in KV', async () => {
const req = new Request('http://localhost/api/auth/gotosocial/authorize');
const res = await handleGtSAuthorize(req, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { state: string; authorizeUrl: string };
expect(body.state).toMatch(/^[0-9a-f]{32}$/);
expect(body.authorizeUrl).toContain(`state=${body.state}`);
expect(mockStateStore.has(`gts:state:${body.state}`)).toBe(true);
});
it('callback rejects a missing state', async () => {
const req = new Request('http://localhost/api/auth/gotosocial/callback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: 'abc' }),
});
const res = await handleGtSCallback(req, env);
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toMatch(/state/i);
});
it('callback rejects an unknown state', async () => {
const req = new Request('http://localhost/api/auth/gotosocial/callback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: 'abc', state: 'never-issued' }),
});
const res = await handleGtSCallback(req, env);
expect(res.status).toBe(400);
});
it('callback consumes a valid state (no replay)', async () => {
const authRes = await handleGtSAuthorize(
new Request('http://localhost/api/auth/gotosocial/authorize'),
env,
);
const { state } = (await authRes.json()) as { state: string };
// First callback with valid state: GTS is mocked via global fetch so
// we don't have a real OAuth server, but the route reaches the token
// exchange. Mock fetch to fail loudly so the request returns 500 with
// a useful error (not 400). What we care about is that the state was
// consumed BEFORE the token exchange.
const before = mockStateStore.has(`gts:state:${state}`);
expect(before).toBe(true);
// The token exchange will fail in this unit test (no real GTS) — we
// only assert that the state was consumed, not the response status.
const fakeRes = new Response('{}', { status: 200 });
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(fakeRes)
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
try {
await handleGtSCallback(
new Request('http://localhost/api/auth/gotosocial/callback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: 'abc', state }),
}),
env,
);
} catch {
/* ignore — we only care about state consumption */
}
expect(mockStateStore.has(`gts:state:${state}`)).toBe(false);
fetchSpy.mockRestore();
});
});

View file

@ -72,13 +72,32 @@ describe('CSRF origin check', () => {
expect(res.status).toBe(403);
});
it('skips origin check for GTS callback', async () => {
it('GTS callback is now origin-protected (was CSRF-exempt before)', async () => {
// The callback previously bypassed origin checks via CSRF_EXEMPT_PATHS.
// It now requires (a) an allowed origin AND (b) a valid state nonce.
const req = new Request('http://localhost/api/auth/gotosocial/callback', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: 'abc' }),
});
const res = await worker.fetch(req, env);
expect([200, 400, 500]).toContain(res.status);
expect(res.status).toBe(403);
});
it('GTS callback accepts requests with an allowed origin', async () => {
const req = new Request('http://localhost/api/auth/gotosocial/callback', {
method: 'POST',
headers: {
origin: 'https://app.degenfeed.xyz',
'content-type': 'application/json',
},
body: JSON.stringify({ code: 'abc', state: 'missing' }),
});
const res = await worker.fetch(req, env);
// Origin passes (no 403). The state is invalid so the route reaches
// token exchange which 500s without a real GTS_CLIENT_SECRET. The key
// assertion is that the origin check passed — the actual state/token
// flow is covered in auth.state.test.ts.
expect(res.status).not.toBe(403);
});
});

View file

@ -21,10 +21,49 @@ vi.mock('@degenfeed/bluesky-sdk', () => ({
})),
}));
vi.mock('@degenfeed/mastodon-sdk', () => ({
GtSClient: vi.fn().mockImplementation(() => ({
getPublicTimeline: vi.fn().mockResolvedValue([]),
})),
vi.mock('@degenfeed/rss-sdk', () => ({
GenericRssSource: {
fetch: vi.fn().mockResolvedValue({
source: 'generic',
url: 'https://example.com/feed',
feed: {
title: 'Mock Feed',
link: 'https://example.com',
items: [
{
title: 'Mock Post',
contentSnippet: 'This is a mock newsletter post.',
link: 'https://example.com/post',
pubDate: new Date().toISOString(),
},
],
},
}),
},
feedToUnifiedPosts: vi.fn().mockReturnValue([
{
id: 'rss:generic:mock',
protocol: 'rss',
author: {
id: 'rss:generic:Mock Feed',
primary: { protocol: 'rss', id: 'rss:generic:Mock Feed' },
handles: {},
displayName: 'Mock Feed',
},
text: 'This is a mock newsletter post.',
html: '<p>This is a mock newsletter post.</p>',
embeds: [],
createdAt: Date.now(),
metrics: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
engagement: {
viewerLiked: false,
viewerReposted: false,
viewerBookmarked: false,
raw: { likes: 0, reposts: 0, replies: 0, quotes: 0 },
},
niches: [],
},
]),
}));
import type { Env } from '../index';

View file

@ -127,6 +127,60 @@ describe('handleHomeFeed', () => {
const body = (await res.json()) as { data: unknown[]; meta: Record<string, unknown> };
expect(body.data.length).toBe(1);
});
it('should attach score breakdown to each post', async () => {
mockFetchFeed.mockResolvedValue({
posts: [
{
id: 'nostr:test',
protocol: 'nostr',
author: {
id: 'nostr:pk',
primary: { protocol: 'nostr', id: 'pk' },
handles: {
nostr: 'pk',
farcaster: null,
lens: null,
bluesky: null,
threads: null,
rss: null,
mastodon: null,
},
displayName: 'pk',
bio: '',
avatarUrl: '',
verified: [],
followerCount: 0,
followingCount: 0,
links: [],
keys: {},
},
text: 'fresh post about bitcoin and ethereum',
embeds: [],
createdAt: Date.now(),
metrics: { likes: 1, reposts: 0, replies: 0, quotes: 0 },
engagement: {
viewerLiked: false,
viewerReposted: false,
viewerBookmarked: false,
raw: { likes: 1, reposts: 0, replies: 0, quotes: 0 },
},
raw: null,
niches: [],
},
],
});
const req = new Request('http://localhost/api/feed/home?protocols=nostr');
const res = await handleHomeFeed(req, mockEnv);
const body = (await res.json()) as {
data: Array<{ score?: { total: number; recency: number; reasons: string[] } }>;
};
expect(body.data.length).toBe(1);
const score = body.data[0]?.score;
expect(score).toBeDefined();
expect(typeof score?.total).toBe('number');
expect(typeof score?.recency).toBe('number');
});
});
describe('handleTrendingFeed', () => {

View file

@ -10,19 +10,42 @@ vi.mock('@degenfeed/nostr-sdk', () => ({
import type { Env } from '../index';
import { handleNotifications } from '../routes/notifications';
import { createSession, parseSessionCookie } from '../session';
const secret = 'test-secret-must-be-at-least-32-bytes';
const env: Env = { AUTH_SECRET: secret };
async function signedRequest(url: string, method = 'GET', body?: BodyInit): Promise<Request> {
const { setCookie } = await createSession('0xAbCd', 'ethereum', secret);
const cookieValue = parseSessionCookie(setCookie);
if (!cookieValue) throw new Error('missing cookie');
const headers: Record<string, string> = { cookie: `__Host-dfid=${cookieValue}` };
if (body) headers['content-type'] = 'application/json';
return new Request(url, { method, headers, body });
}
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 Env);
const req = await signedRequest('http://localhost/api/notifications?auth=%7B%7D');
const res = await handleNotifications(req, env);
expect(res.status).toBe(200);
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 Env);
it('should handle Nostr pubkey auth via POST body', async () => {
const req = await signedRequest(
'http://localhost/api/notifications',
'POST',
JSON.stringify({ auth: { nostr_pubkey: 'abc123' } }),
);
const res = await handleNotifications(req, env);
expect(res.status).toBe(200);
});
it('should reject without session', async () => {
const req = new Request('http://localhost/api/notifications?auth=%7B%7D');
const res = await handleNotifications(req, env);
expect(res.status).toBe(401);
});
});

View file

@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import { sanitizePrefs } from '../routes/preferences';
describe('sanitizePrefs', () => {
it('applies sensible defaults when given empty body', () => {
const out = sanitizePrefs({});
expect(out.hideLowQuality).toBe(false);
expect(out.diversityBoost).toBe(true);
expect(out.preferredNiches).toEqual([]);
expect(out.preferredPlatforms).toEqual([]);
expect(out.mutedNiches).toEqual([]);
expect(out.recencyWeight).toBe(1);
expect(out.engagementWeight).toBe(1);
expect(out.updatedAt).toBeGreaterThan(0);
});
it('lowercases niches and drops empty strings', () => {
const out = sanitizePrefs({
preferredNiches: ['Bitcoin', 'ETHEREUM', '', ' ', 'defi'],
mutedNiches: ['ScamCoin'],
});
expect(out.preferredNiches).toEqual(['bitcoin', 'ethereum', 'defi']);
expect(out.mutedNiches).toEqual(['scamcoin']);
});
it('clamps out-of-range weights to [0.1, 5]', () => {
const out = sanitizePrefs({ recencyWeight: 99, engagementWeight: -2 });
expect(out.recencyWeight).toBe(5);
expect(out.engagementWeight).toBe(0.1);
});
it('falls back to defaults for non-numeric weights', () => {
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input shape
const out = sanitizePrefs({ recencyWeight: 'foo', engagementWeight: NaN } as any);
expect(out.recencyWeight).toBe(1);
expect(out.engagementWeight).toBe(1);
});
it('preserves hideLowQuality boolean', () => {
expect(sanitizePrefs({ hideLowQuality: true }).hideLowQuality).toBe(true);
expect(sanitizePrefs({ hideLowQuality: false }).hideLowQuality).toBe(false);
});
it('filters non-string platforms', () => {
const out = sanitizePrefs({
// biome-ignore lint/suspicious/noExplicitAny: test fixture
preferredPlatforms: ['nostr', 42, null, 'bluesky'] as any,
});
expect(out.preferredPlatforms).toEqual(['nostr', 'bluesky']);
});
});

View file

@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import worker, { type Env } from '../index';
import { createSession, parseSessionCookie } from '../session';
const secret = 'test-secret-must-be-at-least-32-bytes';
const env: Env = { AUTH_SECRET: secret };
async function signedRequest(url: string, method = 'GET', body?: BodyInit): Promise<Request> {
const { setCookie } = await createSession('0xAbCd', 'ethereum', secret);
const cookieValue = parseSessionCookie(setCookie);
if (!cookieValue) throw new Error('missing cookie');
const headers: Record<string, string> = {
cookie: `__Host-dfid=${cookieValue}`,
origin: 'https://app.degenfeed.xyz',
};
if (body) headers['content-type'] = 'application/json';
return new Request(url, { method, headers, body });
}
describe('GET /api/preferences', () => {
it('returns defaults when none stored', async () => {
const req = await signedRequest('http://localhost/api/preferences');
const res = await worker.fetch(req, env);
expect(res.status).toBe(200);
const body = (await res.json()) as {
hideLowQuality: boolean;
diversityBoost: boolean;
};
expect(body.hideLowQuality).toBe(false);
expect(body.diversityBoost).toBe(true);
});
it('rejects without session', async () => {
const req = new Request('http://localhost/api/preferences');
const res = await worker.fetch(req, env);
expect(res.status).toBe(401);
});
});
describe('POST /api/preferences', () => {
it('persists hideLowQuality and returns sanitized payload', async () => {
const req = await signedRequest(
'http://localhost/api/preferences',
'POST',
JSON.stringify({ hideLowQuality: true, recencyWeight: 2.5 }),
);
const res = await worker.fetch(req, env);
expect(res.status).toBe(200);
const body = (await res.json()) as {
ok: boolean;
preferences: {
hideLowQuality: boolean;
recencyWeight: number;
hideLowQualityThreshold: number;
allowNonCrypto: boolean;
};
};
expect(body.ok).toBe(true);
expect(body.preferences.hideLowQuality).toBe(true);
expect(body.preferences.recencyWeight).toBe(2.5);
expect(body.preferences.hideLowQualityThreshold).toBe(1.0);
expect(body.preferences.allowNonCrypto).toBe(false);
});
it('persists threshold and allowNonCrypto', async () => {
const req = await signedRequest(
'http://localhost/api/preferences',
'POST',
JSON.stringify({ hideLowQualityThreshold: 3.5, allowNonCrypto: true }),
);
const res = await worker.fetch(req, env);
const body = (await res.json()) as {
preferences: { hideLowQualityThreshold: number; allowNonCrypto: boolean };
};
expect(body.preferences.hideLowQualityThreshold).toBe(3.5);
expect(body.preferences.allowNonCrypto).toBe(true);
});
it('clamps out-of-range recencyWeight', async () => {
const req = await signedRequest(
'http://localhost/api/preferences',
'POST',
JSON.stringify({ recencyWeight: 99 }),
);
const res = await worker.fetch(req, env);
const body = (await res.json()) as {
preferences: { recencyWeight: number };
};
expect(body.preferences.recencyWeight).toBe(5);
});
it('returns 405 on PUT', async () => {
const req = await signedRequest('http://localhost/api/preferences', 'PUT');
const res = await worker.fetch(req, env);
expect(res.status).toBe(405);
});
});

View file

@ -68,6 +68,26 @@ describe('GET /api/auth/session', () => {
});
});
describe('POST /api/auth/signout', () => {
it('clears the session cookie', async () => {
const { setCookie } = await createSession('0xAbCd', 'ethereum', secret);
const cookieValue = parseSessionCookie(setCookie);
expect(cookieValue).toBeTruthy();
if (!cookieValue) throw new Error('missing cookie');
const req = new Request('http://localhost/api/auth/signout', {
method: 'POST',
headers: { origin: 'https://app.degenfeed.xyz', cookie: `__Host-dfid=${cookieValue}` },
});
const res = await worker.fetch(req, env);
expect(res.status).toBe(200);
const cookies = Array.from(res.headers.entries())
.filter(([key]) => key.toLowerCase() === 'set-cookie')
.map(([, value]) => value);
expect(cookies.some((c) => c.includes('__Host-dfid=') && c.includes('Max-Age=0'))).toBe(true);
});
});
describe('protected routes', () => {
it('rejects POST /api/tip/create without a session', async () => {
const req = new Request('http://localhost/api/tip/create', {
@ -110,4 +130,17 @@ describe('protected routes', () => {
const body = (await res.json()) as { method: string };
expect(body.method).toBe('ethereum');
});
it('rejects POST /api/bridge/rss-to-nostr without a session', async () => {
const req = new Request('http://localhost/api/bridge/rss-to-nostr', {
method: 'POST',
headers: {
origin: 'https://app.degenfeed.xyz',
'content-type': 'application/json',
},
body: JSON.stringify({ url: 'https://example.com/feed.xml' }),
});
const res = await worker.fetch(req, env);
expect(res.status).toBe(401);
});
});

View file

@ -1,12 +1,49 @@
export const ALLOWED_ORIGINS = new Set([
/**
* CORS allowlist env-driven.
*
* Origins are read from the `ALLOWED_ORIGINS` env var (comma-separated).
* Set via wrangler.toml [vars] per environment:
* ALLOWED_ORIGINS = "https://degenfeed.xyz,https://app.degenfeed.xyz"
*
* Fallback origins are hardcoded for local development so `wrangler dev`
* works without configuration.
*/
const FALLBACK_ALLOWED_ORIGINS = [
'https://degenfeed.xyz',
'https://app.degenfeed.xyz',
'https://social.degenfeed.xyz',
'http://localhost:3000',
]);
'http://127.0.0.1:3000',
] as const;
export function isAllowedOrigin(origin: string): boolean {
return ALLOWED_ORIGINS.has(origin);
let cachedAllowlist: Set<string> | null = null;
let cachedAllowlistKey: string | null = null;
function buildAllowlist(envAllowlist?: string): Set<string> {
const src =
envAllowlist && envAllowlist.trim().length > 0
? envAllowlist
: FALLBACK_ALLOWED_ORIGINS.join(',');
return new Set(
src
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
}
export function getAllowedOriginSet(envAllowlist?: string): Set<string> {
const key = envAllowlist ?? '';
if (cachedAllowlist && cachedAllowlistKey === key) return cachedAllowlist;
cachedAllowlist = buildAllowlist(envAllowlist);
cachedAllowlistKey = key;
return cachedAllowlist;
}
export function isAllowedOrigin(origin: string | null | undefined, envAllowlist?: string): boolean {
if (!origin) return false;
return getAllowedOriginSet(envAllowlist).has(origin.toLowerCase());
}
export function parseOriginHeader(header: string | null): string | null {
@ -18,10 +55,10 @@ export function parseOriginHeader(header: string | null): string | null {
}
}
export function getAllowedOrigin(request: Request): string | null {
export function getAllowedOrigin(request: Request, envAllowlist?: string): string | null {
const origin = request.headers.get('origin');
if (!origin) return null;
return isAllowedOrigin(origin) ? origin : null;
return isAllowedOrigin(origin, envAllowlist) ? origin : null;
}
export function corsHeaders(origin: string | null): Headers {

View file

@ -18,7 +18,6 @@ import { handleBillingCheckout, handleBillingStatus, handleBillingVerify } from
import { handleBlinkTip } from './routes/blink';
import { handleNewsletterFeed, handleTopFeed } from './routes/curated';
import { handleEngage } from './routes/engage';
import { handleLandingCss, handleLandingHtml, handleLandingJs } from './routes/landing';
import {
handleCategoryFeed,
handleFeedHealth,
@ -30,6 +29,7 @@ import {
import { handleFundingFeed } from './routes/funding';
import { handleRelaysHealth } from './routes/health';
import { handleResolveIdentity } from './routes/identity';
import { handleLandingCss, handleLandingHtml, handleLandingJs } from './routes/landing';
import { handleNewsFeed } from './routes/news';
import {
handleNewsletterDigest,
@ -37,6 +37,8 @@ import {
handleNewsletterSubscribe,
} from './routes/newsletter';
import { handleNotifications } from './routes/notifications';
import { handlePostById } from './routes/post';
import { handlePreferences } from './routes/preferences';
import { handlePrices } from './routes/prices';
import { handleActivityPubFeed, handleThreadsFeed } from './routes/protocols';
import { handlePublish } from './routes/publish';
@ -51,7 +53,6 @@ import {
import { handleRssFeed } from './routes/rss';
import { handleRssXml } from './routes/rss-xml';
import { handleSearch, handleSearchIdentity } from './routes/search';
import { handlePostById } from './routes/post';
import {
handleLightningInvoice,
handleTipCreate,
@ -64,7 +65,7 @@ import {
} from './routes/tip';
import { handleWalletNonce, handleWalletVerify } from './routes/wallet';
import { handleX402Paywall, handleX402Verify } from './routes/x402';
import { handleSessionVerify, requireSession } from './session';
import { handleSessionSignOut, handleSessionVerify, requireSession } from './session';
// ─── Environment variables ───────────────────────────────────────────
@ -85,11 +86,17 @@ export interface Env {
THREADS_PDS_URL?: string;
RATE_WINDOW_MS?: string;
RATE_MAX_REQ?: string;
/** When "true", disables in-process rate limiting (use only for local dev). */
RATE_LIMIT_DISABLED?: string;
/** Comma-separated list of allowed CORS origins for state-changing requests. */
ALLOWED_ORIGINS?: string;
SOLANA_RPC_URL?: string;
/** Base URL of the rmi-backend (default: http://127.0.0.1:8000) */
RMI_BACKEND_URL?: string;
/** Optional bearer for rmi-backend if gated */
RMI_API_KEY?: string;
/** Optional Sentry DSN for error reporting */
SENTRY_DSN?: string;
}
// ─── Shared constants ────────────────────────────────────────────────
@ -112,30 +119,65 @@ export const THREADS_PUBLIC = 'https://public.threads.net';
// ─── Rate limiting ───────────────────────────────────────────────────
const requestLog = new Map<string, { count: number; resetAt: number }>();
const DEFAULT_RATE_WINDOW = 5000;
const DEFAULT_RATE_MAX = 20;
export function rateLimit(
interface RateLimitResult {
allowed: boolean;
retryAfter: number;
}
/**
* KV-backed fixed-window rate limiter.
*
* Cloudflare Workers can spin up many isolates per zone, so any in-memory
* counter is unreliable. We use the CACHE KV namespace with a per-bucket
* TTL equal to the window. Each bucket key = `${ip}:${path}:${windowMs/1000}`,
* storing a JSON `{ count, resetAt }`. The bucket auto-expires after one
* window so the counter resets.
*
* If `CACHE` is not bound (local dev), the function falls back to a
* permissive "always allow" result and logs once never blocks real users.
*/
export async function rateLimit(
env: Env,
ip: string,
path: 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) {
for (const [key, entry] of requestLog) {
if (now > entry.resetAt) requestLog.delete(key);
}
}
const entry = requestLog.get(ip);
if (!entry || now > entry.resetAt) {
requestLog.set(ip, { count: 1, resetAt: now + windowMs });
): Promise<RateLimitResult> {
if (env.RATE_LIMIT_DISABLED === 'true') {
return { allowed: true, retryAfter: 0 };
}
entry.count++;
if (entry.count > maxReq) {
return { allowed: false, retryAfter: entry.resetAt - now };
if (!env.CACHE) {
// No KV bound (local dev). Skip throttling so the dev experience works.
return { allowed: true, retryAfter: 0 };
}
const bucketSec = Math.max(1, Math.ceil(windowMs / 1000));
const key = `rl:${ip}:${path}:${bucketSec}`;
const now = Date.now();
const resetAt = now + windowMs;
let count = 1;
try {
const raw = await env.CACHE.get(key);
if (raw) {
const parsed = JSON.parse(raw) as { count: number; resetAt: number };
if (parsed.resetAt > now) {
count = parsed.count + 1;
}
}
// TTL covers the window so the key expires automatically.
await env.CACHE.put(key, JSON.stringify({ count, resetAt }), {
expirationTtl: bucketSec,
});
} catch {
// KV hiccup — fail open so a single KV outage doesn't break the API.
return { allowed: true, retryAfter: 0 };
}
if (count > maxReq) {
return { allowed: false, retryAfter: Math.max(0, resetAt - now) };
}
return { allowed: true, retryAfter: 0 };
}
@ -148,7 +190,7 @@ 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
instanceUrl: 'https://social.degenfeed.xyz',
apiBase: 'http://127.0.0.1:8082',
apiBase: 'https://social.degenfeed.xyz',
redirectUri: 'https://degenfeed.xyz/auth/callback',
scopes: 'read write follow push',
};
@ -176,10 +218,21 @@ export function jsonResponse(data: unknown, status = 200): Response {
});
}
export function noCacheJsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: {
'content-type': 'application/json',
'cache-control': 'no-store, must-revalidate',
},
});
}
// ─── CORS / CSRF helpers ─────────────────────────────────────────────
const CSRF_EXEMPT_PATHS = [
'/api/auth/gotosocial/callback',
// /api/auth/gotosocial/callback is now protected by a signed state nonce
// (see routes/auth.ts) AND a normal origin check, so it's no longer exempt.
'/api/tip/frame/',
'/api/bridge/rss-to-nostr',
'/api/blink/tip',
@ -194,14 +247,14 @@ function isCsrfExempt(path: string): boolean {
return CSRF_EXEMPT_PATHS.some((prefix) => path.startsWith(prefix));
}
function validateOrigin(request: Request, path: string): boolean {
function validateOrigin(request: Request, path: string, env: Env): boolean {
if (isCsrfExempt(path)) return true;
const origin = request.headers.get('origin');
if (origin) return isAllowedOrigin(origin);
if (origin) return isAllowedOrigin(origin, env.ALLOWED_ORIGINS);
const referer = request.headers.get('referer');
if (referer) {
const refererOrigin = parseOriginHeader(referer);
return refererOrigin ? isAllowedOrigin(refererOrigin) : false;
return refererOrigin ? isAllowedOrigin(refererOrigin, env.ALLOWED_ORIGINS) : false;
}
return false;
}
@ -212,7 +265,7 @@ export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const origin = getAllowedOrigin(request);
const origin = getAllowedOrigin(request, env.ALLOWED_ORIGINS);
// CORS preflight
if (request.method === 'OPTIONS') {
@ -226,7 +279,7 @@ export default {
'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);
const rl = await rateLimit(env, ip, path, maxReq, windowMs);
if (!rl.allowed) {
const response = new Response(
JSON.stringify({ error: 'Rate limit', retryAfterMs: rl.retryAfter }),
@ -242,7 +295,7 @@ export default {
}
// CSRF origin check for state-changing requests
if (isStateChanging(request.method) && !validateOrigin(request, path)) {
if (isStateChanging(request.method) && !validateOrigin(request, path, env)) {
return applyCors(jsonResponse({ error: 'Forbidden' }, 403), origin);
}
@ -286,20 +339,18 @@ export default {
} else if (path.startsWith('/api/feed/category/')) {
const category = path.replace('/api/feed/category/', '');
response = await handleCategoryFeed(request, env, category);
} else if (path === '/api/feed/activitypub') {
response = await handleActivityPubFeed(request, env);
} else if (path === '/api/feed/threads') {
response = await handleThreadsFeed(request, env);
} else if (path === '/api/feed/news') {
response = await handleNewsFeed(request, env);
} else if (path.startsWith('/api/feed/post/')) {
const postId = path.replace('/api/feed/post/', '');
response = await handlePostById(request, env, postId);
} else if (path === '/api/bridge/rss-to-nostr') {
const { handleRssToNostrBridge } = await import('./routes/publish');
response = await handleRssToNostrBridge(request, env);
const session = await requireSession(request, env);
if (session instanceof Response) {
response = session;
} else {
const { handleRssToNostrBridge } = await import('./routes/publish');
response = await handleRssToNostrBridge(request, env);
}
} else if (path === '/api/feed/rss') {
response = await handleRssFeed(request, env);
} else if (path === '/api/auth/signout') {
response = await handleSessionSignOut(request, env);
} else if (path === '/api/auth/gotosocial/authorize') {
response = await handleGtSAuthorize(request, env);
} else if (path === '/api/auth/gotosocial/callback') {
@ -374,6 +425,8 @@ export default {
response = await handleBillingVerify(request, env);
} else if (path === '/api/billing/status') {
response = await handleBillingStatus(request, env);
} else if (path === '/api/preferences') {
response = await handlePreferences(request, env);
} else if (path === '/api/x402/paywall') {
response = await handleX402Paywall(request, env);
} else if (path === '/api/x402/verify') {

View file

@ -1,29 +1,96 @@
import { type Env, getGtsConfig, jsonResponse } from '../index';
const STATE_KEY_PREFIX = 'gts:state:';
const STATE_TTL_SECONDS = 600; // 10 minutes
function generateState(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16));
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
async function storeState(env: Env, state: string): Promise<void> {
if (!env.CACHE) return; // dev mode: skip state persistence
try {
await env.CACHE.put(STATE_KEY_PREFIX + state, '1', {
expirationTtl: STATE_TTL_SECONDS,
});
} catch {
/* KV hiccup — fall through; we'll fail-closed on callback */
}
}
async function consumeState(env: Env, state: string): Promise<boolean> {
if (!env.CACHE) return true; // dev mode: trust all states
try {
const existing = await env.CACHE.get(STATE_KEY_PREFIX + state);
if (!existing) return false;
// Best-effort delete to prevent replay.
await env.CACHE.delete(STATE_KEY_PREFIX + state);
return true;
} catch {
return false;
}
}
/**
* GET /api/auth/gotosocial/authorize?redirect=...
*
* Generates a CSRF `state` nonce, stores it in KV, and returns the
* authorize URL the client should redirect the user to. The `state`
* parameter must be echoed back to /api/auth/gotosocial/callback.
*/
export async function handleGtSAuthorize(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const redirect = url.searchParams.get('redirect') || getGtsConfig(env).redirectUri;
const devMode = url.searchParams.get('dev') === 'true';
const authorizeUrl = devMode
? `http://127.0.0.1:8082/oauth/authorize?client_id=${getGtsConfig(env).clientId}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=${getGtsConfig(env).scopes}`
: `https://social.degenfeed.xyz/oauth/authorize?client_id=${getGtsConfig(env).clientId}&redirect_uri=${encodeURIComponent(redirect)}&response_type=code&scope=${getGtsConfig(env).scopes}`;
const state = generateState();
await storeState(env, state);
return jsonResponse({ authorizeUrl, instanceUrl: getGtsConfig(env).instanceUrl, dev: devMode });
const params = new URLSearchParams({
client_id: getGtsConfig(env).clientId,
redirect_uri: devMode ? 'urn:ietf:wg:oauth:2.0:oob' : redirect,
response_type: 'code',
scope: getGtsConfig(env).scopes,
state,
});
const authorizeUrl = devMode
? `http://127.0.0.1:8082/oauth/authorize?${params.toString()}`
: `https://social.degenfeed.xyz/oauth/authorize?${params.toString()}`;
return jsonResponse({
authorizeUrl,
state,
instanceUrl: getGtsConfig(env).instanceUrl,
dev: devMode,
});
}
/**
* POST /api/auth/gotosocial/callback
* Exchange an OAuth authorization code for an access token.
* Body: { code: string, redirect_uri?: string }
* Body: { code: string, state: string, redirect_uri?: string }
*
* Verifies the `state` nonce against KV (CSRF protection) before exchanging
* the OAuth authorization code for an access token.
*/
export async function handleGtSCallback(request: Request, env: Env): Promise<Response> {
try {
const body = (await request.json()) as { code: string; redirect_uri?: string };
const body = (await request.json()) as {
code?: string;
state?: string;
redirect_uri?: string;
};
if (!body.code) {
return jsonResponse({ error: 'Missing authorization code' }, 400);
}
if (!body.state) {
return jsonResponse({ error: 'Missing state nonce' }, 400);
}
const stateValid = await consumeState(env, body.state);
if (!stateValid) {
return jsonResponse({ error: 'Invalid or expired state' }, 400);
}
const redirectUri = body.redirect_uri || getGtsConfig(env).redirectUri;
const tokenRes = await fetch(`${getGtsConfig(env).apiBase}/oauth/token`, {
@ -79,29 +146,35 @@ export async function handleGtSCallback(request: Request, env: Env): Promise<Res
/**
* POST /api/auth/gotosocial/register
* Proxy for creating a new account on GotoSocial.
* This requires an admin token (stored server-side).
*
* For v1, this proxies the standard OAuth registration flow.
* Returns the authorization URL the user should visit.
* Generates a state nonce and the authorize URL the user should visit.
* The state must be echoed back to /api/auth/gotosocial/callback.
*/
export async function handleGtSRegister(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const redirect = url.searchParams.get('redirect') || getGtsConfig(env).redirectUri;
const devMode = url.searchParams.get('dev') === 'true';
const state = generateState();
await storeState(env, state);
const params = new URLSearchParams({
client_id: getGtsConfig(env).clientId,
redirect_uri: devMode ? 'urn:ietf:wg:oauth:2.0:oob' : redirect,
response_type: 'code',
scope: getGtsConfig(env).scopes,
state,
sign_up: 'true',
});
const authorizeUrl = devMode
? `http://127.0.0.1:8082/oauth/authorize?client_id=${getGtsConfig(env).clientId}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=${getGtsConfig(env).scopes}&sign_up=true`
: `https://social.degenfeed.xyz/oauth/authorize?client_id=${getGtsConfig(env).clientId}&redirect_uri=${encodeURIComponent(redirect)}&response_type=code&scope=${getGtsConfig(env).scopes}&sign_up=true`;
? `http://127.0.0.1:8082/oauth/authorize?${params.toString()}`
: `https://social.degenfeed.xyz/oauth/authorize?${params.toString()}`;
return jsonResponse({
success: true,
message:
'Redirect user to the authorize URL. They will be prompted to sign up if they do not have an account.',
authorizeUrl,
state,
instanceUrl: getGtsConfig(env).instanceUrl,
});
}
// ─── Feed Handlers ─────────────────────────────────────────────────────

View file

@ -157,6 +157,9 @@ const NEWSLETTER_SOURCES = [
{ name: 'Ethereum Foundation', url: 'https://blog.ethereum.org/feed.xml', category: 'headlines' },
];
const NEWSLETTER_MAX_SOURCES = 15;
const NEWSLETTER_SOURCE_TIMEOUT_MS = 5000;
export async function handleNewsletterFeed(request: Request, _env: Env): Promise<Response> {
const url = new URL(request.url);
const limit = Number(url.searchParams.get('limit')) || 25;
@ -175,11 +178,12 @@ export async function handleNewsletterFeed(request: Request, _env: Env): Promise
});
}
const _results = await Promise.allSettled(
sources.slice(0, 15).map(async (src) => {
const { GenericRssSource, feedToUnifiedPosts } = await import('@degenfeed/rss-sdk');
await Promise.allSettled(
sources.slice(0, NEWSLETTER_MAX_SOURCES).map(async (src) => {
try {
const { GenericRssSource, feedToUnifiedPosts } = await import('@degenfeed/rss-sdk');
const s = await GenericRssSource.fetch(src.url);
const signal = AbortSignal.timeout(NEWSLETTER_SOURCE_TIMEOUT_MS);
const s = await GenericRssSource.fetch(src.url, { signal });
const posts = feedToUnifiedPosts(s);
allPosts.push(...posts);
} catch {}

View file

@ -1,4 +1,3 @@
import { parseProtocols, rmiUrl } from '@degenfeed/utils';
import {
deduplicatePosts,
enrichPostNiches,
@ -23,12 +22,14 @@ import {
import {
computeTrendingTopics,
rankDiverse,
scoreBreakdownV2,
type UserAlgorithmPreferences,
} from '@degenfeed/ranking';
import type { Protocol, UnifiedPost } from '@degenfeed/types';
import type { Protocol, UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
import { parseProtocols, rmiUrl } from '@degenfeed/utils';
import { type Env, jsonResponse } from '../index';
const DEFAULT_PROTOCOLS: Protocol[] = [
export const DEFAULT_PROTOCOLS: Protocol[] = [
'rss',
'reddit',
'nostr',
@ -41,9 +42,7 @@ const DEFAULT_PROTOCOLS: Protocol[] = [
const DEFAULT_TIMEOUT_MS = 6000;
function getProvider(protocol: Protocol, env: Env): FeedProvider {
export function getProvider(protocol: Protocol, env: Env): FeedProvider {
switch (protocol) {
case 'nostr':
return new NostrProvider({
@ -58,7 +57,7 @@ function getProvider(protocol: Protocol, env: Env): FeedProvider {
case 'mastodon':
return new MastodonProvider({ instanceUrl: env.GTS_INSTANCE_URL });
case 'threads':
return new ThreadsProvider({ pdsUrl: env.THREADS_PDS_URL });
return new ThreadsProvider();
case 'reddit':
return new RedditProvider();
case 'rss':
@ -73,10 +72,12 @@ function getProvider(protocol: Protocol, env: Env): FeedProvider {
// network, not from the Cloudflare edge.
class RssWithFallbackProvider implements FeedProvider {
readonly protocol: Protocol = 'rss';
private readonly env: Env;
private readonly rmi: RmiDataBusProvider;
private readonly rss: RssProvider;
constructor(env: Env) {
this.env = env;
this.rmi = new RmiDataBusProvider({ backendUrl: rmiUrl(env) });
// Use a small curated set for edge reliability — the full 40+ feed
// list is too slow from Cloudflare Workers.
@ -94,21 +95,28 @@ class RssWithFallbackProvider implements FeedProvider {
}
async fetchFeed(opts: FetchOptions = {}): Promise<FeedResult> {
try {
const result = await this.rmi.fetchFeed(opts);
if (result.posts.length > 0) return result;
} catch {
/* RMI unavailable — fall through */
const rmiUrlValue = rmiUrl(this.env);
const rmiConfigured =
rmiUrlValue && !rmiUrlValue.includes('127.0.0.1') && !rmiUrlValue.includes('localhost');
if (rmiConfigured) {
try {
const result = await this.rmi.fetchFeed({
...opts,
timeoutMs: Math.min(opts.timeoutMs ?? 2000, 2000),
});
if (result.posts.length > 0) return result;
} catch {
/* RMI unavailable — fall through */
}
}
return this.rss.fetchFeed(opts);
}
async healthCheck(opts: FetchOptions = {}): Promise<HealthResult> {
try {
return await this.rmi.healthCheck!(opts);
} catch {
return this.rss.healthCheck!(opts);
if (!this.rmi.healthCheck) {
return { healthy: false, latencyMs: 0, error: 'No health check implemented' };
}
return this.rmi.healthCheck(opts);
}
}
@ -147,9 +155,7 @@ function parsePrefsFromQuery(params: URLSearchParams): UserAlgorithmPreferences
if (!niches && !platforms && !recencyWeight && !engagementWeight) return null;
return {
preferredNiches: niches ? niches.split(',').filter(Boolean) : [],
preferredPlatforms: platforms
? (platforms.split(',').filter(Boolean) as Protocol[])
: [],
preferredPlatforms: platforms ? (platforms.split(',').filter(Boolean) as Protocol[]) : [],
recencyWeight: recencyWeight || 1,
engagementWeight: engagementWeight || 1,
diversityBoost: diversityBoost !== 'false',
@ -165,7 +171,16 @@ interface UserRankingContext {
followed: Set<string>;
mutedAuthors: Set<string>;
mutedWords: Set<string>;
preferences: { preferredNiches: string[]; preferredPlatforms: Protocol[]; recencyWeight: number; engagementWeight: number; diversityBoost: boolean } | null;
preferences: {
preferredNiches: string[];
preferredPlatforms: Protocol[];
recencyWeight: number;
engagementWeight: number;
diversityBoost: boolean;
hideLowQuality?: boolean;
hideLowQualityThreshold?: number;
allowNonCrypto?: boolean;
} | null;
}
async function loadUserRankingContext(request: Request, env: Env): Promise<UserRankingContext> {
@ -178,7 +193,10 @@ async function loadUserRankingContext(request: Request, env: Env): Promise<UserR
const cookie = request.headers.get('cookie');
if (!cookie) return empty;
// Parse session cookie
const match = cookie.split(';').map((s) => s.trim()).find((s) => s.startsWith('__Host-dfid='));
const match = cookie
.split(';')
.map((s) => s.trim())
.find((s) => s.startsWith('__Host-dfid='));
if (!match) return empty;
const value = match.slice('__Host-dfid='.length);
// Verify session
@ -198,6 +216,15 @@ async function loadUserRankingContext(request: Request, env: Env): Promise<UserR
const mutedAuthors = new Set<string>(mutedAuthorsRaw ? JSON.parse(mutedAuthorsRaw) : []);
const mutedWords = new Set<string>(mutedWordsRaw ? JSON.parse(mutedWordsRaw) : []);
const preferences = prefsRaw ? JSON.parse(prefsRaw) : null;
if (preferences && typeof preferences.hideLowQuality !== 'boolean') {
preferences.hideLowQuality = false;
}
if (preferences && typeof preferences.allowNonCrypto !== 'boolean') {
preferences.allowNonCrypto = false;
}
if (preferences && typeof preferences.hideLowQualityThreshold !== 'number') {
preferences.hideLowQualityThreshold = 1.0;
}
return { followed, mutedAuthors, mutedWords, preferences };
} catch {
return empty;
@ -206,11 +233,12 @@ async function loadUserRankingContext(request: Request, env: Env): Promise<UserR
export async function handleHomeFeed(request: Request, env: Env): Promise<Response> {
const params = new URL(request.url).searchParams;
const requestedProtocols = parseProtocols(params.get('protocols'));
const requestedProtocols = parseProtocols(params.get('protocols'), DEFAULT_PROTOCOLS);
const limit = Math.min(Number(params.get('limit')) || 25, 100);
const category = params.get('category')?.toLowerCase() ?? '';
const prefs = parsePrefsFromQuery(params);
const diverse = params.get('diverse') !== 'false';
const queryAllowNonCrypto = params.get('allowNonCrypto') === 'true';
const allPosts: UnifiedPost[] = [];
const errors: string[] = [];
@ -250,6 +278,9 @@ export async function handleHomeFeed(request: Request, env: Env): Promise<Respon
// Load per-user ranking context (followed, muted, preferences) from KV
// via the session cookie. Unauthenticated users get an empty context.
const userCtx = await loadUserRankingContext(request, env);
// Resolve allowNonCrypto from query param OR stored preference early so the
// relevance filter below can use it.
const allowNonCrypto = queryAllowNonCrypto || userCtx.preferences?.allowNonCrypto === true;
// Filter out muted authors and posts containing muted words
if (userCtx.mutedAuthors.size > 0 || userCtx.mutedWords.size > 0) {
deduped = deduped.filter((p) => {
@ -263,19 +294,44 @@ export async function handleHomeFeed(request: Request, env: Env): Promise<Respon
return true;
});
}
const relevant = deduped.filter(isRelevantPost);
// Skip the crypto-gating relevance filter when the user opts into non-crypto
// content. The spam filter still applies so we don't drown in junk.
const relevant = allowNonCrypto ? deduped : deduped.filter(isRelevantPost);
// Merge per-user preferred niches/platforms into the query params
const mergedPrefs: UserAlgorithmPreferences = prefs ?? {
preferredNiches: [],
preferredPlatforms: [],
recencyWeight: 1,
engagementWeight: 1,
diversityBoost: true,
};
if (userCtx.preferences) {
for (const n of userCtx.preferences.preferredNiches) {
if (!prefs.preferredNiches?.includes(n)) prefs.preferredNiches = [...(prefs.preferredNiches ?? []), n];
if (!mergedPrefs.preferredNiches.includes(n)) {
mergedPrefs.preferredNiches = [...mergedPrefs.preferredNiches, n];
}
}
for (const p of userCtx.preferences.preferredPlatforms) {
if (!prefs.preferredPlatforms?.includes(p)) prefs.preferredPlatforms = [...(prefs.preferredPlatforms ?? []), p];
if (!mergedPrefs.preferredPlatforms.includes(p)) {
mergedPrefs.preferredPlatforms = [...mergedPrefs.preferredPlatforms, p];
}
}
if (userCtx.preferences.recencyWeight) prefs.recencyWeight = userCtx.preferences.recencyWeight;
if (userCtx.preferences.engagementWeight) prefs.engagementWeight = userCtx.preferences.engagementWeight;
if (userCtx.preferences.diversityBoost === false) prefs.diversityBoost = false;
if (userCtx.preferences.recencyWeight)
mergedPrefs.recencyWeight = userCtx.preferences.recencyWeight;
if (userCtx.preferences.engagementWeight)
mergedPrefs.engagementWeight = userCtx.preferences.engagementWeight;
if (userCtx.preferences.diversityBoost === false) mergedPrefs.diversityBoost = false;
}
// Resolve hide-low-quality controls. Query params override stored prefs
// for the duration of the request; the persisted preference still wins
// on subsequent fetches.
const queryHideLowQuality = params.get('hideLowQuality') === 'true';
const queryThreshold = Number(params.get('hideLowQualityThreshold'));
const hideLowQuality = queryHideLowQuality || userCtx.preferences?.hideLowQuality === true;
const hideLowQualityThreshold =
Number.isFinite(queryThreshold) && queryThreshold > 0
? queryThreshold
: (userCtx.preferences?.hideLowQualityThreshold ?? 1.0);
// Populate recentAuthors for the diversity bonus: count how many times
// each author appears. Authors that appear more than once get penalized
// so one noisy account doesn't dominate the feed.
@ -288,13 +344,45 @@ export async function handleHomeFeed(request: Request, env: Env): Promise<Respon
sourceQuality.set(key, Math.min(1.5, p.author.followerCount / 10000));
}
}
const ctx = buildRankingContext(Date.now(), prefs, recentAuthors, undefined, sourceQuality);
const ctx = buildRankingContext(Date.now(), mergedPrefs, recentAuthors, undefined, sourceQuality);
// Compute score breakdowns for transparency / hover signals.
const scoredRelevant = relevant.map((p) => ({ post: p, breakdown: scoreBreakdownV2(p, ctx) }));
// Optionally filter low-quality posts at the user's preferred threshold.
// Threshold 0 means "only drop truly negative scores"; higher is stricter.
const filteredScored = hideLowQuality
? scoredRelevant.filter((s) => s.breakdown.total >= hideLowQualityThreshold)
: scoredRelevant;
// rankDiverse: greedy per-author cap ensures a balanced feed
// (otherwise one noisy author can dominate).
const ranked = (diverse ? rankDiverse(relevant, ctx, { maxPerAuthor: 2 }) : [...relevant]).slice(
0,
Math.min(limit, 100),
);
const rankedPosts = (
diverse
? rankDiverse(
filteredScored.map((s) => s.post),
ctx,
{ maxPerAuthor: 2 },
)
: filteredScored.map((s) => s.post)
).slice(0, Math.min(limit, 100));
// Attach score breakdowns by id lookup
const scoreById = new Map(filteredScored.map((s) => [s.post.id, s.breakdown]));
const ranked = rankedPosts.map((p) => {
const breakdown = scoreById.get(p.id);
if (!breakdown) return p;
return {
...p,
score: {
total: breakdown.total,
recency: breakdown.recency,
engagement: breakdown.engagement,
relevance: breakdown.relevance,
quality: breakdown.quality,
diversity: breakdown.diversity,
source: breakdown.source,
velocity: breakdown.velocity,
reasons: breakdown.reasons,
},
};
});
const trending = computeTrendingTopics(relevant, Date.now());
if (ranked.length === 0 && errors.length > 0) {
@ -308,7 +396,8 @@ export async function handleHomeFeed(request: Request, env: Env): Promise<Respon
errors,
notices,
fetchedAt: Date.now(),
note: relevant.length === 0 ? 'No relevant posts found' : 'All sources failed',
note:
relevant.length === 0 ? 'No relevant posts found' : 'All sources failed or filtered',
},
},
200,
@ -353,10 +442,18 @@ export async function handleRedditFeed(request: Request, env: Env): Promise<Resp
try {
const cached = await env.CACHE?.get(cacheKey);
if (cached) return jsonResponse(JSON.parse(cached));
} catch { /* ignore */ }
} catch {
/* ignore */
}
const { RedditProvider } = await import('@degenfeed/feed-providers') as unknown as {
RedditProvider: new (options?: { subreddits?: string[] }) => { fetchFeed: (opts?: { timeoutMs?: number }) => Promise<{ posts: import('@degenfeed/types').UnifiedPost[]; notice?: string }> };
const { RedditProvider } = (await import('@degenfeed/feed-providers')) as unknown as {
RedditProvider: new (options?: {
subreddits?: string[];
}) => {
fetchFeed: (opts?: {
timeoutMs?: number;
}) => Promise<{ posts: import('@degenfeed/types').UnifiedPost[]; notice?: string }>;
};
};
const provider = subreddit
? new RedditProvider({ subreddits: [subreddit] })
@ -375,7 +472,9 @@ export async function handleRedditFeed(request: Request, env: Env): Promise<Resp
};
try {
await env.CACHE?.put(cacheKey, JSON.stringify(response), { expirationTtl: 180 });
} catch { /* ignore */ }
} catch {
/* ignore */
}
return jsonResponse(response);
}

View file

@ -5,7 +5,7 @@
*/
import type { UnifiedIdentity, UnifiedPost } from '@degenfeed/types';
import { type Env, jsonResponse } from '../index';
import { type Env, noCacheJsonResponse } from '../index';
interface NewsSource {
name: string;
@ -653,7 +653,7 @@ export async function handleNewsFeed(request: Request, env: Env): Promise<Respon
const cached = await env.CACHE?.get(cacheKey);
if (cached) {
const parsed = JSON.parse(cached);
return jsonResponse(parsed);
return noCacheJsonResponse(parsed);
}
} catch {
/* ignore */
@ -736,5 +736,5 @@ export async function handleNewsFeed(request: Request, env: Env): Promise<Respon
/* ignore */
}
return jsonResponse(response);
return noCacheJsonResponse(response);
}

View file

@ -16,30 +16,18 @@
* ranked by the v2 algorithm, and formatted for email consumption.
*/
import { rmiUrl } from '@degenfeed/utils';
import {
deduplicatePosts,
enrichPostNiches,
isRelevantPost,
isSpamPost,
} from '@degenfeed/feed-core';
import {
RmiDataBusProvider,
RssProvider,
} from '@degenfeed/feed-providers';
import { RmiDataBusProvider, RssProvider } from '@degenfeed/feed-providers';
import { computeTrendingTopics, rankDiverse } from '@degenfeed/ranking';
import type { Protocol, UnifiedPost } from '@degenfeed/types';
import { type Env, jsonResponse } from '../index';
const DEFAULT_PROTOCOLS: Protocol[] = [
'rss',
'nostr',
'farcaster',
'lens',
'bluesky',
'mastodon',
'threads',
];
import { rmiUrl } from '@degenfeed/utils';
import { type Env, jsonResponse, noCacheJsonResponse } from '../index';
import { DEFAULT_PROTOCOLS, getProvider } from './feed';
const ALLOWED_NICHES = new Set([
'bitcoin',
@ -59,7 +47,6 @@ const ALLOWED_NICHES = new Set([
'crypto',
]);
function isValidEmail(s: string): boolean {
if (typeof s !== 'string') return false;
if (s.length > 254) return false;
@ -136,40 +123,65 @@ async function gatherDigestPosts(
const all: UnifiedPost[] = [];
const timeoutMs = 5000;
// RMI DataBus (institutional-grade news, 200+ sources)
// RMI DataBus (institutional-grade news, 200+ sources) — only reachable from internal network.
try {
const rmi = new RmiDataBusProvider({ backendUrl: rmiUrl(env) });
const r = await rmi.fetchFeed({ timeoutMs });
if (r.posts.length > 0) all.push(...r.posts);
const rmiUrlValue = rmiUrl(env);
const rmiConfigured =
rmiUrlValue && !rmiUrlValue.includes('127.0.0.1') && !rmiUrlValue.includes('localhost');
if (rmiConfigured) {
const rmi = new RmiDataBusProvider({ backendUrl: rmiUrlValue });
const r = await rmi.fetchFeed({ timeoutMs });
if (r.posts.length > 0) all.push(...r.posts);
}
} catch {
/* RMI unavailable from edge */
}
// Public RSS feeds (fast, reliable)
const fastFeeds = [
'https://cointelegraph.com/rss',
'https://decrypt.co/feed',
'https://bitcoinmagazine.com/.rss/full/',
'https://blog.ethereum.org/feed.xml',
'https://blog.chain.link/feed/',
'https://bankless.substack.com/feed',
'https://weekinethereumnews.com/feed',
];
const results = await Promise.allSettled(
fastFeeds.map(async (url) => {
try {
const rss = new RssProvider({ feeds: [url] });
const r = await rss.fetchFeed({ timeoutMs: 3000 });
return r.posts;
} catch {
return [] as UnifiedPost[];
}
}),
// Fetch from each selected protocol so the digest is truly multi-protocol.
const providerResults = await Promise.allSettled(
protocols
.filter((p) => p !== 'rss')
.map(async (protocol) => {
try {
const provider = getProvider(protocol, env);
const result = await provider.fetchFeed({ timeoutMs, concurrency: 4 });
return result.posts;
} catch {
return [] as UnifiedPost[];
}
}),
);
for (const r of results) {
for (const r of providerResults) {
if (r.status === 'fulfilled') all.push(...r.value);
}
// Public RSS feeds (fast, reliable) — used when RSS is selected or as a fallback.
if (protocols.includes('rss')) {
const fastFeeds = [
'https://cointelegraph.com/rss',
'https://decrypt.co/feed',
'https://bitcoinmagazine.com/.rss/full/',
'https://blog.ethereum.org/feed.xml',
'https://blog.chain.link/feed/',
'https://bankless.substack.com/feed',
'https://weekinethereumnews.com/feed',
];
const results = await Promise.allSettled(
fastFeeds.map(async (url) => {
try {
const rss = new RssProvider({ feeds: [url] });
const r = await rss.fetchFeed({ timeoutMs: 3000 });
return r.posts;
} catch {
return [] as UnifiedPost[];
}
}),
);
for (const r of results) {
if (r.status === 'fulfilled') all.push(...r.value);
}
}
let filtered = all.filter((p) => !isSpamPost(p)).map(enrichPostNiches);
if (niches.length > 0) {
filtered = filtered.filter((p) => p.niches.some((n) => niches.includes(n)));
@ -203,7 +215,7 @@ function buildDigestHtml(
): string {
const list = posts
.slice(0, 15)
.map((p, i) => {
.map((p) => {
const link =
p.embeds.find((e) => e.kind === 'link')?.url ||
`https://degenfeed.xyz/post/${encodeURIComponent(p.id)}`;
@ -376,10 +388,12 @@ export async function handleNewsletterDigest(request: Request, env: Env): Promis
.filter((p): p is Protocol => DEFAULT_PROTOCOLS.includes(p as Protocol))
: DEFAULT_PROTOCOLS;
const cacheKey = `newsletter:digest:v2:${niches.join(',') || 'all'}:${protocols.join(',')}:${new Date().toDateString()}`;
// Cache bucketed to 5 minutes so preview loads stay fast but never stale.
const fiveMinBucket = Math.floor(Date.now() / (1000 * 60 * 5));
const cacheKey = `newsletter:digest:v3:${niches.join(',') || 'all'}:${protocols.join(',')}:${fiveMinBucket}`;
try {
const cached = await env.CACHE?.get(cacheKey);
if (cached && fmt === 'json') return jsonResponse(JSON.parse(cached));
if (cached && fmt === 'json') return noCacheJsonResponse(JSON.parse(cached));
} catch {
/* ignore */
}
@ -394,7 +408,7 @@ export async function handleNewsletterDigest(request: Request, env: Env): Promis
status: 200,
headers: {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'public, max-age=600',
'cache-control': 'no-store, must-revalidate',
},
});
}
@ -404,7 +418,7 @@ export async function handleNewsletterDigest(request: Request, env: Env): Promis
status: 200,
headers: {
'content-type': 'text/plain; charset=utf-8',
'cache-control': 'public, max-age=600',
'cache-control': 'no-store, must-revalidate',
},
});
}
@ -431,11 +445,11 @@ export async function handleNewsletterDigest(request: Request, env: Env): Promis
},
};
try {
await env.CACHE?.put(cacheKey, JSON.stringify(response), { expirationTtl: 600 });
await env.CACHE?.put(cacheKey, JSON.stringify(response), { expirationTtl: 300 });
} catch {
/* ignore */
}
return jsonResponse(response);
return noCacheJsonResponse(response);
}
export async function handleNewsletterStatus(_request: Request, env: Env): Promise<Response> {

View file

@ -1,4 +1,5 @@
import { type Env, jsonResponse } from '../index';
import { type Env, noCacheJsonResponse } from '../index';
import { requireSession } from '../session';
interface Notification {
id: string;
@ -15,6 +16,21 @@ interface Notification {
export type { Notification };
interface NotificationAuth {
nostr_pubkey?: string;
mastodon?: string;
mastodon_instance?: string;
bluesky?: {
handle?: string;
did?: string;
accessJwt?: string;
};
farcaster?: {
fid?: string;
signerKey?: string;
};
}
interface MastodonAccount {
id: string;
display_name?: string;
@ -36,16 +52,36 @@ interface MastodonNotification {
status?: MastodonStatus;
}
interface BlueskyNotification {
uri: string;
cid: string;
author: {
did: string;
handle: string;
displayName?: string;
avatar?: string;
};
reason: 'like' | 'repost' | 'follow' | 'mention' | 'reply' | string;
reasonSubject?: string;
record?: { text?: string };
isRead: boolean;
indexedAt: string;
}
function npubToHex(npubOrHex: string): string {
if (!npubOrHex) return '';
if (npubOrHex.startsWith('npub1') && npubOrHex.length === 62) {
try {
const mod = require('@degenfeed/nostr-sdk') as { nip19?: { decode?: (s: string) => { type: string; data: string } } };
const mod = require('@degenfeed/nostr-sdk') as {
nip19?: { decode?: (s: string) => { type: string; data: string } };
};
const decoded = mod.nip19?.decode?.(npubOrHex);
if (decoded && decoded.type === 'npub' && typeof decoded.data === 'string') {
return decoded.data;
}
} catch { /* fall through */ }
} catch {
/* fall through */
}
}
return npubOrHex;
}
@ -75,7 +111,10 @@ async function fetchNostrNotifications(pubkey: string): Promise<Notification[]>
}
const mentions = await pool.list({ kinds: [1], limit: 20 }, 3000);
for (const ev of mentions) {
if (ev.pubkey === hexPubkey || !ev.tags.some((t: string[]) => t[0] === 'p' && t[1] === hexPubkey))
if (
ev.pubkey === hexPubkey ||
!ev.tags.some((t: string[]) => t[0] === 'p' && t[1] === hexPubkey)
)
continue;
results.push({
id: `nostr:mention:${ev.id}`,
@ -129,30 +168,90 @@ async function fetchMastodonNotifications(
return results;
}
export async function handleNotifications(request: Request, _env: Env): Promise<Response> {
const url = new URL(request.url);
let auth: Record<string, string> = {};
async function fetchBlueskyNotifications(
pdsUrl: string,
accessJwt: string,
): Promise<Notification[]> {
const results: Notification[] = [];
try {
auth = JSON.parse(decodeURIComponent(url.searchParams.get('auth') || '{}'));
const res = await fetch(`${pdsUrl}/xrpc/app.bsky.notification.listNotifications?limit=30`, {
headers: {
authorization: `Bearer ${accessJwt}`,
accept: 'application/json',
'user-agent': 'DegenFeed/0.1 (+https://degenfeed.xyz)',
},
});
if (!res.ok) return results;
const data = (await res.json()) as { notifications?: BlueskyNotification[] };
for (const n of data.notifications ?? []) {
const typeMap: Record<string, 'like' | 'repost' | 'reply' | 'follow' | 'mention'> = {
like: 'like',
repost: 'repost',
reply: 'reply',
follow: 'follow',
mention: 'mention',
};
results.push({
id: `bluesky:${n.uri}`,
type: typeMap[n.reason] || 'mention',
protocol: 'bluesky',
actorName: n.author.displayName || n.author.handle,
actorHandle: n.author.handle,
actorAvatar: n.author.avatar,
postId: n.reasonSubject || n.uri,
postText: n.record?.text?.slice(0, 100),
createdAt: new Date(n.indexedAt).getTime(),
read: n.isRead,
});
}
} catch {}
return results;
}
async function parseAuth(request: Request): Promise<NotificationAuth> {
if (request.method === 'POST') {
try {
const body = (await request.json()) as { auth?: NotificationAuth };
return body.auth ?? {};
} catch {}
}
try {
const url = new URL(request.url);
return JSON.parse(decodeURIComponent(url.searchParams.get('auth') || '{}')) as NotificationAuth;
} catch {}
return {};
}
export async function handleNotifications(request: Request, env: Env): Promise<Response> {
const session = await requireSession(request, env);
if (session instanceof Response) return session;
const auth = await parseAuth(request);
const all: Notification[] = [];
const npub = auth.nostr_pubkey || '';
if (npub) {
if (auth.nostr_pubkey) {
try {
all.push(...(await fetchNostrNotifications(npub)));
all.push(...(await fetchNostrNotifications(auth.nostr_pubkey)));
} catch {}
}
const mToken = auth.mastodon || '';
const mInst = auth.mastodon_instance || 'https://social.degenfeed.xyz';
if (mToken) {
if (auth.mastodon) {
try {
all.push(...(await fetchMastodonNotifications(mInst, mToken)));
all.push(
...(await fetchMastodonNotifications(
auth.mastodon_instance || 'https://social.degenfeed.xyz',
auth.mastodon,
)),
);
} catch {}
}
if (auth.bluesky?.accessJwt) {
try {
all.push(...(await fetchBlueskyNotifications('https://bsky.social', auth.bluesky.accessJwt)));
} catch {}
}
all.sort((a, b) => b.createdAt - a.createdAt);
return jsonResponse({ notifications: all.slice(0, 50), meta: { total: all.length } });
return noCacheJsonResponse({ notifications: all.slice(0, 50), meta: { total: all.length } });
}

View file

@ -5,7 +5,6 @@
* Cached for 5 minutes in KV.
*/
import { rmiUrl } from '@degenfeed/utils';
import { enrichPostNiches, isSpamPost } from '@degenfeed/feed-core';
import {
BlueskyProvider,
@ -19,9 +18,9 @@ import {
ThreadsProvider,
} from '@degenfeed/feed-providers';
import type { Protocol, UnifiedPost } from '@degenfeed/types';
import { rmiUrl } from '@degenfeed/utils';
import { type Env, jsonResponse } from '../index';
function providerFor(protocol: Protocol, env: Env) {
switch (protocol) {
case 'rss':
@ -46,11 +45,18 @@ function providerFor(protocol: Protocol, env: Env) {
}
const ALL_PROTOCOLS: Protocol[] = [
'rss', 'reddit', 'nostr', 'farcaster', 'lens', 'bluesky', 'mastodon', 'threads',
'rss',
'reddit',
'nostr',
'farcaster',
'lens',
'bluesky',
'mastodon',
'threads',
];
export async function handlePostById(
request: Request,
_request: Request,
env: Env,
postId: string,
): Promise<Response> {
@ -59,14 +65,16 @@ export async function handlePostById(
try {
const cached = await env.CACHE?.get(cacheKey);
if (cached) return jsonResponse(JSON.parse(cached));
} catch { /* ignore */ }
} catch {
/* ignore */
}
for (const protocol of ALL_PROTOCOLS) {
const provider = providerFor(protocol, env);
if (!provider) continue;
try {
const result = await Promise.race([
provider.fetchFeed({ timeoutMs: 4000, limit: 50 }),
provider.fetchFeed({ timeoutMs: 4000 }),
new Promise<{ posts: UnifiedPost[] }>((resolve) =>
setTimeout(() => resolve({ posts: [] }), 4000),
),
@ -78,7 +86,9 @@ export async function handlePostById(
const response = { data: enriched };
try {
await env.CACHE?.put(cacheKey, JSON.stringify(response), { expirationTtl: 300 });
} catch { /* ignore */ }
} catch {
/* ignore */
}
return jsonResponse(response);
}
} catch {
@ -87,18 +97,24 @@ export async function handlePostById(
}
try {
const rss = new RssProvider({ feeds: ['https://cointelegraph.com/rss', 'https://decrypt.co/feed'] });
const r = await rss.fetchFeed({ timeoutMs: 4000, limit: 50 });
const rss = new RssProvider({
feeds: ['https://cointelegraph.com/rss', 'https://decrypt.co/feed'],
});
const r = await rss.fetchFeed({ timeoutMs: 4000 });
const found = r.posts.find((p) => p.id === decoded);
if (found && !isSpamPost(found)) {
const enriched = enrichPostNiches(found);
const response = { data: enriched };
try {
await env.CACHE?.put(cacheKey, JSON.stringify(response), { expirationTtl: 300 });
} catch { /* ignore */ }
} catch {
/* ignore */
}
return jsonResponse(response);
}
} catch { /* ignore */ }
} catch {
/* ignore */
}
return jsonResponse({ error: 'Post not found', id: decoded }, 404);
}

View file

@ -0,0 +1,127 @@
/**
* /api/preferences User ranking preferences (KV-backed)
*
* Stores per-user ranking preferences in the `user:prefs:<address>` KV
* namespace so they apply across devices/sessions. Currently supported:
*
* - hideLowQuality: boolean drop posts with score < 1.0 from feeds
* - preferredNiches: string[] boost posts in these niches
* - preferredPlatforms: Protocol[] boost posts from these platforms
* - mutedNiches: string[] penalize posts in these niches
* - recencyWeight: number
* - engagementWeight: number
* - diversityBoost: boolean
*
* Auth: requires a valid session cookie (`__Host-dfid`).
*/
import type { Protocol } from '@degenfeed/types';
import { type Env, jsonResponse, noCacheJsonResponse } from '../index';
import { requireSession } from '../session';
interface PreferencesBody {
hideLowQuality?: boolean;
hideLowQualityThreshold?: number;
preferredNiches?: string[];
preferredPlatforms?: string[];
mutedNiches?: string[];
recencyWeight?: number;
engagementWeight?: number;
diversityBoost?: boolean;
allowNonCrypto?: boolean;
}
interface StoredPreferences {
hideLowQuality: boolean;
hideLowQualityThreshold: number;
preferredNiches: string[];
preferredPlatforms: Protocol[];
mutedNiches: string[];
recencyWeight: number;
engagementWeight: number;
diversityBoost: boolean;
allowNonCrypto: boolean;
updatedAt: number;
}
const DEFAULT_PREFS: StoredPreferences = {
hideLowQuality: false,
hideLowQualityThreshold: 1.0,
preferredNiches: [],
preferredPlatforms: [],
mutedNiches: [],
recencyWeight: 1,
engagementWeight: 1,
diversityBoost: true,
allowNonCrypto: false,
updatedAt: 0,
};
function clamp(n: unknown, min: number, max: number, fallback: number): number {
if (typeof n !== 'number' || !Number.isFinite(n)) return fallback;
return Math.max(min, Math.min(max, n));
}
export function sanitizePrefs(body: PreferencesBody): StoredPreferences {
return {
hideLowQuality: typeof body.hideLowQuality === 'boolean' ? body.hideLowQuality : false,
preferredNiches: Array.isArray(body.preferredNiches)
? body.preferredNiches.map((n) => String(n).toLowerCase().trim()).filter((n) => n.length > 0)
: [],
preferredPlatforms: Array.isArray(body.preferredPlatforms)
? body.preferredPlatforms.filter((p): p is Protocol => typeof p === 'string')
: [],
mutedNiches: Array.isArray(body.mutedNiches)
? body.mutedNiches.map((n) => String(n).toLowerCase().trim()).filter((n) => n.length > 0)
: [],
recencyWeight: clamp(body.recencyWeight, 0.1, 5, 1),
engagementWeight: clamp(body.engagementWeight, 0.1, 5, 1),
diversityBoost: typeof body.diversityBoost === 'boolean' ? body.diversityBoost : true,
allowNonCrypto: typeof body.allowNonCrypto === 'boolean' ? body.allowNonCrypto : false,
hideLowQualityThreshold: clamp(body.hideLowQualityThreshold, 0, 10, 1),
updatedAt: Date.now(),
};
}
function prefsKey(address: string): string {
return `user:prefs:${address.toLowerCase()}`;
}
export async function handlePreferences(request: Request, env: Env): Promise<Response> {
const session = await requireSession(request, env);
if (session instanceof Response) return session;
if (request.method === 'GET') {
try {
const raw = await env.CACHE?.get(prefsKey(session.address));
if (raw) {
const parsed = JSON.parse(raw) as Partial<StoredPreferences>;
return noCacheJsonResponse({ ...DEFAULT_PREFS, ...parsed });
}
} catch {
/* ignore */
}
return noCacheJsonResponse(DEFAULT_PREFS);
}
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, 405);
}
let body: PreferencesBody = {};
try {
body = (await request.json()) as PreferencesBody;
} catch {
return jsonResponse({ error: 'Invalid JSON' }, 400);
}
const sanitized = sanitizePrefs(body);
try {
await env.CACHE?.put(prefsKey(session.address), JSON.stringify(sanitized), {
expirationTtl: 60 * 60 * 24 * 400,
});
} catch {
return jsonResponse({ error: 'Failed to persist preferences' }, 500);
}
return noCacheJsonResponse({ ok: true, preferences: sanitized });
}

View file

@ -13,13 +13,11 @@
* GET /api/rmi/news/stats /api/v1/news/stats
* GET /api/rmi/health rmi-backend up check
*/
import { rmiUrl } from '@degenfeed/utils';
import { RmiDataBusProvider, type RmiFeedResponse } from '@degenfeed/feed-providers';
import type { Env, UnifiedPost } from '../index';
import { jsonResponse, type Env as WorkerEnv } from '../index';
const DEFAULT_RMI_URL = 'http://127.0.0.1:8000';
import { rmiUrl } from '@degenfeed/utils';
import type { Env } from '../index';
import { jsonResponse } from '../index';
function provider(env: Env): RmiDataBusProvider {
return new RmiDataBusProvider({ backendUrl: rmiUrl(env) });
@ -35,7 +33,6 @@ export async function handleRmiNews(request: Request, env: Env): Promise<Respons
const refresh = url.searchParams.get('refresh') === 'true';
try {
const p = provider(env);
const backendUrl = buildUrl(rmiUrl(env), '/api/v1/news/feed', {
limit: String(limit),
...(category ? { category } : {}),

View file

@ -11,20 +11,16 @@
* q free-text query filter
*/
import { parseProtocols, rmiUrl } from '@degenfeed/utils';
import {
deduplicatePosts,
enrichPostNiches,
isRelevantPost,
isSpamPost,
} from '@degenfeed/feed-core';
import {
RmiDataBusProvider,
RssProvider,
} from '@degenfeed/feed-providers';
import { RmiDataBusProvider, RssProvider } from '@degenfeed/feed-providers';
import type { Protocol, UnifiedPost } from '@degenfeed/types';
import { type Env, jsonResponse } from '../index';
import { parseProtocols, rmiUrl } from '@degenfeed/utils';
import type { Env } from '../index';
function escapeXml(s: string): string {
return s
@ -145,9 +141,6 @@ async function gather(env: Env, _requested: Protocol[]): Promise<UnifiedPost[]>
return all;
}
const DEFAULT_PROTOCOLS: Protocol[] = ['rss', 'nostr', 'farcaster', 'lens', 'bluesky', 'mastodon'];
export async function handleRssXml(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const category = url.searchParams.get('category')?.toLowerCase() ?? '';

View file

@ -9,7 +9,6 @@
* GET /api/search/identity?q=handle&limit=5
*/
import { rmiUrl } from '@degenfeed/utils';
import {
deduplicatePosts,
enrichPostNiches,
@ -27,7 +26,6 @@ import { type Env, jsonResponse } from '../index';
const DEFAULT_PROTOCOLS: Protocol[] = ['rss', 'nostr', 'farcaster', 'lens', 'bluesky', 'mastodon'];
export async function handleSearch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const q = url.searchParams.get('q')?.toLowerCase().trim() ?? '';
@ -35,14 +33,18 @@ export async function handleSearch(request: Request, env: Env): Promise<Response
const limit = Math.min(Number(url.searchParams.get('limit')) || 25, 100);
const protocolsParam = url.searchParams.get('protocols') ?? '';
const protocols = protocolsParam
? protocolsParam.split(',').filter((p): p is Protocol => DEFAULT_PROTOCOLS.includes(p as Protocol))
? protocolsParam
.split(',')
.filter((p): p is Protocol => DEFAULT_PROTOCOLS.includes(p as Protocol))
: DEFAULT_PROTOCOLS;
const cacheKey = `search:v1:${q}:${protocols.join(',')}:${limit}`;
try {
const cached = await env.CACHE?.get(cacheKey);
if (cached) return jsonResponse(JSON.parse(cached));
} catch { /* ignore */ }
} catch {
/* ignore */
}
const all: UnifiedPost[] = [];
const timeoutMs = 2000;
@ -51,31 +53,50 @@ export async function handleSearch(request: Request, env: Env): Promise<Response
const hardDeadline = 5000;
const providers: { protocol: Protocol; fetch: () => Promise<UnifiedPost[]> }[] = [
{ protocol: 'rss', fetch: async () => {
const rss = new RssProvider({ feeds: ['https://cointelegraph.com/rss', 'https://decrypt.co/feed', 'https://bitcoinmagazine.com/.rss/full/', 'https://www.theblock.co/rss'] });
const r = await rss.fetchFeed({ timeoutMs });
return r.posts;
} },
{ protocol: 'nostr', fetch: async () => {
const p = new NostrProvider({ relays: (env.RELAY_URLS ?? '').split(',').filter(Boolean) });
const r = await p.fetchFeed({ timeoutMs });
return r.posts;
} },
{ protocol: 'bluesky', fetch: async () => {
const p = new BlueskyProvider({ baseUrl: env.BSKY_PUBLIC_URL });
const r = await p.fetchFeed({ timeoutMs });
return r.posts;
} },
{ protocol: 'mastodon', fetch: async () => {
const p = new MastodonProvider({ instanceUrl: env.GTS_INSTANCE_URL });
const r = await p.fetchFeed({ timeoutMs });
return r.posts;
} },
{
protocol: 'rss',
fetch: async () => {
const rss = new RssProvider({
feeds: [
'https://cointelegraph.com/rss',
'https://decrypt.co/feed',
'https://bitcoinmagazine.com/.rss/full/',
'https://www.theblock.co/rss',
],
});
const r = await rss.fetchFeed({ timeoutMs });
return r.posts;
},
},
{
protocol: 'nostr',
fetch: async () => {
const p = new NostrProvider({ relays: (env.RELAY_URLS ?? '').split(',').filter(Boolean) });
const r = await p.fetchFeed({ timeoutMs });
return r.posts;
},
},
{
protocol: 'bluesky',
fetch: async () => {
const p = new BlueskyProvider({ baseUrl: env.BSKY_PUBLIC_URL });
const r = await p.fetchFeed({ timeoutMs });
return r.posts;
},
},
{
protocol: 'mastodon',
fetch: async () => {
const p = new MastodonProvider({ instanceUrl: env.GTS_INSTANCE_URL });
const r = await p.fetchFeed({ timeoutMs });
return r.posts;
},
},
];
const selected = providers.filter((p) => protocols.includes(p.protocol));
// Race all providers against a hard deadline
const deadlinePromise = new Promise<never>((resolve) => {
const deadlinePromise = new Promise<void>((resolve) => {
setTimeout(() => resolve(), hardDeadline);
});
const results = await Promise.race([
@ -90,7 +111,8 @@ export async function handleSearch(request: Request, env: Env): Promise<Response
.filter((p) => !isSpamPost(p))
.map(enrichPostNiches)
.filter((p) => {
const text = `${p.text} ${p.author.displayName} ${p.author.bio} ${p.niches.join(' ')}`.toLowerCase();
const text =
`${p.text} ${p.author.displayName} ${p.author.bio} ${p.niches.join(' ')}`.toLowerCase();
return text.includes(q);
});
@ -115,7 +137,9 @@ export async function handleSearch(request: Request, env: Env): Promise<Response
try {
await env.CACHE?.put(cacheKey, JSON.stringify(response), { expirationTtl: 120 });
} catch { /* ignore */ }
} catch {
/* ignore */
}
return jsonResponse(response);
}
@ -124,7 +148,6 @@ export async function handleSearchIdentity(request: Request, _env: Env): Promise
const url = new URL(request.url);
const q = url.searchParams.get('q')?.toLowerCase().trim() ?? '';
if (!q || q.length < 2) return jsonResponse({ error: 'Query too short' }, 400);
const limit = Math.min(Number(url.searchParams.get('limit')) || 5, 20);
// Resolve via the identity package
try {
@ -136,7 +159,9 @@ export async function handleSearchIdentity(request: Request, _env: Env): Promise
meta: { query: q, total: 1 },
});
}
} catch { /* identity resolution unavailable */ }
} catch {
/* identity resolution unavailable */
}
return jsonResponse({
data: [],

View file

@ -117,6 +117,21 @@ export async function handleSessionVerify(request: Request, env: Env): Promise<R
});
}
export function clearSessionCookie(): string {
return `${COOKIE_NAME}=; Max-Age=0; Path=/; HttpOnly; Secure; SameSite=Lax`;
}
export async function handleSessionSignOut(_request: Request, _env: Env): Promise<Response> {
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
'content-type': 'application/json',
'set-cookie': clearSessionCookie(),
'cache-control': 'no-store',
},
});
}
export async function requireSession(
request: Request,
env: Env,

View file

@ -3,24 +3,68 @@ main = "src/index.ts"
compatibility_date = "2026-07-07"
compatibility_flags = ["nodejs_compat"]
# KV namespaces
# Create once with:
# wrangler kv:namespace create CACHE
# wrangler kv:namespace create CACHE --preview
# wrangler kv:namespace create SUBSCRIPTIONS
# wrangler kv:namespace create SUBSCRIPTIONS --preview
# Then paste the returned ids below (and update [env.preview] if you use --env).
#
# Production ids must match `[[kv_namespaces]]` ids. Preview ids go under [env.preview].
[[kv_namespaces]]
binding = "CACHE"
id = "7a27990414c444efa9116e843ee03e19"
preview_id = "49c1ba7cfab84768886507b3a3778fc1"
[[kv_namespaces]]
binding = "SUBSCRIPTIONS"
id = "f7051e909e5b46cfbbea872741955828"
preview_id = "6c8cec9665dd44fda8c869ce1e675ae8"
# Routes — main app + api on degenfeed.xyz domain
routes = [
{ pattern = "degenfeed.xyz/api/*", zone_id = "116a9676c3358def6796910ccb96f1fc" },
{ pattern = "app.degenfeed.xyz/api/*", zone_id = "116a9676c3358def6796910ccb96f1fc" },
{ pattern = "degenfeed.xyz/*", zone_id = "116a9676c3358def6796910ccb96f1fc" }
]
# GTS_CLIENT_SECRET is a secret. Set it via:
# Secrets — set with:
# wrangler secret put AUTH_SECRET
# wrangler secret put GTS_CLIENT_SECRET
# Do not add it to [vars].
# Generate AUTH_SECRET with: openssl rand -hex 32
# GTS_CLIENT_SECRET is from your GotoSocial app config.
# Secrets are NEVER committed to git; only set via wrangler.
#
# [env.preview] secrets can be different (use --env preview on wrangler).
[vars]
GTS_CLIENT_ID = "01X3AAWH39P71TXZVKMEGAKMVC"
# Non-secret config — safe to commit
GTS_CLIENT_ID = "01X3AAWH39P71TXZVKMEGAKMVC" # gitleaks:allow
GTS_INSTANCE_URL = "https://social.degenfeed.xyz"
GTS_API_BASE = "https://social.degenfeed.xyz"
GTS_REDIRECT_URI = "https://app.degenfeed.xyz/auth/callback"
RELAY_URLS = "wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.nostr.band,wss://nostr.wine"
RELAY_URLS = "wss://relay.damus.io,wss://relay.primal.net"
FARCASTER_HUB_URL = "https://hub.farcaster.standardcrypto.vc:2281"
LENS_API_URL = "https://api.lens.xyz/graphql"
BSKY_PUBLIC_URL = "https://public.api.bsky.app"
THREADS_PDS_URL = "https://public.threads.net"
# Comma-separated list of allowed CORS origins for state-changing requests.
# Add your own preview hosts here.
ALLOWED_ORIGINS = "https://degenfeed.xyz,https://app.degenfeed.xyz,https://social.degenfeed.xyz,http://localhost:3000"
RATE_WINDOW_MS = "5000"
RATE_MAX_REQ = "20"
RATE_MAX_REQ = "60"
RATE_LIMIT_DISABLED = "false"
# Optional: analytics / monitoring
SENTRY_DSN = ""
# Per-environment overrides. Preview deploys (PR builds) use --env preview.
[env.preview]
name = "degenfeed-api-preview"
routes = [
{ pattern = "preview.degenfeed.xyz/api/*", zone_id = "116a9676c3358def6796910ccb96f1fc" },
{ pattern = "preview.degenfeed.xyz/*", zone_id = "116a9676c3358def6796910ccb96f1fc" }
]
[env.preview.vars]
ALLOWED_ORIGINS = "https://preview.degenfeed.xyz,https://app.preview.degenfeed.xyz,http://localhost:3000"
RATE_MAX_REQ = "600"