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:
parent
1d09b43507
commit
5b394f6c92
99 changed files with 5744 additions and 1385 deletions
115
.github/workflows/deploy.yml
vendored
Normal file
115
.github/workflows/deploy.yml
vendored
Normal 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
9
.gitleaks.toml
Normal 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
154
DEPLOYMENT.md
Normal 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`.
|
||||
|
|
@ -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
39
STATUS.md
Normal 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 #1–15).
|
||||
|
||||
## 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
111
TODO.md
|
|
@ -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 60–180s 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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%);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /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>
|
||||
|
|
|
|||
|
|
@ -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] || '?'
|
||||
|
|
|
|||
184
apps/web/src/app/status/StatusClient.tsx
Normal file
184
apps/web/src/app/status/StatusClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/app/status/page.tsx
Normal file
66
apps/web/src/app/status/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
594
apps/web/src/app/twitter/page.tsx
Normal file
594
apps/web/src/app/twitter/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
58
apps/web/src/components/BottomNav.tsx
Normal file
58
apps/web/src/components/BottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
115
apps/web/src/components/Toast.tsx
Normal file
115
apps/web/src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
apps/web/src/components/TwitterComposer.tsx
Normal file
254
apps/web/src/components/TwitterComposer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
apps/web/src/components/TwitterSidebar.tsx
Normal file
179
apps/web/src/components/TwitterSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
155
apps/web/src/hooks/useNotifications.ts
Normal file
155
apps/web/src/hooks/useNotifications.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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
100
apps/web/src/lib/nostr.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
11
biome.json
11
biome.json
|
|
@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -99,5 +99,4 @@ describe('@degenfeed/feed-core', () => {
|
|||
});
|
||||
expect(deduplicatePosts([a, b])).toHaveLength(1);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ async function resolveLensHandle(
|
|||
}
|
||||
}
|
||||
|
||||
async function resolveThreadsHandle(
|
||||
async function _resolveThreadsHandle(
|
||||
_handle: string,
|
||||
_fetchFn: typeof fetch,
|
||||
): Promise<HandleResolution | null> {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -45,4 +45,4 @@ export class ThreadsClient {
|
|||
}
|
||||
}
|
||||
|
||||
export { BskyClient, BSKY_PUBLIC_URL, BSKY_SOCIAL_URL };
|
||||
export { BSKY_PUBLIC_URL, BSKY_SOCIAL_URL, BskyClient };
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@degenfeed/storage": "workspace:*",
|
||||
"@degenfeed/types": "workspace:*",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
93
packages/ui/src/ComposePreview.tsx
Normal file
93
packages/ui/src/ComposePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 50–100 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
8
packages/utils/tsconfig.json
Normal file
8
packages/utils/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
99
pnpm-lock.yaml
generated
99
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
||||
|
|
|
|||
206
scripts/deploy/setup-cloudflare.sh
Normal file
206
scripts/deploy/setup-cloudflare.sh
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
104
workers/api/src/__tests__/auth.state.test.ts
Normal file
104
workers/api/src/__tests__/auth.state.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
52
workers/api/src/__tests__/preferences.sanitize.test.ts
Normal file
52
workers/api/src/__tests__/preferences.sanitize.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
98
workers/api/src/__tests__/preferences.test.ts
Normal file
98
workers/api/src/__tests__/preferences.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 } });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
127
workers/api/src/routes/preferences.ts
Normal file
127
workers/api/src/routes/preferences.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 } : {}),
|
||||
|
|
|
|||
|
|
@ -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() ?? '';
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue