Compare commits

..

13 Commits

Author SHA1 Message Date
lemon 3e433e70cb Hoist three duplicated discovery helpers to shared modules
Three small extractions that consolidate hand-rolled copies in the
discovery surfaces. No behavior change.

- getPledgeCoord → src/lib/pledges.ts. Was defined three times
  (PledgesDiscoverySection, ActionsPage, ActionShareMenu), each with
  the same '36639:<pubkey>:<d>' template. Lifted into the existing
  pledges lib and typed structurally on { pubkey, id } so the lib
  layer doesn't take a hook dep on Action.

- parseSort + toQuerySort → exported from useDiscoveryFilters and
  useAllCampaigns respectively. AllCampaignsPage was carrying its own
  copy of both with an apologetic comment ('mirroring the one in
  useDiscoveryFilters'); CampaignsDiscoverySection had its own
  toQuerySort. One source of truth each now, with the pages and the
  section importing from the same module as the hook that consumes
  the result.

- PledgeCardSkeleton → exported from PledgeCard. Replaces two
  byte-identical ActionSkeleton components in PledgesDiscoverySection
  and ActionsPage. Naming matches the existing CampaignCardSkeleton /
  CommunityMiniCardSkeleton convention of placing the skeleton next
  to its card.
2026-05-28 16:16:05 -07:00
lemon 3525f685bb Skip wasted discovery-section queries
Two related gates on the unified discovery sections:

- PledgesDiscoverySection: the chronological useActions({ limit: 300 })
  query only feeds the idle render branch (via idlePledges), but it
  was firing in active-search mode too. Active mode renders searchHits
  from useNip50Search, which never reads rawActions. On every keystroke
  that activates search we were burning a 300-event relay round-trip
  whose results went nowhere. Gate the query on !isSearching so the
  fetch happens only when the idle branch can actually consume it.

- CampaignsDiscoverySection: align the featured-coords useCampaigns
  query's enabled flag with the pledges section's pattern. useCampaigns
  already short-circuits on an empty coordinates array, so this is
  purely about not creating an empty cache entry when moderators have
  curated nothing — but it removes a small asymmetry that would have
  made the next reviewer second-guess which pattern is intentional.
2026-05-28 16:16:05 -07:00
lemon a358a5d95c Extract three reusable discovery sections shared by home and dedicated pages
The home page now shows the same Campaigns / Groups / Pledges sections
as their dedicated pages (/campaigns/all, /groups, /pledges), with the
same titles, taglines, and search/sort/country toolbars instead of
'Browse all' shortcut links. Each surface's discovery logic lived
in its own page and the home page was about to grow a fourth copy of
it, so the section bodies move into reusable components:

  CampaignsDiscoverySection  src/components/discovery/
  GroupsDiscoverySection
  PledgesDiscoverySection

Each owns the section header (title / tagline switch on active
search), the DiscoverySearchToolbar, the idle featured grid, the
active search/sort/country grid, and the per-section empty / no-match
cards. Filter state (search input, sort, country, debouncing) lives
in a new useDiscoveryFilters hook which has two modes:

  filterPersistence='url'   - flat ?q=&sort=&country= params. Used by
                              the dedicated pages so search results
                              are shareable and survive refresh.
  filterPersistence='local' - local-only state. Used by / where three
                              sections coexist and can't all own ?q=.
                              Refreshing the home lands on the curated
                              idle view, which matches what we want.

The dedicated pages keep their hero, optional Your-X shelf, and the
moderator-only Hidden collapsible — those stay page-level because
each page wants its own copy. They drive the section's Show-hidden
toolbar switch via a hoisted prop so the page-level Hidden
collapsible can read the same flag.

Side effects:

  - ActionShareMenu moves from inside ActionsPage to its own file
    so PledgesDiscoverySection can render it on every card without
    re-importing the page module.

  - useDiscoverCommunities is unchanged but only the dedicated
    /groups page calls it now (for the Hidden collapsible /
    hidden-count badge). The home page never triggers it.

  - browseAllGroups and browseAllPledges locale keys drop from all
    16 locales since the launchpad layout that needed them no
    longer exists.
2026-05-28 16:16:05 -07:00
lemon ba2ab78995 Turn the home page into a featured-only launchpad for all three surfaces
The home page used to be the canonical browse view for campaigns: hero
plus featured row, then the full community grid, then moderator-only
Pending/Hidden sections, then a per-viewer 'Your campaigns' shelf.
With /campaigns/all, /groups, and /pledges all now hosting their own
dedicated browse views (featured + search + sort + country in one
unified section), the home page no longer needs to duplicate the
campaigns browse experience.

Rebuild / as a three-section launchpad:

  Hero  -> unchanged (HeroLightningMap, Bebas Neue tagline, brand CTAs).
  Featured campaigns  -> capped at 4, links to /campaigns/all.
  Featured groups     -> capped at 8, links to /groups.
  Featured pledges    -> capped at 8, links to /pledges.

Each section pulls its featured set from the same moderation labels
that drive the dedicated page, so what surfaces here matches what
surfaces there — just truncated. Sections with no featured items
collapse silently (no empty card) so the page degrades gracefully if
moderators only curate one or two surfaces.

Each section's skeleton respects the dependency chain that gates its
underlying query: campaigns wait on useCampaignModeration, groups on
useOrganizationModeration (because useFeaturedOrganizations is
internally gated on it), pledges on usePledgeModeration. While those
are still resolving the section renders skeleton cards rather than
flashing an empty state.

Drop the unmoderated community grid, the Pending/Hidden moderator
sections, the 'Your campaigns' shelf, and the campaign-search
toolbar from the home page. All of that now lives on /campaigns/all
where viewers actually expect to browse and filter.

Add browseAllGroups and browseAllPledges to campaigns.home in all
16 locales so each section can link out with locale-appropriate copy.
2026-05-28 16:16:05 -07:00
lemon bc7e3b8547 Keep skeleton up while moderation labels are still resolving
useFeaturedOrganizations is internally gated on moderationReady — while
the organization moderation labels are loading, the underlying query
is disabled and reports isLoading: false / data: undefined. The Groups
page was using only that isLoading flag to decide whether to show the
skeleton, so during the moderation-loading window it rendered the
empty state for a moment before the curated grid popped in.

Track moderation readiness alongside the featured query and treat any
of the three states — moderation not ready, featured query in flight,
featured data not yet defined — as loading.
2026-05-28 16:16:05 -07:00
lemon f19562cf64 Render featured groups directly without intermediate event flash
The Groups page was firing a global kind-34550 query through
useDiscoverCommunities, rendering the full results, then filtering
them client-side for the 'agora' client tag. This produced a brief
flash of unrelated communities before the curated set settled.

Drop the client-side Agora-tag filter entirely and stop using the
all-communities fetch for the idle render path. The unified Groups
section now renders moderator-featured groups directly, gated on
useFeaturedOrganizations's own loading state, so the page goes
skeleton → curated grid with no intermediate render.

useDiscoverCommunities is still called for moderators only — it
feeds the Hidden collapsible section and the hidden-count badge on
the toolbar. Non-moderators no longer trigger the global fetch at
all.
2026-05-28 16:16:05 -07:00
lemon 9244bb2f16 Merge Featured and All sections on discovery pages
Campaigns, Groups, and Pledges each previously stacked a Featured
shelf above an All-X section. Collapse them into a single section
titled simply 'Campaigns' / 'Groups' / 'Pledges' that:

- Idle (no query, no sort, no country) shows the moderator-featured
  grid. If nothing is featured yet, falls back to the chronological
  all-X grid so the page is never blank.
- Active (the user typed, picked Top/New, or chose a country) shows
  the full result set, ranked or chronological per the toolbar.

The shared toolbar drops the 'default' sort option from its dropdown
(now only Top and New). Clicking an already-active sort returns the
page to the curated idle view, giving users a clear exit affordance
now that 'default' is no longer an explicit menu choice.

Personal shelves (My pledges / My groups / Your campaigns) stay
above the unified section as separate, user-scoped lists.
2026-05-28 16:16:05 -07:00
lemon 41c3fd62ac Shorten All groups tagline 2026-05-28 16:16:05 -07:00
lemon ca4855718a Hide group shelves until content is known
Regression-of: 5607f5fa
2026-05-28 16:16:05 -07:00
lemon 749c325b91 Hide Featured headers when no events are surfaced
Regression-of: 9663b05e
2026-05-28 16:16:05 -07:00
lemon 73df1484b7 Hide My pledges header when the user has no pledges 2026-05-28 16:16:05 -07:00
lemon ad43d540e0 Hide My campaigns header when the user has no campaigns 2026-05-28 16:16:05 -07:00
lemon ad31e12803 Hide My groups header when the user has no groups 2026-05-28 16:16:05 -07:00
400 changed files with 43193 additions and 28078 deletions
+10 -20
View File
@@ -37,12 +37,12 @@ deploy-web:
- if: $CI_COMMIT_TAG
when: never
- if: $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH && $DEPLOY_SSH_KEY && $DEPLOY_TARGET
# Vite inlines VITE_* env vars at build time. These are sourced directly from
# project-level CI/CD variables, which are already present in the job
# environment — do NOT re-declare them here as `KEY: $KEY`. That self-reference
# overwrites the real value with the literal string "$KEY" whenever the source
# variable is out of scope (e.g. a Protected variable on an unprotected ref),
# which is how "$VITE_TRANSLATE_WORKER_URL" leaked into the built app.
variables:
# Vite inlines VITE_* env vars at build time. Sourced from GitLab CI/CD
# variables so values can be rotated without a code change.
VITE_TRANSLATE_WORKER_URL: $VITE_TRANSLATE_WORKER_URL
VITE_PLAUSIBLE_DOMAIN: $VITE_PLAUSIBLE_DOMAIN
VITE_PLAUSIBLE_ENDPOINT: $VITE_PLAUSIBLE_ENDPOINT
script:
# Build the web app
- npm ci
@@ -167,33 +167,23 @@ build-apk:
# Write local.properties for Gradle
- echo "sdk.dir=$ANDROID_SDK_ROOT" > android/local.properties
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility.
# PKCS12 conceptually uses one password for the store and every entry; if the
# store and key passwords differ, keytool protects the migrated entry with the
# STORE password regardless of -destkeypass, so Gradle's later read with the
# key password fails ("Given final block not properly padded"). Unlock the
# source key with its own password ($KEY_PASSWORD), then write the PKCS12 with
# a single uniform password ($KEY_PASSWORD) for both store and entry so the
# key.properties below is internally consistent.
# Decode signing keystore and migrate JKS -> PKCS12 for Gradle compatibility
- echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/my-upload-key.jks
- keytool -importkeystore
-srckeystore android/app/my-upload-key.jks
-destkeystore android/app/my-upload-key.keystore
-deststoretype pkcs12
-srcstorepass "$KEYSTORE_PASSWORD"
-deststorepass "$KEYSTORE_PASSWORD"
-srcalias upload
-destalias upload
-srckeypass "$KEY_PASSWORD"
-deststorepass "$KEY_PASSWORD"
-destkeypass "$KEY_PASSWORD"
-noprompt
- rm android/app/my-upload-key.jks
# Write key.properties from CI/CD variables. The PKCS12 above uses
# $KEY_PASSWORD uniformly, so both storePassword and keyPassword point to it.
# Write key.properties from CI/CD variables
- |
cat > android/key.properties << EOF
storePassword=$KEY_PASSWORD
storePassword=$KEYSTORE_PASSWORD
keyPassword=$KEY_PASSWORD
keyAlias=upload
storeFile=my-upload-key.keystore
+5 -9
View File
@@ -1,10 +1,6 @@
# Project Overview
Agora is a peer-to-peer crowdfunding Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
Donations are **on-chain Bitcoin** — donors pay a campaign's Bitcoin address directly. Agora ships an integrated **non-custodial HD Bitcoin wallet** (deterministically derived from the user's Nostr key) with BIP-86 Taproot and **BIP-352 silent-payment** support. The app never custodies or converts funds; it is a non-custodial UI that connects donors and campaigns peer-to-peer.
**This is not a Lightning project.** Lightning (`useZaps`, `useWallet`, `useNWC`, LNURL/NWC/WebLN) survives only as a secondary *tipping* path for notes/profiles and a deprecated Breez/Spark wallet in recovery-only mode — never for campaign donations. The crowdfunding core is strictly on-chain.
Agora is a Nostr client built with React 19.x, TailwindCSS 3.x, Vite, shadcn/ui, and Nostrify, wrapped as a native iOS/Android app via Capacitor.
## Technology Stack
@@ -21,7 +17,7 @@ Donations are **on-chain Bitcoin** — donors pay a campaign's Bitcoin address d
## Project Structure
- `/src/components/` — UI components. `ui/` holds shadcn primitives; `auth/` holds login components.
- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Core Nostr: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`. **On-chain wallet & crowdfunding (the headline feature):** `useHdWallet`, `useHdWalletSp` (BIP-352 silent payments), `useBitcoinSigner`, `useDonateCampaign`, `useCampaign`/`useCampaigns`, `useCampaignDonations`, `useOnchainZap`. **Lightning (secondary tipping only, not campaigns):** `useZaps`, `useWallet` (NWC/WebLN status — *not* the on-chain wallet), `useNWC`.
- `/src/hooks/` — custom hooks. Discover the full set with `ls src/hooks/`. Key ones: `useNostr`, `useAuthor`, `useCurrentUser`, `useNostrPublish`, `useUploadFile`, `useAppContext`, `useTheme`, `useToast`, `useLoggedInAccounts`, `useLoginActions`, `useIsMobile`, `useZaps`, `useWallet`, `useNWC`, `useShakespeare`.
- `/src/pages/` — page components wired into `AppRouter.tsx`. The catch-all `/:nip19` route is handled by `NIP19Page.tsx` (see the `nip19-routing` skill).
- `/src/lib/` — utility functions and shared logic.
- `/src/contexts/` — React context providers (`AppContext`, `NWCContext`).
@@ -266,16 +262,16 @@ The router provides automatic scroll-to-top on navigation and a 404 `NotFound` p
## Internationalization
All user-facing strings live in `src/locales/<lang>.json`. `en.json` is the source of truth; fifteen other locales ship alongside it: `ar`, `es`, `fa`, `fr`, `hi`, `id`, `km`, `ps`, `pt`, `ru`, `sn`, `sw`, `tr`, `zh`, `zh-Hant`.
All user-facing strings live in `src/locales/<lang>.json`. `en.json` is the source of truth; ten other locales ship alongside it: `ar`, `es`, `fa`, `fr`, `km`, `ps`, `pt`, `ru`, `sn`, `zh`.
**When you edit, add, or remove a translated string, update every locale in the same change — not just `en.json`.** Leaving the other locales stale ships an inconsistent app: users in other languages either see outdated copy or get an English fallback in the middle of a localized screen. This applies to FAQ entries, guide bodies, button labels, error messages — every value reachable through `t()`.
Concrete rules:
- **Edits to an existing key** — change the value in `en.json` first, then update the corresponding key in all fifteen other locales. Translate the new content into each language; don't paste English. Preserve `{{interpolation}}` placeholders, markdown links, and technical tokens (`sp1…`, `BIP-352`, kind numbers, etc.) verbatim.
- **Edits to an existing key** — change the value in `en.json` first, then update the corresponding key in all ten other locales. Translate the new content into each language; don't paste English. Preserve `{{interpolation}}` placeholders, markdown links, and technical tokens (`sp1…`, `BIP-352`, kind numbers, etc.) verbatim.
- **New keys** — add to `en.json` first, then add the same key with a translated value in every other locale. `src/test/locales.test.ts` fails the build if any locale ships a key that doesn't exist in `en.json`, but the inverse (a key missing from a non-English locale) is allowed and falls back to English at runtime — which is exactly the user-visible mess you're trying to avoid.
- **Removed keys** — delete from `en.json` and every other locale together. Leftover keys are dead translations and clutter future diffs.
- **Parallelize the translation work** — when updating one English string across all fifteen locales, dispatch the per-language edits to subagents in parallel rather than translating fifteen files sequentially. Provide each subagent the new English source, the existing translation snippet (so it matches established voice), and explicit instructions to preserve placeholders and technical tokens.
- **Parallelize the translation work** — when updating one English string across all ten locales, dispatch the per-language edits to subagents in parallel rather than translating ten files sequentially. Provide each subagent the new English source, the existing translation snippet (so it matches established voice), and explicit instructions to preserve placeholders and technical tokens.
Always run `npm run test` after locale changes — `locales.test.ts` catches structural drift, and the wider suite catches any `t()` calls that referenced a key you renamed.
-120
View File
@@ -1,125 +1,5 @@
# Changelog
## [2.9.1] - 2026-06-27
The Venezuela earthquake relief appeal now rallies behind every campaign on the ground, not just one. The home banner and relief page showcase a live, swipeable carousel of all Venezuelan relief efforts, with a running total of everything raised so far — so you can pick exactly who to help.
### Changed
- The Venezuela relief appeal now showcases every Venezuelan relief campaign in a live, auto-scrolling carousel you can swipe through, with a combined raised total across all of them, instead of featuring a single campaign.
## [2.9.0] - 2026-06-25
A big one. Private messaging arrives with a fast, searchable inbox you can reply to in your own language. Campaign organizers can now get verified by trusted reviewers through a guided sign-up, and verified badges appear right on campaigns. There's a new Venezuela earthquake relief appeal that takes you straight to donations, plus optional Tor routing on Android, separate Public and Private wallets, faster donation scanning, and refreshed profiles, settings, and login.
### Added
- Private direct messages: a dedicated inbox you can search by name, start new chats with inline recipient lookup, page back through old conversations, and read incoming messages translated into your language.
- Get verified: a guided sign-up for trusted reviewers to set up a verifier profile, publish a public "how we verify" statement, and vouch for campaigns. Verified badges now show on campaign pages, and a short tutorial walks you through it.
- A Venezuela earthquake relief appeal — a home-page banner, a one-time popup, and a shareable page that bakes in the relief campaign so you can read its story and donate without leaving.
- Optional Tor routing on Android for added privacy.
- Separate Public and Private wallets, keeping your spending and your private silent-payment funds cleanly apart.
- A "Don't have Bitcoin?" prompt on campaign pages that points first-time donors to Cash App.
- An always-visible language switcher in the top navigation, and a corporate sponsorship page.
### Changed
- Redesigned profiles with cleaner stats, a merged campaigns view, and a themed raised total.
- Redesigned Settings into an Apple-style grouped layout.
- Reworked the login and onboarding flow, including a new welcome screen built around the Agora brand.
- Silent-payment donations now scan faster and keep working in the background, with a progress bar on the private wallet.
- The home page now shows every featured campaign instead of capping the list.
### Fixed
- The audio, music, and podcast pages no longer crash.
- Backfilled and corrected translations across all sixteen languages.
## [2.8.9] - 2026-06-02
Adds an in-app prompt to grab the Android app from Zapstore, makes it easier to start or explore campaigns right from the home page, and irons out a batch of language and display fixes.
### Added
- A prompt to download the Android app from Zapstore, shown to mobile web visitors on the home page, in the account menu, and in the slide-out menu.
- A "Start a campaign" button alongside "Browse all" in the middle of the home page.
### Changed
- The "Explore campaigns" button now appears for everyone, not just logged-out visitors.
### Fixed
- Switching languages now takes effect immediately instead of showing stale text.
- The reply box and the replies heading on a post now show up in your chosen language.
- Account balances keep their Latin numerals regardless of display language.
- Filled in missing translations on the "Why Agora" screen.
## [2.8.8] - 2026-06-02
Fixes the app icon proportions and updates the loading splash to the Agora bolt.
### Fixed
- App icon no longer appears squashed.
- Loading splash now shows the Agora bolt instead of the old logo.
## [2.8.7] - 2026-06-02
Fixes the top navigation bar rendering behind the status bar on Android.
### Fixed
- Top navigation bar now clears the system status bar on Android.
## [2.8.6] - 2026-06-02
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
### Changed
- Update the app icon to the current Agora bolt on a brand-orange background.
## [2.8.5] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.4] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.3] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.2] - 2026-06-02
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
## [2.8.1] - 2026-06-02
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
### Added
- Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
- Organizations with their own events, pledges, members, and moderation tools.
- Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
- One-tap support: zap posts, profiles, campaigns, and organizations.
- AI agent chat with a model selector, tool-calling, and slash commands.
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
- Comments and reactions on campaigns, and donation receipts shown inline.
### Changed
- Refreshed Agora branding, navigation, and app icons throughout.
- Streamlined onboarding with country and people follows.
- Polished campaign, organization, and donation flows end to end.
### Removed
- Direct messaging and ephemeral geo chat.
## [1.0.0] - 2026-04-30
### Added
+23 -162
View File
@@ -15,7 +15,6 @@
| 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 |
| 14672 | Verifier Statement | Self-authored statement describing how the author verifies campaigns (one per user) |
### Agora Protocols
@@ -23,8 +22,7 @@
|--------------------------|-----------------------------------------|-----------------------------------------------------------------|
| 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 | 33863, 34550, 36639, 1985, 39089 | Discovery curation (hidden + featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns, organizations, and pledges identically. |
| Campaign Verification | 33863, 1985 | Positive trust signal: moderator-signed NIP-32 labels in the `agora.verified` namespace (value `verified`) vouching for a campaign. Gated by the same moderator pack as hide/feature; retracted via kind 5 deletion. |
| Campaign Moderation | 33863, 34550, 36639, 1985, 39089 | Discovery curation (approved / hidden / featured axes) via moderator-signed labels in the `agora.moderation` namespace, gated by a follow-pack moderator roster. Covers campaigns (all three axes), organizations (hidden + featured), and pledges (hidden + featured). |
| HD Wallet Derivation | — | BIP-39 mnemonic deterministically derived from the user's nsec via HKDF; seeds a BIP-86 Taproot + BIP-352 silent-payment wallet importable into any BIP-39-compatible wallet (see [Agora HD Wallet](#agora-hd-wallet-derivation) below). |
### Agora Content Marker
@@ -72,7 +70,7 @@ Clients filter both case variants (`agora` and `Agora`) because Nostr `t` tags a
#### 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`). 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).
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
@@ -284,11 +282,11 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo
### Summary
Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
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 one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code).
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, banner, goal, and wallet 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
@@ -317,6 +315,7 @@ The kind is addressable so the creator can edit the story, banner, goal, and wal
["w", "sp1qq...verylongsilentpaymentcode..."],
["goal", "25000"],
["deadline", "1735689600"],
["i", "iso3166:US"],
["k", "iso3166"],
@@ -353,6 +352,7 @@ The `content` field is the **campaign story**, formatted as Markdown. Clients SH
| `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:<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`. |
| `t` | Optional | User-entered discovery/category tags. Agora also adds `t:agora` as the app marker; other `t` values are freeform topics such as `legal-defense` or `mutual-aid`. |
@@ -498,7 +498,7 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned
### Agora Moderation Labels
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, 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.
Agora curates which kind 33863 campaigns appear on the homepage (`/`) and on the Support directory (`/campaigns/all`), which kind 34550 organizations appear in the Featured shelf on `/communities`, and which kind 36639 pledges appear in the discovery surfaces on `/pledges`, 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, organizations, and pledges share a single label namespace and a single moderator pack (Team Soapbox); the only thing distinguishing the three streams is the kind prefix on the `a` tag of each label:
@@ -521,26 +521,27 @@ Each label event carries the namespace twice, per NIP-32:
#### Label values
Two independent axes are defined; the newest moderator-signed label per axis per coordinate wins. All three surfaces (campaigns, organizations, pledges) use the same two axes — every Agora-tagged entity is publicly visible by default, and moderation reduces to suppressing unwanted entries (`hide`) and lifting curated ones into a featured row (`featured`).
Three independent axes are defined; the newest moderator-signed label per axis per coordinate wins. **Campaigns** use all three axes (`approval`, `hide`, `featured`). **Organizations** and **pledges** use only two — `hide` and `featured` — because every Agora-tagged organization or pledge is publicly visible by default; there is no approval gate. Moderators MUST NOT publish `approved` or `unapproved` labels against kind 34550 or kind 36639 coordinates, and clients MUST ignore any such labels they receive.
| 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` | campaigns, organizations, pledges | `hidden` suppresses the target everywhere it would otherwise appear. `unhidden` retracts a previous hide. |
| featured | `featured`, `unfeatured` | campaigns, organizations, pledges | `featured` places the target in a hand-picked Featured row. `unfeatured` retracts. |
> **Legacy `approved` / `unapproved` labels.** A previous revision of this spec defined a third axis ("approval") used only by campaigns to gate which campaigns appeared on the home page. The axis was retired once `featured` became the single positive-curation mechanism on the home page. Clients MUST ignore `approved` / `unapproved` labels and SHOULD NOT publish new ones. Existing labels in relay archives are dead data.
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 by the featured label's effective rank (see Moderator-driven Ordering), descending.
- **Discover shelf on `/campaigns`** — iff the latest hide label is not `hidden`. Every non-hidden campaign on the network is enumerable here; the home page's Featured row is a curated subset, not a gate.
- **Moderator-only "Hidden"** — iff hidden. Surfaces the suppressed set so moderators can unhide.
- **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 by the featured label's effective rank, descending (see Moderator-driven Ordering).
- **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.
@@ -553,28 +554,6 @@ Surfacing rules (hide always wins):
- **Direct-URL access** — a pledge's detail page (`/<naddr>`) renders regardless of moderation state. Hidden pledges remain reachable by anyone who has the link; moderation only governs which surfaces enumerate them.
- **Featured** — reserved for a future curated pledge shelf. The `featured` axis is defined for symmetry with campaigns/organizations, and clients MAY use it when implementing such a shelf.
#### Moderator-driven Ordering
The Featured row is sorted by the **effective rank** of the moderator's latest `featured` label per campaign coordinate, descending.
A label's effective rank is the numeric value of its `["rank", "<number>"]` tag if present, falling back to the label's `created_at` when no rank tag is set. Labels published before this feature existed — and any normal hide / feature actions that don't carry a rank — surface with their `created_at` as the effective rank, so newer feature actions naturally float to the top.
The fold rule per `(coord, axis)` is unchanged: the newest event by `created_at` wins. Encoding order in the `created_at` itself would conflict with that rule the moment a moderator tried to lower a campaign's position — the new label would have an older `created_at` than the existing one and lose the fold. The rank tag decouples sort key from event recency so reorder publishes always use `created_at = now` and the fold always picks them up.
A moderator MAY reorder the row by republishing the `featured` label for a campaign with a `rank` tag carrying a chosen integer. Three operations cover the common cases:
- **Move to top** — publish with `rank = max(freshRank, currentTopRank + 1)`, where `freshRank` is a strictly-monotonic integer the client SHOULD source from current wall-clock time at sub-second resolution (Agora uses `Date.now() * 1000`). The `max` guard handles a (rare) clock-skewed existing rank that's already above `freshRank`.
- **Move up by one** — publish with `rank = neighborAbove.rank + 1`, where `neighborAbove` is the label sorted directly above the campaign being moved.
- **Move down by one** — publish with `rank = neighborBelow.rank - 1`. Only the moved campaign's label is republished; the neighbor below is untouched.
A general "drop at index `j`" (e.g. drag-and-drop in a moderator UI) is implemented by computing the two new neighbors of the moved campaign in the rearranged list and choosing any integer rank strictly between their ranks. When the gap is too tight (`prev.rank - next.rank < 2`), clients SHOULD pick `next.rank + 1` and accept that the rendered list may briefly be off by one until the next reorder leaves a wider gap. Using a sub-second-resolution `freshRank` keeps inter-rank gaps wide enough for many midpoint inserts before any renumbering is needed.
The conflict model matches the rest of the moderation namespace: the newest label per `(coord, axis)` from any moderator wins. Concurrent reorders by two moderators resolve to whoever's publish lands later; clients SHOULD refetch labels after a reorder publish to surface the authoritative order.
Reorder labels remain valid moderation labels in every other respect. Clients that don't recognize the `rank` tag simply read the label's axis state and ignore the rank — the labels are not a separate kind, not a separate namespace, and not a new tag namespace. Non-Agora clients see exactly the same hide / feature state they always have.
The featured row is the only Agora surface that uses moderator-driven ordering today. The same mechanism MAY be applied to the organization or pledge featured shelves if those grow a curation UI; until then, those shelves sort by `created_at` (the legacy behavior, identical to using a missing rank tag).
#### Event Structure
```json
@@ -583,9 +562,9 @@ The featured row is the only Agora surface that uses moderator-driven ordering t
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "featured", "agora.moderation"],
["l", "approved", "agora.moderation"],
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
["alt", "Campaign moderation: featured"]
["alt", "Campaign moderation: approved"]
]
}
```
@@ -627,26 +606,6 @@ Required tags:
- `a` referencing the target coordinate (`33863:<pubkey>:<d>` for a campaign, `34550:<pubkey>:<d>` for an organization, `36639:<pubkey>:<d>` for a pledge).
- `alt` (NIP-31) — clients without label support will display this string. The `alt` value SHOULD identify the surface (e.g. `Campaign moderation: featured`, `Organization moderation: featured`, or `Pledge moderation: hidden`) so non-Agora clients can read it.
Optional tags:
- `rank` — single string element parsed as an integer. Used on `featured` labels to position the target within the moderator-curated Featured row; see Moderator-driven Ordering above. Labels without this tag sort by `created_at` (descending), which is the correct behavior for all non-reorder uses.
A label with a rank tag looks like:
```json
{
"kind": 1985,
"content": "",
"tags": [
["L", "agora.moderation"],
["l", "featured", "agora.moderation"],
["a", "33863:<author-pubkey>:<campaign-d-tag>"],
["rank", "1700000000123000"],
["alt", "Campaign moderation: featured"]
]
}
```
#### Trust Model
Only label events authored by current members of the **Team Soapbox** follow pack are honored. The pack is a kind 39089 (NIP-51 follow pack) addressable event:
@@ -659,8 +618,8 @@ d-tag: k4p5w0n22suf
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-promotion is impossible unless the pack author has added you.
- A moderator removed from the pack immediately loses moderation authority — campaigns kept alive on the Featured row only by their labels fall off the row until another moderator features them.
- Self-approval is impossible unless the pack author has added you.
- 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.
@@ -693,111 +652,13 @@ Step 3 — fold by `(coord, axis)`, latest-`created_at`-wins, filtering to the r
#### Client Behavior
- Clients SHOULD render hide/feature controls only for users whose pubkey appears in the pack.
- Clients MAY display "Hidden" badges on hidden campaigns/organizations/pledges when viewed by a moderator, and SHOULD NOT render them at all to non-moderators.
- Authors' own campaigns, organizations, and pledges are visible at their NIP-19 routes regardless of moderation state. The campaign URL remains live and donatable even when the campaign is not on the home page's Featured row.
#### Campaign Verification Labels (`agora.verified`)
Separately from the hide/feature moderation axes above, Agora supports a positive **verification** signal: a campaign moderator vouches for a specific campaign. Verification is a distinct NIP-32 label namespace, `agora.verified`, with a single value `verified`. It rides the same kind 1985 label kind and the **same moderator pack** as the hide/feature labels, but is otherwise independent of `agora.moderation` — no axes, no rank, purely additive.
A verification label points at one campaign coordinate (`33863:<pubkey>:<d>`):
```json
{
"kind": 1985,
"content": "",
"tags": [
["L", "agora.verified"],
["l", "verified", "agora.verified"],
["a", "33863:<campaign-pubkey>:<campaign-d>"],
["alt", "Campaign verification"]
]
}
```
**Trust model.** The set of pubkeys whose `agora.verified` labels are honored is the campaign moderator pack — the same allowlist that governs hide/feature labels (the Team Soapbox follow pack `p` tags). Clients MUST filter the read query by `authors: <moderators>` — a `verified` label signed by any pubkey outside the pack MUST be ignored, otherwise the badge is forgeable by anyone. As with moderation labels, clients MUST NOT run the query with an empty `authors:` filter.
**Reading.** One filter fetches every verification across all moderators:
```json
{
"kinds": [1985],
"authors": ["<moderator-1>", "<moderator-2>"],
"#L": ["agora.verified"],
"#l": ["verified"],
"limit": 2000
}
```
Fold by `(coord, moderator)`, keeping the newest label per pair. A campaign is "verified by" the set of moderators with a surviving label; clients SHOULD render the moderators' avatars stacked as a badge, with multiple moderators forming a stack.
**Retraction.** There is no `unverified` value. A moderator retracts a verification by publishing a NIP-09 kind 5 deletion of their own label event (referenced by `e` tag plus `k: 1985`). A kind 5 only takes effect on events authored by the signer, so a moderator can only remove their own verification.
**Client behavior.**
- Verification is a moderator action: clients SHOULD render the verify / remove-verification control inside the campaign moderator menu (alongside hide / add-to-list), gated on moderator membership.
- Verification is purely additive — it never hides or promotes a campaign on its own. It is a trust hint layered over whatever moderation/discovery state already applies.
- The label kind 1985 read is routed to Agora's search relays (`relay.ditto.pub`, `relay.dreamith.to`) where these labels are published.
- 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.
---
## Kind 14672: Verifier Statement
### Summary
Replaceable event kind for a **self-authored statement describing how the author verifies campaigns**. Anyone can "become a verifier" simply by publishing one of these events — there is no gatekeeper. The statement is a public, freeform explanation of the diligence process the author applies before vouching for a campaign, so donors can judge whether to trust that author's judgement.
Exactly one statement per user (replaceable, no `d` tag): publishing a new event replaces the previous one. Clients surface the statement prominently on the author's profile page.
This kind is **distinct from** the `agora.verified` campaign-verification labels (kind 1985, see Kind 33863 above). Those are moderator-signed, gated by the Team Soapbox follow pack, and vouch for one specific campaign. A kind 14672 statement is an open, self-published description of an author's *general* verification methodology and confers no special authority — it is a reputation signal donors read, not an access-control mechanism.
### Event Structure
```json
{
"kind": 14672,
"pubkey": "<author-pubkey>",
"content": "I personally visit each campaign organizer over video call, confirm their identity against a government ID, and cross-check the cause with at least two independent local sources before I vouch for it. ...",
"tags": [
["alt", "Verifier statement: how this account verifies campaigns"],
["t", "agora"]
]
}
```
### Content
The `content` field is the verifier statement, formatted as **Markdown**. Clients SHOULD render it with the same Markdown renderer they use for other long-form Agora content (campaign stories, policy pages). Empty or whitespace-only content means the author has **withdrawn** their verifier statement — clients MUST treat an empty-content event the same as no event (the author is no longer a verifier) and MUST NOT render a verifier section for it.
### Tags
| Tag | Required | Description |
|-------|-------------|-----------------------------------------------------------------------------|
| `alt` | Recommended | NIP-31 human-readable fallback describing the event's purpose. |
| `t` | Optional | Agora content marker (`t:agora`). Added at publish time via `withAgoraTag`. |
The statement carries no queryable fields beyond the author and kind — it is identified entirely by `(14672, pubkey)`.
### Querying
**Fetch a user's verifier statement:**
```json
{ "kinds": [14672], "authors": ["<pubkey>"], "limit": 1 }
```
Clients MUST filter by `authors` — a verifier statement only describes the diligence of the pubkey that signed it, so an unfiltered query would be meaningless (and would let anyone's statement be attributed to anyone).
### Client Behavior
- **Becoming a verifier:** a user publishes a kind 14672 event with their statement in `content`. No approval, allowlist, or moderation gate applies.
- **Withdrawing:** a user republishes the event with empty `content`, or publishes a NIP-09 kind 5 deletion referencing the event. Either way clients stop rendering the verifier section.
- **Rendering:** clients SHOULD surface the statement prominently on the author's profile (e.g. a dedicated "Verifier" section in the profile overview), rendering the Markdown content sanitized.
- **Editing:** because the kind is replaceable, the latest event per `(14672, pubkey)` wins. Clients performing an edit SHOULD pass the previous event as `prev` so `published_at` is preserved (NIP-23 convention).
---
## Kind 16769: Profile Tabs
### Summary
+13 -14
View File
@@ -1,21 +1,22 @@
# Eranos
# Agora
Power to the people.
Eranos is a Nostr client focused on community ownership, expressive identity, and censorship resistance. This repository is the Eranos-branded app, a Grin-only fork of Agora (itself built from the Ditto codebase). All Bitcoin and Lightning payment rails have been removed; Grin payments land in a later phase.
Agora is a Nostr client focused on community ownership, expressive identity, and censorship resistance. This repository (`agora-3`) is the Agora-branded app built from the Ditto codebase.
**[eranos.fund](https://eranos.fund)** | **Upstream: [Agora](https://gitlab.com/soapbox-pub/agora-3)**
**[agora.spot](https://agora.spot)** | **[Source](https://gitlab.com/soapbox-pub/agora-3)**
## What This Repo Is
- Eranos product identity (name, theme, assets, native IDs)
- Agora product identity (name, theme, assets, native IDs)
- Ditto-derived implementation with broad Nostr feature coverage
- Configurable deployment defaults via `eranos.json`
- Configurable deployment defaults via `agora.json`
## Features
- **Community-first social client**: notes, articles, comments, reposts, reactions, and rich event rendering
- **Theming system**: built-in presets + custom color/font/background themes that can be shared as events
- **Lightning support**: zaps with Nostr Wallet Connect and WebLN
- **Private messaging**: NIP-04 and NIP-17 direct messages
- **Mobile app shell**: Capacitor-powered Android/iOS wrappers
- **Self-hostable**: static web build + configurable relay and upload infrastructure
@@ -30,8 +31,8 @@ Eranos is a Nostr client focused on community ownership, expressive identity, an
### Development
```sh
git clone <this-repo>
cd eranos
git clone https://gitlab.com/soapbox-pub/agora-3.git
cd agora-3
npm install
npm run dev
```
@@ -43,8 +44,8 @@ Development server: `http://localhost:8080`
Use Docker Compose when you want the nginx reverse-proxy stack (necessary if you want decryptable media in messages - kind 15s of NIP 17):
```sh
git clone <this-repo>
cd eranos
git clone https://gitlab.com/soapbox-pub/agora-3.git
cd agora-3
cp .env.example .env
docker compose up --build
```
@@ -86,7 +87,7 @@ This runs type-checking, linting, unit tests, and production build checks.
## Configuration
Build-time config is read from `eranos.json` (gitignored by default so each deployment can provide its own values).
Build-time config is read from `agora.json` (gitignored by default so each deployment can provide its own values).
```jsonc
{
@@ -108,7 +109,7 @@ Build-time config is read from `eranos.json` (gitignored by default so each depl
Configuration priority (highest first):
1. User settings (local storage)
2. Build config (`eranos.json`)
2. Build config (`agora.json`)
3. Hardcoded app defaults
Use a custom config path:
@@ -119,7 +120,7 @@ CONFIG_FILE=./my-config.json npm run build
## Deployment
Eranos builds to static files and can be deployed to any static host.
Agora builds to static files and can be deployed to any static host.
- GitLab/GitHub Pages
- Netlify/Vercel
@@ -154,5 +155,3 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a merge request.
## License
[AGPL-3.0](LICENSE)
🤖 Built with AI pair-programming assistance (Claude)
+3 -63
View File
@@ -7,22 +7,15 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace = "fund.eranos.app"
namespace = "spot.agora.app"
compileSdk = rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "fund.eranos.app"
applicationId "spot.agora.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "2.9.1"
versionName "2.14.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// The arti-mobile AAR bundles large native Rust libraries for every
// ABI (~45 MB total). Restrict to the ABIs we actually ship/test:
// arm64-v8a + armeabi-v7a (real devices) and x86_64 (emulators).
// Drop x86_64 here if you only ever test on physical devices.
ndk {
abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64'
}
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
@@ -58,20 +51,10 @@ repositories {
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.core:core:$androidxCoreVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation project(':capacitor-android')
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
// Tor in Rust (arti) — prebuilt AAR from Guardian Project's gpmaven repo
// (source pinned to an immutable commit in the root build.gradle).
// Provides org.torproject.arti.ArtiProxy used by TorController.
// The resolved AAR is checksum-verified below (verifyArtiChecksum).
implementation 'org.torproject:arti-mobile:1.7.0.1'
// arti pulls androidx.webkit in transitively but only at runtime; we
// compile against ProxyController/WebViewFeature in TorController, so
// declare it explicitly on the app's compile classpath.
implementation "androidx.webkit:webkit:$androidxWebkitVersion"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
@@ -80,49 +63,6 @@ dependencies {
apply from: 'capacitor.build.gradle'
// Supply-chain pin for the arti-mobile AAR. It carries a native library with
// network-proxy privileges and is sourced from Guardian Project's gpmaven repo,
// so we verify the resolved artifact's SHA-256 against a value pinned here.
// A mismatch fails the build before any APK is assembled. To bump arti, update
// the version + repo commit (root build.gradle) and replace this checksum after
// re-verifying a fresh download.
ext.artiMobileSha256 = 'cbdb34ce3cdb32f755f25f6dd05a2d1eb9a44025a17ec9202729816e2a3af05b'
task verifyArtiChecksum {
doLast {
def artifact = configurations.releaseRuntimeClasspath
.resolvedConfiguration
.resolvedArtifacts
.find { it.moduleVersion.id.group == 'org.torproject' && it.moduleVersion.id.name == 'arti-mobile' }
if (artifact == null) {
throw new GradleException("arti-mobile artifact not found on the runtime classpath; cannot verify Tor native library.")
}
def actual = java.security.MessageDigest.getInstance("SHA-256")
.digest(artifact.file.bytes)
.collect { String.format('%02x', it) }
.join('')
if (actual != project.ext.artiMobileSha256) {
throw new GradleException(
"arti-mobile AAR checksum mismatch!\n" +
" expected: ${project.ext.artiMobileSha256}\n" +
" actual: ${actual}\n" +
" file: ${artifact.file}\n" +
"Refusing to build a Tor proxy from an unverified native artifact."
)
}
logger.lifecycle("Verified arti-mobile AAR checksum (${actual}).")
}
}
afterEvaluate {
tasks.matching { it.name.startsWith('assemble') || it.name.startsWith('bundle') }.configureEach {
dependsOn verifyArtiChecksum
}
}
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
+1 -13
View File
@@ -7,7 +7,7 @@
# Keep Capacitor classes (WebView JS bridge)
-keep class com.getcapacitor.** { *; }
-keep class fund.eranos.app.** { *; }
-keep class spot.agora.app.** { *; }
# Keep WebView JS interfaces
-keepclassmembers class * {
@@ -19,18 +19,6 @@
-dontwarn okio.**
-keep class okhttp3.** { *; }
# Barcode scanner plugin (@capacitor/barcode-scanner -> OutSystems ionbarcode)
# references Gson's @SerializedName, but Gson isn't on the release classpath.
# Suppress the missing-class warning, keep the annotation attribute, and keep
# the plugin's model classes so R8 doesn't strip/rename serialized fields.
-dontwarn com.google.gson.**
-keepattributes *Annotation*
-keep class com.outsystems.plugins.barcode.** { *; }
# Keep arti (Tor) classes ArtiJNI declares native methods invoked from the
# Rust .so via JNI, so its names must not be obfuscated/stripped.
-keep class org.torproject.arti.** { *; }
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
+2 -2
View File
@@ -24,12 +24,12 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep links: open eranos.fund URLs in the app -->
<!-- Deep links: open agora.spot URLs in the app -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="eranos.fund" />
<data android:scheme="https" android:host="agora.spot" />
</intent-filter>
</activity>
@@ -1,381 +0,0 @@
package fund.eranos.app;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.webkit.ProxyConfig;
import androidx.webkit.ProxyController;
import androidx.webkit.WebViewFeature;
import org.json.JSONException;
import org.json.JSONObject;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.torproject.arti.ArtiProxy;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
/**
* Process-wide controller for the optional Tor (arti) mode on Android.
*
* <p>When enabled, this starts a local SOCKS5 proxy backed by arti (Tor in
* Rust) and — via {@link ArtiProxy.ArtiProxyBuilder#setWrapWebView(boolean)} —
* installs an Android {@code ProxyController} override so that <em>all</em>
* Capacitor WebView traffic (every {@code fetch} and relay {@code WebSocket})
* is routed through Tor. No changes to the TypeScript HTTP layer are needed.
*
* <p>The enabled flag is persisted to {@link SharedPreferences} by
* {@link TorPlugin} and read here at startup from {@link MainActivity}, so arti
* auto-starts on a cold launch <em>before</em> the WebView loads — there is no
* pre-bootstrap leak window. Beyond that, activation is live: the settings
* toggle calls {@link #start}/{@link #stop} (bridged through {@link TorPlugin}),
* which start or stop arti immediately while also updating the persisted flag.
*
* <p>Pluggable transports (obfs4 via IPtProxy) are intentionally not wired up
* yet — the builder already exposes {@code setObfs4Port}/{@code setBridgeLines}
* for a future censorship-resistance layer.
*/
public class TorController {
private static final String TAG = "TorController";
/** Local SOCKS5 port arti listens on (arti's own default). */
public static final int SOCKS_PORT = 9150;
static final String PREFS_NAME = "tor_config";
static final String KEY_ENABLED = "enabled";
/** Endpoint used to confirm a working Tor circuit (small JSON response). */
private static final String PROBE_URL = "https://check.torproject.org/api/ip";
// Re-verify continuously (gently) so the status reflects current reality.
private static final long PROBE_INTERVAL_SECONDS = 10;
/** After this long without a successful probe, surface a soft "failed". */
private static final long SOFT_TIMEOUT_SECONDS = 120;
// Status values mirrored to JS (see src/lib/tor.ts TorStatus).
public static final String STATUS_DISABLED = "disabled";
public static final String STATUS_CONNECTING = "connecting";
public static final String STATUS_CONNECTED = "connected";
public static final String STATUS_FAILED = "failed";
/** Receives status changes so the Capacitor plugin can forward them to JS. */
public interface StatusListener {
void onTorStatus(String status, int bootstrapPercent, @Nullable String error, @Nullable String exitIp);
}
private static volatile TorController instance;
private final Object lock = new Object();
private final AtomicBoolean started = new AtomicBoolean(false);
private ArtiProxy artiProxy;
private ScheduledExecutorService scheduler;
private volatile String status = STATUS_DISABLED;
private volatile int bootstrapPercent = 0;
@Nullable private volatile String error = null;
/** Tor exit-node IP from the last successful check (for verification UI). */
@Nullable private volatile String exitIp = null;
/** Consecutive failed probes; used to debounce CONNECTED -> reconnecting. */
private int consecutiveFailures = 0;
@Nullable private volatile StatusListener listener;
private volatile long startedAtMs = 0;
private TorController() {}
public static TorController getInstance() {
if (instance == null) {
synchronized (TorController.class) {
if (instance == null) {
instance = new TorController();
}
}
}
return instance;
}
/** Whether Tor is enabled in persisted preferences (read at cold-launch startup). */
public static boolean isEnabled(Context context) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
return prefs.getBoolean(KEY_ENABLED, false);
}
/**
* Persist the enabled flag only. This controls whether arti auto-starts on
* the next cold launch; it does not start or stop arti now. For live
* activation call {@link #start}/{@link #stop}, which also persist the flag.
*/
public static void setEnabled(Context context, boolean enabled) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putBoolean(KEY_ENABLED, enabled)
.apply();
}
public void setListener(@Nullable StatusListener listener) {
this.listener = listener;
// Replay the current status so a freshly-attached listener is in sync.
if (listener != null) {
listener.onTorStatus(status, bootstrapPercent, error, exitIp);
}
}
public String getStatus() {
return status;
}
public int getBootstrapPercent() {
return bootstrapPercent;
}
@Nullable
public String getError() {
return error;
}
@Nullable
public String getExitIp() {
return exitIp;
}
/**
* Start arti and install the WebView proxy override. Idempotent: a second
* call while already running is a no-op. Heavy work runs off the caller's
* thread so this is safe to invoke from {@code MainActivity.onCreate}.
*/
public void start(Context context) {
if (!started.compareAndSet(false, true)) {
return;
}
final Context appContext = context.getApplicationContext();
exitIp = null;
consecutiveFailures = 0;
// Install the fail-closed WebView proxy override synchronously, BEFORE
// the WebView loads (start() is called from MainActivity.onCreate ahead
// of super.onCreate). With no direct fallback, any request that arti
// can't carry fails instead of leaking out directly — even during the
// bootstrap window when arti isn't connected yet.
applyWebViewProxy();
updateStatus(STATUS_CONNECTING, 0, null);
startedAtMs = System.currentTimeMillis();
Thread t = new Thread(() -> {
try {
synchronized (lock) {
// NB: we do NOT use setWrapWebView(true) — arti's helper
// appends a DIRECT fallback (fail-open). We set our own
// fail-closed override in applyWebViewProxy() instead.
artiProxy = ArtiProxy.Builder(appContext)
.setSocksPort(SOCKS_PORT)
.setLogListener(this::onArtiLog)
.build();
artiProxy.start();
}
Log.d(TAG, "arti started on socks://127.0.0.1:" + SOCKS_PORT);
beginConnectivityProbe();
} catch (Throwable e) {
Log.e(TAG, "Failed to start arti", e);
updateStatus(STATUS_FAILED, bootstrapPercent, String.valueOf(e.getMessage()));
}
}, "arti-start");
t.setDaemon(true);
t.start();
}
/** Stop arti and route the WebView back to a direct connection. Safe to
* call live (toggle off) — clears the SOCKS proxy override so traffic
* doesn't get stranded on the now-stopped proxy. */
public void stop() {
// Remove the WebView SOCKS override first so new requests go direct.
clearWebViewProxy();
synchronized (lock) {
if (scheduler != null) {
scheduler.shutdownNow();
scheduler = null;
}
if (artiProxy != null) {
try {
artiProxy.stop();
} catch (Throwable e) {
Log.w(TAG, "Error stopping arti", e);
}
artiProxy = null;
}
}
started.set(false);
exitIp = null;
updateStatus(STATUS_DISABLED, 0, null);
}
/** Re-run the connectivity probe (used by a "Retry" action in the gate). */
public void retry() {
if (!started.get()) {
return;
}
consecutiveFailures = 0;
startedAtMs = System.currentTimeMillis();
if (!STATUS_CONNECTED.equals(status)) {
updateStatus(STATUS_CONNECTING, bootstrapPercent, null);
}
beginConnectivityProbe();
}
// --- internals -------------------------------------------------------
/**
* Route the WebView through arti's SOCKS proxy, FAIL-CLOSED. There is no
* {@code addDirect()} fallback, so when Tor can't carry a request it fails
* rather than leaking to a direct connection. localhost is bypassed (it's
* the local Capacitor asset server, never remote traffic).
*/
private void applyWebViewProxy() {
try {
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
ProxyConfig config = new ProxyConfig.Builder()
.addProxyRule("socks://127.0.0.1:" + SOCKS_PORT)
// No addDirect() — fail closed.
.addBypassRule("localhost")
.addBypassRule("127.0.0.1")
.build();
ProxyController.getInstance().setProxyOverride(config, Runnable::run, () -> {});
}
} catch (Throwable e) {
Log.w(TAG, "Error applying WebView proxy override", e);
}
}
/** Remove the app-wide WebView SOCKS proxy override so the WebView reverts
* to a direct connection. */
private void clearWebViewProxy() {
try {
if (WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) {
ProxyController.getInstance().clearProxyOverride(Runnable::run, () -> {});
}
} catch (Throwable e) {
Log.w(TAG, "Error clearing WebView proxy override", e);
}
}
private static final Pattern PERCENT = Pattern.compile("(\\d{1,3})\\s*%");
private void onArtiLog(String line) {
if (line == null) return;
Log.d("artilog", line);
// Best-effort bootstrap progress for the UI. arti's log format isn't a
// stable API, so the connectivity probe (below) remains authoritative
// for the definitive "connected" signal.
Matcher m = PERCENT.matcher(line);
if (m.find()) {
try {
int pct = Integer.parseInt(m.group(1));
if (pct >= 0 && pct <= 100 && pct >= bootstrapPercent
&& !STATUS_CONNECTED.equals(status)) {
updateStatus(STATUS_CONNECTING, pct, null);
}
} catch (NumberFormatException ignored) {
}
}
}
private void beginConnectivityProbe() {
synchronized (lock) {
if (scheduler != null) {
scheduler.shutdownNow();
}
scheduler = Executors.newSingleThreadScheduledExecutor(r -> {
Thread th = new Thread(r, "tor-probe");
th.setDaemon(true);
return th;
});
final ScheduledExecutorService s = scheduler;
final OkHttpClient client = new OkHttpClient.Builder()
.proxy(new Proxy(Proxy.Type.SOCKS, new InetSocketAddress("127.0.0.1", SOCKS_PORT)))
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build();
// Probe continuously (no shutdown on success). check.torproject.org
// reports whether the request actually exited via Tor, so we only
// report CONNECTED when IsTor is true — and we keep re-verifying so a
// dropped circuit downgrades the status instead of lying.
s.scheduleWithFixedDelay(() -> {
Request req = new Request.Builder()
.url(PROBE_URL)
.header("Accept", "application/json")
.build();
try (Response resp = client.newCall(req).execute()) {
String body = resp.body() != null ? resp.body().string() : "";
boolean isTor = false;
String ip = null;
try {
JSONObject json = new JSONObject(body);
isTor = json.optBoolean("IsTor", false);
ip = json.has("IP") ? json.optString("IP", null) : null;
} catch (JSONException ignored) {
// Non-JSON response — treat as not-via-Tor below.
}
if (resp.isSuccessful() && isTor) {
consecutiveFailures = 0;
exitIp = ip;
updateStatus(STATUS_CONNECTED, 100, null);
} else if (resp.isSuccessful()) {
// Reached the internet but NOT through Tor — a leak/bypass.
// This should not happen with the SOCKS proxy, but report
// it honestly rather than claiming a Tor connection.
consecutiveFailures = 0;
exitIp = ip;
updateStatus(STATUS_FAILED, bootstrapPercent,
"Connected to the internet, but not through Tor.");
} else {
handleProbeFailure();
}
} catch (Exception e) {
handleProbeFailure();
}
}, 0, PROBE_INTERVAL_SECONDS, TimeUnit.SECONDS);
}
}
/** A probe couldn't reach Tor. Debounce CONNECTED, surface FAILED after the
* soft timeout while still connecting. */
private void handleProbeFailure() {
consecutiveFailures++;
if (STATUS_CONNECTED.equals(status)) {
// Tolerate a couple of transient blips before downgrading.
if (consecutiveFailures >= 3) {
exitIp = null;
updateStatus(STATUS_CONNECTING, bootstrapPercent,
"Lost the Tor circuit; reconnecting…");
}
return;
}
long elapsed = (System.currentTimeMillis() - startedAtMs) / 1000;
if (elapsed >= SOFT_TIMEOUT_SECONDS && !STATUS_FAILED.equals(status)) {
updateStatus(STATUS_FAILED, bootstrapPercent,
"Couldn't reach the Tor network. Your network may be blocking Tor.");
}
}
private void updateStatus(String newStatus, int percent, @Nullable String err) {
this.status = newStatus;
this.bootstrapPercent = percent;
this.error = err;
StatusListener l = this.listener;
if (l != null) {
l.onTorStatus(newStatus, percent, err, exitIp);
}
}
}
@@ -1,102 +0,0 @@
package fund.eranos.app;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
/**
* Capacitor bridge for the Tor (arti) mode.
*
* <p>Mirrors {@link DittoNotificationPlugin}'s pattern: JS configures native
* state, native owns the work. On a cold launch arti auto-starts from
* {@link MainActivity} based on the persisted flag. At runtime the settings
* toggle activates Tor live via {@link #start}/{@link #stop}, which start or
* stop arti immediately and update the persisted flag. ({@link #setEnabled}
* persists the flag only, without touching the running proxy.) Live bootstrap
* status is pushed to JS via the {@code torStatus} event.
*
* <p>JS interface: see {@code src/lib/tor.ts}.
*/
@CapacitorPlugin(name = "Tor")
public class TorPlugin extends Plugin {
private static final String EVENT_STATUS = "torStatus";
@Override
public void load() {
// Forward native status changes to JS listeners. Attaching also replays
// the current status, keeping a newly-mounted JS gate in sync.
TorController.getInstance().setListener((status, bootstrapPercent, error, exitIp) -> {
JSObject data = new JSObject();
data.put("status", status);
data.put("bootstrapPercent", bootstrapPercent);
data.put("error", error);
data.put("exitIp", exitIp);
notifyListeners(EVENT_STATUS, data);
});
}
/** Whether Tor is enabled in persisted preferences. */
@PluginMethod
public void isEnabled(PluginCall call) {
JSObject ret = new JSObject();
ret.put("enabled", TorController.isEnabled(getContext()));
call.resolve(ret);
}
/**
* Persist the enabled flag only, without starting or stopping arti now.
* Controls whether arti auto-starts on the next cold launch. For live
* activation use {@link #start}/{@link #stop}.
*/
@PluginMethod
public void setEnabled(PluginCall call) {
Boolean enabled = call.getBoolean("enabled");
if (enabled == null) {
call.reject("Missing 'enabled' boolean");
return;
}
TorController.setEnabled(getContext(), enabled);
call.resolve();
}
/** Start arti now (live activation). Also persists enabled=true so it
* auto-starts on the next cold launch. */
@PluginMethod
public void start(PluginCall call) {
TorController.setEnabled(getContext(), true);
TorController.getInstance().start(getContext());
call.resolve();
}
/** Stop arti now (live deactivation) and clear the WebView proxy. Also
* persists enabled=false. */
@PluginMethod
public void stop(PluginCall call) {
TorController.setEnabled(getContext(), false);
TorController.getInstance().stop();
call.resolve();
}
/** Current connection status (synchronous snapshot). */
@PluginMethod
public void getStatus(PluginCall call) {
TorController controller = TorController.getInstance();
JSObject ret = new JSObject();
ret.put("enabled", TorController.isEnabled(getContext()));
ret.put("status", controller.getStatus());
ret.put("bootstrapPercent", controller.getBootstrapPercent());
ret.put("error", controller.getError());
ret.put("exitIp", controller.getExitIp());
call.resolve(ret);
}
/** Re-run the connectivity probe (for a "Retry" action in the gate). */
@PluginMethod
public void retry(PluginCall call) {
TorController.getInstance().retry();
call.resolve();
}
}
@@ -1,4 +1,4 @@
package fund.eranos.app;
package spot.agora.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
@@ -1,4 +1,4 @@
package fund.eranos.app;
package spot.agora.app;
import android.app.ForegroundServiceStartNotAllowedException;
import android.content.Context;
@@ -8,12 +8,6 @@ import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.webkit.WebView;
import androidx.core.graphics.Insets;
import androidx.core.view.ViewCompat;
import androidx.core.view.WindowInsetsCompat;
import com.getcapacitor.BridgeActivity;
@@ -25,14 +19,6 @@ public class MainActivity extends BridgeActivity {
protected void onCreate(Bundle savedInstanceState) {
// Register native plugins before super.onCreate.
registerPlugin(DittoNotificationPlugin.class);
registerPlugin(TorPlugin.class);
// If the user enabled Tor (apply on relaunch), start arti BEFORE
// super.onCreate so the WebView SOCKS proxy override is installed
// before the WebView issues any network request no leak window.
if (TorController.isEnabled(this)) {
TorController.getInstance().start(getApplicationContext());
}
super.onCreate(savedInstanceState);
@@ -61,35 +47,6 @@ public class MainActivity extends BridgeActivity {
// Handle notification tap deep link
handleNotificationIntent(getIntent());
// The Android WebView reports env(safe-area-inset-*) as 0, so inject the
// real system-bar insets as CSS variables (--safe-area-inset-top/bottom)
// that the web layer consumes (see src/index.css). Without this, the top
// nav renders behind the status bar in the APK.
applySafeAreaInsets();
}
/**
* Read the status-bar (top) and navigation-bar (bottom) insets and write
* them into the WebView as CSS pixel variables. Re-applies on every inset
* change (rotation, status-bar show/hide, etc.).
*/
private void applySafeAreaInsets() {
final WebView webView = getBridge().getWebView();
if (webView == null) return;
ViewCompat.setOnApplyWindowInsetsListener(webView, (v, insets) -> {
Insets bars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
float density = getResources().getDisplayMetrics().density;
int topPx = Math.round(bars.top / density);
int bottomPx = Math.round(bars.bottom / density);
String js =
"document.documentElement.style.setProperty('--safe-area-inset-top','" + topPx + "px');" +
"document.documentElement.style.setProperty('--safe-area-inset-bottom','" + bottomPx + "px');";
v.post(() -> webView.evaluateJavascript(js, null));
return insets;
});
ViewCompat.requestApplyInsets(webView);
}
@Override
@@ -106,7 +63,7 @@ public class MainActivity extends BridgeActivity {
private void handleNotificationIntent(Intent intent) {
if (intent == null) return;
Uri data = intent.getData();
if (data != null && "eranos.fund".equals(data.getHost())) {
if (data != null && "agora.spot".equals(data.getHost())) {
String path = data.getPath();
if (path != null && !path.isEmpty()) {
// Wait for WebView to be ready, then navigate
@@ -1,4 +1,4 @@
package fund.eranos.app;
package spot.agora.app;
import android.app.NotificationChannel;
import android.app.NotificationManager;
@@ -337,7 +337,7 @@ public class NostrPoller {
if (manager == null) return;
Intent intent = new Intent(context, MainActivity.class);
intent.setData(Uri.parse("https://eranos.fund/notifications"));
intent.setData(Uri.parse("https://agora.spot/notifications"));
intent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent pendingIntent = PendingIntent.getActivity(
context, id, intent,
@@ -1,4 +1,4 @@
package fund.eranos.app;
package spot.agora.app;
import android.app.AlarmManager;
import android.app.ForegroundServiceStartNotAllowedException;
@@ -83,7 +83,7 @@ public class NotificationRelayService extends Service {
// + REQ + up to 5 events + EOSE + metadata fetch + disconnect.
private static final long FETCH_WAKELOCK_TIMEOUT_MS = 30_000;
private static final String ACTION_FETCH = "fund.eranos.app.ACTION_FETCH";
private static final String ACTION_FETCH = "spot.agora.app.ACTION_FETCH";
// Backoff bounds for relay connect failures (separate from alarm interval).
private static final long INITIAL_BACKOFF_MS = 1_000;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

@@ -0,0 +1,50 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="100"
android:viewportHeight="100">
<!--
Ditto logo from public/logo.svg.
SVG viewBox is "-5 -10 100 100", so we shift all paths by (+5, +10)
to place the origin at (0,0) for the 100x100 viewport.
Then scale to 60% around the content center (50, 40) to fit within
Android's adaptive icon safe zone (66% of 108dp).
-->
<group
android:translateX="5"
android:translateY="10"
android:scaleX="0.7"
android:scaleY="0.7"
android:pivotX="50"
android:pivotY="40">
<!-- path1: bottom arc / bottom-right swash -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 71.719615,49.36907 -0.62891,0.37109 c -0.12891,0.07031 -0.26172,0.14844 -0.39062,0.21875 -3.9883,10.309 -14.008,17.617 -25.699,17.617 -4.1211,0 -8.0312,-0.89844 -11.539,-2.5391 -0.12891,0.03906 -0.26172,0.07031 -0.39063,0.10156 l -0.35156,0.08984 h -0.02734 l -0.25,0.05859 -0.07813,0.01953 -0.10938,0.03125 c -0.55859,0.12891 -1.1289,0.26172 -1.6992,0.39062 -0.10156,0.03125 -0.19922,0.05078 -0.30078,0.07031 l -0.30078,0.10156 -0.18359,0.0078 c -0.26953,0.05859 -1.3086,0.26953 -1.3086,0.26953 -0.28906,0.05859 -0.55859,0.10937 -0.82813,0.17187 4.9805,3.3086 10.961,5.2305 17.371,5.2305 15.059,0 27.699,-10.602 30.828,-24.738 -0.75,0.48828 -1.5195,0.96875 -2.2891,1.4414 -0.59375,0.36328 -1.2031,0.72656 -1.8242,1.0859 z" />
<!-- path2: small left accent dot/arc -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 30.926615,29.47807 c 0.36328,-0.48828 0.75,-0.95312 1.1523,-1.3828 0.75781,-0.80469 0.71484,-2.0703 -0.08984,-2.8281 -0.80469,-0.75781 -2.0703,-0.71484 -2.8281,0.08984 -0.50781,0.53906 -0.99219,1.125 -1.4492,1.7383 -0.65625,0.88672 -0.47266,2.1406 0.41406,2.7969 0.35938,0.26562 0.77344,0.39453 1.1875,0.39453 0.61719,0 1.2227,-0.27734 1.6133,-0.80859 z" />
<!-- path3: left vertical bar -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 26.742615,32.67807 c -1.0586,-0.3125 -2.1719,0.29687 -2.4805,1.3594 -0.55859,1.9062 -0.83984,3.9141 -0.83984,5.9609 0,2.3789 0.39062,4.7227 1.1602,6.9609 0.28516,0.82812 1.0625,1.3516 1.8906,1.3516 0.21484,0 0.43359,-0.03516 0.64844,-0.10938 1.043,-0.35938 1.6016,-1.4961 1.2422,-2.543 -0.625,-1.8203 -0.94141,-3.7227 -0.94141,-5.6602 0,-1.668 0.22656,-3.2969 0.67969,-4.8398 0.30859,-1.0586 -0.30078,-2.168 -1.3594,-2.4805 z" />
<!-- path4: main ring arc -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 14.691615,48.83807 c 0.10156,0.33984 0.19922,0.67969 0.32812,1.0195 0.42969,1.3516 0.94922,2.6484 1.5781,3.9102 0.10156,-0.01172 0.21094,-0.01172 0.32031,-0.01953 l 0.16016,-0.01172 0.80078,-0.07031 c 0.37109,-0.03906 0.67188,-0.07031 0.98047,-0.10156 0.51172,-0.05859 1.0195,-0.12109 1.5586,-0.19922 l 0.21875,-0.03125 c 0.07031,-0.01172 0.14062,-0.01953 0.21094,-0.03125 -1.2188,-2.2109 -2.1484,-4.6016 -2.7305,-7.1211 -0.16016,-0.71094 -0.30078,-1.4297 -0.39844,-2.1602 -0.19922,-1.3086 -0.30078,-2.6602 -0.30078,-4.0312 0,-0.89844 0.03906,-1.7812 0.12891,-2.6484 0.07031,-0.71094 0.16016,-1.4102 0.28906,-2.1016 2.25,-12.949 13.57,-22.828 27.16,-22.828 6.0508,0 11.648,1.9609 16.211,5.3008 0.57031,0.41016 1.1289,0.85938 1.6719,1.3203 1.6914,1.4219 3.2109,3.0703 4.5,4.8789 0.42969,0.60156 0.83984,1.2109 1.2188,1.8398 1.3203,2.1602 2.3398,4.5117 3.0195,7 0.23828,-0.17188 0.42969,-0.30859 0.62891,-0.46875 0.64844,-0.48047 1.2109,-0.92188 1.7383,-1.3398 0.28125,-0.23047 0.5,-0.41016 0.71094,-0.57812 0.10156,-0.07813 0.19141,-0.16016 0.28125,-0.23828 -0.42969,-1.3516 -0.96094,-2.6484 -1.5898,-3.8984 -0.14062,-0.32812 -0.30859,-0.64844 -0.48047,-0.96875 -0.32812,-0.64844 -0.69922,-1.2891 -1.0898,-1.9102 -1.6797,-2.7109 -3.7695,-5.1406 -6.1719,-7.2188 -0.57031,-0.48828 -1.1484,-0.96875 -1.7617,-1.4102 -0.55859,-0.42188 -1.1406,-0.82812 -1.7305,-1.2109 -4.9414,-3.2188 -10.852,-5.0898 -17.16,-5.0898 -14.961,0 -27.531,10.469 -30.75,24.469 -0.17188,0.67969 -0.30859,1.3711 -0.42188,2.0703 -0.12891,0.73828 -0.21875,1.5 -0.28906,2.2617 -0.07813,0.91016 -0.12109,1.8398 -0.12109,2.7812 0,2.3008 0.25,4.5508 0.71875,6.7109 0.17188,0.71484 0.35156,1.4258 0.5625,2.125 z" />
<!-- path5: top-right swash / outer arc with tail -->
<path
android:fillColor="#FFFFFF"
android:pathData="m 90.441615,21.60007 c -2.1797,-5.3398 -9.4102,-7.3984 -21,-6.0391 1.8906,1.8906 3.5391,3.9688 4.9297,6.2109 0.28906,0.46094 0.55859,0.92187 0.80859,1.3789 5.5391,-0.12109 7.6094,1.0391 7.8398,1.4492 0.12891,0.46875 -0.55078,2.7305 -4.5898,6.4805 -0.01953,0.01953 -0.03125,0.03125 -0.03906,0.03906 -0.26172,0.23828 -0.51953,0.48047 -0.80078,0.71875 -0.19922,0.17969 -0.41016,0.35938 -0.62891,0.53906 -0.10938,0.10156 -0.21875,0.19141 -0.33984,0.28906 -0.23828,0.19922 -0.5,0.41016 -0.76172,0.62109 -0.12891,0.10156 -0.26172,0.21094 -0.39844,0.32031 -0.42969,0.33984 -0.89063,0.69141 -1.3711,1.0508 -0.26953,0.21094 -0.53906,0.41016 -0.82812,0.60938 -0.32031,0.23047 -0.64062,0.46875 -0.98047,0.69922 0,0.01172 -0.01172,0.01172 -0.01172,0.01172 -0.26953,0.19141 -0.55078,0.37891 -0.82812,0.57031 -0.28125,0.19141 -0.55859,0.37109 -0.85156,0.55859 -0.25,0.16016 -0.5,0.32812 -0.76172,0.48828 -6,3.8984 -13.48,7.7188 -21.379,10.922 -8.0117,3.2383 -15.871,5.6602 -22.93,7.0391 -0.30078,0.05859 -0.60156,0.12109 -0.89062,0.17188 -0.60938,0.12109 -1.2188,0.21875 -1.8203,0.32031 -0.07031,0.01172 -0.12891,0.01953 -0.19922,0.03125 h -0.01953 c -0.28906,0.05078 -0.57031,0.08984 -0.83984,0.12891 -0.30859,0.05078 -0.60938,0.08984 -0.91016,0.12891 -0.57031,0.07813 -1.1094,0.14844 -1.6406,0.21094 -0.35156,0.03906 -0.69141,0.07031 -1.0195,0.10156 -0.30078,0.03125 -0.58984,0.05078 -0.87891,0.07813 -0.48047,0.03125 -0.92969,0.05859 -1.3711,0.07813 -0.39844,0.01953 -0.78125,0.03125 -1.1484,0.03906 -5.5116996,0.10938 -7.5702996,-1.0391 -7.8007996,-1.4492 -0.12891,-0.48047 0.55078,-2.7383 4.6093996,-6.5 -0.12891,-0.48828 -0.26172,-1 -0.37891,-1.5391 -0.51953,-2.4219 -0.78906,-4.8906 -0.78906,-7.3594 0,-0.17969 0,-0.37109 0.01172,-0.55078 -9.2733996,7.082 -13.0229996,13.59 -10.8749996,18.949 1.7383,4.2695 6.7188,6.4492 14.5899996,6.4492 2.8594,0 6.1016,-0.28906 9.7109,-0.87109 0.17188,-0.03125 0.33984,-0.05859 0.51953,-0.08984 0.17188,-0.03125 0.35156,-0.05859 0.51953,-0.08984 l 1.2188,-0.21875 c 0.57031,-0.10156 1.1484,-0.21875 1.7305,-0.33984 0.53125,-0.10938 1.0508,-0.21094 1.5781,-0.32812 0.01172,0 0.03125,-0.01172 0.03906,-0.01172 0.05078,-0.01172 0.08984,-0.01953 0.14062,-0.03125 0.07031,-0.01172 0.12891,-0.03125 0.19922,-0.05078 0.57812,-0.12891 1.1602,-0.26172 1.7383,-0.39844 0.05078,-0.01172 0.10156,-0.03125 0.14844,-0.03906 0.21094,-0.05078 0.42188,-0.10156 0.64062,-0.14844 h 0.01172 c 6.0898,-1.5117 12.559,-3.6406 19.102,-6.2891 6.5508,-2.6602 12.68,-5.6289 18.109,-8.7812 0.21875,-0.12891 0.44141,-0.26172 0.66016,-0.39062 0.58984,-0.33984 1.1797,-0.69141 1.7617,-1.0508 1.5703,-0.96094 3.0586,-1.9297 4.4805,-2.9102 0.12891,-0.08984 0.26172,-0.17969 0.39062,-0.26953 11.242,-7.8477 15.941,-15.09 13.594,-20.938 z" />
</group>
</vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 751 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 652 B

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#e9673f</color>
<color name="ic_launcher_background">#ff6600</color>
</resources>
+4 -4
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">Eranos</string>
<string name="title_activity_main">Eranos</string>
<string name="package_name">fund.eranos.app</string>
<string name="custom_url_scheme">fund.eranos.app</string>
<string name="app_name">Agora</string>
<string name="title_activity_main">Agora</string>
<string name="package_name">spot.agora.app</string>
<string name="custom_url_scheme">spot.agora.app</string>
</resources>
-12
View File
@@ -21,18 +21,6 @@ allprojects {
repositories {
google()
mavenCentral()
// Guardian Project's experimental Maven repo, hosting the prebuilt
// org.torproject:arti-mobile AAR (Tor in Rust) used for the optional Tor mode.
//
// Pinned to an immutable commit SHA rather than the mutable `master`
// branch: this artifact ships a native library with network-proxy
// privileges, so we don't want a force-push or new commit to gpmaven
// silently changing what we resolve. To bump arti, update both the
// commit below and the checksum pin in `app/build.gradle`, and re-verify
// the SHA-256 against a fresh download.
//
// Commit: guardianproject/gpmaven@b3ee2a63eec4ce37ea22fcc6b1ff009f406f2b13
maven { url "https://raw.githubusercontent.com/guardianproject/gpmaven/b3ee2a63eec4ce37ea22fcc6b1ff009f406f2b13" }
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
ext {
minSdkVersion = 26
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
+10 -3
View File
@@ -1,8 +1,8 @@
import type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'fund.eranos.app',
appName: 'Eranos',
appId: 'spot.agora.app',
appName: 'Agora',
webDir: 'dist',
server: {
androidScheme: 'https',
@@ -16,7 +16,14 @@ const config: CapacitorConfig = {
ios: {
backgroundColor: '#14161f',
contentInset: 'never',
scheme: 'Eranos'
scheme: 'Agora'
},
plugins: {
SystemBars: {
// Inject --safe-area-inset-* CSS variables on Android to work around
// a Chromium bug (<140) where env(safe-area-inset-*) reports 0.
insetsHandling: 'css',
},
},
};
+3 -3
View File
@@ -10,7 +10,7 @@ services:
depends_on:
- vite
networks:
- eranos-network
- agora-network
vite:
image: node:22-alpine
@@ -22,9 +22,9 @@ services:
environment:
- NODE_ENV=development
networks:
- eranos-network
- agora-network
restart: unless-stopped
networks:
eranos-network:
agora-network:
driver: bridge
+9 -9
View File
@@ -1,27 +1,27 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<title>Eranos — Power to the people.</title>
<title>Agora — Power to the people.</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="description" content="Eranos — a peer-to-peer crowdfunding app on Nostr. Fund campaigns directly with Grin, no middlemen." />
<meta name="description" content="Agora — a Nostr social client for communities, creativity, and ownership." />
<!-- Open Graph -->
<meta property="og:title" content="Eranos" />
<meta property="og:title" content="Agora" />
<meta property="og:description" content="Power to the people." />
<meta property="og:image" content="https://eranos.fund/og-image.jpg" />
<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" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://eranos.fund" />
<meta property="og:site_name" content="Eranos" />
<meta property="og:url" content="https://agora.spot" />
<meta property="og:site_name" content="Agora" />
<!-- Twitter / X -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Eranos" />
<meta name="twitter:title" content="Agora" />
<meta name="twitter:description" content="Power to the people." />
<meta name="twitter:image" content="https://eranos.fund/og-image.jpg" />
<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'; worker-src 'self' blob:; child-src 'self' blob:; 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">
@@ -29,7 +29,7 @@
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<meta name="theme-color" content="#0a0c14" media="(prefers-color-scheme: dark)">
<meta name="theme-color" content="#faa805" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#ff6600" media="(prefers-color-scheme: light)">
<link rel="manifest" href="/manifest.webmanifest">
<style>@keyframes agora-spin{to{transform:rotate(360deg)}}</style>
</head>
+4 -4
View File
@@ -323,9 +323,9 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.9.1;
MARKETING_VERSION = 2.14.4;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = fund.eranos.app;
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
@@ -347,8 +347,8 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.9.1;
PRODUCT_BUNDLE_IDENTIFIER = fund.eranos.app;
MARKETING_VERSION = 2.8.0;
PRODUCT_BUNDLE_IDENTIFIER = spot.agora.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_VERSION = 5.0;
+2 -2
View File
@@ -4,8 +4,8 @@
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>webcredentials:eranos.fund</string>
<string>webcredentials:eranos.fund?mode=developer</string>
<string>webcredentials:agora.spot</string>
<string>webcredentials:agora.spot?mode=developer</string>
</array>
</dict>
</plist>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 40 KiB

+1 -1
View File
@@ -33,7 +33,7 @@ public class DittoNotificationPlugin: CAPPlugin, CAPBridgedPlugin {
// MARK: - Constants
static let bgTaskIdentifier = "fund.eranos.app.notification-refresh"
static let bgTaskIdentifier = "spot.agora.app.notification-refresh"
private static let prefsKey = "ditto_notification_config"
// MARK: - Plugin Methods
+5 -5
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Eranos</string>
<string>Agora</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -48,11 +48,11 @@
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSPhotoLibraryUsageDescription</key>
<string>Eranos needs access to your photo library to upload images to your posts and profile.</string>
<string>Agora needs access to your photo library to upload images to your posts and profile.</string>
<key>NSCameraUsageDescription</key>
<string>Eranos needs camera access to take photos and videos for your posts, and to scan QR codes.</string>
<string>Agora needs camera access to take photos and videos for your posts, and to scan QR codes when sending Bitcoin.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Eranos needs access to your microphone to record voice messages.</string>
<string>Agora needs access to your microphone to record voice messages.</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIBackgroundModes</key>
@@ -61,7 +61,7 @@
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>fund.eranos.app.notification-refresh</string>
<string>spot.agora.app.notification-refresh</string>
</array>
</dict>
</plist>
+1 -1
View File
@@ -1,2 +1,2 @@
app_identifier("fund.eranos.app")
app_identifier("spot.agora.app")
team_id("GZLTTH5DLM")
+5 -5
View File
@@ -3,7 +3,7 @@ default_platform(:ios)
platform :ios do
# ─── Lanes ────────────────────────────────────────────────────────────
desc "Build and sign the App Store IPA. Output at ../artifacts/Eranos.ipa."
desc "Build and sign the App Store IPA. Output at ../artifacts/Agora.ipa."
lane :build_ipa do
setup_lane_signing!
build_release_ipa!
@@ -19,7 +19,7 @@ platform :ios do
submit_release_for_review!(ipa_path)
end
desc "Build, sign, and submit Eranos to the App Store for review (single-step convenience)."
desc "Build, sign, and submit Agora to the App Store for review (single-step convenience)."
lane :release do
setup_lane_signing!
build_release_ipa!
@@ -83,7 +83,7 @@ platform :ios do
configuration: "Release",
export_method: "app-store",
output_directory: "../artifacts",
output_name: "Eranos.ipa",
output_name: "Agora.ipa",
clean: true,
# Override the Xcode project's Automatic signing for this build only.
# Match has already installed the AppStore cert + profile into the
@@ -93,7 +93,7 @@ platform :ios do
xcargs: [
"CODE_SIGN_STYLE=Manual",
"CODE_SIGN_IDENTITY='Apple Distribution'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore fund.eranos.app'",
"PROVISIONING_PROFILE_SPECIFIER='match AppStore spot.agora.app'",
"DEVELOPMENT_TEAM=GZLTTH5DLM",
].join(" "),
export_options: {
@@ -101,7 +101,7 @@ platform :ios do
signingStyle: "manual",
teamID: "GZLTTH5DLM",
provisioningProfiles: {
"fund.eranos.app" => "match AppStore fund.eranos.app",
"spot.agora.app" => "match AppStore spot.agora.app",
},
},
)
+1 -1
View File
@@ -1,5 +1,5 @@
git_url("https://gitlab.com/soapbox-pub/certificates.git")
storage_mode("git")
type("appstore")
app_identifier(["fund.eranos.app"])
app_identifier(["spot.agora.app"])
team_id("GZLTTH5DLM")
+2423 -323
View File
File diff suppressed because it is too large Load Diff
+14 -8
View File
@@ -1,7 +1,7 @@
{
"name": "eranos",
"name": "agora",
"private": true,
"version": "2.9.1",
"version": "2.8.0",
"type": "module",
"scripts": {
"dev": "npm i --silent && vite",
@@ -15,6 +15,7 @@
"node": ">=22"
},
"dependencies": {
"@breeztech/breez-sdk-spark": "^0.10.0",
"@capacitor/app": "^8.0.0",
"@capacitor/barcode-scanner": "^3.0.2",
"@capacitor/core": "^8.1.0",
@@ -43,6 +44,7 @@
"@fontsource/pirata-one": "^5.2.8",
"@fontsource/silkscreen": "^5.2.8",
"@fontsource/special-elite": "^5.2.8",
"@getalby/sdk": "^5.1.1",
"@hookform/resolvers": "^5.2.2",
"@milkdown/core": "^7.20.0",
"@milkdown/ctx": "^7.20.0",
@@ -55,11 +57,10 @@
"@milkdown/prose": "^7.20.0",
"@milkdown/react": "^7.20.0",
"@milkdown/utils": "^7.20.0",
"@noble/curves": "^1.2.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^1.8.0",
"@scure/base": "^1.1.1",
"@nostrify/nostrify": "^0.52.2",
"@nostrify/react": "^0.6.2",
"@nostrify/nostrify": "^0.52.0",
"@nostrify/react": "^0.6.0",
"@nostrify/types": "^0.37.0",
"@plausible-analytics/tracker": "^0.4.4",
"@radix-ui/react-accordion": "^1.2.0",
@@ -85,6 +86,10 @@
"@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",
"@unhead/addons": "^2.1.13",
@@ -104,6 +109,7 @@
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"idb": "^8.0.3",
"iso-3166": "^4.4.0",
"lucide-react": "^1.8.0",
"nostr-tools": "^2.13.0",
"qr-scanner": "^1.4.2",
@@ -144,12 +150,12 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@webbtc/webln-types": "^3.0.0",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.0",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.9",
"globals": "^15.9.0",
"iso-3166": "^4.4.0",
"jsdom": "^26.1.0",
"postcss": "^8.4.47",
"rollup-plugin-visualizer": "^7.0.1",
@@ -157,7 +163,7 @@
"typescript": "^5.5.3",
"typescript-eslint": "^8.0.1",
"vite": "^8.0.3",
"vitest": "^4.1.8"
"vitest": "^3.1.4"
},
"overrides": {
"react": "$react",
-120
View File
@@ -1,125 +1,5 @@
# Changelog
## [2.9.1] - 2026-06-27
The Venezuela earthquake relief appeal now rallies behind every campaign on the ground, not just one. The home banner and relief page showcase a live, swipeable carousel of all Venezuelan relief efforts, with a running total of everything raised so far — so you can pick exactly who to help.
### Changed
- The Venezuela relief appeal now showcases every Venezuelan relief campaign in a live, auto-scrolling carousel you can swipe through, with a combined raised total across all of them, instead of featuring a single campaign.
## [2.9.0] - 2026-06-25
A big one. Private messaging arrives with a fast, searchable inbox you can reply to in your own language. Campaign organizers can now get verified by trusted reviewers through a guided sign-up, and verified badges appear right on campaigns. There's a new Venezuela earthquake relief appeal that takes you straight to donations, plus optional Tor routing on Android, separate Public and Private wallets, faster donation scanning, and refreshed profiles, settings, and login.
### Added
- Private direct messages: a dedicated inbox you can search by name, start new chats with inline recipient lookup, page back through old conversations, and read incoming messages translated into your language.
- Get verified: a guided sign-up for trusted reviewers to set up a verifier profile, publish a public "how we verify" statement, and vouch for campaigns. Verified badges now show on campaign pages, and a short tutorial walks you through it.
- A Venezuela earthquake relief appeal — a home-page banner, a one-time popup, and a shareable page that bakes in the relief campaign so you can read its story and donate without leaving.
- Optional Tor routing on Android for added privacy.
- Separate Public and Private wallets, keeping your spending and your private silent-payment funds cleanly apart.
- A "Don't have Bitcoin?" prompt on campaign pages that points first-time donors to Cash App.
- An always-visible language switcher in the top navigation, and a corporate sponsorship page.
### Changed
- Redesigned profiles with cleaner stats, a merged campaigns view, and a themed raised total.
- Redesigned Settings into an Apple-style grouped layout.
- Reworked the login and onboarding flow, including a new welcome screen built around the Agora brand.
- Silent-payment donations now scan faster and keep working in the background, with a progress bar on the private wallet.
- The home page now shows every featured campaign instead of capping the list.
### Fixed
- The audio, music, and podcast pages no longer crash.
- Backfilled and corrected translations across all sixteen languages.
## [2.8.9] - 2026-06-02
Adds an in-app prompt to grab the Android app from Zapstore, makes it easier to start or explore campaigns right from the home page, and irons out a batch of language and display fixes.
### Added
- A prompt to download the Android app from Zapstore, shown to mobile web visitors on the home page, in the account menu, and in the slide-out menu.
- A "Start a campaign" button alongside "Browse all" in the middle of the home page.
### Changed
- The "Explore campaigns" button now appears for everyone, not just logged-out visitors.
### Fixed
- Switching languages now takes effect immediately instead of showing stale text.
- The reply box and the replies heading on a post now show up in your chosen language.
- Account balances keep their Latin numerals regardless of display language.
- Filled in missing translations on the "Why Agora" screen.
## [2.8.8] - 2026-06-02
Fixes the app icon proportions and updates the loading splash to the Agora bolt.
### Fixed
- App icon no longer appears squashed.
- Loading splash now shows the Agora bolt instead of the old logo.
## [2.8.7] - 2026-06-02
Fixes the top navigation bar rendering behind the status bar on Android.
### Fixed
- Top navigation bar now clears the system status bar on Android.
## [2.8.6] - 2026-06-02
Refreshes the app icon to the orange Agora bolt mark across Android, iOS, and the web.
### Changed
- Update the app icon to the current Agora bolt on a brand-orange background.
## [2.8.5] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.4] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.3] - 2026-06-02
A maintenance release that fixes the Android build so signed releases publish correctly. No user-facing changes.
## [2.8.2] - 2026-06-02
A maintenance release that fixes the Android build pipeline so signed releases publish correctly. No user-facing changes.
## [2.8.1] - 2026-06-02
Agora becomes a home for putting your money where your heart is. Launch and back fundraising campaigns, rally around organizations with their own events and pledges, and send support straight from a built-in Bitcoin and Lightning wallet. Explore the world through immersive country pages, chat with a new AI agent, and move through a faster, cleaner app with a fresh look throughout.
### Added
- Fundraising campaigns as the new home surface — create, edit, and back campaigns, set goals, add beneficiaries, and follow progress.
- Organizations with their own events, pledges, members, and moderation tools.
- Built-in wallet for sending Bitcoin and Lightning payments, with transaction history and balances shown in USD.
- One-tap support: zap posts, profiles, campaigns, and organizations.
- AI agent chat with a model selector, tool-calling, and slash commands.
- Immersive country pages with anthems, flags, weather, and a country-scoped feed, plus a new Discover square for exploring the world.
- Comments and reactions on campaigns, and donation receipts shown inline.
### Changed
- Refreshed Agora branding, navigation, and app icons throughout.
- Streamlined onboarding with country and people follows.
- Polished campaign, organization, and donation flows end to end.
### Removed
- Direct messaging and ephemeral geo chat.
## [1.0.0] - 2026-04-30
### Added
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 B

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 985 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 98 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 226 KiB

+5 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 303 B

+10 -4
View File
@@ -1,11 +1,11 @@
{
"name": "Eranos",
"short_name": "Eranos",
"name": "Agora",
"short_name": "Agora",
"description": "Power to the people. Organize, create, and connect across the open Nostr network.",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0c14",
"theme_color": "#faa805",
"theme_color": "#ff6600",
"icons": [
{
"src": "/icon-192.png",
@@ -38,6 +38,12 @@
"purpose": "any"
}
],
"related_applications": [],
"related_applications": [
{
"platform": "play",
"url": "https://play.google.com/store/apps/details?id=spot.agora.app",
"id": "spot.agora.app"
}
],
"prefer_related_applications": false
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 124 KiB

+6 -6
View File
@@ -1,5 +1,5 @@
/**
* Eranos Service Worker
* Agora Service Worker
*
* Handles incoming Web Push notifications from the nostr-push server and
* opens/focuses the app when the user taps a notification.
@@ -14,17 +14,17 @@ self.addEventListener('push', (event) => {
try {
payload = event.data.json();
} catch {
payload = { title: 'Eranos', body: event.data.text() };
payload = { title: 'Agora', body: event.data.text() };
}
const title = payload.title ?? 'Eranos';
const title = payload.title ?? 'Agora';
const options = {
body: payload.body ?? '',
icon: payload.icon ?? '/icon-192.png',
badge: payload.badge ?? '/icon-192.png',
data: payload.data ?? {},
requireInteraction: false,
tag: payload.data?.subscription_id ?? 'eranos-notification',
tag: payload.data?.subscription_id ?? 'agora-notification',
renotify: true,
};
@@ -42,7 +42,7 @@ self.addEventListener('notificationclick', (event) => {
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then((clientList) => {
// Focus an existing Eranos tab if one is open
// Focus an existing Agora tab if one is open
for (const client of clientList) {
if (new URL(client.url).origin === self.location.origin) {
client.navigate('/notifications');
@@ -58,7 +58,7 @@ self.addEventListener('notificationclick', (event) => {
// --- Activate immediately ---
//
// On activate:
// 1. Wipe every Cache Storage entry. A previous version of Eranos deployed
// 1. Wipe every Cache Storage entry. A previous version of Agora deployed
// a precaching service worker (Workbox-style) that's still serving stale
// HTML/JS to returning users on this origin. Clearing caches means future
// requests bypass anything the old SW left behind.
+1 -1
View File
@@ -2,7 +2,7 @@
// preloader background before first paint. Runs as a blocking <script> so
// there's no flash of the wrong theme.
//
// Eranos's colors are hardcoded in src/index.css via :root {} and .dark {}
// Agora's colors are hardcoded in src/index.css via :root {} and .dark {}
// blocks. There is no custom-theme branch; the only thing this script
// does is set the right class on <html> and paint the preloader with the
// matching background + primary color so the page doesn't flash white
-39
View File
@@ -1,39 +0,0 @@
// Generate src/lib/subdivisionCodes.ts — the authoritative list of ISO 3166-2
// subdivision codes, extracted from the `iso-3166` package.
//
// We ship only the code strings (~42 KB) instead of importing the full
// `iso-3166` dataset (~244 KB of objects with names, parents, and tree
// structure) into the critical-path bundle. The only thing the runtime needs
// these for is validating that a `CC-XX` code is a real subdivision
// (see src/lib/countries.ts `isValidSubdivisionCode`).
//
// Run with: node scripts/gen-subdivision-codes.mjs
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { iso31662 } from 'iso-3166';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
const OUTPUT = path.join(REPO_ROOT, 'src/lib/subdivisionCodes.ts');
const codes = [...new Set(iso31662.map((s) => s.code))].sort();
const header = `// AUTO-GENERATED — do not edit by hand.
//
// The authoritative list of ISO 3166-2 subdivision codes, extracted from the
// \`iso-3166\` package at build time. We ship only the code strings (~42 KB)
// instead of importing the full \`iso-3166\` dataset (~244 KB of objects with
// names, parents, and tree structure) into the critical-path bundle, since
// the only thing the runtime needs these for is validating that a \`CC-XX\`
// code is a real subdivision.
//
// Regenerate with: node scripts/gen-subdivision-codes.mjs
`;
const body = `export const SUBDIVISION_CODES: readonly string[] = ${JSON.stringify(codes)};\n`;
fs.writeFileSync(OUTPUT, header + body);
console.log(`Wrote ${path.relative(REPO_ROOT, OUTPUT)} (${codes.length} codes)`);
+6 -7
View File
@@ -55,17 +55,16 @@ COLORED_SVG="$TMPDIR/logo_colored.svg"
RAW_PNG="$TMPDIR/raw.png"
MASTER_PNG="$TMPDIR/master.png"
# The phoenix logo.svg carries its own brand yellow (#fcd414); the sed only
# recolors legacy black-fill sources, so it's a no-op for the phoenix.
# Recolor the SVG's black fill to the brand color, on transparent background.
sed 's/fill="black"/fill="'"$LOGO_COLOR"'"/g' "$SOURCE_SVG" > "$COLORED_SVG"
# The SVG's viewBox is 1446x1246 (wider than tall). Render at its native
# aspect ratio first so we don't squish the logo vertically.
# The SVG's viewBox is 720x880 (taller than wide). Render at its native
# aspect ratio first so we don't squish the logo horizontally.
MASTER_BOX=512 # final square canvas size
MASTER_W=$MASTER_BOX # render the longer side at full size
MASTER_H=$(( MASTER_BOX * 1246 / 1446 )) # preserve 1446:1246 aspect
MASTER_H=$MASTER_BOX # render the longer side at full size
MASTER_W=$(( MASTER_BOX * 720 / 880 )) # preserve 720:880 aspect
echo "Rendering ${MASTER_W}x${MASTER_H} from $SOURCE_SVG (preserving 1446:1246 aspect)..."
echo "Rendering ${MASTER_W}x${MASTER_H} from $SOURCE_SVG (preserving 720:880 aspect)..."
if [ "$SVG_RENDERER" = "inkscape" ]; then
inkscape --export-type=png --export-filename="$RAW_PNG" \
-w "$MASTER_W" -h "$MASTER_H" --export-background-opacity=0 \
+15 -92
View File
@@ -42,42 +42,21 @@ if [ ! -f "$SOURCE_SVG" ]; then
fi
# Brand colors
BG_COLOR="#e9673f" # Agora orange (hsl(14 79% 58%))
BG_COLOR="#7c52e0" # Ditto purple
TMPDIR=$(mktemp -d)
LOGO_WHITE_SVG="$TMPDIR/logo_white.svg"
LOGO_WHITE="$TMPDIR/logo_white.png"
# Recolor the SVG fill to white before rasterizing. The phoenix logo.svg
# declares fill="#fcd414"; older sources used black/purple, kept for safety.
sed -e 's/fill="black"/fill="#ffffff"/g' \
-e 's/#000000/#ffffff/g' \
-e 's/#7c52e0/#ffffff/g' \
-e 's/#fcd414/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
# Recolor the SVG fill to white before rasterizing.
sed 's/#7c52e0/#ffffff/g' "$SOURCE_SVG" > "$LOGO_WHITE_SVG"
echo "Rendering white SVG (preserving aspect ratio)..."
echo "Rendering white SVG at 512x512..."
# Render at 1024px tall and let the renderer derive the width from the SVG
# viewBox, so the non-square logo (720x880) is NOT stretched into a square.
# The composite steps below use -resize WxH which fits-inside (aspect-
# preserving), keeping the glyph's true proportions.
if [ "$SVG_RENDERER" = "inkscape" ]; then
inkscape --export-type=png --export-filename="$LOGO_WHITE" -h 1024 "$LOGO_WHITE_SVG" 2>/dev/null
inkscape --export-type=png --export-filename="$LOGO_WHITE" -w 512 -h 512 "$LOGO_WHITE_SVG" 2>/dev/null
else
rsvg-convert -h 1024 "$LOGO_WHITE_SVG" -o "$LOGO_WHITE"
fi
# Orange-fill render, for marks placed on a white/light background (iOS splash).
LOGO_ORANGE_SVG="$TMPDIR/logo_orange.svg"
LOGO_ORANGE="$TMPDIR/logo_orange.png"
sed -e 's/fill="black"/fill="'"$BG_COLOR"'"/g' \
-e 's/#000000/'"$BG_COLOR"'/g' \
-e 's/#7c52e0/'"$BG_COLOR"'/g' \
-e 's/#fcd414/'"$BG_COLOR"'/g' "$SOURCE_SVG" > "$LOGO_ORANGE_SVG"
if [ "$SVG_RENDERER" = "inkscape" ]; then
inkscape --export-type=png --export-filename="$LOGO_ORANGE" -h 1024 "$LOGO_ORANGE_SVG" 2>/dev/null
else
rsvg-convert -h 1024 "$LOGO_ORANGE_SVG" -o "$LOGO_ORANGE"
rsvg-convert -w 512 -h 512 "$LOGO_WHITE_SVG" -o "$LOGO_WHITE"
fi
# ── Adaptive icon foreground PNGs (transparent bg, white logo, safe-zone padding) ──
@@ -103,27 +82,23 @@ make_foreground 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foregrou
# ── Legacy launcher icons (ic_launcher.png and ic_launcher_round.png) ──
# These are used on pre-API-26 devices and as fallback on some launchers.
# Both are the white logo composited onto an orange circle (brand mark).
# They must have the logo composited onto the purple background — NOT just
# a solid color fill.
echo "Generating legacy launcher icons (ic_launcher.png and ic_launcher_round.png)..."
# make_legacy_square: white logo on an orange circle (transparent corners)
# make_legacy_square: logo on flat purple square background
make_legacy_square() {
local size=$1
local content_size=$(echo "$size * 60 / 100" | bc)
local dest=$2
local mask="$TMPDIR/circle_mask_sq_${size}.png"
$MAGICK -size "${size}x${size}" "xc:none" \
-fill white -draw "circle $((size/2)),$((size/2)) $((size/2)),0" \
"$mask"
$MAGICK -size "${size}x${size}" "xc:${BG_COLOR}" \
"$mask" -compose dst-in -composite \
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
-gravity center -compose over -composite \
"$dest"
}
# make_legacy_round: white logo on circular orange background (alpha-masked circle)
# make_legacy_round: logo on circular purple background (alpha-masked circle)
make_legacy_round() {
local size=$1
local content_size=$(echo "$size * 60 / 100" | bc)
@@ -133,7 +108,7 @@ make_legacy_round() {
$MAGICK -size "${size}x${size}" "xc:none" \
-fill white -draw "circle $((size/2)),$((size/2)) $((size/2)),0" \
"$mask"
# Fill orange, apply circle mask, composite logo
# Fill purple, apply circle mask, composite logo
$MAGICK -size "${size}x${size}" "xc:${BG_COLOR}" \
"$mask" -compose dst-in -composite \
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
@@ -147,55 +122,23 @@ make_legacy_square 96 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
make_legacy_square 144 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
make_legacy_square 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
# Web logo.png (AppDownloadNudge) uses the same round brand-mark style.
make_legacy_round 512 public/logo.png
# PWA install icons (manifest.webmanifest "any" + "maskable" purposes).
make_legacy_round 192 public/icon-192.png
make_legacy_round 512 public/icon-512.png
make_legacy_round 48 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
make_legacy_round 72 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
make_legacy_round 96 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
make_legacy_round 144 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
make_legacy_round 192 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
# ── Android push-notification status-bar icon (ic_stat_ditto) ──
# Must be a flat white silhouette on transparent — the OS tints it, any
# color/gradient gets ignored or looks wrong. Referenced directly by
# NostrPoller.java / NotificationRelayService.java via R.drawable.ic_stat_ditto.
echo "Generating notification status-bar icon (ic_stat_ditto)..."
make_notification_icon() {
local size=$1
local content_size=$(echo "$size * 80 / 100" | bc)
local dest=$2
$MAGICK -size "${size}x${size}" "xc:none" \
\( "$LOGO_WHITE" -resize "${content_size}x${content_size}" \) \
-gravity center -compose over -composite \
"$dest"
}
for dir in drawable mipmap; do
make_notification_icon 24 android/app/src/main/res/${dir}-mdpi/ic_stat_ditto.png
make_notification_icon 48 android/app/src/main/res/${dir}-xhdpi/ic_stat_ditto.png
make_notification_icon 72 android/app/src/main/res/${dir}-xxhdpi/ic_stat_ditto.png
make_notification_icon 96 android/app/src/main/res/${dir}-xxxhdpi/ic_stat_ditto.png
done
make_notification_icon 36 android/app/src/main/res/drawable-hdpi/ic_stat_ditto.png
# Update background color
BACKGROUND_COLOR_FILE="android/app/src/main/res/values/ic_launcher_background.xml"
mkdir -p android/app/src/main/res/values
cat > "$BACKGROUND_COLOR_FILE" << 'EOF'
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#e9673f</color>
<color name="ic_launcher_background">#7c52e0</color>
</resources>
EOF
# ── iOS App Icon (1024x1024, white logo on orange background) ──
# ── iOS App Icon (1024x1024, white logo on purple background) ──
echo "Generating iOS app icon..."
@@ -203,7 +146,7 @@ IOS_ICON_DIR="ios/App/App/Assets.xcassets/AppIcon.appiconset"
if [ -d "$IOS_ICON_DIR" ]; then
IOS_ICON="$IOS_ICON_DIR/AppIcon-512@2x.png"
# Logo at ~60% of canvas, centered on orange background (matches Android style)
# Logo at ~60% of canvas, centered on purple background (matches legacy Android style)
$MAGICK -size "1024x1024" "xc:${BG_COLOR}" \
\( "$LOGO_WHITE" -resize "614x614" \) \
-gravity center -compose over -composite \
@@ -213,31 +156,11 @@ else
echo -e " ${YELLOW}Skipped: $IOS_ICON_DIR not found${NC}"
fi
# ── iOS launch screen (Splash.imageset, 2732x2732, white bg + small mark) ──
# Referenced by LaunchScreen.storyboard. Ships one image for all 3 scale
# slots (1x/2x/3x), matching how the project already had it set up.
echo "Generating iOS launch screen..."
IOS_SPLASH_DIR="ios/App/App/Assets.xcassets/Splash.imageset"
if [ -d "$IOS_SPLASH_DIR" ]; then
$MAGICK -size "2732x2732" "xc:white" \
\( "$LOGO_ORANGE" -resize "160x160" \) \
-gravity center -compose over -composite \
"$IOS_SPLASH_DIR/splash-2732x2732.png"
cp "$IOS_SPLASH_DIR/splash-2732x2732.png" "$IOS_SPLASH_DIR/splash-2732x2732-1.png"
cp "$IOS_SPLASH_DIR/splash-2732x2732.png" "$IOS_SPLASH_DIR/splash-2732x2732-2.png"
echo -e " ${GREEN}${NC} $IOS_SPLASH_DIR (3 files)"
else
echo -e " ${YELLOW}Skipped: $IOS_SPLASH_DIR not found${NC}"
fi
# Cleanup temp files
rm -rf "$TMPDIR"
echo -e "\n${GREEN}App icons generated successfully!${NC}"
echo -e "Icon: white Agora logo on ${GREEN}${BG_COLOR}${NC} (Agora orange)"
echo -e "Icon: white Ditto logo on ${GREEN}${BG_COLOR}${NC} (Ditto purple)"
echo -e "Generated:"
echo -e " Android:"
echo -e " - ic_launcher_foreground.png (adaptive, all densities)"
+22 -42
View File
@@ -15,11 +15,9 @@ import { SentryProvider } from "@/components/SentryProvider";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAppContext } from "@/hooks/useAppContext";
import { useNsecPasteGuard } from "@/hooks/useNsecPasteGuard";
import { useTor } from "@/hooks/useTor";
import type { AppConfig } from "@/contexts/AppContext";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { NWCProvider } from "@/contexts/NWCContext";
import { OnboardingProvider } from "@/contexts/OnboardingProvider";
import { BuildConfigSchema, type BuildConfig } from "@/lib/schemas";
import { secureStorage } from "@/lib/secureStorage";
@@ -41,11 +39,11 @@ const queryClient = new QueryClient({
/** Hardcoded fallback values. Always provides every required field. */
const hardcodedConfig: AppConfig = {
appName: "Eranos",
appId: "eranos",
appName: "Agora",
appId: "agora",
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
homePage: "campaigns",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkzem0wfssdl264k",
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
theme: "system",
useAppRelays: true,
useUserRelays: false,
@@ -59,6 +57,7 @@ const hardcodedConfig: AppConfig = {
feedIncludeReposts: true,
feedIncludeGenericReposts: true,
feedIncludeReactions: false,
feedIncludeZaps: true,
feedIncludeArticles: true,
showArticles: true,
showHighlights: true,
@@ -118,6 +117,7 @@ const hardcodedConfig: AppConfig = {
"feed",
"communities",
"world",
"wallet",
"agent",
"messages",
"profile",
@@ -145,8 +145,14 @@ const hardcodedConfig: AppConfig = {
imageQuality: 'compressed',
imageProxy: 'https://wsrv.nl',
lowBandwidthMode: false,
torEnabled: false,
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
esploraApis: [
'https://mempool.emzy.de/api',
'https://mempool.space/api',
'https://blockstream.info/api',
],
blockbookBaseUrl: 'https://btc.trezor.io',
bip352IndexerUrl: 'https://silentpayments.dev/blindbit/mainnet',
sidebarWidgets: [
{ id: 'trends' },
{ id: 'hot-posts' },
@@ -156,14 +162,6 @@ const hardcodedConfig: AppConfig = {
aiApiKey: '',
aiModel: 'google/gemma-4-26b',
aiSystemPrompt: '',
translateWorkerUrl: import.meta.env.VITE_TRANSLATE_WORKER_URL || '',
// Grin payments (Plan 2, C1). The GoblinPay instance URL/token are
// deployment-specific and land via build config (APP_CONFIG) or env;
// empty disables the in-app GoblinPay path. The node is read-only
// (kernel lookups for the payment-proof tally).
goblinPayUrl: import.meta.env.VITE_GOBLINPAY_URL || '',
goblinPayApiToken: import.meta.env.VITE_GOBLINPAY_API_TOKEN || '',
grinNodeUrl: import.meta.env.VITE_GRIN_NODE_URL || 'https://api.grin.money',
};
/**
@@ -193,24 +191,6 @@ const defaultConfig: AppConfig = {
feedSettings: { ...hardcodedConfig.feedSettings, ...buildConfig.feedSettings },
};
/**
* Wraps NostrProvider with a key that changes when Tor routing changes, so the
* relay layer remounts: existing connections close and reopen under the new
* routing (direct ⇄ fail-closed Tor), and reconnect immediately once Tor is up
* rather than waiting out the relay reconnect backoff. No-op off Android (the
* key is always "direct").
*/
function RelayProvider({ children }: { children: React.ReactNode }) {
const { config } = useAppContext();
const { status } = useTor();
const key = !config.torEnabled
? "direct"
: status === "connected"
? "tor-connected"
: "tor-pending";
return <NostrProvider key={key}>{children}</NostrProvider>;
}
export function App() {
useNsecPasteGuard();
@@ -222,19 +202,19 @@ export function App() {
<PlausibleProvider>
<QueryClientProvider client={queryClient}>
<NostrLoginProvider storageKey="nostr:login" storage={secureStorage}>
<RelayProvider>
<NostrProvider>
<NostrSync />
<InitialSyncRunner />
<NativeNotifications />
<OnboardingProvider>
<TooltipProvider>
<AudioPlayerProvider>
<AppRouter />
</AudioPlayerProvider>
</TooltipProvider>
</OnboardingProvider>
</RelayProvider>
<NWCProvider>
<OnboardingProvider>
<TooltipProvider>
<AppRouter />
</TooltipProvider>
</OnboardingProvider>
</NWCProvider>
</NostrProvider>
</NostrLoginProvider>
</QueryClientProvider>
</PlausibleProvider>
+34 -48
View File
@@ -7,9 +7,6 @@ import { TopNav } from "./components/TopNav";
import { OnboardingGate } from "./components/OnboardingGate";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { MinimizedAudioBar } from "./components/MinimizedAudioBar";
import { AudioNavigationGuard } from "./components/AudioNavigationGuard";
import { TorStatusBanner } from "./components/TorStatusBanner";
import { useCurrentUser } from "./hooks/useCurrentUser";
import { useProfileUrl } from "./hooks/useProfileUrl";
import { cn } from "@/lib/utils";
@@ -24,7 +21,6 @@ import NotFound from "./pages/NotFound";
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
const AllCampaignsPage = lazy(() => import("./pages/AllCampaignsPage").then(m => ({ default: m.AllCampaignsPage })));
const CampaignListDetailPage = lazy(() => import("./pages/CampaignListDetailPage").then(m => ({ default: m.CampaignListDetailPage })));
// All other pages: code-split via React.lazy
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
@@ -39,24 +35,27 @@ const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({
const ExternalContentPage = lazy(() => import("./pages/ExternalContentPage").then(m => ({ default: m.ExternalContentPage })));
const GeotagPage = lazy(() => import("./pages/GeotagPage").then(m => ({ default: m.GeotagPage })));
const HashtagPage = lazy(() => import("./pages/HashtagPage").then(m => ({ default: m.HashtagPage })));
const MessagesPage = lazy(() => import("./pages/MessagesPage").then(m => ({ default: m.MessagesPage })));
const MyDashboardPage = lazy(() => import("./pages/MyDashboardPage").then(m => ({ default: m.MyDashboardPage })));
const AboutPage = lazy(() => import("./pages/AboutPage").then(m => ({ default: m.AboutPage })));
const DonorGuidePage = lazy(() => import("./pages/DonorGuidePage").then(m => ({ default: m.DonorGuidePage })));
const RecipientGuidePage = lazy(() => import("./pages/RecipientGuidePage").then(m => ({ default: m.RecipientGuidePage })));
const CorporateSponsorshipPage = lazy(() => import("./pages/CorporateSponsorshipPage").then(m => ({ default: m.CorporateSponsorshipPage })));
const ActivistGuidePage = lazy(() => import("./pages/ActivistGuidePage").then(m => ({ default: m.ActivistGuidePage })));
const LanguageSettingsPage = lazy(() => import("./pages/LanguageSettingsPage").then(m => ({ default: m.LanguageSettingsPage })));
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
const OrganizationsPage = lazy(() => import("./pages/OrganizationsPage").then(m => ({ default: m.OrganizationsPage })));
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
const EventDashboardPage = lazy(() => import("./pages/EventDashboardPage").then(m => ({ default: m.EventDashboardPage })));
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
const WalletMigrateV1Page = lazy(() => import("./pages/WalletMigrateV1Page").then(m => ({ default: m.WalletMigrateV1Page })));
const WalletDoubleTweakFixPage = lazy(() => import("./pages/WalletDoubleTweakFixPage").then(m => ({ default: m.WalletDoubleTweakFixPage })));
const WalletRecoveryPage = lazy(() => import("./pages/WalletRecoveryPage").then(m => ({ default: m.WalletRecoveryPage })));
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
const LegacyWalletRecoveryPage = lazy(() => import("./pages/LegacyWalletRecoveryPage").then(m => ({ default: m.LegacyWalletRecoveryPage })));
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
@@ -84,7 +83,7 @@ function PageSkeleton() {
function SiteFooter() {
const { t } = useTranslation();
return (
<footer className="bg-background mt-auto pt-6 sm:pt-12">
<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">
<button
type="button"
@@ -95,8 +94,6 @@ function SiteFooter() {
</button>
<nav className="flex items-center gap-5">
<Link to="/about" className="hover:text-foreground motion-safe:transition-colors">{t('nav.about')}</Link>
<Link to="/sponsors" className="hover:text-foreground motion-safe:transition-colors">{t('nav.sponsors')}</Link>
<Link to="/verify" className="hover:text-foreground motion-safe:transition-colors">{t('nav.verify')}</Link>
<Link to="/privacy" className="hover:text-foreground motion-safe:transition-colors">{t('nav.privacy')}</Link>
<Link to="/safety" className="hover:text-foreground motion-safe:transition-colors">{t('nav.safety')}</Link>
<Link to="/changelog" className="hover:text-foreground motion-safe:transition-colors">{t('nav.changelog')}</Link>
@@ -113,18 +110,18 @@ function SiteFooter() {
* form/prose-style pages, wide (full width) for landing / dashboard / detail
* pages that render their own internal layout.
*/
function FundraiserLayout({ narrow, hideFooter }: { narrow: boolean; hideFooter?: boolean }) {
function FundraiserLayout({ narrow }: { narrow: boolean }) {
return (
<div className={cn('flex flex-col bg-background', hideFooter ? 'h-dvh overflow-hidden' : 'min-h-dvh')}>
<div className="min-h-dvh flex flex-col bg-background">
<TopNav />
<Suspense fallback={<PageSkeleton />}>
<div
className={cn('min-w-0 w-full flex-1 mx-auto', hideFooter && 'min-h-0', narrow && 'max-w-3xl')}
className={cn("flex-1 min-w-0 w-full mx-auto", narrow && "max-w-3xl")}
>
<Outlet />
</div>
</Suspense>
{!hideFooter && <SiteFooter />}
<SiteFooter />
</div>
);
}
@@ -135,11 +132,6 @@ export function AppRouter() {
<Toaster />
<VersionCheck />
<ScrollToTop />
<AudioNavigationGuard />
<MinimizedAudioBar />
{/* App-wide Tor status banner. Must live inside BrowserRouter — it
renders a <Link> to the Tor settings, which needs Router context. */}
<TorStatusBanner />
<OnboardingGate>
<Routes>
{/* Narrow layout — `max-w-3xl` center column. The default for
@@ -156,43 +148,48 @@ export function AppRouter() {
<Route path="/settings/appearance" element={<AppearanceSettingsPage />} />
<Route path="/settings/language" element={<LanguageSettingsPage />} />
<Route path="/settings/profile" element={<ProfileSettings />} />
<Route path="/settings/wallet" element={<WalletSettingsPage />} />
<Route path="/settings/notifications" element={<NotificationSettings />} />
<Route path="/settings/advanced" element={<AdvancedSettingsPage />} />
<Route path="/settings/network" element={<NetworkSettingsPage />} />
<Route path="/wallet" element={<WalletPage />} />
<Route path="/wallet/legacy" element={<LegacyWalletRecoveryPage />} />
{/* Old nested paths kept as redirects so any existing links / muscle
memory still land on the right page. `/wallet/settings` was an
intermediate hub that has been replaced by an overflow menu on
`/wallet`, so it redirects to the wallet home. `/wallet/backup`
is now an in-page dialog opened from that menu, so it also
redirects home. */}
<Route path="/wallet/settings" element={<Navigate to="/wallet" replace />} />
<Route path="/wallet/backup" element={<Navigate to="/wallet" replace />} />
<Route path="/wallet/settings/backup" element={<Navigate to="/wallet" replace />} />
<Route path="/wallet/settings/legacy" element={<Navigate to="/wallet/legacy" replace />} />
<Route path="/wallet/recovery" element={<WalletRecoveryPage />} />
<Route path="/wallet/migrate-v1" element={<WalletMigrateV1Page />} />
<Route path="/wallet/double-tweak-fix" element={<WalletDoubleTweakFixPage />} />
<Route path="/bitcoin" element={<Navigate to="/wallet" replace />} />
{/* Legacy /help routes redirect to /about so existing links keep
working. The About page and the two guides themselves live
under the wide layout below. */}
<Route path="/help" element={<Navigate to="/about" replace />} />
<Route path="/help/donors" element={<Navigate to="/about/donors" replace />} />
<Route path="/help/activists" element={<Navigate to="/about/recipients" replace />} />
<Route path="/help/recipients" element={<Navigate to="/about/recipients" replace />} />
<Route path="/help/activists" element={<Navigate to="/about/activists" replace />} />
<Route path="/privacy" element={<PrivacyPolicyPage />} />
<Route path="/safety" element={<CSAEPolicyPage />} />
<Route path="/changelog" element={<ChangelogPage />} />
<Route path="/organizers" element={<OrganizersPage />} />
{/* `/settings/verifier` moved to the public `/verify` onboarding
page. Keep the old path as a redirect so existing links resolve. */}
<Route path="/settings/verifier" element={<Navigate to="/verify" replace />} />
{/* Callback target for remote signers (e.g. Amber, Primal) after NIP-46 approval */}
<Route path="/remoteloginsuccess" element={<RemoteLoginSuccessPage />} />
</Route>
<Route element={<FundraiserLayout narrow={false} hideFooter />}>
<Route path="/messages" element={<MessagesPage />} />
</Route>
{/* Wide layout — no max-width on the center column. Used by landing /
list / detail pages that render their own internal width
constraints. */}
<Route element={<FundraiserLayout narrow={false} />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/campaigns" element={<AllCampaignsPage />} />
<Route path="/campaigns" element={<Navigate to="/" replace />} />
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
<Route path="/campaigns/lists/:slug" element={<CampaignListDetailPage />} />
{/* Legacy URL: the all-campaigns directory lived at
`/campaigns/all` for a while. Keep it as a redirect so
external links and bookmarks still resolve. */}
<Route path="/campaigns/all" element={<Navigate to="/campaigns" replace />} />
<Route path="/campaigns/all" element={<AllCampaignsPage />} />
<Route path="/groups" element={<CommunitiesPage />} />
<Route path="/groups/new" element={<CreateCommunityPage />} />
<Route path="/events/new" element={<CreateEventPage />} />
@@ -200,22 +197,11 @@ export function AppRouter() {
<Route path="/pledges/new" element={<CreateActionPage />} />
<Route path="/dashboard" element={<EventDashboardPage />} />
<Route path="/i/*" element={<ExternalContentPage />} />
{/* About page + Donor / Recipient guides. Full-bleed landing-style
{/* About page + Donor / Activist guides. Full-bleed landing-style
layouts that render their own internal max-widths. */}
<Route path="/about" element={<AboutPage />} />
<Route path="/about/donors" element={<DonorGuidePage />} />
<Route path="/about/recipients" element={<RecipientGuidePage />} />
{/* Corporate sponsorship / partnership marketing page. Wide layout
so the hero and section backgrounds span the viewport like /about. */}
<Route path="/sponsors" element={<CorporateSponsorshipPage />} />
{/* Verification onboarding / marketing page. Wide layout so the
hero and section backgrounds can span the viewport like /about. */}
<Route path="/verify" element={<OrganizationsPage />} />
<Route path="/organizations" element={<Navigate to="/verify" replace />} />
{/* Legacy URL: the recipient guide lived at `/about/activists`
before the "activist" → "recipient" copy change. Redirect so
external links and bookmarks still resolve. */}
<Route path="/about/activists" element={<Navigate to="/about/recipients" replace />} />
<Route path="/about/activists" element={<ActivistGuidePage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1.
Goes through the wide layout because the dispatch may resolve to
a profile, campaign, action, or community page — all of which
+5 -2
View File
@@ -5,10 +5,11 @@ import type { NostrEvent } from '@nostrify/nostrify';
import { Camera, Clock, DollarSign, Info, Megaphone, Palette } from 'lucide-react';
import { parseAction, type Action } from '@/hooks/useActions';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { countryCodeToFlag, getGeoDisplayName } from '@/lib/countries';
import { CountryFlag } from '@/components/CountryFlag';
import { DEFAULT_COVER_IMAGE } from '@/lib/defaultActionCovers';
import { formatPledgeAmount } from '@/lib/pledges';
import { formatSats, satsToUSDWhole } from '@/lib/bitcoin';
import { cn } from '@/lib/utils';
const ACTION_ICONS = {
@@ -27,6 +28,7 @@ function actionNaddr(action: Action): string {
}
export function ActionContent({ event, compact = true }: { event: NostrEvent; compact?: boolean }) {
const { data: btcPrice } = useBtcPrice();
const action = parseAction(event);
if (!action) return null;
@@ -96,8 +98,9 @@ export function ActionContent({ event, compact = true }: { event: NostrEvent; co
<div className="flex items-center gap-2 text-sm">
<DollarSign className="size-4 shrink-0 text-primary" />
<span className="font-semibold">
{formatPledgeAmount(action.bounty)}
{btcPrice ? satsToUSDWhole(action.bounty, btcPrice) : `${formatSats(action.bounty)} sats`}
</span>
{btcPrice && <span className="text-xs text-muted-foreground">~{formatSats(action.bounty)} sats</span>}
{action.countryCode && (
<>
<span className="text-muted-foreground/50">·</span>
-38
View File
@@ -17,9 +17,6 @@ import { DEFAULT_SYSTEM_PROMPT_TEMPLATE } from '@/lib/aiChatSystemPrompt';
const DEFAULT_AI_BASE_URL = 'https://ai.shakespeare.diy/v1';
const DEFAULT_AI_MODEL = 'google/gemma-4-26b';
/** Build-time default translation worker URL from the environment variable. */
const DEFAULT_TRANSLATE_WORKER_URL = import.meta.env.VITE_TRANSLATE_WORKER_URL || '';
/** The build-time default DSN from the environment variable. */
const DEFAULT_SENTRY_DSN = import.meta.env.VITE_SENTRY_DSN || '';
@@ -37,7 +34,6 @@ export function AdvancedSettings() {
const [faviconUrl, setFaviconUrl] = useState(config.faviconUrl);
const [linkPreviewUrl, setLinkPreviewUrl] = useState(config.linkPreviewUrl);
const [corsProxy, setCorsProxy] = useState(config.corsProxy);
const [translateWorkerUrl, setTranslateWorkerUrl] = useState(config.translateWorkerUrl);
const [sentryDsn, setSentryDsn] = useState(config.sentryDsn);
const [baseUrlDraft, setBaseUrlDraft] = useState(config.aiBaseURL);
const [apiKeyDraft, setApiKeyDraft] = useState(config.aiApiKey);
@@ -403,40 +399,6 @@ export function AdvancedSettings() {
<span className="font-mono break-all">https://proxy.shakespeare.diy/?url={'{href}'}</span>
</div>
</div>
{/* Translation Worker URL */}
<div>
<Label htmlFor="translate-worker-url" className="text-sm font-medium">
Translation Worker URL
</Label>
<p className="text-xs text-muted-foreground mt-1 mb-2">
DeepL-backed worker endpoint used by the "Translate" button on notes. Receives a POST with the text and target language.
</p>
<Input
id="translate-worker-url"
type="url"
value={translateWorkerUrl}
onChange={(e) => setTranslateWorkerUrl(e.target.value)}
onBlur={async () => {
const trimmed = translateWorkerUrl.trim();
if (trimmed && trimmed !== config.translateWorkerUrl) {
updateConfig(() => ({ translateWorkerUrl: trimmed }));
if (user) await updateSettings.mutateAsync({ translateWorkerUrl: trimmed });
toast({ title: 'Translation worker URL updated' });
}
}}
placeholder={DEFAULT_TRANSLATE_WORKER_URL || 'https://example.workers.dev'}
className="font-mono text-base md:text-sm"
autoComplete="off"
spellCheck={false}
/>
{DEFAULT_TRANSLATE_WORKER_URL && (
<div className="text-xs text-muted-foreground mt-2">
<span className="font-medium">Default: </span>
<span className="font-mono break-all">{DEFAULT_TRANSLATE_WORKER_URL}</span>
</div>
)}
</div>
</div>
</CollapsibleContent>
</Collapsible>
+58 -16
View File
@@ -1,20 +1,33 @@
import type { NostrEvent } from '@nostrify/nostrify';
import type { ReactNode } from 'react';
import { CalendarClock, HandHeart, MapPin, Megaphone, Users } from 'lucide-react';
import { CalendarClock, HandHeart, MapPin, Megaphone, ShieldCheck, Users } from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useBtcPrice } from '@/hooks/useBtcPrice';
import { useCampaignDonations } from '@/hooks/useCampaignDonations';
import { useAuthor } from '@/hooks/useAuthor';
import { parseAction } from '@/hooks/useActions';
import { getGeoDisplayName } from '@/lib/countries';
import { parseCampaign, getCampaignCountryLabel } from '@/lib/campaign';
import { parseCommunityEvent } from '@/lib/communityUtils';
import { formatUsdGoal } from '@/lib/formatCampaignAmount';
import { formatCampaignAmount, formatUsdGoal, satsToUsd } from '@/lib/formatCampaignAmount';
import { formatCompactPledgeDeadline, formatPledgeAmount } from '@/lib/pledges';
import { sanitizeUrl } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
function getDeadlineLabel(unixSeconds: number): { label: string; isPast: boolean } {
const now = Math.floor(Date.now() / 1000);
const diff = unixSeconds - now;
if (diff <= 0) return { label: 'Ended', isPast: true };
const days = Math.ceil(diff / 86_400);
if (days <= 1) return { label: 'Ends today', isPast: false };
if (days < 30) return { label: `${days} days left`, isPast: false };
const months = Math.round(days / 30);
return { label: `${months} mo left`, isPast: false };
}
function InlineShell({
image,
fallbackIcon,
@@ -51,8 +64,9 @@ function InlineShell({
}
export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
const { t } = useTranslation();
const campaign = parseCampaign(event);
const { data: btcPrice } = useBtcPrice();
const { data: stats } = useCampaignDonations(campaign ?? undefined);
const author = useAuthor(event.pubkey);
if (!campaign) return null;
@@ -62,9 +76,15 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
?? sanitizeUrl(authorMetadata?.picture);
const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: campaign.identifier });
const countryLabel = getCampaignCountryLabel(campaign);
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0
? t('campaignsDetail.target', { amount: formatUsdGoal(campaign.goalUsd) })
: undefined;
const deadline = campaign.deadline ? getDeadlineLabel(campaign.deadline) : undefined;
const isSilentPayment = !campaign.wallets.onchain;
const goalLabel = campaign.goalUsd && campaign.goalUsd > 0 ? formatUsdGoal(campaign.goalUsd) : undefined;
const raisedSats = stats?.totalSats ?? 0;
const raisedLabel = isSilentPayment ? undefined : formatCampaignAmount(raisedSats, btcPrice);
const raisedUsd = isSilentPayment ? undefined : satsToUsd(raisedSats, btcPrice);
const progress = campaign.goalUsd && raisedUsd !== undefined
? Math.min(100, Math.round((raisedUsd / campaign.goalUsd) * 100))
: 0;
return (
<Link to={`/${naddr}`} onClick={(e) => e.stopPropagation()} className="block rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background">
@@ -74,15 +94,36 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
title={campaign.title}
description={campaign.story}
meta={(
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-xs text-muted-foreground">
{goalLabel && (
<span className="font-semibold text-foreground">{goalLabel}</span>
)}
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
{countryLabel}
</span>
)}
<div className="space-y-2 pt-1">
{campaign.goalUsd && !isSilentPayment ? (
<div className="h-1.5 overflow-hidden rounded-full bg-foreground/15">
<div className="h-full rounded-full bg-primary" style={{ width: `${progress}%` }} />
</div>
) : null}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
{isSilentPayment ? (
<span className="inline-flex items-center gap-1.5">
<ShieldCheck className="size-3.5" />
{goalLabel ?? 'Private campaign'}
</span>
) : (
<span className="font-semibold text-foreground">
{raisedLabel}<span className="font-normal text-muted-foreground"> {goalLabel ? `/ ${goalLabel}` : 'raised'}</span>
</span>
)}
{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>
)}
/>
@@ -93,6 +134,7 @@ export function CampaignInlinePreview({ event }: { event: NostrEvent }) {
export function PledgeInlinePreview({ event }: { event: NostrEvent }) {
const { t } = useTranslation();
const pledge = parseAction(event);
const { data: btcPrice } = useBtcPrice();
if (!pledge) return null;
const naddr = nip19.naddrEncode({ kind: 36639, pubkey: pledge.pubkey, identifier: pledge.id });
@@ -110,7 +152,7 @@ export function PledgeInlinePreview({ event }: { event: NostrEvent }) {
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 pt-1 text-xs text-muted-foreground">
<span className="inline-flex items-baseline gap-1.5">
<span className="font-semibold uppercase tracking-wide text-primary">{t('pledges.card.pledged')}</span>
<span className="text-sm font-bold text-foreground">{formatPledgeAmount(pledge.bounty)}</span>
<span className="text-sm font-bold text-foreground">{formatPledgeAmount(pledge.bounty, btcPrice)}</span>
</span>
{countryLabel && (
<span className="inline-flex items-center gap-1.5">
-50
View File
@@ -1,50 +0,0 @@
import { Capacitor } from '@capacitor/core';
import { useTranslation } from 'react-i18next';
import { ArrowRight } from 'lucide-react';
import { useAppContext } from '@/hooks/useAppContext';
import { ZAPSTORE_URL } from '@/lib/zapstore';
import { cn } from '@/lib/utils';
/**
* Zapstore download nudge — prompts mobile-web visitors to install the native
* Android app. Hidden inside the native app (you're already in it) and on
* desktop (`sm:hidden`), where downloading works differently.
*/
export function AppDownloadNudge({ className }: { className?: string }) {
const { t } = useTranslation();
const { config } = useAppContext();
if (Capacitor.isNativePlatform()) return null;
return (
<div className={cn('sm:hidden px-4 pt-8 pb-4', className)}>
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">
{t('feed.getApp.eyebrow')}
</p>
<div className="flex items-center gap-3">
<img
src="/logo.png"
alt={config.appName}
className="h-10 w-10 shrink-0 rounded-xl"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-foreground">
{t('feed.getApp.title', { appName: config.appName })}
</p>
<p className="text-xs text-muted-foreground">
{t('feed.getApp.subtitle', { appName: config.appName })}
</p>
</div>
<a
href={ZAPSTORE_URL}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary/10 hover:bg-primary/20 text-primary text-xs font-medium transition-colors"
>
{t('feed.getApp.download')}
<ArrowRight className="h-3 w-3" />
</a>
</div>
</div>
);
}
-6
View File
@@ -72,12 +72,6 @@ export function AppProvider(props: AppProviderProps) {
const config = {
...defaultConfig,
...rawConfig,
// An empty persisted translateWorkerUrl must not shadow the build-time
// default — fall back to the default so the Translate button stays
// available. (Earlier builds could persist "" by merely opening Settings.)
translateWorkerUrl: rawConfig.translateWorkerUrl?.trim()
? rawConfig.translateWorkerUrl
: defaultConfig.translateWorkerUrl,
// Deep-merge feedSettings so new keys added to the default are visible
// even for existing users who have an older feedSettings in localStorage.
feedSettings: { ...defaultConfig.feedSettings, ...rawConfig.feedSettings },
-25
View File
@@ -1,25 +0,0 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
/**
* Auto-minimizes the audio player when the user navigates to a different page.
* No dialog — audio just keeps playing in the floating mini-bar.
*/
export function AudioNavigationGuard() {
const { currentTrack, minimized, minimize } = useAudioPlayer();
const location = useLocation();
const prevPath = useRef(location.pathname);
useEffect(() => {
if (location.pathname !== prevPath.current) {
// Route changed — minimize if playing and expanded
if (currentTrack && !minimized) {
minimize();
}
prevPath.current = location.pathname;
}
}, [location.pathname, currentTrack, minimized, minimize]);
return null;
}
+118
View File
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { cn } from '@/lib/utils';
interface BitcoinAmountPickerProps {
usdAmount: number | string;
onUsdAmountChange: (amount: number | string) => void;
presets: readonly number[];
insufficient?: boolean;
satsLabel?: string;
onAmountChangeStart?: () => void;
}
export function BitcoinAmountPicker({
usdAmount,
onUsdAmountChange,
presets,
insufficient = false,
satsLabel,
onAmountChangeStart,
}: BitcoinAmountPickerProps) {
const [editingAmount, setEditingAmount] = useState(false);
const amountInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingAmount) {
amountInputRef.current?.focus();
amountInputRef.current?.select();
}
}, [editingAmount]);
const commitAmountEdit = useCallback(() => {
setEditingAmount(false);
if (typeof usdAmount === 'string' && usdAmount.trim() === '') {
onUsdAmountChange(0);
}
}, [onUsdAmountChange, usdAmount]);
const currentUsd = typeof usdAmount === 'string' ? parseFloat(usdAmount) : usdAmount;
return (
<>
<div className="flex flex-col items-center pt-2">
{editingAmount ? (
<div className="flex items-baseline justify-center">
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
<input
ref={amountInputRef}
type="number"
inputMode="decimal"
min={0}
step="0.01"
value={usdAmount}
onChange={(e) => {
onAmountChangeStart?.();
onUsdAmountChange(e.target.value);
}}
onBlur={commitAmountEdit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
commitAmountEdit();
}
}}
aria-label="Amount in USD"
className={cn(
'bg-transparent border-0 outline-none text-4xl font-semibold text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
insufficient && 'text-destructive',
)}
style={{ width: `${Math.max(2, String(usdAmount).length + 1)}ch` }}
/>
</div>
) : (
<button
type="button"
onClick={() => setEditingAmount(true)}
aria-label="Edit amount"
className="flex items-baseline justify-center rounded-md px-2 -mx-2 hover:bg-muted/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-colors"
>
<span className={cn('text-4xl font-semibold', insufficient ? 'text-destructive' : 'text-muted-foreground')}>$</span>
<span className={cn('text-4xl font-semibold tabular-nums', insufficient && 'text-destructive')}>
{Number.isFinite(currentUsd) && currentUsd > 0 ? currentUsd : 0}
</span>
</button>
)}
{satsLabel && (
<span className="text-xs text-muted-foreground mt-1 tabular-nums">
{satsLabel}
</span>
)}
</div>
<ToggleGroup
type="single"
value={presets.includes(Number(usdAmount)) ? String(usdAmount) : ''}
onValueChange={(value) => {
if (value) {
onAmountChangeStart?.();
onUsdAmountChange(Number(value));
setEditingAmount(false);
}
}}
className="grid grid-cols-5 gap-1 w-full"
>
{presets.map((preset) => (
<ToggleGroupItem
key={preset}
value={String(preset)}
className="h-8 min-w-0 text-xs font-semibold px-1"
>
${preset}
</ToggleGroupItem>
))}
</ToggleGroup>
</>
);
}
+641
View File
@@ -0,0 +1,641 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import {
ArrowDownLeft,
ArrowRight,
ArrowUpRight,
Bitcoin,
Check,
Clock,
Copy,
ExternalLink,
Hash,
Layers,
RefreshCw,
Weight,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { useBitcoinTx } from '@/hooks/useBitcoinTx';
import { useBitcoinAddress } from '@/hooks/useBitcoinAddress';
import { satsToBTC, satsToUSD, formatSats, formatBTC } from '@/lib/bitcoin';
import type { TxDetail, TxInput, TxOutput } from '@/lib/bitcoin';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
function truncateMiddle(str: string, startLen = 8, endLen = 8): string {
if (str.length <= startLen + endLen + 3) return str;
return `${str.slice(0, startLen)}...${str.slice(-endLen)}`;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// clipboard not available
}
};
return (
<button
onClick={handleCopy}
className="p-1 rounded hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground cursor-pointer"
title="Copy"
>
{copied ? <Check className="size-3.5 text-green-500" /> : <Copy className="size-3.5" />}
</button>
);
}
/** Format a unix timestamp as a readable date string. */
function formatBlockTime(timestamp: number): string {
const date = new Date(timestamp * 1000);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
}
/** Format a large number with locale separators. */
function formatNumber(n: number): string {
return n.toLocaleString();
}
// ---------------------------------------------------------------------------
// Bitcoin Transaction Header
// ---------------------------------------------------------------------------
export function BitcoinTxHeader({ txid }: { txid: string }) {
const { tx, btcPrice, isLoading, error } = useBitcoinTx(txid);
if (isLoading) return <TxSkeleton />;
if (error || !tx) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load transaction</p>
<p className="text-xs text-muted-foreground font-mono break-all">{txid}</p>
<a
href={`https://mempool.space/tx/${txid}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-10 rounded-full ${
tx.confirmed
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-orange-500/10 text-orange-600 dark:text-orange-400'
}`}>
{tx.confirmed ? <Check className="size-5" /> : <Clock className="size-5" />}
</div>
<div>
<h2 className="text-lg font-bold">
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</h2>
{tx.blockTime && (
<p className="text-sm text-muted-foreground">{formatBlockTime(tx.blockTime)}</p>
)}
</div>
</div>
{/* Transaction ID */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Transaction ID</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{tx.txid}</p>
<CopyButton text={tx.txid} />
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
{tx.confirmed && tx.blockHeight !== undefined && (
<StatCard icon={<Layers className="size-3.5" />} label="Block" value={formatNumber(tx.blockHeight)} />
)}
<StatCard icon={<Weight className="size-3.5" />} label="Size" value={`${formatNumber(tx.weight / 4)} vB`} />
<StatCard
icon={<Bitcoin className="size-3.5" />}
label="Fee"
value={`${formatSats(tx.fee)} sat`}
subtitle={`${(tx.fee / (tx.weight / 4)).toFixed(1)} sat/vB`}
/>
<StatCard
icon={<Hash className="size-3.5" />}
label="Amount"
value={`${formatBTC(tx.totalOutput)} BTC`}
subtitle={btcPrice ? satsToUSD(tx.totalOutput, btcPrice) : undefined}
/>
</div>
</div>
{/* Inputs → Outputs flow */}
<div className="border-t border-border">
<TxFlow tx={tx} btcPrice={btcPrice} />
</div>
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/tx/${txid}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function StatCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
return (
<div className="rounded-xl bg-secondary/40 px-3.5 py-2.5 space-y-0.5">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<p className="text-sm font-semibold">{value}</p>
{subtitle && <p className="text-xs text-muted-foreground">{subtitle}</p>}
</div>
);
}
/** Inputs → Outputs visualization, mempool.space-style. */
function TxFlow({ tx, btcPrice }: { tx: TxDetail; btcPrice?: number }) {
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
<span>{tx.inputs.length} Input{tx.inputs.length !== 1 ? 's' : ''}</span>
<ArrowRight className="size-3" />
<span>{tx.outputs.length} Output{tx.outputs.length !== 1 ? 's' : ''}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Inputs */}
<div className="space-y-1.5">
{tx.inputs.slice(0, 10).map((input, i) => (
<TxInputRow key={`${input.txid}-${input.vout}-${i}`} input={input} btcPrice={btcPrice} />
))}
{tx.inputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.inputs.length - 10} more input{tx.inputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
{/* Outputs */}
<div className="space-y-1.5">
{tx.outputs.slice(0, 10).map((output, i) => (
<TxOutputRow key={`${output.address ?? 'op_return'}-${i}`} output={output} btcPrice={btcPrice} />
))}
{tx.outputs.length > 10 && (
<p className="text-xs text-muted-foreground text-center py-1">
+{tx.outputs.length - 10} more output{tx.outputs.length - 10 !== 1 ? 's' : ''}
</p>
)}
</div>
</div>
</div>
);
}
function TxInputRow({ input, btcPrice }: { input: TxInput; btcPrice?: number }) {
if (input.isCoinbase) {
return (
<div className="rounded-lg bg-amber-500/5 border border-amber-500/20 px-3 py-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">Coinbase</span>
<span className="text-xs font-mono">{formatBTC(input.value)} BTC</span>
</div>
</div>
);
}
return (
<div className="rounded-lg bg-red-500/5 border border-red-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{input.address ? (
<Link
to={`/i/bitcoin:address:${input.address}`}
className="text-xs font-mono text-red-600 dark:text-red-400 hover:underline truncate"
>
{truncateMiddle(input.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(input.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(input.value, btcPrice)}</p>
)}
</div>
);
}
function TxOutputRow({ output, btcPrice }: { output: TxOutput; btcPrice?: number }) {
const isOpReturn = output.scriptpubkeyType === 'op_return';
if (isOpReturn) {
return (
<div className="rounded-lg bg-secondary/60 border border-border/50 px-3 py-2">
<span className="text-xs text-muted-foreground">OP_RETURN</span>
</div>
);
}
return (
<div className="rounded-lg bg-green-500/5 border border-green-500/10 px-3 py-2 space-y-0.5">
<div className="flex items-center justify-between gap-2">
{output.address ? (
<Link
to={`/i/bitcoin:address:${output.address}`}
className="text-xs font-mono text-green-600 dark:text-green-400 hover:underline truncate"
>
{truncateMiddle(output.address, 10, 6)}
</Link>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
)}
<span className="text-xs font-mono shrink-0">{formatBTC(output.value)} BTC</span>
</div>
{btcPrice !== undefined && (
<p className="text-[10px] text-muted-foreground text-right">{satsToUSD(output.value, btcPrice)}</p>
)}
</div>
);
}
function TxSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-28" />
<Skeleton className="h-3.5 w-40" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-4 w-full" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
<div className="border-t border-border p-4 space-y-3">
<Skeleton className="h-3 w-32" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Bitcoin Address Header
// ---------------------------------------------------------------------------
export function BitcoinAddressHeader({ address }: { address: string }) {
const { addressDetail, btcPrice, isLoading, error, refetch } = useBitcoinAddress(address);
if (isLoading) return <AddressSkeleton />;
if (error || !addressDetail) {
return (
<div className="rounded-2xl border border-border p-6 text-center space-y-3">
<Bitcoin className="size-10 mx-auto text-muted-foreground/40" />
<p className="text-sm text-destructive">Failed to load address</p>
<p className="text-xs text-muted-foreground font-mono break-all">{address}</p>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
);
}
return (
<div className="rounded-2xl border border-border overflow-hidden">
{/* Header */}
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-10 rounded-full bg-primary/10 text-primary">
<Bitcoin className="size-5" />
</div>
<div>
<h2 className="text-lg font-bold">Bitcoin Address</h2>
<p className="text-xs text-muted-foreground">
{addressDetail.txCount + addressDetail.pendingTxCount} transaction{(addressDetail.txCount + addressDetail.pendingTxCount) !== 1 ? 's' : ''}
</p>
</div>
</div>
{/* Address */}
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Address</p>
<div className="flex items-center gap-2">
<p className="text-sm font-mono text-foreground break-all">{address}</p>
<CopyButton text={address} />
</div>
</div>
{/* Balance hero */}
<div className="rounded-xl bg-secondary/40 p-4 text-center space-y-1">
<p className="text-xs text-muted-foreground uppercase tracking-wider">Balance</p>
<p className="text-3xl font-bold tracking-tight">
{btcPrice ? satsToUSD(addressDetail.totalBalance, btcPrice) : `${formatBTC(addressDetail.totalBalance)} BTC`}
</p>
<p className="text-sm text-muted-foreground">
{formatBTC(addressDetail.totalBalance)} BTC
</p>
{addressDetail.pendingBalance !== 0 && (
<p className="flex items-center justify-center gap-1 text-xs text-orange-500 dark:text-orange-400 pt-1">
<RefreshCw className="size-3 animate-spin" />
{btcPrice
? `${satsToUSD(addressDetail.pendingBalance, btcPrice)} pending`
: `${formatBTC(addressDetail.pendingBalance)} BTC pending`}
</p>
)}
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<StatCard
icon={<ArrowDownLeft className="size-3.5" />}
label="Total Received"
value={`${formatBTC(addressDetail.totalReceived)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalReceived, btcPrice) : undefined}
/>
<StatCard
icon={<ArrowUpRight className="size-3.5" />}
label="Total Sent"
value={`${formatBTC(addressDetail.totalSent)} BTC`}
subtitle={btcPrice ? satsToUSD(addressDetail.totalSent, btcPrice) : undefined}
/>
</div>
</div>
{/* Recent Transactions */}
{addressDetail.recentTxs.length > 0 && (
<div className="border-t border-border">
<div className="px-5 py-3">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Recent Transactions
</p>
</div>
<div className="divide-y divide-border">
{addressDetail.recentTxs.slice(0, 10).map((tx) => (
<AddressTxRow key={tx.txid} tx={tx} btcPrice={btcPrice} />
))}
</div>
{addressDetail.recentTxs.length > 10 && (
<div className="px-5 py-3 text-center">
<p className="text-xs text-muted-foreground">
{addressDetail.txCount - 10} more transaction{addressDetail.txCount - 10 !== 1 ? 's' : ''}
</p>
</div>
)}
</div>
)}
{/* Footer: link to mempool.space */}
<div className="border-t border-border px-5 py-2.5">
<a
href={`https://mempool.space/address/${address}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<Bitcoin className="size-3.5" />
<span>View on mempool.space</span>
<ExternalLink className="size-3" />
</a>
</div>
</div>
);
}
function AddressTxRow({ tx, btcPrice }: { tx: { txid: string; amount: number; type: 'receive' | 'send'; confirmed: boolean; timestamp?: number }; btcPrice?: number }) {
const isReceive = tx.type === 'receive';
return (
<Link
to={`/i/bitcoin:tx:${tx.txid}`}
className="flex items-center justify-between py-3 px-5 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`flex items-center justify-center size-8 rounded-full ${
isReceive
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'
}`}>
{isReceive ? <ArrowDownLeft className="size-4" /> : <ArrowUpRight className="size-4" />}
</div>
<div>
<p className="text-sm font-medium">{isReceive ? 'Received' : 'Sent'}</p>
<p className="text-xs text-muted-foreground font-mono">{truncateMiddle(tx.txid, 8, 8)}</p>
</div>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${
isReceive ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{isReceive ? '+' : '-'}{formatBTC(tx.amount)} BTC
</p>
{btcPrice && (
<p className="text-xs text-muted-foreground">
{satsToUSD(tx.amount, btcPrice)}
</p>
)}
</div>
</Link>
);
}
function AddressSkeleton() {
return (
<div className="rounded-2xl border border-border overflow-hidden">
<div className="p-5 space-y-4">
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-1.5">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3.5 w-24" />
</div>
</div>
<div className="space-y-1.5">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-4 w-full" />
</div>
<div className="rounded-xl bg-secondary/40 p-4 space-y-2 flex flex-col items-center">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-9 w-40" />
<Skeleton className="h-4 w-28" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-16 rounded-xl" />
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Compact previews (used in NoteCard embeds, hover cards, etc.)
// ---------------------------------------------------------------------------
/** Compact preview for a Bitcoin transaction — fetches real data. */
export function BitcoinTxPreview({ txid, link }: { txid: string; link: string }) {
const { tx, btcPrice, isLoading } = useBitcoinTx(txid);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-32" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const amount = tx ? tx.totalOutput : 0;
const fee = tx?.fee ?? 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Transaction</span>
{tx && (
<span className={tx.confirmed
? 'text-green-600 dark:text-green-400'
: 'text-yellow-600 dark:text-yellow-400'
}>
{tx.confirmed ? 'Confirmed' : 'Unconfirmed'}
</span>
)}
</div>
<p className="text-sm font-medium truncate mt-0.5">
{tx ? `${satsToBTC(amount)} BTC` : truncateMiddle(txid, 12, 8)}
{tx && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(amount, btcPrice)})</span>
) : null}
</p>
{tx && (
<p className="text-xs text-muted-foreground truncate">
Fee {formatSats(fee)} sats
{tx.blockHeight ? ` · Block ${tx.blockHeight.toLocaleString()}` : ''}
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}
/** Compact preview for a Bitcoin address — fetches real data. */
export function BitcoinAddressPreview({ address, link }: { address: string; link: string }) {
const { addressDetail, btcPrice, isLoading } = useBitcoinAddress(address);
if (isLoading) {
return (
<div className="px-4 py-3 border-b border-border">
<div className="flex items-center gap-3">
<Skeleton className="size-12 rounded-lg shrink-0" />
<div className="flex-1 min-w-0 space-y-1.5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-3/4" />
</div>
</div>
</div>
);
}
const balance = addressDetail?.totalBalance ?? 0;
const txCount = addressDetail ? addressDetail.txCount + addressDetail.pendingTxCount : 0;
return (
<Link
to={link}
className="flex items-center gap-3 px-4 py-3 border-b border-border hover:bg-secondary/30 transition-colors"
>
<div className="size-12 rounded-lg bg-orange-500/10 flex items-center justify-center shrink-0">
<Bitcoin className="size-5 text-orange-600 dark:text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Bitcoin className="size-3 shrink-0" />
<span>Bitcoin Address</span>
</div>
<p className="text-sm font-medium truncate mt-0.5">
{addressDetail ? `${satsToBTC(balance)} BTC` : truncateMiddle(address, 12, 8)}
{addressDetail && btcPrice ? (
<span className="text-muted-foreground font-normal"> ({satsToUSD(balance, btcPrice)})</span>
) : null}
</p>
{addressDetail && (
<p className="text-xs text-muted-foreground truncate">
{txCount.toLocaleString()} transaction{txCount !== 1 ? 's' : ''}
{' · '}
<span className="font-mono">{truncateMiddle(address, 8, 6)}</span>
</p>
)}
</div>
<ExternalLink className="size-4 text-muted-foreground shrink-0" />
</Link>
);
}

Some files were not shown because too many files have changed in this diff Show More