Merge branch 'main' into cooking/planetora
@@ -12,7 +12,7 @@
|
||||
|
||||
| Kind | Name | Description |
|
||||
|-------|----------------------------|----------------------------------------------------------------|
|
||||
| 30223 | Campaign | Fundraising campaign with a list of on-chain Bitcoin recipients |
|
||||
| 33863 | Campaign | Self-authored fundraising campaign with a single Bitcoin wallet endpoint (`bc1...` or `sp1...`) |
|
||||
| 30385 | Community Stats Snapshot | Pre-computed per-country / global community leaderboards |
|
||||
| 36639 | Pledge | Donor pledge for concrete submissions, stored as sats |
|
||||
|
||||
@@ -22,7 +22,54 @@
|
||||
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
|
||||
| Flat Communities | 34550, 30009, 8, 1111, 1984 | One-level badge membership with explicit moderators (NIP-72 ext) |
|
||||
| Community Chat | 34550, 1311 | Realtime member chat scoped to a NIP-72 community |
|
||||
| Campaign Moderation | 30223, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster |
|
||||
| Campaign Moderation | 33863, 1985, 39089 | Homepage curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster |
|
||||
|
||||
### Agora Content Marker
|
||||
|
||||
Every event Agora publishes that represents a first-class Agora object carries the single-letter tag `["t", "agora"]`. This marker enables the Agora activity feed to filter strictly server-side via the relay-indexed `#t` filter (multi-letter tags like the NIP-89 `client` tag are not indexed by relays and are therefore unsuitable for this purpose).
|
||||
|
||||
#### Tagged kinds
|
||||
|
||||
| Kind | Object | Where tagged |
|
||||
|-------|---------------------|---------------------------------------------------------------|
|
||||
| 1 | Note (top-level, reply, quote) | `ComposeBox` default for top-level kind 1 publishes |
|
||||
| 1111 | NIP-22 comment | `usePostComment` (all comments authored in Agora) |
|
||||
| 8333 | Onchain zap | `useOnchainZap`, `useDonateCampaign`, `SendBitcoinDialog` |
|
||||
| 9041 | Zap goal | `CreateGoalDialog` |
|
||||
| 33863 | Campaign | `CreateCampaignPage` |
|
||||
| 31922 | Date calendar event | `CreateEventPage`, `CreateCommunityEventDialog` |
|
||||
| 31923 | Time calendar event | `CreateEventPage`, `CreateCommunityEventDialog` |
|
||||
| 34550 | Community | `CreateCommunityPage` |
|
||||
| 36639 | Pledge | `CreateActionPage` |
|
||||
|
||||
The tag is added at publish time via the `withAgoraTag` helper in `src/lib/agoraNoteTags.ts`, which dedupes against any user-supplied `t:agora` tag.
|
||||
|
||||
#### Untagged kinds (intentional)
|
||||
|
||||
Reactions, reposts, follow lists, profile metadata, lists, settings, badges, vanish requests, encrypted DMs, and live chat are user-state or response events rather than first-class Agora content. Tagging them would pollute `#agora` hashtag surfaces without adding value to the activity feed.
|
||||
|
||||
Untagged on purpose: 0, 3, 6, 7, 8, 16, 62, 1311, 30009, 10000-series, 30078, and any NIP-04 / NIP-44 encrypted kind.
|
||||
|
||||
#### Querying
|
||||
|
||||
The Agora activity feed combines a `t:agora`-strict layer with an intentionally cross-client world layer:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "kinds": [33863, 36639, 34550, 8333], "#t": ["agora", "Agora"] },
|
||||
{ "kinds": [1111], "#t": ["agora", "Agora"], "#K": ["33863", "36639", "34550"] },
|
||||
{ "kinds": [1111, 1068], "#k": ["iso3166", "geo"] },
|
||||
{ "kinds": [1], "#t": ["agora", "Agora"] }
|
||||
]
|
||||
```
|
||||
|
||||
The first two filters surface only Agora-created content. The third surfaces all country/geo-rooted comments and polls regardless of origin — the world layer is intentionally cross-client. The fourth captures any kind 1 note carrying `#agora` (including hashtags users type themselves), which preserves viral / opt-in discovery.
|
||||
|
||||
Clients filter both case variants (`agora` and `Agora`) because Nostr `t` tags are conventionally lowercase but some clients normalize hashtags to title case.
|
||||
|
||||
#### Backward compatibility
|
||||
|
||||
Events published before this marker was adopted do not carry `t:agora` and therefore do not appear in the Agora activity feed. They remain reachable by direct link and via kind-specific directories (e.g. the moderator-curated `/campaigns/all`). Authors who wish to surface a legacy event in the feed can republish it (any edit through the Agora UI will add the marker automatically).
|
||||
|
||||
### Community Chat
|
||||
|
||||
@@ -89,26 +136,45 @@ Single-recipient zap (the common case — tipping a post or profile):
|
||||
}
|
||||
```
|
||||
|
||||
Multi-recipient zap (one transaction paying multiple recipients — campaign donations, community splits):
|
||||
Multi-recipient zap (one transaction paying multiple recipients — community splits):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 8333,
|
||||
"pubkey": "<sender-pubkey>",
|
||||
"content": "Great campaign!",
|
||||
"content": "Great community!",
|
||||
"tags": [
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["p", "<recipient-1-pubkey>"],
|
||||
["p", "<recipient-2-pubkey>"],
|
||||
["p", "<recipient-3-pubkey>"],
|
||||
["amount", "<total-sats-paid-to-all-listed-recipients>"],
|
||||
["a", "30223:<campaign-author>:<campaign-d-tag>"],
|
||||
["K", "30223"],
|
||||
["a", "34550:<community-author>:<community-d-tag>"],
|
||||
["K", "34550"],
|
||||
["alt", "Donation: 75000 sats across 3 recipients"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Campaign donation (one transaction paying a single campaign wallet — see Kind 33863 below):
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 8333,
|
||||
"pubkey": "<donor-pubkey>",
|
||||
"content": "Keep up the good work.",
|
||||
"tags": [
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["amount", "<sats-paid-to-campaign-wallet>"],
|
||||
["a", "33863:<campaign-author>:<campaign-d-tag>"],
|
||||
["K", "33863"],
|
||||
["alt", "Donation to Save the Last Bookstore: 25000 sats"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Campaign donation receipts MUST NOT include `p` tags — campaigns no longer have Nostr-identity recipients, only a `w` wallet endpoint. Verification matches tx outputs against the campaign's declared `w` address rather than derived Taproot addresses (see *Verification* and Kind 33863 below).
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735).
|
||||
@@ -165,13 +231,25 @@ For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For pro
|
||||
|
||||
**Verification (REQUIRED before trusting amounts):**
|
||||
|
||||
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify:
|
||||
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. Verification has two modes depending on the event shape:
|
||||
|
||||
*Identity-recipient mode* (the event has `p` tags — profile zaps, event zaps, community splits):
|
||||
|
||||
1. Extract the txid from the `i` tag.
|
||||
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
|
||||
3. For each `p` tag, derive the recipient's expected Taproot address.
|
||||
4. Sum the values of all outputs in the transaction that pay any of the derived recipient addresses. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to listed recipients.
|
||||
5. If the verified amount is 0 (no listed recipient received anything in the tx), the event SHOULD be discarded.
|
||||
|
||||
*Campaign-wallet mode* (the event has an `a` tag pointing at a kind 33863 campaign and no `p` tags):
|
||||
|
||||
1. Extract the txid from the `i` tag and the campaign coordinate from the `a` tag.
|
||||
2. Fetch the campaign event and read its `w` tag to get the campaign's declared bech32(m) wallet address. Reject the receipt if `w` is missing, malformed, or starts with `sp1` (silent-payment campaigns do not publish receipts; see Kind 33863).
|
||||
3. Fetch the transaction from a Bitcoin data source.
|
||||
4. Sum the values of all outputs in the transaction that pay the campaign's `w` address. This is the **verified amount**.
|
||||
|
||||
In both modes:
|
||||
|
||||
5. If the verified amount is 0, the event SHOULD be discarded.
|
||||
6. If the sender's `amount` tag exceeds the verified amount, clients MAY discard the event or MAY display the smaller verified amount (capping). Clients MUST NOT display or count the claimed amount when it exceeds the verified amount.
|
||||
7. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate zap totals or clearly label them as pending.
|
||||
|
||||
@@ -199,139 +277,202 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
|
||||
|
||||
---
|
||||
|
||||
## Kind 30223: Campaign
|
||||
## Kind 33863: Campaign
|
||||
|
||||
### Summary
|
||||
|
||||
Addressable event representing a **fundraising campaign**. A campaign carries the marketing-style metadata you would expect on GoFundMe, Kickstarter, or GiveSendGo (title, summary, cover image, story, category, goal, optional deadline, and recommended country), and — most importantly — a list of recipient pubkeys (`p` tags) that share the proceeds of any donation.
|
||||
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional deadline, optional country) and exactly one Bitcoin wallet endpoint declared in a `w` tag. The wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode is inferred from the prefix — the client renders the corresponding QR code and adjusts the donation-progress UI accordingly.
|
||||
|
||||
Donations are sent as a **single Bitcoin on-chain transaction** with one output per recipient. The donor's wallet derives each recipient's Taproot address from their pubkey via BIP-340/BIP-341 (the same scheme used by kind 8333 onchain zaps), so the campaign event itself does not need to carry Bitcoin addresses. After broadcasting the funding tx, the donor's client publishes one kind 8333 event referencing the `txid`, listing every campaign recipient under its own `p` tag, and tagging the campaign via `a` / `K`. The donation then shows up in the campaign's totals and in each recipient's profile zap history (the `#p` filter matches every listed recipient).
|
||||
The author of the event is also the beneficiary. Campaigns are never authored on behalf of someone else; the event creator owns the wallet declared in `w` and receives the donations. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate.
|
||||
|
||||
The kind is addressable so the creator can edit the story, image, goal, deadline, and recipient list over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
|
||||
The kind is addressable so the creator can edit the story, banner, goal, deadline, and wallet over the life of the campaign without minting new identifiers. The `d` tag is the campaign's slug.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30223,
|
||||
"kind": 33863,
|
||||
"pubkey": "<creator-pubkey>",
|
||||
"content": "<markdown story>",
|
||||
"tags": [
|
||||
["d", "save-the-bookstore"],
|
||||
["d", "save-the-last-bookstore"],
|
||||
|
||||
["title", "Save the Last Bookstore"],
|
||||
["summary", "Help our 40-year-old neighborhood bookstore make rent through winter."],
|
||||
["image", "https://example.com/cover.jpg"],
|
||||
["t", "human-rights"],
|
||||
["t", "legal-defense"],
|
||||
["goal", "10000000"],
|
||||
["banner", "https://blossom.example/abc123.jpg"],
|
||||
["imeta",
|
||||
"url https://blossom.example/abc123.jpg",
|
||||
"m image/jpeg",
|
||||
"x abc123def456...",
|
||||
"dim 1600x900",
|
||||
"blurhash LKO2?U%2Tw=w]~RBVZRi};RPxuwH",
|
||||
"alt Storefront of the Last Bookstore at dusk"
|
||||
],
|
||||
["alt", "Fundraising campaign: Save the Last Bookstore"],
|
||||
|
||||
["w", "bc1p7w2k3xq9...xyz"],
|
||||
|
||||
["goal", "25000"],
|
||||
["deadline", "1735689600"],
|
||||
["i", "iso3166:VE"],
|
||||
["k", "iso3166"],
|
||||
["p", "<recipient-1-hex-pubkey>", "wss://relay.example", "2"],
|
||||
["p", "<recipient-2-hex-pubkey>", "wss://relay.example", "1"],
|
||||
["p", "<recipient-3-hex-pubkey>"],
|
||||
["alt", "Fundraising campaign: Save the Last Bookstore"]
|
||||
|
||||
["i", "iso3166-1:US"],
|
||||
["k", "iso3166-1"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A silent-payment campaign is identical except the `w` tag carries an `sp1…` code:
|
||||
|
||||
```json
|
||||
["w", "sp1qq...verylongsilentpaymentcode..."]
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is the **campaign story**, formatted as Markdown. Clients SHOULD render it with the same Markdown renderer they use for NIP-23 long-form content. Empty content is permitted (e.g. for a campaign that lives entirely in its summary).
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `d` | Yes | Campaign slug, unique per author. Forms the addressable coordinate `30223:<pubkey>:<d>`. |
|
||||
| `title` | Yes | Display title of the campaign (plain text, max ~200 chars). |
|
||||
| `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. |
|
||||
| `image` | Recommended | HTTPS URL of the cover image (jpg/png/webp). Clients MUST sanitize and verify the URL before rendering. |
|
||||
| `t` | Recommended | Topic tag for discovery and filtering (e.g. `human-rights`, `legal-defense`, `independent-media`). Multiple `t` tags MAY be used. Clients SHOULD normalize user-entered tag labels by removing a leading `#`, lowercasing, and replacing whitespace with hyphens. |
|
||||
| `goal` | Recommended | Fundraising goal in **satoshis** (decimal integer). Omit if the campaign has no fixed goal. |
|
||||
| `deadline` | Optional | Unix timestamp (seconds) at which the campaign closes. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
|
||||
| `i` | Recommended | NIP-73 country identifier for sorting and discovery. SHOULD be `iso3166:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166:VE`). |
|
||||
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166`. |
|
||||
| `location` | Legacy | Human-readable location string used by older campaign events. New events SHOULD prefer `i` + `k` country tags. Clients MAY display this as a fallback only. |
|
||||
| `status` | Optional | Lifecycle status. The only defined value is `archived`, which marks the campaign closed without deleting it. Other values SHOULD be ignored. See *Closing & archiving* below. |
|
||||
| `p` | Yes (≥1) | Recipient pubkey. The 2nd element is the hex pubkey; the 3rd (optional) is a relay hint; the 4th (optional) is a positive decimal **weight** for split allocation. |
|
||||
| `alt` | Recommended | NIP-31 human-readable fallback. |
|
||||
| Tag | Required | Description |
|
||||
|-----------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `d` | Yes | Campaign slug, unique per author. Forms the addressable coordinate `33863:<pubkey>:<d>`. |
|
||||
| `title` | Yes | Display title of the campaign (plain text, max ~200 chars). |
|
||||
| `w` | Yes | Bitcoin wallet endpoint. The 2nd element is a single bech32(m) string: a mainnet on-chain address starting with `bc1q` (P2WPKH/P2WSH) or `bc1p` (P2TR), **or** a silent-payment code starting with `sp1` per BIP-352. Exactly one `w` tag per campaign. |
|
||||
| `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. |
|
||||
| `banner` | Recommended | HTTPS URL of the wide banner image. Clients MUST sanitize the URL (see `sanitizeUrl()` in `nostr-security`) before rendering, and SHOULD pair the URL with a NIP-92 `imeta` tag for dimensions, blurhash, MIME type, and SHA-256. |
|
||||
| `imeta` | Recommended | NIP-92 media metadata for the banner. The first `url <value>` pair MUST match the `banner` URL; clients SHOULD ignore an `imeta` whose URL does not match. |
|
||||
| `goal` | Optional | Fundraising goal in **integer US Dollars** (no unit suffix, no decimals). Clients MAY display an estimated sat-equivalent at view time using a live exchange rate. |
|
||||
| `deadline`| Optional | Unix timestamp (seconds) at which the campaign closes for new donations. After the deadline, clients SHOULD show the campaign as ended but MAY still accept donations. |
|
||||
| `i` | Recommended | NIP-73 country identifier. SHOULD be `iso3166-1:<code>` with an uppercase ISO 3166-1 alpha-2 country code (e.g. `iso3166-1:VE`). |
|
||||
| `k` | Recommended if `i` is present | NIP-73 external content kind. For country identifiers this SHOULD be `iso3166-1`. |
|
||||
| `alt` | Recommended | NIP-31 human-readable fallback. |
|
||||
|
||||
### Recipient Split Rules
|
||||
### Wallet Modes
|
||||
|
||||
When a donor sends an amount `T` in satoshis to a campaign:
|
||||
The prefix of the `w` value selects one of two donation modes. Clients MUST detect the mode from the prefix; the event carries no other mode discriminator.
|
||||
|
||||
1. Read all `p` tags from the campaign event.
|
||||
2. Parse the weight of each `p` tag from the 4th element. If absent, malformed, or non-positive, the weight defaults to **1**.
|
||||
3. Compute each recipient's share as `floor(T * weight_i / sum_of_weights)` satoshis.
|
||||
4. Any remainder from rounding (at most N−1 sats) MAY be appended to the largest share or kept by the donor as change — clients SHOULD prefer appending the remainder to the largest share so the full amount reaches the campaign.
|
||||
5. If any computed share is below the Bitcoin dust limit (546 sats for P2TR), the donor's client MUST refuse the donation and surface a minimum-amount error.
|
||||
| Prefix | Mode | Description |
|
||||
|---------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `bc1q…` / `bc1p…` | On-chain | Public mainnet bech32(m) address. Donations are traceable; clients show a progress bar, total raised, and donation list. |
|
||||
| `sp1…` | Silent payment | BIP-352 silent-payment code. Donations are **unlinkable by design**. Clients MUST hide all aggregate totals and progress UI (see below). |
|
||||
|
||||
Equal splits are the default: omit the weight on every `p` tag, and all recipients receive `floor(T / N)` sats each.
|
||||
Other prefixes (`tb1…`, `bcrt1…`, `tsp1…`, lightning invoices, etc.) MUST be rejected at parse time; the campaign does not render.
|
||||
|
||||
### Donation Flow
|
||||
Clients SHOULD validate the bech32(m) checksum of the `w` value, not just its prefix.
|
||||
|
||||
1. Donor opens the campaign and chooses an amount in sats (preset or custom).
|
||||
2. Donor's client computes per-recipient amounts using the split rules above.
|
||||
3. Donor's client builds a **single PSBT** with one output per recipient (paying each recipient's derived Taproot address) plus a change output back to the donor.
|
||||
4. Donor signs the PSBT with their Nostr key (Taproot key-path spend) and broadcasts the resulting transaction.
|
||||
5. Donor's client publishes **one kind 8333 event for the whole transaction**, listing every recipient under its own `p` tag. The event MUST include:
|
||||
### Client Behavior by Mode
|
||||
|
||||
| UI element | On-chain (`bc1`) | Silent payment (`sp1`) |
|
||||
|-----------------------------|-----------------------------------------------------------------|-------------------------------------------------------|
|
||||
| QR code | bech32(m) address QR (or BIP-21 `bitcoin:` URI) | SP code QR (BIP-352 / BIP-21 SP extension) |
|
||||
| "Raised X" / progress bar | Shown, computed from verified kind 8333 receipts | **Hidden.** Replaced with a "Private campaign — totals are not public" notice. |
|
||||
| Donor / recent-donation list| Shown | **Hidden.** |
|
||||
| Goal display | Shown as USD target with optional sat-equivalent estimate | Shown as USD target; no progress computation |
|
||||
| Donation receipt published | Donor's client publishes a kind 8333 receipt (see below) | **No receipt published.** Publishing one would defeat SP unlinkability and is forbidden. |
|
||||
|
||||
For silent-payment campaigns, clients MUST NOT attempt to scan the chain, MUST NOT publish receipts, and MUST NOT display any aggregate that could leak donation activity. The only signal the public sees is the campaign event itself.
|
||||
|
||||
### Donation Flow — On-chain (`bc1`)
|
||||
|
||||
1. Donor opens the campaign and chooses an amount.
|
||||
2. Donor's client constructs and broadcasts a Bitcoin transaction paying the campaign's `w` address.
|
||||
3. After broadcast, the donor's client publishes a single kind 8333 receipt:
|
||||
|
||||
```json
|
||||
[
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["p", "<recipient-1-pubkey>"],
|
||||
["p", "<recipient-2-pubkey>"],
|
||||
["p", "<recipient-3-pubkey>"],
|
||||
["amount", "<total-sats-paid-to-all-recipients>"],
|
||||
["a", "30223:<campaign-author-pubkey>:<campaign-d-tag>"],
|
||||
["K", "30223"],
|
||||
["amount", "<sats-paid-to-campaign-wallet>"],
|
||||
["a", "33863:<campaign-author-pubkey>:<campaign-d-tag>"],
|
||||
["K", "33863"],
|
||||
["alt", "Donation to <campaign-title>: <total-amount> sats"]
|
||||
]
|
||||
```
|
||||
|
||||
The `amount` tag is the sum of the outputs paying the listed recipients (i.e. the full donation, excluding the donor's change). Per-recipient amounts are not encoded in the event; clients that need them recompute them from the on-chain transaction by matching each recipient's derived Taproot address against the tx outputs.
|
||||
The receipt MUST NOT carry `p` tags — campaigns are not Nostr-identity recipients. The `amount` tag is the sum of tx outputs paying the campaign's `w` address (excluding the donor's change output).
|
||||
|
||||
This mirrors the community batch-zap pattern documented in the kind 8333 section above, with the campaign's addressable coordinate replacing the community coordinate.
|
||||
4. The receipt is published **after** the tx is broadcast; the txid is already final at that point. A receipt-publish failure does not roll back the donation — the on-chain transaction stands.
|
||||
|
||||
### Donation Flow — Silent Payment (`sp1`)
|
||||
|
||||
1. Donor opens the campaign and chooses an amount.
|
||||
2. Donor's client uses the campaign's SP code to derive a fresh, one-time Taproot output script per BIP-352.
|
||||
3. Donor broadcasts a Bitcoin transaction paying that derived output.
|
||||
4. **No Nostr event is published.** The campaign owner discovers the donation by scanning the chain locally with their SP private key.
|
||||
|
||||
Silent-payment unlinkability is the entire point of this mode. Clients MUST NOT publish receipts, MUST NOT advertise the donation in any other Nostr event (replies, mentions, etc.) on the donor's behalf, and MUST NOT correlate the donor's pubkey with the campaign in any persisted client telemetry.
|
||||
|
||||
### Querying
|
||||
|
||||
**List campaigns (newest first):**
|
||||
|
||||
```json
|
||||
{ "kinds": [30223], "limit": 50 }
|
||||
```
|
||||
|
||||
**Filter by category:**
|
||||
|
||||
```json
|
||||
{ "kinds": [30223], "#t": ["medical"], "limit": 50 }
|
||||
{ "kinds": [33863], "limit": 50 }
|
||||
```
|
||||
|
||||
**Fetch a specific campaign:**
|
||||
|
||||
```json
|
||||
{ "kinds": [30223], "authors": ["<creator-pubkey>"], "#d": ["<slug>"], "limit": 1 }
|
||||
{ "kinds": [33863], "authors": ["<creator-pubkey>"], "#d": ["<slug>"], "limit": 1 }
|
||||
```
|
||||
|
||||
**Aggregate donations for a campaign:**
|
||||
**Aggregate donations for an on-chain campaign:**
|
||||
|
||||
```json
|
||||
{ "kinds": [8333], "#a": ["30223:<creator-pubkey>:<slug>"], "limit": 500 }
|
||||
{ "kinds": [8333], "#a": ["33863:<creator-pubkey>:<slug>"], "limit": 500 }
|
||||
```
|
||||
|
||||
Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the verification rules in the kind 8333 section.
|
||||
Clients MUST verify each kind 8333 event on-chain before counting it toward the campaign total, per the *Campaign-wallet mode* verification rules in the kind 8333 section.
|
||||
|
||||
**Filter by country:**
|
||||
|
||||
```json
|
||||
{ "kinds": [33863], "#i": ["iso3166-1:VE"], "limit": 50 }
|
||||
```
|
||||
|
||||
**Fetch pinned event comments:**
|
||||
|
||||
Event owners MAY pin important comments or activity feed events with a NIP-78 app-specific data event (`kind: 30078`) authored by the root event owner. The `d` tag is scoped to the root event coordinate. Agora uses this for campaigns (`33863`), pledges (`36639`), organizations (`34550`), and calendar events (`31922` / `31923`).
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "<root-event-author-pubkey>",
|
||||
"content": "{\"pinnedEvents\":[\"<event-id-2>\",\"<event-id-1>\"]}",
|
||||
"tags": [
|
||||
["d", "agora-pinned-comments:<kind>:<root-event-author-pubkey>:<d-tag>"],
|
||||
["a", "<kind>:<root-event-author-pubkey>:<d-tag>"],
|
||||
["k", "<kind>"],
|
||||
["alt", "Pinned event comments"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Clients SHOULD query the pin list with:
|
||||
|
||||
```json
|
||||
{ "kinds": [30078], "authors": ["<root-event-author-pubkey>"], "#d": ["agora-pinned-comments:<kind>:<root-event-author-pubkey>:<d-tag>"], "limit": 1 }
|
||||
```
|
||||
|
||||
The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned event removes it. Clients SHOULD ignore pin lists not authored by the root event owner.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
- **Recipient validity:** clients SHOULD reject `p` tag entries whose pubkey is not 64 hex characters and SHOULD ignore weights that are not positive finite decimals.
|
||||
- **Dust protection:** when a donor enters an amount that would assign any recipient less than the dust limit, the client MUST block the donation and either suggest the minimum viable total or prompt the donor to remove recipients.
|
||||
- **Editability:** the creator MAY republish the same `(kind, pubkey, d)` triple to update the campaign. Clients SHOULD keep `published_at` from the first publish on subsequent edits (NIP-23 convention).
|
||||
- **Closing & archiving:** the creator MAY soft-close a campaign by republishing it with a `["status", "archived"]` tag. Clients SHOULD hide archived campaigns from discovery feeds and disable the donate flow, but MUST keep them reachable by direct link so existing donors can still find them and donation history is preserved. The creator can reopen the campaign by republishing without the status tag (or with any other status value). For a hard delete, the creator MAY publish a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate; clients SHOULD continue to render past donations against the campaign even after deletion.
|
||||
- **Wallet validity:** clients MUST reject events whose `w` tag is missing, present more than once, or whose value does not pass bech32(m) checksum validation for one of the supported prefixes. Invalid campaigns do not render.
|
||||
- **Editability:** the creator MAY republish the same `(33863, pubkey, d)` triple to update any field, including the `w` wallet endpoint. Clients SHOULD keep `published_at` from the first publish on subsequent edits (NIP-23 convention).
|
||||
- **Closing a campaign:** there is no `status` tag. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate. Clients SHOULD honor the deletion by removing the campaign from discovery feeds. Historical kind 8333 receipts MAY still be rendered against the (now-deleted) campaign coordinate so donors can find their past donations.
|
||||
- **No category, no topics:** kind 33863 events MUST NOT carry `t` tags or NIP-32 category labels in any `agora.*` namespace. Campaigns are individual stories; discovery happens via search (NIP-50 against title/summary/content), country (`#i`), and moderator curation (below).
|
||||
- **Migration:** kind 33863 has no relationship to any earlier campaign kind. Clients MUST NOT read, merge, or migrate events of any other kind into the kind 33863 namespace.
|
||||
|
||||
### Campaign Moderation Labels
|
||||
### Agora Moderation Labels
|
||||
|
||||
Agora curates which kind 30223 campaigns appear on the homepage (`/`) and on Discover (`/discover`) via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The campaign event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), and which kind 34550 organizations appear in the Featured shelf on `/communities`, via moderator-signed NIP-32 label events (kind 1985) in a dedicated label namespace. The labeled event itself is never modified — surfacing is purely a client-side rollup of label events.
|
||||
|
||||
Campaigns and organizations share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the two streams is the kind prefix on the `a` tag of each label:
|
||||
|
||||
- `33863:<author-pubkey>:<d>` — campaign (kind 33863, see "Open Campaigns" above).
|
||||
- `34550:<author-pubkey>:<d>` — organization (kind 34550, NIP-72 community definition).
|
||||
|
||||
A client surfacing campaigns MUST filter folded labels to those whose `a` tag starts with `33863:`. A client surfacing organizations MUST filter to `34550:`. Mixing the two streams would let a moderator's `featured` label on a campaign appear to feature an unrelated organization with the same `d` tag, or vice versa.
|
||||
|
||||
#### Namespace
|
||||
|
||||
@@ -346,22 +487,32 @@ Each label event carries the namespace twice, per NIP-32:
|
||||
|
||||
#### Label values
|
||||
|
||||
Three independent axes; the newest moderator-signed label per axis per campaign wins.
|
||||
Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** use only two — `hide` and `featured` — because every Agora-tagged organization is publicly visible by default; there is no approval gate for orgs. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 coordinates, and clients MUST ignore any such labels they receive.
|
||||
|
||||
| Axis | Values | Meaning |
|
||||
|----------|---------------------------|-------------------------------------------------------------------------|
|
||||
| approval | `approved`, `unapproved` | `approved` allows the campaign on `/` and Discover. `unapproved` retracts a previous approval. |
|
||||
| hide | `hidden`, `unhidden` | `hidden` suppresses the campaign everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
|
||||
| featured | `featured`, `unfeatured` | `featured` places the campaign in the hand-picked Featured row on `/`. `unfeatured` retracts. |
|
||||
| Axis | Values | Surfaces | Meaning |
|
||||
|----------|---------------------------|----------------|-------------------------------------------------------------------------|
|
||||
| approval | `approved`, `unapproved` | campaigns only | `approved` allows the campaign on its discovery surfaces. `unapproved` retracts a previous approval. |
|
||||
| hide | `hidden`, `unhidden` | both | `hidden` suppresses the campaign/organization everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
|
||||
| featured | `featured`, `unfeatured` | both | `featured` places the campaign in the hand-picked Featured row on `/`, or the organization in the Featured shelf on `/communities`. `unfeatured` retracts. |
|
||||
|
||||
Surfacing rules (hide always wins):
|
||||
|
||||
**Campaigns**
|
||||
|
||||
- **Featured row on `/`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first. Featured is independent of Approved at the protocol level; a campaign may be featured without being approved (the home page treats Featured and Approved as deduplicated bins, with Featured taking precedence).
|
||||
- **Community Campaigns grid on `/`** — iff approved, not hidden, and not featured (featured campaigns get their own row above).
|
||||
- **Discover shelf** — iff approved AND not hidden.
|
||||
- **Moderator-only "Pending"** — iff neither approved nor hidden.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
|
||||
**Organizations**
|
||||
|
||||
- **Featured shelf on `/communities`** — iff the latest featured label is `featured` AND the latest hide label is not `hidden`. Ordered newest-`created_at`-of-`featured`-label first.
|
||||
- **"My organizations" shelf on `/communities`** — intentionally ignores all moderation labels. A user's own founded, moderated, or followed organizations always render regardless of label state.
|
||||
- **Moderator-only "Needs review"** — iff `t:agora` AND not featured AND not hidden. Surfaces orgs minted through Agora's create flow that haven't been triaged into Featured or Hidden yet.
|
||||
- **Moderator-only "Hidden"** — iff hidden.
|
||||
- **Hide enforcement on other organization discovery surfaces** — clients SHOULD suppress `hidden` organizations from any future "All organizations" / browse surface for non-moderators. Moderators MAY see hidden organizations with a "Hidden" treatment so they can unhide.
|
||||
|
||||
#### Event Structure
|
||||
|
||||
```json
|
||||
@@ -371,20 +522,33 @@ Surfacing rules (hide always wins):
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "approved", "agora.moderation"],
|
||||
["a", "30223:<author-pubkey>:<campaign-d-tag>"],
|
||||
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
|
||||
["alt", "Campaign moderation: approved"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
A `featured` label has the same shape with `["l", "featured", "agora.moderation"]` and `["alt", "Campaign moderation: featured"]`.
|
||||
An organization label has the same shape with a kind 34550 `a` tag:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 1985,
|
||||
"content": "",
|
||||
"tags": [
|
||||
["L", "agora.moderation"],
|
||||
["l", "featured", "agora.moderation"],
|
||||
["a", "34550:<author-pubkey>:<organization-d-tag>"],
|
||||
["alt", "Organization moderation: featured"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Required tags:
|
||||
|
||||
- `L` set to `agora.moderation`.
|
||||
- `l` with the label value as the 2nd element and `agora.moderation` as the 3rd.
|
||||
- `a` referencing the campaign coordinate `30223:<pubkey>:<d>`.
|
||||
- `alt` (NIP-31) — clients without label support will display this string.
|
||||
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization).
|
||||
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured` or `Organization moderation: featured`) so non-Agora clients can read it.
|
||||
|
||||
#### Trust Model
|
||||
|
||||
@@ -396,12 +560,14 @@ pubkey: 932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d
|
||||
d-tag: k4p5w0n22suf
|
||||
```
|
||||
|
||||
The pack `p` tags are the authoritative moderator list. Anyone may publish a kind 1985 event in the `agora.moderation` namespace, but events from non-pack authors are silently ignored at the relay-filter layer (`authors:` is pinned to the pack `p` tags). This means:
|
||||
The pack `p` tags are the authoritative moderator list. Clients MUST pin `authors:` on their label REQ to the pack `p` tags; events from non-pack authors MUST be ignored. This means:
|
||||
|
||||
- Self-approval is impossible unless the pack author has added you.
|
||||
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive only by their labels return to "pending" until another moderator approves them.
|
||||
- A moderator removed from the pack immediately loses moderation authority — campaigns/organizations kept alive only by their labels return to "pending" until another moderator approves them.
|
||||
- The pack author (single signer) can reset the entire moderator roster by republishing the pack.
|
||||
|
||||
The same moderator set governs both campaign and organization labels. Carving out per-surface moderator subsets is out of scope; clients that need that distinction would have to introduce a second follow pack and a second label namespace.
|
||||
|
||||
#### Querying
|
||||
|
||||
Step 1 — fetch the pack:
|
||||
@@ -426,13 +592,14 @@ Step 2 — fetch label events from pack members in the namespace:
|
||||
}
|
||||
```
|
||||
|
||||
Step 3 — fold by `(campaign-coord, axis)`, latest-`created_at`-wins. Then fetch only the approved-and-not-hidden campaign coordinates with one filter per author (bundled in a single REQ).
|
||||
Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the relevant kind prefix (`33863:` for campaigns or `34550:` for organizations). Then fetch the targeted events themselves — one filter per author (bundled in a single REQ) keyed by their d-tags.
|
||||
|
||||
#### Client Behavior
|
||||
|
||||
- Clients SHOULD render approve/hide controls only for users whose pubkey appears in the pack.
|
||||
- Clients MAY display "Hidden" badges on hidden campaigns when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
|
||||
- Clients SHOULD render approve/hide/feature controls only for users whose pubkey appears in the pack.
|
||||
- Clients MAY display "Hidden" badges on hidden campaigns/organizations when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
|
||||
- Non-moderator authors viewing the homepage SHOULD see their own pending campaigns in a separate explained section so they understand why their campaign isn't yet on the homepage. The campaign URL remains live and donatable regardless of moderation state.
|
||||
- Organization authors are not shown an equivalent "pending" surface today — organizations are visible at their NIP-19 route regardless of moderation, and the only moderation surface is the Featured shelf.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Agora" />
|
||||
<meta property="og:description" content="Power to the people." />
|
||||
<meta property="og:image" content="https://agora.spot/og-image.png" />
|
||||
<meta property="og:image" content="https://agora.spot/og-image.jpg" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:type" content="image/jpeg" />
|
||||
@@ -21,7 +21,7 @@
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Agora" />
|
||||
<meta name="twitter:description" content="Power to the people." />
|
||||
<meta name="twitter:image" content="https://agora.spot/og-image.png" />
|
||||
<meta name="twitter:image" content="https://agora.spot/og-image.jpg" />
|
||||
|
||||
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self' https:; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' blob: https:">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"name": "agora",
|
||||
"version": "2.8.0",
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
@@ -35,6 +34,7 @@
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/bungee-shade": "^5.2.7",
|
||||
"@fontsource/caveat": "^5.2.8",
|
||||
"@fontsource/cherry-bomb-one": "^5.2.7",
|
||||
@@ -96,15 +96,16 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@scure/btc-signer": "^2.2.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"bitcoinjs-lib": "^7.0.1",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -114,7 +115,6 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"ecpair": "^3.0.1",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -360,30 +360,6 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/secp256k1": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.2.0.tgz",
|
||||
"integrity": "sha512-jeujZSzb3JOZfmJYI0ph1PVpCRV5oaexCgy+RvCXV8XlY+XFB/2n3WOcvBsKLsOw78KYgnQrQWb2HrKE4be88Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitcoinerlab/secp256k1/node_modules/@noble/curves": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
|
||||
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@breeztech/breez-sdk-spark": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@breeztech/breez-sdk-spark/-/breez-sdk-spark-0.10.0.tgz",
|
||||
@@ -1486,6 +1462,15 @@
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bebas-neue": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.2.7.tgz",
|
||||
"integrity": "sha512-DsmBrmq55d9BCU0mt4DT4RZDdH8vhWRKEUOfbuNB1EEjMuwbtFvM8N+3gIlkYSFbsb10P8Q19BV5OdpMu2h0fA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/bungee-shade": {
|
||||
"version": "5.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/bungee-shade/-/bungee-shade-5.2.7.tgz",
|
||||
@@ -6213,55 +6198,40 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@scure/bip32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
|
||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz",
|
||||
"integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.1.0",
|
||||
"@noble/hashes": "~1.3.1",
|
||||
"@scure/base": "~1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/curves/node_modules/@noble/hashes": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz",
|
||||
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"@noble/curves": "2.2.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@scure/base": "2.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@noble/hashes": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz",
|
||||
"integrity": "sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip32/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/bip39": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz",
|
||||
@@ -6284,6 +6254,42 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/btc-signer/-/btc-signer-2.2.0.tgz",
|
||||
"integrity": "sha512-ZXZ08sZqSZKEcOuEQnxTF66ouHtl6+UA6U/QfQM06K9WiOlEkXF4LviZCaSgkdiFh9cyMt9+xdup7JtEv3p0fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~2.2.0",
|
||||
"@noble/hashes": "~2.2.0",
|
||||
"@scure/base": "~2.2.0",
|
||||
"micro-packed": "~0.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer/node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
"integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@scure/btc-signer/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@sentry-internal/browser-utils": {
|
||||
"version": "10.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.42.0.tgz",
|
||||
@@ -7741,16 +7747,11 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base-x": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base-x/-/base-x-5.0.1.tgz",
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"devOptional": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -7767,12 +7768,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bech32": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
|
||||
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "12.9.0",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz",
|
||||
@@ -7821,37 +7816,6 @@
|
||||
"file-uri-to-path": "1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bip174": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bip174/-/bip174-3.0.0.tgz",
|
||||
"integrity": "sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.9",
|
||||
"varuint-bitcoin": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bitcoinjs-lib": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-7.0.1.tgz",
|
||||
"integrity": "sha512-vwEmpL5Tpj0I0RBdNkcDMXePoaYSTeKY6mL6/l5esbnTs+jGdPDuLp4NY1hSh6Zk5wSgePygZ4Wx5JJao30Pww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"bech32": "^2.0.0",
|
||||
"bip174": "^3.0.0",
|
||||
"bs58check": "^4.0.0",
|
||||
"uint8array-tools": "^0.0.9",
|
||||
"valibot": "^1.2.0",
|
||||
"varuint-bitcoin": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
@@ -7965,49 +7929,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58/-/bs58-6.0.0.tgz",
|
||||
"integrity": "sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base-x": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bs58check": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bs58check/-/bs58check-4.0.0.tgz",
|
||||
"integrity": "sha512-FsGDOnFg9aVI9erdriULkd/JjEWONV/lQE5aYziB5PoBsXRind56lh8doIZIc9X4HoxT5x4bLjMWN1/NB8Zp5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.2.0",
|
||||
"bs58": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-crc32": {
|
||||
"version": "0.2.13",
|
||||
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
|
||||
@@ -8831,29 +8752,6 @@
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/ecpair": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ecpair/-/ecpair-3.0.1.tgz",
|
||||
"integrity": "sha512-uz8wMFvtdr58TLrXnAesBsoMEyY8UudLOfApcyg40XfZjP+gt1xO4cuZSIkZ8hTMTQ8+ETgt7xSIV4eM7M6VNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.8",
|
||||
"valibot": "^1.2.0",
|
||||
"wif": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ecpair/node_modules/uint8array-tools": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
|
||||
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.149",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.149.tgz",
|
||||
@@ -9766,7 +9664,8 @@
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
@@ -11009,6 +10908,30 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/micro-packed": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/micro-packed/-/micro-packed-0.9.0.tgz",
|
||||
"integrity": "sha512-gFdaWTxEXOwtSOcpxulO4AuXVtp3HWIRmB8eq8+3m1Zku0ubgva0UGpi03YhcvsTJasHngG9gTIUK5kHNKdesg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@scure/base": "~2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/micro-packed/node_modules/@scure/base": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
|
||||
"integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||
@@ -11874,6 +11797,32 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@scure/bip32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz",
|
||||
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/curves": "~1.1.0",
|
||||
"@noble/hashes": "~1.3.1",
|
||||
"@scure/base": "~1.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@scure/bip32/node_modules/@noble/curves": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz",
|
||||
"integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools/node_modules/@scure/bip39": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz",
|
||||
@@ -14698,15 +14647,6 @@
|
||||
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uint8array-tools": {
|
||||
"version": "0.0.9",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.9.tgz",
|
||||
"integrity": "sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -15045,38 +14985,6 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/valibot": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.0.tgz",
|
||||
"integrity": "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/varuint-bitcoin": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/varuint-bitcoin/-/varuint-bitcoin-2.0.0.tgz",
|
||||
"integrity": "sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uint8array-tools": "^0.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/varuint-bitcoin/node_modules/uint8array-tools": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.8.tgz",
|
||||
"integrity": "sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
@@ -16679,15 +16587,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wif": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wif/-/wif-5.0.0.tgz",
|
||||
"integrity": "sha512-iFzrC/9ne740qFbNjTZ2FciSRJlHIXoxqk/Y5EnE08QOXu1WjJyCCswwDTYbohAOEnlCtLaAAQBhyaLRFh2hMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bs58check": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bitcoinerlab/secp256k1": "^1.2.0",
|
||||
"@breeztech/breez-sdk-spark": "^0.10.0",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
"@capacitor/barcode-scanner": "^3.0.2",
|
||||
@@ -42,6 +41,7 @@
|
||||
"@fontsource-variable/nunito": "^5.2.7",
|
||||
"@fontsource-variable/outfit": "^5.2.8",
|
||||
"@fontsource-variable/playfair-display": "^5.2.8",
|
||||
"@fontsource/bebas-neue": "^5.2.7",
|
||||
"@fontsource/bungee-shade": "^5.2.7",
|
||||
"@fontsource/caveat": "^5.2.8",
|
||||
"@fontsource/cherry-bomb-one": "^5.2.7",
|
||||
@@ -103,15 +103,16 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@scure/bip32": "^2.2.0",
|
||||
"@scure/bip39": "^1.6.0",
|
||||
"@scure/btc-signer": "^2.2.0",
|
||||
"@sentry/react": "^10.42.0",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@unhead/addons": "^2.1.13",
|
||||
"@unhead/react": "^2.1.13",
|
||||
"bitcoinjs-lib": "^7.0.1",
|
||||
"blurhash": "^2.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"capacitor-secure-storage-plugin": "^0.13.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -121,7 +122,6 @@
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"ecpair": "^3.0.1",
|
||||
"embla-carousel-react": "^8.3.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"fflate": "^0.8.2",
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -43,7 +43,6 @@ const hardcodedConfig: AppConfig = {
|
||||
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
|
||||
homePage: "campaigns",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
theme: "system",
|
||||
useAppRelays: true,
|
||||
useUserRelays: false,
|
||||
@@ -57,48 +56,48 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeReactions: false,
|
||||
feedIncludeZaps: false,
|
||||
feedIncludeZaps: true,
|
||||
feedIncludeArticles: true,
|
||||
showArticles: true,
|
||||
showHighlights: true,
|
||||
feedIncludeHighlights: false,
|
||||
feedIncludeHighlights: true,
|
||||
showEvents: true,
|
||||
feedIncludeEvents: true,
|
||||
showVines: true,
|
||||
showVines: false,
|
||||
showPolls: true,
|
||||
showTreasures: true,
|
||||
showTreasureGeocaches: true,
|
||||
showTreasureFoundLogs: true,
|
||||
showColors: true,
|
||||
showTreasures: false,
|
||||
showTreasureGeocaches: false,
|
||||
showTreasureFoundLogs: false,
|
||||
showColors: false,
|
||||
showPeopleLists: true,
|
||||
feedIncludeVines: true,
|
||||
feedIncludeVines: false,
|
||||
feedIncludePolls: true,
|
||||
feedIncludeTreasureGeocaches: true,
|
||||
feedIncludeTreasureFoundLogs: true,
|
||||
feedIncludeColors: true,
|
||||
feedIncludeTreasureGeocaches: false,
|
||||
feedIncludeTreasureFoundLogs: false,
|
||||
feedIncludeColors: false,
|
||||
feedIncludePeopleLists: true,
|
||||
showDecks: true,
|
||||
feedIncludeDecks: true,
|
||||
showWebxdc: true,
|
||||
feedIncludeWebxdc: true,
|
||||
showDecks: false,
|
||||
feedIncludeDecks: false,
|
||||
showWebxdc: false,
|
||||
feedIncludeWebxdc: false,
|
||||
showPhotos: true,
|
||||
feedIncludePhotos: true,
|
||||
showVideos: true,
|
||||
feedIncludeNormalVideos: true,
|
||||
feedIncludeShortVideos: true,
|
||||
feedIncludeVoiceMessages: true,
|
||||
showEmojiPacks: true,
|
||||
feedIncludeEmojiPacks: true,
|
||||
showCustomEmojis: true,
|
||||
showUserStatuses: true,
|
||||
showMusic: true,
|
||||
feedIncludeMusicTracks: true,
|
||||
feedIncludeMusicPlaylists: true,
|
||||
showPodcasts: true,
|
||||
feedIncludePodcastEpisodes: true,
|
||||
feedIncludePodcastTrailers: true,
|
||||
showDevelopment: true,
|
||||
feedIncludeDevelopment: true,
|
||||
showEmojiPacks: false,
|
||||
feedIncludeEmojiPacks: false,
|
||||
showCustomEmojis: false,
|
||||
showUserStatuses: false,
|
||||
showMusic: false,
|
||||
feedIncludeMusicTracks: false,
|
||||
feedIncludeMusicPlaylists: false,
|
||||
showPodcasts: false,
|
||||
feedIncludePodcastEpisodes: false,
|
||||
feedIncludePodcastTrailers: false,
|
||||
showDevelopment: false,
|
||||
feedIncludeDevelopment: false,
|
||||
showCommunities: true,
|
||||
feedIncludeCommunities: true,
|
||||
showBadges: true,
|
||||
@@ -109,10 +108,10 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeProfileBadges: true,
|
||||
feedIncludeBadgeAwards: true,
|
||||
feedIncludeVanish: true,
|
||||
showBirdstar: true,
|
||||
feedIncludeBirdDetections: true,
|
||||
feedIncludeBirdex: true,
|
||||
feedIncludeConstellations: true,
|
||||
showBirdstar: false,
|
||||
feedIncludeBirdDetections: false,
|
||||
feedIncludeBirdex: false,
|
||||
feedIncludeConstellations: false,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
@@ -147,7 +146,13 @@ const hardcodedConfig: AppConfig = {
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
esploraBaseUrl: 'https://mempool.space/api',
|
||||
esploraApis: [
|
||||
'https://mempool.space/api',
|
||||
'https://mempool.emzy.de/api',
|
||||
'https://blockstream.info/api',
|
||||
],
|
||||
blockbookBaseUrl: 'https://btc.trezor.io',
|
||||
bip352IndexerUrl: 'https://silentpayments.dev/blindbit/mainnet',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ id: 'hot-posts' },
|
||||
|
||||
@@ -25,7 +25,7 @@ const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").th
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
|
||||
// when an naddr resolves to kind 30223.) The campaigns list IS the homepage;
|
||||
// when an naddr resolves to kind 33863.) The campaigns list IS the homepage;
|
||||
// the configurable HomePage delegation from the Twitter-era app is gone.
|
||||
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
|
||||
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
|
||||
@@ -47,10 +47,8 @@ const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ de
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const CreateCommunityPage = lazy(() => import("./pages/CreateCommunityPage").then(m => ({ default: m.CreateCommunityPage })));
|
||||
const CreateEventPage = lazy(() => import("./pages/CreateEventPage").then(m => ({ default: m.CreateEventPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
const DiscoverPage = lazy(() => import("./pages/DiscoverPage").then(m => ({ default: m.DiscoverPage })));
|
||||
const DomainFeedPage = lazy(() => import("./pages/DomainFeedPage").then(m => ({ default: m.DomainFeedPage })));
|
||||
const EventsFeedPage = lazy(() => import("./pages/EventsFeedPage").then(m => ({ default: m.EventsFeedPage })));
|
||||
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
|
||||
@@ -63,7 +61,6 @@ const KindFeedPage = lazy(() => import("./pages/KindFeedPage").then(m => ({ defa
|
||||
const LetterComposePage = lazy(() => import("./pages/LetterComposePage").then(m => ({ default: m.LetterComposePage })));
|
||||
const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage").then(m => ({ default: m.LetterPreferencesPage })));
|
||||
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
|
||||
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
|
||||
const MusicPage = lazy(() => import("./pages/MusicPage").then(m => ({ default: m.MusicPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
@@ -93,7 +90,6 @@ const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m
|
||||
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
|
||||
const PlanetoraPage = lazy(() => import("./pages/PlanetoraPage").then(m => ({ default: m.PlanetoraPage })));
|
||||
const ReceivePage = lazy(() => import("./pages/ReceivePage").then(m => ({ default: m.ReceivePage })));
|
||||
const ClaimPage = lazy(() => import("./pages/ClaimPage").then(m => ({ default: m.ClaimPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
@@ -167,13 +163,11 @@ export function AppRouter() {
|
||||
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
|
||||
<Route path="/follow/:npub" element={<FollowPage />} />
|
||||
<Route path="/receive" element={<ReceivePage />} />
|
||||
<Route path="/claim" element={<ClaimPage />} />
|
||||
|
||||
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
|
||||
<Route path="/planetora" element={<PlanetoraPage />} />
|
||||
<Route element={<FundraiserLayout />}>
|
||||
<Route path="/" element={<CampaignsPage />} />
|
||||
<Route path="/discover" element={<DiscoverPage />} />
|
||||
<Route path="/feed" element={<Index />} />
|
||||
<Route path="/campaigns" element={<Navigate to="/" replace />} />
|
||||
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
|
||||
@@ -190,7 +184,6 @@ export function AppRouter() {
|
||||
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
|
||||
<Route path="/settings/profile" element={<ProfileSettings />} />
|
||||
<Route path="/settings/feed" element={<ContentSettingsPage />} />
|
||||
<Route path="/settings/content" element={<ContentPage />} />
|
||||
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
|
||||
<Route
|
||||
path="/settings/notifications"
|
||||
@@ -200,7 +193,6 @@ export function AppRouter() {
|
||||
path="/settings/advanced"
|
||||
element={<AdvancedSettingsPage />}
|
||||
/>
|
||||
<Route path="/settings/magic" element={<MagicSettingsPage />} />
|
||||
<Route path="/settings/network" element={<NetworkSettingsPage />} />
|
||||
<Route path="/lists" element={<UserListsPage />} />
|
||||
<Route path="/events" element={<EventsFeedPage />} />
|
||||
|
||||
@@ -33,6 +33,8 @@ interface ArcBackgroundProps {
|
||||
variant: 'down' | 'up' | 'rect';
|
||||
/** Extra classes on the <svg> element. */
|
||||
className?: string;
|
||||
/** Extra classes on the filled background path. */
|
||||
fillClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,7 +42,7 @@ interface ArcBackgroundProps {
|
||||
* MobileBottomNav. Draws a semi-transparent filled shape (rectangle + optional
|
||||
* curved arc) as a single path so there are no sub-pixel seams between layers.
|
||||
*/
|
||||
export function ArcBackground({ variant, className }: ArcBackgroundProps) {
|
||||
export function ArcBackground({ variant, className, fillClassName }: ArcBackgroundProps) {
|
||||
const path = variant === 'down' ? ARC_DOWN_PATH : variant === 'up' ? ARC_UP_PATH : RECT_PATH;
|
||||
const hasArc = variant !== 'rect';
|
||||
|
||||
@@ -57,7 +59,7 @@ export function ArcBackground({ variant, className }: ArcBackgroundProps) {
|
||||
preserveAspectRatio="none"
|
||||
style={hasArc ? (variant === 'up' ? arcUpHeightStyle : arcDownHeightStyle) : fullHeightStyle}
|
||||
>
|
||||
<path d={path} className="fill-background/85" />
|
||||
<path d={path} className={cn('fill-background/85', fillClassName)} />
|
||||
{variant === 'down' && <path d="M0,34 L50,46 L100,34" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
|
||||
{variant === 'up' && <path d="M0,40 L50,16 L100,40" fill="none" className="stroke-border" strokeWidth="1" vectorEffect="non-scaling-stroke" strokeLinejoin="round" />}
|
||||
</svg>
|
||||
|
||||
@@ -64,8 +64,25 @@ export function BanConfirmDialog({
|
||||
});
|
||||
|
||||
// Invalidate community queries so the moderation overlay updates
|
||||
// immediately (removes banned content without a page refresh).
|
||||
await queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] });
|
||||
// immediately (removes banned content without a page refresh). The
|
||||
// activity feed's key is `['community-activity-feed', <aTagsKey>]`
|
||||
// where aTagsKey is a comma-joined list of the viewer's subscribed A
|
||||
// tags. Predicate-match any feed whose aTagsKey contains this
|
||||
// communityATag so the banned post disappears immediately.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['community-members', communityATag] }),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [root, aTagsKey] = q.queryKey;
|
||||
return root === 'community-activity-feed'
|
||||
&& typeof aTagsKey === 'string'
|
||||
&& aTagsKey.split(',').includes(communityATag);
|
||||
},
|
||||
}),
|
||||
// Also refresh the organization-activity feed shown on the org
|
||||
// detail page (used by the pledge/campaign shelves).
|
||||
queryClient.invalidateQueries({ queryKey: ['organization-activity', communityATag] }),
|
||||
]);
|
||||
|
||||
toast({ title: 'Post removed from organization' });
|
||||
setReason('');
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AlertTriangle, Check, Copy, ExternalLink } from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { nostrPubkeyToBitcoinAddress } from '@/lib/bitcoin';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
interface BeneficiaryDonatePanelProps {
|
||||
/** Hex pubkey of the beneficiary. */
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline panel rendering a beneficiary's Taproot address as a scannable
|
||||
* BIP-21 QR code, a copyable string, and an "Open in wallet" button.
|
||||
*
|
||||
* Used both by `BeneficiaryDonateDialog` (modal context) and embedded
|
||||
* directly into the campaign page when there's a single beneficiary.
|
||||
*
|
||||
* Always shows the beneficiary's profile preview (avatar + name) as a
|
||||
* link to their Nostr profile — even when the surrounding page also
|
||||
* identifies a campaign organizer, the beneficiary is a distinct party
|
||||
* (the organizer may be running the campaign on someone else's behalf).
|
||||
*
|
||||
* Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
|
||||
* that's `DonateDialog`'s job.
|
||||
*/
|
||||
export function BeneficiaryDonatePanel({
|
||||
pubkey,
|
||||
}: BeneficiaryDonatePanelProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName =
|
||||
metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
const picture = sanitizeUrl(metadata?.picture);
|
||||
const profileUrl = useProfileUrl(pubkey, metadata);
|
||||
|
||||
const address = useMemo(
|
||||
() => nostrPubkeyToBitcoinAddress(pubkey),
|
||||
[pubkey],
|
||||
);
|
||||
// BIP-21 URI: most wallets recognize the `bitcoin:` scheme when scanning.
|
||||
// No amount field — donor picks one in their wallet.
|
||||
const bip21 = address ? `bitcoin:${address}` : '';
|
||||
|
||||
const copyAddress = async () => {
|
||||
if (!address) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(address);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
toast({ title: 'Address copied' });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Select and copy the address manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!address) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
|
||||
<span>We couldn't derive a Bitcoin address for this beneficiary.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
to={profileUrl}
|
||||
className="flex items-center gap-3 rounded-md -mx-2 px-2 py-1.5 motion-safe:transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Avatar className="size-10 ring-1 ring-border">
|
||||
{picture && <AvatarImage src={picture} alt="" />}
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium truncate">{displayName}</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* QR code */}
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={bip21} size={200} level="M" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyable address */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
Bitcoin address
|
||||
</Label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyAddress}
|
||||
className="w-full flex items-center justify-between gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs break-all text-left hover:bg-muted/60 motion-safe:transition-colors"
|
||||
aria-label="Copy Bitcoin address"
|
||||
>
|
||||
<span className="break-all">{address}</span>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Privacy notice — informational only. Bitcoin is a public
|
||||
ledger, so the donation can be traced back to the donor's
|
||||
wallet. */}
|
||||
<BitcoinPublicDisclaimer
|
||||
tone="soft"
|
||||
includeCashOutAdvice={false}
|
||||
leadText="Donations are public and can be traced back to you."
|
||||
/>
|
||||
|
||||
{/* Open in wallet — relies on the `bitcoin:` URI handler. */}
|
||||
<Button asChild className="w-full">
|
||||
<a href={bip21}>
|
||||
<ExternalLink className="size-4 mr-1.5" />
|
||||
Open in wallet
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BeneficiaryDonateDialogProps {
|
||||
/** Hex pubkey of the beneficiary. */
|
||||
pubkey: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal wrapper around `BeneficiaryDonatePanel` for places that still want
|
||||
* the dialog UX (e.g. multi-beneficiary campaigns, where each row's
|
||||
* "Donate" button opens this dialog).
|
||||
*/
|
||||
export function BeneficiaryDonateDialog({
|
||||
pubkey,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: BeneficiaryDonateDialogProps) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName =
|
||||
metadata?.display_name || metadata?.name || genUserName(pubkey);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Donate to {displayName}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Scan the QR code or copy the Bitcoin address below to donate.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<BeneficiaryDonatePanel pubkey={pubkey} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
|
||||
/**
|
||||
* Informational notice for BIP-352 silent-payment receive endpoints
|
||||
* (sp1…). Surfaces the "private but experimental" trade-off the user
|
||||
* accepts when they choose silent payments instead of a regular
|
||||
* on-chain address.
|
||||
*
|
||||
* Visual treatment mirrors `BitcoinPublicDisclaimer` with `tone="soft"`:
|
||||
* `role="note"`, amber tint, no icon, no checkbox. The lead sentence
|
||||
* carries the headline, and "Learn more" opens a popover with the full
|
||||
* explanation.
|
||||
*/
|
||||
export function BitcoinPrivateDisclaimer() {
|
||||
return (
|
||||
<Alert
|
||||
role="note"
|
||||
className="border-amber-500/30 bg-amber-500/10 text-foreground"
|
||||
>
|
||||
{/* No icon — the shadcn Alert reserves left padding for an icon via
|
||||
`[&>svg~*]:pl-7`, so omitting it reclaims the indent. */}
|
||||
<AlertDescription className="text-xs">
|
||||
<p>
|
||||
Experimental. Donations are private, but bugs may occur.{' '}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="underline underline-offset-2 font-medium hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||||
>
|
||||
Learn more
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" className="w-72 text-xs leading-relaxed">
|
||||
Your private wallet hides the real address of your wallet
|
||||
and your donors on the Bitcoin network. Funds are always
|
||||
fully recoverable, but bugs in the wallet may cause it to
|
||||
show an incorrect balance, and it may require long wait
|
||||
times to synchronize.
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
@@ -41,6 +42,14 @@ interface BitcoinPublicDisclaimerProps {
|
||||
* Defaults to `true` for backwards compatibility.
|
||||
*/
|
||||
includeCashOutAdvice?: boolean;
|
||||
/**
|
||||
* Override the popover body. When set, replaces the entire "Bitcoin
|
||||
* is a public ledger…" paragraph (including the cash-out advice). Use
|
||||
* when the calling surface has a meaningfully different audience —
|
||||
* e.g. a campaign *creator* configuring a receive address, vs. the
|
||||
* sender flow this component was originally written for.
|
||||
*/
|
||||
popoverText?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +65,7 @@ export function BitcoinPublicDisclaimer({
|
||||
leadText = 'Money you send is public and can be traced back to you.',
|
||||
tone = 'destructive',
|
||||
includeCashOutAdvice = true,
|
||||
popoverText,
|
||||
}: BitcoinPublicDisclaimerProps) {
|
||||
const showCheckbox = onAcknowledgedChange !== undefined;
|
||||
const isSoft = tone === 'soft';
|
||||
@@ -67,7 +77,12 @@ export function BitcoinPublicDisclaimer({
|
||||
role={isSoft ? 'note' : 'alert'}
|
||||
className={cn(
|
||||
isSoft
|
||||
? 'border-amber-300/60 bg-amber-50 text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100'
|
||||
// Use the project's foreground token (not raw amber-900) so
|
||||
// the text always contrasts against the page in both light
|
||||
// and dark themes. The faint amber tint keeps the
|
||||
// "informational notice" cue without leaning on hard-coded
|
||||
// amber text that disappears on the wrong backdrop.
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-foreground'
|
||||
: 'border-destructive/50 bg-destructive/5 text-destructive dark:border-destructive',
|
||||
)}
|
||||
>
|
||||
@@ -88,11 +103,15 @@ export function BitcoinPublicDisclaimer({
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="start" className="w-72 text-xs leading-relaxed">
|
||||
Bitcoin is a public ledger. Transactions you send can
|
||||
be traced back to you forever, even after being
|
||||
exchanged by multiple people. Send it only to those
|
||||
you wish to support publicly
|
||||
{includeCashOutAdvice ? ', or cash out at an exchange.' : '.'}
|
||||
{popoverText ?? (
|
||||
<>
|
||||
Bitcoin is a public ledger. Transactions you send can
|
||||
be traced back to you forever, even after being
|
||||
exchanged by multiple people. Send it only to those
|
||||
you wish to support publicly
|
||||
{includeCashOutAdvice ? ', or cash out at an exchange.' : '.'}
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarDays,
|
||||
ChevronLeft,
|
||||
MapPin,
|
||||
Clock,
|
||||
Users,
|
||||
@@ -18,10 +18,12 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { PostActionBar } from '@/components/PostActionBar';
|
||||
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { CreateCommunityEventDialog } from '@/components/CreateCommunityEventDialog';
|
||||
import { RSVPAvatars } from '@/components/RSVPAvatars';
|
||||
@@ -33,9 +35,11 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
|
||||
import { useMyRSVP } from '@/hooks/useMyRSVP';
|
||||
import { usePublishRSVP } from '@/hooks/usePublishRSVP';
|
||||
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -126,6 +130,26 @@ function formatDetailDate(event: NostrEvent): string {
|
||||
return startStr;
|
||||
}
|
||||
|
||||
function formatCalendarHeroDate(event: NostrEvent): string | null {
|
||||
const startRaw = getTag(event.tags, 'start');
|
||||
if (!startRaw) return null;
|
||||
|
||||
if (event.kind === 31922) {
|
||||
const [year, month, day] = startRaw.split('-').map(Number);
|
||||
const date = new Date(year, month - 1, day);
|
||||
if (isNaN(date.getTime())) return startRaw;
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
const timestamp = Number(startRaw);
|
||||
if (!Number.isFinite(timestamp)) return startRaw;
|
||||
const startTzid = getTag(event.tags, 'start_tzid');
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric',
|
||||
...(startTzid ? { timeZone: startTzid } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const ROLE_ORDER = ['host', 'speaker', 'moderator', 'participant'];
|
||||
function roleSort(a: string, b: string): number {
|
||||
const ai = ROLE_ORDER.indexOf(a.toLowerCase());
|
||||
@@ -162,6 +186,15 @@ function PersonRow({ pubkey, label, size = 'md' }: { pubkey: string; label?: str
|
||||
);
|
||||
}
|
||||
|
||||
function EventDetailRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-xl bg-muted/40 px-3 py-3">
|
||||
<div className="mt-0.5 text-primary shrink-0">{icon}</div>
|
||||
<div className="min-w-0 text-sm leading-relaxed">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main Component ---
|
||||
|
||||
export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
@@ -170,7 +203,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const title = getTag(event.tags, 'title') ?? 'Untitled Event';
|
||||
const image = getTag(event.tags, 'image');
|
||||
const image = sanitizeUrl(getTag(event.tags, 'image'));
|
||||
const locationRaw = getTag(event.tags, 'location');
|
||||
const location = locationRaw ? parseLocation(locationRaw) : undefined;
|
||||
const summary = getTag(event.tags, 'summary');
|
||||
@@ -179,6 +212,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
|
||||
const eventCoord = useMemo(() => getEventCoord(event), [event]);
|
||||
const dateStr = useMemo(() => formatDetailDate(event), [event]);
|
||||
const heroDate = useMemo(() => formatCalendarHeroDate(event), [event]);
|
||||
|
||||
// Participants grouped by role
|
||||
const participantsByRole = useMemo(() => {
|
||||
@@ -200,6 +234,12 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
const myRsvp = useMyRSVP(eventCoord);
|
||||
const publishRSVP = usePublishRSVP();
|
||||
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
||||
const {
|
||||
pinnedEvents,
|
||||
isPinned,
|
||||
canManagePins,
|
||||
togglePin,
|
||||
} = usePinnedEventComments(eventCoord, event.pubkey);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
@@ -225,6 +265,11 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
.map((comment) => buildNode(comment));
|
||||
}, [commentsData]);
|
||||
|
||||
const pinnedNodes = useMemo(
|
||||
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
|
||||
[pinnedEvents],
|
||||
);
|
||||
|
||||
const handleRSVP = useCallback(async (status: 'accepted' | 'declined' | 'tentative') => {
|
||||
if (status === myRsvp.status) return;
|
||||
try {
|
||||
@@ -240,202 +285,339 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
}, [eventCoord, event.pubkey, myRsvp.status, publishRSVP, toast]);
|
||||
|
||||
const showRSVP = !!user;
|
||||
const attendingCount = rsvps.accepted.length;
|
||||
const interestedCount = rsvps.tentative.length;
|
||||
const rsvpStatusLabel = myRsvp.status === 'accepted'
|
||||
? 'You are going'
|
||||
: myRsvp.status === 'tentative'
|
||||
? 'You are interested'
|
||||
: myRsvp.status === 'declined'
|
||||
? "You can't go"
|
||||
: 'Choose your RSVP';
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto pb-16">
|
||||
{/* ── Standard top bar ── */}
|
||||
<div className="flex items-center gap-4 px-4 pt-4 pb-5">
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
className="p-1.5 -ml-1.5 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold flex-1">Event Details</h1>
|
||||
{canEdit && (
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-secondary/60 transition-colors"
|
||||
onClick={() => setEditOpen(true)}
|
||||
aria-label="Edit event"
|
||||
>
|
||||
<Pencil className="size-5" />
|
||||
</button>
|
||||
const eventDetailsCard = (
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-5 space-y-5">
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hosted by</div>
|
||||
<PersonRow pubkey={event.pubkey} size="sm" />
|
||||
</div>
|
||||
|
||||
{(event.content || summary) && (
|
||||
<div className="space-y-2 border-t border-border/60 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Description</div>
|
||||
{event.content ? (
|
||||
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} disableEmbeds disableNoteEmbeds />
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Cover image ── */}
|
||||
{image ? (
|
||||
<div className="aspect-[2/1] w-full overflow-hidden">
|
||||
<img src={image} alt={title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-[3/1] w-full bg-gradient-to-br from-primary/15 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<CalendarDays className="size-20 text-primary/20" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Content ── */}
|
||||
<div className="px-5 mt-5 space-y-5">
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-bold leading-tight tracking-tight">{title}</h2>
|
||||
{/* Organizer row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<PersonRow pubkey={event.pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date & Location — sidebar-style pills */}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
|
||||
<Clock className="size-5 text-primary shrink-0" />
|
||||
<span className="text-sm">{dateStr}</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<EventDetailRow icon={<Clock className="size-5" />}>
|
||||
{dateStr}
|
||||
</EventDetailRow>
|
||||
{location && (
|
||||
<div className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85">
|
||||
<MapPin className="size-5 text-primary shrink-0" />
|
||||
<span className="text-sm">{location}</span>
|
||||
</div>
|
||||
<EventDetailRow icon={<MapPin className="size-5" />}>
|
||||
{location}
|
||||
</EventDetailRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hashtags */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hashtags.map((tag) => (
|
||||
<Link key={tag} to={`/t/${tag}`}>
|
||||
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
|
||||
#{tag}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
{showRSVP && (
|
||||
<div className="space-y-3 border-t border-border/60 pt-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">RSVP</div>
|
||||
<span className="text-xs font-medium text-muted-foreground">{rsvpStatusLabel}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('rounded-full px-2', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
|
||||
onClick={() => handleRSVP('accepted')}
|
||||
>
|
||||
<Check className="size-3.5 mr-1" />
|
||||
Going
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('rounded-full px-2', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
|
||||
onClick={() => handleRSVP('tentative')}
|
||||
>
|
||||
<Star className="size-3.5 mr-1" />
|
||||
Interested
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('rounded-full px-2', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||
onClick={() => handleRSVP('declined')}
|
||||
>
|
||||
<XIcon className="size-3.5 mr-1" />
|
||||
Can't Go
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{(event.content || summary) && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">About</h2>
|
||||
{event.content ? (
|
||||
<NoteContent event={event} className="text-sm leading-relaxed text-foreground" hideEmbedImages={!!image} />
|
||||
) : (
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{summary}</p>
|
||||
{rsvps.total > 0 && (
|
||||
<div className="space-y-3 border-t border-border/60 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Attendees</div>
|
||||
<div className="space-y-3">
|
||||
{([
|
||||
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
|
||||
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
|
||||
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
|
||||
] as const).map(([label, pks, cls]) => pks.length > 0 && (
|
||||
<div key={label} className="space-y-2">
|
||||
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
|
||||
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<div className="space-y-2 border-t border-border/60 pt-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Links</div>
|
||||
<div className="space-y-1">
|
||||
{links.map((url) => (
|
||||
<button
|
||||
key={url}
|
||||
type="button"
|
||||
onClick={() => void openUrl(url)}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<LinkIcon className="size-4 text-primary shrink-0" />
|
||||
<span className="truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink className="size-3.5 text-muted-foreground shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
const participantsCard = participantsByRole.length > 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-3">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Participants</div>
|
||||
<div className="space-y-2">
|
||||
{participantsByRole.map(([role, pubkeys]) =>
|
||||
pubkeys.map((pk) => <PersonRow key={`${role}-${pk}`} pubkey={pk} label={role} size="sm" />),
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pb-16">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
|
||||
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/30 via-primary/15 to-secondary">
|
||||
{image ? (
|
||||
<img src={image} alt="" className="absolute inset-0 size-full object-cover" />
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<CalendarDays className="size-16 sm:size-20 text-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/85 via-black/25 to-black/45" />
|
||||
|
||||
<div className="absolute left-0 right-0 top-0 z-10 flex items-center justify-between gap-3 px-4 pt-4">
|
||||
<button
|
||||
onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')}
|
||||
className="p-2.5 -ml-2 rounded-full text-white/90 hover:bg-white/15 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/80 motion-safe:transition-colors"
|
||||
aria-label="Go back"
|
||||
>
|
||||
<ChevronLeft className="size-6 drop-shadow-[0_1px_2px_rgba(0,0,0,0.85)]" />
|
||||
</button>
|
||||
{canEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditOpen(true)}
|
||||
className="rounded-full bg-transparent text-white/90 shadow-none hover:bg-white/15 hover:text-white focus-visible:ring-white/80"
|
||||
>
|
||||
<Pencil className="size-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 space-y-2 p-5 sm:p-6 [text-shadow:0_1px_4px_rgba(0,0,0,0.75),0_2px_10px_rgba(0,0,0,0.45)]">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold leading-tight tracking-tight text-white">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs sm:text-sm font-medium text-white/85">
|
||||
{heroDate && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<CalendarDays className="size-3.5 sm:size-4" />
|
||||
{heroDate}
|
||||
</span>
|
||||
)}
|
||||
{location && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-3.5 sm:size-4" />
|
||||
{location}
|
||||
</span>
|
||||
)}
|
||||
{attendingCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="size-3.5 sm:size-4" />
|
||||
{attendingCount} attending
|
||||
</span>
|
||||
)}
|
||||
{interestedCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="size-3.5 sm:size-4" />
|
||||
{interestedCount} interested
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{summary && (
|
||||
<p className="max-w-2xl text-base sm:text-lg text-white/90 line-clamp-3">
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6">
|
||||
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comment"
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pinnedNodes.length > 0 && (
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-6">
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={pinnedNodes}
|
||||
renderItemHeader={(event) => (
|
||||
<EventPinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
|
||||
<div className="lg:hidden mb-6 space-y-4">
|
||||
{eventDetailsCard}
|
||||
{participantsCard}
|
||||
</div>
|
||||
|
||||
<div className="lg:flex lg:gap-8 lg:items-start">
|
||||
<div className="flex-1 min-w-0 space-y-8">
|
||||
<section className="space-y-5">
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hashtags.map((tag) => (
|
||||
<Link key={tag} to={`/t/${tag}`}>
|
||||
<Badge variant="secondary" className="cursor-pointer hover:bg-secondary/80 text-xs px-2.5 py-0.5">
|
||||
#{tag}
|
||||
</Badge>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(event.content || summary) && (
|
||||
<article className="prose prose-neutral dark:prose-invert max-w-none">
|
||||
{event.content ? (
|
||||
<NoteContent event={event} hideEmbedImages={!!image} />
|
||||
) : (
|
||||
<p className="text-muted-foreground">{summary}</p>
|
||||
)}
|
||||
</article>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* External links */}
|
||||
{links.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{links.map((url) => (
|
||||
<a
|
||||
key={url}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 px-3 py-3 rounded-full bg-background/85 hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<LinkIcon className="size-5 text-primary shrink-0" />
|
||||
<span className="text-sm truncate flex-1">{url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
|
||||
</a>
|
||||
))}
|
||||
<section id="event-comments" className="scroll-mt-20">
|
||||
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
|
||||
{replyTree.length > 0 ? (
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{replyTree.length.toLocaleString()} {replyTree.length === 1 ? 'comment' : 'comments'}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DetailCommentComposer event={event} className="mb-3" />
|
||||
|
||||
{commentsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-card border border-border/60 px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={replyTree}
|
||||
renderItemHeader={(event) => (
|
||||
<EventPinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReplyOpen(true)}
|
||||
className="block w-full rounded-2xl border border-dashed border-border/80 bg-card/50 px-6 py-10 text-center hover:bg-card hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<p className="text-base font-medium text-foreground">No comments yet</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Be the first to comment.</p>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{participantsByRole.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Users className="size-4" /> Participants
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{participantsByRole.map(([role, pubkeys]) =>
|
||||
pubkeys.map((pk) => <PersonRow key={pk} pubkey={pk} label={role} size="sm" />),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Attendees */}
|
||||
{rsvps.total > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Users className="size-4" /> Attendees
|
||||
</h2>
|
||||
<div className="space-y-2.5">
|
||||
{([
|
||||
['Going', rsvps.accepted, 'border-green-500/50 bg-green-500/5 text-green-600'],
|
||||
['Interested', rsvps.tentative, 'border-amber-500/50 bg-amber-500/5 text-amber-600'],
|
||||
["Can't Go", rsvps.declined, 'border-muted-foreground/30 bg-muted/30 text-muted-foreground'],
|
||||
] as const).map(([label, pks, cls]) => pks.length > 0 && (
|
||||
<div key={label} className="flex items-center gap-3">
|
||||
<Badge variant="outline" className={cn(cls, 'shrink-0 text-xs')}>{label} ({pks.length})</Badge>
|
||||
<RSVPAvatars pubkeys={pks} maxVisible={8} size="sm" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* RSVP section */}
|
||||
{showRSVP && (
|
||||
<>
|
||||
<Separator />
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-2">
|
||||
<Check className="size-4" /> RSVP
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'accepted' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('flex-1 rounded-full', myRsvp.status === 'accepted' && 'bg-green-600 hover:bg-green-700 text-white')}
|
||||
onClick={() => handleRSVP('accepted')}
|
||||
>
|
||||
<Check className="size-3.5 mr-1.5" /> Going
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'tentative' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('flex-1 rounded-full', myRsvp.status === 'tentative' && 'bg-amber-500 hover:bg-amber-600 text-white')}
|
||||
onClick={() => handleRSVP('tentative')}
|
||||
>
|
||||
<Star className="size-3.5 mr-1.5" /> Interested
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={myRsvp.status === 'declined' ? 'default' : 'outline'}
|
||||
disabled={publishRSVP.isPending}
|
||||
className={cn('flex-1 rounded-full', myRsvp.status === 'declined' && 'bg-destructive hover:bg-destructive/90 text-destructive-foreground')}
|
||||
onClick={() => handleRSVP('declined')}
|
||||
>
|
||||
<XIcon className="size-3.5 mr-1.5" /> Can't Go
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comments"
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
className="-mx-5 px-5"
|
||||
/>
|
||||
<aside className="hidden lg:block lg:w-[360px] lg:shrink-0 lg:self-start">
|
||||
<div className="lg:sticky lg:top-4 space-y-4">
|
||||
{eventDetailsCard}
|
||||
{participantsCard}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<NoteMoreMenu event={event} open={moreMenuOpen} onOpenChange={setMoreMenuOpen} />
|
||||
<ReplyComposeModal event={event} open={replyOpen} onOpenChange={setReplyOpen} />
|
||||
@@ -446,32 +628,40 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) {
|
||||
event={event}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section>
|
||||
{commentsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<div className="-mx-5">
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center text-muted-foreground text-sm">
|
||||
No comments yet. Be the first to comment!
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
function handleTogglePin(event: NostrEvent) {
|
||||
const wasPinned = isPinned(event.id);
|
||||
togglePin.mutate(event.id, {
|
||||
onSuccess: () => {
|
||||
toast({ title: wasPinned ? 'Unpinned from event' : 'Pinned to event' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to update event pins', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function EventPinHeader({
|
||||
isPinned,
|
||||
canManagePins,
|
||||
pinPending,
|
||||
onTogglePin,
|
||||
}: {
|
||||
isPinned: boolean;
|
||||
canManagePins: boolean;
|
||||
pinPending: boolean;
|
||||
onTogglePin: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PinnedCommentHeader
|
||||
isPinned={isPinned}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={pinPending}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { CalendarClock, HandHeart, MapPin, Target, Users, Archive } from 'lucide-react';
|
||||
import { CalendarClock, EyeOff, HandHeart, MapPin, ShieldCheck, Target } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
@@ -15,9 +16,8 @@ import {
|
||||
type ParsedCampaign,
|
||||
encodeCampaignNaddr,
|
||||
getCampaignCountryLabel,
|
||||
getCampaignPrimaryTagLabel,
|
||||
} from '@/lib/campaign';
|
||||
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
|
||||
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -33,20 +33,32 @@ function formatDeadline(unixSeconds: number): { label: string; isPast: boolean }
|
||||
return { label: `${months} mo left`, isPast: false };
|
||||
}
|
||||
|
||||
/** Short helper rendered both inline (cards) and in the detail page. */
|
||||
/**
|
||||
* Short helper rendered both inline (cards) and in the detail page.
|
||||
*
|
||||
* Per NIP.md Kind 33863, the campaign **goal** is integer USD and the
|
||||
* **raised** total is the sum of verified sats. We render both in the
|
||||
* goal's unit (USD) for consistency, converting the sats total at view
|
||||
* time using the live BTC price. While the price is loading the raised
|
||||
* amount falls back to sats.
|
||||
*/
|
||||
export function CampaignProgress({
|
||||
raisedSats,
|
||||
goalSats,
|
||||
goalUsd,
|
||||
btcPrice,
|
||||
className,
|
||||
}: {
|
||||
raisedSats: number;
|
||||
goalSats?: number;
|
||||
goalUsd?: number;
|
||||
btcPrice?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const hasGoal = !!goalSats && goalSats > 0;
|
||||
const pct = hasGoal ? Math.min(100, Math.round((raisedSats / goalSats!) * 100)) : 0;
|
||||
const hasGoal = !!goalUsd && goalUsd > 0;
|
||||
const raisedUsd = satsToUsd(raisedSats, btcPrice);
|
||||
const pct = hasGoal && raisedUsd !== undefined
|
||||
? Math.min(100, Math.round((raisedUsd / goalUsd!) * 100))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
{hasGoal && <Progress value={pct} className="h-2" />}
|
||||
@@ -56,32 +68,59 @@ export function CampaignProgress({
|
||||
{!hasGoal && <span className="ml-1 font-normal text-muted-foreground">raised</span>}
|
||||
</span>
|
||||
{hasGoal && (
|
||||
<span className="text-muted-foreground">of {formatCampaignAmount(goalSats!, btcPrice)} goal</span>
|
||||
<span className="text-muted-foreground">of {formatUsdGoal(goalUsd!)} goal</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces {@link CampaignProgress} for silent-payment campaigns, where
|
||||
* on-chain totals are unobservable by design. Shows the goal as a target
|
||||
* (if set) but no progress bar or raised amount.
|
||||
*/
|
||||
export function CampaignPrivateNotice({
|
||||
goalUsd,
|
||||
className,
|
||||
}: {
|
||||
goalUsd?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('space-y-1.5 text-sm', className)}>
|
||||
<div className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
<span>Private campaign — totals are not public</span>
|
||||
</div>
|
||||
{goalUsd && goalUsd > 0 && (
|
||||
<div className="text-xs text-muted-foreground">Target: {formatUsdGoal(goalUsd)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CampaignCardProps {
|
||||
campaign: ParsedCampaign;
|
||||
/** Visual variant: `compact` for grid items, `featured` for hero placement. */
|
||||
variant?: 'compact' | 'featured';
|
||||
className?: string;
|
||||
/** Optional footer affordance rendered opposite the author line. */
|
||||
footerBadge?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single campaign as a clickable card. The whole card is a
|
||||
* `<Link>` to the campaign's naddr-based detail route.
|
||||
*/
|
||||
export function CampaignCard({ campaign, variant = 'compact', className }: CampaignCardProps) {
|
||||
export function CampaignCard({ campaign, variant = 'compact', className, footerBadge }: CampaignCardProps) {
|
||||
const author = useAuthor(campaign.pubkey);
|
||||
const { data: stats } = useCampaignDonations(campaign.aTag);
|
||||
const { data: stats } = useCampaignDonations(campaign);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const { data: moderation } = useCampaignModeration();
|
||||
|
||||
const naddr = useMemo(() => encodeCampaignNaddr(campaign), [campaign]);
|
||||
const cover = sanitizeUrl(campaign.image);
|
||||
const cover = sanitizeUrl(campaign.banner);
|
||||
const creatorName =
|
||||
author.data?.metadata?.display_name ||
|
||||
author.data?.metadata?.name ||
|
||||
@@ -89,7 +128,7 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
|
||||
const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null;
|
||||
const raisedSats = stats?.totalSats ?? 0;
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
const tagLabel = getCampaignPrimaryTagLabel(campaign);
|
||||
const isSilentPayment = campaign.wallet.mode === 'sp';
|
||||
|
||||
const isFeaturedVariant = variant === 'featured';
|
||||
const isApproved = moderation.approvedCoords.has(campaign.aTag);
|
||||
@@ -129,29 +168,22 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
|
||||
<HandHeart className="size-12 text-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
{tagLabel && (
|
||||
{isSilentPayment && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute top-3 left-3 backdrop-blur bg-background/80 border-border/40"
|
||||
>
|
||||
{tagLabel}
|
||||
<ShieldCheck className="size-3.5 mr-1" />
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
<div className="absolute top-3 right-3 flex items-center gap-2">
|
||||
{campaign.archived && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-background/85 border-border/40"
|
||||
>
|
||||
<Archive className="size-3.5 mr-1" />
|
||||
Archived
|
||||
</Badge>
|
||||
)}
|
||||
{isHidden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30"
|
||||
>
|
||||
<EyeOff className="size-3.5 mr-1" />
|
||||
Hidden
|
||||
</Badge>
|
||||
)}
|
||||
@@ -190,16 +222,15 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<CampaignProgress raisedSats={raisedSats} goalSats={campaign.goalSats} btcPrice={btcPrice} />
|
||||
{isSilentPayment ? (
|
||||
<CampaignPrivateNotice goalUsd={campaign.goalUsd} />
|
||||
) : (
|
||||
<CampaignProgress raisedSats={raisedSats} goalUsd={campaign.goalUsd} btcPrice={btcPrice} />
|
||||
)}
|
||||
|
||||
{/* Meta row */}
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{campaign.recipients.length}{' '}
|
||||
{campaign.recipients.length === 1 ? 'recipient' : 'recipients'}
|
||||
</span>
|
||||
{stats && stats.donorCount > 0 && (
|
||||
{!isSilentPayment && stats && stats.donorCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Target className="size-3.5" />
|
||||
{stats.donorCount} {stats.donorCount === 1 ? 'donor' : 'donors'}
|
||||
@@ -224,8 +255,11 @@ export function CampaignCard({ campaign, variant = 'compact', className }: Campa
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
|
||||
by <span className="font-medium text-foreground">{creatorName}</span>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<div className="truncate">
|
||||
by <span className="font-medium text-foreground">{creatorName}</span>
|
||||
</div>
|
||||
{footerBadge && <div className="shrink-0">{footerBadge}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
interface CampaignHeroBackgroundProps {
|
||||
/**
|
||||
* Image URL for the active campaign. Each new URL crossfades over the
|
||||
* previous one — we keep up to two layers mounted at a time so the
|
||||
* transition is smooth even when the source changes mid-fade.
|
||||
*/
|
||||
imageUrl: string | undefined;
|
||||
/** Optional className for the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Layer {
|
||||
/** Stable key so React doesn't tear down the layer mid-transition. */
|
||||
id: number;
|
||||
/** Sanitized URL (or `null` for the gradient-only fallback). */
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
const FADE_MS = 1500;
|
||||
|
||||
/**
|
||||
* Full-bleed crossfading background built from the active campaign's banner
|
||||
* image. Modelled after Treasures' HeroGallery: each image gets its own
|
||||
* stacked layer and we toggle opacity to crossfade. The previous layer
|
||||
* unmounts after the fade completes, so we never accumulate more than a
|
||||
* couple of layers in the DOM.
|
||||
*
|
||||
* A warm tint + subtle film-grain SVG sit on top so headlines stay readable
|
||||
* over any photo.
|
||||
*/
|
||||
export function CampaignHeroBackground({ imageUrl, className }: CampaignHeroBackgroundProps) {
|
||||
const idRef = useRef(0);
|
||||
const [layers, setLayers] = useState<Layer[]>([]);
|
||||
const lastUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const safe = sanitizeUrl(imageUrl) ?? null;
|
||||
if (safe === lastUrlRef.current) return;
|
||||
lastUrlRef.current = safe;
|
||||
|
||||
const id = ++idRef.current;
|
||||
// Add the new layer; existing layers stay mounted so the crossfade has
|
||||
// something to fade from.
|
||||
setLayers((prev) => [...prev, { id, url: safe }]);
|
||||
|
||||
// After the fade completes, drop everything except the most recent
|
||||
// layer to keep the DOM tidy.
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLayers((prev) => prev.filter((l) => l.id === id));
|
||||
}, FADE_MS + 50);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [imageUrl]);
|
||||
|
||||
return (
|
||||
<div className={cn('absolute inset-0 overflow-hidden', className)} aria-hidden="true">
|
||||
{layers.map((layer, i) => {
|
||||
const isTop = i === layers.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
opacity: isTop ? 1 : 0,
|
||||
transition: `opacity ${FADE_MS}ms ease-in-out`,
|
||||
}}
|
||||
>
|
||||
{layer.url ? (
|
||||
<img
|
||||
src={layer.url}
|
||||
alt=""
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
// Slow continuous pan toward the left — pairs with the
|
||||
// right-anchored globe so the scene reads as moving toward
|
||||
// the headline copy.
|
||||
className="absolute inset-0 w-full h-full object-cover hero-pan-left"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-background to-secondary/40" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Warm tint + dark gradient — keeps foreground text legible without
|
||||
completely washing the photo out. */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/85 via-background/55 to-background/40" />
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/25 via-transparent to-secondary/30" />
|
||||
|
||||
{/* Film grain — same trick as Treasures' HeroGallery. Helps the
|
||||
composited globe + photo feel like one image. */}
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full pointer-events-none"
|
||||
style={{ opacity: 0.18 }}
|
||||
>
|
||||
<filter id="hero-grain">
|
||||
<feTurbulence type="fractalNoise" baseFrequency="0.7" numOctaves="2" stitchTiles="stitch" />
|
||||
<feColorMatrix type="saturate" values="0" />
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#hero-grain)" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { CampaignCard } from '@/components/CampaignCard';
|
||||
import { parseCampaign } from '@/lib/campaign';
|
||||
|
||||
/**
|
||||
* Renders a kind 33863 Campaign event inside the activity feed using the
|
||||
* same polished {@link CampaignCard} component that powers the campaign
|
||||
* directory. The whole card is a `<Link>` to the campaign's naddr-based
|
||||
* detail route, so taps from the feed land directly on the campaign page.
|
||||
*
|
||||
* Malformed events (missing required fields, invalid wallet endpoint,
|
||||
* etc.) silently drop — `parseCampaign` returns `null` and we return
|
||||
* `null` from the component. A future enhancement could render a
|
||||
* "Malformed campaign" fallback, but for now keeping the feed clean
|
||||
* wins over surfacing parse errors to viewers.
|
||||
*/
|
||||
export function CampaignNoteCardContent({ event }: { event: NostrEvent }) {
|
||||
const campaign = parseCampaign(event);
|
||||
if (!campaign) return null;
|
||||
return <CampaignCard campaign={campaign} className="mt-2" />;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, Check, Copy, ExternalLink, ShieldCheck } from 'lucide-react';
|
||||
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { QRCodeCanvas } from '@/components/ui/qrcode';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { CampaignWallet } from '@/lib/campaign';
|
||||
|
||||
interface CampaignWalletDonatePanelProps {
|
||||
/** Parsed wallet endpoint declared by the campaign's `w` tag. */
|
||||
wallet: CampaignWallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline panel rendering the campaign's wallet endpoint as a scannable
|
||||
* QR code, a copyable string, and an "Open in wallet" button.
|
||||
*
|
||||
* Behavior forks on the wallet's mode:
|
||||
*
|
||||
* - **on-chain** (`bc1q…` / `bc1p…`) — BIP-21 QR with the address; a
|
||||
* public-ledger disclaimer reminds donors that the donation is
|
||||
* traceable.
|
||||
* - **sp** (`sp1…`) — raw silent-payment code QR; an "unlinkable by
|
||||
* design" notice replaces the traceability disclaimer.
|
||||
*
|
||||
* Intentionally minimal: no amount input, no PSBT/in-app wallet flow —
|
||||
* that's `DonateDialog`'s job. This panel is the always-available
|
||||
* "scan and pay from any wallet" affordance.
|
||||
*/
|
||||
export function CampaignWalletDonatePanel({
|
||||
wallet,
|
||||
}: CampaignWalletDonatePanelProps) {
|
||||
const { toast } = useToast();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Build the QR payload. For on-chain we use BIP-21 so any wallet that
|
||||
// recognizes the `bitcoin:` scheme can pre-fill the address; for SP we
|
||||
// use the BIP-21 `bitcoin:?sp=` extension. Donors pick the amount in
|
||||
// their wallet either way.
|
||||
const qrPayload = wallet.mode === 'onchain'
|
||||
? `bitcoin:${wallet.value}`
|
||||
: `bitcoin:?sp=${wallet.value}`;
|
||||
|
||||
const copyValue = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(wallet.value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
toast({ title: wallet.mode === 'sp' ? 'Silent-payment code copied' : 'Address copied' });
|
||||
} catch {
|
||||
toast({
|
||||
title: 'Copy failed',
|
||||
description: 'Select and copy the value manually.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* QR — large, centered on a clean white tile with the Agora logo
|
||||
embedded in an orange circular badge in the center.
|
||||
Error-correction level H tolerates the centered occlusion
|
||||
(~30% of modules can be missing and the code still scans). */}
|
||||
<div className="flex justify-center">
|
||||
<div className="relative rounded-2xl bg-white p-4 shadow-sm">
|
||||
<QRCodeCanvas value={qrPayload} size={280} level="H" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
>
|
||||
<div className="rounded-full bg-primary p-2 ring-[6px] ring-white">
|
||||
<img
|
||||
src="/logo.svg"
|
||||
alt=""
|
||||
className="size-16 object-contain brightness-0 invert"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyable value — single line, tap to copy. No wrapping
|
||||
container; sits flush with the rest of the column. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyValue}
|
||||
className="w-full flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs text-left hover:bg-muted/60 motion-safe:transition-colors"
|
||||
aria-label={wallet.mode === 'sp' ? 'Copy silent-payment code' : 'Copy Bitcoin address'}
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate" title={wallet.value}>
|
||||
{wallet.value}
|
||||
</span>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{wallet.mode === 'onchain' ? (
|
||||
<BitcoinPublicDisclaimer
|
||||
tone="soft"
|
||||
includeCashOutAdvice={false}
|
||||
leadText="Donations are public and can be traced back to you."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-muted/40 px-3 py-2.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0 mt-0.5 text-primary" />
|
||||
<span>
|
||||
Silent-payment campaigns are unlinkable by design. Your donation
|
||||
cannot be tied to the campaign by anyone other than the organizer.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open in wallet — relies on the `bitcoin:` URI handler. SP codes
|
||||
inside `bitcoin:?sp=` are still understood by BIP-352-aware
|
||||
wallets. Older wallets that don't know about SP will ignore
|
||||
the parameter and either refuse the link or show an error — at
|
||||
which point the donor falls back to copy/paste anyway. */}
|
||||
<Button asChild className="w-full text-white">
|
||||
<a href={qrPayload}>
|
||||
<ExternalLink className="size-4 mr-1.5" />
|
||||
Open in wallet
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback rendered when the wallet failed to parse. The detail page
|
||||
* should normally never reach this — `parseCampaign` rejects events
|
||||
* without a valid `w` tag — but a defensive surface is cheap and helps
|
||||
* debugging.
|
||||
*/
|
||||
export function CampaignWalletMissing() {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertTriangle className="size-5 text-orange-500 shrink-0" />
|
||||
<span>This campaign is missing a valid wallet endpoint.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import type React from 'react';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Award, BarChart3, Bird, BookOpen, Camera, Clapperboard, FileText, Film,
|
||||
GitBranch, GitPullRequest, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
|
||||
GitBranch, GitPullRequest, HandHeart, Highlighter, Mail, MapPin, Megaphone, MessageSquare, Mic, Music,
|
||||
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
|
||||
Stars, Target, Users, UserCheck, Vote, Zap,
|
||||
} from 'lucide-react';
|
||||
@@ -29,13 +29,11 @@ import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { useScryfallCard } from '@/hooks/useScryfallCard';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { CountryFlag } from '@/components/CountryFlag';
|
||||
import { customFlagAsset, hasCustomFlag } from '@/lib/customFlags';
|
||||
import { hasCustomFlag } from '@/lib/customFlags';
|
||||
import { useCountryFeed } from '@/contexts/CountryFeedContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFlagPalette } from '@/lib/flagPalette';
|
||||
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
|
||||
import { extractGathererCard, type GathererCard } from '@/lib/linkEmbed';
|
||||
import { cardPrimaryImage } from '@/lib/scryfall';
|
||||
|
||||
@@ -148,6 +146,7 @@ const KIND_LABELS: Record<number, string> = {
|
||||
34236: 'a divine',
|
||||
34550: 'an organization',
|
||||
9041: 'a goal',
|
||||
33863: 'a campaign',
|
||||
35128: 'an nsite',
|
||||
36639: 'a pledge',
|
||||
36787: 'a track',
|
||||
@@ -205,6 +204,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
39089: PartyPopper,
|
||||
3367: Palette,
|
||||
9041: Target,
|
||||
33863: HandHeart,
|
||||
9735: Zap,
|
||||
9802: Highlighter,
|
||||
2473: Bird,
|
||||
@@ -247,6 +247,7 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
|
||||
37516: 'treasure',
|
||||
30621: 'constellation',
|
||||
34550: 'organization',
|
||||
33863: 'campaign',
|
||||
30054: 'episode',
|
||||
30055: 'trailer',
|
||||
34139: 'playlist',
|
||||
@@ -912,10 +913,9 @@ function useCountryRootContext(event: NostrEvent): { iTag: string; code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given event is rendering with country chrome (pill + flag
|
||||
* backdrop) in the current context. Useful for sibling components that want
|
||||
* to coordinate styling — e.g. NoteCard switching its text to white when a
|
||||
* flag is showing through behind the author row.
|
||||
* Whether the given event is rendering with country chrome (the corner
|
||||
* flag pill) in the current context. Useful for sibling components that
|
||||
* want to coordinate styling.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useIsCountryRooted(event: NostrEvent): boolean {
|
||||
@@ -939,100 +939,11 @@ export function CountryCommentPill({ event, className }: { event: NostrEvent; cl
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorative flag backdrop for country-rooted kind-1111 posts. Renders the
|
||||
* country's Wikipedia lead image (the flag, for country articles) faded
|
||||
* behind the post, echoing the country detail page's hero
|
||||
* (`CountryContentHeader` in `ExternalContentHeader.tsx`) but scaled down
|
||||
* to a card. Pairs with `CountryCommentPill`.
|
||||
*
|
||||
* Designed to be rendered as the first child of a `relative overflow-hidden`
|
||||
* parent. The wrapper is absolutely positioned at `z-0`; its foreground
|
||||
* siblings must declare `relative` (any positioned value works) so they
|
||||
* paint above the backdrop. Pointer events are disabled so the post body
|
||||
* stays fully interactive.
|
||||
*
|
||||
* The Wikipedia summary fetch is cached for 24 h across all cards
|
||||
* referencing the same country code, so a feed of N Venezuelan posts only
|
||||
* pays the network cost once.
|
||||
*
|
||||
* Visibility rules: see `useCountryRootContext` (identical to the pill).
|
||||
* Decorative flag backdrop for country-rooted kind-1111 posts has been
|
||||
* removed in favor of a cleaner card surface. The `CountryCommentPill`
|
||||
* in the upper-right of the header is now the sole country chrome for
|
||||
* world posts.
|
||||
*/
|
||||
export function CountryFlagBackdrop({ event }: { event: NostrEvent }) {
|
||||
const ctx = useCountryRootContext(event);
|
||||
const info = ctx ? getCountryInfo(ctx.code) : null;
|
||||
const wikiTitle = ctx ? getWikipediaTitle(ctx.code) : null;
|
||||
const { data: wiki } = useWikipediaSummary(wikiTitle);
|
||||
// Sample dominant colors from the flag emoji at render time. Used as the
|
||||
// fallback gradient while Wikipedia is still resolving and after image
|
||||
// load failures, so the backdrop never reverts to a giant blurred emoji.
|
||||
const palette = useFlagPalette(info?.flag);
|
||||
// Track image load failures so we cleanly fall back to the flag-color
|
||||
// gradient. Wikipedia hosts these PNGs from upload.wikimedia.org which is
|
||||
// generally CORS-friendly, but hotlink-protection or transient 4xx
|
||||
// responses can still happen.
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
|
||||
if (!ctx) return null;
|
||||
|
||||
// Bundled-asset override: for codes with a curated flag SVG (currently
|
||||
// CN-XZ / Tibet) we skip Wikipedia entirely — its lead image is often
|
||||
// a parent-country map or an administrative-region inset, neither of
|
||||
// which reads as a flag behind a note. The Snow Lion SVG is what the
|
||||
// post is editorially "about", so it earns the backdrop slot.
|
||||
const bundledAsset = customFlagAsset(ctx.code);
|
||||
// For country articles Wikipedia returns the flag as the page's lead image
|
||||
// — the same source used by `CountryContentHeader`. Prefer the original
|
||||
// (full-resolution) over the 330px thumbnail; the thumbnail gets upscaled
|
||||
// and looks fuzzy when stretched across a full-width feed card.
|
||||
const flagImage = !imageFailed
|
||||
? (bundledAsset ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null)
|
||||
: null;
|
||||
|
||||
// Pre-built gradient using the palette (sampled from the flag emoji at
|
||||
// mount). Used as the fallback when Wikipedia hasn't returned an image or
|
||||
// its image failed to load. Single-color palettes get duplicated so
|
||||
// linear-gradient still has two stops.
|
||||
const paletteGradient =
|
||||
palette && palette.length > 0
|
||||
? `linear-gradient(135deg, ${palette.length === 1 ? `${palette[0]}, ${palette[0]}` : palette.join(', ')})`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div aria-hidden className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-64 sm:h-72">
|
||||
{flagImage ? (
|
||||
// Full-width flag banner across the top of the card. A mask-image
|
||||
// gradient fades the image to nothing at its bottom edge, so the
|
||||
// flag dissolves into the card with no hard seam.
|
||||
<img
|
||||
src={flagImage}
|
||||
alt=""
|
||||
decoding="async"
|
||||
onError={() => setImageFailed(true)}
|
||||
className="w-full h-full object-cover opacity-20 select-none"
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
) : paletteGradient ? (
|
||||
// Wikipedia not yet resolved (or its image failed) — paint the
|
||||
// flag-color gradient as a placeholder/fallback. Same opacity and
|
||||
// mask shape as the image so the visual swap is seamless when the
|
||||
// image arrives.
|
||||
<div
|
||||
className="absolute inset-0 opacity-20"
|
||||
style={{
|
||||
backgroundImage: paletteGradient,
|
||||
maskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, black 0%, black 35%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Body-level comment context for ISO 3166 roots — intentionally renders
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
CalendarClock,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
Pencil,
|
||||
Shield,
|
||||
Share2,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
@@ -25,30 +27,42 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
import { CampaignCard } from '@/components/CampaignCard';
|
||||
import { PeopleAvatarStack } from '@/components/PeopleAvatarStack';
|
||||
import { PostActionBar } from '@/components/PostActionBar';
|
||||
import { DetailCommentComposer } from '@/components/DetailCommentComposer';
|
||||
import { PinnedCommentHeader } from '@/components/PinnedCommentHeader';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DonateDialog } from '@/components/DonateDialog';
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { FollowToggleButton } from '@/components/FollowButton';
|
||||
import { InteractionsModal, type InteractionTab } from '@/components/InteractionsModal';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { ThreadedReplyList, type ReplyNode } from '@/components/ThreadedReplyList';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { useBitcoinWallet } from '@/hooks/useBitcoinWallet';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { useCommunityBookmarks } from '@/hooks/useCommunityBookmarks';
|
||||
import { useCommunityMembers } from '@/hooks/useCommunityMembers';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { useNow } from '@/hooks/useNow';
|
||||
import { useOrganizationActivity } from '@/hooks/useOrganizationActivity';
|
||||
import { usePinnedEventComments } from '@/hooks/usePinnedEventComments';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useEventRSVPs } from '@/hooks/useEventRSVPs';
|
||||
@@ -217,7 +231,7 @@ function parseShelfLocation(raw: string): string {
|
||||
|
||||
function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/95 px-2.5 py-1 text-xs font-semibold text-muted-foreground shadow-sm">
|
||||
<div className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-background/95 px-2.5 py-1 text-xs font-semibold text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
@@ -225,7 +239,7 @@ function ActivityTypePill({ icon, label }: { icon: React.ReactNode; label: strin
|
||||
}
|
||||
|
||||
function PledgeShelfCard({ pledge }: { pledge: Action }) {
|
||||
const { btcPrice } = useBitcoinWallet();
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
const author = useAuthor(pledge.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, pledge.pubkey);
|
||||
@@ -243,11 +257,8 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/${naddr}`}
|
||||
className="group block w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
|
||||
className="group block h-[430px] w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="mb-2">
|
||||
<ActivityTypePill icon={<Megaphone className="size-3.5 text-primary" />} label="Pledge" />
|
||||
</div>
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
|
||||
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
<img
|
||||
@@ -302,8 +313,11 @@ function PledgeShelfCard({ pledge }: { pledge: Action }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
|
||||
by <span className="font-medium text-foreground">{displayName}</span>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<div className="truncate">
|
||||
by <span className="font-medium text-foreground">{displayName}</span>
|
||||
</div>
|
||||
<ActivityTypePill icon={<Megaphone className="size-3.5 text-primary" />} label="Pledge" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -336,11 +350,8 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
|
||||
return (
|
||||
<Link
|
||||
to={`/${naddr}`}
|
||||
className="group block w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
|
||||
className="group block h-[430px] w-[280px] shrink-0 rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
|
||||
>
|
||||
<div className="mb-2">
|
||||
<ActivityTypePill icon={<CalendarDays className="size-3.5 text-primary" />} label="Event" />
|
||||
</div>
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
|
||||
<div className="relative w-full aspect-[16/9] overflow-hidden bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
{coverImage ? (
|
||||
@@ -405,8 +416,11 @@ function CalendarEventShelfCard({ event }: { event: NostrEvent }) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground border-t border-border/60 pt-3 truncate">
|
||||
by <span className="font-medium text-foreground">{displayName}</span>
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 pt-3 text-xs text-muted-foreground">
|
||||
<div className="truncate">
|
||||
by <span className="font-medium text-foreground">{displayName}</span>
|
||||
</div>
|
||||
<ActivityTypePill icon={<CalendarDays className="size-3.5 text-primary" />} label="Event" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -438,7 +452,7 @@ function OfficialShelf({ title, count, isLoading, isEmpty, children }: OfficialS
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="-mx-4 sm:-mx-6 px-4 sm:px-6 flex gap-3 overflow-x-auto scrollbar-none pb-1">
|
||||
<div className="-mx-4 sm:-mx-6 px-4 sm:px-6 flex items-stretch gap-3 overflow-x-auto scrollbar-none pb-1">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
@@ -462,11 +476,9 @@ function OfficialActivityShelves({
|
||||
eventsLoading: boolean;
|
||||
now: number;
|
||||
}) {
|
||||
// Drop archived campaigns; mixed activity is sorted newest publish first.
|
||||
const liveCampaigns = useMemo(
|
||||
() => campaigns.filter((c) => !c.archived),
|
||||
[campaigns],
|
||||
);
|
||||
// All loaded campaigns. Closure is via NIP-09 deletion (relay-level),
|
||||
// so anything that reached us is current.
|
||||
const liveCampaigns = campaigns;
|
||||
|
||||
// Drop expired pledges; mixed activity is sorted newest publish first.
|
||||
const livePledges = useMemo(() => {
|
||||
@@ -529,11 +541,12 @@ function OfficialActivityShelves({
|
||||
{mixedActivity.map((item) => {
|
||||
if (item.type === 'campaign') {
|
||||
return (
|
||||
<div key={`campaign:${item.id}`} className="w-[280px] shrink-0">
|
||||
<div className="mb-2">
|
||||
<ActivityTypePill icon={<HandHeart className="size-3.5 text-primary" />} label="Campaign" />
|
||||
</div>
|
||||
<CampaignCard campaign={item.campaign} />
|
||||
<div key={`campaign:${item.id}`} className="h-[430px] w-[280px] shrink-0">
|
||||
<CampaignCard
|
||||
campaign={item.campaign}
|
||||
className="h-full"
|
||||
footerBadge={<ActivityTypePill icon={<HandHeart className="size-3.5 text-primary" />} label="Campaign" />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -609,17 +622,15 @@ function CommunityCreateActions({
|
||||
|
||||
export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { btcPrice } = useBitcoinWallet();
|
||||
|
||||
const [membersDialogOpen, setMembersDialogOpen] = useState(false);
|
||||
const [descriptionDialogOpen, setDescriptionDialogOpen] = useState(false);
|
||||
const [donateOpen, setDonateOpen] = useState(false);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [interactionsOpen, setInteractionsOpen] = useState(false);
|
||||
const [interactionsTab, setInteractionsTab] = useState<InteractionTab>('reposts');
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const deleteMutation = useDeleteEvent();
|
||||
|
||||
// Parse community definition
|
||||
const community = useMemo(() => parseCommunityEvent(event), [event]);
|
||||
@@ -669,29 +680,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
// (content bans, reports) used by the comment thread below.
|
||||
const { moderation, rankMap, isLoading: membersLoading } = useCommunityMembers(community);
|
||||
|
||||
const communityDonationTarget = useMemo<ParsedCampaign | null>(() => {
|
||||
if (!community) return null;
|
||||
const recipients = [
|
||||
{ pubkey: community.founderPubkey, weight: 1 },
|
||||
...community.moderatorPubkeys.map((pubkey) => ({ pubkey, weight: 1 })),
|
||||
];
|
||||
return {
|
||||
event,
|
||||
pubkey: event.pubkey,
|
||||
identifier: community.dTag,
|
||||
aTag: community.aTag,
|
||||
title: community.name,
|
||||
summary: community.description,
|
||||
story: community.description,
|
||||
image: community.image,
|
||||
category: 'community',
|
||||
tags: ['community'],
|
||||
recipients,
|
||||
createdAt: event.created_at,
|
||||
archived: false,
|
||||
};
|
||||
}, [community, event]);
|
||||
|
||||
// Only the founder can edit organization metadata. Moderators can
|
||||
// moderate content via the community context but don't get the
|
||||
// "Edit community" action.
|
||||
@@ -742,6 +730,12 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
|
||||
// ── Comments (NIP-22 on the community event) ───────────────────────────────
|
||||
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
||||
const {
|
||||
pinnedEvents,
|
||||
isPinned,
|
||||
canManagePins,
|
||||
togglePin,
|
||||
} = usePinnedEventComments(communityATag || undefined, event.pubkey);
|
||||
|
||||
// ── Official activity shelves ─────────────────────────────────────────────
|
||||
// Author-filtered to founder + moderators (see useOrganizationActivity).
|
||||
@@ -765,21 +759,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
}, [community, event.kind, event.pubkey]);
|
||||
|
||||
// ── Engagement stats for the community event itself ──────────────────────
|
||||
// Pulled from NIP-85. Used both for the inline counters above the action
|
||||
// bar and for the threaded-comments header. Matches the rhythm of the
|
||||
// campaign and pledge detail pages.
|
||||
// Pulled from NIP-85 for the threaded-comments header.
|
||||
const { data: engagementStats, isLoading: statsLoading } = useEventStats(event.id, event);
|
||||
const hasStats =
|
||||
!!engagementStats?.replies ||
|
||||
!!engagementStats?.reposts ||
|
||||
!!engagementStats?.quotes ||
|
||||
!!engagementStats?.reactions;
|
||||
|
||||
const openInteractions = useCallback((tab: InteractionTab) => {
|
||||
setInteractionsTab(tab);
|
||||
setInteractionsOpen(true);
|
||||
}, []);
|
||||
|
||||
const replyTree = useMemo((): ReplyNode[] => {
|
||||
if (!commentsData) return [];
|
||||
const topLevel = commentsData.topLevelComments ?? [];
|
||||
@@ -811,6 +792,11 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
.map((r) => buildNode(r));
|
||||
}, [commentsData, moderation]);
|
||||
|
||||
const pinnedNodes = useMemo(
|
||||
() => pinnedEvents.map((event): ReplyNode => ({ event, children: [] })),
|
||||
[pinnedEvents],
|
||||
);
|
||||
|
||||
// ── Share handler ───────────────────────────────────────────────────────────
|
||||
const handleShare = useCallback(async () => {
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
@@ -828,6 +814,51 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
}
|
||||
}, [event, toast]);
|
||||
|
||||
// ── Delete handler ─────────────────────────────────────────────────────────
|
||||
// Founder-only. Publishes a NIP-09 kind 5 deletion request referencing the
|
||||
// community definition (kind 34550) by both `e` and `a` tags so relays can
|
||||
// drop it from both id-based and addressable lookups. After the request
|
||||
// ships we invalidate every org-related cache so the page the user lands
|
||||
// on (`/communities`) shows the deletion immediately, even if some relays
|
||||
// haven't propagated yet.
|
||||
const handleDeleteOrganization = useCallback(() => {
|
||||
if (!community) return;
|
||||
deleteMutation.mutate(
|
||||
{
|
||||
eventId: event.id,
|
||||
eventKind: event.kind,
|
||||
eventPubkey: event.pubkey,
|
||||
eventDTag: community.dTag,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Organization deleted',
|
||||
description:
|
||||
'A deletion request was published. Well-behaved relays will drop the organization from feeds.',
|
||||
});
|
||||
setDeleteConfirmOpen(false);
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['addr-event', event.kind, event.pubkey, community.dTag],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ['community-definition', community.aTag] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['manageable-organizations'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['featured-organizations'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['followed-organizations'] });
|
||||
navigate('/communities');
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
toast({
|
||||
title: 'Could not delete organization',
|
||||
description: msg,
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [community, deleteMutation, event, navigate, queryClient, toast]);
|
||||
|
||||
useLayoutOptions({
|
||||
noMaxWidth: true,
|
||||
rightSidebar: null,
|
||||
@@ -848,7 +879,7 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 pt-4">
|
||||
<CommunityModerationContext.Provider value={moderationCtx}>
|
||||
{/* ── Hero ─────────────────────────────────────────────────────── */}
|
||||
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-xl overflow-hidden bg-gradient-to-br from-primary/40 via-primary/20 to-secondary">
|
||||
<div className="relative aspect-[16/9] sm:aspect-[21/9] rounded-t-xl rounded-b-none overflow-hidden bg-gradient-to-br from-primary/40 via-primary/20 to-secondary">
|
||||
{cover ? (
|
||||
<img src={cover} alt="" className="absolute inset-0 size-full object-cover" />
|
||||
) : (
|
||||
@@ -970,6 +1001,18 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
Edit organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isFounder && community && (
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setDeleteConfirmOpen(true);
|
||||
}}
|
||||
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
Delete organization
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
@@ -977,21 +1020,39 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-b-xl rounded-t-none bg-card border border-t-0 border-border/60 shadow-sm px-4 sm:px-5 py-3">
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comment"
|
||||
hideZap
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pinnedNodes.length > 0 && (
|
||||
<div className="pt-6">
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={pinnedNodes}
|
||||
renderItemHeader={(event) => (
|
||||
<OrganizationPinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Body — single column, pledge-detail-style ─────────────────── */}
|
||||
<div className="py-6 lg:py-10 space-y-8">
|
||||
{/* Donate (when there's a member set) and Share buttons. Sits
|
||||
just below the hero like the pledge page's action row. */}
|
||||
<div className={cn('grid gap-2', communityDonationTarget ? 'grid-cols-4' : 'grid-cols-1')}>
|
||||
{communityDonationTarget && (
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full col-span-3"
|
||||
onClick={() => setDonateOpen(true)}
|
||||
>
|
||||
<HandHeart className="size-5 mr-2" />
|
||||
Donate
|
||||
</Button>
|
||||
)}
|
||||
<div className="grid gap-2 grid-cols-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -1022,48 +1083,9 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
now={now}
|
||||
/>
|
||||
|
||||
{/* Engagement card — stats counters + post action bar. Matches
|
||||
the pledge / campaign detail layout. No funding progress bar
|
||||
here; an organization isn't a fundraising target itself. */}
|
||||
{/* Comments — NIP-22 thread on the community event itself. */}
|
||||
<div id="org-activity" className="scroll-mt-20">
|
||||
<div className="rounded-2xl bg-card border border-border/60 shadow-sm px-4 sm:px-5 py-4 sm:py-5">
|
||||
{hasStats && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm text-muted-foreground pb-2">
|
||||
{engagementStats?.reposts ? (
|
||||
<button onClick={() => openInteractions('reposts')} className="hover:underline transition-colors">
|
||||
<span className="font-bold text-foreground">{formatNumber(engagementStats.reposts)}</span>{' '}
|
||||
Repost{engagementStats.reposts !== 1 ? 's' : ''}
|
||||
</button>
|
||||
) : null}
|
||||
{engagementStats?.quotes ? (
|
||||
<button onClick={() => openInteractions('quotes')} className="hover:underline transition-colors">
|
||||
<span className="font-bold text-foreground">{formatNumber(engagementStats.quotes)}</span>{' '}
|
||||
Quote{engagementStats.quotes !== 1 ? 's' : ''}
|
||||
</button>
|
||||
) : null}
|
||||
{engagementStats?.reactions ? (
|
||||
<button onClick={() => openInteractions('reactions')} className="hover:underline transition-colors">
|
||||
<span className="font-bold text-foreground">{formatNumber(engagementStats.reactions)}</span>{' '}
|
||||
Like{engagementStats.reactions !== 1 ? 's' : ''}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comment"
|
||||
hideZap
|
||||
onReply={() => setReplyOpen(true)}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
className={hasStats ? 'pt-3 border-t border-border/60' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Comments — NIP-22 thread on the community event itself.
|
||||
Member-filter aware (see replyTree) and routed through
|
||||
CommunityModerationContext so per-reply ban actions work. */}
|
||||
<div className="mt-6">
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-3 mb-3 px-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Comments</h2>
|
||||
{engagementStats?.replies ? (
|
||||
@@ -1074,6 +1096,8 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DetailCommentComposer event={event} className="mb-3" />
|
||||
|
||||
{commentsLoading && statsLoading && replyTree.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
@@ -1081,8 +1105,18 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
))}
|
||||
</div>
|
||||
) : replyTree.length > 0 ? (
|
||||
<div className="-mx-2 sm:-mx-4 rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList roots={replyTree} />
|
||||
<div className="rounded-2xl bg-card border border-border/60 overflow-hidden">
|
||||
<ThreadedReplyList
|
||||
roots={replyTree}
|
||||
renderItemHeader={(event) => (
|
||||
<OrganizationPinHeader
|
||||
isPinned={isPinned(event.id)}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={togglePin.isPending}
|
||||
onTogglePin={() => handleTogglePin(event)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
@@ -1103,15 +1137,6 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
</CommunityModerationContext.Provider>
|
||||
|
||||
{communityDonationTarget && (
|
||||
<DonateDialog
|
||||
campaign={communityDonationTarget}
|
||||
open={donateOpen}
|
||||
onOpenChange={setDonateOpen}
|
||||
btcPrice={btcPrice}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Description dialog — opened by clicking the truncated description in
|
||||
the banner. Renders the full raw description plus a clickable
|
||||
website link when the description ends with a URL. */}
|
||||
@@ -1195,16 +1220,74 @@ export function CommunityDetailPage({ event }: { event: NostrEvent }) {
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
|
||||
{/* Tapping a repost / quote / like counter on the stats row opens
|
||||
a modal listing the people who took that action. */}
|
||||
<InteractionsModal
|
||||
eventId={event.id}
|
||||
open={interactionsOpen}
|
||||
onOpenChange={setInteractionsOpen}
|
||||
initialTab={interactionsTab}
|
||||
/>
|
||||
{/* Founder-only delete confirmation. NIP-09 is advisory — relays decide
|
||||
whether to honor the request — so the copy makes the limitation
|
||||
explicit and steers founders toward "Edit organization" if they
|
||||
just want to change something. */}
|
||||
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this organization?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This publishes a NIP-09 deletion request for{' '}
|
||||
<span className="font-medium text-foreground">{name}</span>.
|
||||
Well-behaved relays will drop the organization from feeds and
|
||||
direct links. Campaigns, pledges, and posts published under
|
||||
the organization stay on-chain regardless. This action cannot
|
||||
be undone — to change the name, banner, or moderators, edit
|
||||
the organization instead.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDeleteOrganization();
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleteMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
function handleTogglePin(event: NostrEvent) {
|
||||
const wasPinned = isPinned(event.id);
|
||||
togglePin.mutate(event.id, {
|
||||
onSuccess: () => {
|
||||
toast({ title: wasPinned ? 'Unpinned from organization' : 'Pinned to organization' });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to update organization pins', variant: 'destructive' });
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function OrganizationPinHeader({
|
||||
isPinned,
|
||||
canManagePins,
|
||||
pinPending,
|
||||
onTogglePin,
|
||||
}: {
|
||||
isPinned: boolean;
|
||||
canManagePins: boolean;
|
||||
pinPending: boolean;
|
||||
onTogglePin: () => void;
|
||||
}) {
|
||||
return (
|
||||
<PinnedCommentHeader
|
||||
isPinned={isPinned}
|
||||
canManagePins={canManagePins}
|
||||
pinPending={pinPending}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, EyeOff, Eye, Loader2, MoreHorizontal, Sparkles, SparklesIcon } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { ModerationLabel } from '@/lib/agoraModeration';
|
||||
|
||||
interface CommunityModerationMenuProps {
|
||||
/** The organization's `34550:<pubkey>:<d>` coordinate. */
|
||||
coord: string;
|
||||
/** Visible name for the organization (for toast feedback). */
|
||||
organizationName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-card kebab menu exposing the moderator actions for an organization:
|
||||
*
|
||||
* Hide / Unhide (axis = hide)
|
||||
* Feature / Unfeature (axis = featured)
|
||||
*
|
||||
* Organizations intentionally do **not** have an `approved` axis — unlike
|
||||
* campaigns, which gate homepage placement on moderator approval, every
|
||||
* Agora-tagged organization is publicly visible by default. Moderators
|
||||
* curate via two narrower controls: lifting an org into the Featured
|
||||
* shelf, or suppressing it with a Hidden label.
|
||||
*
|
||||
* Renders `null` for users who are not Team Soapbox pack members. Sits
|
||||
* inside the clickable `CommunityMiniCard` `<Link>`, so the trigger
|
||||
* swallows its own click and the dropdown content stops propagation —
|
||||
* otherwise every menu interaction would navigate to the organization
|
||||
* detail page.
|
||||
*
|
||||
* The moderation rollup is read inside this component (after the
|
||||
* moderator gate) instead of at the parent so non-moderator viewers
|
||||
* never subscribe to the heavy `useOrganizationModeration` query — every
|
||||
* `CommunityMiniCard` in a grid would otherwise wake the same cache
|
||||
* subscription up to 18+ times per page.
|
||||
*/
|
||||
export function CommunityModerationMenu({
|
||||
coord,
|
||||
organizationName,
|
||||
className,
|
||||
}: CommunityModerationMenuProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
// Bail before the heavy moderation query subscribes. Non-moderators
|
||||
// (the overwhelming majority) never pay the network or render cost.
|
||||
if (!isMod) return null;
|
||||
|
||||
return <CommunityModerationMenuInner coord={coord} organizationName={organizationName} className={className} />;
|
||||
}
|
||||
|
||||
function CommunityModerationMenuInner({
|
||||
coord,
|
||||
organizationName,
|
||||
className,
|
||||
}: CommunityModerationMenuProps) {
|
||||
const { data: moderation, moderate } = useOrganizationModeration();
|
||||
const { toast } = useToast();
|
||||
const [busy, setBusy] = useState<ModerationLabel | null>(null);
|
||||
|
||||
const isHidden = moderation.hiddenCoords.has(coord);
|
||||
const isFeatured = moderation.featuredCoords.has(coord);
|
||||
|
||||
const runAction = async (action: ModerationLabel, verbPast: string) => {
|
||||
if (busy) return;
|
||||
setBusy(action);
|
||||
try {
|
||||
await moderate.mutateAsync({ coord, action });
|
||||
toast({ title: verbPast, description: organizationName });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
toast({
|
||||
title: `Failed to ${action}`,
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.preventDefault()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Moderate organization"
|
||||
className={className ?? 'h-8 w-8 bg-background/80 backdrop-blur text-muted-foreground hover:text-foreground'}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <MoreHorizontal className="h-4 w-4" />}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
Moderator actions
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{isFeatured ? (
|
||||
<DropdownMenuItem onClick={() => runAction('unfeatured', 'Removed from featured')}>
|
||||
<SparklesIcon className="h-4 w-4 mr-2" />
|
||||
Unfeature
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Featured
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={() => runAction('featured', 'Featured organization')}>
|
||||
<Sparkles className="h-4 w-4 mr-2" />
|
||||
Feature
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
{isHidden ? (
|
||||
<DropdownMenuItem onClick={() => runAction('unhidden', 'Unhidden')}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Unhide
|
||||
<span className="ml-auto text-xs text-muted-foreground inline-flex items-center gap-1">
|
||||
<Check className="h-3 w-3" /> Hidden
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() => runAction('hidden', 'Hidden')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
Hide
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner-overlay wrapper for `CommunityMiniCard` cards. Renders the
|
||||
* moderator kebab plus a "Hidden" badge when applicable, both
|
||||
* absolutely-positioned at the card's top-right. Returns `null` for
|
||||
* non-moderators so non-mod grids never subscribe to the moderation
|
||||
* query at all.
|
||||
*
|
||||
* Pulling the overlay (and its `useOrganizationModeration` subscription)
|
||||
* out of `CommunityMiniCard` into a single moderator-gated component is
|
||||
* the perf win that lets `/communities` paint Featured/My orgs
|
||||
* immediately without waiting for the moderator pack or the label query
|
||||
* for every card on the page.
|
||||
*/
|
||||
export function CommunityModerationOverlay({
|
||||
coord,
|
||||
organizationName,
|
||||
}: {
|
||||
coord: string;
|
||||
organizationName: string;
|
||||
}) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isMod = !!user && !!moderators && moderators.includes(user.pubkey);
|
||||
|
||||
if (!isMod) return null;
|
||||
|
||||
return (
|
||||
<CommunityModerationOverlayInner coord={coord} organizationName={organizationName} />
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityModerationOverlayInner({
|
||||
coord,
|
||||
organizationName,
|
||||
}: {
|
||||
coord: string;
|
||||
organizationName: string;
|
||||
}) {
|
||||
const { data: moderation } = useOrganizationModeration();
|
||||
const isHidden = moderation.hiddenCoords.has(coord);
|
||||
|
||||
return (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-1.5">
|
||||
{isHidden && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="backdrop-blur bg-destructive/15 text-destructive border-destructive/30 h-6 px-1.5 text-[10px]"
|
||||
>
|
||||
<EyeOff className="size-3 mr-1" />
|
||||
Hidden
|
||||
</Badge>
|
||||
)}
|
||||
{/* The kebab inner uses the same moderation cache subscription, so
|
||||
no extra round-trip is incurred. */}
|
||||
<CommunityModerationMenuInner coord={coord} organizationName={organizationName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, HelpCircle } from 'lucide-react';
|
||||
import { Paperclip, Smile, AlertTriangle, X, Loader2, Mic, Square, Sticker, BarChart3, Plus, ChevronLeft, Check, Globe, HelpCircle } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { encode as blurhashEncode } from 'blurhash';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -12,7 +12,21 @@ import { Input } from '@/components/ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { GifPicker } from '@/components/GifPicker';
|
||||
@@ -29,12 +43,14 @@ import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { usePostComment } from '@/hooks/usePostComment';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useCountryFollows } from '@/hooks/useCountryFollows';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { useDefaultPostCountry } from '@/hooks/useDefaultPostCountry';
|
||||
import { COUNTRY_LIST, getCountryInfo } from '@/lib/countries';
|
||||
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
import type { Nip85EventStats } from '@/hooks/useNip85Stats';
|
||||
import { invalidateEventStats } from '@/lib/invalidateEventStats';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
import { extractVideoUrls, extractAudioUrls, IMETA_MEDIA_URL_REGEX, IMETA_MEDIA_URL_TEST_REGEX, mimeFromExt } from '@/lib/mediaUrls';
|
||||
@@ -50,6 +66,7 @@ import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { resizeImage } from '@/lib/resizeImage';
|
||||
import { extractHashtags } from '@/lib/hashtag';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { AGORA_DEFAULT_NOTE_TAGS } from '@/lib/agoraNoteTags';
|
||||
|
||||
const MAX_CHARS = 5000;
|
||||
|
||||
@@ -156,6 +173,18 @@ interface ComposeBoxProps {
|
||||
hidePoll?: boolean;
|
||||
/** Label for the primary submit button. */
|
||||
submitLabel?: string;
|
||||
/**
|
||||
* Tags added to new top-level kind 1 notes without putting them in content.
|
||||
*
|
||||
* Defaults to {@link AGORA_DEFAULT_NOTE_TAGS} (the silent `t:agora` tag) when
|
||||
* the composer is producing a top-level kind 1 note (no replyTo, not a quote,
|
||||
* not poll mode, no custom publish, no country-scoped destination). Replies,
|
||||
* quotes, polls, comments, and custom-kind publishes do not receive these
|
||||
* tags regardless of this prop. Pass `[]` to opt out explicitly.
|
||||
*/
|
||||
defaultTags?: string[][];
|
||||
/** If true, the composer starts expanded without taking modal/flex behavior. */
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
/** Circular progress ring for character count. */
|
||||
@@ -214,6 +243,8 @@ export function ComposeBox({
|
||||
customPublish,
|
||||
hidePoll = false,
|
||||
submitLabel = 'Post!',
|
||||
defaultTags,
|
||||
defaultExpanded = false,
|
||||
}: ComposeBoxProps) {
|
||||
const { user, metadata, isLoading: isProfileLoading } = useCurrentUser();
|
||||
const userProfileUrl = useProfileUrl(user?.pubkey ?? '', metadata);
|
||||
@@ -228,6 +259,7 @@ export function ComposeBox({
|
||||
const { toast } = useToast();
|
||||
const { config } = useAppContext();
|
||||
const imageQuality = config.imageQuality;
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Build a stable localStorage key based on compose context.
|
||||
@@ -248,7 +280,7 @@ export function ComposeBox({
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
const [cwEnabled, setCwEnabled] = useState(false);
|
||||
const [cwText, setCwText] = useState('');
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
@@ -263,23 +295,39 @@ export function ComposeBox({
|
||||
// from the home feed (no replyTo, not a custom-kind publish). When a
|
||||
// country code is selected, the post is published as a NIP-22 kind
|
||||
// 1111 comment rooted on that country instead of a plain kind 1 note.
|
||||
// Dropdown lists only the countries the user follows, with "Global"
|
||||
// always at the top.
|
||||
//
|
||||
// The dropdown shows: Global + the countries the user follows (quick
|
||||
// picks) + a "Choose another country…" item that opens a searchable
|
||||
// dialog over the full country list. So a user can post about any
|
||||
// country, even one they don't follow.
|
||||
const { followedCountries } = useCountryFollows();
|
||||
const canChooseDestination =
|
||||
!replyTo && !customPublish && mode === 'post' && !!user && followedCountries.length > 0;
|
||||
!replyTo && !customPublish && mode === 'post' && !!user;
|
||||
/**
|
||||
* User's saved default destination (persisted to localStorage). Used as
|
||||
* the initial value of `destination` on every fresh compose, and updated
|
||||
* when the user clicks "Set as default" in the destination menu.
|
||||
*/
|
||||
const [defaultPostCountry, setDefaultPostCountry] = useDefaultPostCountry();
|
||||
/** `'world'` for a regular kind-1 note, or an ISO 3166 country code for a kind-1111 community post. */
|
||||
const [destination, setDestination] = useState<'world' | string>('world');
|
||||
const [destination, setDestination] = useState<'world' | string>(defaultPostCountry);
|
||||
/** Open state for the "Choose another country" searchable picker dialog. */
|
||||
const [countryPickerOpen, setCountryPickerOpen] = useState(false);
|
||||
const selectedCountryCode = destination !== 'world' ? destination : null;
|
||||
const selectedCountryInfo = selectedCountryCode ? getCountryInfo(selectedCountryCode) : null;
|
||||
// If the user unfollows the currently-selected country mid-session,
|
||||
// snap back to world so we don't try to publish a kind 1111 with
|
||||
// a root the user no longer cares about.
|
||||
// Snap back to world if the currently selected destination is an
|
||||
// invalid ISO code (e.g. a previously-followed country that was later
|
||||
// removed from the country directory). Picking a non-followed but
|
||||
// valid country is allowed — users can post about any country via the
|
||||
// "Choose another country" picker, so following is not a prerequisite.
|
||||
useEffect(() => {
|
||||
if (selectedCountryCode && !followedCountries.includes(selectedCountryCode)) {
|
||||
if (selectedCountryCode && !getCountryInfo(selectedCountryCode)) {
|
||||
setDestination('world');
|
||||
if (defaultPostCountry === selectedCountryCode) {
|
||||
setDefaultPostCountry('world');
|
||||
}
|
||||
}
|
||||
}, [selectedCountryCode, followedCountries]);
|
||||
}, [selectedCountryCode, defaultPostCountry, setDefaultPostCountry]);
|
||||
const [pollOptions, setPollOptions] = useState([
|
||||
{ id: pollOptionId(), label: '' },
|
||||
{ id: pollOptionId(), label: '' },
|
||||
@@ -305,7 +353,7 @@ export function ComposeBox({
|
||||
setContent('');
|
||||
setCwEnabled(false);
|
||||
setCwText('');
|
||||
setExpanded(false);
|
||||
setExpanded(defaultExpanded);
|
||||
setPickerOpen(false);
|
||||
setTrayOpen(false);
|
||||
setInternalPreviewMode(false);
|
||||
@@ -317,10 +365,10 @@ export function ComposeBox({
|
||||
setUploadedFileGroups(new Map());
|
||||
setWebxdcUuids(new Map());
|
||||
setWebxdcMetas(new Map());
|
||||
setDestination('world');
|
||||
setDestination(defaultPostCountry);
|
||||
// Clear the auto-saved draft
|
||||
try { localStorage.removeItem(draftKey); } catch { /* ignore */ }
|
||||
}, [initialMode, draftKey]);
|
||||
}, [initialMode, draftKey, defaultExpanded, defaultPostCountry]);
|
||||
|
||||
// Use controlled preview mode if provided, otherwise use internal state
|
||||
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
|
||||
@@ -862,6 +910,11 @@ export function ComposeBox({
|
||||
|
||||
// Reset state
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
// Voice messages can surface in the home Agora activity feed (via
|
||||
// the `t:Agora` marker on root messages and through the comment
|
||||
// path on replies). Refresh both home feed queries.
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||||
if (replyTo) {
|
||||
if (isExternalRoot(replyTo)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||||
@@ -870,7 +923,13 @@ export function ComposeBox({
|
||||
if (replyTo.kind !== 1) {
|
||||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||||
}
|
||||
// Bump comment count on the parent event so the UI updates.
|
||||
invalidateEventStats(queryClient, replyTo, statsPubkey);
|
||||
}
|
||||
} else if (canChooseDestination && selectedCountryCode) {
|
||||
// Root voice message published to a country community feed.
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||||
}
|
||||
notificationSuccess();
|
||||
toast({ title: 'Voice message sent!', description: 'Your voice message has been published.' });
|
||||
@@ -880,7 +939,7 @@ export function ComposeBox({
|
||||
} finally {
|
||||
setIsPublishingVoice(false);
|
||||
}
|
||||
}, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess]);
|
||||
}, [user, voiceRecorder, uploadFile, buildContentWarningTags, customPublish, createEvent, onPublished, replyTo, queryClient, toast, onSuccess, canChooseDestination, selectedCountryCode, statsPubkey]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!content.trim() || !user || charCount > MAX_CHARS) return;
|
||||
@@ -1108,22 +1167,49 @@ export function ComposeBox({
|
||||
const countryRoot = new URL(createCountryIdentifier(selectedCountryCode));
|
||||
await postComment({ root: countryRoot, reply: undefined, content: finalContent, tags });
|
||||
} else {
|
||||
// Top-level kind 1 note. If the caller hasn't supplied `defaultTags`,
|
||||
// auto-attach the silent Agora tag so the post surfaces in the Agora
|
||||
// activity feed. Callers can opt out by passing `defaultTags={[]}`.
|
||||
const effectiveDefaultTags = defaultTags ?? AGORA_DEFAULT_NOTE_TAGS;
|
||||
await createEvent({
|
||||
kind: 1,
|
||||
content: finalContent,
|
||||
tags,
|
||||
tags: [...effectiveDefaultTags, ...tags],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
resetComposeState();
|
||||
// Optimistically bump the reply count on the parent event
|
||||
// Optimistically bump the comment count on the parent event
|
||||
if (replyTo && !isExternalRoot(replyTo)) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', replyTo.id], (prev) =>
|
||||
prev ? { ...prev, replies: prev.replies + 1 } : prev,
|
||||
queryClient.setQueryData<Nip85EventStats | null>(
|
||||
['nip85-event-stats', replyTo.id, statsPubkey],
|
||||
(prev) => prev ? { ...prev, commentCount: prev.commentCount + 1 } : prev,
|
||||
);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
// Top-level kind 1 posts with the silent Agora tag (the default for
|
||||
// user-authored notes) surface in the home Agora activity feed
|
||||
// (useAgoraFeed / mixed-feed). Invalidate both so the post appears
|
||||
// there without a refresh — over-invalidation is cheap here.
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||||
// Top-level kind 1 posts surface on country pages too. Country posts
|
||||
// route through `usePostComment` (which handles its own invalidation),
|
||||
// but the top-level branch above publishes via `createEvent`, so we
|
||||
// need to invalidate the country feed keys here. `selectedCountryCode`
|
||||
// is null for global posts, in which case nothing extra needs to
|
||||
// refresh (the global Agora feed is served by relays, not a per-country
|
||||
// query). For drafts attached to a specific country via customPublish
|
||||
// we conservatively invalidate the broader prefix.
|
||||
if (canChooseDestination && selectedCountryCode && !replyTo) {
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||||
}, 3000);
|
||||
}
|
||||
if (replyTo) {
|
||||
if (isExternalRoot(replyTo)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||||
@@ -1138,7 +1224,7 @@ export function ComposeBox({
|
||||
}
|
||||
}
|
||||
if (quotedEvent) {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-stats', quotedEvent.id] });
|
||||
invalidateEventStats(queryClient, quotedEvent, statsPubkey);
|
||||
queryClient.invalidateQueries({ queryKey: ['event-interactions', quotedEvent.id] });
|
||||
}
|
||||
notificationSuccess();
|
||||
@@ -1200,6 +1286,19 @@ export function ComposeBox({
|
||||
await createEvent({ kind: 1068, content: finalContent, tags });
|
||||
resetComposeState();
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
// World-layer polls (iso3166 root) and Agora-marked polls surface
|
||||
// in the home Agora activity feed.
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||||
// Polls published with an iso3166 root surface on the country feed.
|
||||
if (replyTo instanceof URL && replyTo.protocol === 'iso3166:') {
|
||||
const countryCode = replyTo.pathname.toUpperCase();
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', countryCode] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', countryCode] });
|
||||
} else if (canChooseDestination && selectedCountryCode) {
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-paginated', selectedCountryCode] });
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed-new-posts', selectedCountryCode] });
|
||||
}
|
||||
notificationSuccess();
|
||||
toast({ title: 'Poll published!' });
|
||||
onSuccess?.();
|
||||
@@ -1531,14 +1630,14 @@ export function ComposeBox({
|
||||
})()}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Select value={destination} onValueChange={setDestination}>
|
||||
<SelectTrigger
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Post destination"
|
||||
className={cn(
|
||||
'h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
|
||||
'border-0 bg-muted/50 hover:bg-muted shadow-none',
|
||||
'focus:ring-2 focus:ring-primary/50 focus:ring-offset-0',
|
||||
'rounded-lg',
|
||||
'inline-flex items-center justify-center h-8 w-auto gap-1.5 px-2.5 py-1 text-base leading-none',
|
||||
'bg-muted/50 hover:bg-muted shadow-none',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:ring-offset-0',
|
||||
'rounded-lg motion-safe:transition-colors',
|
||||
)}
|
||||
>
|
||||
{/* Show just the flag in the trigger to keep the row
|
||||
@@ -1547,28 +1646,145 @@ export function ComposeBox({
|
||||
<span aria-hidden="true">
|
||||
{selectedCountryInfo?.flag ?? '🌍'}
|
||||
</span>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" className="min-w-[180px]">
|
||||
<SelectItem value="world">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[240px]">
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDestination('world')}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 flex-1">
|
||||
<span aria-hidden="true">🌍</span>
|
||||
<span>Global</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
{followedCountries.map((code) => {
|
||||
const info = getCountryInfo(code);
|
||||
if (!info) return null;
|
||||
return (
|
||||
<SelectItem key={code} value={code}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span aria-hidden="true">{info.flag}</span>
|
||||
<span>{info.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{destination === 'world' && (
|
||||
<Check className="size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
{/* Build the quick-pick list. Followed countries appear first;
|
||||
if the user has selected an ad-hoc country via the
|
||||
searchable picker that they don't follow, show it too so
|
||||
they have a one-tap way back to it. De-duplicates by code. */}
|
||||
{(() => {
|
||||
const codes = new Set<string>();
|
||||
const quickPicks: string[] = [];
|
||||
for (const code of followedCountries) {
|
||||
if (!codes.has(code) && getCountryInfo(code)) {
|
||||
codes.add(code);
|
||||
quickPicks.push(code);
|
||||
}
|
||||
}
|
||||
if (selectedCountryCode && !codes.has(selectedCountryCode) && getCountryInfo(selectedCountryCode)) {
|
||||
quickPicks.push(selectedCountryCode);
|
||||
}
|
||||
return quickPicks.map((code) => {
|
||||
const info = getCountryInfo(code);
|
||||
if (!info) return null;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onSelect={() => setDestination(code)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2 flex-1">
|
||||
<span aria-hidden="true">{info.flag}</span>
|
||||
<span>{info.name}</span>
|
||||
</span>
|
||||
{destination === code && (
|
||||
<Check className="size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setCountryPickerOpen(true);
|
||||
}}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
<Globe className="size-4 mr-2 text-muted-foreground" aria-hidden />
|
||||
Choose another country…
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{destination === defaultPostCountry ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
if (defaultPostCountry === 'world') return 'Global is your default';
|
||||
const info = getCountryInfo(defaultPostCountry);
|
||||
return info ? `${info.name} is your default` : 'This is your default';
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setDefaultPostCountry(destination);
|
||||
const info = destination === 'world'
|
||||
? null
|
||||
: getCountryInfo(destination);
|
||||
toast({
|
||||
title: 'Default updated',
|
||||
description: info
|
||||
? `New posts will go to ${info.name} by default.`
|
||||
: 'New posts will be global by default.',
|
||||
});
|
||||
}}
|
||||
className="cursor-pointer text-sm"
|
||||
>
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Searchable picker over the full country list. Opened from the
|
||||
"Choose another country…" item in the destination dropdown,
|
||||
so users can post to any country without having to follow it
|
||||
first. */}
|
||||
<CommandDialog
|
||||
open={countryPickerOpen}
|
||||
onOpenChange={setCountryPickerOpen}
|
||||
>
|
||||
<CommandInput placeholder="Search countries..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No countries found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="Global 🌍"
|
||||
onSelect={() => {
|
||||
setDestination('world');
|
||||
setCountryPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" className="mr-2">🌍</span>
|
||||
<span>Global</span>
|
||||
{destination === 'world' && (
|
||||
<Check className="ml-auto size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</CommandItem>
|
||||
{COUNTRY_LIST.map((country) => (
|
||||
<CommandItem
|
||||
key={country.code}
|
||||
// Include code + name in the searchable value so users
|
||||
// can type either "iran" or "IR".
|
||||
value={`${country.name} ${country.code}`}
|
||||
onSelect={() => {
|
||||
setDestination(country.code);
|
||||
setCountryPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" className="mr-2">{country.flag}</span>
|
||||
<span>{country.name}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{country.code}</span>
|
||||
{destination === country.code && (
|
||||
<Check className="ml-auto size-4 text-primary" aria-hidden />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1769,7 +1985,7 @@ export function ComposeBox({
|
||||
<Button
|
||||
onClick={handlePollSubmit}
|
||||
disabled={!isPollValid || isPollPending || !user}
|
||||
className="rounded-full px-5 font-bold"
|
||||
className="rounded-full px-5 font-bold text-white"
|
||||
size="sm"
|
||||
>
|
||||
{isPollPending ? 'Publishing...' : 'Publish poll'}
|
||||
@@ -1778,7 +1994,7 @@ export function ComposeBox({
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!content.trim() || isPending || isCommentPending || !user || charCount > MAX_CHARS}
|
||||
className="rounded-full px-5 font-bold"
|
||||
className="rounded-full px-5 font-bold text-white"
|
||||
size="sm"
|
||||
>
|
||||
{isPending || isCommentPending ? 'Posting...' : submitLabel}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Users, Download, Loader2, X, Pencil, Home, Globe, MapPin,
|
||||
Palette, Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink, ShieldAlert,
|
||||
Trash2, Plus, UserX, Hash, MessageSquareOff, ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -26,220 +25,108 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { FeedEditModal } from '@/components/FeedEditModal';
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { EXTRA_KINDS, FEED_KINDS, SECTION_ORDER, SECTION_LABELS } from '@/lib/extraKinds';
|
||||
import { CONTENT_KIND_ICONS, SIDEBAR_ITEMS } from '@/lib/sidebarItems';
|
||||
import type { SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
|
||||
import type { ExtraKindDef, SubKindDef } from '@/lib/extraKinds';
|
||||
import { EXTRA_KINDS } from '@/lib/extraKinds';
|
||||
import { SIDEBAR_ITEMS } from '@/lib/sidebarItems';
|
||||
import type { FeedSettings, SavedFeed, TabFilter, ContentWarningPolicy } from '@/contexts/AppContext';
|
||||
import type { ExtraKindDef } from '@/lib/extraKinds';
|
||||
|
||||
export function ContentSettings() {
|
||||
return (
|
||||
<div>
|
||||
{/* Intro */}
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">What You See</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Customize your feed, choose what content appears, and control what you want to hide.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Homepage Section */}
|
||||
<div className="space-y-8">
|
||||
<HomePageSetting />
|
||||
|
||||
{/* Feed Tabs Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">Home Feed Tabs</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<FeedTabsSection />
|
||||
</div>
|
||||
</div>
|
||||
<Section title="Saved Feeds">
|
||||
<FeedTabsSection />
|
||||
</Section>
|
||||
|
||||
{/* Notes Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">Basic Home Feed Options</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
<div className="px-3 pt-3 pb-4">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Core content types that appear in your feed.
|
||||
</p>
|
||||
</div>
|
||||
<Section title="Content in Home Feed">
|
||||
<FlatContentList />
|
||||
</Section>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
|
||||
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
|
||||
</div>
|
||||
|
||||
<NotesFeedSettings />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other Stuff Section */}
|
||||
<div>
|
||||
<div className="relative px-3 py-3.5">
|
||||
<h2 className="text-base font-semibold">Show More Content Types in Home Feed</h2>
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary rounded-full" />
|
||||
</div>
|
||||
<div className="pb-4">
|
||||
{/* Intro section for Other Stuff */}
|
||||
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
|
||||
<IntroImage src="/feed-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">Other Stuff</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Nostr isn't just text posts — people publish all kinds of things. Pick what shows up in your sidebar and feed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column headers */}
|
||||
<div className="flex items-center justify-end gap-2 px-3 pb-2 border-b border-border">
|
||||
<span className="text-[11px] font-medium text-muted-foreground w-[52px] text-center">Feed</span>
|
||||
</div>
|
||||
|
||||
{/* Content type rows - reuse the internals from FeedSettingsForm */}
|
||||
<FeedSettingsFormInternals />
|
||||
</div>
|
||||
</div>
|
||||
<Section title="Muted">
|
||||
<MuteSettingsInternals />
|
||||
</Section>
|
||||
|
||||
<Section title="Sensitive Content">
|
||||
<SensitiveContentSection />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function KindBadge({ kind }: { kind: number }) {
|
||||
function Section({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<span className="text-[10px] font-mono text-muted-foreground/60 shrink-0">
|
||||
[{kind}]
|
||||
</span>
|
||||
<section>
|
||||
<h2 className="text-base font-semibold px-3 pb-2 border-b border-border">{title}</h2>
|
||||
<div className="pt-2">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SubKindRow({ sub }: { sub: SubKindDef }) {
|
||||
const { feedSettings, updateFeedSettings } = useFeedSettings();
|
||||
const { updateSettings } = useEncryptedSettings();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const handleToggle = async (key: string, value: boolean) => {
|
||||
updateFeedSettings({ [key]: value });
|
||||
if (user) {
|
||||
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
|
||||
}
|
||||
};
|
||||
function FlatContentList() {
|
||||
// Flat, ordered list of curated kinds. No section grouping, no sub-rows, no kind badges.
|
||||
const orderedIds = [
|
||||
'posts', 'replies', 'reposts', 'articles', 'highlights',
|
||||
'photos', 'videos', 'voice',
|
||||
'events', 'polls', 'communities', 'badges',
|
||||
'reactions', 'zaps',
|
||||
];
|
||||
const byId = new Map(EXTRA_KINDS.map((def) => [def.id, def]));
|
||||
// Replies is id 'comments' in the registry; alias here for readability.
|
||||
byId.set('replies', byId.get('comments')!);
|
||||
const rows = orderedIds.map((id) => byId.get(id)).filter((d): d is ExtraKindDef => !!d && !!d.agora);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2.5 pl-12 pr-3 transition-colors">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm">{sub.label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<KindBadge kind={sub.kind} />{' '}{sub.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-[52px] flex justify-center">
|
||||
<Switch
|
||||
checked={feedSettings[sub.feedKey]}
|
||||
onCheckedChange={(checked) => handleToggle(sub.feedKey, checked)}
|
||||
className="scale-90"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{rows.map((def) => (
|
||||
<li key={def.id}>
|
||||
<ContentTypeRow def={def} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function ContentTypeRow({ def }: { def: ExtraKindDef }) {
|
||||
const { feedSettings, updateFeedSettings } = useFeedSettings();
|
||||
const { updateSettings } = useEncryptedSettings();
|
||||
const { user } = useCurrentUser();
|
||||
const IconComponent = CONTENT_KIND_ICONS[def.id] ?? Palette;
|
||||
const icon = <IconComponent className="size-5" />;
|
||||
const hasSubKinds = !!def.subKinds;
|
||||
|
||||
const handleToggle = async (key: string, value: boolean) => {
|
||||
updateFeedSettings({ [key]: value });
|
||||
// Toggle key: prefer the feed inclusion key; fall back to the sidebar visibility key
|
||||
// for kinds that have no direct feed key of their own (e.g. parent kinds with sub-kinds).
|
||||
const toggleKey: keyof FeedSettings | undefined = def.feedKey ?? def.showKey;
|
||||
if (!toggleKey) return null;
|
||||
|
||||
const checked = feedSettings[toggleKey] !== false;
|
||||
|
||||
const handleToggle = async (value: boolean) => {
|
||||
const next: Partial<FeedSettings> = { [toggleKey]: value };
|
||||
// Parent kinds with sub-kinds: toggle all sub-kind feed keys together so the
|
||||
// single parent switch governs everything below it.
|
||||
if (def.subKinds) {
|
||||
for (const sub of def.subKinds) {
|
||||
next[sub.feedKey] = value;
|
||||
}
|
||||
}
|
||||
updateFeedSettings(next);
|
||||
if (user) {
|
||||
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, [key]: value } });
|
||||
await updateSettings.mutateAsync({ feedSettings: { ...feedSettings, ...next } });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
<div className="flex items-center justify-between py-3.5 px-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{icon}</span>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{def.label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
<KindBadge kind={def.kind} />{' '}{def.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-[52px] flex justify-center">
|
||||
{!hasSubKinds && def.feedKey ? (
|
||||
<Switch
|
||||
checked={feedSettings[def.feedKey]}
|
||||
onCheckedChange={(checked) => handleToggle(def.feedKey!, checked)}
|
||||
/>
|
||||
) : !hasSubKinds && def.feedOnly && def.showKey ? (
|
||||
<Switch
|
||||
checked={feedSettings[def.showKey] !== false}
|
||||
onCheckedChange={(checked) => handleToggle(def.showKey!, checked)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 py-3.5 px-3">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium">{def.label}</span>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{def.description}</p>
|
||||
</div>
|
||||
|
||||
{hasSubKinds && def.subKinds && def.subKinds.map((sub) => (
|
||||
<SubKindRow
|
||||
key={sub.feedKey}
|
||||
sub={sub}
|
||||
/>
|
||||
))}
|
||||
<Switch checked={checked} onCheckedChange={handleToggle} className="shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotesFeedSettings() {
|
||||
return (
|
||||
<>
|
||||
{FEED_KINDS.map((def) => (
|
||||
<ContentTypeRow key={def.id} def={def} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedSettingsFormInternals() {
|
||||
return (
|
||||
<>
|
||||
{SECTION_ORDER.map((section) => {
|
||||
const sectionKinds = EXTRA_KINDS.filter((def) => def.section === section);
|
||||
if (sectionKinds.length === 0) return null;
|
||||
return (
|
||||
<div key={section}>
|
||||
<div className="px-3 pt-4 pb-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{SECTION_LABELS[section]}
|
||||
</span>
|
||||
</div>
|
||||
{sectionKinds.map((def) => (
|
||||
<ContentTypeRow key={def.id} def={def} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Feed Tabs Section Component
|
||||
function FeedTabsSection() {
|
||||
const { toast } = useToast();
|
||||
@@ -407,14 +294,11 @@ function FeedTabsSection() {
|
||||
return (
|
||||
<div>
|
||||
{/* Intro section for Feed Tabs */}
|
||||
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
|
||||
<IntroImage src="/community-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">Feed Navigation</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Manage which feed tabs appear in your navigation and follow communities by domain.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-3 pb-4">
|
||||
<h3 className="text-sm font-semibold">Feed Navigation</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Manage which feed tabs appear in your navigation and follow communities by domain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feed Tab Toggles */}
|
||||
@@ -922,16 +806,10 @@ export function SensitiveContentSection() {
|
||||
return (
|
||||
<div>
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-3 pb-4">
|
||||
<div className="w-40 shrink-0 flex items-center justify-center">
|
||||
<ShieldAlert className="size-16 text-muted-foreground/40" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-semibold">Content Warnings</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Some posts are tagged with content warnings (NIP-36) by their authors. This can include NSFW material, spoilers, or other sensitive content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-3 pb-4">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Some posts are tagged by their authors as sensitive — NSFW, graphic, or otherwise needing a content warning. Choose how to handle them.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Policy options — consistent row style with other settings */}
|
||||
|
||||
@@ -25,6 +25,14 @@ interface CoverImageFieldProps {
|
||||
onChange: (url: string) => void;
|
||||
/** Notifies parent forms so they can block submit while Blossom upload runs. */
|
||||
onUploadingChange?: (uploading: boolean) => void;
|
||||
/**
|
||||
* Fires after a successful Blossom upload with the NIP-94-style tag
|
||||
* array returned by `useUploadFile`:
|
||||
* `[["url", "<url>"], ["x", "<sha256>"], ["ox", "<sha256>"], ["size", "<bytes>"], ["m", "image/jpeg"]]`.
|
||||
* Parents that want to publish a paired NIP-92 `imeta` tag in their
|
||||
* Nostr event should convert this array — see Kind 33863 publishing.
|
||||
*/
|
||||
onUploadComplete?: (nip94Tags: string[][]) => void;
|
||||
/** Optional template gallery shown between the dropzone and the URL input. */
|
||||
templates?: readonly CoverImageTemplate[];
|
||||
}
|
||||
@@ -45,7 +53,7 @@ interface CoverImageFieldProps {
|
||||
* anything other than a well-formed https URL — that's deliberate, since
|
||||
* the same value is what gets published in the Nostr event's `image` tag.
|
||||
*/
|
||||
export function CoverImageField({ value, onChange, onUploadingChange, templates }: CoverImageFieldProps) {
|
||||
export function CoverImageField({ value, onChange, onUploadingChange, onUploadComplete, templates }: CoverImageFieldProps) {
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const { toast } = useToast();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -71,8 +79,21 @@ export function CoverImageField({ value, onChange, onUploadingChange, templates
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [[, url]] = await uploadFile(file);
|
||||
const tags = await uploadFile(file);
|
||||
const [[, url]] = tags;
|
||||
onChange(url);
|
||||
// Forward the raw NIP-94 tag array to the parent so it can build a
|
||||
// paired NIP-92 imeta tag. The URL inside the tags is what Blossom
|
||||
// returned; the parent's `value` may pick up an appended extension
|
||||
// via the useUploadFile post-processing, but the sha256 ("x") still
|
||||
// identifies the same byte stream.
|
||||
if (onUploadComplete) {
|
||||
// Replace the URL in the first tag with the extension-corrected
|
||||
// value the parent now holds (matches the rendered banner src).
|
||||
const adjusted = tags.map((t) => [...t]);
|
||||
if (adjusted[0]?.[0] === 'url') adjusted[0][1] = url;
|
||||
onUploadComplete(adjusted);
|
||||
}
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
|
||||
@@ -298,6 +298,7 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha
|
||||
if (communityATag) {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['community-actions', communityATag] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['organization-activity', communityATag] }),
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const [root, aTagsKey] = q.queryKey;
|
||||
@@ -308,6 +309,9 @@ export function CreateActionDialog({ countryCode, communityATag, open, onOpenCha
|
||||
}),
|
||||
]);
|
||||
}
|
||||
// Pledges (kind 36639) surface in the home Agora activity feed.
|
||||
await queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||||
|
||||
setFormData({
|
||||
title: '', description: '', tagInput: '', pledgeUsd: '',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { createOrganizationAssociationTags } from '@/lib/organizationContext';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
interface CreateCommunityEventDialogProps {
|
||||
communityATag?: string;
|
||||
@@ -307,7 +308,7 @@ export function CreateCommunityEventDialog({ communityATag, open, onOpenChange,
|
||||
const publishedEvent = await publishEvent({
|
||||
kind,
|
||||
content: description.trim(),
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getEffectiveRelays } from '@/lib/appRelays';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { ZAP_GOAL_KIND } from '@/lib/goalUtils';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
interface CreateGoalDialogProps {
|
||||
/** The community `a` tag coordinate (e.g. `34550:<pubkey>:<d-tag>`). */
|
||||
@@ -116,7 +117,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
|
||||
await publishEvent({
|
||||
kind: ZAP_GOAL_KIND,
|
||||
content: title.trim(),
|
||||
tags,
|
||||
tags: withAgoraTag(tags),
|
||||
});
|
||||
|
||||
// Refresh the goals tab and the community activity feed
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface Ring {
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
maxRadius: number;
|
||||
life: number; // 1 → 0
|
||||
}
|
||||
|
||||
function parseHslString(hsl: string): { h: number; s: number; l: number } {
|
||||
const parts = hsl.trim().split(/\s+/);
|
||||
return {
|
||||
h: parseFloat(parts[0] ?? '30'),
|
||||
s: parseFloat(parts[1] ?? '100'),
|
||||
l: parseFloat(parts[2] ?? '55'),
|
||||
};
|
||||
}
|
||||
|
||||
export function CursorFireEffect() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const particles = useRef<Particle[]>([]);
|
||||
const rings = useRef<Ring[]>([]);
|
||||
const cursor = useRef<{ x: number; y: number } | null>(null);
|
||||
const active = useRef(false);
|
||||
const raf = useRef(0);
|
||||
const pulse = useRef(0);
|
||||
const frame = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
function resize() {
|
||||
if (!canvas) return;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
cursor.current = { x: e.clientX, y: e.clientY };
|
||||
active.current = true;
|
||||
}
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
const t = e.touches[0];
|
||||
if (t) { cursor.current = { x: t.clientX, y: t.clientY }; active.current = true; }
|
||||
}
|
||||
function onLeave() { active.current = false; }
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
spawnClickBurst(e.clientX, e.clientY);
|
||||
}
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0];
|
||||
if (t) spawnClickBurst(t.clientX, t.clientY);
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||
window.addEventListener('mouseleave', onLeave);
|
||||
window.addEventListener('touchend', onLeave);
|
||||
window.addEventListener('click', onClick);
|
||||
window.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
|
||||
function getPrimary() {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue('--primary').trim();
|
||||
return raw ? parseHslString(raw) : { h: 270, s: 80, l: 60 };
|
||||
}
|
||||
|
||||
function spawnWispParticles(x: number, y: number) {
|
||||
const count = Math.floor(Math.random() * 2) + 2;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.3;
|
||||
const speed = Math.random() * 0.6 + 0.3;
|
||||
particles.current.push({
|
||||
x: x + (Math.random() - 0.5) * 6,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed * 0.2,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
size: Math.random() * 28 + 20,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function spawnClickBurst(x: number, y: number) {
|
||||
// Expanding shockwave ring
|
||||
rings.current.push({ x, y, radius: 0, maxRadius: 120, life: 1 });
|
||||
|
||||
// Secondary smaller ring
|
||||
rings.current.push({ x, y, radius: 0, maxRadius: 60, life: 1 });
|
||||
|
||||
// Radial burst of particles in all directions
|
||||
const count = 18;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = (i / count) * Math.PI * 2;
|
||||
const speed = Math.random() * 3.5 + 1.5;
|
||||
particles.current.push({
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
size: Math.random() * 20 + 12,
|
||||
});
|
||||
}
|
||||
|
||||
// Extra upward plume
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = -Math.PI / 2 + (Math.random() - 0.5) * 0.8;
|
||||
const speed = Math.random() * 4 + 2;
|
||||
particles.current.push({
|
||||
x: x + (Math.random() - 0.5) * 10,
|
||||
y,
|
||||
vx: Math.cos(angle) * speed * 0.3,
|
||||
vy: Math.sin(angle) * speed,
|
||||
life: 1,
|
||||
size: Math.random() * 30 + 18,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const { h, s, l } = getPrimary();
|
||||
|
||||
// Spawn wisp particles every 4th frame
|
||||
frame.current++;
|
||||
if (active.current && cursor.current && frame.current % 4 === 0) {
|
||||
spawnWispParticles(cursor.current.x, cursor.current.y);
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'screen';
|
||||
|
||||
// Draw expanding rings
|
||||
const aliveRings: Ring[] = [];
|
||||
for (const r of rings.current) {
|
||||
r.life -= 0.022;
|
||||
if (r.life <= 0) continue;
|
||||
r.radius += (r.maxRadius - r.radius) * 0.08;
|
||||
|
||||
const t = r.life;
|
||||
const lineAlpha = Math.pow(t, 1.5) * 0.8;
|
||||
const glowAlpha = Math.pow(t, 2) * 0.4;
|
||||
const lineWidth = t * 3;
|
||||
|
||||
// Outer glow halo
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `hsla(${h}, ${s}%, ${Math.min(l + 20, 85)}%, ${glowAlpha})`;
|
||||
ctx.lineWidth = lineWidth + 8;
|
||||
ctx.stroke();
|
||||
|
||||
// Sharp ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, r.radius, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = `hsla(${h - 10}, ${s}%, 90%, ${lineAlpha})`;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.stroke();
|
||||
|
||||
aliveRings.push(r);
|
||||
}
|
||||
rings.current = aliveRings;
|
||||
|
||||
// Draw flame particles
|
||||
const alive: Particle[] = [];
|
||||
for (const p of particles.current) {
|
||||
p.life -= 0.005 + Math.random() * 0.002;
|
||||
if (p.life <= 0) continue;
|
||||
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.vy -= 0.018;
|
||||
p.vx *= 0.98;
|
||||
p.size *= 0.985;
|
||||
|
||||
const t = p.life;
|
||||
const ph = h + (1 - t) * 25;
|
||||
const pl = Math.min(l + t * 40, 90);
|
||||
const alpha = Math.pow(t, 1.5) * 0.18;
|
||||
const radius = p.size * (0.4 + t * 0.6);
|
||||
|
||||
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, radius);
|
||||
g.addColorStop(0, `hsla(${ph - 5}, ${s}%, ${pl}%, ${alpha})`);
|
||||
g.addColorStop(0.35, `hsla(${ph}, ${s}%, ${Math.max(l, 40)}%, ${alpha * 0.6})`);
|
||||
g.addColorStop(0.7, `hsla(${ph + 15}, ${s}%, ${Math.max(l - 15, 20)}%, ${alpha * 0.2})`);
|
||||
g.addColorStop(1, `hsla(${ph + 25}, ${s}%, ${Math.max(l - 25, 5)}%, 0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = g;
|
||||
ctx.fill();
|
||||
|
||||
alive.push(p);
|
||||
}
|
||||
particles.current = alive;
|
||||
|
||||
// Orb: slow pulsing core glow at cursor
|
||||
if (active.current && cursor.current) {
|
||||
const { x, y } = cursor.current;
|
||||
pulse.current += 0.025;
|
||||
const pv = (Math.sin(pulse.current) + 1) / 2;
|
||||
const r = 20 + pv * 12;
|
||||
const a = 0.5 + pv * 0.3;
|
||||
|
||||
const orb = ctx.createRadialGradient(x, y, 0, x, y, r);
|
||||
orb.addColorStop(0, `hsla(${h - 10}, ${Math.max(s - 10, 0)}%, 95%, ${a})`);
|
||||
orb.addColorStop(0.4, `hsla(${h}, ${s}%, ${Math.min(l + 15, 85)}%, ${a * 0.5})`);
|
||||
orb.addColorStop(1, `hsla(${h + 15}, ${s}%, ${l}%, 0)`);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||
ctx.fillStyle = orb;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
raf.current = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
raf.current = requestAnimationFrame(draw);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('touchmove', onTouchMove);
|
||||
window.removeEventListener('mouseleave', onLeave);
|
||||
window.removeEventListener('touchend', onLeave);
|
||||
window.removeEventListener('click', onClick);
|
||||
window.removeEventListener('touchstart', onTouchStart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[9999]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
|
||||
interface DetailCommentComposerProps {
|
||||
event: NostrEvent;
|
||||
placeholder?: string;
|
||||
onSuccess?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DetailCommentComposer({
|
||||
event,
|
||||
placeholder = "What's on your mind?",
|
||||
onSuccess,
|
||||
className,
|
||||
}: DetailCommentComposerProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<ComposeBox
|
||||
compact
|
||||
defaultExpanded
|
||||
hideBorder
|
||||
replyTo={event}
|
||||
placeholder={placeholder}
|
||||
onSuccess={onSuccess}
|
||||
className="bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { z } from 'zod';
|
||||
import { IntroImage } from '@/components/IntroImage';
|
||||
import { ImageCropDialog } from '@/components/ImageCropDialog';
|
||||
|
||||
// Extended form schema that includes custom fields
|
||||
@@ -203,7 +202,7 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
// Combine existing metadata with new values
|
||||
const data: Record<string, unknown> = { ...metadata, ...standardMetadata };
|
||||
|
||||
// Strip any legacy avatar shape data from old Ditto-style profiles
|
||||
// Strip any legacy avatar-shape field carried over from older clients.
|
||||
delete data.shape;
|
||||
|
||||
// Clean up empty values in standard metadata
|
||||
@@ -248,14 +247,11 @@ export const EditProfileForm: React.FC<EditProfileFormProps> = ({ onValuesChange
|
||||
return (
|
||||
<div>
|
||||
{/* Intro */}
|
||||
<div className="flex items-center gap-4 px-3 pt-2 pb-4">
|
||||
<IntroImage src="/profile-intro.png" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold">Your Identity</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-3 pt-2 pb-4">
|
||||
<h2 className="text-sm font-semibold">Your Identity</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
|
||||
Customize your profile with a name, bio, images, and verification. This is how others will see you on Nostr.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Crop dialog */}
|
||||
|
||||
@@ -3,8 +3,7 @@ import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, BookOpen, Coins, ExternalLink, FileText, Globe, Landmark, Languages, MapPin, Megaphone, MessageCircle, Package, Pause, Play, Repeat2, Share2, User, UserCheck, UserMinus, UserPlus, Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { Skeleton } from '@/components/ui/skeleton';import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { ExternalReactionButton } from '@/components/ExternalReactionButton';
|
||||
import { FollowToggleButton } from '@/components/FollowButton';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
@@ -22,7 +21,6 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCountryFollows } from '@/hooks/useCountryFollows';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useWeather } from '@/hooks/useWeather';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getCountryInfo, getWikipediaTitle } from '@/lib/countries';
|
||||
@@ -638,9 +636,11 @@ function WikipediaExtract({ extract, articleUrl }: { extract: string; articleUrl
|
||||
* above the Wikipedia extract doesn't draw against a phantom row.
|
||||
*/
|
||||
function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts | undefined }) {
|
||||
const { data: weather, isLoading } = useWeather(code);
|
||||
const capital = facts?.capital ?? null;
|
||||
|
||||
// Weather has been removed; this row now renders only the country vitals
|
||||
// (population / languages / currency). The legacy name is preserved so
|
||||
// the mount call sites don't churn — the row still vanishes when there
|
||||
// are no vitals to show, matching the original behavior.
|
||||
void code;
|
||||
const vitals: { key: string; icon: React.ReactNode; label: string; value: string }[] = [];
|
||||
if (facts) {
|
||||
if (facts.population !== null) {
|
||||
@@ -670,40 +670,17 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && vitals.length === 0) {
|
||||
return (
|
||||
<div className="px-4 py-2 flex items-center gap-3">
|
||||
<Skeleton className="size-6 rounded-md" />
|
||||
<Skeleton className="h-4 w-40" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (vitals.length === 0) return null;
|
||||
|
||||
const hasWeatherSide = !!weather || !!capital;
|
||||
const hasVitalsSide = vitals.length > 0;
|
||||
if (!hasWeatherSide && !hasVitalsSide) return null;
|
||||
const capital = facts?.capital ?? null;
|
||||
const hasCapitalSide = !!capital;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5 text-sm">
|
||||
{/* Left group — weather + capital. */}
|
||||
{hasWeatherSide && (
|
||||
{/* Left group — capital. */}
|
||||
{hasCapitalSide && (
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 min-w-0">
|
||||
{weather && (
|
||||
<>
|
||||
<span className="flex items-baseline gap-2 text-foreground">
|
||||
<span className="text-xl leading-none" role="img" aria-label={weather.description}>
|
||||
{weather.icon}
|
||||
</span>
|
||||
<span className="font-bold tabular-nums">{weather.temperature}°</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">{weather.description}</span>
|
||||
</>
|
||||
)}
|
||||
{capital && (
|
||||
// The country's capital is the stable national place anchor for
|
||||
// the header. The weather-station city is intentionally omitted
|
||||
// — it's often a smaller, less-recognised town nearby and
|
||||
// duplicates a less-meaningful place name on the same line.
|
||||
<span className="flex items-center gap-1 text-muted-foreground/80 text-xs">
|
||||
<Landmark className="size-3 shrink-0" />
|
||||
<span>{capital}</span>
|
||||
@@ -712,28 +689,21 @@ function WeatherVitalsRow({ code, facts }: { code: string; facts: CountryFacts |
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right group — vitals (population, language, currency). On narrow
|
||||
viewports this wraps onto its own line under the weather group
|
||||
rather than getting crushed beside it. Styled to match the
|
||||
capital chip on the left (text-xs muted-foreground/80 with a
|
||||
size-3 icon) so the row reads as a single uniform metadata
|
||||
strip rather than two competing weights. */}
|
||||
{hasVitalsSide && (
|
||||
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 min-w-0">
|
||||
{vitals.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className="flex items-center gap-1 min-w-0"
|
||||
title={`${item.label}: ${item.value}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="truncate max-w-[14ch] sm:max-w-[18ch]">
|
||||
{item.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Right group — vitals (population, language, currency). */}
|
||||
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground/80 min-w-0">
|
||||
{vitals.map((item) => (
|
||||
<li
|
||||
key={item.key}
|
||||
className="flex items-center gap-1 min-w-0"
|
||||
title={`${item.label}: ${item.value}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="truncate max-w-[14ch] sm:max-w-[18ch]">
|
||||
{item.value}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -877,7 +847,6 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
// Country facts are only fetched for sovereign countries (alpha-2 codes);
|
||||
// the hook's internal guard returns `null` for subdivisions like `US-CA`.
|
||||
const { data: facts } = useCountryFacts(info?.subdivision ? null : code);
|
||||
const { data: weather } = useWeather(code);
|
||||
const { user } = useCurrentUser();
|
||||
const { isFollowingCountry, toggleCountryFollow, isPending } = useCountryFollows();
|
||||
const { toast } = useToast();
|
||||
@@ -916,7 +885,10 @@ export function CountryContentHeader({ code }: { code: string }) {
|
||||
// map or administrative photo, which contradicts the editorial choice
|
||||
// to surface Tibet as a country in its own right.
|
||||
const heroImage = customFlagAsset(code) ?? wiki?.originalImage?.source ?? wiki?.thumbnail?.source ?? null;
|
||||
const isDay = weather?.isDay ?? true;
|
||||
// Always render the daytime sky overlay. Previously we keyed this off the
|
||||
// live `weather.isDay` flag to flip into a night palette; weather has been
|
||||
// removed so we default to the warm amber/rose daytime tint.
|
||||
const isDay = true;
|
||||
// Sky-tint gradient layered above the hero photo. Warm amber/rose during
|
||||
// local daytime, deep indigo/violet at night. Same gradient shape, only
|
||||
// the colour palette flips — preserves the cinematic curve while the mood
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { usePageRefresh } from '@/hooks/usePageRefresh';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
@@ -7,29 +6,25 @@ import { LandingHero } from '@/components/LandingHero';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { PullToRefresh } from '@/components/PullToRefresh';
|
||||
import { FeedEmptyState } from '@/components/FeedEmptyState';
|
||||
import { FeedModeSwitcher } from '@/components/FeedModeSwitcher';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Globe2, Loader2, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import AuthDialog from '@/components/auth/AuthDialog';
|
||||
import { useFeed } from '@/hooks/useFeed';
|
||||
import { useFollowingFeed } from '@/hooks/useFollowingFeed';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFeedTab } from '@/hooks/useFeedTab';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useTabFeed } from '@/hooks/useProfileFeed';
|
||||
import { useSavedFeeds } from '@/hooks/useSavedFeeds';
|
||||
import { useResolveTabFilter } from '@/hooks/useResolveTabFilter';
|
||||
import { useWorldFeed } from '@/hooks/useWorldFeed';
|
||||
import { useMixedFeed, type FeedMode } from '@/hooks/useMixedFeed';
|
||||
import { shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNavHidden } from '@/contexts/LayoutContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FeedItem } from '@/lib/feedUtils';
|
||||
import type { SavedFeed } from '@/contexts/AppContext';
|
||||
|
||||
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world';
|
||||
type CoreFeedTab = 'follows' | 'network' | 'global' | 'communities' | 'world' | 'agora';
|
||||
type FeedTab = CoreFeedTab | string; // string = saved feed id
|
||||
|
||||
interface FeedProps {
|
||||
@@ -43,72 +38,42 @@ interface FeedProps {
|
||||
hideCompose?: boolean;
|
||||
/** Message shown when the feed is empty. */
|
||||
emptyMessage?: string;
|
||||
/** Unique identifier for this feed page, used to persist the active tab in sessionStorage. Defaults to 'home'. */
|
||||
/** Unique identifier for this feed page, used to persist the active tab/mode in localStorage. Defaults to 'home'. */
|
||||
feedId?: string;
|
||||
}
|
||||
|
||||
const FEED_MODES: readonly FeedMode[] = ['agora', 'all-nostr', 'following'] as const;
|
||||
|
||||
function isFeedMode(value: string): value is FeedMode {
|
||||
return (FEED_MODES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, feedId = 'home' }: FeedProps = {}) {
|
||||
const { user } = useCurrentUser();
|
||||
const navigate = useNavigate();
|
||||
const { muteItems } = useMuteList();
|
||||
const { savedFeeds } = useSavedFeeds();
|
||||
const navHidden = useNavHidden();
|
||||
|
||||
// Tab settings from localStorage
|
||||
const showGlobalFeed = (() => {
|
||||
const stored = localStorage.getItem('ditto:showGlobalFeed');
|
||||
return stored !== null ? stored === 'true' : false;
|
||||
})();
|
||||
|
||||
const showWorldFeed = (() => {
|
||||
const stored = localStorage.getItem('agora:showWorldFeed');
|
||||
return stored !== null ? stored === 'true' : true;
|
||||
})();
|
||||
|
||||
const showCommunityFeed = (() => {
|
||||
const stored = localStorage.getItem('ditto:showCommunityFeed');
|
||||
return stored !== null ? stored === 'true' : false;
|
||||
})();
|
||||
|
||||
const communityLabel = (() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('ditto:community');
|
||||
if (stored) {
|
||||
const community = JSON.parse(stored);
|
||||
return community.label || 'Community';
|
||||
}
|
||||
} catch {
|
||||
// Fall through
|
||||
}
|
||||
return 'Community';
|
||||
})();
|
||||
|
||||
const [rawActiveTab, handleSetActiveTab] = useFeedTab<FeedTab>(feedId);
|
||||
const [authDialogOpen, setAuthDialogOpen] = useState(false);
|
||||
const isHomeAgoraFeed = !kinds && !tagFilters;
|
||||
|
||||
// Kind-specific pages only support Follows + Global. Clamp any other
|
||||
// persisted tab (e.g. 'world', 'communities') back to the appropriate default.
|
||||
// Logged-out users on the home feed land on 'world' to see global content.
|
||||
// For the home /feed page we use a three-mode picker instead of the
|
||||
// Follows/Global tab pair. Mode persists via the same useFeedTab storage,
|
||||
// keyed under the same feedId.
|
||||
const homeFeedMode: FeedMode = (() => {
|
||||
if (!isHomeAgoraFeed) return 'agora';
|
||||
if (isFeedMode(rawActiveTab)) return rawActiveTab;
|
||||
// Legacy values get coerced to the Agora default.
|
||||
return 'agora';
|
||||
})();
|
||||
|
||||
// Specialized feed pages keep the original Follows + Global tabs.
|
||||
const activeTab: FeedTab = (() => {
|
||||
if (!kinds) {
|
||||
// Migrate legacy 'ditto' tab to 'world'
|
||||
if (rawActiveTab === 'ditto') return 'world';
|
||||
// Legacy hashtag:/geotag: tabs are now part of the combined Following
|
||||
// feed; surface them there instead of rendering a missing sub-feed.
|
||||
if (rawActiveTab.startsWith('hashtag:') || rawActiveTab.startsWith('geotag:')) return 'follows';
|
||||
return rawActiveTab;
|
||||
}
|
||||
if (isHomeAgoraFeed) return homeFeedMode;
|
||||
if (rawActiveTab === 'global') return 'global';
|
||||
if (rawActiveTab === 'follows' && user) return 'follows';
|
||||
return user ? 'follows' : 'global';
|
||||
})();
|
||||
|
||||
// Is the active tab a saved feed?
|
||||
const activeSavedFeed = useMemo(
|
||||
() => savedFeeds.find((f) => f.id === activeTab) ?? null,
|
||||
[savedFeeds, activeTab],
|
||||
);
|
||||
|
||||
// Migrate legacy hashtag:/geotag: tabs (which used to render their own
|
||||
// sub-feeds) back to the home Following feed. Followed hashtags/geotags
|
||||
// now contribute to the combined Following feed instead of getting
|
||||
@@ -119,60 +84,51 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
}
|
||||
}, [rawActiveTab, handleSetActiveTab]);
|
||||
|
||||
const handleModeChange = (mode: FeedMode) => {
|
||||
handleSetActiveTab(mode);
|
||||
};
|
||||
|
||||
// Kind-specific pages (e.g. Development, WebXDC) only show Follows + Global tabs.
|
||||
// Extra tabs (World, Community, saved feeds) are only for the home feed.
|
||||
const isKindSpecificPage = !!kinds;
|
||||
|
||||
// When logged out (and not on a kind-specific page), show the World feed.
|
||||
const useWorldForLoggedOut = !user && !kinds;
|
||||
// -------------------------------------------------------------------------
|
||||
// Home feed (mixed-mode): drives off useMixedFeed.
|
||||
// -------------------------------------------------------------------------
|
||||
const mixedFeed = useMixedFeed(homeFeedMode, isHomeAgoraFeed);
|
||||
|
||||
// When the World tab is active (logged in), show the world feed.
|
||||
// Disabled on kind-specific pages — the World tab is not shown there.
|
||||
const useWorldTab = activeTab === 'world' && !kinds;
|
||||
|
||||
// Is the world feed active?
|
||||
const isWorldActive = useWorldForLoggedOut || !!useWorldTab;
|
||||
|
||||
// Standard feed query (used when logged in, or on kind-specific pages, or core tabs)
|
||||
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters;
|
||||
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world';
|
||||
// -------------------------------------------------------------------------
|
||||
// Specialized feed pages: original Follows/Global behavior.
|
||||
// -------------------------------------------------------------------------
|
||||
const isHomeFollowingActive = activeTab === 'follows' && !isKindSpecificPage && !tagFilters && !isHomeAgoraFeed;
|
||||
const isCoreFeedTab = activeTab === 'follows' || activeTab === 'network' || activeTab === 'global' || activeTab === 'communities' || activeTab === 'world' || activeTab === 'agora';
|
||||
type UseFeedTab = 'follows' | 'network' | 'global' | 'communities';
|
||||
const feedTabForQuery: UseFeedTab =
|
||||
activeTab === 'follows'
|
||||
? (isHomeFollowingActive ? 'network' : 'network')
|
||||
? 'network'
|
||||
: activeTab === 'network' || activeTab === 'global' || activeTab === 'communities'
|
||||
? (activeTab as UseFeedTab)
|
||||
: 'global';
|
||||
const standardFeedOptions = (kinds || tagFilters)
|
||||
? { kinds, tagFilters, enabled: !isHomeFollowingActive }
|
||||
: { enabled: !isHomeFollowingActive };
|
||||
? { kinds, tagFilters, enabled: !isHomeFollowingActive && !isHomeAgoraFeed }
|
||||
: { enabled: !isHomeFollowingActive && !isHomeAgoraFeed };
|
||||
const feedQuery = useFeed(
|
||||
isCoreFeedTab && !isWorldActive ? feedTabForQuery : 'global',
|
||||
isCoreFeedTab && !isHomeAgoraFeed ? feedTabForQuery : 'global',
|
||||
standardFeedOptions,
|
||||
);
|
||||
|
||||
const followingFeed = useFollowingFeed(isHomeFollowingActive);
|
||||
|
||||
// World feed: all country-tagged events with diversity cap + live streaming.
|
||||
const worldFeed = useWorldFeed(isWorldActive);
|
||||
const { flushStreamBuffer } = worldFeed;
|
||||
|
||||
// For non-world tabs, use the standard feed query
|
||||
const queryKey = useMemo(
|
||||
() => isWorldActive
|
||||
? ['world-feed']
|
||||
: isHomeFollowingActive
|
||||
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
|
||||
: ['feed', activeTab],
|
||||
[isWorldActive, isHomeFollowingActive, activeTab],
|
||||
() => isHomeAgoraFeed
|
||||
? ['mixed-feed', homeFeedMode]
|
||||
: isHomeFollowingActive
|
||||
? [['feed', 'network'], ['community-activity-feed'], ['following-country-feed']]
|
||||
: ['feed', activeTab],
|
||||
[isHomeAgoraFeed, homeFeedMode, isHomeFollowingActive, activeTab],
|
||||
);
|
||||
|
||||
const handleRefresh = usePageRefresh(queryKey);
|
||||
const handleWorldRefresh = useCallback(async () => {
|
||||
flushStreamBuffer();
|
||||
await handleRefresh();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}, [flushStreamBuffer, handleRefresh]);
|
||||
|
||||
const {
|
||||
data: rawData,
|
||||
@@ -184,16 +140,16 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
} = isHomeFollowingActive ? followingFeed : feedQuery;
|
||||
|
||||
// Unify pagination interface
|
||||
const fetchNextPage = isWorldActive ? worldFeed.fetchNextPage : fetchNextPageStandard;
|
||||
const hasNextPage = isWorldActive ? worldFeed.hasNextPage : hasNextPageStandard;
|
||||
const isFetchingNextPage = isWorldActive ? worldFeed.isFetchingNextPage : isFetchingNextPageStandard;
|
||||
const fetchNextPage = isHomeAgoraFeed ? mixedFeed.fetchNextPage : fetchNextPageStandard;
|
||||
const hasNextPage = isHomeAgoraFeed ? mixedFeed.hasNextPage : hasNextPageStandard;
|
||||
const isFetchingNextPage = isHomeAgoraFeed ? mixedFeed.isFetchingNextPage : isFetchingNextPageStandard;
|
||||
|
||||
// Auto-fetch page 2 as soon as page 1 arrives for smoother scrolling
|
||||
useEffect(() => {
|
||||
if (!isHomeFollowingActive && !isWorldActive && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
|
||||
if (!isHomeFollowingActive && !isHomeAgoraFeed && hasNextPage && !isFetchingNextPage && rawData?.pages?.length === 1) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [isHomeFollowingActive, isWorldActive, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
|
||||
}, [isHomeFollowingActive, isHomeAgoraFeed, hasNextPage, isFetchingNextPage, rawData?.pages?.length, fetchNextPage]);
|
||||
|
||||
// Intersection observer for infinite scroll
|
||||
const { ref: scrollRef, inView } = useInView({
|
||||
@@ -209,9 +165,8 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
|
||||
// Flatten, deduplicate, and filter muted content.
|
||||
const feedItems = useMemo(() => {
|
||||
if (isWorldActive) {
|
||||
// World feed: events are already filtered/deduped by useWorldFeed
|
||||
return worldFeed.events.map((event): FeedItem => ({ event, sortTimestamp: event.created_at }));
|
||||
if (isHomeAgoraFeed) {
|
||||
return mixedFeed.items;
|
||||
}
|
||||
|
||||
if (!rawData?.pages) return [];
|
||||
@@ -227,79 +182,67 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [isWorldActive, worldFeed.events, rawData?.pages, muteItems]);
|
||||
}, [isHomeAgoraFeed, mixedFeed.items, rawData?.pages, muteItems]);
|
||||
|
||||
// Show skeletons while loading.
|
||||
const showSkeleton = isWorldActive
|
||||
? worldFeed.isLoading
|
||||
: (isPending || (isLoading && !rawData));
|
||||
const showSkeleton = isHomeAgoraFeed
|
||||
? mixedFeed.isLoading && feedItems.length === 0
|
||||
: (isPending || (isLoading && !rawData));
|
||||
|
||||
const showSavedFeedTabs = user && !isKindSpecificPage && !tagFilters;
|
||||
// Per-mode empty-state copy for the home feed.
|
||||
const homeEmptyMessage = (() => {
|
||||
if (homeFeedMode === 'agora') {
|
||||
return "Quiet moment on Agora. New campaigns, pledges, donations, and posts will appear here as they happen.";
|
||||
}
|
||||
if (homeFeedMode === 'following') {
|
||||
return user
|
||||
? "Your follow feed is empty. Follow some people to see what they're up to, or switch to Agora or All Nostr."
|
||||
: "Log in to see posts from people you follow.";
|
||||
}
|
||||
return 'Nothing to show. Check your relay connections or try again in a moment.';
|
||||
})();
|
||||
|
||||
return (
|
||||
<main className="flex-1 min-w-0 min-h-dvh">
|
||||
{header}
|
||||
<main className="flex-1 min-w-0 min-h-dvh bg-background">
|
||||
<div>
|
||||
{header}
|
||||
|
||||
{/* CTA (logged out, main feed only) */}
|
||||
{!user && !kinds && (
|
||||
<LandingHero onJoinClick={() => setAuthDialogOpen(true)} />
|
||||
)}
|
||||
{/* CTA (logged out, main feed only) */}
|
||||
{!user && !kinds && (
|
||||
<LandingHero onJoinClick={() => setAuthDialogOpen(true)} />
|
||||
)}
|
||||
|
||||
{!hideCompose && <ComposeBox compact hideBorder />}
|
||||
|
||||
{/* Tabs (logged in) */}
|
||||
{user && (
|
||||
<SubHeaderBar>
|
||||
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
|
||||
{!isKindSpecificPage && !tagFilters && (
|
||||
<TabButton label="Network" active={activeTab === 'network'} onClick={() => handleSetActiveTab('network')} />
|
||||
)}
|
||||
{!isKindSpecificPage && showWorldFeed && (
|
||||
<TabButton label="World" active={activeTab === 'world'} onClick={() => handleSetActiveTab('world')} />
|
||||
)}
|
||||
{!isKindSpecificPage && showCommunityFeed && (
|
||||
<TabButton label={communityLabel} active={activeTab === 'communities'} onClick={() => handleSetActiveTab('communities')} />
|
||||
)}
|
||||
{(isKindSpecificPage || showGlobalFeed) && (
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
|
||||
)}
|
||||
{showSavedFeedTabs && savedFeeds.map((feed) => (
|
||||
<TabButton
|
||||
key={feed.id}
|
||||
label={feed.label}
|
||||
active={activeTab === feed.id}
|
||||
onClick={() => handleSetActiveTab(feed.id)}
|
||||
{/* Home-feed mode switcher: top-left, anchors the page visually */}
|
||||
{isHomeAgoraFeed && (
|
||||
<div className="px-4 pt-5 pb-3 sm:pt-6">
|
||||
<FeedModeSwitcher
|
||||
value={homeFeedMode}
|
||||
onChange={handleModeChange}
|
||||
followingAvailable={!!user}
|
||||
onLoginRequested={() => setAuthDialogOpen(true)}
|
||||
/>
|
||||
))}
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feed content — saved feed tab gets its own stream */}
|
||||
{activeSavedFeed ? (
|
||||
<SavedFeedContent feed={activeSavedFeed} />
|
||||
) : (
|
||||
<PullToRefresh onRefresh={isWorldActive ? handleWorldRefresh : handleRefresh}>
|
||||
{/* "X new posts" pill for World tab */}
|
||||
{isWorldActive && worldFeed.newPostCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'sticky new-posts-pill z-10 flex justify-center pointer-events-none',
|
||||
'max-sidebar:transition-opacity max-sidebar:duration-300 max-sidebar:ease-in-out',
|
||||
navHidden && 'max-sidebar:opacity-0 max-sidebar:pointer-events-none',
|
||||
)}
|
||||
style={{ marginBottom: '-3rem' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
worldFeed.flushStreamBuffer();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
className="pointer-events-auto px-4 py-1.5 rounded-full bg-primary text-primary-foreground text-sm font-medium shadow-lg hover:bg-primary/90 transition-colors animate-in fade-in slide-in-from-top-2 duration-300"
|
||||
>
|
||||
{worldFeed.newPostCount} new post{worldFeed.newPostCount !== 1 ? 's' : ''}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!hideCompose && (
|
||||
<ComposeBox
|
||||
compact
|
||||
hideBorder={isHomeAgoraFeed}
|
||||
defaultExpanded
|
||||
placeholder="What's happening?"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs are only kept for specialized feed pages. The home feed uses
|
||||
the FeedModeSwitcher above. */}
|
||||
{user && (isKindSpecificPage || tagFilters) && (
|
||||
<SubHeaderBar>
|
||||
<TabButton label={isKindSpecificPage || tagFilters ? 'Follows' : 'Following'} active={activeTab === 'follows'} onClick={() => handleSetActiveTab('follows')} />
|
||||
<TabButton label="Global" active={activeTab === 'global'} onClick={() => handleSetActiveTab('global')} />
|
||||
</SubHeaderBar>
|
||||
)}
|
||||
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
{showSkeleton ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
@@ -313,7 +256,6 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
highlight={isWorldActive && worldFeed.flushedIds.has(item.event.id)}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
@@ -326,157 +268,78 @@ export function Feed({ kinds, tagFilters, header, hideCompose, emptyMessage, fee
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isHomeFollowingActive && !emptyMessage ? (
|
||||
<FollowingEmptyState onExploreWorld={() => navigate('/world')} />
|
||||
) : activeTab === 'network' && !emptyMessage ? (
|
||||
<NetworkEmptyState onDiscoverPeople={() => navigate('/packs')} />
|
||||
) : isHomeAgoraFeed ? (
|
||||
<HomeFeedEmptyState
|
||||
mode={homeFeedMode}
|
||||
message={homeEmptyMessage}
|
||||
onSwitchToAgora={homeFeedMode !== 'agora' ? () => handleModeChange('agora') : undefined}
|
||||
onLoginClick={!user && homeFeedMode === 'following' ? () => setAuthDialogOpen(true) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<FeedEmptyState
|
||||
message={
|
||||
emptyMessage ?? (
|
||||
activeTab === 'follows' || activeTab === 'network'
|
||||
activeTab === 'follows'
|
||||
? 'Your feed is empty. Follow some people to see their posts here.'
|
||||
: activeTab === 'world'
|
||||
? 'No world posts yet. Check back soon for global activity.'
|
||||
: 'No posts found. Check your relay connections or come back soon.'
|
||||
: 'No posts found. Check your relay connections or come back soon.'
|
||||
)
|
||||
}
|
||||
showDiscover={!emptyMessage && (activeTab === 'follows' || activeTab === 'network')}
|
||||
showDiscover={!emptyMessage && activeTab === 'follows'}
|
||||
onSwitchToGlobal={
|
||||
(activeTab === 'follows' || activeTab === 'network') && showGlobalFeed
|
||||
activeTab === 'follows'
|
||||
? () => handleSetActiveTab('global')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
)}
|
||||
|
||||
{/* Auth dialog (only needed on main feed) */}
|
||||
{!kinds && (
|
||||
<AuthDialog
|
||||
isOpen={authDialogOpen}
|
||||
onClose={() => setAuthDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{/* Auth dialog (only needed on main feed) */}
|
||||
{!kinds && (
|
||||
<AuthDialog
|
||||
isOpen={authDialogOpen}
|
||||
onClose={() => setAuthDialogOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a saved search feed using useTabFeed (TanStack Query cached, infinite scroll). */
|
||||
function SavedFeedContent({ feed }: { feed: SavedFeed }) {
|
||||
const { ref: scrollRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
const { user } = useCurrentUser();
|
||||
const { muteItems } = useMuteList();
|
||||
|
||||
// Resolve variable placeholders ($follows etc.) the same way profile tabs do
|
||||
const { filter: resolvedFilter, isLoading: isResolving } = useResolveTabFilter(
|
||||
feed.filter,
|
||||
feed.vars ?? [],
|
||||
user?.pubkey ?? '',
|
||||
);
|
||||
|
||||
// Augment the resolved filter with protocol:nostr (NIP-50 Ditto extension)
|
||||
// to match the behavior of the core feeds and ensure latest native Nostr
|
||||
// posts are returned.
|
||||
const augmentedFilter = useMemo(() => {
|
||||
if (!resolvedFilter) return null;
|
||||
const existing = resolvedFilter.search ?? '';
|
||||
const search = existing.includes('protocol:nostr')
|
||||
? existing
|
||||
: existing
|
||||
? `${existing} protocol:nostr`
|
||||
: 'protocol:nostr';
|
||||
return { ...resolvedFilter, search };
|
||||
}, [resolvedFilter]);
|
||||
|
||||
const {
|
||||
data: rawData,
|
||||
isLoading: isFeedLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTabFeed(augmentedFilter, `saved-${feed.id}`, !isResolving);
|
||||
|
||||
const isLoading = isResolving || isFeedLoading;
|
||||
|
||||
// Prefix key -- usePageRefresh does prefix matching, so this invalidates
|
||||
// the full ['tab-feed', tabKey, kindsKey, authorsKey, searchKey] used by useTabFeed.
|
||||
const queryKey = useMemo(
|
||||
() => ['tab-feed', `saved-${feed.id}`],
|
||||
[feed.id],
|
||||
);
|
||||
const handleRefresh = usePageRefresh(queryKey);
|
||||
|
||||
// Infinite scroll: fetch next page when sentinel is in view
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Flatten pages, deduplicate, and filter muted content
|
||||
const feedItems = useMemo(() => {
|
||||
if (!rawData?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
return rawData.pages
|
||||
.flatMap((page) => page.items)
|
||||
.filter((item) => {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (!key || seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
if (shouldHideFeedEvent(item.event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [rawData?.pages, muteItems]);
|
||||
|
||||
if (isLoading && feedItems.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<NoteCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<FeedEmptyState message={`No posts found for "${feed.label}". Try adjusting your relay connections or check back later.`} />
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
interface HomeFeedEmptyStateProps {
|
||||
mode: FeedMode;
|
||||
message: string;
|
||||
onSwitchToAgora?: () => void;
|
||||
onLoginClick?: () => void;
|
||||
}
|
||||
|
||||
function HomeFeedEmptyState({ mode, message, onSwitchToAgora, onLoginClick }: HomeFeedEmptyStateProps) {
|
||||
return (
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<div>
|
||||
{feedItems.map((item) => (
|
||||
<NoteCard
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div ref={scrollRef} className="py-4">
|
||||
{isFetchingNextPage && (
|
||||
<div className="flex justify-center">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-20 px-8 flex flex-col items-center text-center">
|
||||
<p className="text-muted-foreground max-w-sm leading-relaxed">{message}</p>
|
||||
<div className="flex flex-col gap-2 mt-6 w-full max-w-xs">
|
||||
{onLoginClick && (
|
||||
<Button className="rounded-full" onClick={onLoginClick}>
|
||||
Log in
|
||||
</Button>
|
||||
)}
|
||||
{onSwitchToAgora && (
|
||||
<Button
|
||||
variant={mode === 'following' ? 'default' : 'ghost'}
|
||||
className="rounded-full"
|
||||
onClick={onSwitchToAgora}
|
||||
>
|
||||
Browse the Agora feed
|
||||
</Button>
|
||||
)}
|
||||
{!hasNextPage && <div ref={scrollRef} className="py-2" />}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoteCardSkeleton() {
|
||||
function NoteCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<div className={cn('px-4 py-3 border-b border-border', className)}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
@@ -497,47 +360,3 @@ function NoteCardSkeleton() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FollowingEmptyState({ onExploreWorld }: { onExploreWorld: () => void }) {
|
||||
return (
|
||||
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<Globe2 className="size-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<h2 className="text-xl font-bold">No activity yet</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Your Following feed is quiet right now. Visit World to discover more global activity.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full max-w-xs">
|
||||
<Button className="rounded-full" onClick={onExploreWorld}>
|
||||
<Globe2 className="size-4 mr-2" />
|
||||
Visit World
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkEmptyState({ onDiscoverPeople }: { onDiscoverPeople: () => void }) {
|
||||
return (
|
||||
<div className="py-20 px-8 flex flex-col items-center gap-6 text-center">
|
||||
<div className="p-4 rounded-full bg-primary/10">
|
||||
<Users className="size-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2 max-w-xs">
|
||||
<h2 className="text-xl font-bold">No network activity yet</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Follow more people to fill your Network feed.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full max-w-xs">
|
||||
<Button className="rounded-full" onClick={onDiscoverPeople}>
|
||||
<Users className="size-4 mr-2" />
|
||||
Discover people
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Check, ChevronDown, Globe, Sparkles, Users } from 'lucide-react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { FeedMode } from '@/hooks/useMixedFeed';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FeedModeOption {
|
||||
mode: FeedMode;
|
||||
label: string;
|
||||
icon: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const OPTIONS: FeedModeOption[] = [
|
||||
{ mode: 'agora', label: 'Agora', icon: Sparkles },
|
||||
{ mode: 'all-nostr', label: 'All Nostr', icon: Globe },
|
||||
{ mode: 'following', label: 'Following', icon: Users },
|
||||
];
|
||||
|
||||
interface FeedModeSwitcherProps {
|
||||
value: FeedMode;
|
||||
onChange: (mode: FeedMode) => void;
|
||||
/** When false, Following mode is disabled (requires login). */
|
||||
followingAvailable: boolean;
|
||||
/** Click handler for the disabled Following item (typically opens the auth dialog). */
|
||||
onLoginRequested?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The primary feed-mode picker rendered at the top-left of the home feed page.
|
||||
*
|
||||
* Visually anchored as the page heading — the active mode label is the largest
|
||||
* text on the page. Clicking opens a compact dropdown menu offering the three
|
||||
* modes; the active one is marked with a check.
|
||||
*
|
||||
* Logged-out users see "Following" greyed out; clicking it invokes
|
||||
* {@link FeedModeSwitcherProps.onLoginRequested} to surface the auth dialog.
|
||||
*/
|
||||
export function FeedModeSwitcher({
|
||||
value,
|
||||
onChange,
|
||||
followingAvailable,
|
||||
onLoginRequested,
|
||||
className,
|
||||
}: FeedModeSwitcherProps) {
|
||||
const active = OPTIONS.find((opt) => opt.mode === value) ?? OPTIONS[0];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
'group inline-flex items-center gap-2 rounded-lg -ml-1 px-1 py-1 outline-none',
|
||||
'text-foreground hover:text-foreground motion-safe:transition-colors',
|
||||
'focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
className,
|
||||
)}
|
||||
aria-label={`Feed mode: ${active.label}. Click to change.`}
|
||||
>
|
||||
<span className="text-2xl sm:text-3xl font-bold tracking-tight leading-none">
|
||||
{active.label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className="size-5 text-muted-foreground motion-safe:transition-transform group-data-[state=open]:rotate-180"
|
||||
aria-hidden
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" sideOffset={8} className="w-56 p-1.5">
|
||||
{OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const isActive = opt.mode === value;
|
||||
const isFollowing = opt.mode === 'following';
|
||||
const disabled = isFollowing && !followingAvailable;
|
||||
|
||||
const handleSelect = (event: Event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault();
|
||||
onLoginRequested?.();
|
||||
return;
|
||||
}
|
||||
onChange(opt.mode);
|
||||
};
|
||||
|
||||
const itemContent = (
|
||||
<DropdownMenuItem
|
||||
key={opt.mode}
|
||||
onSelect={handleSelect}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 cursor-pointer',
|
||||
disabled && 'opacity-60 data-[disabled]:opacity-60',
|
||||
)}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<span className="flex-1 text-sm font-medium">{opt.label}</span>
|
||||
{isActive && <Check className="size-4 shrink-0 text-primary" aria-hidden />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<Tooltip key={opt.mode}>
|
||||
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
Log in to see posts from people you follow
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return itemContent;
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lazy, Suspense, useState } from 'react';
|
||||
import { Plus, Construction } from 'lucide-react';
|
||||
import { Construction } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LogoIcon } from '@/components/icons/LogoIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -41,26 +42,43 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderedIcon = icon ?? <Plus strokeWidth={4} size={16} />;
|
||||
const hasCustomIcon = icon !== undefined;
|
||||
const renderedIcon = icon;
|
||||
const logoButtonClassName = "relative size-20 text-primary transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm";
|
||||
const logoButtonStyle = { filter: 'drop-shadow(0 3px 10px hsl(var(--primary) / 0.28))' };
|
||||
|
||||
// ── Menu mode — anchor a Popover to the FAB itself ────────────────────────
|
||||
if (menu && menu.length > 0) {
|
||||
return (
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
|
||||
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-primary rounded-full" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
|
||||
{renderedIcon}
|
||||
</span>
|
||||
</button>
|
||||
{hasCustomIcon ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
className="relative size-16 transition-transform hover:scale-105 active:scale-95 disabled:opacity-40 disabled:pointer-events-none"
|
||||
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-primary rounded-full" />
|
||||
<span className="absolute inset-0 flex items-center justify-center text-primary-foreground">
|
||||
{renderedIcon}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add"
|
||||
aria-expanded={menuOpen}
|
||||
aria-haspopup="menu"
|
||||
className={logoButtonClassName}
|
||||
style={logoButtonStyle}
|
||||
>
|
||||
<span className="absolute inset-[-8%] rounded-full bg-primary/15 blur-xl" aria-hidden />
|
||||
<LogoIcon className="relative size-full" />
|
||||
</button>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
@@ -108,10 +126,23 @@ export function FloatingComposeButton({ kind = 1, href, onFabClick, icon, menu }
|
||||
|
||||
return (
|
||||
<>
|
||||
<FabButton
|
||||
onClick={handleClick}
|
||||
icon={renderedIcon}
|
||||
/>
|
||||
{hasCustomIcon ? (
|
||||
<FabButton
|
||||
onClick={handleClick}
|
||||
icon={renderedIcon}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
aria-label="Add"
|
||||
className={logoButtonClassName}
|
||||
style={logoButtonStyle}
|
||||
>
|
||||
<span className="absolute inset-[-8%] rounded-full bg-primary/15 blur-xl" aria-hidden />
|
||||
<LogoIcon className="relative size-full" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Kind 1: Compose modal (lazy-loaded) */}
|
||||
{kind === 1 && composeOpen && (
|
||||
|
||||
@@ -15,12 +15,9 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useStreamPosts } from '@/hooks/useStreamPosts';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parsePackEvent } from '@/lib/packUtils';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
@@ -139,10 +136,9 @@ export function PackMembersTab({
|
||||
*/
|
||||
export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
const { toast } = useToast();
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followList } = useFollowList();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { followMany } = useFollowActions();
|
||||
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
@@ -175,27 +171,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
|
||||
setIsFollowingAll(true);
|
||||
try {
|
||||
// 1. Fetch freshest kind 3 from relays (not cache)
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
|
||||
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
|
||||
const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
|
||||
const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
|
||||
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
|
||||
|
||||
// 3. Merge: add new pubkeys that aren't already followed
|
||||
const newPTags = pubkeys
|
||||
.filter((pk) => !existingPubkeys.has(pk))
|
||||
.map((pk) => ['p', pk]);
|
||||
const added = newPTags.length;
|
||||
|
||||
// 4. Publish with prev for published_at preservation
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: prev?.content ?? '',
|
||||
tags: [...nonPTags, ...existingPTags, ...newPTags],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
const added = await followMany(pubkeys);
|
||||
|
||||
toast({
|
||||
title: 'Following all!',
|
||||
@@ -213,7 +189,7 @@ export function FollowPackDetailContent({ event }: { event: NostrEvent }) {
|
||||
} finally {
|
||||
setIsFollowingAll(false);
|
||||
}
|
||||
}, [user, pubkeys, nostr, publishEvent, toast]);
|
||||
}, [user, pubkeys, followMany, toast]);
|
||||
|
||||
const handleCopyLink = useCallback(() => {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
|
||||
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
|
||||
import { TopNav } from '@/components/TopNav';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
@@ -48,7 +49,7 @@ function PageSkeleton() {
|
||||
function FundraiserLayoutInner() {
|
||||
const centerColumnRef = useRef<HTMLDivElement>(null);
|
||||
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
|
||||
const { noMaxWidth, wrapperClassName } = useLayoutSnapshot();
|
||||
const { noMaxWidth, wrapperClassName, showFAB = false, fabKind = 1, fabHref, onFabClick, fabIcon, fabMenu } = useLayoutSnapshot();
|
||||
|
||||
// Mobile drawer is owned by TopNav now, so consumers of `useOpenDrawer`
|
||||
// become no-ops. Keeping the context shape avoids touching every page that
|
||||
@@ -83,6 +84,14 @@ function FundraiserLayoutInner() {
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
{showFAB && (
|
||||
<div className="fixed bottom-fab right-6 z-30 pointer-events-none sidebar:right-[max(1.5rem,calc((100vw-48rem)/2-7rem))]">
|
||||
<div className="pointer-events-auto">
|
||||
<FloatingComposeButton kind={fabKind} href={fabHref} onFabClick={onFabClick} icon={fabIcon} menu={fabMenu} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</NavHiddenContext.Provider>
|
||||
@@ -93,7 +102,7 @@ function FundraiserLayoutInner() {
|
||||
|
||||
function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-border bg-background mt-auto">
|
||||
<footer className="bg-background mt-auto pt-12">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
|
||||
<nav className="flex items-center gap-5">
|
||||
|
||||
@@ -0,0 +1,741 @@
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useHdWalletAccess } from '@/hooks/useHdWalletAccess';
|
||||
import { useHdWallet } from '@/hooks/useHdWallet';
|
||||
import { notificationSuccess } from '@/lib/haptics';
|
||||
import {
|
||||
isLargeAmount,
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
satsToUSD,
|
||||
} from '@/lib/bitcoin';
|
||||
import {
|
||||
broadcastBlockbookTx,
|
||||
type BlockbookFeeRates,
|
||||
fetchFeeRates,
|
||||
} from '@/lib/hdwallet/blockbook';
|
||||
import {
|
||||
buildHdSpendPsbt,
|
||||
finalizeHdPsbt,
|
||||
type HdInput,
|
||||
type HdSpendableSpUtxo,
|
||||
type HdSpendableUtxo,
|
||||
parseHdRecipient,
|
||||
previewHdFee,
|
||||
signHdPsbt,
|
||||
} from '@/lib/hdwallet/transaction';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USD_PRESETS = [1, 5, 10, 25, 100];
|
||||
|
||||
type FeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
|
||||
|
||||
const FEE_SPEED_LABELS: Record<FeeSpeed, string> = {
|
||||
fastest: '~10 min',
|
||||
halfHour: '~30 min',
|
||||
hour: '~1 hour',
|
||||
economy: '~1 day',
|
||||
};
|
||||
|
||||
const FEE_SPEED_ORDER: FeeSpeed[] = ['fastest', 'halfHour', 'hour', 'economy'];
|
||||
|
||||
function getRateForSpeed(rates: BlockbookFeeRates, speed: FeeSpeed): number {
|
||||
switch (speed) {
|
||||
case 'fastest': return rates.fastestFee;
|
||||
case 'halfHour': return rates.halfHourFee;
|
||||
case 'hour': return rates.hourFee;
|
||||
case 'economy': return rates.economyFee;
|
||||
}
|
||||
}
|
||||
|
||||
function getUniqueFeeSpeeds(rates: BlockbookFeeRates | undefined): FeeSpeed[] {
|
||||
if (!rates) return FEE_SPEED_ORDER;
|
||||
const seen = new Set<number>();
|
||||
const result: FeeSpeed[] = [];
|
||||
for (const speed of FEE_SPEED_ORDER) {
|
||||
const rate = getRateForSpeed(rates, speed);
|
||||
if (!seen.has(rate)) { seen.add(rate); result.push(speed); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recipient resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ResolvedRecipient {
|
||||
/**
|
||||
* Final P2TR/P2WPKH/etc. address used as the PSBT output.
|
||||
*
|
||||
* For silent-payment (`sp1…`) recipients this is the original `sp1…`
|
||||
* string — the real on-chain `P_k` is derived at build time, after coin
|
||||
* selection. The dialog never displays this value directly when
|
||||
* `kind === 'sp'`; it's kept here so {@link buildHdSpendPsbt} can route
|
||||
* by recipient kind.
|
||||
*/
|
||||
address: string;
|
||||
/** Optional Nostr pubkey when the recipient was an npub/nprofile. */
|
||||
pubkey?: string;
|
||||
/** Raw text the user typed (for re-display). */
|
||||
raw: string;
|
||||
/**
|
||||
* Recipient kind. `'address'` for bare Bitcoin addresses (including
|
||||
* Nostr-derived ones); `'sp'` for BIP-352 silent-payment addresses.
|
||||
*/
|
||||
kind: 'address' | 'sp';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the recipient input as one of:
|
||||
* - bare Bitcoin address (mainnet, any standard type)
|
||||
* - silent-payment address (`sp1…`, mainnet, v0)
|
||||
* - npub1… → P2TR derived from the Nostr pubkey
|
||||
* - nprofile1… → P2TR derived from the encoded pubkey
|
||||
*
|
||||
* Returns `null` for unparseable input. The caller should treat `null` as
|
||||
* "input still in progress" rather than "error" until the user submits.
|
||||
*/
|
||||
function resolveRecipient(input: string): ResolvedRecipient | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Try bare Bitcoin / silent-payment via the unified parser.
|
||||
const parsed = parseHdRecipient(trimmed);
|
||||
if (parsed) {
|
||||
if (parsed.kind === 'address') {
|
||||
return { address: parsed.address, raw: trimmed, kind: 'address' };
|
||||
}
|
||||
return { address: parsed.spAddress, raw: trimmed, kind: 'sp' };
|
||||
}
|
||||
|
||||
// Try NIP-19 npub / nprofile.
|
||||
if (trimmed.startsWith('npub1') || trimmed.startsWith('nprofile1')) {
|
||||
try {
|
||||
const decoded = nip19.decode(trimmed);
|
||||
if (decoded.type === 'npub') {
|
||||
const address = nostrPubkeyToBitcoinAddress(decoded.data);
|
||||
if (address) return { address, pubkey: decoded.data, raw: trimmed, kind: 'address' };
|
||||
} else if (decoded.type === 'nprofile') {
|
||||
const address = nostrPubkeyToBitcoinAddress(decoded.data.pubkey);
|
||||
if (address) return { address, pubkey: decoded.data.pubkey, raw: trimmed, kind: 'address' };
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HDSendBitcoinDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** BTC/USD price — passed in to avoid duplicate fetches. */
|
||||
btcPrice?: number;
|
||||
}
|
||||
|
||||
interface SendResult {
|
||||
txid: string;
|
||||
amountSats: number;
|
||||
fee: number;
|
||||
/**
|
||||
* Silent-payment UTXOs (`(txid, vout)`) consumed by the broadcast tx.
|
||||
* Pruned from local SP storage in `onSuccess` — otherwise the wallet
|
||||
* would keep treating them as spendable and the displayed balance would
|
||||
* jump *up* after the spend (because the BIP-86 change credits to
|
||||
* Blockbook's xpub balance while the SP entries remain locally).
|
||||
*/
|
||||
consumedSpUtxos: Array<{ txid: string; vout: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Send Bitcoin" dialog for the HD wallet at `/wallet`.
|
||||
*
|
||||
* Provides a large editable USD amount, preset chips, fee speed picker, two-tap
|
||||
* arming for large amounts, and a privacy disclaimer for raw addresses. Uses
|
||||
* the HD wallet's UTXO set across many addresses, signs with per-input HD-derived
|
||||
* keys, and emits change to a fresh internal address.
|
||||
*/
|
||||
export function HDSendBitcoinDialog({ isOpen, onClose, btcPrice }: HDSendBitcoinDialogProps) {
|
||||
const availability = useHdWalletAccess();
|
||||
const {
|
||||
scan,
|
||||
silentPaymentBalance,
|
||||
silentPaymentStorage,
|
||||
refetch: refetchWallet,
|
||||
pruneSpentSilentPaymentUtxos,
|
||||
} = useHdWallet();
|
||||
const { config } = useAppContext();
|
||||
const { blockbookBaseUrl } = config;
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const isReady = availability.status === 'available';
|
||||
|
||||
// ── Form state ───────────────────────────────────────────────
|
||||
const [recipientInput, setRecipientInput] = useState('');
|
||||
const [usdAmount, setUsdAmount] = useState<number | string>(5);
|
||||
const [feeSpeed, setFeeSpeed] = useState<FeeSpeed>('halfHour');
|
||||
const [error, setError] = useState('');
|
||||
const [editingAmount, setEditingAmount] = useState(false);
|
||||
const [feePopoverOpen, setFeePopoverOpen] = useState(false);
|
||||
const [success, setSuccess] = useState<SendResult | null>(null);
|
||||
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
const feeSpeedUserChanged = useRef(false);
|
||||
|
||||
const recipient = useMemo(() => resolveRecipient(recipientInput), [recipientInput]);
|
||||
|
||||
// ── Fee rates ────────────────────────────────────────────────
|
||||
const { data: feeRates } = useQuery({
|
||||
queryKey: ['blockbook-fee-rates', blockbookBaseUrl],
|
||||
queryFn: ({ signal }) => fetchFeeRates(blockbookBaseUrl, signal),
|
||||
enabled: isOpen && isReady,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const currentFeeRate = useMemo(() => {
|
||||
if (!feeRates) return undefined;
|
||||
return getRateForSpeed(feeRates, feeSpeed);
|
||||
}, [feeRates, feeSpeed]);
|
||||
|
||||
// ── Owned UTXO set ───────────────────────────────────────────
|
||||
//
|
||||
// Combines BIP-86 UTXOs scanned from Blockbook with silent-payment UTXOs
|
||||
// discovered by the BIP-352 scanner and persisted via NIP-78. Both can
|
||||
// fund a send; the PSBT builder dispatches per-input.
|
||||
const bip86Utxos: HdSpendableUtxo[] = useMemo(() => scan?.utxos ?? [], [scan]);
|
||||
const spUtxos: HdSpendableSpUtxo[] = useMemo(
|
||||
() =>
|
||||
(silentPaymentStorage?.utxos ?? []).map((u) => ({
|
||||
txid: u.txid,
|
||||
vout: u.vout,
|
||||
value: u.value,
|
||||
tweakHex: u.tweak,
|
||||
k: u.k,
|
||||
height: u.height,
|
||||
})),
|
||||
[silentPaymentStorage],
|
||||
);
|
||||
const ownedInputs: HdInput[] = useMemo(
|
||||
() => [
|
||||
...bip86Utxos.map<HdInput>((utxo) => ({ kind: 'bip86', utxo })),
|
||||
...spUtxos.map<HdInput>((utxo) => ({ kind: 'sp', utxo })),
|
||||
],
|
||||
[bip86Utxos, spUtxos],
|
||||
);
|
||||
const totalBalance = useMemo(
|
||||
() => bip86Utxos.reduce((s, u) => s + u.value, 0) + silentPaymentBalance,
|
||||
[bip86Utxos, silentPaymentBalance],
|
||||
);
|
||||
|
||||
// ── USD → sats ───────────────────────────────────────────────
|
||||
const amountSats = useMemo(() => {
|
||||
if (!btcPrice) return 0;
|
||||
const usd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
|
||||
if (!Number.isFinite(usd) || usd <= 0) return 0;
|
||||
return Math.round((usd / btcPrice) * 100_000_000);
|
||||
}, [usdAmount, btcPrice]);
|
||||
|
||||
// ── Fee estimate (matches the actual coin selection) ────────
|
||||
//
|
||||
// Crucially we do NOT use `ownedInputs.length` as the input count: an HD
|
||||
// wallet typically has many UTXOs across many addresses, but a real send
|
||||
// only consumes the minimal set the coin selector picks. Using the full
|
||||
// count would over-estimate fees by 10x or more on an active wallet, and
|
||||
// would also make the UI think we're insufficient when we're not.
|
||||
const estimatedFeeSats = useMemo(() => {
|
||||
if (!ownedInputs.length || !currentFeeRate || !amountSats) return 0;
|
||||
return previewHdFee(ownedInputs, amountSats, currentFeeRate);
|
||||
}, [ownedInputs, currentFeeRate, amountSats]);
|
||||
|
||||
const totalSats = amountSats + estimatedFeeSats;
|
||||
// `previewHdFee` returns 0 when the coin selector can't cover `amount + fee`.
|
||||
// Treat that as insufficient so the UI doesn't claim a 0-sat fee is fine.
|
||||
const selectionFailed =
|
||||
amountSats > 0 && !!currentFeeRate && ownedInputs.length > 0 && estimatedFeeSats === 0;
|
||||
const insufficient = selectionFailed || (totalBalance > 0 && totalSats > totalBalance);
|
||||
const showBalance = insufficient || (amountSats > 0 && totalBalance === 0);
|
||||
|
||||
// Auto-tune fee speed to keep fees < 40% of the send amount, unless the
|
||||
// user has manually overridden.
|
||||
useEffect(() => {
|
||||
if (feeSpeedUserChanged.current) return;
|
||||
if (!ownedInputs.length || !feeRates || amountSats <= 0) return;
|
||||
|
||||
const uniqueSpeeds = getUniqueFeeSpeeds(feeRates);
|
||||
const threshold = amountSats * 0.4;
|
||||
|
||||
let target: FeeSpeed = uniqueSpeeds[uniqueSpeeds.length - 1];
|
||||
for (const speed of uniqueSpeeds) {
|
||||
const rate = getRateForSpeed(feeRates, speed);
|
||||
const fee = previewHdFee(ownedInputs, amountSats, rate);
|
||||
if (fee > 0 && fee <= threshold) { target = speed; break; }
|
||||
}
|
||||
setFeeSpeed((prev) => (prev === target ? prev : target));
|
||||
}, [amountSats, feeRates, ownedInputs, totalBalance]);
|
||||
|
||||
const handleFeeSpeedChange = useCallback((speed: FeeSpeed) => {
|
||||
feeSpeedUserChanged.current = true;
|
||||
setFeeSpeed(speed);
|
||||
setFeePopoverOpen(false);
|
||||
}, []);
|
||||
|
||||
// ── Two-tap arm + raw-address disclaimer ─────────────────────
|
||||
const isLarge = isLargeAmount(totalSats, btcPrice);
|
||||
// SP recipients (`sp1…`) produce a fresh, unlinkable Taproot output per
|
||||
// payment — they do NOT have the privacy concern of a reused on-chain
|
||||
// address. The public disclaimer is only needed for bare BTC addresses
|
||||
// typed in directly (no Nostr identity attached, no SP).
|
||||
const isRawAddress =
|
||||
!!recipient && recipient.kind === 'address' && !recipient.pubkey;
|
||||
const [confirmArmed, setConfirmArmed] = useState(false);
|
||||
const [acknowledgedPublic, setAcknowledgedPublic] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmArmed(false);
|
||||
}, [amountSats, currentFeeRate, btcPrice, recipient?.address]);
|
||||
|
||||
// Reset the privacy acknowledgement only when the recipient changes —
|
||||
// not when the user adjusts the amount or fee tier. Toggling between
|
||||
// fee speeds should not silently uncheck the warning.
|
||||
useEffect(() => {
|
||||
setAcknowledgedPublic(false);
|
||||
}, [recipient?.address]);
|
||||
|
||||
const requiresArm = isLarge || isRawAddress;
|
||||
|
||||
// ── Amount focus management ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (editingAmount) {
|
||||
amountInputRef.current?.focus();
|
||||
amountInputRef.current?.select();
|
||||
}
|
||||
}, [editingAmount]);
|
||||
|
||||
const commitAmountEdit = useCallback(() => {
|
||||
setEditingAmount(false);
|
||||
if (typeof usdAmount === 'string' && usdAmount.trim() === '') setUsdAmount(0);
|
||||
}, [usdAmount]);
|
||||
|
||||
// ── Send mutation ────────────────────────────────────────────
|
||||
const [progress, setProgress] = useState<'idle' | 'building' | 'signing' | 'broadcasting'>('idle');
|
||||
|
||||
const sendMutation = useMutation<SendResult, Error, void>({
|
||||
mutationFn: async () => {
|
||||
if (availability.status !== 'available') {
|
||||
throw new Error('HD wallet is not available for this login type.');
|
||||
}
|
||||
if (!recipient) throw new Error('Enter a Bitcoin address, sp1… address, or npub.');
|
||||
if (!ownedInputs.length) throw new Error('No spendable Bitcoin in this wallet.');
|
||||
if (!feeRates) throw new Error('Fee rates not loaded.');
|
||||
if (recipient.pubkey === availability.pubkey) throw new Error("You can't send to yourself.");
|
||||
if (amountSats <= 0) throw new Error('Enter an amount.');
|
||||
if (insufficient) throw new Error('Not enough Bitcoin for this amount + network fee.');
|
||||
|
||||
const rate = getRateForSpeed(feeRates, feeSpeed);
|
||||
const nextChangeIndex = scan?.change.firstUnusedIndex ?? 0;
|
||||
|
||||
setProgress('building');
|
||||
const built = buildHdSpendPsbt({
|
||||
account: availability.account,
|
||||
inputs: ownedInputs,
|
||||
recipient:
|
||||
recipient.kind === 'sp'
|
||||
? { kind: 'sp', spAddress: recipient.address }
|
||||
: { kind: 'address', address: recipient.address },
|
||||
amountSats,
|
||||
feeRate: rate,
|
||||
nextChangeIndex,
|
||||
nsecBytes: availability.nsecBytes,
|
||||
});
|
||||
|
||||
setProgress('signing');
|
||||
const signedHex = signHdPsbt(
|
||||
built.psbtHex,
|
||||
built.inputDescriptors,
|
||||
availability.account,
|
||||
availability.nsecBytes,
|
||||
);
|
||||
const txHex = finalizeHdPsbt(signedHex);
|
||||
|
||||
setProgress('broadcasting');
|
||||
const txid = await broadcastBlockbookTx(blockbookBaseUrl, txHex);
|
||||
|
||||
return { txid, amountSats, fee: built.fee, consumedSpUtxos: built.consumedSpUtxos };
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
notificationSuccess();
|
||||
setSuccess(result);
|
||||
queryClient.invalidateQueries({ queryKey: ['hdwallet-scan'] });
|
||||
// Remove the SP UTXOs we just spent from local storage and
|
||||
// republish the NIP-78 doc. Blockbook's xpub scan can't see SP
|
||||
// outputs, so without this the spent UTXOs would linger forever:
|
||||
// the balance would still count them, the coin selector would try
|
||||
// to spend them again (resulting in "missing/spent input" broadcast
|
||||
// errors), and the wallet would appear to *gain* money on each SP
|
||||
// spend (BIP-86 change is observed by Blockbook, but the consumed
|
||||
// SP value is not subtracted locally).
|
||||
if (result.consumedSpUtxos.length > 0) {
|
||||
pruneSpentSilentPaymentUtxos(result.consumedSpUtxos);
|
||||
}
|
||||
void refetchWallet();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: 'Transaction failed', description: err.message, variant: 'destructive' });
|
||||
},
|
||||
onSettled: () => setProgress('idle'),
|
||||
});
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
setError('');
|
||||
if (availability.status !== 'available') {
|
||||
setError('HD wallet is not available for this login type.'); return;
|
||||
}
|
||||
if (!recipient) { setError('Enter a Bitcoin address, sp1… address, or npub.'); return; }
|
||||
if (recipient.pubkey === availability.pubkey) { setError("You can't send to yourself."); return; }
|
||||
if (!btcPrice) { setError('Waiting for BTC price…'); return; }
|
||||
if (amountSats <= 0) { setError('Enter an amount.'); return; }
|
||||
if (!ownedInputs.length) { setError("You don't have any Bitcoin yet."); return; }
|
||||
if (insufficient) { setError('Not enough Bitcoin for this amount + network fee.'); return; }
|
||||
if (isRawAddress && !acknowledgedPublic) {
|
||||
setError('Acknowledge the privacy warning before sending.'); return;
|
||||
}
|
||||
if (requiresArm && !confirmArmed) { setConfirmArmed(true); return; }
|
||||
sendMutation.mutate();
|
||||
}, [
|
||||
availability,
|
||||
recipient,
|
||||
btcPrice,
|
||||
amountSats,
|
||||
ownedInputs.length,
|
||||
insufficient,
|
||||
isRawAddress,
|
||||
acknowledgedPublic,
|
||||
requiresArm,
|
||||
confirmArmed,
|
||||
sendMutation,
|
||||
]);
|
||||
|
||||
// ── Reset on close ───────────────────────────────────────────
|
||||
const handleClose = useCallback(() => {
|
||||
if (sendMutation.isPending) return;
|
||||
onClose();
|
||||
// defer to allow exit animation
|
||||
setTimeout(() => {
|
||||
setRecipientInput('');
|
||||
setUsdAmount(5);
|
||||
setError('');
|
||||
setConfirmArmed(false);
|
||||
setAcknowledgedPublic(false);
|
||||
setSuccess(null);
|
||||
feeSpeedUserChanged.current = false;
|
||||
}, 200);
|
||||
}, [onClose, sendMutation.isPending]);
|
||||
|
||||
// ── Render helpers ───────────────────────────────────────────
|
||||
const sendButtonLabel = (() => {
|
||||
if (sendMutation.isPending) {
|
||||
switch (progress) {
|
||||
case 'building': return 'Building transaction…';
|
||||
case 'signing': return 'Signing…';
|
||||
case 'broadcasting': return 'Broadcasting…';
|
||||
default: return 'Sending…';
|
||||
}
|
||||
}
|
||||
if (confirmArmed) return 'Tap again to confirm';
|
||||
return 'Send Bitcoin';
|
||||
})();
|
||||
|
||||
const sendDisabled =
|
||||
sendMutation.isPending ||
|
||||
!recipient ||
|
||||
!btcPrice ||
|
||||
amountSats <= 0 ||
|
||||
insufficient ||
|
||||
!ownedInputs.length ||
|
||||
(isRawAddress && !acknowledgedPublic);
|
||||
|
||||
// ── Render ───────────────────────────────────────────────────
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(v) => { if (!v) handleClose(); }}>
|
||||
<DialogContent className="sm:max-w-md p-0 gap-0 overflow-hidden [&>button]:hidden">
|
||||
<DialogTitle className="sr-only">Send Bitcoin</DialogTitle>
|
||||
|
||||
{success ? (
|
||||
<SuccessScreen
|
||||
txid={success.txid}
|
||||
amountSats={success.amountSats}
|
||||
btcPrice={btcPrice}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-5 px-6 py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Send Bitcoin</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="flex flex-col items-center py-2">
|
||||
{editingAmount ? (
|
||||
<div className="flex items-center text-4xl font-bold tracking-tight">
|
||||
<span className="text-muted-foreground">$</span>
|
||||
<Input
|
||||
ref={amountInputRef}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={usdAmount}
|
||||
onChange={(e) => setUsdAmount(e.target.value)}
|
||||
onBlur={commitAmountEdit}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') commitAmountEdit(); }}
|
||||
className="bg-transparent border-none focus-visible:ring-0 text-4xl font-bold tracking-tight w-32 text-center px-0 h-auto"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingAmount(true)}
|
||||
className="text-4xl font-bold tracking-tight hover:text-primary transition-colors cursor-text"
|
||||
>
|
||||
${typeof usdAmount === 'number' ? usdAmount : (parseFloat(usdAmount) || 0)}
|
||||
</button>
|
||||
)}
|
||||
{amountSats > 0 && btcPrice && (
|
||||
<span className="text-xs text-muted-foreground mt-1 tabular-nums">
|
||||
≈ {amountSats.toLocaleString()} sats
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* USD presets */}
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{USD_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => { setUsdAmount(preset); setEditingAmount(false); }}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded-full text-xs border transition-colors',
|
||||
Number(usdAmount) === preset
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'border-border hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
${preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Recipient */}
|
||||
<div className="grid gap-1">
|
||||
<label className="text-xs text-muted-foreground" htmlFor="hd-recipient-input">
|
||||
Recipient
|
||||
</label>
|
||||
<Input
|
||||
id="hd-recipient-input"
|
||||
value={recipientInput}
|
||||
onChange={(e) => setRecipientInput(e.target.value)}
|
||||
placeholder="bc1…, sp1…, or npub…"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{recipient && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{recipient.kind === 'sp' ? (
|
||||
<>Sending via a silent payment — the recipient gets a fresh, unlinkable on-chain address.</>
|
||||
) : recipient.pubkey ? (
|
||||
<>Sending to a Nostr user's on-chain address.</>
|
||||
) : (
|
||||
<>Sending to a raw Bitcoin address.</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Privacy disclaimer for raw addresses */}
|
||||
{isRawAddress && (
|
||||
<BitcoinPublicDisclaimer
|
||||
acknowledged={acknowledgedPublic}
|
||||
onAcknowledgedChange={setAcknowledgedPublic}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fee speed */}
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground">Network fee</span>
|
||||
<Popover open={feePopoverOpen} onOpenChange={setFeePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-muted-foreground tabular-nums"
|
||||
>
|
||||
{estimatedFeeSats > 0 && btcPrice ? (
|
||||
<>≈ {satsToUSD(estimatedFeeSats, btcPrice)}</>
|
||||
) : currentFeeRate ? (
|
||||
<>{currentFeeRate} sat/vB</>
|
||||
) : (
|
||||
<>—</>
|
||||
)}
|
||||
<span className="opacity-60">·</span>
|
||||
{FEE_SPEED_LABELS[feeSpeed]}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-44 p-1" align="end">
|
||||
<div className="grid gap-0.5">
|
||||
{getUniqueFeeSpeeds(feeRates).map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
type="button"
|
||||
onClick={() => handleFeeSpeedChange(speed)}
|
||||
className={cn(
|
||||
'flex justify-between items-center px-3 py-1.5 rounded-md text-xs hover:bg-muted/50 transition-colors',
|
||||
feeSpeed === speed && 'bg-muted',
|
||||
)}
|
||||
>
|
||||
<span>{FEE_SPEED_LABELS[speed]}</span>
|
||||
{feeRates && (
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{getRateForSpeed(feeRates, speed)} sat/vB
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{showBalance && totalBalance > 0 && btcPrice && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Available: {satsToUSD(totalBalance, btcPrice)} ({totalBalance.toLocaleString()} sats)
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="py-2">
|
||||
<AlertTriangle className="size-3.5" />
|
||||
<AlertDescription className="text-xs">{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Send button */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={sendDisabled}
|
||||
className={cn(
|
||||
'w-full',
|
||||
confirmArmed && !sendMutation.isPending && 'bg-amber-500 hover:bg-amber-600 text-white',
|
||||
)}
|
||||
>
|
||||
{sendMutation.isPending && <Loader2 className="size-4 mr-2 animate-spin" />}
|
||||
{sendButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Success screen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SuccessScreenProps {
|
||||
txid: string;
|
||||
amountSats: number;
|
||||
btcPrice: number | undefined;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function SuccessScreen({ txid, amountSats, btcPrice, onClose }: SuccessScreenProps) {
|
||||
const usdDisplay = btcPrice ? satsToUSD(amountSats, btcPrice) : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="relative grid gap-5 px-6 py-8 w-full overflow-hidden text-center motion-safe:animate-success-fade-up"
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 -z-10 bg-[radial-gradient(circle_at_50%_35%,hsl(var(--primary)/0.18),transparent_65%)]"
|
||||
/>
|
||||
|
||||
<div className="relative mx-auto flex size-28 items-center justify-center">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400/40 to-orange-500/30 motion-safe:animate-success-halo"
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 shadow-lg shadow-orange-500/30 motion-safe:animate-success-pop"
|
||||
/>
|
||||
<Check className="relative size-14 text-white drop-shadow-sm motion-safe:animate-success-pop" strokeWidth={3} aria-hidden />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Bitcoin sent</h2>
|
||||
<div className="text-4xl font-bold tabular-nums bg-gradient-to-br from-amber-500 to-orange-600 bg-clip-text text-transparent">
|
||||
{usdDisplay || `${amountSats.toLocaleString()} sats`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Button type="button" variant="outline" asChild className="w-full">
|
||||
<Link to={`/i/bitcoin:tx:${txid}`} onClick={onClose}>
|
||||
<ExternalLink className="size-4 mr-2" />
|
||||
View transaction
|
||||
</Link>
|
||||
</Button>
|
||||
<Button type="button" onClick={onClose} className="w-full">Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useHdWalletSp } from '@/hooks/useHdWalletSp';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HD wallet — silent-payment "Scan history" dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Walks the user through running a BIP-352 chain scan over a configurable
|
||||
// block range. Defaults to "from last scanned height → tip", which is the
|
||||
// common forward-catch-up case; advanced users can edit the bounds for a
|
||||
// targeted backfill.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface HDSilentPaymentScanDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function HDSilentPaymentScanDialog({ open, onOpenChange }: HDSilentPaymentScanDialogProps) {
|
||||
const sp = useHdWalletSp();
|
||||
const [from, setFrom] = useState('');
|
||||
const [to, setTo] = useState('');
|
||||
const [touched, setTouched] = useState(false);
|
||||
const [includeSpent, setIncludeSpent] = useState(false);
|
||||
|
||||
// Seed defaults whenever the dialog opens or upstream data changes.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTouched(false);
|
||||
setIncludeSpent(false);
|
||||
return;
|
||||
}
|
||||
if (touched) return;
|
||||
const tip = sp.tipHeight;
|
||||
const lastScanned = sp.storage?.scanHeight ?? 0;
|
||||
const defaultFrom = lastScanned > 0 ? lastScanned + 1 : tip ? Math.max(0, tip - 144) : 0;
|
||||
setFrom(String(defaultFrom));
|
||||
setTo(tip ? String(tip) : '');
|
||||
}, [open, sp.tipHeight, sp.storage?.scanHeight, touched]);
|
||||
|
||||
const fromNum = Number(from);
|
||||
const toNum = Number(to);
|
||||
const fromValid = Number.isInteger(fromNum) && fromNum >= 0;
|
||||
const toValid = to === '' || (Number.isInteger(toNum) && toNum >= fromNum);
|
||||
const inputsValid = fromValid && toValid;
|
||||
|
||||
const handleScan = async () => {
|
||||
if (!inputsValid) return;
|
||||
await sp.scanRange({
|
||||
fromHeight: fromNum,
|
||||
toHeight: to === '' ? undefined : toNum,
|
||||
includeSpent,
|
||||
});
|
||||
};
|
||||
|
||||
const progressPercent = sp.scanProgress
|
||||
? Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
((sp.scanProgress.currentHeight - sp.scanProgress.fromHeight + 1) /
|
||||
Math.max(1, sp.scanProgress.toHeight - sp.scanProgress.fromHeight + 1)) *
|
||||
100,
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scan for silent payments</DialogTitle>
|
||||
<DialogDescription>
|
||||
Walks the configured BIP-352 indexer block-by-block to detect incoming silent payments.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-scan-from" className="text-xs">
|
||||
From block
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-scan-from"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={from}
|
||||
onChange={(e) => {
|
||||
setTouched(true);
|
||||
setFrom(e.target.value);
|
||||
}}
|
||||
disabled={sp.isScanning}
|
||||
aria-invalid={!fromValid}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sp-scan-to" className="text-xs">
|
||||
To block
|
||||
</Label>
|
||||
<Input
|
||||
id="sp-scan-to"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
placeholder="tip"
|
||||
value={to}
|
||||
onChange={(e) => {
|
||||
setTouched(true);
|
||||
setTo(e.target.value);
|
||||
}}
|
||||
disabled={sp.isScanning}
|
||||
aria-invalid={!toValid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sp.tipHeight !== undefined && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Indexer tip: <span className="font-mono">{sp.tipHeight.toLocaleString()}</span>
|
||||
{sp.storage && (
|
||||
<>
|
||||
{' · '}
|
||||
Last fully scanned:{' '}
|
||||
<span className="font-mono">
|
||||
{sp.storage.scanHeight > 0 ? sp.storage.scanHeight.toLocaleString() : 'never'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/*
|
||||
* "Include already-spent" deep-rescan toggle. Off by default
|
||||
* because the normal scan path doesn't want already-spent
|
||||
* outputs cluttering the active UTXO set. Turn on to recover
|
||||
* historical receive rows whose UTXOs were later spent and
|
||||
* subsequently pruned from local storage — matches against
|
||||
* spent outputs are routed straight into the `spent` archive,
|
||||
* which powers both the receive-history rows and the
|
||||
* send-vs-receive classifier in the tx list.
|
||||
*/}
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="sp-include-spent"
|
||||
checked={includeSpent}
|
||||
onCheckedChange={(v) => setIncludeSpent(v === true)}
|
||||
disabled={sp.isScanning}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sp-include-spent" className="text-xs cursor-pointer">
|
||||
Include already-spent
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Also detect silent payments that have since been spent. Use when
|
||||
rebuilding receive history after a missed scan or a reset.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sp.isScanning && sp.scanProgress && (
|
||||
<div className="space-y-2">
|
||||
<Progress value={progressPercent} />
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Block {sp.scanProgress.currentHeight.toLocaleString()} /{' '}
|
||||
{sp.scanProgress.toHeight.toLocaleString()}
|
||||
</span>
|
||||
<span>
|
||||
{sp.scanProgress.matchesFound} match
|
||||
{sp.scanProgress.matchesFound === 1 ? '' : 'es'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sp.isScanning && sp.scanError && (
|
||||
<div className="flex items-start gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-4 shrink-0 mt-0.5" />
|
||||
<p>{sp.scanError.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!sp.isScanning && !sp.scanError && sp.scanProgress && (
|
||||
<div className="flex items-start gap-2 text-xs text-muted-foreground">
|
||||
<CheckCircle2 className="size-4 shrink-0 mt-0.5 text-green-500" />
|
||||
<p>
|
||||
Scanned blocks {sp.scanProgress.fromHeight.toLocaleString()} →{' '}
|
||||
{sp.scanProgress.currentHeight.toLocaleString()}.{' '}
|
||||
{sp.scanProgress.matchesFound > 0
|
||||
? `Found ${sp.scanProgress.matchesFound} new ${
|
||||
sp.scanProgress.matchesFound === 1 ? 'output' : 'outputs'
|
||||
}.`
|
||||
: 'No new payments.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Reconcile spent UTXOs ──────────────────────────── */}
|
||||
{/*
|
||||
* Manual fix-up path for SP UTXOs that were spent outside the
|
||||
* local send flow — different device, or a build that predates
|
||||
* the send-time prune logic. Walks the stored set, asks
|
||||
* Blockbook for each output's spent status, and drops the spent
|
||||
* ones. Capped at 50 UTXOs per click; subsequent clicks pick up
|
||||
* any remainder.
|
||||
*/}
|
||||
{sp.storage && sp.storage.utxos.length > 0 && (
|
||||
<div className="space-y-2 border-t pt-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Reconcile spent UTXOs</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Checks each stored silent-payment UTXO against Blockbook and removes any
|
||||
that have been spent. Use this if the balance is higher than it should
|
||||
be after a send.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sp.reconcileProgress && !sp.reconcileError && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sp.isReconciling
|
||||
? `Checking ${sp.reconcileProgress.checked} / ${sp.reconcileProgress.total}…`
|
||||
: `Checked ${sp.reconcileProgress.checked} UTXO${
|
||||
sp.reconcileProgress.checked === 1 ? '' : 's'
|
||||
} · pruned ${sp.reconcileProgress.prunedSoFar}.`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sp.reconcileError && (
|
||||
<div className="flex items-start gap-2 text-xs text-destructive">
|
||||
<AlertCircle className="size-4 shrink-0 mt-0.5" />
|
||||
<p>{sp.reconcileError.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void sp.reconcileSpentUtxos();
|
||||
}}
|
||||
disabled={sp.isReconciling || sp.isScanning}
|
||||
>
|
||||
{sp.isReconciling ? (
|
||||
<>
|
||||
<Loader2 className="size-3 animate-spin mr-2" />
|
||||
Reconciling…
|
||||
</>
|
||||
) : (
|
||||
'Reconcile now'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
{sp.isScanning ? (
|
||||
<Button variant="outline" onClick={() => sp.cancelScan()}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleScan} disabled={!inputsValid}>
|
||||
Start scan
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,8 @@ interface HeroAtmosphereProps {
|
||||
* warm palette is the wrong vibe.
|
||||
*/
|
||||
hue?: HopeHue;
|
||||
/** Crossfade duration in milliseconds. Defaults to the campaign hero timing. */
|
||||
fadeMs?: number;
|
||||
/** Extra classes for the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
@@ -51,7 +53,7 @@ const FADE_MS = 1500;
|
||||
* the old one, matching the timing of the photo crossfade so the whole
|
||||
* hero blooms together.
|
||||
*/
|
||||
export function HeroAtmosphere({ seed, hue: hueOverride, className }: HeroAtmosphereProps) {
|
||||
export function HeroAtmosphere({ seed, hue: hueOverride, fadeMs = FADE_MS, className }: HeroAtmosphereProps) {
|
||||
const idRef = useRef(0);
|
||||
const [layers, setLayers] = useState<AtmosphereLayer[]>([]);
|
||||
const lastHueRef = useRef<string | null>(null);
|
||||
@@ -68,9 +70,9 @@ export function HeroAtmosphere({ seed, hue: hueOverride, className }: HeroAtmosp
|
||||
// safely past, so the DOM never accumulates stale gradients.
|
||||
const timeout = window.setTimeout(() => {
|
||||
setLayers((prev) => prev.filter((l) => l.id === id));
|
||||
}, FADE_MS + 50);
|
||||
}, fadeMs + 50);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [seed, hueOverride]);
|
||||
}, [seed, hueOverride, fadeMs]);
|
||||
|
||||
return (
|
||||
<div className={cn('absolute inset-0 pointer-events-none', className)} aria-hidden="true">
|
||||
@@ -82,7 +84,7 @@ export function HeroAtmosphere({ seed, hue: hueOverride, className }: HeroAtmosp
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
opacity: isTop ? 1 : 0,
|
||||
transition: `opacity ${FADE_MS}ms ease-in-out`,
|
||||
transition: `opacity ${fadeMs}ms ease-in-out`,
|
||||
}}
|
||||
>
|
||||
{/* Warm directional scrim — pulls the photo toward the active
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, MapPin } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { encodeCampaignNaddr, getCampaignCountryLabel, type ParsedCampaign } from '@/lib/campaign';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
|
||||
import { useBtcPrice } from '@/hooks/useBtcPrice';
|
||||
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
|
||||
|
||||
interface HeroCampaignSpotlightProps {
|
||||
/** Campaign to feature. `null` renders the empty placeholder. */
|
||||
campaign: ParsedCampaign | null;
|
||||
/** Show a skeleton while the parent is still loading featured campaigns. */
|
||||
isLoading?: boolean;
|
||||
/** Extra classes for the outer wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner-overlay spotlight for the active campaign — title, summary,
|
||||
* author, location, and a "View campaign" CTA — rendered directly on the
|
||||
* hero photo (no card chrome). The hero photo IS the background, so this
|
||||
* component is purely a text overlay.
|
||||
*
|
||||
* Parent (`CampaignsPage`) drives the `campaign` prop, cycling on a timer
|
||||
* or pinning to whichever marker the user clicked on the globe.
|
||||
*/
|
||||
export function HeroCampaignSpotlight({
|
||||
campaign,
|
||||
isLoading = false,
|
||||
className,
|
||||
}: HeroCampaignSpotlightProps) {
|
||||
// useAuthor must be called unconditionally to keep hook order stable —
|
||||
// when there's no campaign yet we pass an empty pubkey and ignore the
|
||||
// (no-op) result below. Same for donations + BTC price.
|
||||
const author = useAuthor(campaign?.pubkey ?? '');
|
||||
const { data: stats } = useCampaignDonations(campaign?.aTag);
|
||||
const { data: btcPrice } = useBtcPrice();
|
||||
|
||||
if (isLoading && !campaign) {
|
||||
return (
|
||||
<div className={cn('space-y-1.5', className)}>
|
||||
<Skeleton className="h-5 w-52 bg-white/20" />
|
||||
<Skeleton className="h-3 w-64 bg-white/20" />
|
||||
<Skeleton className="h-3 w-40 bg-white/20" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!campaign) return null;
|
||||
|
||||
const naddr = encodeCampaignNaddr(campaign);
|
||||
const meta = author.data?.metadata;
|
||||
const authorName = meta?.display_name || meta?.name || genUserName(campaign.pubkey);
|
||||
const authorPicture = sanitizeUrl(meta?.picture);
|
||||
const countryLabel = getCampaignCountryLabel(campaign);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Compact text block over the photo. Light text + subtle drop
|
||||
// shadow for legibility, no card chrome — modeled after the
|
||||
// Treasures hero overlay: tight, dense, low-key.
|
||||
'space-y-1.5 text-foreground [text-shadow:0_1px_2px_rgb(0_0_0/0.4)]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-base font-semibold leading-snug line-clamp-1">
|
||||
{campaign.title}
|
||||
</p>
|
||||
|
||||
{campaign.summary && (
|
||||
<p className="text-xs text-foreground/80 line-clamp-2 max-w-xs">
|
||||
{campaign.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Progress / goal. Hand-rolled instead of using <CampaignProgress>
|
||||
so we can tune the bar for legibility on top of a photo: dark
|
||||
translucent track, glowing primary fill. When the campaign has no
|
||||
goal tag, the bar is omitted entirely and we only show the raised
|
||||
total. */}
|
||||
{(() => {
|
||||
const raised = stats?.totalSats ?? 0;
|
||||
const goal = campaign.goalSats;
|
||||
const hasGoal = !!goal && goal > 0;
|
||||
const pct = hasGoal ? Math.min(100, Math.round((raised / goal!) * 100)) : 0;
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1 max-w-xs">
|
||||
{hasGoal && (
|
||||
<div className="relative h-1.5 w-full overflow-hidden rounded-full bg-black/40 ring-1 ring-white/15">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-primary shadow-[0_0_8px_hsl(var(--primary)/0.7)] motion-safe:transition-[width] motion-safe:duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-baseline justify-between gap-2 text-[11px] [text-shadow:none]">
|
||||
<span className="font-semibold text-foreground">
|
||||
{formatCampaignAmount(raised, btcPrice)}
|
||||
{!hasGoal && <span className="ml-1 font-normal text-foreground/70">raised</span>}
|
||||
</span>
|
||||
{hasGoal && (
|
||||
<span className="text-foreground/70">
|
||||
of {formatCampaignAmount(goal!, btcPrice)} goal
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-foreground/75 pt-0.5">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Avatar className="size-4 ring-1 ring-white/40">
|
||||
{authorPicture && <AvatarImage src={authorPicture} alt="" />}
|
||||
<AvatarFallback className="text-[8px]">
|
||||
{authorName.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{authorName}</span>
|
||||
</span>
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="size-3" />
|
||||
<span className="truncate max-w-[16ch]">{countryLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={`/${naddr}`}
|
||||
className="inline-flex items-center gap-1 font-medium text-primary hover:text-primary/80 focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
View
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,470 +0,0 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import { LAND_RINGS } from '@/lib/landPolygons';
|
||||
import { HOPE_PALETTE, type HopeHue } from '@/lib/hopePalette';
|
||||
|
||||
/** Geographic point used by the globe projection. */
|
||||
interface GeoPoint {
|
||||
/** Latitude in degrees, [-90, 90]. */
|
||||
lat: number;
|
||||
/** Longitude in degrees, [-180, 180]. */
|
||||
lng: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual variant for a globe marker. Each kind gets its own glyph + halo
|
||||
* so the three "threads" of Discover — campaigns, communities, and
|
||||
* country activity — read distinctly without needing legend chrome.
|
||||
*/
|
||||
export type GlobeMarkerKind = 'campaign' | 'community' | 'country-pulse';
|
||||
|
||||
interface CampaignMarker extends GeoPoint {
|
||||
/** Stable key for the marker (e.g. the campaign aTag). */
|
||||
key: string;
|
||||
/** Tooltip / accessible label shown on hover. */
|
||||
label?: string;
|
||||
/**
|
||||
* Visual style of this marker. Defaults to `'campaign'` so existing
|
||||
* callers (the campaigns hero) keep their heart markers unchanged.
|
||||
*/
|
||||
kind?: GlobeMarkerKind;
|
||||
}
|
||||
|
||||
interface HeroGlobeProps {
|
||||
/** Markers to plot on top of the globe — one per geo-located campaign. */
|
||||
markers?: CampaignMarker[];
|
||||
/**
|
||||
* Marker the user has selected. The selected marker gets a stronger glow
|
||||
* and a slightly larger heart so it reads as the "live" one.
|
||||
*/
|
||||
selectedKey?: string | null;
|
||||
/** Fires when the user clicks a marker. */
|
||||
onMarkerClick?: (key: string) => void;
|
||||
/**
|
||||
* Active hopeful hue. Drives the outer halo color and the back-lit
|
||||
* limb tint so the globe agrees with the surrounding {@link HeroAtmosphere}.
|
||||
*/
|
||||
hue?: HopeHue;
|
||||
/** Optional className applied to the outer container. */
|
||||
className?: string;
|
||||
/** Optional inline style applied to the outer container (e.g. fluid width via `clamp()`). */
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/** Pre-parsed land rings as arrays of {lat, lng} points. */
|
||||
const LANDMASSES: readonly GeoPoint[][] = LAND_RINGS.map((flat) => {
|
||||
const out: GeoPoint[] = [];
|
||||
for (let i = 0; i < flat.length; i += 2) {
|
||||
out.push({ lng: flat[i], lat: flat[i + 1] });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const RADIUS = 285;
|
||||
const CENTER = 300;
|
||||
/** Seconds per full revolution. Slow on purpose so the motion is ambient. */
|
||||
const ROTATION_PERIOD_SECONDS = 140;
|
||||
|
||||
/**
|
||||
* Orthographic projection: turns a (lat, lng) pair into 2D screen
|
||||
* coordinates plus a `z` depth value. Points with `z <= 0` are on the
|
||||
* back hemisphere and should be hidden (or drawn with low opacity).
|
||||
*/
|
||||
function project(lat: number, lng: number, rotationDeg: number) {
|
||||
const phi = (lat * Math.PI) / 180;
|
||||
// Subtract rotation so the globe appears to spin west-to-east.
|
||||
const lambda = ((lng - rotationDeg) * Math.PI) / 180;
|
||||
const cosPhi = Math.cos(phi);
|
||||
const x = cosPhi * Math.sin(lambda);
|
||||
const y = Math.sin(phi);
|
||||
const z = cosPhi * Math.cos(lambda);
|
||||
return {
|
||||
x: CENTER + x * RADIUS,
|
||||
// Negate so positive latitudes render upward in SVG.
|
||||
y: CENTER - y * RADIUS,
|
||||
z,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slowly-rotating SVG globe rendered with pure SVG (no WebGL, no canvas).
|
||||
*
|
||||
* Visuals are intentionally warm and hand-drawn rather than satellite/HUD:
|
||||
* - a soft cream sphere lit from the upper-left,
|
||||
* - sandy-amber landmasses (real Natural Earth continent shapes,
|
||||
* pre-simplified to ~1.5k vertices), and
|
||||
* - small glowing marker dots for active campaigns.
|
||||
*
|
||||
* Rotation is driven by `requestAnimationFrame` and applied imperatively via
|
||||
* refs so the component never re-renders during animation. Respects
|
||||
* `prefers-reduced-motion` by holding at a static angle.
|
||||
*/
|
||||
export function HeroGlobe({
|
||||
markers = [],
|
||||
selectedKey = null,
|
||||
onMarkerClick,
|
||||
hue = HOPE_PALETTE[0],
|
||||
className,
|
||||
style,
|
||||
}: HeroGlobeProps) {
|
||||
const landRef = useRef<SVGGElement | null>(null);
|
||||
const markersRef = useRef<SVGGElement | null>(null);
|
||||
|
||||
// Stable per-ring point counts so the animation loop knows how many polygon
|
||||
// elements to update without re-reading the DOM each frame.
|
||||
const ringSizes = useMemo(() => LANDMASSES.map((r) => r.length), []);
|
||||
|
||||
// Live refs so the rAF loop can read the latest markers / selection
|
||||
// without retriggering the effect — otherwise every spotlight tick
|
||||
// would tear down the loop and snap rotation back to 0°.
|
||||
const markersRefValue = useRef(markers);
|
||||
const selectedKeyRef = useRef(selectedKey);
|
||||
useEffect(() => {
|
||||
markersRefValue.current = markers;
|
||||
selectedKeyRef.current = selectedKey;
|
||||
}, [markers, selectedKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
let rafId = 0;
|
||||
let start: number | null = null;
|
||||
|
||||
const tick = (timestamp: number) => {
|
||||
if (start === null) start = timestamp;
|
||||
const elapsedSeconds = (timestamp - start) / 1000;
|
||||
const rotation = prefersReducedMotion
|
||||
? 25 // Hold at a flattering static angle.
|
||||
: (elapsedSeconds / ROTATION_PERIOD_SECONDS) * 360;
|
||||
|
||||
// --- Landmass polygons ---
|
||||
//
|
||||
// For each ring we walk vertex-by-vertex projecting through the
|
||||
// orthographic camera. Vertices on the *front* of the sphere
|
||||
// (z > 0) are kept as-is. Vertices on the *back* (z < 0) would
|
||||
// otherwise project on top of front-side land — orthographic
|
||||
// projection collapses depth — so we drop them.
|
||||
//
|
||||
// Where a ring crosses the visible limb (front ↔ back) we emit an
|
||||
// interpolated point on the limb itself, so polygons that wrap
|
||||
// around the side of the globe close cleanly along the sphere's
|
||||
// outline instead of cutting across the disc interior.
|
||||
//
|
||||
// We also fade rings out over a narrow band near the limb so they
|
||||
// don't pop on/off when crossing z = 0. Anything with maxZ below
|
||||
// FADE_OUT is considered fully hidden; rings between FADE_OUT and
|
||||
// FADE_IN ease in/out.
|
||||
const FADE_OUT = 0.0;
|
||||
const FADE_IN = 0.08;
|
||||
const landEl = landRef.current;
|
||||
if (landEl) {
|
||||
const polygons = landEl.children;
|
||||
for (let i = 0; i < LANDMASSES.length; i++) {
|
||||
const ring = LANDMASSES[i];
|
||||
const polygon = polygons[i] as SVGPolygonElement | undefined;
|
||||
if (!polygon) continue;
|
||||
|
||||
// First pass: project every vertex, remembering z so we can
|
||||
// detect front/back transitions cheaply.
|
||||
const n = ring.length;
|
||||
const xs = new Array<number>(n);
|
||||
const ys = new Array<number>(n);
|
||||
const zs = new Array<number>(n);
|
||||
let maxZ = -1;
|
||||
for (let j = 0; j < n; j++) {
|
||||
const p = project(ring[j].lat, ring[j].lng, rotation);
|
||||
xs[j] = p.x;
|
||||
ys[j] = p.y;
|
||||
zs[j] = p.z;
|
||||
if (p.z > maxZ) maxZ = p.z;
|
||||
}
|
||||
if (maxZ <= FADE_OUT) {
|
||||
polygon.setAttribute('opacity', '0');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Second pass: emit only the visible portion. For each edge we
|
||||
// include the endpoint when it's in front, and any limb-crossing
|
||||
// we step over gets an interpolated point on the sphere edge.
|
||||
const parts: string[] = [];
|
||||
for (let j = 0; j < n; j++) {
|
||||
const k = (j + 1) % n;
|
||||
const zj = zs[j];
|
||||
const zk = zs[k];
|
||||
if (zj > 0) parts.push(`${xs[j].toFixed(1)},${ys[j].toFixed(1)}`);
|
||||
if ((zj > 0) !== (zk > 0)) {
|
||||
// Find the parameter t in [0,1] along this edge where z=0.
|
||||
const t = zj / (zj - zk);
|
||||
const ex = xs[j] + (xs[k] - xs[j]) * t;
|
||||
const ey = ys[j] + (ys[k] - ys[j]) * t;
|
||||
// Project the limb point onto the actual sphere edge so it
|
||||
// never lands inside the disc.
|
||||
const dx = ex - CENTER;
|
||||
const dy = ey - CENTER;
|
||||
const d = Math.hypot(dx, dy) || 1;
|
||||
const lx = CENTER + (dx / d) * RADIUS;
|
||||
const ly = CENTER + (dy / d) * RADIUS;
|
||||
parts.push(`${lx.toFixed(1)},${ly.toFixed(1)}`);
|
||||
}
|
||||
}
|
||||
if (parts.length < 3) {
|
||||
polygon.setAttribute('opacity', '0');
|
||||
continue;
|
||||
}
|
||||
polygon.setAttribute('points', parts.join(' '));
|
||||
// Smooth fade as rings come around the limb. `fade` clamps to
|
||||
// [0,1] over the narrow FADE_OUT→FADE_IN band, then we keep
|
||||
// adding the small depth-based dimming used before.
|
||||
const fade = Math.min(1, Math.max(0, (maxZ - FADE_OUT) / (FADE_IN - FADE_OUT)));
|
||||
polygon.setAttribute('opacity', (fade * Math.min(1, 0.55 + maxZ * 0.55)).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Campaign markers ---
|
||||
const markersEl = markersRef.current;
|
||||
const liveMarkers = markersRefValue.current;
|
||||
const liveSelectedKey = selectedKeyRef.current;
|
||||
if (markersEl) {
|
||||
const groups = markersEl.children;
|
||||
for (let i = 0; i < liveMarkers.length; i++) {
|
||||
const m = liveMarkers[i];
|
||||
const group = groups[i] as SVGGElement | undefined;
|
||||
if (!group) continue;
|
||||
const p = project(m.lat, m.lng, rotation);
|
||||
if (p.z <= 0) {
|
||||
group.setAttribute('opacity', '0');
|
||||
// Pull off-canvas so backside markers don't intercept clicks.
|
||||
group.setAttribute('transform', 'translate(-1000 -1000)');
|
||||
continue;
|
||||
}
|
||||
// Selected marker scales up subtly to read as "you are here".
|
||||
const scale = m.key === liveSelectedKey ? 1.35 : 1;
|
||||
group.setAttribute(
|
||||
'transform',
|
||||
`translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) scale(${scale})`,
|
||||
);
|
||||
group.setAttribute('opacity', (0.55 + p.z * 0.45).toFixed(2));
|
||||
}
|
||||
}
|
||||
|
||||
if (!prefersReducedMotion) {
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
// `markers` and `selectedKey` are read inside `tick` via refs above,
|
||||
// so we deliberately omit them from this dep list to keep the
|
||||
// rotation loop alive across spotlight cycles.
|
||||
}, [ringSizes]);
|
||||
|
||||
return (
|
||||
<div className={className} style={style}>
|
||||
{/* Wrapper so the outer halo can sit behind the SVG. The halo is a
|
||||
plain div (not part of the SVG) so its blur extends past the
|
||||
sphere without needing a giant viewBox, and so we can drive it
|
||||
with a CSS keyframe animation independent of the rotation. */}
|
||||
<div className="relative size-full">
|
||||
{/* Outer atmospheric halo. Scaled larger than the wrapper so light
|
||||
spills out into the photo, blurred for softness, and tinted
|
||||
with the active campaign's hopeful hue. Breathes slowly via
|
||||
the .hero-globe-halo-breath class defined in index.css. */}
|
||||
<div
|
||||
className="hero-globe-halo-breath absolute inset-[-15%] pointer-events-none"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(closest-side, ${hue.glow} 0%, ${hue.rim} 30%, transparent 70%)`,
|
||||
filter: 'blur(40px)',
|
||||
// background-image isn't actually transitionable across
|
||||
// gradient stops in CSS, but keeping the declaration here
|
||||
// documents that the hue swap is driven by React re-renders
|
||||
// synced to the HeroAtmosphere crossfade.
|
||||
}}
|
||||
/>
|
||||
|
||||
<svg
|
||||
viewBox="0 0 600 600"
|
||||
className="relative size-full"
|
||||
role="img"
|
||||
aria-label="Globe showing locations of active fundraising campaigns"
|
||||
focusable="false"
|
||||
>
|
||||
<defs>
|
||||
{/* Sphere base: warm dawn gold lit from the upper-left, fading
|
||||
into a deeper honey shadow on the lower-right. The whole
|
||||
sphere is meant to read as "lit from within" — like the
|
||||
moment before sunrise — not as a slab of dirt. */}
|
||||
<radialGradient id="hero-globe-base" cx="32%" cy="28%" r="78%">
|
||||
<stop offset="0%" stopColor="hsl(46 100% 96% / 0.92)" />
|
||||
<stop offset="40%" stopColor="hsl(38 90% 82% / 0.82)" />
|
||||
<stop offset="100%" stopColor="hsl(28 65% 60% / 0.72)" />
|
||||
</radialGradient>
|
||||
{/* Back-lit limb light. Reads as light pooling on the inside of
|
||||
the sphere edge — Earthrise rather than satellite. Tinted
|
||||
with the active hopeful hue, kept narrow + low-opacity so it
|
||||
feels like atmosphere, not a neon ring. */}
|
||||
<radialGradient id="hero-globe-rim" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="86%" stopColor={hue.rim} stopOpacity="0" />
|
||||
<stop offset="97%" stopColor={hue.rim} stopOpacity="0.55" />
|
||||
<stop offset="100%" stopColor={hue.glow} stopOpacity="0" />
|
||||
</radialGradient>
|
||||
{/* Soft highlight in the upper-left to sell the sphere shape. */}
|
||||
<radialGradient id="hero-globe-highlight" cx="28%" cy="22%" r="38%">
|
||||
<stop offset="0%" stopColor="hsl(50 100% 98% / 0.85)" />
|
||||
<stop offset="100%" stopColor="hsl(50 100% 98% / 0)" />
|
||||
</radialGradient>
|
||||
{/* Marker glow halo. Soft, warm, no pulsing. */}
|
||||
<radialGradient id="hero-marker-glow" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.55" />
|
||||
<stop offset="70%" stopColor="hsl(var(--primary))" stopOpacity="0.12" />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
{/* Stronger halo used for the selected marker so it visibly leads
|
||||
the eye to whatever the spotlight card is currently showing. */}
|
||||
<radialGradient id="hero-marker-glow-active" cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity="0.9" />
|
||||
<stop offset="55%" stopColor="hsl(var(--primary))" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
{/* Clip everything to the sphere so polygons straddling the
|
||||
terminator don't leak outside the circle. */}
|
||||
<clipPath id="hero-globe-clip">
|
||||
<circle cx={CENTER} cy={CENTER} r={RADIUS} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
{/* Base sphere with light shading. */}
|
||||
<circle cx={CENTER} cy={CENTER} r={RADIUS} fill="url(#hero-globe-base)" />
|
||||
|
||||
{/* Landmasses, clipped to the sphere. */}
|
||||
<g clipPath="url(#hero-globe-clip)">
|
||||
<g
|
||||
ref={landRef}
|
||||
fill="hsl(30 55% 52%)"
|
||||
stroke="hsl(28 50% 40% / 0.25)"
|
||||
strokeWidth="0.3"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{LANDMASSES.map((_, i) => (
|
||||
<polygon key={i} opacity={0} />
|
||||
))}
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Warm highlight + rim shading sit above the land so the sphere
|
||||
still reads as a lit ball, not a flat map. */}
|
||||
<circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="url(#hero-globe-highlight)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
<circle
|
||||
cx={CENTER}
|
||||
cy={CENTER}
|
||||
r={RADIUS}
|
||||
fill="url(#hero-globe-rim)"
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
{/* Campaign markers — a small heart glyph with a warm glow halo.
|
||||
Each marker is a button: clicking selects the campaign, which
|
||||
the parent uses to populate the spotlight card.
|
||||
|
||||
On the Discover page the same `<g>` slots are reused for
|
||||
community and country-pulse markers, distinguished by `m.kind`
|
||||
and rendered with a softer glyph + halo so campaigns stay the
|
||||
visual lead. */}
|
||||
<g ref={markersRef}>
|
||||
{markers.map((m) => {
|
||||
const isSelected = m.key === selectedKey;
|
||||
const kind: GlobeMarkerKind = m.kind ?? 'campaign';
|
||||
return (
|
||||
<g
|
||||
key={m.key}
|
||||
opacity={0}
|
||||
transform="translate(-1000 -1000)"
|
||||
role={onMarkerClick ? 'button' : undefined}
|
||||
tabIndex={onMarkerClick ? 0 : undefined}
|
||||
aria-label={m.label ?? 'View campaign'}
|
||||
aria-pressed={onMarkerClick ? isSelected : undefined}
|
||||
onClick={onMarkerClick ? () => onMarkerClick(m.key) : undefined}
|
||||
onKeyDown={
|
||||
onMarkerClick
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onMarkerClick(m.key);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
cursor: onMarkerClick ? 'pointer' : undefined,
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{kind === 'campaign' ? (
|
||||
<>
|
||||
{/* Glow halo (stronger for the active marker). */}
|
||||
<circle
|
||||
r={isSelected ? 16 : 12}
|
||||
fill={`url(#hero-marker-glow${isSelected ? '-active' : ''})`}
|
||||
/>
|
||||
{/* Heart glyph. Path is centered at the origin (~14×12 units)
|
||||
so the parent <g>'s translate+scale lands it on the globe. */}
|
||||
<path
|
||||
d="M0,3.5 C-3.5,1 -7,-1.5 -7,-4.5 C-7,-7 -5,-8.5 -3,-8.5 C-1.5,-8.5 -0.5,-7.5 0,-6.5 C0.5,-7.5 1.5,-8.5 3,-8.5 C5,-8.5 7,-7 7,-4.5 C7,-1.5 3.5,1 0,3.5 Z"
|
||||
fill="hsl(var(--primary))"
|
||||
stroke="hsl(40 100% 98%)"
|
||||
strokeWidth="0.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Tiny inner highlight to make the heart pop on the warm
|
||||
landmass without needing a heavy outline. */}
|
||||
<ellipse cx={-2.5} cy={-5.5} rx={1.5} ry={1} fill="hsl(40 100% 98% / 0.55)" />
|
||||
</>
|
||||
) : kind === 'community' ? (
|
||||
<>
|
||||
{/* Community: a softly-glowing ring. Reads as a circle of
|
||||
people, gathered. Smaller than the heart so campaigns
|
||||
stay the dominant signal. */}
|
||||
<circle r={10} fill="url(#hero-marker-glow)" />
|
||||
<circle
|
||||
r={4.2}
|
||||
fill="hsl(40 100% 96% / 0.92)"
|
||||
stroke="hsl(28 65% 45% / 0.55)"
|
||||
strokeWidth="0.7"
|
||||
/>
|
||||
<circle r={1.4} fill="hsl(28 70% 50%)" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Country pulse: tiny warm sun-dot, no halo button feel.
|
||||
These are decorative — they trace where the world is
|
||||
currently posting without inviting interaction. */}
|
||||
<circle r={6} fill="url(#hero-marker-glow)" opacity={0.65} />
|
||||
<circle r={1.8} fill="hsl(38 100% 70%)" />
|
||||
</>
|
||||
)}
|
||||
{/* Transparent hit target — much easier to click/tap than the
|
||||
tiny visible glyph, especially on touch. */}
|
||||
<circle
|
||||
r={14}
|
||||
fill="transparent"
|
||||
style={{ cursor: onMarkerClick ? 'pointer' : 'default' }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { memo, useId, useMemo } from 'react';
|
||||
|
||||
import { LAND_RINGS } from '@/lib/landPolygons';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Decorative dark world map with glowing brand-orange Lightning arcs and
|
||||
* pulsing city nodes. Designed as a hero backdrop on near-black surfaces:
|
||||
* type sits comfortably over it without any text shadow.
|
||||
*
|
||||
* Composition (back to front):
|
||||
* 1. Equirectangular world map drawn from {@link LAND_RINGS} — barely
|
||||
* lit so it reads as texture, not focus.
|
||||
* 2. Central radial glow tinted in brand orange behind the visual
|
||||
* center of gravity.
|
||||
* 3. Curated set of arcs between major cities, drawn as quadratic
|
||||
* Bézier paths with a flowing dash animation (the "lightning" hops).
|
||||
* 4. Pulsing dot at every endpoint, with a soft halo.
|
||||
*
|
||||
* The data is intentionally curated — no campaign coupling. The map is a
|
||||
* brand visual, not a state visualization. Arc list lives at the bottom
|
||||
* of this file and can be swapped freely without touching layout.
|
||||
*
|
||||
* Pure SVG, no WebGL, no canvas. ~12 arcs + ~150 polygons — render cost
|
||||
* is negligible. Animations honor `prefers-reduced-motion`.
|
||||
*/
|
||||
function HeroLightningMapImpl({ className }: { className?: string }) {
|
||||
const uid = useId();
|
||||
const arcId = (key: string) => `${uid}-${key}`;
|
||||
|
||||
// viewBox is the equirectangular world: 360 wide × 180 tall, recentered
|
||||
// so (0,0) is the geographic origin. We project [lng, lat] -> [lng, -lat]
|
||||
// (SVG y grows downward).
|
||||
const W = 360;
|
||||
const H = 180;
|
||||
|
||||
const landPaths = useMemo(() => {
|
||||
return LAND_RINGS.map((ring, idx) => {
|
||||
// Rings are flat [lng, lat, lng, lat, ...]. Convert to an SVG path.
|
||||
//
|
||||
// Antimeridian handling: a few rings (notably Russia and Antarctica
|
||||
// in the Natural Earth source) cross the ±180° seam. The data stores
|
||||
// those rings as a single polygon whose longitude jumps from +180 to
|
||||
// -180 (or vice versa) in one step. Drawn naively with a continuous
|
||||
// `L` command, that jump renders as a long horizontal slash spanning
|
||||
// the whole equirectangular viewBox — the "two lines" sitting at
|
||||
// ~lat 41 and ~lat 77 across the map are exactly Russia's bounding
|
||||
// edges drawn by such a connection.
|
||||
//
|
||||
// Detect any longitude step > 180° and close + restart the subpath
|
||||
// with `M` instead, so the two halves of the country render in their
|
||||
// actual hemispheres without a connecting line through the middle.
|
||||
let d = '';
|
||||
let prevLng: number | null = null;
|
||||
for (let i = 0; i < ring.length; i += 2) {
|
||||
const lng = ring[i];
|
||||
const lat = ring[i + 1];
|
||||
const isFirst = i === 0;
|
||||
const wraps = prevLng !== null && Math.abs(lng - prevLng) > 180;
|
||||
const cmd = isFirst || wraps ? 'M' : 'L';
|
||||
d += `${cmd}${lng.toFixed(2)} ${(-lat).toFixed(2)}`;
|
||||
prevLng = lng;
|
||||
}
|
||||
d += 'Z';
|
||||
return <path key={idx} d={d} />;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// All endpoints across the curated arc set, deduplicated, so we render
|
||||
// one pulsing node per city even if multiple arcs share it.
|
||||
const nodes = useMemo(() => {
|
||||
const seen = new Map<string, { lng: number; lat: number }>();
|
||||
for (const arc of CURATED_ARCS) {
|
||||
const a = `${arc.from[0]},${arc.from[1]}`;
|
||||
const b = `${arc.to[0]},${arc.to[1]}`;
|
||||
if (!seen.has(a)) seen.set(a, { lng: arc.from[0], lat: arc.from[1] });
|
||||
if (!seen.has(b)) seen.set(b, { lng: arc.to[0], lat: arc.to[1] });
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('absolute inset-0 overflow-hidden pointer-events-none', className)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Central radial brand-orange glow. Sits behind the map texture so
|
||||
the map reads as illuminated by it, not pasted over it. Position
|
||||
biased slightly right so the headline column on the left stays
|
||||
on the cooler side of the glow. */}
|
||||
<div
|
||||
className="absolute -inset-[10%]"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(60% 55% at 62% 45%, hsl(24 100% 55% / 0.12) 0%, hsl(24 95% 50% / 0.07) 28%, hsl(220 30% 8% / 0) 65%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<svg
|
||||
viewBox={`-${W / 2} -${H / 2} ${W} ${H}`}
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
className="absolute inset-0 w-full h-full"
|
||||
>
|
||||
<defs>
|
||||
{/* Land fill — brand-orange wash, fully opaque. The transparency
|
||||
lives on the wrapping <g opacity=…> below so that overlapping
|
||||
country polygons don't stack their alpha at shared borders
|
||||
(which is what painted the visible "latitude line" along the
|
||||
equator and other country seams). */}
|
||||
<linearGradient id={arcId('land')} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(24 80% 50%)" />
|
||||
<stop offset="100%" stopColor="hsl(24 70% 45%)" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Arc gradient — bright at midpoint, fading at endpoints, so
|
||||
the line reads as energy traveling rather than a solid wire. */}
|
||||
<linearGradient id={arcId('arc')} x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="hsl(24 100% 60%)" stopOpacity="0.0" />
|
||||
<stop offset="35%" stopColor="hsl(24 100% 60%)" stopOpacity="0.85" />
|
||||
<stop offset="65%" stopColor="hsl(30 100% 65%)" stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor="hsl(30 100% 65%)" stopOpacity="0.0" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Glow filter for arcs and nodes — wider and softer than a CSS
|
||||
shadow, and crucially, applied inside the SVG so it scales
|
||||
cleanly with the viewBox. */}
|
||||
<filter id={arcId('glow')} x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="1.2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Stronger glow for the nodes themselves so they punch through
|
||||
the arcs at intersections. */}
|
||||
<radialGradient id={arcId('node-halo')}>
|
||||
<stop offset="0%" stopColor="hsl(30 100% 70%)" stopOpacity="0.9" />
|
||||
<stop offset="40%" stopColor="hsl(24 100% 55%)" stopOpacity="0.45" />
|
||||
<stop offset="100%" stopColor="hsl(24 100% 50%)" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* Land. Each country is its own ring; rendered as separate paths
|
||||
with semi-transparent fill, every shared country border doubles
|
||||
up where polygons overlap. The most jarring of those overlaps
|
||||
falls along the equator (Kenya/Tanzania, DRC/Angola, Indonesian
|
||||
islands) and reads as a horizontal "latitude line."
|
||||
|
||||
Fix: paint each country with a fully-opaque fill, then put the
|
||||
transparency on the wrapping <g opacity=…>. SVG group opacity
|
||||
renders the children into an offscreen buffer first and then
|
||||
composites the buffer at the group's alpha, so internal overlaps
|
||||
don't stack. No stroke for the same reason. */}
|
||||
<g
|
||||
fill={`url(#${arcId('land')})`}
|
||||
stroke="none"
|
||||
opacity="0.18"
|
||||
>
|
||||
{landPaths}
|
||||
</g>
|
||||
|
||||
{/* Arcs. Each arc is a quadratic Bézier with the control point
|
||||
lifted above the great-circle path, giving the curved silhouette
|
||||
from the reference. Stroke-dasharray + animated stroke-dashoffset
|
||||
produces the flowing-energy effect.
|
||||
|
||||
`vector-effect="non-scaling-stroke"` keeps the stroke at a fixed
|
||||
pixel width regardless of viewBox-to-screen scale, which is what
|
||||
kills the line jitter — without it, sub-pixel stroke widths in
|
||||
user-space combine with the SVG glow filter to shimmer at any
|
||||
responsive size. */}
|
||||
<g
|
||||
fill="none"
|
||||
stroke={`url(#${arcId('arc')})`}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
filter={`url(#${arcId('glow')})`}
|
||||
>
|
||||
{CURATED_ARCS.map((arc, i) => {
|
||||
const [x1, y1] = [arc.from[0], -arc.from[1]];
|
||||
const [x2, y2] = [arc.to[0], -arc.to[1]];
|
||||
// Lift the control point above the chord, scaled with chord
|
||||
// length so short arcs stay tight and trans-oceanic arcs
|
||||
// sweep dramatically.
|
||||
const len = Math.hypot(x2 - x1, y2 - y1);
|
||||
const lift = Math.min(60, len * 0.42);
|
||||
const mx = (x1 + x2) / 2;
|
||||
const my = (y1 + y2) / 2;
|
||||
// Push the lift toward whichever hemisphere the chord midpoint
|
||||
// already favors, so equator-crossing arcs sweep clearly into
|
||||
// their dominant hemisphere instead of all stacking through
|
||||
// y=0. Pure equatorial midpoints (my≈0) default to lifting
|
||||
// north (negative y in SVG space).
|
||||
const direction = my > 0 ? 1 : -1;
|
||||
const cx = mx;
|
||||
const cy = my + lift * direction;
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={`M${x1.toFixed(2)} ${y1.toFixed(2)} Q${cx.toFixed(2)} ${cy.toFixed(2)} ${x2.toFixed(2)} ${y2.toFixed(2)}`}
|
||||
className="hero-arc-flow"
|
||||
style={{
|
||||
// Stagger each arc's animation so the flow feels
|
||||
// organic, not lockstep.
|
||||
animationDelay: `${(i * 0.43).toFixed(2)}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* City nodes. Two layers per node: a soft halo behind, a hot dot
|
||||
in front. Halo is what reads at distance; dot is what reads up
|
||||
close. */}
|
||||
<g>
|
||||
{nodes.map((n, i) => {
|
||||
const x = n.lng;
|
||||
const y = -n.lat;
|
||||
return (
|
||||
<g key={i}>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="2.2"
|
||||
fill={`url(#${arcId('node-halo')})`}
|
||||
className="hero-node-pulse"
|
||||
style={{ animationDelay: `${(i * 0.31).toFixed(2)}s` }}
|
||||
/>
|
||||
<circle
|
||||
cx={x}
|
||||
cy={y}
|
||||
r="0.55"
|
||||
fill="hsl(36 100% 70%)"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const HeroLightningMap = memo(HeroLightningMapImpl);
|
||||
|
||||
/**
|
||||
* Hand-picked arcs between major activist hubs across continents. Order
|
||||
* matters only for the staggered animation start times — pairs are
|
||||
* otherwise independent. Coordinates are `[lng, lat]` in degrees.
|
||||
*
|
||||
* Edit freely. Twelve arcs is roughly the sweet spot — fewer feels
|
||||
* sparse, more turns into a tangle that competes with the headline.
|
||||
*/
|
||||
const CURATED_ARCS: ReadonlyArray<{
|
||||
from: readonly [number, number];
|
||||
to: readonly [number, number];
|
||||
}> = [
|
||||
// Trans-Atlantic — North America ↔ Europe / Africa
|
||||
{ from: [-74.0, 40.7], to: [-0.1, 51.5] }, // New York → London
|
||||
{ from: [-122.4, 37.8], to: [13.4, 52.5] }, // San Francisco → Berlin
|
||||
{ from: [-79.4, 43.7], to: [2.35, 48.9] }, // Toronto → Paris
|
||||
{ from: [-0.1, 51.5], to: [3.4, 6.5] }, // London → Lagos
|
||||
// Trans-Pacific — Americas ↔ Asia / Oceania
|
||||
{ from: [-118.2, 34.0], to: [139.7, 35.7] }, // Los Angeles → Tokyo
|
||||
{ from: [-99.1, 19.4], to: [121.5, 25.0] }, // Mexico City → Taipei
|
||||
{ from: [151.2, -33.9], to: [-122.4, 37.8] }, // Sydney → San Francisco
|
||||
// South America bridges
|
||||
{ from: [-58.4, -34.6], to: [-43.2, -22.9] }, // Buenos Aires → Rio
|
||||
{ from: [-43.2, -22.9], to: [3.4, 6.5] }, // Rio → Lagos
|
||||
// Asia / Africa lattice
|
||||
{ from: [77.2, 28.6], to: [55.3, 25.3] }, // Delhi → Dubai
|
||||
{ from: [55.3, 25.3], to: [31.2, 30.0] }, // Dubai → Cairo
|
||||
{ from: [31.2, 30.0], to: [13.4, 52.5] }, // Cairo → Berlin
|
||||
{ from: [103.8, 1.35], to: [121.5, 25.0] }, // Singapore → Taipei
|
||||
{ from: [18.4, -33.9], to: [3.4, 6.5] }, // Cape Town → Lagos
|
||||
];
|
||||
@@ -1,25 +0,0 @@
|
||||
interface IntroImageProps {
|
||||
src: string;
|
||||
/** Tailwind size class, e.g. "w-40" (default) or "w-10" */
|
||||
size?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IntroImage({ src, size = 'w-40', className }: IntroImageProps) {
|
||||
return (
|
||||
<div
|
||||
className={`${size} shrink-0 bg-primary opacity-90 ${className ?? ''}`}
|
||||
style={{
|
||||
maskImage: `url(${src})`,
|
||||
maskSize: 'contain',
|
||||
maskRepeat: 'no-repeat',
|
||||
maskPosition: 'center',
|
||||
WebkitMaskImage: `url(${src})`,
|
||||
WebkitMaskSize: 'contain',
|
||||
WebkitMaskRepeat: 'no-repeat',
|
||||
WebkitMaskPosition: 'center',
|
||||
aspectRatio: '1 / 1',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,8 @@ import { LeftSidebar } from '@/components/LeftSidebar';
|
||||
import { MobileTopBar } from '@/components/MobileTopBar';
|
||||
import { MobileDrawer } from '@/components/MobileDrawer';
|
||||
import { FloatingComposeButton } from '@/components/FloatingComposeButton';
|
||||
import { CursorFireEffect } from '@/components/CursorFireEffect';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { CenterColumnContext, DrawerContext, LayoutStore, LayoutStoreContext, NavHiddenContext, useLayoutSnapshot } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useScrollDirection } from '@/hooks/useScrollDirection';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -55,15 +53,11 @@ function MainLayoutInner() {
|
||||
const openDrawer = useCallback(() => setDrawerOpen(true), []);
|
||||
const centerColumnRef = useRef<HTMLDivElement>(null);
|
||||
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
|
||||
const { config } = useAppContext();
|
||||
const { hidden: navHidden } = useScrollDirection(scrollContainer);
|
||||
return (
|
||||
<CenterColumnContext.Provider value={centerColumnEl}>
|
||||
<DrawerContext.Provider value={openDrawer}>
|
||||
<NavHiddenContext.Provider value={navHidden}>
|
||||
{/* Magic Mouse fire particle overlay */}
|
||||
{config.magicMouse && <CursorFireEffect />}
|
||||
|
||||
{/* Mobile top bar - only on small screens, hidden when page requests immersive mode */}
|
||||
{!hideTopBar && <MobileTopBar onAvatarClick={() => setDrawerOpen(true)} hasSubHeader={hasSubHeader} />}
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ export function MobileBottomNav() {
|
||||
{/* Organizations */}
|
||||
<NavItem
|
||||
icon={Users}
|
||||
label="Organize"
|
||||
label="Groups"
|
||||
active={isOnCommunities}
|
||||
to="/communities"
|
||||
onClick={() => { selectionChanged(); setSearchOpen(false); }}
|
||||
|
||||
@@ -83,6 +83,12 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
if (def.requiresAdmin && !isAdmin(user?.pubkey)) return false;
|
||||
return true;
|
||||
});
|
||||
// Phase 1b: when logged out, ensure Help is visible in the main menu.
|
||||
// Logged-in users access Help via the account menu (AccountSwitcher),
|
||||
// but logged-out users have no equivalent affordance — surface it here.
|
||||
if (!user && !filtered.includes('help')) {
|
||||
filtered.push('help');
|
||||
}
|
||||
// Phase 2: remove leading, trailing, and consecutive dividers.
|
||||
return filtered.filter((id, i, arr) => {
|
||||
if (id !== 'divider') return true;
|
||||
@@ -98,6 +104,9 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
const def = getSidebarItem(item.id);
|
||||
if (!def) return true;
|
||||
if (def.requiresAdmin && !isAdmin(user?.pubkey)) return false;
|
||||
// When logged out, Help is forced into the main list, so hide it
|
||||
// from the "More…" menu to avoid duplication.
|
||||
if (!user && item.id === 'help') return false;
|
||||
return true;
|
||||
});
|
||||
}, [hiddenItems, user]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
FileText,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
HandHeart,
|
||||
Mail,
|
||||
Megaphone,
|
||||
MessageCircle,
|
||||
@@ -33,13 +34,14 @@ import {
|
||||
} from "@/components/AudioKindContent";
|
||||
import { ActionContent } from "@/components/ActionContent";
|
||||
import { BadgeContent } from "@/components/BadgeContent";
|
||||
import { CampaignNoteCardContent } from "@/components/CampaignNoteCardContent";
|
||||
import { CommunityContent } from "@/components/CommunityContent";
|
||||
import { CalendarEventContent } from "@/components/CalendarEventContent";
|
||||
import {
|
||||
ColorMomentContent,
|
||||
ColorMomentEyeButton,
|
||||
} from "@/components/ColorMomentContent";
|
||||
import { CommentContext, CountryCommentPill, CountryFlagBackdrop } from "@/components/CommentContext";
|
||||
import { CommentContext, CountryCommentPill } from "@/components/CommentContext";
|
||||
import { CommunityContentWarning } from "@/components/CommunityContentWarning";
|
||||
import { ContentWarningGuard } from "@/components/ContentWarningGuard";
|
||||
import { EmojifiedText, ReactionEmoji } from "@/components/CustomEmoji";
|
||||
@@ -58,7 +60,6 @@ import { ChestIcon } from "@/components/icons/ChestIcon";
|
||||
import { RepostIcon } from "@/components/icons/RepostIcon";
|
||||
import { LiveStreamPlayer } from "@/components/LiveStreamPlayer";
|
||||
import { MagicDeckContent } from "@/components/MagicDeckContent";
|
||||
import { Nip05Badge } from "@/components/Nip05Badge";
|
||||
import { NoteContent } from "@/components/NoteContent";
|
||||
import { NoteMoreMenu } from "@/components/NoteMoreMenu";
|
||||
import { PatchCard } from "@/components/PatchCard";
|
||||
@@ -86,7 +87,6 @@ import { ZapDialog } from "@/components/ZapDialog";
|
||||
import { useAppContext } from "@/hooks/useAppContext";
|
||||
import { useAuthor } from "@/hooks/useAuthor";
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useNip05Verify } from "@/hooks/useNip05Verify";
|
||||
import { useOpenPost } from "@/hooks/useOpenPost";
|
||||
import { useProfileUrl } from "@/hooks/useProfileUrl";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
@@ -398,11 +398,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
const metadata = author.data?.metadata;
|
||||
const actionMetadata = actionEvent ? actionAuthor.data?.metadata : metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const nip05 = metadata?.nip05;
|
||||
const { data: nip05Verified, isPending: nip05Pending } = useNip05Verify(
|
||||
nip05,
|
||||
event.pubkey,
|
||||
);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
const encodedId = useMemo(() => encodeEventId(actionTarget), [actionTarget]);
|
||||
const { data: stats } = useEventStats(actionTarget.id, actionTarget);
|
||||
@@ -474,6 +469,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
const isCommunity = event.kind === 34550;
|
||||
const isZapGoal = event.kind === 9041;
|
||||
const isAction = event.kind === 36639;
|
||||
const isCampaign = event.kind === 33863;
|
||||
const isReaction = event.kind === 7;
|
||||
const isPollVote = event.kind === 1018;
|
||||
const isRepost = event.kind === 6 || event.kind === 16;
|
||||
@@ -520,6 +516,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
!isCommunity &&
|
||||
!isZapGoal &&
|
||||
!isAction &&
|
||||
!isCampaign &&
|
||||
!isReaction &&
|
||||
!isPollVote &&
|
||||
!isRepost &&
|
||||
@@ -683,6 +680,9 @@ export const NoteCard = memo(function NoteCard({
|
||||
) : isAction ? (
|
||||
<ActionContent event={event} />
|
||||
|
||||
) : isCampaign ? (
|
||||
<CampaignNoteCardContent event={event} />
|
||||
|
||||
) : isVoiceMessage ? (
|
||||
<VoiceMessagePlayer event={event} />
|
||||
) : isCalendarEvent ? (
|
||||
@@ -776,12 +776,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground min-w-0 pr-2">
|
||||
{nip05 && nip05Pending && <Skeleton className="h-3 w-24" />}
|
||||
{nip05 && nip05Pending && <span className="shrink-0">·</span>}
|
||||
{nip05 && nip05Verified && (
|
||||
<Nip05Badge nip05={nip05} pubkey={event.pubkey} />
|
||||
)}
|
||||
{nip05 && nip05Verified && <span className="shrink-0">·</span>}
|
||||
<span className="shrink-0 hover:underline whitespace-nowrap">
|
||||
{timeAgo(event.created_at)}
|
||||
</span>
|
||||
@@ -1111,9 +1105,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<CountryFlagBackdrop event={event} />
|
||||
{/* Foreground wrapper — `relative` lifts the entire post above the
|
||||
absolute backdrop layer rendered by CountryFlagBackdrop. */}
|
||||
<div className="relative">
|
||||
{threadedKindHeader && (
|
||||
<div>
|
||||
@@ -1164,9 +1155,6 @@ export const NoteCard = memo(function NoteCard({
|
||||
onClick={handleCardClick}
|
||||
onAuxClick={handleAuxClick}
|
||||
>
|
||||
<CountryFlagBackdrop event={event} />
|
||||
{/* Foreground wrapper — `relative` lifts the entire post above the
|
||||
absolute backdrop layer rendered by CountryFlagBackdrop. */}
|
||||
<div className="relative">
|
||||
<div>
|
||||
{/* Action header — repost takes priority, otherwise derived from event kind */}
|
||||
@@ -1206,17 +1194,13 @@ export const NoteCard = memo(function NoteCard({
|
||||
})()
|
||||
)}
|
||||
|
||||
{/* Header: avatar + name/handle stacked. The country pill is
|
||||
appended outside this flag-mode wrapper as a flex sibling, so
|
||||
it keeps its own surface treatment. */}
|
||||
{/* Header: avatar + name/handle with the country pill anchored
|
||||
right. The pill is a flex sibling of the author row so it
|
||||
keeps its own surface treatment regardless of context. */}
|
||||
<div className="flex items-center gap-3">
|
||||
{avatarElement}
|
||||
{authorInfo}
|
||||
{isColor && <ColorMomentEyeButton event={event} />}
|
||||
{/* Country pill — rendered outside the flag-mode color flip via
|
||||
`[&]:` to escape the parent's color rules. It's wrapped in
|
||||
its own flex slot so the row layout matches the non-flag
|
||||
case (pill anchored right). */}
|
||||
<CountryCommentPill
|
||||
event={event}
|
||||
className="shrink-0 [text-shadow:none]"
|
||||
@@ -1814,6 +1798,12 @@ const KIND_HEADER_MAP: Record<number, KindHeaderConfig> = {
|
||||
noun: "organization",
|
||||
nounRoute: "/communities",
|
||||
},
|
||||
33863: {
|
||||
icon: HandHeart,
|
||||
action: (event) => publishedAtAction(event, { created: "launched a", updated: "updated a", fallback: "shared a" }),
|
||||
noun: "campaign",
|
||||
nounRoute: "/campaigns/all",
|
||||
},
|
||||
30009: {
|
||||
icon: Award,
|
||||
action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "created a" }),
|
||||
|
||||
@@ -96,7 +96,7 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont
|
||||
const { capability } = useBitcoinSigner();
|
||||
const { logins } = useNostrLogin();
|
||||
const { config } = useAppContext();
|
||||
const { esploraBaseUrl } = config;
|
||||
const { esploraApis } = config;
|
||||
const loginType = logins[0]?.type;
|
||||
|
||||
const [usdAmount, setUsdAmount] = useState<number | string>(5);
|
||||
@@ -117,21 +117,21 @@ export function OnchainZapContent({ target, onSuccess, onClose }: OnchainZapCont
|
||||
: '';
|
||||
|
||||
const { data: btcPrice } = useQuery({
|
||||
queryKey: ['btc-price', esploraBaseUrl],
|
||||
queryFn: () => fetchBtcPrice(esploraBaseUrl),
|
||||
queryKey: ['btc-price', esploraApis],
|
||||
queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: utxos } = useQuery({
|
||||
queryKey: ['bitcoin-utxos', esploraBaseUrl, senderAddress],
|
||||
queryFn: () => fetchUTXOs(senderAddress, esploraBaseUrl),
|
||||
queryKey: ['bitcoin-utxos', esploraApis, senderAddress],
|
||||
queryFn: ({ signal }) => fetchUTXOs(senderAddress, esploraApis, signal),
|
||||
enabled: !!senderAddress && capability !== 'unsupported',
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: feeRates } = useQuery({
|
||||
queryKey: ['bitcoin-fee-rates', esploraBaseUrl],
|
||||
queryFn: () => getFeeRates(esploraBaseUrl),
|
||||
queryKey: ['bitcoin-fee-rates', esploraApis],
|
||||
queryFn: ({ signal }) => getFeeRates(esploraApis, signal),
|
||||
enabled: capability !== 'unsupported',
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Pin } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface PinnedCommentHeaderProps {
|
||||
isPinned: boolean;
|
||||
canManagePins: boolean;
|
||||
pinPending: boolean;
|
||||
onTogglePin: () => void;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function PinnedCommentHeader({
|
||||
isPinned,
|
||||
canManagePins,
|
||||
pinPending,
|
||||
onTogglePin,
|
||||
children,
|
||||
}: PinnedCommentHeaderProps) {
|
||||
if (!isPinned && !canManagePins && !children) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 px-4 pt-3 pb-0 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isPinned && (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2 py-0.5 font-medium text-primary">
|
||||
<Pin className="size-3 rotate-45 fill-current" />
|
||||
Pinned
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{canManagePins && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePin();
|
||||
}}
|
||||
disabled={pinPending}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-medium transition-colors hover:bg-primary/10 hover:text-primary disabled:cursor-not-allowed disabled:opacity-60',
|
||||
isPinned && 'text-primary',
|
||||
)}
|
||||
>
|
||||
<Pin className={cn('size-3 rotate-45', isPinned && 'fill-current')} />
|
||||
{isPinned ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { useEffect, useRef, memo } from 'react';
|
||||
|
||||
import type { PrecipitationIntensity, PrecipitationType } from '@/hooks/useWeather';
|
||||
|
||||
interface PrecipitationEffectProps {
|
||||
type: PrecipitationType;
|
||||
intensity: PrecipitationIntensity;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Particle pool sizes by intensity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const RAIN_COUNT: Record<PrecipitationIntensity, number> = {
|
||||
light: 80,
|
||||
moderate: 160,
|
||||
heavy: 280,
|
||||
};
|
||||
|
||||
const SNOW_COUNT: Record<PrecipitationIntensity, number> = {
|
||||
light: 50,
|
||||
moderate: 100,
|
||||
heavy: 180,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raindrop particle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface RainDrop {
|
||||
x: number;
|
||||
y: number;
|
||||
speed: number;
|
||||
length: number;
|
||||
opacity: number;
|
||||
drift: number;
|
||||
}
|
||||
|
||||
function createRainDrop(w: number, h: number, intensity: PrecipitationIntensity): RainDrop {
|
||||
const speedBase = intensity === 'heavy' ? 14 : intensity === 'moderate' ? 10 : 7;
|
||||
const speedRange = intensity === 'heavy' ? 8 : intensity === 'moderate' ? 5 : 3;
|
||||
return {
|
||||
x: Math.random() * (w + 100) - 50,
|
||||
y: Math.random() * h * -1 - 20,
|
||||
speed: speedBase + Math.random() * speedRange,
|
||||
length: intensity === 'heavy' ? 18 + Math.random() * 12 : 10 + Math.random() * 10,
|
||||
opacity: 0.15 + Math.random() * 0.2,
|
||||
drift: intensity === 'heavy' ? 1.5 + Math.random() : 0.5 + Math.random() * 0.8,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snowflake particle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SnowFlake {
|
||||
x: number;
|
||||
y: number;
|
||||
speed: number;
|
||||
radius: number;
|
||||
opacity: number;
|
||||
wobbleAmp: number;
|
||||
wobbleFreq: number;
|
||||
wobblePhase: number;
|
||||
}
|
||||
|
||||
function createSnowFlake(w: number, h: number, intensity: PrecipitationIntensity): SnowFlake {
|
||||
const sizeBase = intensity === 'heavy' ? 2.5 : intensity === 'moderate' ? 2 : 1.5;
|
||||
const sizeRange = intensity === 'heavy' ? 3 : intensity === 'moderate' ? 2.5 : 2;
|
||||
return {
|
||||
x: Math.random() * (w + 60) - 30,
|
||||
y: Math.random() * h * -1 - 10,
|
||||
speed: 0.5 + Math.random() * (intensity === 'heavy' ? 1.8 : intensity === 'moderate' ? 1.2 : 0.8),
|
||||
radius: sizeBase + Math.random() * sizeRange,
|
||||
opacity: 0.4 + Math.random() * 0.4,
|
||||
wobbleAmp: 0.3 + Math.random() * 0.8,
|
||||
wobbleFreq: 0.01 + Math.random() * 0.02,
|
||||
wobblePhase: Math.random() * Math.PI * 2,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canvas precipitation renderer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PrecipitationEffect = memo(function PrecipitationEffect({
|
||||
type,
|
||||
intensity,
|
||||
}: PrecipitationEffectProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rafRef = useRef(0);
|
||||
const rainDrops = useRef<RainDrop[]>([]);
|
||||
const snowFlakes = useRef<SnowFlake[]>([]);
|
||||
const frameRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || !type) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
function resize() {
|
||||
if (!canvas) return;
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
}
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// Initialize particle pools
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
if (type === 'rain') {
|
||||
const count = RAIN_COUNT[intensity];
|
||||
rainDrops.current = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const drop = createRainDrop(w, h, intensity);
|
||||
// Scatter initial y positions across the screen for instant coverage
|
||||
drop.y = Math.random() * h;
|
||||
rainDrops.current.push(drop);
|
||||
}
|
||||
} else {
|
||||
const count = SNOW_COUNT[intensity];
|
||||
snowFlakes.current = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const flake = createSnowFlake(w, h, intensity);
|
||||
flake.y = Math.random() * h;
|
||||
snowFlakes.current.push(flake);
|
||||
}
|
||||
}
|
||||
|
||||
function drawRain() {
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
for (const drop of rainDrops.current) {
|
||||
drop.y += drop.speed;
|
||||
drop.x += drop.drift;
|
||||
|
||||
// Reset when off screen
|
||||
if (drop.y > canvas.height + 20) {
|
||||
drop.y = -drop.length - Math.random() * 40;
|
||||
drop.x = Math.random() * (canvas.width + 100) - 50;
|
||||
}
|
||||
if (drop.x > canvas.width + 50) {
|
||||
drop.x = -50;
|
||||
}
|
||||
|
||||
// Draw the raindrop as a thin line with a subtle glow
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(drop.x, drop.y);
|
||||
ctx.lineTo(drop.x + drop.drift * 0.5, drop.y + drop.length);
|
||||
ctx.strokeStyle = `rgba(174, 194, 224, ${drop.opacity})`;
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(drawRain);
|
||||
}
|
||||
|
||||
function drawSnow() {
|
||||
if (!canvas || !ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
frameRef.current++;
|
||||
|
||||
for (const flake of snowFlakes.current) {
|
||||
flake.y += flake.speed;
|
||||
flake.x += Math.sin(frameRef.current * flake.wobbleFreq + flake.wobblePhase) * flake.wobbleAmp;
|
||||
|
||||
// Reset when off screen
|
||||
if (flake.y > canvas.height + 10) {
|
||||
flake.y = -flake.radius * 2 - Math.random() * 30;
|
||||
flake.x = Math.random() * (canvas.width + 60) - 30;
|
||||
}
|
||||
if (flake.x > canvas.width + 30) {
|
||||
flake.x = -30;
|
||||
} else if (flake.x < -30) {
|
||||
flake.x = canvas.width + 30;
|
||||
}
|
||||
|
||||
// Draw the snowflake as a soft glowing circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(flake.x, flake.y, flake.radius, 0, Math.PI * 2);
|
||||
|
||||
const gradient = ctx.createRadialGradient(
|
||||
flake.x, flake.y, 0,
|
||||
flake.x, flake.y, flake.radius,
|
||||
);
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${flake.opacity})`);
|
||||
gradient.addColorStop(0.5, `rgba(230, 238, 255, ${flake.opacity * 0.6})`);
|
||||
gradient.addColorStop(1, `rgba(210, 225, 250, 0)`);
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
rafRef.current = requestAnimationFrame(drawSnow);
|
||||
}
|
||||
|
||||
if (type === 'rain') {
|
||||
rafRef.current = requestAnimationFrame(drawRain);
|
||||
} else {
|
||||
rafRef.current = requestAnimationFrame(drawSnow);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
rainDrops.current = [];
|
||||
snowFlakes.current = [];
|
||||
};
|
||||
}, [type, intensity]);
|
||||
|
||||
if (!type) return null;
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[100]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -95,6 +95,8 @@ export interface ProfileCardProps {
|
||||
onRemoveAvatar?: () => void;
|
||||
/** Show NIP-05 row (default true) */
|
||||
showNip05?: boolean;
|
||||
/** Show NIP-58 badge showcase row (default true). */
|
||||
showBadges?: boolean;
|
||||
/** When provided, render an editable profile fields section below bio */
|
||||
extraFields?: ProfileField[];
|
||||
onExtraFieldsChange?: (fields: ProfileField[]) => void;
|
||||
@@ -107,6 +109,7 @@ export function ProfileCard({
|
||||
onPickImage,
|
||||
onRemoveAvatar,
|
||||
showNip05 = true,
|
||||
showBadges = true,
|
||||
extraFields,
|
||||
onExtraFieldsChange,
|
||||
}: ProfileCardProps) {
|
||||
@@ -332,7 +335,7 @@ export function ProfileCard({
|
||||
</div>
|
||||
|
||||
{/* Badge showcase */}
|
||||
{(badgeRefs.length > 0 || badgesLoading) && (
|
||||
{showBadges && (badgeRefs.length > 0 || badgesLoading) && (
|
||||
<div className="px-4 pb-3">
|
||||
<BadgeShowcaseGrid
|
||||
items={badgeRefs.map((ref) => ({
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { SmilePlus } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { QuickReactMenu } from '@/components/QuickReactMenu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useEmojiUsage } from '@/hooks/useEmojiUsage';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import { invalidateEventStats } from '@/lib/invalidateEventStats';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface ProfileReactionButtonProps {
|
||||
@@ -28,6 +31,9 @@ export function ProfileReactionButton({ profileEvent, className }: ProfileReacti
|
||||
const { mutate: publishEvent } = useNostrPublish();
|
||||
const { trackEmojiUsage } = useEmojiUsage();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const pickerExpandedRef = useRef(false);
|
||||
const justClosedRef = useRef(false);
|
||||
@@ -56,10 +62,23 @@ export function ProfileReactionButton({ profileEvent, className }: ProfileReacti
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Reaction sent!' });
|
||||
// Bump reaction count on the profile. Profile reactions target the
|
||||
// kind 0 event by id, so useNip85EventStats refresh covers it. Also
|
||||
// refresh the addressable `0:<pubkey>:` key in case any consumer
|
||||
// reads profile stats via useNip85AddrStats.
|
||||
setTimeout(() => {
|
||||
invalidateEventStats(queryClient, profileEvent, statsPubkey);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['nip85-addr-stats', `0:${profileEvent.pubkey}:`, statsPubkey],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['nip85-addr-stats', `0:${profileEvent.pubkey}:`],
|
||||
});
|
||||
}, 3000);
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [user, profileEvent, publishEvent, trackEmojiUsage, toast]);
|
||||
}, [user, profileEvent, publishEvent, trackEmojiUsage, toast, queryClient, statsPubkey]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
|
||||
@@ -82,6 +82,16 @@ interface ProfileRightSidebarProps {
|
||||
onMediaClick?: (url: string) => void;
|
||||
/** Override the root element's className (e.g. to show on mobile). */
|
||||
className?: string;
|
||||
/**
|
||||
* Layout variant.
|
||||
*
|
||||
* - `'rail'` (default) — legacy 1/4-width fixed sidebar with full-height
|
||||
* sticky scrolling. Designed for the old MainLayout shell.
|
||||
* - `'inline'` — fills its parent's width with no positioning, so it can
|
||||
* be slotted into a grid cell or another container that manages
|
||||
* layout (e.g. the ProfilePage two-column body).
|
||||
*/
|
||||
variant?: 'rail' | 'inline';
|
||||
}
|
||||
|
||||
interface MediaItem {
|
||||
@@ -495,7 +505,7 @@ function sidebarJustifiedLayout(items: MediaItem[]): { items: MediaItem[]; heigh
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }: ProfileRightSidebarProps) {
|
||||
export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className, variant = 'rail' }: ProfileRightSidebarProps) {
|
||||
const { config } = useAppContext();
|
||||
const { nostr } = useNostr();
|
||||
|
||||
@@ -534,8 +544,12 @@ export function ProfileRightSidebar({ fields, pubkey, onMediaClick, className }:
|
||||
|
||||
const sidebarRows = useMemo(() => sidebarJustifiedLayout(media), [media]);
|
||||
|
||||
const rootClass = variant === 'inline'
|
||||
? 'flex flex-col w-full'
|
||||
: 'w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3';
|
||||
|
||||
return (
|
||||
<aside className={cn("w-1/4 max-w-[300px] shrink-0 hidden lg:flex flex-col sticky top-0 h-screen overflow-y-auto pt-2 pb-3 px-3", className)}>
|
||||
<aside className={cn(rootClass, className)}>
|
||||
{/* Media Section — only shown when pubkey prop is provided */}
|
||||
{pubkey !== undefined && <section className="mb-6 bg-background/85 rounded-xl p-3 -mx-1">
|
||||
<h2 className="text-xl font-bold mb-3" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>Media</h2>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { EmojiPicker, type EmojiSelection } from '@/components/EmojiPicker';
|
||||
import { isCustomEmoji } from '@/lib/customEmoji';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEmojiUsage } from '@/hooks/useEmojiUsage';
|
||||
@@ -12,7 +13,8 @@ import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
import { invalidateEventStats } from '@/lib/invalidateEventStats';
|
||||
import type { Nip85EventStats } from '@/hooks/useNip85Stats';
|
||||
import type { ResolvedEmoji } from '@/lib/customEmoji';
|
||||
|
||||
interface QuickReactMenuProps {
|
||||
@@ -48,6 +50,12 @@ export function QuickReactMenu({
|
||||
const { user } = useCurrentUser();
|
||||
const { mutate: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
const statsKey = useMemo(
|
||||
() => ['nip85-event-stats', eventId, statsPubkey] as const,
|
||||
[eventId, statsPubkey],
|
||||
);
|
||||
const { trackEmojiUsage, getTopEmojis } = useEmojiUsage();
|
||||
const { feedSettings } = useFeedSettings();
|
||||
const { emojis: allCustomEmojis } = useCustomEmojis();
|
||||
@@ -104,14 +112,11 @@ export function QuickReactMenu({
|
||||
const resolvedEmoji: ResolvedEmoji = emojiTag
|
||||
? { content: displayEmoji, url: emojiTag[2], name: emojiTag[1] }
|
||||
: { content: displayEmoji };
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', eventId]);
|
||||
const prevStats = queryClient.getQueryData<Nip85EventStats | null>(statsKey);
|
||||
if (prevStats) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', eventId], {
|
||||
queryClient.setQueryData<Nip85EventStats | null>(statsKey, {
|
||||
...prevStats,
|
||||
reactions: prevStats.reactions + 1,
|
||||
reactionEmojis: prevStats.reactionEmojis.some((e) => e.content === displayEmoji)
|
||||
? prevStats.reactionEmojis
|
||||
: [...prevStats.reactionEmojis, resolvedEmoji],
|
||||
reactionCount: prevStats.reactionCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -136,20 +141,20 @@ export function QuickReactMenu({
|
||||
{
|
||||
onSuccess: () => {
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-stats', eventId] });
|
||||
invalidateEventStats(queryClient, eventId, statsPubkey);
|
||||
queryClient.invalidateQueries({ queryKey: ['event-interactions', eventId] });
|
||||
}, 3000);
|
||||
},
|
||||
onError: () => {
|
||||
setSelectedEmoji(null);
|
||||
if (prevStats) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', eventId], prevStats);
|
||||
queryClient.setQueryData<Nip85EventStats | null>(statsKey, prevStats);
|
||||
}
|
||||
queryClient.removeQueries({ queryKey: ['user-reaction', eventId] });
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [user, eventId, eventPubkey, eventKind, onReact, publishEvent, queryClient, trackEmojiUsage, onClose]);
|
||||
}, [user, eventId, eventPubkey, eventKind, onReact, publishEvent, queryClient, trackEmojiUsage, onClose, statsPubkey, statsKey]);
|
||||
|
||||
/** Handle selection from the quick buttons (native or custom emoji). */
|
||||
const handleQuickSelect = useCallback((emoji: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
@@ -6,13 +6,15 @@ import { useNostr } from '@nostrify/react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { QuickReactMenu } from '@/components/QuickReactMenu';
|
||||
import { RenderResolvedEmoji } from '@/components/CustomEmoji';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useUserReaction } from '@/hooks/useUserReaction';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import type { Nip85EventStats } from '@/hooks/useNip85Stats';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { impactLight } from '@/lib/haptics';
|
||||
import { invalidateEventStats } from '@/lib/invalidateEventStats';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
|
||||
interface ReactionButtonProps {
|
||||
/** The event ID being reacted to. */
|
||||
@@ -50,6 +52,12 @@ export function ReactionButton({
|
||||
const { nostr } = useNostr();
|
||||
const { mutate: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
const statsKey = useMemo(
|
||||
() => ['nip85-event-stats', eventId, statsPubkey] as const,
|
||||
[eventId, statsPubkey],
|
||||
);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const justClosedRef = useRef(false);
|
||||
@@ -76,14 +84,14 @@ export function ReactionButton({
|
||||
|
||||
// Snapshot for rollback
|
||||
const prevReaction = queryClient.getQueryData(['user-reaction', eventId]);
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', eventId]);
|
||||
const prevStats = queryClient.getQueryData<Nip85EventStats | null>(statsKey);
|
||||
|
||||
// Optimistic update: clear reaction and decrement count
|
||||
queryClient.setQueryData(['user-reaction', eventId], null);
|
||||
if (prevStats) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', eventId], {
|
||||
queryClient.setQueryData<Nip85EventStats | null>(statsKey, {
|
||||
...prevStats,
|
||||
reactions: Math.max(0, prevStats.reactions - 1),
|
||||
reactionCount: Math.max(0, prevStats.reactionCount - 1),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,7 +100,7 @@ export function ReactionButton({
|
||||
{
|
||||
onSuccess: () => {
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-stats', eventId] });
|
||||
invalidateEventStats(queryClient, eventId, statsPubkey);
|
||||
queryClient.invalidateQueries({ queryKey: ['event-interactions', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-reaction', eventId] });
|
||||
}, 3000);
|
||||
@@ -101,12 +109,12 @@ export function ReactionButton({
|
||||
// Rollback
|
||||
queryClient.setQueryData(['user-reaction', eventId], prevReaction);
|
||||
if (prevStats) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', eventId], prevStats);
|
||||
queryClient.setQueryData<Nip85EventStats | null>(statsKey, prevStats);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [user, nostr, eventId, publishEvent, queryClient]);
|
||||
}, [user, nostr, eventId, publishEvent, queryClient, statsPubkey, statsKey]);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (!user) return;
|
||||
@@ -163,12 +171,12 @@ export function ReactionButton({
|
||||
if (hasReacted) return;
|
||||
impactLight();
|
||||
setMenuOpen(false);
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', eventId]);
|
||||
const prevStats = queryClient.getQueryData<Nip85EventStats | null>(statsKey);
|
||||
queryClient.setQueryData(['user-reaction', eventId], { content: '❤️' });
|
||||
if (prevStats) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', eventId], {
|
||||
queryClient.setQueryData<Nip85EventStats | null>(statsKey, {
|
||||
...prevStats,
|
||||
reactions: prevStats.reactions + 1,
|
||||
reactionCount: prevStats.reactionCount + 1,
|
||||
});
|
||||
}
|
||||
publishEvent(
|
||||
@@ -180,7 +188,7 @@ export function ReactionButton({
|
||||
{
|
||||
onSuccess: () => {
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-stats', eventId] });
|
||||
invalidateEventStats(queryClient, eventId, statsPubkey);
|
||||
queryClient.invalidateQueries({ queryKey: ['event-interactions', eventId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-reaction', eventId] });
|
||||
}, 3000);
|
||||
@@ -188,7 +196,7 @@ export function ReactionButton({
|
||||
onError: () => {
|
||||
queryClient.setQueryData(['user-reaction', eventId], null);
|
||||
if (prevStats) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', eventId], prevStats);
|
||||
queryClient.setQueryData<Nip85EventStats | null>(statsKey, prevStats);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -214,8 +222,6 @@ export function ReactionButton({
|
||||
<span className={cn('tabular-nums', variant === 'chip' ? '' : 'text-sm', hasReacted && 'text-pink-500')}>
|
||||
{formatNumber(reactionCount)}
|
||||
</span>
|
||||
) : variant === 'chip' ? (
|
||||
<span className="hidden sm:inline">React</span>
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Quote, Rocket, Undo2 } from 'lucide-react';
|
||||
import { Megaphone, Quote, Undo2 } from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import { useState } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
@@ -7,6 +7,7 @@ import { impactLight } from '@/lib/haptics';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useDeleteEvent } from '@/hooks/useDeleteEvent';
|
||||
import { useRepostStatus } from '@/hooks/useRepostStatus';
|
||||
@@ -15,7 +16,8 @@ import { useToast } from '@/hooks/useToast';
|
||||
import { getRepostKind } from '@/lib/feedUtils';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { encodeEventAddress } from '@/lib/encodeEvent';
|
||||
import type { EventStats } from '@/hooks/useTrending';
|
||||
import type { Nip85EventStats } from '@/hooks/useNip85Stats';
|
||||
import { invalidateEventStats } from '@/lib/invalidateEventStats';
|
||||
|
||||
interface RepostMenuProps {
|
||||
event: NostrEvent;
|
||||
@@ -32,6 +34,9 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
const repostEventId = useRepostStatus(event.id);
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
const statsKey = ['nip85-event-stats', event.id, statsPubkey] as const;
|
||||
|
||||
const isReposted = !!repostEventId;
|
||||
|
||||
@@ -43,11 +48,11 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
impactLight();
|
||||
|
||||
// Optimistically update stats cache immediately
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', event.id]);
|
||||
const prevStats = queryClient.getQueryData<Nip85EventStats | null>(statsKey);
|
||||
if (prevStats) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', event.id], {
|
||||
queryClient.setQueryData<Nip85EventStats | null>(statsKey, {
|
||||
...prevStats,
|
||||
reposts: prevStats.reposts + 1,
|
||||
repostCount: prevStats.repostCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -83,7 +88,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
setOpen(false);
|
||||
// Delay invalidation so the relay has time to index the new event.
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] });
|
||||
invalidateEventStats(queryClient, event, statsPubkey);
|
||||
queryClient.invalidateQueries({ queryKey: ['event-interactions', event.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-repost', event.id] });
|
||||
}, 3000);
|
||||
@@ -92,7 +97,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
toast({ title: 'Failed to repost', variant: 'destructive' });
|
||||
// Revert optimistic updates
|
||||
if (prevStats) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', event.id], prevStats);
|
||||
queryClient.setQueryData<Nip85EventStats | null>(statsKey, prevStats);
|
||||
}
|
||||
queryClient.setQueryData(['user-repost', event.id], null);
|
||||
},
|
||||
@@ -105,11 +110,11 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
impactLight();
|
||||
|
||||
// Optimistically update stats cache
|
||||
const prevStats = queryClient.getQueryData<EventStats>(['event-stats', event.id]);
|
||||
const prevStats = queryClient.getQueryData<Nip85EventStats | null>(statsKey);
|
||||
if (prevStats) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', event.id], {
|
||||
queryClient.setQueryData<Nip85EventStats | null>(statsKey, {
|
||||
...prevStats,
|
||||
reposts: Math.max(0, prevStats.reposts - 1),
|
||||
repostCount: Math.max(0, prevStats.repostCount - 1),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +129,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
toast({ title: 'Repost removed' });
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] });
|
||||
invalidateEventStats(queryClient, event, statsPubkey);
|
||||
queryClient.invalidateQueries({ queryKey: ['event-interactions', event.id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['user-repost', event.id] });
|
||||
}, 3000);
|
||||
@@ -133,7 +138,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
toast({ title: 'Failed to remove repost', variant: 'destructive' });
|
||||
// Revert optimistic updates
|
||||
if (prevStats) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', event.id], prevStats);
|
||||
queryClient.setQueryData<Nip85EventStats | null>(statsKey, prevStats);
|
||||
}
|
||||
queryClient.setQueryData(['user-repost', event.id], prevRepostStatus);
|
||||
},
|
||||
@@ -195,7 +200,7 @@ export function RepostMenu({ event, children }: RepostMenuProps) {
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 text-[15px] text-foreground hover:bg-secondary/60 transition-colors"
|
||||
>
|
||||
<Rocket className="size-5" />
|
||||
<Megaphone className="size-5" />
|
||||
<span>Boost</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ type KindOption = {
|
||||
|
||||
// ─── Kind options (built once) ───────────────────────────────────────────────
|
||||
|
||||
import { buildKindOptions } from '@/lib/feedFilterUtils';
|
||||
import { buildKindOptions, AGORA_PRESET_KIND_VALUES } from '@/lib/feedFilterUtils';
|
||||
|
||||
// ─── useScrollCarets ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -152,7 +152,26 @@ export function KindPicker({ value, options, onChange }: {
|
||||
);
|
||||
}, [options, search]);
|
||||
|
||||
const selected = value === 'all' || value === 'custom' ? null : options.find((o) => o.value === value);
|
||||
// Partition into Agora preset (top of picker) and the rest.
|
||||
// When the user is searching, we skip the partition and show a flat list.
|
||||
const presetSet = useMemo(() => new Set(AGORA_PRESET_KIND_VALUES), []);
|
||||
const { presetOptions, otherOptions } = useMemo(() => {
|
||||
if (search) return { presetOptions: [], otherOptions: filtered };
|
||||
const preset: KindOption[] = [];
|
||||
const other: KindOption[] = [];
|
||||
// Preserve AGORA_PRESET_KIND_VALUES order for the preset section.
|
||||
const byValue = new Map(filtered.map((o) => [o.value, o]));
|
||||
for (const v of AGORA_PRESET_KIND_VALUES) {
|
||||
const opt = byValue.get(v);
|
||||
if (opt) preset.push(opt);
|
||||
}
|
||||
for (const o of filtered) {
|
||||
if (!presetSet.has(o.value)) other.push(o);
|
||||
}
|
||||
return { presetOptions: preset, otherOptions: other };
|
||||
}, [filtered, presetSet, search]);
|
||||
|
||||
const selected = value === 'all' || value === 'agora' || value === 'custom' ? null : options.find((o) => o.value === value);
|
||||
const SelectedIcon = selected?.icon;
|
||||
|
||||
const handleSelect = (v: string) => { onChange(v); setOpen(false); setSearch(''); };
|
||||
@@ -170,7 +189,13 @@ export function KindPicker({ value, options, onChange }: {
|
||||
? <SelectedIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
: <Hash className="size-3.5 shrink-0 text-muted-foreground" />}
|
||||
<span className="flex-1 truncate">
|
||||
{value === 'all' ? 'All' : value === 'custom' ? 'Custom...' : (selected?.label ?? value)}
|
||||
{value === 'all'
|
||||
? 'All kinds'
|
||||
: value === 'agora'
|
||||
? 'Agora content'
|
||||
: value === 'custom'
|
||||
? 'Custom...'
|
||||
: (selected?.label ?? value)}
|
||||
</span>
|
||||
<ChevronDown className="size-3 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -198,8 +223,28 @@ export function KindPicker({ value, options, onChange }: {
|
||||
</div>
|
||||
{canScrollUp && <KindScrollCaret direction="up" onMouseEnter={() => startScroll('up')} onMouseLeave={stopScroll} />}
|
||||
<div ref={refCallback} className="overflow-y-auto flex-1 min-h-0" onScroll={onScroll}>
|
||||
{!search && <KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />}
|
||||
{filtered.map((opt) => (
|
||||
{!search && (
|
||||
<>
|
||||
<KindPickerItem icon={null} label="Agora content" active={value === 'agora'} onClick={() => handleSelect('agora')} />
|
||||
<KindPickerItem icon={null} label="All kinds" active={value === 'all'} onClick={() => handleSelect('all')} />
|
||||
</>
|
||||
)}
|
||||
{!search && presetOptions.length > 0 && (
|
||||
<>
|
||||
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Agora content
|
||||
</div>
|
||||
{presetOptions.map((opt) => (
|
||||
<KindPickerItem key={opt.value} icon={opt.icon ?? null} label={opt.label} active={value === opt.value} onClick={() => handleSelect(opt.value)} />
|
||||
))}
|
||||
{otherOptions.length > 0 && (
|
||||
<div className="px-2.5 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
All kinds
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{otherOptions.map((opt) => (
|
||||
<KindPickerItem key={opt.value} icon={opt.icon ?? null} label={opt.label} active={value === opt.value} onClick={() => handleSelect(opt.value)} />
|
||||
))}
|
||||
{(!search || 'custom'.includes(search.toLowerCase())) && (
|
||||
|
||||
@@ -16,6 +16,8 @@ interface SubHeaderBarProps {
|
||||
className?: string;
|
||||
/** Extra classes on the inner flex container holding the tabs. */
|
||||
innerClassName?: string;
|
||||
/** Extra classes on the SVG background fill. */
|
||||
backgroundFillClassName?: string;
|
||||
/** Replace the decorative arc with a plain rectangle. */
|
||||
noArc?: boolean;
|
||||
/** Keep the bar visible when the mobile top bar hides (slides to top-0 instead of off-screen). */
|
||||
@@ -30,7 +32,7 @@ interface SubHeaderBarProps {
|
||||
* Used by all tab bars (Feed, Search, Notifications, etc.) and the MobileTopBar
|
||||
* fallback arc.
|
||||
*/
|
||||
export function SubHeaderBar({ children, className, innerClassName, noArc: _noArc, pinned }: SubHeaderBarProps) {
|
||||
export function SubHeaderBar({ children, className, innerClassName, backgroundFillClassName, noArc: _noArc, pinned }: SubHeaderBarProps) {
|
||||
const [hover, setHover] = useState<HoverSlice | null>(null);
|
||||
const [active, setActive] = useState<HoverSlice | null>(null);
|
||||
const navHidden = useNavHidden();
|
||||
@@ -127,7 +129,7 @@ export function SubHeaderBar({ children, className, innerClassName, noArc: _noAr
|
||||
)}
|
||||
{/* Inner wrapper holds the ArcBackground and tab content. */}
|
||||
<div className="relative">
|
||||
<ArcBackground variant="rect" />
|
||||
<ArcBackground variant="rect" fillClassName={backgroundFillClassName} />
|
||||
{/* Per-tab hover highlight: a flat-bottomed slab clipped to the hovered tab's x-slice */}
|
||||
{hover && (
|
||||
<svg
|
||||
|
||||
@@ -8,11 +8,9 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useFollowActions, useFollowList } from '@/hooks/useFollowActions';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { TEAM_SOAPBOX_PACK } from '@/lib/helpContent';
|
||||
@@ -26,7 +24,7 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followList } = useFollowList();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { followMany } = useFollowActions();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [event, setEvent] = useState<NostrEvent | null>(null);
|
||||
@@ -87,27 +85,7 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
|
||||
|
||||
setIsFollowingAll(true);
|
||||
try {
|
||||
// 1. Fetch freshest kind 3 from relays (not cache)
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
|
||||
// 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc.
|
||||
const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? [];
|
||||
const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? [];
|
||||
const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk));
|
||||
|
||||
// 3. Merge: add new pubkeys that aren't already followed
|
||||
const newPTags = pubkeys
|
||||
.filter((pk) => !existingPubkeys.has(pk))
|
||||
.map((pk) => ['p', pk]);
|
||||
const added = newPTags.length;
|
||||
|
||||
// 4. Publish with prev for published_at preservation
|
||||
await publishEvent({
|
||||
kind: 3,
|
||||
content: prev?.content ?? '',
|
||||
tags: [...nonPTags, ...existingPTags, ...newPTags],
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
const added = await followMany(pubkeys);
|
||||
|
||||
toast({
|
||||
title: 'Following Team Soapbox!',
|
||||
@@ -125,7 +103,7 @@ export function TeamSoapboxCard({ className }: { className?: string }) {
|
||||
} finally {
|
||||
setIsFollowingAll(false);
|
||||
}
|
||||
}, [user, event, pubkeys, nostr, publishEvent, toast]);
|
||||
}, [user, event, pubkeys, followMany, toast]);
|
||||
|
||||
if (loading) {
|
||||
return <TeamSoapboxCardSkeleton className={className} />;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -14,17 +15,23 @@ export interface ReplyNode {
|
||||
}
|
||||
|
||||
/** Renders a fully threaded reply tree with collapsible deep branches. */
|
||||
export function ThreadedReplyList({ roots }: { roots: ReplyNode[] }) {
|
||||
export function ThreadedReplyList({ roots, renderItemHeader }: { roots: ReplyNode[]; renderItemHeader?: (event: NostrEvent) => ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
// Drop the trailing border on the last comment in the list — when
|
||||
// the surrounding page doesn't wrap us in a card, that border
|
||||
// floats orphaned below the final note. Two selectors are needed
|
||||
// because the last root may be either a bare <article> (no
|
||||
// children) or a <div> wrapping an <article> chain. `!important`
|
||||
// overrides NoteCard's own `border-b border-border` utility.
|
||||
<div className="[&>article:last-child]:!border-b-transparent [&>div:last-child_article]:!border-b-transparent">
|
||||
{roots.map((node) => (
|
||||
<ReplyThread key={node.event.id} node={node} depth={0} />
|
||||
<ReplyThread key={node.event.id} node={node} depth={0} renderItemHeader={renderItemHeader} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: number; depthless?: boolean }) {
|
||||
function ReplyThread({ node, depth, depthless, renderItemHeader }: { node: ReplyNode; depth: number; depthless?: boolean; renderItemHeader?: (event: NostrEvent) => ReactNode }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const hasChildren = node.children.length > 0;
|
||||
@@ -34,6 +41,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
|
||||
if (shouldCollapse) {
|
||||
return (
|
||||
<div>
|
||||
{renderItemHeader?.(node.event)}
|
||||
<NoteCard event={node.event} threaded />
|
||||
<ExpandThreadButton count={countDescendants(node)} onClick={() => setExpanded(true)} isLast />
|
||||
</div>
|
||||
@@ -41,7 +49,12 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
|
||||
}
|
||||
|
||||
if (!hasChildren) {
|
||||
return <NoteCard event={node.event} />;
|
||||
return (
|
||||
<div>
|
||||
{renderItemHeader?.(node.event)}
|
||||
<NoteCard event={node.event} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Once expanded past the depth cap, skip further caps for this subtree
|
||||
@@ -49,6 +62,7 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderItemHeader?.(node.event)}
|
||||
<NoteCard event={node.event} threaded />
|
||||
{/* Show hidden sibling count between parent and first child */}
|
||||
{hiddenCount > 0 && !showHidden && (
|
||||
@@ -56,10 +70,13 @@ function ReplyThread({ node, depth, depthless }: { node: ReplyNode; depth: numbe
|
||||
)}
|
||||
{/* Revealed hidden siblings render as threaded items before the inline child */}
|
||||
{showHidden && node.hiddenChildren!.map((child) => (
|
||||
<NoteCard key={child.event.id} event={child.event} threaded threadedLineClassName="bg-primary/30" />
|
||||
<div key={child.event.id}>
|
||||
{renderItemHeader?.(child.event)}
|
||||
<NoteCard event={child.event} threaded threadedLineClassName="bg-primary/30" />
|
||||
</div>
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<ReplyThread key={child.event.id} node={child} depth={depth + 1} depthless={childDepthless} />
|
||||
<ReplyThread key={child.event.id} node={child} depth={depth + 1} depthless={childDepthless} renderItemHeader={renderItemHeader} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, type ComponentType } from 'react';
|
||||
import { Link, NavLink } from 'react-router-dom';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Activity,
|
||||
Bell,
|
||||
@@ -33,8 +33,9 @@ interface NavItem {
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ label: 'Discover', to: '/discover', icon: HandHeart },
|
||||
{ label: 'Organize', to: '/communities', icon: Users },
|
||||
{ label: 'Activity', to: '/feed', icon: Activity },
|
||||
{ label: 'Campaigns', to: '/campaigns/all', icon: HandHeart },
|
||||
{ label: 'Groups', to: '/communities', icon: Users },
|
||||
{ label: 'Pledge', to: '/pledges', icon: Megaphone },
|
||||
];
|
||||
|
||||
@@ -53,6 +54,9 @@ export function TopNav() {
|
||||
const { user } = useCurrentUser();
|
||||
const { orderedItems } = useFeedSettings();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const goToSearch = () => navigate('/search');
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
|
||||
@@ -88,6 +92,18 @@ export function TopNav() {
|
||||
|
||||
{/* Right cluster */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{/* Search — navigates to the /search page. Visible on all
|
||||
breakpoints so users can always reach search from the chrome. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSearch}
|
||||
className="shrink-0 size-9 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label="Search"
|
||||
title="Search"
|
||||
>
|
||||
<Search className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* LoginArea handles both logged-in (account avatar dropdown) and
|
||||
logged-out (Log in / Sign up) states. We render it inline-flex
|
||||
and let it style its own children. */}
|
||||
|
||||
@@ -289,7 +289,7 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
const { toast } = useToast();
|
||||
const { webln, activeNWC } = useWallet();
|
||||
const { config } = useAppContext();
|
||||
const { esploraBaseUrl } = config;
|
||||
const { esploraApis } = config;
|
||||
|
||||
// Success state: populated by either zap rail's onSuccess callback.
|
||||
// When set, we replace the tab UI with <ZapSuccessScreen />.
|
||||
@@ -323,8 +323,8 @@ export function ZapDialog({ target, children, className }: ZapDialogProps) {
|
||||
const amountInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: btcPrice } = useQuery({
|
||||
queryKey: ['btc-price', esploraBaseUrl],
|
||||
queryFn: () => fetchBtcPrice(esploraBaseUrl),
|
||||
queryKey: ['btc-price', esploraApis],
|
||||
queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -837,6 +837,7 @@ const NsecLoginForm: React.FC<NsecLoginFormProps> = ({
|
||||
onSubmit();
|
||||
}}
|
||||
className="space-y-3"
|
||||
data-nsec-allowed
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CommunityGridProps {
|
||||
children: React.ReactNode;
|
||||
/** Extra classes on the grid container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive grid container for community/organization cards on the
|
||||
* `/communities` page. Replaces the previous horizontal-scroll shelves so
|
||||
* organizations wrap onto multiple rows instead of disappearing off the
|
||||
* right edge.
|
||||
*
|
||||
* Column counts are tuned to the page's `max-w-5xl` (~1024px) content
|
||||
* column so each cell ends up close to the legacy 256px `CommunityMiniCard`
|
||||
* width at the `lg` breakpoint:
|
||||
* - <640px: 1 column
|
||||
* - sm 640+: 2 columns
|
||||
* - md 768+: 3 columns
|
||||
* - lg 1024+: 4 columns
|
||||
*
|
||||
* Cards passed in should be `w-full` so they fill their grid cell — the
|
||||
* default `w-64` on `CommunityMiniCard` can be overridden with
|
||||
* `className="w-full"` thanks to `tailwind-merge`.
|
||||
*/
|
||||
export function CommunityGrid({ children, className }: CommunityGridProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 px-4 sm:px-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { CommunityModerationOverlay } from '@/components/CommunityModerationMenu';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
@@ -29,6 +30,13 @@ interface CommunityMiniCardProps {
|
||||
* - founder avatar + display name in a muted row.
|
||||
*
|
||||
* Kept narrow enough to fit ~4 cards across a desktop content column.
|
||||
*
|
||||
* Moderators (Team Soapbox pack members) see a kebab menu overlaid on the
|
||||
* banner exposing the Feature / Hide actions plus a Hidden badge when the
|
||||
* org is currently hidden. Non-moderators see no overlay — the whole
|
||||
* moderation pipeline (including the heavy `useOrganizationModeration`
|
||||
* query) is bypassed for them so grids of dozens of cards don't fan out
|
||||
* a per-card cache subscription on every viewer.
|
||||
*/
|
||||
export function CommunityMiniCard({ community, className }: CommunityMiniCardProps) {
|
||||
const founder = useAuthor(community.founderPubkey);
|
||||
@@ -67,6 +75,11 @@ export function CommunityMiniCard({ community, className }: CommunityMiniCardPro
|
||||
<Users className="size-10 text-primary/40" />
|
||||
</div>
|
||||
)}
|
||||
{/* Moderator overlay (Hidden badge + kebab). Renders `null` for
|
||||
non-moderators, which is why this component owns the
|
||||
`useOrganizationModeration` subscription rather than the
|
||||
card — keeps non-mod grids free of the heavy label query. */}
|
||||
<CommunityModerationOverlay coord={community.aTag} organizationName={community.name} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 p-3.5 flex-1">
|
||||
<h3 className="font-semibold leading-tight text-sm tracking-tight line-clamp-1">
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Globe2, HandHeart, PlusCircle, Users } from 'lucide-react';
|
||||
|
||||
import { HeroGlobe, type GlobeMarkerKind } from '@/components/HeroGlobe';
|
||||
import { HeroAtmosphere } from '@/components/HeroAtmosphere';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useCampaigns } from '@/hooks/useCampaigns';
|
||||
import { useGlobalActivity } from '@/hooks/useGlobalActivity';
|
||||
import { useGlobalDonations } from '@/hooks/useGlobalDonations';
|
||||
import { useDiscoverCommunities } from '@/hooks/useDiscoverCommunities';
|
||||
import { HOPE_PALETTE } from '@/lib/hopePalette';
|
||||
import { searchCountry } from '@/lib/countries';
|
||||
import { getCoordinates } from '@/lib/coordinates';
|
||||
import { formatSatsShort } from '@/lib/formatCampaignAmount';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DiscoverHeroProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface GlobeMarker {
|
||||
key: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
label: string;
|
||||
kind: GlobeMarkerKind;
|
||||
}
|
||||
|
||||
interface TickerStat {
|
||||
/** Stable React key. */
|
||||
id: string;
|
||||
/** Big number / value text. */
|
||||
value: string;
|
||||
/** Trailing label that describes what the number is. */
|
||||
label: string;
|
||||
/** Decorative leading icon. */
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Country code → lat/lng. Used to seed country-pulse markers. */
|
||||
function lookupCountryCoords(code: string) {
|
||||
const coords = getCoordinates(code);
|
||||
return coords ? { lat: coords.latitude, lng: coords.longitude } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover-page hero. The same hand-drawn `HeroGlobe` that anchors the
|
||||
* fundraising home page (`/`), but reframed: the globe is the
|
||||
* protagonist, three marker types sit on it at once — campaigns
|
||||
* (hearts), communities (rings), and country-pulse (warm dots) — and a
|
||||
* rotating stat ticker headlines what the network has done.
|
||||
*
|
||||
* Visual chrome:
|
||||
* - Slow hue drift through `HOPE_PALETTE` every ~8s (the page literally
|
||||
* pulses with hope).
|
||||
* - `HeroAtmosphere` carries the warm scrim + radial glow + sunrise rim,
|
||||
* same component the campaigns hero uses for crossfade.
|
||||
* - No background photo — Discover isn't selling any one campaign, so
|
||||
* the sphere reads against a soft secondary wash instead.
|
||||
*/
|
||||
export function DiscoverHero({ className }: DiscoverHeroProps) {
|
||||
// ─── Data ──────────────────────────────────────────────────────────────
|
||||
const { data: campaigns } = useCampaigns({ limit: 60 });
|
||||
const { data: communities } = useDiscoverCommunities({ limit: 60 });
|
||||
const { data: activityByCountry } = useGlobalActivity();
|
||||
const { data: donations, isLoading: donationsLoading } = useGlobalDonations();
|
||||
|
||||
// ─── Globe markers ─────────────────────────────────────────────────────
|
||||
// Layer three pin types. We dedupe primarily by country so the globe
|
||||
// never piles dozens of markers on top of each other — the goal is a
|
||||
// sparse, hopeful constellation, not a heatmap. Hearts win over rings
|
||||
// win over dots when the same country shows up in multiple sources.
|
||||
const markers = useMemo<GlobeMarker[]>(() => {
|
||||
const out: GlobeMarker[] = [];
|
||||
const claimedCountries = new Set<string>();
|
||||
|
||||
// 1. Campaigns → hearts. Newest first; cap at 18 so they don't crowd.
|
||||
let heartCount = 0;
|
||||
for (const c of campaigns ?? []) {
|
||||
if (heartCount >= 18) break;
|
||||
if (!c.location) continue;
|
||||
const match = searchCountry(c.location);
|
||||
if (!match) continue;
|
||||
if (claimedCountries.has(match.country.code)) continue;
|
||||
const coords = getCoordinates(match.country.code);
|
||||
if (!coords) continue;
|
||||
claimedCountries.add(match.country.code);
|
||||
out.push({
|
||||
key: `campaign:${c.aTag}`,
|
||||
lat: coords.latitude,
|
||||
lng: coords.longitude,
|
||||
label: c.title,
|
||||
kind: 'campaign',
|
||||
});
|
||||
heartCount++;
|
||||
}
|
||||
|
||||
// 2. Country-pulse dots — the trusted-stats country activity, sized
|
||||
// implicitly by the marker glyph. Cap at 28 so the back of the globe
|
||||
// doesn't bristle when it rotates into view.
|
||||
let pulseCount = 0;
|
||||
if (activityByCountry) {
|
||||
const sortedCodes = [...activityByCountry.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([code]) => code);
|
||||
for (const code of sortedCodes) {
|
||||
if (pulseCount >= 28) break;
|
||||
if (claimedCountries.has(code)) continue;
|
||||
const coords = lookupCountryCoords(code);
|
||||
if (!coords) continue;
|
||||
claimedCountries.add(code);
|
||||
out.push({
|
||||
key: `pulse:${code}`,
|
||||
lat: coords.lat,
|
||||
lng: coords.lng,
|
||||
label: `Active in ${code}`,
|
||||
kind: 'country-pulse',
|
||||
});
|
||||
pulseCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Community rings — only when we can geolocate one of the
|
||||
// moderators. Communities don't carry a location tag of their own,
|
||||
// so we use a small heuristic: spread the first N communities across
|
||||
// continents by scattering them on a stable hash. Keeps the layer
|
||||
// present without inventing coordinates we can't justify.
|
||||
//
|
||||
// To keep ourselves honest we cap this at 6 rings and never overwrite
|
||||
// a country that already has a campaign heart or pulse dot. If we
|
||||
// genuinely can't place any, we skip the layer.
|
||||
const scatter: Array<{ lat: number; lng: number }> = [
|
||||
{ lat: 40.7, lng: -74.0 }, // Americas
|
||||
{ lat: -23.5, lng: -46.6 }, // S. America
|
||||
{ lat: 51.5, lng: -0.1 }, // Europe
|
||||
{ lat: -1.3, lng: 36.8 }, // Africa
|
||||
{ lat: 35.7, lng: 139.7 }, // E. Asia
|
||||
{ lat: -33.9, lng: 151.2 }, // Oceania
|
||||
];
|
||||
let ringCount = 0;
|
||||
for (const community of communities ?? []) {
|
||||
if (ringCount >= scatter.length) break;
|
||||
const slot = scatter[ringCount];
|
||||
out.push({
|
||||
key: `community:${community.aTag}`,
|
||||
lat: slot.lat,
|
||||
lng: slot.lng,
|
||||
label: community.name,
|
||||
kind: 'community',
|
||||
});
|
||||
ringCount++;
|
||||
}
|
||||
|
||||
return out;
|
||||
}, [campaigns, communities, activityByCountry]);
|
||||
|
||||
// ─── Hue drift ─────────────────────────────────────────────────────────
|
||||
// Cycle through the hopeful palette on a slow ~9s interval. We seed
|
||||
// HeroAtmosphere with a stable string per cycle so its crossfade logic
|
||||
// kicks in correctly between hues.
|
||||
const [hueIndex, setHueIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => {
|
||||
setHueIndex((i) => (i + 1) % HOPE_PALETTE.length);
|
||||
}, 9_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
const activeHue = HOPE_PALETTE[hueIndex];
|
||||
const atmosphereSeed = `discover-hue-${activeHue.name}`;
|
||||
|
||||
// ─── Stat ticker ───────────────────────────────────────────────────────
|
||||
// Three rotating, immutable network-wide stats. We compute them
|
||||
// defensively — when the underlying query is still loading we surface
|
||||
// a small skeleton inside the ticker row instead of "0" so the page
|
||||
// doesn't lie about the network's scale.
|
||||
const stats = useMemo<TickerStat[]>(() => {
|
||||
const items: TickerStat[] = [];
|
||||
|
||||
if (donations && donations.totalSats > 0) {
|
||||
items.push({
|
||||
id: 'sats',
|
||||
value: formatSatsShort(donations.totalSats),
|
||||
label: `raised on-chain across ${donations.campaignCount.toLocaleString()} ${
|
||||
donations.campaignCount === 1 ? 'campaign' : 'campaigns'
|
||||
}`,
|
||||
icon: <HandHeart className="size-5" aria-hidden />,
|
||||
});
|
||||
}
|
||||
if (communities && communities.length > 0) {
|
||||
items.push({
|
||||
id: 'communities',
|
||||
value: communities.length.toLocaleString(),
|
||||
label: `${communities.length === 1 ? 'organization' : 'organizations'} gathering on Nostr`,
|
||||
icon: <Users className="size-5" aria-hidden />,
|
||||
});
|
||||
}
|
||||
if (activityByCountry && activityByCountry.size > 0) {
|
||||
items.push({
|
||||
id: 'countries',
|
||||
value: activityByCountry.size.toLocaleString(),
|
||||
label: `${activityByCountry.size === 1 ? 'country' : 'countries'} posting today`,
|
||||
icon: <Globe2 className="size-5" aria-hidden />,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [donations, communities, activityByCountry]);
|
||||
|
||||
// Auto-advance the ticker. Holds at the first slot until at least one
|
||||
// stat is known so the visitor doesn't see an empty pill.
|
||||
const [tickerIndex, setTickerIndex] = useState(0);
|
||||
useEffect(() => {
|
||||
if (stats.length <= 1) return;
|
||||
const id = window.setInterval(() => {
|
||||
setTickerIndex((i) => (i + 1) % stats.length);
|
||||
}, 4_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [stats.length]);
|
||||
|
||||
const currentStat = stats[tickerIndex % Math.max(stats.length, 1)];
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'relative overflow-hidden border-b border-border bg-secondary/30',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Atmosphere — same scrim + radial glow + sunrise rim used on
|
||||
`/`. Seeded by the active hue so the whole hero blooms together
|
||||
when the palette advances. */}
|
||||
<HeroAtmosphere seed={atmosphereSeed} />
|
||||
|
||||
{/* Globe — centered, dominant. Slight upward bias so the headline
|
||||
beneath has breathing room. */}
|
||||
<div className="absolute inset-0 pointer-events-none flex items-center justify-center">
|
||||
<div className="pointer-events-auto opacity-90">
|
||||
<HeroGlobe
|
||||
markers={markers}
|
||||
hue={activeHue}
|
||||
className="aspect-square max-w-none drop-shadow-2xl"
|
||||
style={{ width: 'clamp(440px, 62dvw, 720px)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Readability scrim. Sits above the globe + atmosphere but below
|
||||
the foreground content so the headline / paragraph stay legible
|
||||
regardless of which hue the palette is currently cycling
|
||||
through. Top-down so the eye-line lands on the darkest pixels;
|
||||
we taper to transparent before the ticker pill so the CTAs and
|
||||
stat row underneath keep their warm wash. */}
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-72 sm:h-80 pointer-events-none bg-gradient-to-b from-black/55 via-black/25 to-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Foreground content — headline above the sphere, ticker + CTAs
|
||||
below it. Uses the same `max-w-5xl` container as the rest of
|
||||
the Discover page so the hero never sprawls wider than the
|
||||
shelves beneath it. */}
|
||||
<div className="relative max-w-5xl mx-auto px-4 sm:px-6 py-12 sm:py-16 lg:py-20 min-h-[560px] sm:min-h-[640px] lg:min-h-[680px] flex flex-col items-center text-center">
|
||||
<div className="relative space-y-3 max-w-3xl">
|
||||
<p className="text-xs sm:text-sm font-semibold uppercase tracking-[0.18em] text-white/80 drop-shadow">
|
||||
Discover
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] text-white drop-shadow-[0_2px_12px_rgb(0_0_0/0.55)]">
|
||||
The world,
|
||||
<br className="sm:hidden" /> gathering.
|
||||
</h1>
|
||||
<p className="text-base sm:text-lg text-white/85 max-w-2xl mx-auto drop-shadow-[0_1px_6px_rgb(0_0_0/0.5)]">
|
||||
Campaigns, communities, and conversations from every corner of the
|
||||
globe. Backed by Bitcoin, broadcast on Nostr, owned by no one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Spacer so the next block lands beneath the sphere. */}
|
||||
<div className="flex-1 min-h-[180px] sm:min-h-[220px]" aria-hidden="true" />
|
||||
|
||||
{/* Rotating stat ticker. The fixed min-height stops the layout
|
||||
from jumping as labels swap; the keyed inner span re-mounts on
|
||||
every change to trigger the fade-in transition. */}
|
||||
<div
|
||||
className="relative w-full max-w-md mx-auto rounded-full bg-background/55 backdrop-blur-xl backdrop-saturate-150 border border-white/20 dark:border-white/10 px-5 py-3 shadow-lg shadow-amber-500/10"
|
||||
aria-live="polite"
|
||||
>
|
||||
{currentStat ? (
|
||||
<div
|
||||
key={currentStat.id}
|
||||
className="flex items-center justify-center gap-3 motion-safe:animate-in motion-safe:fade-in motion-safe:duration-500"
|
||||
>
|
||||
<span className="text-primary shrink-0">{currentStat.icon}</span>
|
||||
<span className="text-sm sm:text-base font-semibold tracking-tight">
|
||||
{currentStat.value}
|
||||
</span>
|
||||
<span className="text-xs sm:text-sm text-muted-foreground line-clamp-1">
|
||||
{currentStat.label}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{donationsLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-3 w-32" />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Connecting to relays…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CTAs — clean glass pills, same vocabulary as `/`. Two clear
|
||||
actions: start something (campaign creation), or browse the
|
||||
world map for inspiration. */}
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<Button
|
||||
size="lg"
|
||||
asChild
|
||||
className={cn(
|
||||
'relative rounded-full text-white font-semibold text-base h-12 px-7 [&_svg]:size-[18px]',
|
||||
'bg-gradient-to-br from-white/14 via-amber-100/10 to-rose-100/10 hover:from-white/20 hover:via-amber-100/14 hover:to-rose-100/14',
|
||||
'backdrop-blur-xl backdrop-saturate-150',
|
||||
'border border-white/25 hover:border-white/35',
|
||||
'shadow-[inset_0_0_0_1px_rgb(255_255_255/0.08),0_10px_28px_-12px_hsl(24_85%_45%/0.4)]',
|
||||
'hover:shadow-[inset_0_0_0_1px_rgb(255_255_255/0.12),0_12px_32px_-10px_hsl(24_85%_45%/0.5)]',
|
||||
'motion-safe:transition-colors motion-safe:duration-200',
|
||||
)}
|
||||
>
|
||||
<Link to="/campaigns/new">
|
||||
<PlusCircle className="mr-2" />
|
||||
Start a campaign
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
asChild
|
||||
className="rounded-full bg-background/60 backdrop-blur h-12 px-6 text-base"
|
||||
>
|
||||
<Link to="/world">
|
||||
<Globe2 className="size-4 mr-2" />
|
||||
Browse the world
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Users } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { CommunityMiniCard } from '@/components/discovery/CommunityMiniCard';
|
||||
import type { ProfileOrganization } from '@/hooks/useProfileOrganizations';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface OrganizationsAllDialogProps {
|
||||
/** Full list of organizations to surface in the overflow dialog. */
|
||||
orgs: ProfileOrganization[];
|
||||
/** Trigger element — typically a "See all N →" button. */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* "See all organizations" dialog used by the profile identity rail.
|
||||
*
|
||||
* Renders every org the profile is associated with in a scrollable
|
||||
* 2-column grid of CommunityMiniCards with role badges. Lifted out of
|
||||
* the (now-deleted) ProfileOrganizationsStrip so the rail can use it
|
||||
* directly without pulling in the strip's full file.
|
||||
*/
|
||||
export function OrganizationsAllDialog({ orgs, children }: OrganizationsAllDialogProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col p-0">
|
||||
<DialogHeader className="px-6 pt-6">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Users className="size-5 text-primary" />
|
||||
All organizations
|
||||
<span className="text-sm font-normal text-muted-foreground">({orgs.length})</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="px-6 py-4 flex-1">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{orgs.map((entry) => (
|
||||
<div key={entry.community.aTag} className="relative">
|
||||
<CommunityMiniCard community={entry.community} className="w-full" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'absolute top-2 left-2 backdrop-blur bg-background/90 border-border/40 text-[10px] font-semibold uppercase tracking-wide',
|
||||
entry.isFounder ? 'text-primary' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{entry.isFounder ? 'Founder' : 'Moderator'}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="px-6 pb-6 pt-2 flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { useAgoraFeed } from '@/hooks/useAgoraFeed';
|
||||
|
||||
interface ProfileActivityTabProps {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified profile feed scoped to one author.
|
||||
*
|
||||
* Pipes {@link useAgoraFeed} through with `authors=[pubkey]` AND
|
||||
* `includeAuthorNotes: true`, so the relay-side filter pulls in:
|
||||
*
|
||||
* - Agora-marked content (campaigns, pledges, communities, marked notes,
|
||||
* Agora-rooted comments, donation receipts), and
|
||||
* - every kind 1 / 6 note this author has published, regardless of the
|
||||
* `t:agora` marker.
|
||||
*
|
||||
* The two sources merge into a single chronological timeline so the
|
||||
* profile shows "everything this person has done on the network." Replaces
|
||||
* the previous separate Activity + Posts tabs.
|
||||
*
|
||||
* Single-column inside the tab area because the timeline is mixed-kind
|
||||
* and benefits from full-width cards.
|
||||
*/
|
||||
export function ProfileActivityTab({ pubkey, displayName }: ProfileActivityTabProps) {
|
||||
const {
|
||||
events,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
hasNextPage,
|
||||
fetchNextPage,
|
||||
} = useAgoraFeed(true, { authors: [pubkey], includeAuthorNotes: true });
|
||||
|
||||
const { ref: scrollRef, inView } = useInView({ threshold: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
if (isLoading && events.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 space-y-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i} className="p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-12">
|
||||
<Card className="border-dashed">
|
||||
<div className="py-12 px-8 text-center">
|
||||
<Sparkles className="size-10 mx-auto mb-3 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
No activity from {displayName} yet. Posts, campaigns, pledges,
|
||||
and donations all show up here.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="divide-y divide-border">
|
||||
{events.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
{hasNextPage && (
|
||||
<div ref={scrollRef} className="flex justify-center py-6">
|
||||
{isFetchingNextPage && (
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Megaphone } from 'lucide-react';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useCampaignModerators } from '@/hooks/useCampaignModerators';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { fetchAddressData } from '@/lib/bitcoin';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
interface ProfileCampaignsTabProps {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
isOwnProfile: boolean;
|
||||
campaigns: ParsedCampaign[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
type SortMode = 'top' | 'new';
|
||||
|
||||
/**
|
||||
* Full grid of every campaign authored by this profile.
|
||||
*
|
||||
* Owner / moderator can toggle "Show hidden" to see campaigns the
|
||||
* moderation pack has hidden from the home page — visitors only see
|
||||
* non-hidden campaigns by default. Sort modes mirror
|
||||
* {@link AllCampaignsPage}: New (newest created_at first, the default
|
||||
* incoming order) and Top (most sats raised, requires the verified
|
||||
* donation totals).
|
||||
*/
|
||||
export function ProfileCampaignsTab({
|
||||
pubkey,
|
||||
displayName,
|
||||
isOwnProfile,
|
||||
campaigns,
|
||||
isLoading,
|
||||
}: ProfileCampaignsTabProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: moderation } = useCampaignModeration();
|
||||
const { data: moderators } = useCampaignModerators();
|
||||
const isModerator = !!user && (moderators ?? []).includes(user.pubkey);
|
||||
|
||||
const [sortMode, setSortMode] = useState<SortMode>('new');
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
|
||||
const canShowHidden = isOwnProfile || isModerator;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (canShowHidden && showHidden) return campaigns;
|
||||
return campaigns.filter((c) => !moderation.hiddenCoords.has(c.aTag));
|
||||
}, [campaigns, canShowHidden, showHidden, moderation.hiddenCoords]);
|
||||
|
||||
if (isLoading && filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<CampaignCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-12" data-pubkey={pubkey}>
|
||||
<Card className="border-dashed">
|
||||
<div className="py-12 px-8 text-center">
|
||||
<Megaphone className="size-10 mx-auto mb-3 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{isOwnProfile
|
||||
? "You haven't launched a campaign yet."
|
||||
: `${displayName} hasn't launched a campaign yet.`}
|
||||
</p>
|
||||
{isOwnProfile && (
|
||||
<Link
|
||||
to="/campaigns/new"
|
||||
className="inline-block mt-4 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Start a campaign →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{filtered.length} {filtered.length === 1 ? 'campaign' : 'campaigns'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={sortMode === 'new' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSortMode('new')}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
<Button
|
||||
variant={sortMode === 'top' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setSortMode('top')}
|
||||
>
|
||||
Top
|
||||
</Button>
|
||||
{canShowHidden && (
|
||||
<Button
|
||||
variant={showHidden ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
>
|
||||
{showHidden ? 'Hide hidden' : 'Show hidden'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sortMode === 'top' ? (
|
||||
<SortedByTopGrid campaigns={filtered} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{filtered.map((c) => (
|
||||
<CampaignCard key={c.aTag} campaign={c} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts the visible campaigns by sats raised (descending) by fanning
|
||||
* out one address-balance query per on-chain campaign. Uses `useQueries`,
|
||||
* so the hook call count is deterministic per render (one queries-tuple,
|
||||
* not one hook per campaign) and the rules of hooks hold.
|
||||
*
|
||||
* Caches share keys with `useCampaignDonations` so the balance results
|
||||
* are reused across the profile and any other view of the same campaign.
|
||||
*/
|
||||
function SortedByTopGrid({ campaigns }: { campaigns: ParsedCampaign[] }) {
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
|
||||
// Only on-chain campaigns can have observable totals. SP campaigns sort to 0.
|
||||
const onchain = campaigns.filter((c) => c.wallet?.mode === 'onchain');
|
||||
|
||||
const balanceQueries = useQueries({
|
||||
queries: onchain.map((campaign) => ({
|
||||
queryKey: ['bitcoin-balance', 'campaign', esploraApis, campaign.wallet?.value ?? ''],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
fetchAddressData(campaign.wallet!.value, esploraApis, signal),
|
||||
staleTime: 30_000,
|
||||
enabled: !!campaign.wallet?.value,
|
||||
})),
|
||||
});
|
||||
|
||||
const totalsByCoord = new Map<string, number>();
|
||||
for (let i = 0; i < onchain.length; i++) {
|
||||
const sats = balanceQueries[i]?.data?.totalReceived ?? 0;
|
||||
totalsByCoord.set(onchain[i].aTag, sats);
|
||||
}
|
||||
|
||||
const sorted = [...campaigns].sort(
|
||||
(a, b) => (totalsByCoord.get(b.aTag) ?? 0) - (totalsByCoord.get(a.aTag) ?? 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{sorted.map((campaign) => (
|
||||
<CampaignCard key={campaign.aTag} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Link, Link as RouterLink } from 'react-router-dom';
|
||||
import {
|
||||
Bitcoin,
|
||||
CalendarClock,
|
||||
Globe,
|
||||
HandHeart,
|
||||
Megaphone,
|
||||
MoreHorizontal,
|
||||
QrCode,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { BioContent } from '@/components/BioContent';
|
||||
import { CampaignCard, CampaignCardSkeleton } from '@/components/CampaignCard';
|
||||
import { CommunityMiniCard, CommunityMiniCardSkeleton } from '@/components/discovery/CommunityMiniCard';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { FollowToggleButton } from '@/components/FollowButton';
|
||||
import { Nip05Badge } from '@/components/Nip05Badge';
|
||||
import { ProfileReactionButton } from '@/components/ProfileReactionButton';
|
||||
import { OrganizationsAllDialog } from '@/components/profile/OrganizationsAllDialog';
|
||||
import { useCampaignModeration } from '@/hooks/useCampaignModeration';
|
||||
import { useProfileOrganizations, type ProfileOrganization } from '@/hooks/useProfileOrganizations';
|
||||
import type { ProfileCampaignStats } from '@/hooks/useProfileCampaignStats';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
import { formatCampaignAmount } from '@/lib/formatCampaignAmount';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Action } from '@/hooks/useActions';
|
||||
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
|
||||
import { getGeoDisplayName } from '@/lib/countries';
|
||||
|
||||
export interface ProfileIdentityRailProps {
|
||||
pubkey: string;
|
||||
/** Whether the logged-in user is viewing their own profile. */
|
||||
isOwnProfile: boolean;
|
||||
/** Resolved kind 0 metadata, if any. */
|
||||
metadata: NostrMetadata | undefined;
|
||||
/** Raw kind 0 event — needed for emoji tag rendering on display name. */
|
||||
metadataEvent: NostrEvent | undefined;
|
||||
/** Pre-resolved display name (with `genUserName` fallback applied upstream). */
|
||||
displayName: string;
|
||||
/** True while the kind-0 author query is still in flight; renders skeletons. */
|
||||
isAuthorLoading: boolean;
|
||||
|
||||
/** Banner image URL — used to wire the avatar lightbox to the same url. */
|
||||
bannerUrl: string | undefined;
|
||||
/** Optional NIP-38 status (renders as a thought bubble next to the avatar). */
|
||||
status?: { text: string | undefined; url: string | undefined };
|
||||
|
||||
/** Custom kind-0 profile fields, already parsed. */
|
||||
fields: { label: string; value: string }[];
|
||||
/** Pre-rendered list of <ProfileFieldInline /> nodes — keeps that helper inside ProfilePage. */
|
||||
fieldsContent: ReactNode;
|
||||
|
||||
/** Campaigns authored by this profile (newest-first). */
|
||||
campaigns: ParsedCampaign[];
|
||||
/** Aggregated campaign + raised stats for the stat block. */
|
||||
campaignStats: ProfileCampaignStats;
|
||||
/**
|
||||
* The profile's pledges (kind 36639) — used to surface the latest one
|
||||
* in the rail when the profile has no campaigns. The rail picks the
|
||||
* newest by `createdAt` itself, so callers can pass the unsorted list.
|
||||
*/
|
||||
pledges: Action[];
|
||||
/** Spot BTC price for the Raised stat row. */
|
||||
btcPrice: number | undefined;
|
||||
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
isFollowing: boolean;
|
||||
followPending: boolean;
|
||||
|
||||
onLightbox: (url: string) => void;
|
||||
onFollowersOpen: () => void;
|
||||
onFollowingOpen: () => void;
|
||||
onMoreMenuOpen: () => void;
|
||||
onFollowQROpen: () => void;
|
||||
onToggleFollow: () => void;
|
||||
onTabChange: (tabId: string) => void;
|
||||
onDonate: (campaign: ParsedCampaign) => void;
|
||||
/** Whether the viewer can take any action (logged in). Disables follow when null. */
|
||||
canFollow: boolean;
|
||||
/** Latest kind-0 event used by ProfileReactionButton; falls back to metadataEvent. */
|
||||
authorEvent: NostrEvent | undefined;
|
||||
}
|
||||
|
||||
const RAIL_CAMPAIGN_LIMIT = 2;
|
||||
const RAIL_ORG_LIMIT = 4;
|
||||
|
||||
/**
|
||||
* ProfileIdentityRail — the left rail of the two-column profile.
|
||||
*
|
||||
* Holds everything that's a *standing fact* about the profile: who they
|
||||
* are (avatar, name, bio), what they're raising for (active campaigns),
|
||||
* who they organize with (orgs), key counts (followers / following /
|
||||
* campaigns / pledges / raised), and the freeform profile fields.
|
||||
*
|
||||
* Sticky on `lg+` so it stays visible while the right tab column scrolls.
|
||||
* Below `lg` the rail just stacks above the content — its avatar still
|
||||
* overlaps the banner via `-mt-16` because the rail is the first child
|
||||
* below the banner element.
|
||||
*
|
||||
* The rail does NOT own the tab bar or the tab content — those live in
|
||||
* the right column. Click handlers like `onTabChange` exist so rail rows
|
||||
* can switch tabs (e.g. "See all campaigns →" jumps to the Campaigns tab).
|
||||
*/
|
||||
export function ProfileIdentityRail({
|
||||
pubkey,
|
||||
isOwnProfile,
|
||||
metadata,
|
||||
metadataEvent,
|
||||
displayName,
|
||||
isAuthorLoading,
|
||||
bannerUrl: _bannerUrl,
|
||||
status,
|
||||
fields,
|
||||
fieldsContent,
|
||||
campaigns,
|
||||
campaignStats,
|
||||
pledges,
|
||||
btcPrice,
|
||||
followersCount,
|
||||
followingCount,
|
||||
isFollowing,
|
||||
followPending,
|
||||
onLightbox,
|
||||
onFollowersOpen,
|
||||
onFollowingOpen,
|
||||
onMoreMenuOpen,
|
||||
onFollowQROpen,
|
||||
onToggleFollow,
|
||||
onTabChange,
|
||||
onDonate,
|
||||
canFollow,
|
||||
authorEvent,
|
||||
}: ProfileIdentityRailProps) {
|
||||
if (isAuthorLoading) {
|
||||
return (
|
||||
<RailSkeleton />
|
||||
);
|
||||
}
|
||||
|
||||
const websiteHref = (() => {
|
||||
if (!metadata?.website) return undefined;
|
||||
const candidate = metadata.website.startsWith('http')
|
||||
? metadata.website
|
||||
: `https://${metadata.website}`;
|
||||
return sanitizeUrl(candidate);
|
||||
})();
|
||||
|
||||
const onchainCampaigns = campaigns.filter((c) => c.wallet?.mode === 'onchain');
|
||||
|
||||
return (
|
||||
// Two-layer structure so the rail can scroll independently on lg+
|
||||
// without clipping the avatar that pokes above the rail's top edge:
|
||||
// - Outer flex column owns the avatar (which uses -mt-16 to overlap
|
||||
// the banner). It must NOT clip overflow.
|
||||
// - Inner div carries the rest of the rail and is the scroll
|
||||
// container: `lg:flex-1 lg:min-h-0 lg:overflow-y-auto` makes it
|
||||
// fill the remaining height of the sticky aside and scroll
|
||||
// internally so the page's main scroll only drives the feed.
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Avatar — overlaps the banner from inside the rail. Sits OUTSIDE
|
||||
the scroll container so its negative-margin overhang is never
|
||||
clipped by `overflow-y-auto`. */}
|
||||
<ProfileAvatarBlock
|
||||
metadata={metadata}
|
||||
displayName={displayName}
|
||||
status={status}
|
||||
onLightbox={onLightbox}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-5 mt-5 lg:flex-1 lg:min-h-0 lg:overflow-y-auto pb-4">
|
||||
<ProfileIdentityHeader
|
||||
pubkey={pubkey}
|
||||
isOwnProfile={isOwnProfile}
|
||||
metadata={metadata}
|
||||
metadataEvent={metadataEvent}
|
||||
displayName={displayName}
|
||||
websiteHref={websiteHref}
|
||||
isFollowing={isFollowing}
|
||||
followPending={followPending}
|
||||
canFollow={canFollow}
|
||||
followersCount={followersCount}
|
||||
followingCount={followingCount}
|
||||
totalRaisedSats={campaignStats.totalRaisedSats}
|
||||
btcPrice={btcPrice}
|
||||
onchainCampaigns={onchainCampaigns}
|
||||
onToggleFollow={onToggleFollow}
|
||||
onMoreMenuOpen={onMoreMenuOpen}
|
||||
onFollowQROpen={onFollowQROpen}
|
||||
onDonate={onDonate}
|
||||
onFollowersOpen={onFollowersOpen}
|
||||
onFollowingOpen={onFollowingOpen}
|
||||
onTabChange={onTabChange}
|
||||
authorEvent={authorEvent}
|
||||
/>
|
||||
<ProfileOverviewSections
|
||||
pubkey={pubkey}
|
||||
isOwnProfile={isOwnProfile}
|
||||
campaigns={campaigns}
|
||||
campaignStats={campaignStats}
|
||||
pledges={pledges}
|
||||
btcPrice={btcPrice}
|
||||
fields={fields}
|
||||
fieldsContent={fieldsContent}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Identity header (name / bio / actions / stats) ─────────────────────────
|
||||
|
||||
export interface ProfileIdentityHeaderProps {
|
||||
pubkey: string;
|
||||
isOwnProfile: boolean;
|
||||
metadata: NostrMetadata | undefined;
|
||||
metadataEvent: NostrEvent | undefined;
|
||||
displayName: string;
|
||||
/** Pre-sanitized website URL (`undefined` if none / unsafe). */
|
||||
websiteHref: string | undefined;
|
||||
isFollowing: boolean;
|
||||
followPending: boolean;
|
||||
canFollow: boolean;
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
totalRaisedSats: number;
|
||||
btcPrice: number | undefined;
|
||||
onchainCampaigns: ParsedCampaign[];
|
||||
onToggleFollow: () => void;
|
||||
onMoreMenuOpen: () => void;
|
||||
onFollowQROpen: () => void;
|
||||
onDonate: (campaign: ParsedCampaign) => void;
|
||||
onFollowersOpen: () => void;
|
||||
onFollowingOpen: () => void;
|
||||
onTabChange: (tabId: string) => void;
|
||||
authorEvent: NostrEvent | undefined;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fixed identity block: name, NIP-05, website, bio, action bar, and
|
||||
* top-level stat row (Followers / Following / Raised).
|
||||
*
|
||||
* Rendered inside `ProfileIdentityRail` on desktop and directly above the
|
||||
* tab bar on mobile. Does NOT include the avatar — that lives outside any
|
||||
* scroll container so its `-mt-16` overhang into the banner isn't clipped.
|
||||
*/
|
||||
export function ProfileIdentityHeader({
|
||||
pubkey,
|
||||
isOwnProfile,
|
||||
metadata,
|
||||
metadataEvent,
|
||||
displayName,
|
||||
websiteHref,
|
||||
isFollowing,
|
||||
followPending,
|
||||
canFollow,
|
||||
followersCount,
|
||||
followingCount,
|
||||
totalRaisedSats,
|
||||
btcPrice,
|
||||
onchainCampaigns,
|
||||
onToggleFollow,
|
||||
onMoreMenuOpen,
|
||||
onFollowQROpen,
|
||||
onDonate,
|
||||
onFollowersOpen,
|
||||
onFollowingOpen,
|
||||
onTabChange,
|
||||
authorEvent,
|
||||
className,
|
||||
}: ProfileIdentityHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-5', className)}>
|
||||
{/* Identity: name + NIP-05 + website + bio */}
|
||||
<div className="space-y-1.5">
|
||||
<h1 className="text-xl font-bold leading-tight break-words">
|
||||
{metadataEvent ? (
|
||||
<EmojifiedText tags={metadataEvent.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</h1>
|
||||
{metadata?.nip05 && (
|
||||
<Nip05Badge nip05={metadata.nip05} pubkey={pubkey} className="text-sm text-muted-foreground" />
|
||||
)}
|
||||
{websiteHref && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground min-w-0">
|
||||
<Globe className="size-3.5 shrink-0" />
|
||||
<a
|
||||
href={websiteHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-primary hover:underline"
|
||||
>
|
||||
{metadata!.website!.replace(/^https?:\/\//, '').replace(/\/$/, '')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{metadata?.about && (
|
||||
<p className="pt-1 text-sm whitespace-pre-wrap break-words text-foreground/90">
|
||||
<BioContent tags={metadataEvent?.tags}>{metadata.about}</BioContent>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action bar — wraps onto multiple rows in a 340px-wide rail. */}
|
||||
<ActionBar
|
||||
isOwnProfile={isOwnProfile}
|
||||
isFollowing={isFollowing}
|
||||
followPending={followPending}
|
||||
canFollow={canFollow}
|
||||
onToggleFollow={onToggleFollow}
|
||||
onMoreMenuOpen={onMoreMenuOpen}
|
||||
onFollowQROpen={onFollowQROpen}
|
||||
authorEvent={authorEvent}
|
||||
onchainCampaigns={onchainCampaigns}
|
||||
onDonate={onDonate}
|
||||
/>
|
||||
|
||||
{/* Stats: Followers + Following inline; Raised below if applicable. */}
|
||||
<StatList
|
||||
followersCount={followersCount}
|
||||
followingCount={followingCount}
|
||||
totalRaisedSats={totalRaisedSats}
|
||||
btcPrice={btcPrice}
|
||||
onFollowersOpen={onFollowersOpen}
|
||||
onFollowingOpen={onFollowingOpen}
|
||||
onTabChange={onTabChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Overview sections (campaigns / latest pledge / orgs / fields) ──────────
|
||||
|
||||
export interface ProfileOverviewSectionsProps {
|
||||
pubkey: string;
|
||||
isOwnProfile: boolean;
|
||||
campaigns: ParsedCampaign[];
|
||||
campaignStats: ProfileCampaignStats;
|
||||
pledges: Action[];
|
||||
btcPrice: number | undefined;
|
||||
fields: { label: string; value: string }[];
|
||||
fieldsContent: ReactNode;
|
||||
onTabChange: (tabId: string) => void;
|
||||
/** Render the Organizations grid inline (default true). Set false on
|
||||
* mobile when "Community" is a dedicated tab and orgs should not also
|
||||
* appear inside Overview. */
|
||||
showOrganizations?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The collection of secondary rail sections: active campaigns, a fallback
|
||||
* "latest pledge" card when there are no campaigns, organizations the
|
||||
* profile founded/moderates, and freeform kind-0 profile fields.
|
||||
*
|
||||
* On desktop these stack inside the identity rail. On mobile they become
|
||||
* the content of the "Overview" tab (with `showOrganizations={false}` so
|
||||
* the organizations list moves into the dedicated "Community" tab).
|
||||
*/
|
||||
export function ProfileOverviewSections({
|
||||
pubkey,
|
||||
isOwnProfile,
|
||||
campaigns,
|
||||
campaignStats,
|
||||
pledges,
|
||||
btcPrice,
|
||||
fields,
|
||||
fieldsContent,
|
||||
onTabChange,
|
||||
showOrganizations = true,
|
||||
className,
|
||||
}: ProfileOverviewSectionsProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-5', className)}>
|
||||
{/* Profile fields (rendered upstream) — placed first so the
|
||||
profile's own freeform metadata (links, addresses, etc.) is
|
||||
the first thing visitors read, ahead of campaigns/orgs. */}
|
||||
{fields.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<RailSectionHeader icon={null} title="Profile" />
|
||||
<div className="space-y-3">{fieldsContent}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Active campaigns */}
|
||||
<RailCampaignsSection
|
||||
campaigns={campaigns}
|
||||
isOwnProfile={isOwnProfile}
|
||||
isLoading={campaignStats.isVerifying && campaigns.length === 0}
|
||||
onSeeAll={() => onTabChange('campaigns')}
|
||||
/>
|
||||
|
||||
{/* Latest pledge — surfaced as a fallback when this profile has
|
||||
nothing in the Campaigns slot, so the rail still has a piece of
|
||||
first-class Agora content in the campaigns slot. */}
|
||||
{campaigns.length === 0 && pledges.length > 0 && (
|
||||
<RailLatestPledgeSection
|
||||
pledges={pledges}
|
||||
btcPrice={btcPrice}
|
||||
showSeeAll={pledges.length > 1}
|
||||
onSeeAll={() => onTabChange('pledges')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Organizations */}
|
||||
{showOrganizations && <RailOrganizationsSection pubkey={pubkey} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone organizations section — same `RailOrganizationsSection`
|
||||
* content used inside the rail's overview, but exposed as a top-level
|
||||
* export so the mobile "Community" tab can render it directly.
|
||||
*
|
||||
* The rendering is identical to the rail's version (same grid, same
|
||||
* "See all" overflow dialog). Wrapping it in its own export keeps the
|
||||
* tab content honest about where the data is coming from and lets us
|
||||
* swap in a richer layout later without touching ProfilePage.
|
||||
*/
|
||||
export function ProfileOrganizationsSection({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-5', className)}>
|
||||
<RailOrganizationsSection pubkey={pubkey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Avatar block ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProfileAvatarBlockProps {
|
||||
metadata: NostrMetadata | undefined;
|
||||
displayName: string;
|
||||
status: { text: string | undefined; url: string | undefined } | undefined;
|
||||
onLightbox: (url: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avatar + NIP-38 status bubble. Always rendered as the first thing below
|
||||
* the banner; the avatar uses `-mt-16 md:-mt-20` to overlap into the banner
|
||||
* area. Must NOT be wrapped in any element with `overflow: hidden` /
|
||||
* `overflow-y: auto` or the overhang will be clipped.
|
||||
*/
|
||||
export function ProfileAvatarBlock({
|
||||
metadata,
|
||||
displayName,
|
||||
status,
|
||||
onLightbox,
|
||||
}: ProfileAvatarBlockProps) {
|
||||
const picture = metadata?.picture;
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="relative z-10 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-full -mt-16 md:-mt-20 block"
|
||||
onClick={() => picture && onLightbox(picture)}
|
||||
disabled={!picture}
|
||||
>
|
||||
<Avatar className={cn(
|
||||
'size-28 md:size-32 border-4 border-background shadow-lg',
|
||||
picture && 'cursor-pointer',
|
||||
)}>
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-3xl">
|
||||
{displayName[0]?.toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
|
||||
{/* NIP-38 thought bubble — floats to the right of the avatar over the banner area. */}
|
||||
{status?.text && (
|
||||
<div className="absolute top-2 left-[calc(7rem+8px)] md:left-[calc(8rem+8px)] z-10 max-w-[200px] animate-in fade-in slide-in-from-left-1 duration-300">
|
||||
<div className="relative bg-background/90 backdrop-blur-sm border border-border rounded-xl px-3 py-1.5 shadow-lg">
|
||||
<p className="text-xs text-foreground italic truncate">
|
||||
{status.url ? (
|
||||
<a href={status.url} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||
{status.text}
|
||||
</a>
|
||||
) : (
|
||||
status.text
|
||||
)}
|
||||
</p>
|
||||
{/* Speech bubble triangle tail */}
|
||||
<div className="absolute -bottom-[7px] left-1 size-0 border-t-[8px] border-t-border border-r-[8px] border-r-transparent" />
|
||||
<div className="absolute -bottom-[5.5px] left-1 size-0 border-t-[7px] border-t-background border-r-[7px] border-r-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Action bar ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ActionBar({
|
||||
isOwnProfile,
|
||||
isFollowing,
|
||||
followPending,
|
||||
canFollow,
|
||||
onToggleFollow,
|
||||
onMoreMenuOpen,
|
||||
onFollowQROpen,
|
||||
authorEvent,
|
||||
onchainCampaigns,
|
||||
onDonate,
|
||||
}: {
|
||||
isOwnProfile: boolean;
|
||||
isFollowing: boolean;
|
||||
followPending: boolean;
|
||||
canFollow: boolean;
|
||||
onToggleFollow: () => void;
|
||||
onMoreMenuOpen: () => void;
|
||||
onFollowQROpen: () => void;
|
||||
authorEvent: NostrEvent | undefined;
|
||||
onchainCampaigns: ParsedCampaign[];
|
||||
onDonate: (campaign: ParsedCampaign) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isOwnProfile ? (
|
||||
<>
|
||||
<Link to="/settings/profile" className="flex-1 min-w-[140px]">
|
||||
<Button variant="outline" className="rounded-full font-bold w-full">
|
||||
Edit profile
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full size-10"
|
||||
title="Share follow link"
|
||||
onClick={onFollowQROpen}
|
||||
>
|
||||
<QrCode className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full size-10"
|
||||
onClick={onMoreMenuOpen}
|
||||
title="More options"
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FollowToggleButton
|
||||
isFollowing={isFollowing}
|
||||
isPending={followPending}
|
||||
onClick={onToggleFollow}
|
||||
disabled={!canFollow}
|
||||
/>
|
||||
{onchainCampaigns.length === 1 ? (
|
||||
<Button
|
||||
onClick={() => onDonate(onchainCampaigns[0])}
|
||||
className="rounded-full font-bold gap-1.5"
|
||||
>
|
||||
<HandHeart className="size-4" />
|
||||
Donate
|
||||
</Button>
|
||||
) : onchainCampaigns.length > 1 ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="rounded-full font-bold gap-1.5">
|
||||
<HandHeart className="size-4" />
|
||||
Donate
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
{onchainCampaigns.map((c) => (
|
||||
<DropdownMenuItem
|
||||
key={c.aTag}
|
||||
onClick={() => onDonate(c)}
|
||||
className="flex flex-col items-start gap-0.5"
|
||||
>
|
||||
<span className="font-medium truncate w-full">{c.title}</span>
|
||||
{c.goalUsd ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Goal ${c.goalUsd.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{authorEvent && <ProfileReactionButton profileEvent={authorEvent} />}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="rounded-full size-10"
|
||||
onClick={onMoreMenuOpen}
|
||||
title="More options"
|
||||
>
|
||||
<MoreHorizontal className="size-5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Stat list ──────────────────────────────────────────────────────────────
|
||||
|
||||
function StatList({
|
||||
followersCount,
|
||||
followingCount,
|
||||
totalRaisedSats,
|
||||
btcPrice,
|
||||
onFollowersOpen,
|
||||
onFollowingOpen,
|
||||
onTabChange,
|
||||
}: {
|
||||
followersCount: number;
|
||||
followingCount: number;
|
||||
totalRaisedSats: number;
|
||||
btcPrice: number | undefined;
|
||||
onFollowersOpen: () => void;
|
||||
onFollowingOpen: () => void;
|
||||
onTabChange: (id: string) => void;
|
||||
}) {
|
||||
// Secondary stat rows (one per row). Followers / Following live inline
|
||||
// at the top. Campaigns and Pledges are intentionally not surfaced as
|
||||
// counts here — the rail's Campaigns and (when relevant) Pledges
|
||||
// sections below already show the underlying content directly.
|
||||
const rows: Array<{
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
onClick?: () => void;
|
||||
show: boolean;
|
||||
}> = [
|
||||
{
|
||||
icon: <Bitcoin className="size-3.5 text-primary" />,
|
||||
label: 'Raised',
|
||||
value: formatCampaignAmount(totalRaisedSats, btcPrice),
|
||||
onClick: () => onTabChange('campaigns'),
|
||||
show: totalRaisedSats > 0,
|
||||
},
|
||||
].filter((r) => r.show);
|
||||
|
||||
const hasFollowRow = followersCount > 0 || followingCount > 0;
|
||||
if (!hasFollowRow && rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Followers + Following on a single horizontal row. */}
|
||||
{hasFollowRow && (
|
||||
<div className="flex items-center gap-5 text-sm">
|
||||
{followersCount > 0 && (
|
||||
<button
|
||||
onClick={onFollowersOpen}
|
||||
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
|
||||
title={`${followersCount} followers`}
|
||||
>
|
||||
<span className="font-bold tabular-nums text-foreground">{formatNumber(followersCount)}</span>
|
||||
<span className="text-muted-foreground">Followers</span>
|
||||
</button>
|
||||
)}
|
||||
{followingCount > 0 && (
|
||||
<button
|
||||
onClick={onFollowingOpen}
|
||||
className="flex items-baseline gap-1.5 hover:opacity-80 transition-opacity"
|
||||
title={`${followingCount} following`}
|
||||
>
|
||||
<span className="font-bold tabular-nums text-foreground">{formatNumber(followingCount)}</span>
|
||||
<span className="text-muted-foreground">Following</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary stat rows. */}
|
||||
{rows.length > 0 && (
|
||||
<div className="rounded-xl border border-border/60 bg-card/40 divide-y divide-border/60">
|
||||
{rows.map((row) => (
|
||||
<button
|
||||
key={row.label}
|
||||
onClick={row.onClick}
|
||||
className="w-full flex items-center justify-between gap-3 px-3 py-2 text-sm hover:bg-secondary/40 transition-colors first:rounded-t-xl last:rounded-b-xl"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
{row.icon}
|
||||
{row.label}
|
||||
</span>
|
||||
<span className="font-semibold tabular-nums text-foreground">{row.value}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Rail Campaigns Section ─────────────────────────────────────────────────
|
||||
|
||||
function RailCampaignsSection({
|
||||
campaigns,
|
||||
isOwnProfile,
|
||||
isLoading,
|
||||
onSeeAll,
|
||||
}: {
|
||||
campaigns: ParsedCampaign[];
|
||||
isOwnProfile: boolean;
|
||||
isLoading: boolean;
|
||||
onSeeAll: () => void;
|
||||
}) {
|
||||
const { data: moderation } = useCampaignModeration();
|
||||
const visible = isOwnProfile
|
||||
? campaigns
|
||||
: campaigns.filter((c) => !moderation.hiddenCoords.has(c.aTag));
|
||||
|
||||
if (isLoading && visible.length === 0) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<RailSectionHeader icon={<Megaphone className="size-4 text-primary" />} title="Campaigns" />
|
||||
<CampaignCardSkeleton />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
const shown = visible.slice(0, RAIL_CAMPAIGN_LIMIT);
|
||||
const more = visible.length - shown.length;
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<RailSectionHeader
|
||||
icon={<Megaphone className="size-4 text-primary" />}
|
||||
title="Campaigns"
|
||||
count={visible.length}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{shown.map((c) => (
|
||||
<CampaignCard key={c.aTag} campaign={c} />
|
||||
))}
|
||||
</div>
|
||||
{(more > 0 || visible.length > 1) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSeeAll}
|
||||
className="text-sm text-primary hover:underline font-medium"
|
||||
>
|
||||
{more > 0 ? `See all ${visible.length} campaigns →` : 'View campaigns tab →'}
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Rail Latest Pledge Section ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Compact "latest pledge" card shown in the rail when the profile has
|
||||
* no campaigns. Picks the newest pledge from the supplied list (sorted
|
||||
* by `createdAt` descending) and renders it as a single small card with
|
||||
* cover, title, pledged amount, country, and deadline.
|
||||
*/
|
||||
function RailLatestPledgeSection({
|
||||
pledges,
|
||||
btcPrice,
|
||||
showSeeAll,
|
||||
onSeeAll,
|
||||
}: {
|
||||
pledges: Action[];
|
||||
btcPrice: number | undefined;
|
||||
showSeeAll: boolean;
|
||||
onSeeAll: () => void;
|
||||
}) {
|
||||
// Pick the newest pledge by created_at. The page query is roughly
|
||||
// newest-first already, but sorting here keeps the rail correct
|
||||
// regardless of upstream order.
|
||||
const latest = [...pledges].sort((a, b) => b.createdAt - a.createdAt)[0];
|
||||
if (!latest) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<RailSectionHeader
|
||||
icon={<HandHeart className="size-4 text-primary" />}
|
||||
title="Latest pledge"
|
||||
/>
|
||||
<RailPledgeCard action={latest} btcPrice={btcPrice} />
|
||||
{showSeeAll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSeeAll}
|
||||
className="text-sm text-primary hover:underline font-medium"
|
||||
>
|
||||
See all {pledges.length} pledges →
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pledge card sized for the rail's narrow column. Smaller cover
|
||||
* aspect, tighter padding, and a single-line pledged amount that doesn't
|
||||
* dominate the rail.
|
||||
*/
|
||||
function RailPledgeCard({
|
||||
action,
|
||||
btcPrice,
|
||||
}: {
|
||||
action: Action;
|
||||
btcPrice: number | undefined;
|
||||
}) {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 36639,
|
||||
pubkey: action.pubkey,
|
||||
identifier: action.id,
|
||||
});
|
||||
const cover = sanitizeUrl(action.image) ?? DEFAULT_COVER_IMAGE;
|
||||
const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null;
|
||||
const isExpired = !!deadline?.isPast;
|
||||
const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined;
|
||||
|
||||
return (
|
||||
<RouterLink
|
||||
to={`/${naddr}`}
|
||||
className="group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow group-hover:shadow-md">
|
||||
<div className="relative aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
/>
|
||||
{isExpired && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute top-2 right-2 backdrop-blur bg-background/85 border-border/40 text-[10px] uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
Ended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 space-y-1.5">
|
||||
<h3 className="font-semibold text-sm leading-snug line-clamp-2">{action.title}</h3>
|
||||
<div className="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span className="text-muted-foreground uppercase tracking-wide font-semibold">Pledged</span>
|
||||
<span className="text-foreground font-bold tabular-nums">
|
||||
{formatPledgeAmount(action.bounty, btcPrice)}
|
||||
</span>
|
||||
</div>
|
||||
{(countryLabel || deadline) && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted-foreground pt-0.5">
|
||||
{deadline && (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1',
|
||||
deadline.isPast && 'text-destructive',
|
||||
)}>
|
||||
<CalendarClock className="size-3" />
|
||||
{deadline.label}
|
||||
</span>
|
||||
)}
|
||||
{countryLabel && <span className="truncate">{countryLabel}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Rail Organizations Section ─────────────────────────────────────────────
|
||||
|
||||
function RailOrganizationsSection({ pubkey }: { pubkey: string }) {
|
||||
const { data: orgs, isLoading } = useProfileOrganizations(pubkey);
|
||||
|
||||
if (isLoading && orgs.length === 0) {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<RailSectionHeader icon={<Users className="size-4 text-primary" />} title="Organizations" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<CommunityMiniCardSkeleton key={i} className="w-full" />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (orgs.length === 0) return null;
|
||||
|
||||
const shown = orgs.slice(0, RAIL_ORG_LIMIT);
|
||||
const overflow = Math.max(0, orgs.length - shown.length);
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<RailSectionHeader
|
||||
icon={<Users className="size-4 text-primary" />}
|
||||
title="Organizations"
|
||||
count={orgs.length}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{shown.map((entry) => (
|
||||
<RailOrgCell key={entry.community.aTag} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
{overflow > 0 && (
|
||||
<OrganizationsAllDialog orgs={orgs}>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-primary hover:underline font-medium"
|
||||
>
|
||||
See all {orgs.length} →
|
||||
</button>
|
||||
</OrganizationsAllDialog>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RailOrgCell({ entry }: { entry: ProfileOrganization }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<CommunityMiniCard community={entry.community} className="w-full" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
'absolute top-2 left-2 backdrop-blur bg-background/90 border-border/40 text-[9px] font-semibold uppercase tracking-wide px-1.5 py-0.5',
|
||||
entry.isFounder ? 'text-primary' : 'text-foreground',
|
||||
)}
|
||||
>
|
||||
{entry.isFounder ? 'Founder' : 'Mod'}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section header & skeleton ──────────────────────────────────────────────
|
||||
|
||||
function RailSectionHeader({
|
||||
icon,
|
||||
title,
|
||||
count,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
count?: number;
|
||||
}) {
|
||||
return (
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{icon}
|
||||
<span>{title}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="text-xs font-normal normal-case text-muted-foreground/70">({count})</span>
|
||||
)}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
function RailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<Skeleton className="size-28 md:size-32 rounded-full -mt-16 md:-mt-20 border-4 border-background" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-4 w-full mt-2" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
<Skeleton className="h-10 w-full rounded-full" />
|
||||
<Skeleton className="h-32 w-full rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { CalendarClock, HandHeart, MapPin } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
|
||||
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
|
||||
import { getGeoDisplayName } from '@/lib/countries';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Action } from '@/hooks/useActions';
|
||||
|
||||
interface ProfilePledgesTabProps {
|
||||
pubkey: string;
|
||||
displayName: string;
|
||||
isOwnProfile: boolean;
|
||||
/** Pledges authored by this pubkey. Already filtered upstream. */
|
||||
pledges: Action[];
|
||||
/** BTC price for sats↔USD conversion in pledge amount labels. */
|
||||
btcPrice: number | undefined;
|
||||
/** True while the underlying useActions() query is still in flight. */
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pledges authored by this profile, rendered as a responsive grid that
|
||||
* mirrors the `/pledges` (`ActionsPage`) directory styling.
|
||||
*
|
||||
* v1 scope per the design plan: pledges *created* by the user.
|
||||
* "Pledges backed" (zapped submissions on others' pledges) is deferred to v2.
|
||||
*/
|
||||
export function ProfilePledgesTab({
|
||||
pubkey,
|
||||
displayName,
|
||||
isOwnProfile,
|
||||
pledges,
|
||||
btcPrice,
|
||||
isLoading,
|
||||
}: ProfilePledgesTabProps) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Loading skeleton until the first list resolves.
|
||||
if (isLoading && pledges.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6">
|
||||
<PledgesGridSkeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pledges.length === 0) {
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-12">
|
||||
<Card className="border-dashed">
|
||||
<div className="py-12 px-8 text-center">
|
||||
<HandHeart className="size-10 mx-auto mb-3 text-muted-foreground/40" />
|
||||
<p className="text-muted-foreground max-w-sm mx-auto">
|
||||
{isOwnProfile
|
||||
? "You haven't created a pledge yet."
|
||||
: `${displayName} hasn't created a pledge yet.`}
|
||||
</p>
|
||||
{isOwnProfile && (
|
||||
<RouterLink
|
||||
to="/pledges/new"
|
||||
className="inline-block mt-4 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Create a pledge →
|
||||
</RouterLink>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Split into active vs ended so the active pledges lead the grid.
|
||||
const active: Action[] = [];
|
||||
const ended: Action[] = [];
|
||||
for (const p of pledges) {
|
||||
if (p.deadline && p.deadline <= now) ended.push(p);
|
||||
else active.push(p);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-6 space-y-8" data-pubkey={pubkey}>
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
{ended.length > 0 && (
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
||||
Active
|
||||
</h3>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{active.map((pledge) => (
|
||||
<ProfilePledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{ended.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
|
||||
Ended
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{ended.map((pledge) => (
|
||||
<ProfilePledgeCard key={pledge.event.id} action={pledge} btcPrice={btcPrice} isExpired />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfilePledgeCard({
|
||||
action,
|
||||
isExpired,
|
||||
btcPrice,
|
||||
}: {
|
||||
action: Action;
|
||||
isExpired?: boolean;
|
||||
btcPrice: number | undefined;
|
||||
}) {
|
||||
const [imageLoadFailed, setImageLoadFailed] = useState(false);
|
||||
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: 36639,
|
||||
pubkey: action.pubkey,
|
||||
identifier: action.id,
|
||||
});
|
||||
|
||||
const coverImage = (action.image && !imageLoadFailed) ? action.image : DEFAULT_COVER_IMAGE;
|
||||
const deadline = action.deadline ? formatCompactPledgeDeadline(action.deadline) : null;
|
||||
const countryLabel = action.countryCode ? getGeoDisplayName(action.countryCode) : undefined;
|
||||
|
||||
return (
|
||||
<RouterLink
|
||||
to={`/${naddr}`}
|
||||
className="group block rounded-xl overflow-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-safe:transition-transform motion-safe:duration-200 motion-safe:hover:-translate-y-0.5"
|
||||
>
|
||||
<Card className="overflow-hidden border-border/70 shadow-sm motion-safe:transition-shadow motion-safe:duration-200 group-hover:shadow-lg h-full flex flex-col">
|
||||
<div className="relative w-full aspect-[16/9] bg-gradient-to-br from-primary/15 via-primary/5 to-secondary">
|
||||
<img
|
||||
src={coverImage}
|
||||
alt=""
|
||||
className="absolute inset-0 size-full object-cover"
|
||||
onError={() => setImageLoadFailed(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
{isExpired && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute top-3 right-3 backdrop-blur bg-background/85 border-border/40 text-muted-foreground"
|
||||
>
|
||||
Ended
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-5 flex-1">
|
||||
<h3 className="font-bold leading-tight tracking-tight text-lg line-clamp-2">
|
||||
{action.title}
|
||||
</h3>
|
||||
{action.description.trim() && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">{action.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/10 px-4 py-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-primary">Pledged</p>
|
||||
<p className="mt-1 text-2xl font-bold tracking-tight text-foreground">
|
||||
{formatPledgeAmount(action.bounty, btcPrice)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground pt-1">
|
||||
{countryLabel && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<MapPin className="size-3.5" />
|
||||
{countryLabel}
|
||||
</span>
|
||||
)}
|
||||
{deadline && (
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1.5',
|
||||
deadline.isPast && 'text-destructive',
|
||||
)}>
|
||||
<CalendarClock className="size-3.5" />
|
||||
{deadline.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</RouterLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PledgesGridSkeleton() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 sm:gap-5">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Card key={i} className="overflow-hidden border-border/70">
|
||||
<Skeleton className="aspect-[16/9] w-full rounded-none" />
|
||||
<div className="p-5 space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-16 w-full rounded-xl" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNavHidden } from '@/contexts/LayoutContext';
|
||||
|
||||
export interface ProfileTabsProps {
|
||||
tabs: Array<{ id: string; label: string }>;
|
||||
activeTab: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile-local tab bar.
|
||||
*
|
||||
* A focused alternative to the global `SubHeaderBar` — no arc decoration,
|
||||
* no hover slice tracking, no FAB-aware spacing. Just a clean horizontal
|
||||
* row with an animated underline marking the active tab. Used in two
|
||||
* shapes on the profile page: a 3-tab content set on desktop (Activity /
|
||||
* Campaigns / Pledges, alongside the sticky identity rail) and a 5-tab
|
||||
* set on mobile (Overview / Activity / Campaigns / Community / Pledges,
|
||||
* since the rail collapses into the Overview / Community tabs).
|
||||
*
|
||||
* Behavior:
|
||||
* - Sticks to the top of its containing scroll context. The parent column
|
||||
* can place it inside any scroll region; the bar uses `position: sticky`.
|
||||
* - Underline animates between active tabs via a single absolute-positioned
|
||||
* indicator measured from the active tab's offset/width.
|
||||
* - Horizontally scrolls when overflowing; auto-scrolls the active tab into
|
||||
* view on selection (matches the previous TabButton behavior).
|
||||
*/
|
||||
export function ProfileTabs({ tabs, activeTab, onChange }: ProfileTabsProps) {
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const tabRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const [indicator, setIndicator] = useState<{ left: number; width: number } | null>(null);
|
||||
const navHidden = useNavHidden();
|
||||
|
||||
// Measure the active tab and update the underline indicator.
|
||||
useLayoutEffect(() => {
|
||||
const btn = tabRefs.current.get(activeTab);
|
||||
if (!btn) {
|
||||
setIndicator(null);
|
||||
return;
|
||||
}
|
||||
setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth });
|
||||
}, [activeTab, tabs]);
|
||||
|
||||
// Recompute on resize (label-width changes between breakpoints).
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
const btn = tabRefs.current.get(activeTab);
|
||||
if (!btn) return;
|
||||
setIndicator({ left: btn.offsetLeft, width: btn.offsetWidth });
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [activeTab]);
|
||||
|
||||
// Scroll the active tab into view when activated (overflow scroll case).
|
||||
useLayoutEffect(() => {
|
||||
const btn = tabRefs.current.get(activeTab);
|
||||
const track = trackRef.current;
|
||||
if (!btn || !track) return;
|
||||
const left = btn.offsetLeft;
|
||||
const right = left + btn.offsetWidth;
|
||||
const viewLeft = track.scrollLeft;
|
||||
const viewRight = viewLeft + track.clientWidth;
|
||||
if (left < viewLeft) {
|
||||
track.scrollTo({ left: left - 8, behavior: 'smooth' });
|
||||
} else if (right > viewRight) {
|
||||
track.scrollTo({ left: right - track.clientWidth + 8, behavior: 'smooth' });
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Stickiness — sits at the top of the column scroll. `top-mobile-bar`
|
||||
// matches the existing app convention so it sits flush with the
|
||||
// mobile top nav. On desktop the chrome shifts and we use top-0.
|
||||
'sticky top-mobile-bar sidebar:top-0 z-10',
|
||||
// On mobile, fade + slide fully out of view when the user scrolls
|
||||
// down — otherwise the tabs sit at `top-mobile-bar` while the top
|
||||
// bar slides away, leaving a translucent gap above them, and when
|
||||
// the top bar slides back in it visibly crosses over the top of
|
||||
// the tab bar (top bar is z-20, tabs z-10).
|
||||
//
|
||||
// We can't simply use the shared `.nav-hidden-slide` utility (as
|
||||
// the global `SubHeaderBar` does) because the profile tab bar is
|
||||
// notably taller than other sub-headers and visibly gets clipped
|
||||
// by the top bar mid-transition. Pair the slide with an opacity
|
||||
// fade so the bar isn't visibly intersecting the top bar as it
|
||||
// animates.
|
||||
'max-sidebar:transition-[transform,opacity] max-sidebar:duration-300 max-sidebar:ease-in-out',
|
||||
navHidden && 'nav-hidden-slide max-sidebar:opacity-0',
|
||||
// Visual separation — translucent backdrop so feed content doesn't
|
||||
// bleed through, with a single hairline border below.
|
||||
'bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60',
|
||||
'border-b border-border/60',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
ref={trackRef}
|
||||
className="relative flex overflow-x-auto scrollbar-none"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.id === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
ref={(el) => {
|
||||
if (el) tabRefs.current.set(tab.id, el);
|
||||
else tabRefs.current.delete(tab.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (active) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} else {
|
||||
window.scrollTo({ top: 0 });
|
||||
onChange(tab.id);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative shrink-0 px-4 py-3.5 text-sm font-medium whitespace-nowrap',
|
||||
'transition-colors duration-150',
|
||||
'focus:outline-none focus-visible:bg-secondary/40 rounded-sm',
|
||||
active
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
aria-selected={active}
|
||||
role="tab"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Active-tab underline indicator. Animates between tab positions. */}
|
||||
{indicator && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 h-0.5 bg-primary rounded-full transition-all duration-200 ease-out"
|
||||
style={{ left: indicator.left, width: indicator.width }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -208,8 +208,6 @@ export interface AppConfig {
|
||||
clientName?: string;
|
||||
/** NIP-19 `naddr1…` identifying this client's kind 31990 handler event. Decoded at publish time to produce the `31990:<pubkey>:<d-tag>` addr and relay hint for the "client" tag per NIP-89. */
|
||||
client?: string;
|
||||
/** Enable Magic Mouse mode: cursor/finger emanates magical fire in the primary color */
|
||||
magicMouse: boolean;
|
||||
/** Current theme */
|
||||
theme: Theme;
|
||||
/** Custom theme config (colors, fonts, background). Only used when theme === "custom". */
|
||||
@@ -271,12 +269,78 @@ export interface AppConfig {
|
||||
/** Wildcard domain used for iframe sandboxing (e.g. "iframe.diy"). Default: "iframe.diy". */
|
||||
sandboxDomain: string;
|
||||
/**
|
||||
* Base URL for the Esplora-compatible Bitcoin REST API. Used by the wallet,
|
||||
* on-chain zap flows, and NIP-73 Bitcoin tx/address pages. The standard
|
||||
* Esplora REST root (no version segment). The mempool.space `/v1/prices`
|
||||
* extension is appended by the price call. Default: "https://mempool.space/api".
|
||||
* Ordered list of base URLs for Esplora-compatible Bitcoin REST APIs. Used
|
||||
* by the wallet, on-chain zap flows, and NIP-73 Bitcoin tx/address pages.
|
||||
* Each URL is the standard Esplora REST root (no version segment, no
|
||||
* trailing slash). The list is tried in order with exponential-backoff
|
||||
* failover on `429` / `5xx` responses — see `src/lib/esplora.ts`.
|
||||
*
|
||||
* The first entry is treated as the primary. The mempool.space `/v1/prices`
|
||||
* extension is appended by the price call; endpoints that don't speak it
|
||||
* (e.g. Blockstream's Esplora) are silently skipped via a `404` soft-failover.
|
||||
*
|
||||
* Default:
|
||||
* ```
|
||||
* [
|
||||
* 'https://mempool.space/api',
|
||||
* 'https://mempool.emzy.de/api',
|
||||
* 'https://blockstream.info/api',
|
||||
* ]
|
||||
* ```
|
||||
*/
|
||||
esploraBaseUrl: string;
|
||||
esploraApis: string[];
|
||||
/**
|
||||
* Base URL for Trezor's Blockbook API, used exclusively by the HD wallet at
|
||||
* `/wallet`. Blockbook's xpub endpoint (`/api/v2/xpub/<descriptor>`) lets
|
||||
* the HD wallet scan, balance, and pull tx history for the entire account
|
||||
* in a single HTTP call, where the equivalent Esplora workflow would be
|
||||
* dozens of per-address calls.
|
||||
*
|
||||
* No version segment, no trailing slash. The endpoint must be a real
|
||||
* Blockbook instance — Esplora-compatible servers do NOT speak the
|
||||
* `/api/v2/xpub/` path. There is no failover list and no automatic
|
||||
* fallback; HD wallet errors are surfaced to the user.
|
||||
*
|
||||
* **Privacy note**: the full account xpub is sent to this server on every
|
||||
* request. Whoever operates the configured Blockbook instance can link
|
||||
* every wallet address and observe balance over time. Default is Trezor's
|
||||
* public mirror; users who care can self-host.
|
||||
*
|
||||
* Default: `"https://btc.trezor.io"` — the canonical endpoint Trezor
|
||||
* Suite itself uses.
|
||||
*/
|
||||
blockbookBaseUrl: string;
|
||||
/**
|
||||
* Base URL of a BIP-352 tweak-data indexer (BlindBit Oracle v2-compatible),
|
||||
* used by the HD wallet at `/wallet` to detect incoming silent payments.
|
||||
*
|
||||
* The wallet derives the scan private key `bscan` locally from the user's
|
||||
* nsec and finishes the BIP-352 ECDH step itself; only public per-tx tweak
|
||||
* data and Taproot outputs come over the wire. `bscan` MUST NEVER leave the
|
||||
* device.
|
||||
*
|
||||
* Endpoints consumed (all public, no auth):
|
||||
* - `GET /info` → tip height
|
||||
* - `GET /tweaks/:height` → 33-byte compressed tweaks
|
||||
* - `GET /utxos/:height` → P2TR outputs in the block
|
||||
*
|
||||
* No version segment, no trailing slash. An empty string disables silent
|
||||
* payment scanning entirely (the wallet still displays the static `sp1q…`
|
||||
* receive address, but never resolves balances or history).
|
||||
*
|
||||
* **Privacy note**: the indexer never sees `bscan`, but it does observe the
|
||||
* sequence of block heights you ask about, paired with your IP. For a
|
||||
* backfill scan over a contiguous range that signal is uninformative; for
|
||||
* live ongoing scans the operator can correlate your IP with the wallet's
|
||||
* last-known tip. Self-hosting (or pointing this at a trusted endpoint) is
|
||||
* the strongest mitigation.
|
||||
*
|
||||
* Default: `"https://silentpayments.dev/blindbit/mainnet"` — the same
|
||||
* public BlindBit Oracle the Dana wallet (cygnet3/dana) uses. Operated by
|
||||
* the silentpayments.dev project, no authentication, no rate-limiting
|
||||
* announced. Override via `agora.json` for a self-hosted endpoint.
|
||||
*/
|
||||
bip352IndexerUrl: string;
|
||||
/**
|
||||
* Display preference for monetary amounts (zap totals, balances, send forms).
|
||||
* - "usd" (default): convert sats to USD using the live BTC price.
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useContentFilters } from '@/hooks/useContentFilters';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import { getEnabledFeedKinds } from '@/lib/extraKinds';
|
||||
import { getPaginationCursor, shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
|
||||
const AGORA_PAGE_SIZE = 25;
|
||||
const PLEDGE_KIND = 36639;
|
||||
const COMMUNITY_KIND = 34550;
|
||||
const POLL_KIND = 1068;
|
||||
const COMMENT_KIND = 1111;
|
||||
const NOTE_KIND = 1;
|
||||
const ONCHAIN_ZAP_KIND = 8333;
|
||||
const LIGHTNING_ZAP_KIND = 9735;
|
||||
|
||||
const AGORA_ENTITY_KINDS = [CAMPAIGN_KIND, PLEDGE_KIND, COMMUNITY_KIND, ONCHAIN_ZAP_KIND];
|
||||
const COMMENT_ROOT_KINDS = [String(CAMPAIGN_KIND), String(PLEDGE_KIND), String(COMMUNITY_KIND)];
|
||||
const WORLD_K_TAGS = ['iso3166', 'geo'];
|
||||
const AGORA_T_TAGS = ['agora', 'Agora'];
|
||||
const IGNORED_AGORA_NOTE_AUTHORS = new Set([
|
||||
'4fe14ef28934b4093d71d43a8c9e9ec42ab4243febfff38470bfef05f51992ec',
|
||||
]);
|
||||
|
||||
interface AgoraFeedPage {
|
||||
events: NostrEvent[];
|
||||
oldestTimestamp: number | null;
|
||||
totalFetched: number;
|
||||
}
|
||||
|
||||
function tagValues(event: NostrEvent, name: string): string[] {
|
||||
return event.tags.filter(([tagName]) => tagName === name).map(([, value]) => value).filter(Boolean);
|
||||
}
|
||||
|
||||
function hasTagValue(event: NostrEvent, name: string, values: readonly string[]): boolean {
|
||||
const accepted = new Set(values.map((value) => value.toLowerCase()));
|
||||
return tagValues(event, name).some((value) => accepted.has(value.toLowerCase()));
|
||||
}
|
||||
|
||||
function hasAgoraTag(event: NostrEvent): boolean {
|
||||
return hasTagValue(event, 't', AGORA_T_TAGS);
|
||||
}
|
||||
|
||||
function isWorldComment(event: NostrEvent): boolean {
|
||||
return event.kind === COMMENT_KIND && hasTagValue(event, 'k', WORLD_K_TAGS);
|
||||
}
|
||||
|
||||
function isWorldPoll(event: NostrEvent): boolean {
|
||||
return event.kind === POLL_KIND && hasTagValue(event, 'k', WORLD_K_TAGS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict Agora filter — accepts an event only if it is genuinely Agora-created
|
||||
* content (carries the `t:agora` marker) OR is a world-layer event (country-
|
||||
* rooted comment / poll), which is intentionally surfaced cross-client.
|
||||
*
|
||||
* See `src/lib/agoraNoteTags.ts` and `NIP.md` (§ Agora Content Marker).
|
||||
*/
|
||||
function isRelevantAgoraEvent(event: NostrEvent): boolean {
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
|
||||
// World-layer posts are kept regardless of the Agora marker.
|
||||
if (isWorldComment(event) || isWorldPoll(event)) return true;
|
||||
|
||||
// Everything else must carry the Agora content marker.
|
||||
if (!hasAgoraTag(event)) return false;
|
||||
|
||||
if (event.kind === CAMPAIGN_KIND) return true;
|
||||
if (event.kind === COMMUNITY_KIND) return true;
|
||||
if (event.kind === PLEDGE_KIND) return true;
|
||||
|
||||
if (event.kind === COMMENT_KIND) {
|
||||
// Comment must reference an Agora entity root (campaign / pledge / community).
|
||||
return hasTagValue(event, 'K', COMMENT_ROOT_KINDS)
|
||||
|| tagValues(event, 'A').some((value) => value.startsWith(`${COMMUNITY_KIND}:`));
|
||||
}
|
||||
|
||||
if (event.kind === NOTE_KIND) {
|
||||
if (IGNORED_AGORA_NOTE_AUTHORS.has(event.pubkey)) return false;
|
||||
return true; // already verified `t:agora` above
|
||||
}
|
||||
|
||||
if (event.kind === ONCHAIN_ZAP_KIND || event.kind === LIGHTNING_ZAP_KIND) {
|
||||
return hasTagValue(event, 'K', COMMENT_ROOT_KINDS) || tagValues(event, 'a').some(isAgoraAddress);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAgoraAddress(value: string): boolean {
|
||||
const kind = value.split(':')[0];
|
||||
return kind === String(CAMPAIGN_KIND) || kind === String(PLEDGE_KIND) || kind === String(COMMUNITY_KIND);
|
||||
}
|
||||
|
||||
function getAddressableCoordinate(event: NostrEvent): string | undefined {
|
||||
if (event.kind < 30000 || event.kind >= 40000) return undefined;
|
||||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
if (!d) return undefined;
|
||||
return `${event.kind}:${event.pubkey}:${d}`;
|
||||
}
|
||||
|
||||
function extractDonationTargets(events: NostrEvent[]): { coordinates: string[]; eventIds: string[] } {
|
||||
const coordinates = new Set<string>();
|
||||
const eventIds = new Set<string>();
|
||||
|
||||
for (const event of events) {
|
||||
const coord = getAddressableCoordinate(event);
|
||||
if (coord && (event.kind === CAMPAIGN_KIND || event.kind === PLEDGE_KIND)) {
|
||||
coordinates.add(coord);
|
||||
}
|
||||
|
||||
if (event.kind === COMMENT_KIND && hasTagValue(event, 'K', [String(PLEDGE_KIND)])) {
|
||||
eventIds.add(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
coordinates: Array.from(coordinates).slice(0, 40),
|
||||
eventIds: Array.from(eventIds).slice(0, 40),
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseAgoraFeedOptions {
|
||||
/**
|
||||
* Restrict the feed to events authored by these pubkeys. Applied as an
|
||||
* `authors:` filter on every relay query (server-side filtering). Empty
|
||||
* array disables the query — used for "Following" mode when the user
|
||||
* follows nobody.
|
||||
*/
|
||||
authors?: string[];
|
||||
/**
|
||||
* When true, also include events authored by `authors` in any of the
|
||||
* user's enabled "feed kinds" (notes, reposts, articles, photos,
|
||||
* videos, polls, etc. — see {@link getEnabledFeedKinds}) regardless
|
||||
* of the `t:agora` marker. Produces a unified "everything this person
|
||||
* has done on the network" feed.
|
||||
*
|
||||
* Only meaningful in combination with `authors`; setting it without
|
||||
* `authors` would flood the feed with all kind-1 notes on every relay
|
||||
* and is silently ignored.
|
||||
*
|
||||
* Used by the profile page to merge the legacy Posts tab into the
|
||||
* Activity tab. Off by default so the strict Agora home feed isn't
|
||||
* affected.
|
||||
*/
|
||||
includeAuthorNotes?: boolean;
|
||||
}
|
||||
|
||||
/** Strict Agora activity feed: campaigns, pledges, communities, world posts, #Agora notes, and donations. */
|
||||
export function useAgoraFeed(enabled: boolean, options?: UseAgoraFeedOptions) {
|
||||
const { nostr } = useNostr();
|
||||
const { muteItems } = useMuteList();
|
||||
const { shouldFilterEvent } = useContentFilters();
|
||||
const { feedSettings } = useFeedSettings();
|
||||
|
||||
const authors = options?.authors;
|
||||
const authorsKey = authors ? [...authors].sort().join(',') : '';
|
||||
// If `authors` is provided but empty, the feed is intentionally empty
|
||||
// (e.g. the user follows nobody) — skip the query entirely.
|
||||
const authorsEmpty = authors !== undefined && authors.length === 0;
|
||||
const queryEnabled = enabled && !authorsEmpty;
|
||||
// Author-scoped notes inclusion only makes sense when at least one
|
||||
// author is set; ignore the option otherwise (see option doc).
|
||||
const includeAuthorNotes = !!options?.includeAuthorNotes && !!authors && authors.length > 0;
|
||||
// Pull the user's enabled "feed kinds" — same set the legacy Posts tab
|
||||
// used. Includes notes (1), reposts (6), articles (30023), photos (20),
|
||||
// videos (21/22), polls, etc. — every kind the user opted to see in
|
||||
// mixed feeds. Memoize via stable cache-key so changing settings refetch.
|
||||
const authorNoteKinds = includeAuthorNotes ? getEnabledFeedKinds(feedSettings) : [];
|
||||
// Always include kind 1 / 6 even if the user disabled them in feed
|
||||
// settings — a profile feed without notes is broken.
|
||||
if (includeAuthorNotes && !authorNoteKinds.includes(1)) authorNoteKinds.push(1);
|
||||
if (includeAuthorNotes && !authorNoteKinds.includes(6)) authorNoteKinds.push(6);
|
||||
const authorNoteKindsKey = [...authorNoteKinds].sort((a, b) => a - b).join(',');
|
||||
|
||||
const query = useInfiniteQuery<AgoraFeedPage, Error>({
|
||||
queryKey: ['agora-feed', authorsKey, includeAuthorNotes, authorNoteKindsKey],
|
||||
queryFn: async ({ pageParam, signal: querySignal }) => {
|
||||
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
|
||||
const until = pageParam as number | undefined;
|
||||
const authorsFilter = authors && authors.length > 0 ? { authors } : {};
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
// Agora entity kinds — strict `t:agora` required.
|
||||
{ kinds: AGORA_ENTITY_KINDS, '#t': AGORA_T_TAGS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
|
||||
// Comments on Agora entities — strict `t:agora` required.
|
||||
{ kinds: [COMMENT_KIND], '#t': AGORA_T_TAGS, '#K': COMMENT_ROOT_KINDS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
|
||||
// World layer — country/geo-rooted comments and polls. Intentionally
|
||||
// cross-client; the `#k=iso3166|geo` filter is the entire gate.
|
||||
{ kinds: [COMMENT_KIND, POLL_KIND], '#k': WORLD_K_TAGS, limit: AGORA_PAGE_SIZE, ...authorsFilter, ...(until && { until }) },
|
||||
// `#Agora`-tagged kind 1 notes — accepts any author opting in via the tag.
|
||||
{ kinds: [NOTE_KIND], '#t': AGORA_T_TAGS, limit: Math.ceil(AGORA_PAGE_SIZE / 2), ...authorsFilter, ...(until && { until }) },
|
||||
];
|
||||
|
||||
// Author-scoped notes — every enabled feed kind from this author,
|
||||
// no `t:agora` requirement. Powers the unified profile feed where
|
||||
// the legacy Posts tab has been folded into Activity. The kind set
|
||||
// mirrors the user's feed settings (notes, reposts, articles,
|
||||
// photos, videos, polls, etc.) so a profile shows everything the
|
||||
// person has done across the network.
|
||||
if (includeAuthorNotes && authorNoteKinds.length > 0) {
|
||||
filters.push({
|
||||
kinds: authorNoteKinds,
|
||||
...authorsFilter,
|
||||
limit: AGORA_PAGE_SIZE,
|
||||
...(until && { until }),
|
||||
});
|
||||
}
|
||||
|
||||
const raw = await nostr.query(filters, { signal });
|
||||
// When author-notes are included, accept any event of an enabled
|
||||
// feed kind authored by one of the requested authors regardless of
|
||||
// the strict Agora gate. The strong author scope is the trust
|
||||
// anchor.
|
||||
const authorSet = new Set(authors ?? []);
|
||||
const authorKindSet = new Set(authorNoteKinds);
|
||||
const filtered = raw.filter((event) => {
|
||||
if (isRelevantAgoraEvent(event)) return true;
|
||||
if (!includeAuthorNotes) return false;
|
||||
if (!authorKindSet.has(event.kind)) return false;
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
return authorSet.has(event.pubkey);
|
||||
});
|
||||
const { coordinates, eventIds } = extractDonationTargets(filtered);
|
||||
|
||||
// Donation enrichment: pull lightning + onchain zaps that reference
|
||||
// the Agora entities visible on this page. Donation events must also
|
||||
// carry the Agora marker to be included (per `isRelevantAgoraEvent`).
|
||||
const donationFilters: NostrFilter[] = [];
|
||||
if (coordinates.length > 0) {
|
||||
donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#t': AGORA_T_TAGS, '#a': coordinates, limit: coordinates.length * 10 });
|
||||
}
|
||||
if (eventIds.length > 0) {
|
||||
donationFilters.push({ kinds: [LIGHTNING_ZAP_KIND, ONCHAIN_ZAP_KIND], '#t': AGORA_T_TAGS, '#e': eventIds, limit: eventIds.length * 10 });
|
||||
}
|
||||
|
||||
const donationEvents = donationFilters.length > 0
|
||||
? await nostr.query(donationFilters, { signal })
|
||||
: [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const combined = [
|
||||
...filtered,
|
||||
// Donation enrichment is already scoped by exact #a/#e targets from this page.
|
||||
...donationEvents.filter((event) => !shouldHideFeedEvent(event) && hasAgoraTag(event)),
|
||||
]
|
||||
.filter((event) => {
|
||||
if (seen.has(event.id)) return false;
|
||||
seen.add(event.id);
|
||||
if (muteItems.length > 0 && isEventMuted(event, muteItems)) return false;
|
||||
if (shouldFilterEvent(event)) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
const page = combined.slice(0, AGORA_PAGE_SIZE);
|
||||
const oldestTimestamp = page.length > 0 ? getPaginationCursor(page) : null;
|
||||
|
||||
return {
|
||||
events: page,
|
||||
oldestTimestamp,
|
||||
totalFetched: combined.length,
|
||||
};
|
||||
},
|
||||
initialPageParam: undefined as number | undefined,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.totalFetched < AGORA_PAGE_SIZE || !lastPage.oldestTimestamp) return undefined;
|
||||
return lastPage.oldestTimestamp - 1;
|
||||
},
|
||||
enabled: queryEnabled,
|
||||
staleTime: 30_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const events: NostrEvent[] = [];
|
||||
for (const page of query.data?.pages ?? []) {
|
||||
for (const event of page.events) {
|
||||
if (seen.has(event.id)) continue;
|
||||
seen.add(event.id);
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoading: queryEnabled ? query.isPending : false,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
hasNextPage: !authorsEmpty && query.hasNextPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
pageCount: query.data?.pages.length,
|
||||
};
|
||||
}
|
||||
@@ -30,7 +30,7 @@ interface CampaignScore {
|
||||
const EMPTY_SCORE: CampaignScore = { totalSats: 0, donorCount: 0 };
|
||||
|
||||
/**
|
||||
* Loads kind 30223 campaigns with optional Top ranking (most-zapped first)
|
||||
* Loads kind 33863 campaigns with optional Top ranking (most-zapped first)
|
||||
* and free-text search applied client-side.
|
||||
*
|
||||
* **Why client-side rather than NIP-50?** Ditto's `sort:top` / `sort:hot`
|
||||
@@ -46,7 +46,7 @@ const EMPTY_SCORE: CampaignScore = { totalSats: 0, donorCount: 0 };
|
||||
* - **Full relay coverage** — we fetch from the user's default pool, not
|
||||
* just Ditto, so campaigns published anywhere are discoverable.
|
||||
* - **Search that actually matches** — substring across title, summary,
|
||||
* story, location, and category tags.
|
||||
* and story.
|
||||
*
|
||||
* Tradeoff: we fetch up to `limit` (default 200) campaigns regardless of
|
||||
* search, then filter in JavaScript. At current campaign volume this is
|
||||
@@ -70,7 +70,7 @@ export function useAllCampaigns({
|
||||
[{ kinds: [CAMPAIGN_KIND], limit }],
|
||||
{ signal: AbortSignal.any([c.signal, AbortSignal.timeout(10_000)]) },
|
||||
);
|
||||
return parseCampaignEvents(events, { includeArchived: false, sortByCreatedAt: true });
|
||||
return parseCampaignEvents(events, { sortByCreatedAt: true });
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -185,9 +185,5 @@ function matchesQuery(campaign: ParsedCampaign, lowerQuery: string): boolean {
|
||||
if (campaign.title.toLowerCase().includes(lowerQuery)) return true;
|
||||
if (campaign.summary.toLowerCase().includes(lowerQuery)) return true;
|
||||
if (campaign.story.toLowerCase().includes(lowerQuery)) return true;
|
||||
// Location and `t` tags are short but worth matching so users can type
|
||||
// "kenya" or "mutual aid" and get useful results.
|
||||
if (campaign.location?.toLowerCase().includes(lowerQuery)) return true;
|
||||
if (campaign.tags.some((t) => t.toLowerCase().includes(lowerQuery))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
import {
|
||||
CAMPAIGN_KIND,
|
||||
parseCampaign,
|
||||
type ParsedCampaign,
|
||||
} from '@/lib/campaign';
|
||||
|
||||
interface ArchiveCampaignArgs {
|
||||
campaign: ParsedCampaign;
|
||||
/** `true` to mark archived, `false` to reopen by removing the status tag. */
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive (or reopen) a fundraising campaign without deleting it.
|
||||
*
|
||||
* Archiving republishes the campaign with `["status", "archived"]` so the
|
||||
* UI can hide it from the main fundraisers feed while still loading it by
|
||||
* direct link. Past donations remain intact because the addressable
|
||||
* coordinate (kind, pubkey, d) is unchanged.
|
||||
*
|
||||
* Unarchive removes the status tag (or any other status value), bringing
|
||||
* the campaign back into the main list.
|
||||
*
|
||||
* Only the campaign author can archive — the relay would reject anyone
|
||||
* else's republish under the same coordinate, but we also guard at the
|
||||
* UI layer.
|
||||
*/
|
||||
export function useArchiveCampaign() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ campaign, archived }: ArchiveCampaignArgs) => {
|
||||
if (!user) throw new Error('You must be logged in.');
|
||||
if (user.pubkey !== campaign.pubkey) {
|
||||
throw new Error('Only the campaign author can change its status.');
|
||||
}
|
||||
|
||||
// Read-modify-write: never trust the local cache for addressable mutations.
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [CAMPAIGN_KIND],
|
||||
authors: [user.pubkey],
|
||||
'#d': [campaign.identifier],
|
||||
});
|
||||
if (!prev || !parseCampaign(prev)) {
|
||||
throw new Error('Could not load the latest version of this campaign.');
|
||||
}
|
||||
|
||||
// Carry over every tag except any existing `status` tag, which we own here.
|
||||
const nextTags = prev.tags.filter(([name]) => name !== 'status');
|
||||
if (archived) nextTags.push(['status', 'archived']);
|
||||
|
||||
const published = await publishEvent({
|
||||
kind: CAMPAIGN_KIND,
|
||||
content: prev.content,
|
||||
tags: nextTags,
|
||||
prev,
|
||||
});
|
||||
|
||||
const parsed = parseCampaign(published);
|
||||
if (!parsed) {
|
||||
throw new Error('Updated campaign failed validation.');
|
||||
}
|
||||
return parsed;
|
||||
},
|
||||
onSuccess: (campaign) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ['campaign', campaign.pubkey, campaign.identifier],
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['campaign-featured'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -10,18 +10,18 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
*/
|
||||
export function useBitcoinAddress(address: string) {
|
||||
const { config } = useAppContext();
|
||||
const { esploraBaseUrl } = config;
|
||||
const { esploraApis } = config;
|
||||
|
||||
const { data: addressDetail, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['bitcoin-address-detail', esploraBaseUrl, address],
|
||||
queryFn: () => fetchAddressDetail(address, esploraBaseUrl),
|
||||
queryKey: ['bitcoin-address-detail', esploraApis, address],
|
||||
queryFn: ({ signal }) => fetchAddressDetail(address, esploraApis, signal),
|
||||
enabled: !!address,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: btcPrice } = useQuery({
|
||||
queryKey: ['btc-price', esploraBaseUrl],
|
||||
queryFn: () => fetchBtcPrice(esploraBaseUrl),
|
||||
queryKey: ['btc-price', esploraApis],
|
||||
queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -10,18 +10,18 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
*/
|
||||
export function useBitcoinTx(txid: string) {
|
||||
const { config } = useAppContext();
|
||||
const { esploraBaseUrl } = config;
|
||||
const { esploraApis } = config;
|
||||
|
||||
const { data: tx, isLoading, error } = useQuery({
|
||||
queryKey: ['bitcoin-tx-detail', esploraBaseUrl, txid],
|
||||
queryFn: () => fetchTxDetail(txid, esploraBaseUrl),
|
||||
queryKey: ['bitcoin-tx-detail', esploraApis, txid],
|
||||
queryFn: ({ signal }) => fetchTxDetail(txid, esploraApis, signal),
|
||||
enabled: !!txid,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const { data: btcPrice } = useQuery({
|
||||
queryKey: ['btc-price', esploraBaseUrl],
|
||||
queryFn: () => fetchBtcPrice(esploraBaseUrl),
|
||||
queryKey: ['btc-price', esploraApis],
|
||||
queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { nostrPubkeyToBitcoinAddress, fetchAddressData, fetchBtcPrice, fetchTransactions } from '@/lib/bitcoin';
|
||||
|
||||
/**
|
||||
* Hook that derives a Bitcoin Taproot address from the current user's Nostr
|
||||
* pubkey and fetches the on-chain balance from the configured Esplora-compatible
|
||||
* API (default: mempool.space).
|
||||
*
|
||||
* Balance auto-refreshes every 30 seconds while the component is mounted.
|
||||
* BTC/USD price refreshes every 60 seconds.
|
||||
*/
|
||||
export function useBitcoinWallet() {
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const { esploraBaseUrl } = config;
|
||||
|
||||
const bitcoinAddress = useMemo(() => {
|
||||
if (!user) return '';
|
||||
return nostrPubkeyToBitcoinAddress(user.pubkey);
|
||||
}, [user]);
|
||||
|
||||
const {
|
||||
data: addressData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ['bitcoin-balance', esploraBaseUrl, bitcoinAddress],
|
||||
queryFn: () => fetchAddressData(bitcoinAddress, esploraBaseUrl),
|
||||
enabled: !!bitcoinAddress,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const { data: btcPrice } = useQuery({
|
||||
queryKey: ['btc-price', esploraBaseUrl],
|
||||
queryFn: () => fetchBtcPrice(esploraBaseUrl),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const {
|
||||
data: transactions,
|
||||
isLoading: isLoadingTxs,
|
||||
} = useQuery({
|
||||
queryKey: ['bitcoin-txs', esploraBaseUrl, bitcoinAddress],
|
||||
queryFn: () => fetchTransactions(bitcoinAddress, esploraBaseUrl),
|
||||
enabled: !!bitcoinAddress,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
return {
|
||||
/** The derived bc1p... Taproot address. */
|
||||
bitcoinAddress,
|
||||
/** Balance and transaction data (undefined while loading). */
|
||||
addressData,
|
||||
/** Current BTC price in USD. */
|
||||
btcPrice,
|
||||
/** Transaction history for the address. */
|
||||
transactions,
|
||||
/** Whether the initial balance fetch is in progress. */
|
||||
isLoading,
|
||||
/** Whether transactions are still loading. */
|
||||
isLoadingTxs,
|
||||
/** Error from the balance query, if any. */
|
||||
error,
|
||||
/** Manually trigger a balance refresh. */
|
||||
refetch,
|
||||
/** The current user's hex pubkey (convenience). */
|
||||
pubkey: user?.pubkey ?? '',
|
||||
};
|
||||
}
|
||||
@@ -16,10 +16,10 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
*/
|
||||
export function useBtcPrice() {
|
||||
const { config } = useAppContext();
|
||||
const { esploraBaseUrl } = config;
|
||||
const { esploraApis } = config;
|
||||
return useQuery({
|
||||
queryKey: ['btc-price', esploraBaseUrl],
|
||||
queryFn: () => fetchBtcPrice(esploraBaseUrl),
|
||||
queryKey: ['btc-price', esploraApis],
|
||||
queryFn: ({ signal }) => fetchBtcPrice(esploraApis, signal),
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -1,76 +1,162 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQueries, useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { verifyOnchainZap, extractOnchainZapTxid, type OnchainZapEntry } from '@/hooks/useOnchainZaps';
|
||||
import { fetchAddressData } from '@/lib/bitcoin';
|
||||
import type { ParsedCampaign } from '@/lib/campaign';
|
||||
|
||||
export interface CampaignDonationStats {
|
||||
/** Total satoshis pledged across all kind 8333 receipts (self-reported sum). */
|
||||
/**
|
||||
* Total satoshis raised, sourced from the cumulative on-chain amount
|
||||
* ever received by the campaign's `w` address (`chain_stats.funded_txo_sum`
|
||||
* from Esplora). This is independent of Nostr donation receipts —
|
||||
* any payment to the address counts, and beneficiary payouts do not
|
||||
* reduce the number.
|
||||
*/
|
||||
totalSats: number;
|
||||
/** Number of unique on-chain transactions counted. */
|
||||
/** Number of unique on-chain transactions counted (from verified receipts). */
|
||||
txCount: number;
|
||||
/** Number of unique donor pubkeys. */
|
||||
/** Number of unique donor pubkeys (from verified receipts). */
|
||||
donorCount: number;
|
||||
/** All kind 8333 receipts for the campaign, newest first. */
|
||||
/** All raw kind 8333 receipts for the campaign, newest first. */
|
||||
receipts: NostrEvent[];
|
||||
/** Verified entries (one per unique txid). */
|
||||
verified: OnchainZapEntry[];
|
||||
/**
|
||||
* True while underlying queries (address balance + receipt verification)
|
||||
* are still in flight. Callers may use this to defer rendering
|
||||
* "0 sats raised" until the data has had a chance to load.
|
||||
*/
|
||||
isVerifying: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_RECEIPTS: NostrEvent[] = [];
|
||||
|
||||
/**
|
||||
* Aggregates donation receipts (kind 8333 events) for a campaign by its
|
||||
* addressable coordinate.
|
||||
* Aggregates donation statistics for a campaign.
|
||||
*
|
||||
* Each kind 8333 event's `amount` tag is the total sats paid to the
|
||||
* recipients listed in that event (see `NIP.md`). New donations publish a
|
||||
* single event per tx covering every recipient; legacy donations published
|
||||
* one event per recipient. In either case, summing `amount` across all
|
||||
* events that tag the campaign yields the campaign's total — the legacy
|
||||
* per-recipient amounts sum to the full donation, and the new per-tx
|
||||
* amount IS the full donation.
|
||||
* The headline number — `totalSats` — comes from a direct balance lookup
|
||||
* on the campaign's `w` Bitcoin address via the configured Esplora endpoint
|
||||
* (default: mempool.space). Specifically, it's `chain_stats.funded_txo_sum`,
|
||||
* the cumulative amount ever sent to the address. This means:
|
||||
*
|
||||
* The returned `totalSats` is **self-reported**. Per the NIP.md spec a
|
||||
* strict client would verify each receipt against the on-chain transaction
|
||||
* before counting it; that's left to a future iteration (see TODO inline).
|
||||
* - Donations are counted whether or not the donor publishes a Nostr
|
||||
* receipt (kind 8333).
|
||||
* - The progress bar does not regress when the beneficiary spends from
|
||||
* the address.
|
||||
* - Anyone who sends sats to the address contributes to "raised" —
|
||||
* address reuse trades off security here. Fresh-per-campaign addresses
|
||||
* (the default "public" wallet source) avoid this entirely.
|
||||
*
|
||||
* Donation receipts (kind 8333) are still fetched and verified on-chain
|
||||
* to populate the donor list, donor count, and per-tx breakdown shown in
|
||||
* the UI. They no longer contribute to `totalSats`.
|
||||
*
|
||||
* Silent-payment campaigns (`w` starts with `sp1…`) short-circuit to
|
||||
* zeros — donations are unlinkable by design, so address balance is
|
||||
* undefined.
|
||||
*/
|
||||
export function useCampaignDonations(aTag: string | undefined) {
|
||||
export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
|
||||
data: CampaignDonationStats;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['campaign-donations', aTag ?? ''],
|
||||
queryFn: async (c): Promise<CampaignDonationStats> => {
|
||||
if (!aTag) {
|
||||
return { totalSats: 0, txCount: 0, donorCount: 0, receipts: [] };
|
||||
}
|
||||
const aTag = campaign?.aTag;
|
||||
const wallet = campaign?.wallet;
|
||||
const isSilentPayment = wallet?.mode === 'sp';
|
||||
const isOnchain = wallet?.mode === 'onchain';
|
||||
const walletValue = wallet?.value;
|
||||
|
||||
// Headline number: query the address balance directly from Esplora.
|
||||
// `totalReceived` is `chain_stats.funded_txo_sum` — sats ever sent to
|
||||
// the address. Does not regress when the beneficiary spends.
|
||||
const addressQuery = useQuery({
|
||||
queryKey: ['bitcoin-balance', 'campaign', esploraApis, walletValue ?? ''],
|
||||
queryFn: ({ signal }) => fetchAddressData(walletValue!, esploraApis, signal),
|
||||
enabled: !!walletValue && isOnchain,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Donor list / breakdown: fetch kind 8333 receipts. Disabled for SP
|
||||
// campaigns (no receipts are published by design).
|
||||
const receiptsQuery = useQuery({
|
||||
queryKey: ['campaign-donations', 'events', aTag ?? ''],
|
||||
queryFn: async ({ signal }): Promise<NostrEvent[]> => {
|
||||
if (!aTag) return EMPTY_RECEIPTS;
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [8333], '#a': [aTag], limit: 500 }],
|
||||
{ signal: c.signal },
|
||||
{ signal },
|
||||
);
|
||||
|
||||
let totalSats = 0;
|
||||
const txids = new Set<string>();
|
||||
const donors = new Set<string>();
|
||||
for (const event of events) {
|
||||
const txid = event.tags.find(([n]) => n === 'i')?.[1]?.replace(/^bitcoin:tx:/, '');
|
||||
const amountTag = event.tags.find(([n]) => n === 'amount')?.[1];
|
||||
const amount = amountTag ? Number(amountTag) : NaN;
|
||||
if (!txid || !Number.isFinite(amount) || amount <= 0) continue;
|
||||
|
||||
totalSats += amount;
|
||||
txids.add(txid);
|
||||
donors.add(event.pubkey);
|
||||
}
|
||||
|
||||
// TODO: verify each txid against mempool.space and sum only the outputs
|
||||
// that pay listed recipients' derived Taproot addresses. Until then the
|
||||
// total is best-effort and trivially spoofable.
|
||||
|
||||
const receipts = [...events].sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
return {
|
||||
totalSats,
|
||||
txCount: txids.size,
|
||||
donorCount: donors.size,
|
||||
receipts,
|
||||
};
|
||||
return events;
|
||||
},
|
||||
enabled: !!aTag,
|
||||
enabled: !!aTag && !isSilentPayment,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
// Dedupe by txid; prefer the earliest receipt per tx (first to claim).
|
||||
const receipts = receiptsQuery.data ?? EMPTY_RECEIPTS;
|
||||
const dedupedByTxid = (() => {
|
||||
const byTxid = new Map<string, NostrEvent>();
|
||||
for (const event of receipts) {
|
||||
const txid = extractOnchainZapTxid(event);
|
||||
if (!txid) continue;
|
||||
const existing = byTxid.get(txid);
|
||||
if (!existing || event.created_at < existing.created_at) {
|
||||
byTxid.set(txid, event);
|
||||
}
|
||||
}
|
||||
return Array.from(byTxid.values());
|
||||
})();
|
||||
|
||||
// Verify each unique-txid receipt against the campaign's `w` wallet
|
||||
// address. The verified entries drive the donor list / breakdown UI,
|
||||
// not the headline raised total.
|
||||
const verifications = useQueries({
|
||||
queries: dedupedByTxid.map((event) => ({
|
||||
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, walletValue ?? ''],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
verifyOnchainZap(event, esploraApis, walletValue, signal),
|
||||
staleTime: 60_000,
|
||||
enabled: !!walletValue && !isSilentPayment,
|
||||
})),
|
||||
});
|
||||
|
||||
const verified: OnchainZapEntry[] = verifications
|
||||
.map((v) => v.data)
|
||||
.filter((v): v is OnchainZapEntry => !!v);
|
||||
|
||||
const totalSats = isOnchain ? (addressQuery.data?.totalReceived ?? 0) : 0;
|
||||
|
||||
const txids = new Set<string>();
|
||||
const donors = new Set<string>();
|
||||
for (const v of verified) {
|
||||
txids.add(v.txid);
|
||||
donors.add(v.senderPubkey);
|
||||
}
|
||||
|
||||
const sortedReceipts = [...receipts].sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
const isVerifying =
|
||||
!isSilentPayment &&
|
||||
(addressQuery.isLoading ||
|
||||
receiptsQuery.isLoading ||
|
||||
verifications.some((v) => v.isLoading));
|
||||
|
||||
return {
|
||||
data: {
|
||||
totalSats,
|
||||
txCount: txids.size,
|
||||
donorCount: donors.size,
|
||||
receipts: sortedReceipts,
|
||||
verified,
|
||||
isVerifying,
|
||||
},
|
||||
isLoading: isVerifying,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,130 +1,31 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { useNostrPublish } from './useNostrPublish';
|
||||
import { useCampaignModerators } from './useCampaignModerators';
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import {
|
||||
AGORA_MODERATION_NAMESPACE,
|
||||
EMPTY_MODERATION_DATA,
|
||||
LABEL_KIND,
|
||||
type ModerationData,
|
||||
type ModerationLabel,
|
||||
type ModerationState,
|
||||
foldModerationLabels,
|
||||
} from '@/lib/agoraModeration';
|
||||
|
||||
/** NIP-32 label kind. */
|
||||
const LABEL_KIND = 1985;
|
||||
/** Label namespace for Agora's moderation labels. */
|
||||
export const AGORA_MODERATION_NAMESPACE = 'agora.moderation';
|
||||
|
||||
/** The six possible label values in the moderation namespace. */
|
||||
export type ModerationLabel =
|
||||
| 'approved'
|
||||
| 'unapproved'
|
||||
| 'hidden'
|
||||
| 'unhidden'
|
||||
| 'featured'
|
||||
| 'unfeatured';
|
||||
|
||||
/** A single label event narrowed to its decision axis. */
|
||||
interface AxisDecision {
|
||||
/** Latest label observed on this axis. */
|
||||
label: ModerationLabel;
|
||||
/** Author of the latest label. */
|
||||
pubkey: string;
|
||||
/** Created-at of the latest label. */
|
||||
createdAt: number;
|
||||
}
|
||||
// Re-exports for existing import sites. The namespace constant and the
|
||||
// `ModerationLabel` type are imported from this module by the campaign
|
||||
// moderation menu and other surfaces; keep those exports stable so the
|
||||
// shared-module refactor stays a no-op for callers.
|
||||
export { AGORA_MODERATION_NAMESPACE };
|
||||
export type { ModerationLabel };
|
||||
|
||||
/** Per-campaign rollup of approval + hide + featured state. */
|
||||
export interface CampaignModerationState {
|
||||
approval?: AxisDecision; // `approved` or `unapproved`
|
||||
hide?: AxisDecision; // `hidden` or `unhidden`
|
||||
featured?: AxisDecision; // `featured` or `unfeatured`
|
||||
}
|
||||
export type CampaignModerationState = ModerationState;
|
||||
|
||||
export interface CampaignModerationData {
|
||||
/** Map of `30223:<pubkey>:<d>` -> rollup. */
|
||||
byCoord: Map<string, CampaignModerationState>;
|
||||
/** Coordinates where the latest approval label is `approved`. */
|
||||
approvedCoords: Set<string>;
|
||||
/** Coordinates where the latest hide label is `hidden`. */
|
||||
hiddenCoords: Set<string>;
|
||||
/** Coordinates where the latest featured label is `featured`. */
|
||||
featuredCoords: Set<string>;
|
||||
/**
|
||||
* Map of `coord` -> `created_at` of the latest `featured` label.
|
||||
* Used to sort the home-page featured row newest-first.
|
||||
*/
|
||||
featuredOrder: Map<string, number>;
|
||||
/** Pubkeys that were considered moderators when the query ran. */
|
||||
moderators: string[];
|
||||
}
|
||||
|
||||
const EMPTY_DATA: CampaignModerationData = {
|
||||
byCoord: new Map(),
|
||||
approvedCoords: new Set(),
|
||||
hiddenCoords: new Set(),
|
||||
featuredCoords: new Set(),
|
||||
featuredOrder: new Map(),
|
||||
moderators: [],
|
||||
};
|
||||
|
||||
/** True if a label value belongs to the approval axis. */
|
||||
function isApprovalLabel(value: string): value is 'approved' | 'unapproved' {
|
||||
return value === 'approved' || value === 'unapproved';
|
||||
}
|
||||
|
||||
/** True if a label value belongs to the hide axis. */
|
||||
function isHideLabel(value: string): value is 'hidden' | 'unhidden' {
|
||||
return value === 'hidden' || value === 'unhidden';
|
||||
}
|
||||
|
||||
/** True if a label value belongs to the featured axis. */
|
||||
function isFeaturedLabel(value: string): value is 'featured' | 'unfeatured' {
|
||||
return value === 'featured' || value === 'unfeatured';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a flat list of label events into per-coordinate rollups by axis.
|
||||
* The newest event per `(coord, axis)` wins. Events not addressing a
|
||||
* campaign coordinate or carrying a value outside the namespace are dropped.
|
||||
*/
|
||||
function foldLabelEvents(events: NostrEvent[], moderators: string[]): CampaignModerationData {
|
||||
const byCoord = new Map<string, CampaignModerationState>();
|
||||
|
||||
for (const event of events) {
|
||||
const value = event.tags.find(([n, , ns]) => n === 'l' && ns === AGORA_MODERATION_NAMESPACE)?.[1];
|
||||
if (!value) continue;
|
||||
const aTag = event.tags.find(([n, v]) => n === 'a' && typeof v === 'string' && v.startsWith(`${CAMPAIGN_KIND}:`))?.[1];
|
||||
if (!aTag) continue;
|
||||
|
||||
const state = byCoord.get(aTag) ?? {};
|
||||
if (isApprovalLabel(value)) {
|
||||
if (!state.approval || event.created_at > state.approval.createdAt) {
|
||||
state.approval = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
} else if (isHideLabel(value)) {
|
||||
if (!state.hide || event.created_at > state.hide.createdAt) {
|
||||
state.hide = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
} else if (isFeaturedLabel(value)) {
|
||||
if (!state.featured || event.created_at > state.featured.createdAt) {
|
||||
state.featured = { label: value, pubkey: event.pubkey, createdAt: event.created_at };
|
||||
}
|
||||
}
|
||||
byCoord.set(aTag, state);
|
||||
}
|
||||
|
||||
const approvedCoords = new Set<string>();
|
||||
const hiddenCoords = new Set<string>();
|
||||
const featuredCoords = new Set<string>();
|
||||
const featuredOrder = new Map<string, number>();
|
||||
for (const [coord, state] of byCoord) {
|
||||
if (state.approval?.label === 'approved') approvedCoords.add(coord);
|
||||
if (state.hide?.label === 'hidden') hiddenCoords.add(coord);
|
||||
if (state.featured?.label === 'featured') {
|
||||
featuredCoords.add(coord);
|
||||
featuredOrder.set(coord, state.featured.createdAt);
|
||||
}
|
||||
}
|
||||
|
||||
return { byCoord, approvedCoords, hiddenCoords, featuredCoords, featuredOrder, moderators };
|
||||
}
|
||||
/** Surface-scoped alias so existing callers keep working. */
|
||||
export type CampaignModerationData = ModerationData;
|
||||
|
||||
/**
|
||||
* Fetches and folds campaign-moderation label events authored by Team
|
||||
@@ -154,7 +55,8 @@ export function useCampaignModeration() {
|
||||
// an empty `authors:` filter (which would return everything matching the
|
||||
// namespace from any author and break our trust model — see AGENTS.md).
|
||||
// Once moderators arrives empty, the query runs and immediately resolves
|
||||
// to EMPTY_DATA — no rendering can promote a campaign without a moderator.
|
||||
// to EMPTY_MODERATION_DATA — no rendering can promote a campaign without
|
||||
// a moderator.
|
||||
const moderatorsKey = moderators ? [...moderators].sort().join(',') : '';
|
||||
|
||||
const moderationQuery = useQuery({
|
||||
@@ -162,7 +64,7 @@ export function useCampaignModeration() {
|
||||
enabled: moderators !== undefined,
|
||||
queryFn: async ({ signal }): Promise<CampaignModerationData> => {
|
||||
if (!moderators || moderators.length === 0) {
|
||||
return { ...EMPTY_DATA, moderators: [] };
|
||||
return { ...EMPTY_MODERATION_DATA, moderators: [] };
|
||||
}
|
||||
const events = await nostr.query(
|
||||
[
|
||||
@@ -179,7 +81,7 @@ export function useCampaignModeration() {
|
||||
],
|
||||
{ signal: AbortSignal.any([signal, AbortSignal.timeout(8000)]) },
|
||||
);
|
||||
return foldLabelEvents(events, moderators);
|
||||
return foldModerationLabels(events, moderators, CAMPAIGN_KIND);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -203,11 +105,18 @@ export function useCampaignModeration() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['campaign-moderation'] });
|
||||
// Moderation decisions (approve / hide / feature) gate which campaigns
|
||||
// surface on the home page, discover shelf, and community grids — so
|
||||
// the list queries need to refetch too, otherwise the moderator's UI
|
||||
// still shows the old approval state until refresh.
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns-all'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns-all-scores'] });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: moderationQuery.data ?? EMPTY_DATA,
|
||||
data: moderationQuery.data ?? EMPTY_MODERATION_DATA,
|
||||
isPending: moderationQuery.isPending,
|
||||
isLoading: moderationQuery.isLoading,
|
||||
isReady: moderationQuery.isSuccess,
|
||||
|
||||
@@ -2,16 +2,10 @@ import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { CAMPAIGN_KIND, type CampaignCategory, parseCampaign, type ParsedCampaign } from '@/lib/campaign';
|
||||
import { CAMPAIGN_KIND, parseCampaign, type ParsedCampaign } from '@/lib/campaign';
|
||||
import { createCountryIdentifier } from '@/lib/countryIdentifiers';
|
||||
|
||||
interface ParseCampaignEventsOptions {
|
||||
/**
|
||||
* Include campaigns whose latest revision carries `["status", "archived"]`.
|
||||
* Defaults to `false` so archived campaigns never appear in the main
|
||||
* fundraisers listing.
|
||||
*/
|
||||
includeArchived?: boolean;
|
||||
/**
|
||||
* When `true`, sort the parsed campaigns newest-`created_at`-first.
|
||||
* When `false`, preserve the order in which events were returned —
|
||||
@@ -23,7 +17,7 @@ interface ParseCampaignEventsOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate, parse, and (optionally) reorder a flat list of kind 30223
|
||||
* Deduplicate, parse, and (optionally) reorder a flat list of kind 33863
|
||||
* events into `ParsedCampaign` objects.
|
||||
*
|
||||
* For each `(pubkey, d)` pair we keep only the latest event — relays may
|
||||
@@ -35,7 +29,7 @@ interface ParseCampaignEventsOptions {
|
||||
*/
|
||||
export function parseCampaignEvents(
|
||||
events: NostrEvent[],
|
||||
{ includeArchived = false, sortByCreatedAt = true }: ParseCampaignEventsOptions = {},
|
||||
{ sortByCreatedAt = true }: ParseCampaignEventsOptions = {},
|
||||
): ParsedCampaign[] {
|
||||
// Track insertion order keyed by coord so we can preserve relay-scored
|
||||
// order when we don't want to re-sort. `Map` iteration order is insertion
|
||||
@@ -62,7 +56,6 @@ export function parseCampaignEvents(
|
||||
if (!event) continue;
|
||||
const campaign = parseCampaign(event);
|
||||
if (!campaign) continue;
|
||||
if (!includeArchived && campaign.archived) continue;
|
||||
parsed.push(campaign);
|
||||
}
|
||||
|
||||
@@ -74,8 +67,6 @@ export function parseCampaignEvents(
|
||||
}
|
||||
|
||||
interface UseCampaignsOptions {
|
||||
/** Optional category filter (`t` tag). */
|
||||
category?: CampaignCategory;
|
||||
/** Optional ISO 3166-1 alpha-2 country filter (`i` tag). */
|
||||
countryCode?: string;
|
||||
/** Maximum number of events to fetch from relays. Default: 60. */
|
||||
@@ -83,12 +74,7 @@ interface UseCampaignsOptions {
|
||||
/** Authors to fetch from, e.g. for a profile's campaigns. */
|
||||
authors?: string[];
|
||||
/**
|
||||
* Restrict to campaigns whose recipient `p` tags include any of these
|
||||
* pubkeys. Used by the /claim page to find campaigns set up *for* a user.
|
||||
*/
|
||||
recipientPubkeys?: string[];
|
||||
/**
|
||||
* Restrict to a specific set of `30223:<pubkey>:<d>` coordinates.
|
||||
* Restrict to a specific set of `33863:<pubkey>:<d>` coordinates.
|
||||
*
|
||||
* Used by moderator-curated surfaces (the home page, Discover) that only
|
||||
* want to render campaigns labeled `approved` by a Team Soapbox moderator
|
||||
@@ -102,24 +88,19 @@ interface UseCampaignsOptions {
|
||||
* the unfiltered behavior while their moderator query loads.
|
||||
*/
|
||||
coordinates?: string[];
|
||||
/**
|
||||
* Include campaigns that have been archived by their creator
|
||||
* (`["status", "archived"]`). Defaults to `false` so archived
|
||||
* campaigns never appear in the main fundraisers listing.
|
||||
*/
|
||||
includeArchived?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads kind 30223 campaign events and returns them as fully-parsed
|
||||
* Loads kind 33863 campaign events and returns them as fully-parsed
|
||||
* {@link ParsedCampaign} objects, newest first.
|
||||
*
|
||||
* Campaigns that fail validation (missing title, no recipients, etc.) are
|
||||
* Campaigns that fail validation (missing title, no `w` wallet, etc.) are
|
||||
* dropped so the UI never has to defensively check for missing fields.
|
||||
*
|
||||
* Archived campaigns (`status=archived`) are excluded by default. Pass
|
||||
* `includeArchived: true` to load them — used by the author's own profile
|
||||
* view so they can see and reopen their own archives.
|
||||
* To stop a campaign from appearing the creator publishes a NIP-09 kind 5
|
||||
* deletion request referencing the campaign's `a` coordinate; well-behaved
|
||||
* relays honor the deletion and the campaign drops out of result sets
|
||||
* automatically.
|
||||
*
|
||||
* For each `(pubkey, d)` pair we keep only the latest event — relays may
|
||||
* return older revisions of an addressable event alongside the current one.
|
||||
@@ -127,13 +108,10 @@ interface UseCampaignsOptions {
|
||||
export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
const { nostr } = useNostr();
|
||||
const {
|
||||
category,
|
||||
countryCode,
|
||||
limit = 60,
|
||||
authors,
|
||||
recipientPubkeys,
|
||||
coordinates,
|
||||
includeArchived = false,
|
||||
} = options;
|
||||
|
||||
// Stable cache key for the coordinates option; sort so order doesn't
|
||||
@@ -143,7 +121,7 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
'campaigns',
|
||||
{ category, countryCode, limit, authors, recipientPubkeys, coordinatesKey, includeArchived },
|
||||
{ countryCode, limit, authors, coordinatesKey },
|
||||
],
|
||||
queryFn: async (c) => {
|
||||
// Sentinel: empty allowlist = empty result. Skip the relay entirely.
|
||||
@@ -156,7 +134,7 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
if (coordinates && coordinates.length > 0) {
|
||||
const byAuthor = new Map<string, string[]>();
|
||||
for (const coord of coordinates) {
|
||||
// Expected: `30223:<pubkey>:<d>`
|
||||
// Expected: `33863:<pubkey>:<d>`
|
||||
const parts = coord.split(':');
|
||||
if (parts.length < 3) continue;
|
||||
const kindPart = Number(parts[0]);
|
||||
@@ -171,23 +149,18 @@ export function useCampaigns(options: UseCampaignsOptions = {}) {
|
||||
if (byAuthor.size === 0) return [] as ParsedCampaign[];
|
||||
filters = Array.from(byAuthor, ([author, dTags]) => {
|
||||
const f: NostrFilter = { kinds: [CAMPAIGN_KIND], authors: [author], '#d': dTags };
|
||||
if (category) f['#t'] = [category];
|
||||
if (countryCode) f['#i'] = [createCountryIdentifier(countryCode)];
|
||||
return f;
|
||||
});
|
||||
} else {
|
||||
const filter: NostrFilter = { kinds: [CAMPAIGN_KIND], limit };
|
||||
if (category) filter['#t'] = [category];
|
||||
if (countryCode) filter['#i'] = [createCountryIdentifier(countryCode)];
|
||||
if (authors && authors.length > 0) filter.authors = authors;
|
||||
if (recipientPubkeys && recipientPubkeys.length > 0) {
|
||||
filter['#p'] = recipientPubkeys;
|
||||
}
|
||||
filters = [filter];
|
||||
}
|
||||
|
||||
const events = await nostr.query(filters, { signal: c.signal });
|
||||
return parseCampaignEvents(events, { includeArchived, sortByCreatedAt: true });
|
||||
return parseCampaignEvents(events, { sortByCreatedAt: true });
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { getStorageKey } from '@/lib/storageKey';
|
||||
|
||||
/**
|
||||
* Sentinel value for "post to the global / world feed" (a plain kind 1 note
|
||||
* with no country root). Matches the value used by ComposeBox's `destination`
|
||||
* state.
|
||||
*/
|
||||
export type PostCountryDestination = 'world' | string;
|
||||
|
||||
const WORLD = 'world' as const;
|
||||
const STORAGE_SUFFIX = 'compose-default-country';
|
||||
|
||||
function readStored(key: string): PostCountryDestination {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw || raw === WORLD) return WORLD;
|
||||
// Validate against the country directory so a stale code (or an
|
||||
// invalid string from a different version) doesn't pin the composer
|
||||
// to a country that no longer parses.
|
||||
return getCountryInfo(raw) ? raw : WORLD;
|
||||
} catch {
|
||||
return WORLD;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's preferred default post destination — either `'world'` (plain
|
||||
* kind 1) or an ISO 3166 country code (NIP-22 country-rooted kind 1111).
|
||||
*
|
||||
* Persisted to localStorage so the choice survives reloads. Hydrates
|
||||
* synchronously from storage on first render, so the composer never flashes
|
||||
* the wrong default.
|
||||
*
|
||||
* The act of selecting a destination in ComposeBox does NOT auto-save the
|
||||
* default — there is an explicit "Set as default" affordance for that. This
|
||||
* means a user posting once to a country they don't normally post to does
|
||||
* not unintentionally change their default.
|
||||
*/
|
||||
export function useDefaultPostCountry(): [
|
||||
PostCountryDestination,
|
||||
(value: PostCountryDestination) => void,
|
||||
] {
|
||||
const { config } = useAppContext();
|
||||
const key = getStorageKey(config.appId, STORAGE_SUFFIX);
|
||||
|
||||
const [value, setValue] = useState<PostCountryDestination>(() => readStored(key));
|
||||
|
||||
const setDefault = useCallback(
|
||||
(next: PostCountryDestination) => {
|
||||
setValue(next);
|
||||
try {
|
||||
if (next === WORLD) {
|
||||
localStorage.setItem(key, WORLD);
|
||||
} else if (getCountryInfo(next)) {
|
||||
localStorage.setItem(key, next);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable — non-critical.
|
||||
}
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return [value, setDefault];
|
||||
}
|
||||
@@ -13,6 +13,71 @@ interface DeleteEventParams {
|
||||
eventDTag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefixes of query keys that can contain a deleted event and therefore
|
||||
* need a refetch after a NIP-09 deletion. This is wider than the average
|
||||
* mutation invalidation — a deletion is inherently cross-cutting (a single
|
||||
* post can sit in the main feed, the author's profile feed, a country
|
||||
* feed, the community activity feed, a comment thread, and more), and
|
||||
* over-invalidating is cheap relative to leaving stale items on screen.
|
||||
*/
|
||||
const FEED_INVALIDATION_PREFIXES: ReadonlySet<string> = new Set([
|
||||
// Generic / legacy feed
|
||||
'feed',
|
||||
// Agora country feeds
|
||||
'agora-feed',
|
||||
'agora-feed-paginated',
|
||||
'agora-feed-new-posts',
|
||||
// Home mixed-mode feed (composes useAgoraFeed + useNostrLayer)
|
||||
'mixed-feed',
|
||||
'nostr-layer',
|
||||
// Profile + likes
|
||||
'profile-feed',
|
||||
'profile-likes-infinite',
|
||||
'profile-media',
|
||||
'profile-pinned-events',
|
||||
// Replies + comments
|
||||
'replies',
|
||||
'nostr', // useComments (NIP-22) uses ['nostr', 'comments', ...]
|
||||
'event-comments',
|
||||
'wall-comments',
|
||||
'pinned-event-comments',
|
||||
'pinned-event-comments-list',
|
||||
// Notifications
|
||||
'notifications',
|
||||
'notifications-unread',
|
||||
// Campaigns & pledges
|
||||
'campaign',
|
||||
'campaigns',
|
||||
'campaigns-all',
|
||||
'campaigns-all-scores',
|
||||
'agora-action',
|
||||
'agora-actions',
|
||||
'community-actions',
|
||||
// Community / org activity surfaces
|
||||
'community-activity-feed',
|
||||
'organization-activity',
|
||||
'organization-home-activity-feed',
|
||||
// Trending & curated
|
||||
'trending',
|
||||
'trending-posts',
|
||||
'sorted-posts',
|
||||
'infinite-sorted-posts',
|
||||
'infinite-hot-feed',
|
||||
'ditto-curated-feed',
|
||||
'world-feed',
|
||||
'following-feed',
|
||||
'following-country-feed',
|
||||
'following-hashtag-feed',
|
||||
'my-feed',
|
||||
'tab-feed',
|
||||
'relay-feed',
|
||||
'domain-feed',
|
||||
// Misc per-event caches
|
||||
'event',
|
||||
'addr-event',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Hook to publish a kind 5 deletion request event (NIP-09).
|
||||
*
|
||||
@@ -20,8 +85,11 @@ interface DeleteEventParams {
|
||||
* an `e` tag and an `a` tag so it works on relays that only support
|
||||
* e-tag deletion as well as relays that support a-tag deletion.
|
||||
*
|
||||
* After publishing, invalidates feed caches so relays are re-queried
|
||||
* and the deleted event is no longer returned.
|
||||
* After publishing, invalidates every feed-shaped cache so relays are
|
||||
* re-queried and the deleted event is no longer returned. The set is
|
||||
* deliberately broad — deletions are user-initiated and rare, and the
|
||||
* cost of an extra refetch is much smaller than the cost of leaving a
|
||||
* deleted post visible across the app.
|
||||
*/
|
||||
export function useDeleteEvent() {
|
||||
const { user } = useCurrentUser();
|
||||
@@ -52,13 +120,14 @@ export function useDeleteEvent() {
|
||||
return eventId;
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalidate feed queries so relays are re-queried.
|
||||
// The relay should no longer return the deleted event.
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['profile-likes-infinite'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['replies'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
// Invalidate every feed-shaped query so relays are re-queried and
|
||||
// the deleted event drops out of every surface it appeared on.
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (q) => {
|
||||
const root = q.queryKey[0];
|
||||
return typeof root === 'string' && FEED_INVALIDATION_PREFIXES.has(root);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { CAMPAIGN_KIND } from '@/lib/campaign';
|
||||
import { shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
|
||||
const DISCOVER_PAGE_SIZE = 30;
|
||||
|
||||
/**
|
||||
* Kinds surfaced in the mixed Discover feed:
|
||||
*
|
||||
* - **30223** — new campaign creations (addressable). When a campaign is
|
||||
* minted or revised it bubbles back to the top, the same way a new
|
||||
* Substack post would.
|
||||
* - **1111** — NIP-22 comments. We pull two slices: comments scoped to
|
||||
* countries (`#K = iso3166`) and comments scoped to communities
|
||||
* (`#K = 34550`). Together these are "posts from the world" + "voices
|
||||
* inside the communities".
|
||||
* - **36639** — Agora pledges (challenges / civic calls). Always
|
||||
* included because they're the most action-oriented funding signal.
|
||||
*
|
||||
* We deliberately *exclude* free-form kind 1 notes here — the Discover
|
||||
* page is the place to see content that's tagged to a real-world thread
|
||||
* (country, community, campaign), not the global text-note firehose. The
|
||||
* old plain feed still lives at `/feed`.
|
||||
*/
|
||||
|
||||
/** Tag scopes we accept on kind 1111 comments. */
|
||||
const COMMENT_K_SCOPES = ['iso3166', 'geo', '34550'];
|
||||
|
||||
/** Aliases we accept on kind 36639 pledge `t` tags. */
|
||||
const ACTION_T_ALIASES = ['agora-action', 'pathos-challenge', 'agora-challenge'];
|
||||
|
||||
/**
|
||||
* Apply Discover-specific filtering after relay fetch. Drops events that
|
||||
* `shouldHideFeedEvent` flags (mutes, content filters happen later) and
|
||||
* any 1111 comment that lacks a recognised scope tag, since relays may
|
||||
* over-return when we union filters.
|
||||
*/
|
||||
function filterDiscoverEvents(events: NostrEvent[]): NostrEvent[] {
|
||||
return events
|
||||
.filter((event) => {
|
||||
if (shouldHideFeedEvent(event)) return false;
|
||||
if (event.kind === 1111) {
|
||||
const kTags = event.tags
|
||||
.filter(([n]) => n === 'k' || n === 'K')
|
||||
.map(([, v]) => v);
|
||||
return kTags.some((v) => COMMENT_K_SCOPES.includes(v));
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public infinite feed for the Discover page. Streams together new
|
||||
* campaigns, world-tagged comments, community comments, and Agora
|
||||
* pledges, paginated by `created_at` cursor.
|
||||
*
|
||||
* Each page issues exactly one relay request (the union of all relevant
|
||||
* filters) to stay inside per-page rate budgets — the same pattern
|
||||
* `useWorldFeed` uses.
|
||||
*
|
||||
* Returns the standard `useInfiniteQuery` surface plus a flattened
|
||||
* `events` list for convenient consumption.
|
||||
*/
|
||||
export function useDiscoverFeed(enabled = true) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ['discover-feed'],
|
||||
queryFn: async ({ pageParam, signal: querySignal }) => {
|
||||
const signal = AbortSignal.any([querySignal, AbortSignal.timeout(8_000)]);
|
||||
const until = pageParam as number | undefined;
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
// New / revised campaigns — addressable, so we lean on a small
|
||||
// limit and let the relay's natural newest-first ordering surface
|
||||
// recent edits. No `#k` scoping needed.
|
||||
{
|
||||
kinds: [CAMPAIGN_KIND],
|
||||
limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
|
||||
...(until && { until }),
|
||||
},
|
||||
// Community + country-scoped comments.
|
||||
{
|
||||
kinds: [1111],
|
||||
'#K': COMMENT_K_SCOPES,
|
||||
limit: DISCOVER_PAGE_SIZE,
|
||||
...(until && { until }),
|
||||
},
|
||||
// Agora pledges.
|
||||
{
|
||||
kinds: [36639],
|
||||
'#t': ACTION_T_ALIASES,
|
||||
limit: Math.floor(DISCOVER_PAGE_SIZE / 3),
|
||||
...(until && { until }),
|
||||
},
|
||||
];
|
||||
|
||||
const raw = await nostr.query(filters, { signal });
|
||||
const filtered = filterDiscoverEvents(raw);
|
||||
const page = filtered.slice(0, DISCOVER_PAGE_SIZE);
|
||||
|
||||
const oldestTimestamp = page.length > 0
|
||||
? page[page.length - 1].created_at
|
||||
: null;
|
||||
|
||||
return {
|
||||
events: page,
|
||||
oldestTimestamp,
|
||||
totalFetched: filtered.length,
|
||||
};
|
||||
},
|
||||
initialPageParam: undefined as number | undefined,
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (lastPage.totalFetched < DISCOVER_PAGE_SIZE || !lastPage.oldestTimestamp) {
|
||||
return undefined;
|
||||
}
|
||||
return lastPage.oldestTimestamp - 1;
|
||||
},
|
||||
enabled,
|
||||
staleTime: 30_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
// Flatten + dedupe. Each addressable event may legitimately appear
|
||||
// across pages if a newer revision lands; we keep the newest version.
|
||||
const seen = new Set<string>();
|
||||
const events: NostrEvent[] = [];
|
||||
for (const page of query.data?.pages ?? []) {
|
||||
for (const event of page.events) {
|
||||
if (seen.has(event.id)) continue;
|
||||
seen.add(event.id);
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events,
|
||||
isLoading: query.isPending,
|
||||
isFetchingNextPage: query.isFetchingNextPage,
|
||||
hasNextPage: query.hasNextPage,
|
||||
fetchNextPage: query.fetchNextPage,
|
||||
pageCount: query.data?.pages.length,
|
||||
};
|
||||
}
|
||||
@@ -5,25 +5,25 @@ import { isSignerCapabilityError, reportSignerUnsupported, useBitcoinSigner } fr
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import {
|
||||
BITCOIN_DUST_LIMIT,
|
||||
broadcastTransaction,
|
||||
buildUnsignedMultiOutputPsbt,
|
||||
buildUnsignedPsbt,
|
||||
fetchUTXOs,
|
||||
finalizePsbt,
|
||||
getFeeRates,
|
||||
nostrPubkeyToBitcoinAddress,
|
||||
} from '@/lib/bitcoin';
|
||||
import type { FeeRates } from '@/lib/bitcoin';
|
||||
import { minDonationForSplit, type ParsedCampaign, splitDonation } from '@/lib/campaign';
|
||||
import { CAMPAIGN_KIND, type ParsedCampaign } from '@/lib/campaign';
|
||||
import { withAgoraTag } from '@/lib/agoraNoteTags';
|
||||
|
||||
/** Supported on-chain fee speeds (mirrors {@link SendBitcoinDialog}). */
|
||||
export type DonationFeeSpeed = 'fastest' | 'halfHour' | 'hour' | 'economy';
|
||||
|
||||
export interface DonateCampaignArgs {
|
||||
campaign: ParsedCampaign;
|
||||
/** Total donation amount in satoshis. Split across recipients per the campaign weights. */
|
||||
/** Donation amount in satoshis. */
|
||||
amountSats: number;
|
||||
/** Optional public comment included in each kind 8333 receipt. */
|
||||
/** Optional public comment included in the kind 8333 receipt. */
|
||||
comment?: string;
|
||||
/** Fee speed for the on-chain tx. Default: `halfHour`. */
|
||||
feeSpeed?: DonationFeeSpeed;
|
||||
@@ -34,9 +34,7 @@ export interface DonateCampaignResult {
|
||||
txid: string;
|
||||
/** On-chain fee paid in satoshis. */
|
||||
fee: number;
|
||||
/** Number of recipients that received funds in the tx. */
|
||||
recipientCount: number;
|
||||
/** Total sent to recipients (donation amount; excludes fee). */
|
||||
/** Sats paid to the campaign wallet (excludes fee and donor change). */
|
||||
totalSats: number;
|
||||
/** Whether the kind 8333 donation receipt published successfully. */
|
||||
receiptPublished: boolean;
|
||||
@@ -58,17 +56,29 @@ function errorMessage(error: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutation hook that sends a single multi-output Bitcoin transaction to all
|
||||
* of a campaign's recipients (split per their weights), broadcasts it via
|
||||
* mempool.space, and then publishes a single kind 8333 onchain-zap receipt
|
||||
* for the transaction referencing the campaign's addressable coordinate and
|
||||
* listing every recipient under its own `p` tag.
|
||||
* Mutation hook that donates to a campaign by paying its declared wallet
|
||||
* endpoint with a single Bitcoin transaction, then publishing a kind 8333
|
||||
* donation receipt referencing the campaign's addressable coordinate.
|
||||
*
|
||||
* Returns an async function that throws on any pre-broadcast failure
|
||||
* (insufficient funds, signer not available, dust, etc.). Once the tx is
|
||||
* broadcast, the function always resolves: a kind 8333 publish failure is
|
||||
* reported in {@link DonateCampaignResult.receiptPublished} rather than
|
||||
* thrown, because the donation itself is already final on-chain.
|
||||
* The campaign's `w` tag drives the destination:
|
||||
*
|
||||
* - **on-chain** (`bc1q…` / `bc1p…`) — the donor's client builds a
|
||||
* single-output PSBT paying the campaign address, broadcasts it, then
|
||||
* publishes a kind 8333 receipt with no `p` tags (campaigns are not
|
||||
* Nostr-identity recipients; verification matches tx outputs against
|
||||
* the campaign's `w` address).
|
||||
* - **silent payment** (`sp1…`) — this hook refuses the request.
|
||||
* Donating to a silent-payment campaign requires a BIP-352-aware
|
||||
* wallet that derives a fresh one-time output from the SP code; the
|
||||
* in-app Taproot signer does not support that. Donors are directed to
|
||||
* an external wallet via a copy/QR affordance instead, and no Nostr
|
||||
* event is ever published.
|
||||
*
|
||||
* Throws on any pre-broadcast failure (insufficient funds, signer not
|
||||
* available, SP mode, etc.). Once the tx is broadcast, the function
|
||||
* always resolves: a kind 8333 publish failure is reported in
|
||||
* {@link DonateCampaignResult.receiptPublished} rather than thrown,
|
||||
* because the donation itself is already final on-chain.
|
||||
*/
|
||||
export function useDonateCampaign() {
|
||||
const { user } = useCurrentUser();
|
||||
@@ -76,7 +86,7 @@ export function useDonateCampaign() {
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const queryClient = useQueryClient();
|
||||
const { config } = useAppContext();
|
||||
const { esploraBaseUrl } = config;
|
||||
const { esploraApis } = config;
|
||||
|
||||
async function donateToCampaign({
|
||||
campaign,
|
||||
@@ -92,31 +102,22 @@ export function useDonateCampaign() {
|
||||
throw new Error('Enter a valid donation amount in satoshis.');
|
||||
}
|
||||
|
||||
// Split the donation across the campaign's payable recipients.
|
||||
const splits = splitDonation(campaign.recipients, amountSats, user.pubkey);
|
||||
|
||||
// Dust guard: every output must clear the BIP-141 dust limit for P2TR.
|
||||
const tooSmall = splits.find((s) => s.amountSats < BITCOIN_DUST_LIMIT);
|
||||
if (tooSmall) {
|
||||
const min = minDonationForSplit(campaign.recipients, user.pubkey, BITCOIN_DUST_LIMIT);
|
||||
if (campaign.wallet.mode === 'sp') {
|
||||
throw new Error(
|
||||
`Donation is too small to split: each recipient would get less than the dust limit (${BITCOIN_DUST_LIMIT} sats). Minimum: ${min.toLocaleString()} sats.`,
|
||||
'This campaign uses silent payments. Donate from an external BIP-352-capable wallet using the QR code.',
|
||||
);
|
||||
}
|
||||
|
||||
// Build the multi-output PSBT.
|
||||
// Donor cannot donate to their own campaign (the tx output would just
|
||||
// pay the donor's own wallet — an obvious foot-gun).
|
||||
if (campaign.pubkey === user.pubkey) {
|
||||
throw new Error('You cannot donate to your own campaign.');
|
||||
}
|
||||
|
||||
const senderAddress = nostrPubkeyToBitcoinAddress(user.pubkey);
|
||||
if (!senderAddress) throw new Error('Failed to derive your Bitcoin address.');
|
||||
|
||||
const outputs = splits.map((s) => {
|
||||
const address = nostrPubkeyToBitcoinAddress(s.pubkey);
|
||||
if (!address) {
|
||||
throw new Error(`Failed to derive Bitcoin address for ${s.pubkey.slice(0, 8)}…`);
|
||||
}
|
||||
return { address, amountSats: s.amountSats };
|
||||
});
|
||||
|
||||
const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress, esploraBaseUrl), getFeeRates(esploraBaseUrl)]);
|
||||
const [utxos, rates] = await Promise.all([fetchUTXOs(senderAddress, esploraApis), getFeeRates(esploraApis)]);
|
||||
if (utxos.length === 0) {
|
||||
throw new Error('Your Bitcoin wallet has no spendable funds.');
|
||||
}
|
||||
@@ -124,9 +125,10 @@ export function useDonateCampaign() {
|
||||
let signedHex: string;
|
||||
let fee: number;
|
||||
try {
|
||||
const unsigned = buildUnsignedMultiOutputPsbt(
|
||||
const unsigned = buildUnsignedPsbt(
|
||||
user.pubkey,
|
||||
outputs,
|
||||
campaign.wallet.value,
|
||||
amountSats,
|
||||
utxos,
|
||||
feeRateForSpeed(rates, feeSpeed),
|
||||
);
|
||||
@@ -140,38 +142,25 @@ export function useDonateCampaign() {
|
||||
}
|
||||
|
||||
const txHex = finalizePsbt(signedHex);
|
||||
const txid = await broadcastTransaction(txHex, esploraBaseUrl);
|
||||
const txid = await broadcastTransaction(txHex, esploraApis);
|
||||
|
||||
// Publish a single kind 8333 receipt covering the whole transaction. The
|
||||
// event lists every recipient under its own `p` tag; the `amount` tag is
|
||||
// the combined total paid to all recipients (i.e. the full donation,
|
||||
// excluding the donor's change). Per-recipient amounts are recomputed
|
||||
// from the on-chain tx at display time by matching each recipient's
|
||||
// derived Taproot address against the tx outputs.
|
||||
//
|
||||
// The on-chain tx is already final at this point; we record a publish
|
||||
// failure rather than throwing so the donor sees a successful result
|
||||
// even if the relay hiccups.
|
||||
const totalSats = splits.reduce((sum, s) => sum + s.amountSats, 0);
|
||||
// Publish the kind 8333 receipt. Per NIP.md §Kind 33863 §Donation flow,
|
||||
// campaign donation receipts MUST NOT carry `p` tags — the recipient is
|
||||
// the campaign's `w` wallet, not a Nostr identity. Viewers verify by
|
||||
// matching tx outputs against the campaign's `w` address.
|
||||
let receiptPublished = false;
|
||||
let receiptPublishError: string | undefined;
|
||||
try {
|
||||
await publishEvent({
|
||||
kind: 8333,
|
||||
content: comment,
|
||||
tags: [
|
||||
tags: withAgoraTag([
|
||||
['i', `bitcoin:tx:${txid}`],
|
||||
...splits.map((s) => ['p', s.pubkey]),
|
||||
['amount', String(totalSats)],
|
||||
['amount', String(amountSats)],
|
||||
['a', campaign.aTag],
|
||||
['K', String(campaign.event.kind)],
|
||||
[
|
||||
'alt',
|
||||
splits.length === 1
|
||||
? `Donation to ${campaign.title}: ${totalSats.toLocaleString()} sats`
|
||||
: `Donation to ${campaign.title}: ${totalSats.toLocaleString()} sats across ${splits.length} recipients`,
|
||||
],
|
||||
],
|
||||
['K', String(CAMPAIGN_KIND)],
|
||||
['alt', `Donation to ${campaign.title}: ${amountSats.toLocaleString()} sats`],
|
||||
]),
|
||||
});
|
||||
receiptPublished = true;
|
||||
} catch (error) {
|
||||
@@ -183,13 +172,33 @@ export function useDonateCampaign() {
|
||||
queryClient.invalidateQueries({ queryKey: ['bitcoin-balance'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['bitcoin-txs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['onchain-zaps'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['campaign-donations', campaign.aTag] });
|
||||
// The receipts query in useCampaignDonations is keyed
|
||||
// ['campaign-donations', 'events', aTag]; also invalidate the looser
|
||||
// ['campaign-donations'] prefix so any related downstream queries refresh.
|
||||
queryClient.invalidateQueries({ queryKey: ['campaign-donations', 'events', campaign.aTag] });
|
||||
queryClient.invalidateQueries({ queryKey: ['campaign-donations'] });
|
||||
// The campaign may be attached to an organization via an `A` tag; refresh
|
||||
// the org's activity feed so the donation shows up there too.
|
||||
const orgATag = campaign.event.tags.find(([n]) => n === 'A')?.[1];
|
||||
if (orgATag) {
|
||||
queryClient.invalidateQueries({ queryKey: ['organization-activity', orgATag] });
|
||||
}
|
||||
// Campaign list views (and per-campaign progress bars) read totals via
|
||||
// useCampaignDonations, which keys its address-balance lookup under
|
||||
// ['bitcoin-balance', 'campaign', …]. The broader ['bitcoin-balance']
|
||||
// invalidation above already covers it.
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['campaigns-all'] });
|
||||
// Donations (kind 8333 receipts) surface in the home Agora activity
|
||||
// feed (useAgoraFeed). Without invalidating it the new donation only
|
||||
// appears after a manual refresh.
|
||||
queryClient.invalidateQueries({ queryKey: ['agora-feed'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mixed-feed'] });
|
||||
|
||||
return {
|
||||
txid,
|
||||
fee,
|
||||
recipientCount: splits.length,
|
||||
totalSats,
|
||||
totalSats: amountSats,
|
||||
receiptPublished,
|
||||
receiptPublishError,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
|
||||
import { useOrganizationModeration } from '@/hooks/useOrganizationModeration';
|
||||
import {
|
||||
COMMUNITY_DEFINITION_KIND,
|
||||
parseCommunityEvent,
|
||||
@@ -9,56 +10,126 @@ import {
|
||||
} from '@/lib/communityUtils';
|
||||
import { dedupeAddressableLatest } from '@/lib/addressableEvents';
|
||||
|
||||
/**
|
||||
* Hand-curated list of featured organization authors.
|
||||
*
|
||||
* We query all organizations authored by these accounts in one relay request,
|
||||
* then latest-wins dedupe addressable revisions client-side.
|
||||
*/
|
||||
export const FEATURED_ORGANIZATION_AUTHORS = [
|
||||
'932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
] as const;
|
||||
|
||||
export interface FeaturedOrganization {
|
||||
community: ParsedCommunity;
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch featured organizations by author.
|
||||
* Parse a kind 34550 coordinate string (`34550:<pubkey>:<d>`) into its
|
||||
* pubkey and d-tag components. Returns `null` for malformed coords so the
|
||||
* caller can skip them without crashing the query.
|
||||
*/
|
||||
function parseCoord(coord: string): { pubkey: string; dTag: string } | null {
|
||||
const colon1 = coord.indexOf(':');
|
||||
if (colon1 < 0) return null;
|
||||
const colon2 = coord.indexOf(':', colon1 + 1);
|
||||
if (colon2 < 0) return null;
|
||||
const pubkey = coord.slice(colon1 + 1, colon2);
|
||||
const dTag = coord.slice(colon2 + 1);
|
||||
if (!pubkey || !dTag) return null;
|
||||
return { pubkey, dTag };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the featured organizations selected by Agora moderators.
|
||||
*
|
||||
* One author-filtered query is more reliable than pinning individual event IDs
|
||||
* because kind 34550 definitions are addressable and can be revised.
|
||||
* Featured selection rides the shared `agora.moderation` namespace (kind
|
||||
* 1985 NIP-32 labels): a moderator publishes a `featured` label tagging
|
||||
* the organization's `34550:<pubkey>:<d>` coordinate, and the home/communities
|
||||
* page surfaces it here. A `hidden` label on the same coord always wins —
|
||||
* even if `featured` is set, a hidden org never reaches this list.
|
||||
*
|
||||
* Coords are grouped by author into one filter per unique author, then OR'd
|
||||
* into a single `nostr.query` call. For the typical case (a small handful
|
||||
* of featured orgs across a few authors) this stays one relay round-trip.
|
||||
* Results are sorted by the `created_at` of the latest `featured` label so
|
||||
* moderators control ordering by simply re-publishing the label to bump an
|
||||
* org to the top.
|
||||
*/
|
||||
export function useFeaturedOrganizations() {
|
||||
const { nostr } = useNostr();
|
||||
const { data: moderation, isReady: moderationReady } = useOrganizationModeration();
|
||||
|
||||
// Derive the curated coord set: featured minus hidden, sorted by the
|
||||
// recency of the `featured` label. No cap — the moderator pack controls
|
||||
// how many orgs surface.
|
||||
const featuredCoords = moderationReady
|
||||
? Array.from(moderation.featuredCoords)
|
||||
.filter((coord) => !moderation.hiddenCoords.has(coord))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(moderation.featuredOrder.get(b) ?? 0) -
|
||||
(moderation.featuredOrder.get(a) ?? 0),
|
||||
)
|
||||
: [];
|
||||
|
||||
// Include the coord set in the query key so the cache busts whenever the
|
||||
// curation changes (mutations invalidate `featured-organizations` too, so
|
||||
// there's no one-tick gap).
|
||||
const featuredCoordsKey = featuredCoords.join('|');
|
||||
|
||||
return useQuery<FeaturedOrganization[]>({
|
||||
queryKey: ['featured-organizations', FEATURED_ORGANIZATION_AUTHORS.join(',')],
|
||||
queryKey: ['featured-organizations', featuredCoordsKey],
|
||||
enabled: moderationReady,
|
||||
queryFn: async ({ signal }) => {
|
||||
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
if (featuredCoords.length === 0) return [];
|
||||
|
||||
const events = await nostr.query(
|
||||
[{
|
||||
// Group coord d-tags by author so we can issue one filter per author
|
||||
// instead of one per coord. Most featured orgs cluster around a few
|
||||
// founders, so this typically collapses to a single-digit number of
|
||||
// filters in one round-trip.
|
||||
const dTagsByAuthor = new Map<string, string[]>();
|
||||
for (const coord of featuredCoords) {
|
||||
const parsed = parseCoord(coord);
|
||||
if (!parsed) continue;
|
||||
const bucket = dTagsByAuthor.get(parsed.pubkey);
|
||||
if (bucket) {
|
||||
bucket.push(parsed.dTag);
|
||||
} else {
|
||||
dTagsByAuthor.set(parsed.pubkey, [parsed.dTag]);
|
||||
}
|
||||
}
|
||||
if (dTagsByAuthor.size === 0) return [];
|
||||
|
||||
const filters: NostrFilter[] = Array.from(dTagsByAuthor.entries()).map(
|
||||
([pubkey, dTags]) => ({
|
||||
kinds: [COMMUNITY_DEFINITION_KIND],
|
||||
authors: [...FEATURED_ORGANIZATION_AUTHORS],
|
||||
limit: 60,
|
||||
}],
|
||||
{ signal: combinedSignal },
|
||||
authors: [pubkey],
|
||||
'#d': dTags,
|
||||
}),
|
||||
);
|
||||
|
||||
const entries: FeaturedOrganization[] = [];
|
||||
const combinedSignal = AbortSignal.any([signal, AbortSignal.timeout(8000)]);
|
||||
const events = await nostr.query(filters, { signal: combinedSignal });
|
||||
|
||||
// Latest-wins dedupe of addressable revisions, then index by coord so
|
||||
// we can return them in the moderator-controlled `featuredOrder`.
|
||||
const byCoord = new Map<string, FeaturedOrganization>();
|
||||
for (const event of dedupeAddressableLatest(events)) {
|
||||
const community = parseCommunityEvent(event);
|
||||
if (!community) continue;
|
||||
entries.push({ community, event });
|
||||
byCoord.set(community.aTag, { community, event });
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.event.created_at - a.event.created_at);
|
||||
|
||||
return entries;
|
||||
// Preserve the moderator's chosen ordering by walking `featuredCoords`
|
||||
// (already sorted newest-label-first) and emitting entries in that
|
||||
// order. Drops coords whose underlying 34550 event we couldn't fetch
|
||||
// (e.g. it was deleted or never reached the queried relays).
|
||||
const ordered: FeaturedOrganization[] = [];
|
||||
for (const coord of featuredCoords) {
|
||||
const entry = byCoord.get(coord);
|
||||
if (entry) ordered.push(entry);
|
||||
}
|
||||
return ordered;
|
||||
},
|
||||
// 5 minutes — featured list is hand-curated, doesn't churn.
|
||||
// Featured org definitions don't change often — orgs publish a
|
||||
// new revision when their banner or description changes, not minute
|
||||
// to minute — so a generous staleTime makes back-navigation to
|
||||
// /communities feel instant. The moderation hook explicitly
|
||||
// invalidates this key on mutation, so moderator-driven churn is
|
||||
// still visible immediately.
|
||||
staleTime: 5 * 60_000,
|
||||
gcTime: 60 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ import { getStorageKey } from '@/lib/storageKey';
|
||||
|
||||
/**
|
||||
* Manages the active feed tab for a specific feed page, persisting
|
||||
* the selection in sessionStorage so it survives navigation within
|
||||
* the same browser session.
|
||||
* the selection in localStorage so it survives reloads and new sessions.
|
||||
*
|
||||
* Each feed page should pass a unique `feedId` (e.g. 'home', 'vines', 'videos').
|
||||
*
|
||||
@@ -24,17 +23,17 @@ export function useFeedTab<T extends string = string>(
|
||||
const [activeTab, setActiveTab] = useState<T>(() => {
|
||||
const defaultTab = (user ? 'follows' : 'world') as T;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(key);
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
if (feedId === 'home' && stored === 'network') {
|
||||
sessionStorage.setItem(key, defaultTab);
|
||||
localStorage.setItem(key, defaultTab);
|
||||
return defaultTab;
|
||||
}
|
||||
if (!validTabs || validTabs.includes(stored as T)) {
|
||||
return stored as T;
|
||||
}
|
||||
}
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
} catch { /* localStorage unavailable */ }
|
||||
// Validate the default tab against validTabs. If it's not in the list,
|
||||
// fall back to the last valid tab (typically 'global').
|
||||
if (validTabs && !validTabs.includes(defaultTab)) {
|
||||
@@ -45,7 +44,7 @@ export function useFeedTab<T extends string = string>(
|
||||
|
||||
const setTab = useCallback((tab: T) => {
|
||||
setActiveTab(tab);
|
||||
try { sessionStorage.setItem(key, tab); } catch { /* ignore */ }
|
||||
try { localStorage.setItem(key, tab); } catch { /* ignore */ }
|
||||
}, [key]);
|
||||
|
||||
return [activeTab, setTab];
|
||||
|
||||