Merge ditto/main into agora
Pulls in 387 commits from ditto/main while preserving Agora-specific features. Where the two codebases diverged on the same concept, kept the Agora side per project direction. Kept Agora-specific: - SparkWallet stack (over Ditto's nostr-derived Bitcoin wallet) - Communities (NIP-72 + chat + members), Messages, Organizers, Actions, Verified, Appearance settings - DMProviderWrapper, country/organizer moderation in NoteMoreMenu - 'Agora' branding, pub.agora.app bundle ID, version 2.8.0 - Built-in theme system (src/themes.ts) only Rejected from Ditto: - All Blobbi virtual pet code (80+ files, route, provider, sidebar, kind labels, feed setting, NIP.md entries, CSS animations) - Custom theme events (kinds 36767/16767) — ThemesPage, ThemeContent, active profile themes, theme snapshot recovery - On-chain zaps (kind 8333) and the entire Bitcoin wallet implementation (useBitcoinWallet, bitcoin-signers, BitcoinContentHeader, bitcoinjs-lib / @bitcoinerlab/secp256k1 / ecpair / tiny-secp256k1) - ZapSuccessScreen (depended on dropped bitcoin lib) Pulled in from Ditto: - .agents/skills/* (12 new specialized skills, slim AGENTS.md) - @nostrify bumps to 0.52 / 0.6 / 0.37 - New routes/pages: Music, Podcasts, Videos, Vines, Wikipedia, Books, Bluesky, Archive, AIChat, Trends, Webxdc, Highlights, Decks, Emojis, Development, Treasures, Colors, Packs - Birdstar feed integration (kinds 2473, 12473, 30621) - Wikipedia/Wikidata/Scryfall lookup in ExternalContentPage - release-notes CI job + extract-release-notes.mjs script - nsite:// URI handling in feed/sidebar - iOS fastlane setup - src/lib/avatarShape.ts + Avatar shape prop (kept for new Music/People components that depend on it) Preserved Agora's ABSOLUTE 'NEVER COMMIT' rule at the top of AGENTS.md and dropped Ditto's contradicting 'Commit at the end of every task' section. Validation: npm run test passes (tsc, eslint, 40/40 vitest, vite build).
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: capacitor-compat
|
||||
description: Browser-API gotchas inside Capacitor's WKWebView (iOS) and Android WebView — which common web APIs silently fail, the downloadTextFile/openUrl helpers that bridge web and native, platform detection, and the installed Capacitor plugins. Load when writing code that interacts with file downloads, external URLs, or platform-specific behavior.
|
||||
---
|
||||
|
||||
# Capacitor Compatibility
|
||||
|
||||
Ditto runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features.
|
||||
|
||||
## What Doesn't Work in WKWebView (iOS)
|
||||
|
||||
- **`<a download>` file downloads** — programmatically creating an anchor with `a.download` and clicking it silently fails. WKWebView ignores the `download` attribute entirely.
|
||||
- **`<a target="_blank">` new tabs** — programmatic clicks on anchors with `target="_blank"` are blocked. There are no tabs in a native app.
|
||||
- **`window.open()`** — may be blocked or behave unexpectedly without user-gesture context.
|
||||
|
||||
For a deeper list of Apple Lockdown Mode restrictions that also affect WKWebView, load the **`lockdown-mode`** skill.
|
||||
|
||||
## File Downloads and URL Opening
|
||||
|
||||
`src/lib/downloadFile.ts` provides two utilities that handle the web/native split automatically. **Always use these** instead of manually constructing anchors.
|
||||
|
||||
### `downloadTextFile(filename, content)`
|
||||
|
||||
Saves a text file to the user's device. On web it uses the `<a download>` pattern. On native it writes to the Capacitor cache directory via `@capacitor/filesystem` and presents the native share sheet via `@capacitor/share`.
|
||||
|
||||
```typescript
|
||||
import { downloadTextFile } from '@/lib/downloadFile';
|
||||
|
||||
await downloadTextFile('backup.txt', fileContents);
|
||||
```
|
||||
|
||||
### `openUrl(url)`
|
||||
|
||||
Opens a URL in a new browser tab on web, or presents the native share sheet on Capacitor.
|
||||
|
||||
```typescript
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
|
||||
await openUrl('https://example.com/image.jpg');
|
||||
```
|
||||
|
||||
**CRITICAL**: Never use `document.createElement('a')` with `.click()` for downloads or opening URLs. The utilities above work correctly on all platforms; manual anchors silently fail on iOS.
|
||||
|
||||
## Detecting Native Platforms
|
||||
|
||||
Use `Capacitor.isNativePlatform()` from `@capacitor/core` when you need platform-specific behavior:
|
||||
|
||||
```typescript
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
// iOS or Android
|
||||
} else {
|
||||
// Web browser
|
||||
}
|
||||
```
|
||||
|
||||
Reserve platform forks for cases where behavior genuinely differs (share sheets, secure storage, haptics). Most UI code should stay platform-agnostic.
|
||||
|
||||
## Installed Capacitor Plugins
|
||||
|
||||
- `@capacitor/app` — app lifecycle events (deep links, back button)
|
||||
- `@capacitor/core` — core runtime and platform detection
|
||||
- `@capacitor/filesystem` — read/write files on the native filesystem
|
||||
- `@capacitor/haptics` — native haptics
|
||||
- `@capacitor/keyboard` — keyboard control (hide accessory bar, etc.)
|
||||
- `@capacitor/local-notifications` — schedule local push notifications
|
||||
- `@capacitor/share` — native share sheet
|
||||
- `@capacitor/status-bar` — control the native status-bar style
|
||||
- `@capgo/capacitor-autofill-save-password` — iOS keychain autofill for nsec
|
||||
- `capacitor-secure-storage-plugin` — OS-level secure storage (iOS Keychain / Android KeyStore)
|
||||
|
||||
After adding or removing plugins, run `npm run cap:sync` to update the native projects.
|
||||
@@ -0,0 +1,350 @@
|
||||
---
|
||||
name: ci-cd-publishing
|
||||
description: Ditto's release and publishing pipeline — cutting a version tag, Zapstore APK publishing with NIP-46 bunker auth, nsite web deploys via nsyte, and Google Play AAB uploads via fastlane supply. Includes GitLab CI variable setup and credential rotation.
|
||||
---
|
||||
|
||||
# CI/CD Pipeline and Publishing
|
||||
|
||||
Ditto uses GitLab CI (`.gitlab-ci.yml`) to run tests on every commit, deploy the web app to nsite on every default-branch push, and build + publish Android binaries to Zapstore and Google Play on every tag. Load this skill when setting up CI credentials, rotating a signing key, diagnosing a failed publish, or adding a new publishing target.
|
||||
|
||||
## Pipeline Overview
|
||||
|
||||
| Stage | Runs on | Job |
|
||||
|-----------|---------------------------|-----------------------------------------|
|
||||
| `test` | every commit (not tags) | `npm run test` |
|
||||
| `deploy` | default branch only | `deploy-nsite` (Vite build → nsyte) |
|
||||
| `build` | tags only | `build-apk` (signed APK + AAB) + `build-ipa` (signed IPA on the Mac runner) |
|
||||
| `release` | tags only | GitLab Release with APK / AAB / IPA links |
|
||||
| `publish` | tags only | `publish-zapstore` + `publish-google-play` + `publish-app-store` |
|
||||
|
||||
## Creating a Release
|
||||
|
||||
Releases are triggered by pushing a version tag:
|
||||
|
||||
```bash
|
||||
npm run release
|
||||
```
|
||||
|
||||
This creates a tag in the format `v2026.03.14+abc1234` (date + short commit hash) and pushes it to GitLab, which triggers the `build-apk`, `release`, `publish-zapstore`, and `publish-google-play` jobs.
|
||||
|
||||
For the full versioning / changelog / native-build workflow, load the **`release`** skill.
|
||||
|
||||
## Zapstore Publishing
|
||||
|
||||
The `publish-zapstore` CI job uploads signed APKs to [Zapstore](https://zapstore.dev/) using the [`zsp`](https://github.com/zapstore/zsp) CLI and NIP-46 bunker signing via Amber.
|
||||
|
||||
**Configuration files:**
|
||||
|
||||
- `zapstore.yaml` — app metadata for Zapstore (name, tags, icon, supported NIPs)
|
||||
- `.gitlab-ci.yml` — the `publish-zapstore` job definition
|
||||
|
||||
**GitLab CI/CD variables** (Settings → CI/CD → Variables):
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `ZAPSTORE_BUNKER_URL` | NIP-46 bunker URL (`bunker://<pubkey>?relay=...`). No `secret` param needed after initial auth. | Yes | No | Yes |
|
||||
| `ZAPSTORE_CLIENT_KEY` | Hex private key used as the NIP-46 client identity for bunker communication | Yes | Yes | Yes |
|
||||
| `ANDROID_KEYSTORE_BASE64` | Base64-encoded Android signing keystore | Yes | Yes | Yes |
|
||||
| `KEYSTORE_PASSWORD` | Android keystore password | Yes | Yes | Yes |
|
||||
| `KEY_PASSWORD` | Android key password | Yes | Yes | Yes |
|
||||
|
||||
### How NIP-46 bunker auth works in CI
|
||||
|
||||
NIP-46 bunker signing requires two keys: the **user's key** (held by Amber) and a **client key** (the CI runner's identity). The bunker authorizes specific client pubkeys — once authorized, the client can request signatures without re-approval.
|
||||
|
||||
The `publish-zapstore` job restores the client key from `ZAPSTORE_CLIENT_KEY` into `~/.config/zsp/bunker-keys/<bunker-pubkey>.key` before running `zsp`, so the bunker recognizes the CI runner as an already-authorized client.
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
Run the NIP-46 client-initiated auth script:
|
||||
|
||||
```bash
|
||||
node scripts/nip46-auth.mjs
|
||||
```
|
||||
|
||||
This generates a `nostrconnect://` URI. Import/paste it into Amber and approve the connection. The script outputs the `bunker://` URI and client key hex, and writes the client key to `~/.config/zsp/bunker-keys/`. Update the GitLab CI/CD variables with the printed values.
|
||||
|
||||
Options:
|
||||
- `--relay <url>` — relay for NIP-46 communication (default: `wss://relay.ditto.pub`)
|
||||
- `--name <name>` — app name shown to the signer (default: `Ditto`)
|
||||
- `--timeout <sec>` — how long to wait for approval (default: 300)
|
||||
|
||||
After authorization, the bunker recognizes the client key and no secret or manual approval is needed for CI runs. If the client key is rotated, run the script again and update the GitLab variables.
|
||||
|
||||
## nsite Publishing
|
||||
|
||||
The `deploy-nsite` CI job deploys the Vite build to [nsite](https://nsite.run) on every push to the default branch using [nsyte](https://github.com/sandwichfarm/nsyte). The job uploads `dist/` to Blossom servers and publishes site manifest events to Nostr relays.
|
||||
|
||||
nsyte uses a NIP-46 bunker credential called **nbunksec** — a bech32-encoded string bundling the bunker pubkey, client secret key, and relay info into a single self-contained token. It's passed to nsyte via `--sec`.
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `NSITE_NBUNKSEC` | nbunksec credential from `nsyte ci`. Must start with `nbunksec1`. | Yes | Yes | Yes |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. Install nsyte locally:
|
||||
```bash
|
||||
curl -fsSL https://nsyte.run/get/install.sh | bash
|
||||
```
|
||||
2. Generate the CI credential:
|
||||
```bash
|
||||
nsyte ci
|
||||
```
|
||||
This guides you through connecting a NIP-46 bunker (e.g. Amber) and outputs an `nbunksec1...` string. The credential is shown only once.
|
||||
3. Add the `nbunksec1...` value as `NSITE_NBUNKSEC` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**.
|
||||
|
||||
### Configured relays and servers
|
||||
|
||||
Relays the deploy job publishes to:
|
||||
|
||||
- `wss://relay.ditto.pub`
|
||||
- `wss://relay.nsite.lol`
|
||||
- `wss://relay.dreamith.to`
|
||||
- `wss://relay.primal.net`
|
||||
|
||||
Blossom servers:
|
||||
|
||||
- `https://blossom.primal.net`
|
||||
- `https://blossom.ditto.pub`
|
||||
- `https://blossom.dreamith.to`
|
||||
|
||||
The `--use-fallback-relays` and `--use-fallback-servers` flags include nsyte's built-in defaults for broader coverage. The `--fallback "/index.html"` flag enables SPA client-side routing.
|
||||
|
||||
### Credential rotation
|
||||
|
||||
To rotate the nsite credential:
|
||||
|
||||
1. Revoke the old bunker connection in your signer app.
|
||||
2. Run `nsyte ci` again to generate a new `nbunksec1...` string.
|
||||
3. Update the `NSITE_NBUNKSEC` variable in GitLab CI/CD settings.
|
||||
|
||||
## Google Play Publishing
|
||||
|
||||
The `publish-google-play` CI job uploads Android AABs to [Google Play](https://play.google.com/store/apps/details?id=pub.ditto.app) using [fastlane supply](https://docs.fastlane.tools/actions/supply/). It runs after a successful AAB build and uploads directly to the production track.
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` | **Base64-encoded** contents of the Google Play API service account key JSON. The CI job decodes with `base64 -d` before passing to `fastlane supply`. | Yes | Yes | No |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. Create or reuse a project in [Google Cloud Console](https://console.cloud.google.com/projectcreate).
|
||||
2. Enable the [Google Play Developer API](https://console.developers.google.com/apis/api/androidpublisher.googleapis.com/) for that project.
|
||||
3. In Google Cloud Console, go to [Service Accounts](https://console.cloud.google.com/iam-admin/serviceaccounts), create a service account, and download a JSON key file for it.
|
||||
4. In Google Play Console, go to [Users & Permissions](https://play.google.com/console/users-and-permissions), click **Invite new users**, enter the service account email, and grant it permission to manage releases for `pub.ditto.app`.
|
||||
5. **Base64-encode** the key file:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
base64 -w0 service-account.json
|
||||
|
||||
# macOS
|
||||
base64 -i service-account.json | tr -d '\n'
|
||||
```
|
||||
|
||||
6. Add the base64-encoded value as `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON` in GitLab CI/CD settings. Mark it as **Protected** and **Masked**. **Do not paste the raw JSON** — the CI script expects base64 and will fail to decode a raw value.
|
||||
|
||||
### Key points
|
||||
|
||||
- The job uploads the signed **AAB** (not APK) — Google Play requires App Bundles.
|
||||
- Uploads go directly to the **production** track. Google's review process still applies before the update reaches users.
|
||||
- Metadata, screenshots, and store-listing description are managed in the Play Console (the job uses `--skip_upload_metadata`, `--skip_upload_images`, `--skip_upload_screenshots`).
|
||||
- **Changelogs ("What's new in this version")** are uploaded from `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt`, generated at CI time from the release summary paragraph in `CHANGELOG.md`. See "Release notes pipeline" below.
|
||||
- The same signing keystore used for Zapstore is reused here (`ANDROID_KEYSTORE_BASE64`, `KEYSTORE_PASSWORD`, `KEY_PASSWORD`).
|
||||
|
||||
## App Store Publishing
|
||||
|
||||
Ditto's iOS pipeline is split across two jobs:
|
||||
|
||||
- **`build-ipa`** (stage `build`, `tags: [macos]`) runs on the self-hosted Mac runner. Decodes the App Store Connect API key, fetches the encrypted distribution cert + provisioning profile via fastlane match, builds the web assets, runs `cap sync ios`, stamps the marketing version into `project.pbxproj`, then `fastlane build_ipa` produces a signed App Store IPA at `artifacts/Ditto.ipa`. The IPA is uploaded to the GitLab Generic Packages registry as `Ditto-${CI_COMMIT_TAG}.ipa` (mirrors how `build-apk` publishes the APK and AAB) and exposed as a CI artifact for downstream jobs.
|
||||
- **`publish-app-store`** (stage `publish`, `tags: [macos]`) also runs on the self-hosted Mac runner. Consumes the IPA artifact via `needs: [build-ipa]` and the release-notes artifact via `needs: [release-notes]`. Decodes the API key, copies the release-notes summary into `ios/fastlane/metadata/en-US/release_notes.txt`, and runs `fastlane submit_release` which calls `deliver` to upload metadata + push the prebuilt IPA + auto-submit for App Store review. **macOS is required** even though the IPA is already signed: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode. A Linux container ran into `No such file or directory @ dir_chdir0` from `JavaTransporterExecutor#execute` because `Helper.itms_path` resolved to a missing Xcode path.
|
||||
|
||||
The Mac runner is therefore used for both iOS jobs. For runner administration (operating the Mac, restarting the agent, viewing logs, rotating signing certs), load the **`mac-runner`** skill.
|
||||
|
||||
**Configuration files:**
|
||||
|
||||
- `ios/fastlane/Fastfile` — exposes four lanes:
|
||||
- `build_ipa` — setup_ci → match (readonly, with API key) → increment_build_number → build_app. Used by CI's `build-ipa`.
|
||||
- `submit_release` — reads `IPA_PATH` env var, calls deliver against the prebuilt IPA. Used by CI's `publish-app-store`.
|
||||
- `release` — combines build_ipa + submit_release; convenience for local one-shot runs.
|
||||
- `submit_only` — debug lane that skips build/upload and only runs deliver against an already-uploaded build (set `BUILD_NUMBER` + `VERSION` env vars). See the `mac-runner` skill.
|
||||
- `ios/fastlane/Appfile` — bundle identifier and team ID
|
||||
- `ios/fastlane/Matchfile` — points at the shared `soapbox-pub/certificates` repo
|
||||
- `ios/fastlane/metadata/en-US/release_notes.txt` — placeholder; CI overwrites it with the release summary paragraph from `CHANGELOG.md` per release
|
||||
- `.gitlab-ci.yml` — `build-ipa` and `publish-app-store` both run on the Mac runner (`tags: [macos]`)
|
||||
|
||||
**Code signing storage**: a private GitLab repo `soapbox-pub/certificates` holds encrypted distribution certs and provisioning profiles, managed by [fastlane match](https://docs.fastlane.tools/actions/match/). Match handles cert/profile lifecycle: one passphrase decrypts everything; the same repo can hold signing material for multiple Soapbox iOS apps under team `GZLTTH5DLM`.
|
||||
|
||||
**App Store Connect auth**: a long-lived [App Store Connect API key](https://developer.apple.com/documentation/appstoreconnectapi/creating-api-keys-for-app-store-connect-api) (`.p8` file + key ID + issuer ID) authenticates `match`, `deliver`, and `pilot`. Avoids 2FA prompts that would interrupt CI.
|
||||
|
||||
**Distribution**: `submit_for_review: true` automatically pushes the build into Apple's review queue once uploaded. `automatic_release: false` keeps a human-controlled final gate — once Apple approves, you click "Release" in the App Store Connect web UI to publish to users. To remove the manual gate, flip `automatic_release` to `true` in `ios/fastlane/Fastfile`.
|
||||
|
||||
**Release notes**: copied from the `release-notes` job's artifact `artifacts/release-notes-summary.txt` (the leading plaintext paragraph of the version's `CHANGELOG.md` section) into `ios/fastlane/metadata/en-US/release_notes.txt`, uploaded by `deliver` as the App Store "What's New in This Version" text. See "Release notes pipeline" below.
|
||||
|
||||
**IPA distribution beyond the App Store**: `build-ipa` uploads the signed IPA to the GitLab Generic Packages registry, and the `release` job links it from the GitLab Release page. The IPA is signed with the App Store distribution profile, so it isn't directly sideloadable — installation goes through Apple's review process — but having it as a stable artifact lays the groundwork for AltStore or ad-hoc distribution later (which would require a separate provisioning profile).
|
||||
|
||||
**GitLab CI/CD variables:**
|
||||
|
||||
| Variable | Description | Protected | Masked | Raw |
|
||||
|---|---|---|---|---|
|
||||
| `MATCH_PASSWORD` | Symmetric passphrase used by match to encrypt/decrypt certs and profiles. The single most important secret — losing it makes the cert repo unreadable. | Yes | Yes | Yes |
|
||||
| `MATCH_GIT_BASIC_AUTHORIZATION` | Base64 of `username:deploy-token` for HTTPS clone of the certificates repo. Generated from a `read_repository`-scoped deploy token on `soapbox-pub/certificates`. | Yes | Yes | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_ID` | App Store Connect API key ID (10 chars). | Yes | No | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_ISSUER_ID` | App Store Connect issuer ID (UUID). | Yes | No | Yes |
|
||||
| `APP_STORE_CONNECT_API_KEY_P8_BASE64` | Base64-encoded contents of the `.p8` private key file. CI decodes with `base64 -d` into `~/.private_keys/AuthKey_<KEY_ID>.p8` and removes it in `after_script`. | Yes | Yes | Yes |
|
||||
| `FASTLANE_KEYCHAIN_PASSWORD` | Password for the ephemeral keychain `setup_ci` creates per build. Random per setup; keep stable across runs. | Yes | Yes | Yes |
|
||||
|
||||
### Initial setup (one-time)
|
||||
|
||||
1. **Provision the Mac runner.** See the **`mac-runner`** skill for hardware/launchd setup, Xcode, Homebrew, fastlane, and `gitlab-runner` registration.
|
||||
|
||||
2. **Create the App Store Connect API key.** Log in to [App Store Connect](https://appstoreconnect.apple.com) → Users and Access → Integrations → App Store Connect API → Generate. Use the **App Manager** role (sufficient for `deliver`'s upload + submit-for-review). Download the `.p8` file (one-time download — Apple won't show it again). Note the **Key ID** (10-char string next to the key) and the **Issuer ID** (UUID at the top of the API page).
|
||||
|
||||
Set the three GitLab CI variables:
|
||||
```bash
|
||||
# Replace <ISSUER_ID>, <KEY_ID>, and the path to your .p8
|
||||
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/projects/$PROJECT_ID/variables" \
|
||||
--data-urlencode "key=APP_STORE_CONNECT_API_KEY_ISSUER_ID" \
|
||||
--data-urlencode "value=<ISSUER_ID>" \
|
||||
--data-urlencode "protected=true" --data-urlencode "raw=true"
|
||||
# repeat for APP_STORE_CONNECT_API_KEY_ID
|
||||
# for the .p8, base64 first:
|
||||
base64 -i AuthKey_<KEY_ID>.p8 | tr -d '\n' # paste this as APP_STORE_CONNECT_API_KEY_P8_BASE64 (masked)
|
||||
```
|
||||
|
||||
3. **Create the certificates repo.** A private GitLab repo at `soapbox-pub/certificates` holds match-encrypted certs/profiles. Create a project deploy token on it (Settings → Repository → Deploy tokens) with `read_repository` scope. Encode `username:token` as base64 → set as `MATCH_GIT_BASIC_AUTHORIZATION` (protected, masked, raw).
|
||||
|
||||
4. **Generate `MATCH_PASSWORD` and `FASTLANE_KEYCHAIN_PASSWORD`.** Both are arbitrary strong random strings — `openssl rand -base64 32 | tr -d '=+/' | head -c 32` works. Store them as protected, masked GitLab variables.
|
||||
|
||||
5. **Bootstrap match certs via a one-shot CI job** (preferred over running match locally — avoids the macOS keychain UI permission dialogs that fastlane bug [#15185](https://github.com/fastlane/fastlane/issues/15185) trips on newer macOS):
|
||||
|
||||
a. Create a temporary write-scoped GitLab variable. The deploy token is `read_repository`; for the initial cert creation match needs to push. Encode `username:write-pat` as base64 and set it as `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` (Protected, Masked, Raw).
|
||||
|
||||
b. Add a temporary `setup-match` job to `.gitlab-ci.yml` that runs on the macos runner with `setup_ci` (which creates an ephemeral keychain — bypasses the GUI permission issue):
|
||||
|
||||
```yaml
|
||||
setup-match:
|
||||
stage: publish
|
||||
tags: [macos]
|
||||
rules:
|
||||
- if: $SETUP_MATCH == "1"
|
||||
when: manual
|
||||
script:
|
||||
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
|
||||
- mkdir -p "$HOME/.private_keys" && chmod 700 "$HOME/.private_keys"
|
||||
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
|
||||
- chmod 600 "$ASC_KEY_PATH"
|
||||
- cd ios
|
||||
- export MATCH_GIT_BASIC_AUTHORIZATION="$MATCH_GIT_BASIC_AUTHORIZATION_WRITE"
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
- |
|
||||
cat > Fastfile.setup <<'RUBY'
|
||||
default_platform(:ios)
|
||||
platform :ios do
|
||||
lane :setup do
|
||||
setup_ci
|
||||
api_key = {
|
||||
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
|
||||
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
|
||||
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
|
||||
duration: 1200,
|
||||
in_house: false,
|
||||
}
|
||||
match(type: "appstore", readonly: false, api_key: api_key, force_for_new_devices: true)
|
||||
end
|
||||
end
|
||||
RUBY
|
||||
- mv fastlane/Fastfile fastlane/Fastfile.bak
|
||||
- mv Fastfile.setup fastlane/Fastfile
|
||||
- fastlane setup
|
||||
- mv fastlane/Fastfile.bak fastlane/Fastfile
|
||||
after_script:
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
```
|
||||
|
||||
c. Trigger the pipeline manually with `SETUP_MATCH=1`:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline" \
|
||||
--data-urlencode "ref=main" \
|
||||
--data-urlencode "variables[][key]=SETUP_MATCH" \
|
||||
--data-urlencode "variables[][value]=1"
|
||||
# Then play the manual setup-match job
|
||||
```
|
||||
|
||||
d. Once the job succeeds (cert + profile pushed to the certificates repo), **delete the `setup-match` job from `.gitlab-ci.yml` and the `MATCH_GIT_BASIC_AUTHORIZATION_WRITE` variable**. They're only needed for bootstrap.
|
||||
|
||||
### Yearly cert renewal
|
||||
|
||||
Apple distribution certs expire annually. Renewal is one command per year, run on any Mac:
|
||||
|
||||
```bash
|
||||
cd ~/Projects/ditto/ios
|
||||
fastlane match nuke distribution # revokes old cert in Apple's portal, removes from match repo
|
||||
fastlane match appstore # creates new cert + profile, encrypts, commits, pushes
|
||||
```
|
||||
|
||||
CI's next tag run picks up the new files automatically (`match(... readonly: true)`).
|
||||
|
||||
### Disaster recovery (Mac dies / new developer joins)
|
||||
|
||||
```bash
|
||||
git clone https://gitlab.com/soapbox-pub/ditto.git
|
||||
cd ditto/ios
|
||||
fastlane match appstore --readonly # decrypts existing certs/profiles using MATCH_PASSWORD
|
||||
```
|
||||
|
||||
No re-issuance of certs needed — the cert repo is the source of truth.
|
||||
|
||||
### App Store Connect API key rotation
|
||||
|
||||
App Store Connect API keys can be revoked anytime. To rotate:
|
||||
|
||||
1. App Store Connect → Users and Access → Integrations → App Store Connect API → Generate new key
|
||||
2. Download the new `.p8`, note the new key ID
|
||||
3. Update `APP_STORE_CONNECT_API_KEY_ID` and `APP_STORE_CONNECT_API_KEY_P8_BASE64` in GitLab variables
|
||||
4. (Issuer ID stays the same — it's per-team, not per-key)
|
||||
5. Revoke the old key in App Store Connect
|
||||
|
||||
### Key points
|
||||
|
||||
- `build-ipa` (Mac) produces a signed **IPA** (App Store distribution format) and uploads it to GitLab's Generic Packages registry. `publish-app-store` (also Mac) submits it to Apple via `deliver`.
|
||||
- Builds go to **App Store Connect**, automatically submit for review, but do **not** auto-release after approval. The final "Release" click is manual in the web UI.
|
||||
- Marketing version comes from the git tag (`v2.1.0` → `MARKETING_VERSION = 2.1.0`); build number comes from `CI_PIPELINE_IID`.
|
||||
- Release notes ("What's New in This Version") come from the release-notes summary paragraph (see "Release notes pipeline" below).
|
||||
- `setup_ci` (in `build-ipa`) creates an ephemeral keychain per build, so the runner never touches the login keychain — works whether or not a GUI session is logged in.
|
||||
- `publish-app-store` does no code signing, but it still needs macOS: `fastlane deliver` shells out to Apple's iTMSTransporter / altool to upload the binary, and those tools only ship inside Xcode.
|
||||
|
||||
## Release notes pipeline
|
||||
|
||||
Release notes for all three storefronts (App Store, Google Play, GitLab Release page) and the in-app version-update toast are derived from a single source: `CHANGELOG.md`.
|
||||
|
||||
**The `release-notes` job** (stage `build`, default `node:22` image, runs only on `v*` tags) calls `scripts/extract-release-notes.mjs` twice and publishes two artifacts:
|
||||
|
||||
- `artifacts/release-notes.md` — the full section for this version (summary paragraph + `### Added` / `### Changed` / etc. lists). Used as the GitLab Release description.
|
||||
- `artifacts/release-notes-summary.txt` — only the leading plaintext paragraph (max 500 chars by convention). Used as the App Store / Play Store "What's new" text. Falls back to `Ditto vX.Y.Z` if the section has no summary paragraph.
|
||||
|
||||
**Downstream consumers** all pull from the `release-notes` job via `needs:`:
|
||||
|
||||
| Consumer | Job | Artifact used |
|
||||
|---|---|---|
|
||||
| GitLab Release description | `release` | `release-notes.md` |
|
||||
| App Store "What's New" | `publish-app-store` | `release-notes-summary.txt` → copied to `ios/fastlane/metadata/en-US/release_notes.txt` → uploaded by `deliver` |
|
||||
| Play Store "What's new" | `publish-google-play` | `release-notes-summary.txt` → copied to `android/fastlane/metadata/android/en-US/changelogs/<versionCode>.txt` → uploaded by `supply` |
|
||||
| In-app toast | `src/components/VersionCheck.tsx` (runtime) | Re-parses `public/CHANGELOG.md` via `parseChangelog()` and reads `entry.summary` (with a fallback to the legacy first-bullet behavior) |
|
||||
|
||||
**The summary format** is documented in the `release` skill — a single plaintext paragraph immediately under the `## [X.Y.Z] - YYYY-MM-DD` heading, before any `### Category`. The script enforces nothing on the parser side; CI emits a warning when the summary exceeds 500 chars but does not fail the build.
|
||||
|
||||
**To preview locally** what each storefront will receive:
|
||||
|
||||
```bash
|
||||
node scripts/extract-release-notes.mjs vX.Y.Z # full GitLab Release body
|
||||
node scripts/extract-release-notes.mjs vX.Y.Z --summary # storefront blurb
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: file-uploads
|
||||
description: Upload files (images, media, attachments) from the browser to a Blossom server via the useUploadFile hook, and attach them to Nostr events with NIP-94 imeta tags.
|
||||
---
|
||||
|
||||
# File Uploads on Nostr
|
||||
|
||||
This project includes a `useUploadFile` hook that uploads files to Blossom servers and returns NIP-94-compatible tags. Use it whenever a feature needs to accept a user-provided file (avatars, banners, post attachments, etc.).
|
||||
|
||||
## The `useUploadFile` Hook
|
||||
|
||||
```tsx
|
||||
import { useUploadFile } from "@/hooks/useUploadFile";
|
||||
|
||||
function MyComponent() {
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
try {
|
||||
// Returns an array of NIP-94-compatible tags.
|
||||
// The first tag is the `url` tag; its second element is the file URL.
|
||||
const tags = await uploadFile(file);
|
||||
const url = tags[0][1];
|
||||
// ...use the url
|
||||
} catch (error) {
|
||||
// ...handle errors (show a toast, etc.)
|
||||
}
|
||||
};
|
||||
|
||||
// ...rest of component
|
||||
}
|
||||
```
|
||||
|
||||
The hook is a TanStack Query mutation, so `isPending` can drive loading UI and `mutateAsync` integrates cleanly with `async`/`await` flows.
|
||||
|
||||
## Attaching Files to Events
|
||||
|
||||
### Kind 0 (profile metadata)
|
||||
|
||||
Use the plain URL in the relevant JSON field:
|
||||
|
||||
```ts
|
||||
const tags = await uploadFile(file);
|
||||
const url = tags[0][1];
|
||||
|
||||
createEvent({
|
||||
kind: 0,
|
||||
content: JSON.stringify({ ...existingMetadata, picture: url }),
|
||||
});
|
||||
```
|
||||
|
||||
### Kind 1 (text notes) and other content events
|
||||
|
||||
Append the URL to `content`, and add one `imeta` tag per file. `imeta` carries the NIP-94 metadata (mime type, dimensions, blurhash, etc.) that the uploader returned:
|
||||
|
||||
```ts
|
||||
const tags = await uploadFile(file); // e.g. [["url", "https://..."], ["m", "image/png"], ["dim", "1024x768"], ...]
|
||||
const url = tags[0][1];
|
||||
|
||||
// Flatten the NIP-94 tags into a single imeta tag value.
|
||||
const imeta = tags.map(([name, value]) => `${name} ${value}`);
|
||||
|
||||
createEvent({
|
||||
kind: 1,
|
||||
content: `Check this out ${url}`,
|
||||
tags: [["imeta", ...imeta]],
|
||||
});
|
||||
```
|
||||
|
||||
Repeat the pattern (one `imeta` tag per file) for multiple attachments.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- **Avatar / banner pickers:** wrap an `<input type="file" accept="image/*">` and call `uploadFile` on change; on success, update the relevant profile field and publish a kind 0 event.
|
||||
- **Post composers:** call `uploadFile` for each selected file before publishing the note, then build `imeta` tags alongside `content`.
|
||||
- **Progress UI:** use `isPending` from the mutation to disable the submit button and show a spinner or skeleton.
|
||||
- **Error handling:** wrap `uploadFile` in `try/catch` and surface failures via `useToast` — network and Blossom-server errors are common and should never break the UI.
|
||||
|
||||
## Constraints
|
||||
|
||||
- The hook requires a logged-in user (Blossom auth is signed by the user's signer). Guard uploads behind `useCurrentUser`.
|
||||
- Don't store or display raw `File` objects after upload — always use the returned URL.
|
||||
- Large files may take time; prefer `mutateAsync` over `mutate` so the caller can `await` completion before publishing an event that references the URL.
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: git-workflow
|
||||
description: Ditto's git conventions — validating changes before committing, writing commit messages that match project style, and attributing regressions with a Regression-of trailer so the release changelog skill can filter them from the "Fixed" section.
|
||||
---
|
||||
|
||||
# Git Workflow
|
||||
|
||||
Ditto expects every completed task to end with a git commit. This skill covers the pre-commit validation loop, commit-message conventions, and the `Regression-of:` trailer used by the release skill to filter intra-release regressions from the changelog.
|
||||
|
||||
## Pre-commit Validation
|
||||
|
||||
**Your task is not finished until the code type-checks and builds without errors.** In priority order:
|
||||
|
||||
1. **Type Checking** (required) — `tsc --noEmit`
|
||||
2. **Building/Compilation** (required) — `vite build`
|
||||
3. **Linting** (recommended; fix anything critical) — `eslint`
|
||||
4. **Tests** (if available) — `vitest run`
|
||||
5. **Git commit** (required)
|
||||
|
||||
The full `npm run test` script runs all of these in sequence; running it is equivalent to steps 1–4.
|
||||
|
||||
## Using Git
|
||||
|
||||
Use `git status` and `git diff` to review changes, and `git log` to learn the project's commit-message conventions before writing a new one. If you make a mistake, `git checkout` restores files.
|
||||
|
||||
When your changes are complete and validated, create a commit with a message that focuses on **why** the change was made (not just **what**). Summaries should fit on one line; a body is warranted for non-trivial changes.
|
||||
|
||||
**Always commit when you are finished making changes. Non-negotiable — every completed task ends with a commit. Don't leave uncommitted changes.**
|
||||
|
||||
## Contributing Guide
|
||||
|
||||
When preparing changes for a merge request, also follow the guidelines in `CONTRIBUTING.md`. It includes a self-review checklist (step 8) that should be run against your diff before committing.
|
||||
|
||||
## Attributing Regressions
|
||||
|
||||
When a commit fixes a bug that was introduced by an identifiable prior commit, add a `Regression-of:` trailer at the bottom of the commit message body referencing the offending commit's short SHA:
|
||||
|
||||
```
|
||||
Fix missing background on expanded emoji picker in feeds
|
||||
|
||||
The compose box overhaul accidentally dropped the bg-background class
|
||||
when refactoring the picker out of QuickReactMenu.
|
||||
|
||||
Regression-of: 3aa08ba9
|
||||
```
|
||||
|
||||
This is a standard Git trailer (compatible with `git interpret-trailers`) that records the cause-and-effect link directly in history. It is consumed by the `release` skill to detect intra-release regressions and exclude them from the changelog's "Fixed" section, and it makes future debugging and post-mortems substantially faster.
|
||||
|
||||
### When to add it
|
||||
|
||||
- The commit fixes a bug (not a new feature, refactor, or doc change).
|
||||
- The introducing commit is identifiable with reasonable effort.
|
||||
|
||||
### When to skip it
|
||||
|
||||
- The bug is pre-existing with no clear single origin.
|
||||
- The behavior was always wrong (no regression).
|
||||
- The introducing commit cannot be determined after a brief search.
|
||||
|
||||
### Finding the introducing commit
|
||||
|
||||
- `git log -S '<removed-or-changed-string>'` — find commits that touched a specific string.
|
||||
- `git log --oneline -- path/to/file` — list all commits touching a file.
|
||||
- `git blame -L <start>,<end> -- path/to/file` — find who last changed specific lines.
|
||||
|
||||
This convention is **strongly recommended but not required.** When the origin is non-obvious, prioritize shipping the fix over hunting indefinitely.
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: mac-runner
|
||||
description: Operate the self-hosted GitLab Runner on the Mac that builds Ditto's iOS IPA. Covers SSH access, restarting the runner, viewing logs, updating Xcode, debugging fastlane locally, and rotating match certificates.
|
||||
---
|
||||
|
||||
# Mac Runner Operations
|
||||
|
||||
Ditto's iOS pipeline runs two CI jobs on a self-hosted GitLab Runner on a MacBook in the rack: `build-ipa` (signs and builds the IPA via Xcode + fastlane match) and `publish-app-store` (uploads the IPA via `fastlane deliver`, which shells out to Apple's iTMSTransporter — that tool only ships inside Xcode, so this job can't run on Linux). This skill covers operating the Mac.
|
||||
|
||||
This skill covers operating the runner: SSH access, restarting after crashes or Xcode updates, watching logs, debugging fastlane locally, and rotating the match certificates. For initial provisioning, App Store Connect API key creation, and GitLab CI variable setup, load the **`ci-cd-publishing`** skill.
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Need | Command |
|
||||
|---|---|
|
||||
| SSH in | `ssh alex@alexs-air.lan` |
|
||||
| Runner status | `gitlab-runner status` |
|
||||
| Restart runner | `gitlab-runner restart` (after `eval "$(/opt/homebrew/bin/brew shellenv)"`) |
|
||||
| Stdout log | `tail -f ~/gitlab-runner.out.log` |
|
||||
| Stderr log | `tail -f ~/gitlab-runner.err.log` |
|
||||
| Runner config | `~/.gitlab-runner/config.toml` |
|
||||
| LaunchAgent plist | `~/Library/LaunchAgents/gitlab-runner.plist` |
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Host**: `alexs-air.lan` (Apple Silicon MacBook, macOS 26+, Xcode 26+)
|
||||
- **User**: `alex` (the runner runs in user-mode so it can access keychain and Xcode UI tooling)
|
||||
- **Tooling**: Homebrew (`/opt/homebrew`), `gitlab-runner`, `node@22`, `ruby@3.3`, fastlane installed as a user gem under `~/.gem/ruby/3.3.0/`
|
||||
- **Service**: launchd LaunchAgent at `~/Library/LaunchAgents/gitlab-runner.plist`. `KeepAlive=true` (auto-restart on crash) and `RunAtLoad=true` (starts on login). The agent loads when `alex` logs in via auto-login at boot.
|
||||
- **Tags**: `macos`, `ios`, `xcode` — both `build-ipa` and `publish-app-store` in `.gitlab-ci.yml` target this runner. `publish-app-store` doesn't sign anything, but it still needs Xcode's bundled iTMSTransporter to push the IPA to App Store Connect.
|
||||
- **Shell setup**: `~/.bash_profile` sources brew shellenv and prepends `~/.gem/ruby/3.3.0/bin` and `/opt/homebrew/opt/ruby@3.3/bin` to `PATH` so `bash --login` (the runner's executor) finds fastlane + ruby 3.3.
|
||||
|
||||
### Why Ruby 3.3, not the brewed 4.0
|
||||
|
||||
Brewed `fastlane` (current version) ships running on Ruby 4.0 from `brew install ruby`. Ruby 4.0's OpenSSL bindings hit fastlane bug [#20553](https://github.com/fastlane/fastlane/issues/20553) — `OpenSSL::PKey::EC.new(pem)` raises "invalid curve name" for `prime256v1` keys, which breaks every App Store Connect API key signing operation. Ruby 3.3.x doesn't have this bug. So we install fastlane via `gem install fastlane --user-install` on `ruby@3.3` instead of `brew install fastlane`.
|
||||
|
||||
### Why IPv6 is disabled on Wi-Fi
|
||||
|
||||
`networksetup -setv6off Wi-Fi` is set because Ruby's net/http on this machine attempted IPv6 to `rubygems.org` first and timed out (~30 s per request). Disabling IPv6 on the Wi-Fi interface forces IPv4 immediately. To re-enable: `sudo networksetup -setv6automatic Wi-Fi`.
|
||||
|
||||
## Verifying the runner is healthy
|
||||
|
||||
From any machine:
|
||||
|
||||
```bash
|
||||
curl -s -H "PRIVATE-TOKEN: $GITLAB_TOKEN" \
|
||||
"https://gitlab.com/api/v4/runners/53111580" \
|
||||
| python3 -c "import json,sys;d=json.load(sys.stdin);print(d['status'], d['online'])"
|
||||
```
|
||||
|
||||
Expected: `online True`. If `offline` or `not_connected`, SSH in and check:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
gitlab-runner status
|
||||
ps aux | grep gitlab-runner
|
||||
tail -50 ~/gitlab-runner.err.log
|
||||
```
|
||||
|
||||
## Restarting the runner
|
||||
|
||||
After a Mac reboot, the runner should start automatically via the LaunchAgent. To restart manually:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
gitlab-runner restart
|
||||
```
|
||||
|
||||
If `gitlab-runner restart` reports "service not installed", reinstall:
|
||||
|
||||
```bash
|
||||
gitlab-runner install
|
||||
gitlab-runner start
|
||||
```
|
||||
|
||||
This rewrites the LaunchAgent plist.
|
||||
|
||||
## Watching a CI job run live
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan 'tail -f ~/gitlab-runner.out.log'
|
||||
```
|
||||
|
||||
The runner streams build output to stdout. The same output appears in the GitLab job UI.
|
||||
|
||||
## Updating Xcode
|
||||
|
||||
After a major Xcode update:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
sudo xcodebuild -license accept # accept the new license non-interactively
|
||||
xcode-select --install # ensure command-line tools are present
|
||||
xcodebuild -version # confirm version
|
||||
```
|
||||
|
||||
Then trigger a no-op tag rebuild (e.g. cut a patch release) to verify the runner still works.
|
||||
|
||||
## Debugging fastlane locally
|
||||
|
||||
If `build-ipa` fails in CI, reproduce on the Mac. The env vars below mirror what CI sets up:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
cd ~/Projects/ditto
|
||||
git pull origin main
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
|
||||
# Match what CI provides
|
||||
export CI_COMMIT_TAG=v2.x.y
|
||||
export CI_PIPELINE_IID=99999
|
||||
export MATCH_PASSWORD='<from GitLab CI variables>'
|
||||
export MATCH_GIT_BASIC_AUTHORIZATION='<base64 of ci-readonly:gldt-...>'
|
||||
export APP_STORE_CONNECT_API_KEY_ID=<key-id>
|
||||
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<issuer-id>
|
||||
export ASC_KEY_PATH=~/.private_keys/AuthKey_<key-id>.p8
|
||||
|
||||
# Build web assets and sync to Capacitor iOS project (CI does this in before_script)
|
||||
npm ci
|
||||
npx vite build -l error
|
||||
cp dist/index.html dist/404.html
|
||||
npx cap sync ios
|
||||
node scripts/patch-cap-config.mjs
|
||||
|
||||
# Stamp marketing version (CI does this in script)
|
||||
VERSION="${CI_COMMIT_TAG#v}"
|
||||
sed -i '' "s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g" ios/App/App.xcodeproj/project.pbxproj
|
||||
|
||||
# Run the build lane
|
||||
cd ios
|
||||
fastlane build_ipa
|
||||
```
|
||||
|
||||
This produces the IPA at `../artifacts/Ditto.ipa` exactly like CI. Add `--verbose` for detailed output.
|
||||
|
||||
To also test the submission step end-to-end (this calls Apple, so be ready to "Remove from Review" in App Store Connect afterward):
|
||||
|
||||
```bash
|
||||
export IPA_PATH="$HOME/Projects/ditto/artifacts/Ditto.ipa"
|
||||
fastlane submit_release
|
||||
```
|
||||
|
||||
Or, to debug *just* the submission against an already-uploaded build without rebuilding, use the `submit_only` lane (see "Debugging App Store submission with the `submit_only` lane" below).
|
||||
|
||||
## Rotating match certificates (yearly)
|
||||
|
||||
Apple distribution certs expire one year after issuance. To renew:
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
cd ~/Projects/ditto/ios
|
||||
eval "$(/opt/homebrew/bin/brew shellenv)"
|
||||
|
||||
# Set Apple credentials (API key path)
|
||||
export MATCH_PASSWORD='<from GitLab CI variables>'
|
||||
|
||||
# Revoke the expiring cert in Apple's portal and remove from the match repo
|
||||
fastlane match nuke distribution
|
||||
|
||||
# Issue a new cert, generate a new App Store profile, encrypt, commit, push
|
||||
fastlane match appstore \
|
||||
--api_key_path ~/.private_keys/AuthKey_<KEY_ID>.p8 \
|
||||
--api_key_id <KEY_ID> \
|
||||
--api_issuer_id <ISSUER_ID>
|
||||
```
|
||||
|
||||
CI's next tag run picks up the new files via `match(... readonly: true)`. No GitLab variables to update.
|
||||
|
||||
## Debugging App Store submission with the `submit_only` lane
|
||||
|
||||
The `Fastfile` exposes a second lane, `submit_only`, that skips build/archive/upload and just runs `deliver` against an already-uploaded build. Useful when the binary is fine but the metadata/submission step is failing — iterate in ~30 seconds instead of waiting for a full ~6-minute CI build.
|
||||
|
||||
```bash
|
||||
ssh alex@alexs-air.lan
|
||||
export PATH="$HOME/.gem/ruby/3.3.0/bin:/opt/homebrew/opt/ruby@3.3/bin:$PATH"
|
||||
cd ~/Projects/ditto/ios
|
||||
|
||||
# Make sure the .p8 is on disk; CI's after_script wipes it after each job
|
||||
scp $LAPTOP:/path/to/AuthKey_<KEY_ID>.p8 ~/.private_keys/
|
||||
|
||||
export ASC_KEY_PATH=$HOME/.private_keys/AuthKey_<KEY_ID>.p8
|
||||
export APP_STORE_CONNECT_API_KEY_ID=<KEY_ID>
|
||||
export APP_STORE_CONNECT_API_KEY_ISSUER_ID=<ISSUER_ID>
|
||||
export BUILD_NUMBER=<existing-build-number-on-ASC>
|
||||
export VERSION=<marketing-version, e.g. 2.14.3>
|
||||
|
||||
fastlane submit_only
|
||||
```
|
||||
|
||||
The lane expects the version to exist in App Store Connect with a `VALID` build attached. It uploads metadata (`./fastlane/metadata/en-US/release_notes.txt`) and calls `submit_for_review`. If Apple rejects, fix the Fastfile, re-run — no rebuild needed.
|
||||
|
||||
If Apple has already accepted the submission for that version, you'll need to "Remove from Review" in App Store Connect (only available while state is `WAITING_FOR_REVIEW`, not `IN_REVIEW`) before re-running, or bump the build number.
|
||||
|
||||
## Inspecting App Store Connect state directly
|
||||
|
||||
When fastlane's error messages aren't enough, query Apple's API directly. There's no installed CLI — use the JWT signing recipe Apple documents. A working Ruby snippet lives in this skill's troubleshooting history; the short version:
|
||||
|
||||
```ruby
|
||||
require "json"; require "openssl"; require "net/http"; require "base64"
|
||||
key_pem = File.read(ENV["ASC_KEY_PATH"])
|
||||
ec = OpenSSL::PKey::EC.new(key_pem)
|
||||
header = { alg: "ES256", kid: ENV["APP_STORE_CONNECT_API_KEY_ID"], typ: "JWT" }
|
||||
payload = { iss: ENV["APP_STORE_CONNECT_API_KEY_ISSUER_ID"], iat: Time.now.to_i, exp: Time.now.to_i + 1200, aud: "appstoreconnect-v1" }
|
||||
def b64(s); Base64.urlsafe_encode64(s, padding: false); end
|
||||
si = b64(JSON.generate(header)) + "." + b64(JSON.generate(payload))
|
||||
sig_der = ec.sign(OpenSSL::Digest::SHA256.new, si)
|
||||
asn = OpenSSL::ASN1.decode(sig_der)
|
||||
r = asn.value[0].value.to_s(2); s = asn.value[1].value.to_s(2)
|
||||
r = ("\x00".b * (32 - r.bytesize)) + r if r.bytesize < 32
|
||||
s = ("\x00".b * (32 - s.bytesize)) + s if s.bytesize < 32
|
||||
jwt = si + "." + b64(r + s)
|
||||
# Now: GET https://api.appstoreconnect.apple.com/v1/apps?filter[bundleId]=pub.ditto.app
|
||||
# with header Authorization: Bearer <jwt>
|
||||
```
|
||||
|
||||
Useful endpoints:
|
||||
- `GET /v1/apps?filter[bundleId]=pub.ditto.app` → app id
|
||||
- `GET /v1/apps/<id>/appStoreVersions` → version list with `appStoreState`
|
||||
- `GET /v1/apps/<id>/builds?sort=-uploadedDate` → recent builds and processing state
|
||||
- `GET /v1/appStoreVersions/<id>/appStoreVersionLocalizations` → release notes (`whatsNew`)
|
||||
|
||||
## What can go wrong
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| Runner shows offline in GitLab | Mac rebooted, auto-login disabled, or LaunchAgent unloaded | SSH in, `gitlab-runner status`, `gitlab-runner restart` |
|
||||
| Build fails: "unable to find Xcode" | Xcode auto-updated and changed path, or command-line tools missing | `xcode-select --install`, `sudo xcodebuild -license accept` |
|
||||
| Build fails: "no signing certificate found" | match cert expired, was revoked manually, or `MATCH_PASSWORD` mismatched | Run yearly rotation procedure above |
|
||||
| Build fails: keychain locked / "User interaction is not allowed" | `setup_ci` failed to create the temporary keychain | Verify `FASTLANE_KEYCHAIN_PASSWORD` is set in GitLab CI variables |
|
||||
| Build fails: ASC API key invalid | Key was revoked or rotated | Generate a new key and update `APP_STORE_CONNECT_API_KEY_*` variables |
|
||||
| "Build already exists" from `deliver` | Previous tag's IPA had the same `CFBundleVersion`; fastlane's `increment_build_number` didn't bump because the value already matched `CI_PIPELINE_IID` | Push a new tag (each new tag has a new pipeline ID) |
|
||||
| Apple precheck rejects metadata | Encryption export compliance, IDFA, content rights flags don't match `Fastfile` | Update `submission_information` in `ios/fastlane/Fastfile` |
|
||||
| `OpenSSL::PKey::PKeyError: invalid curve name` | fastlane is running on brewed Ruby 4.0, which has a broken OpenSSL EC parser ([fastlane#20553](https://github.com/fastlane/fastlane/issues/20553)) | Use `ruby@3.3` from brew and install fastlane as a user gem (`gem install fastlane --user-install`); ensure `~/.bash_profile` puts `~/.gem/ruby/3.3.0/bin` on PATH ahead of `/opt/homebrew/bin` |
|
||||
| `gem install` / `bundle install` hangs for >30s per request | Ruby's net/http tries IPv6 to rubygems.org and times out on this network | `sudo networksetup -setv6off Wi-Fi` (per-interface, persistent until reboot) |
|
||||
| `Unresolved conflict between options: 'api_key_path' and 'api_key'` | `app_store_connect_api_key` action sets `APP_STORE_CONNECT_API_KEY_PATH` env var (path to `.p8`), match's same-named env var expects a JSON descriptor | Build the API key hash inline in the Fastfile (don't call `app_store_connect_api_key`); read `.p8` from a non-conflicting var like `ASC_KEY_PATH` |
|
||||
| `[match] Could not find the newly generated certificate installed` when running match interactively on macOS 26+ | [fastlane#15185](https://github.com/fastlane/fastlane/issues/15185) — the new-cert verification step trips on partition list and keychain trust | Run cert generation **in CI** via the bootstrap procedure in the `ci-cd-publishing` skill (uses `setup_ci`'s ephemeral keychain). Don't run `fastlane match appstore` interactively. |
|
||||
| iOS build fails: `No "iOS Development" signing certificate matching team ID` | The Xcode project uses `CODE_SIGN_STYLE=Automatic`; xcodebuild tries to find a Development cert even for Release builds | Override via `xcargs: "CODE_SIGN_STYLE=Manual CODE_SIGN_IDENTITY='Apple Distribution' PROVISIONING_PROFILE_SPECIFIER='match AppStore <bundle-id>' DEVELOPMENT_TEAM=<team>"` in the Fastfile (already configured) |
|
||||
| `vite.config.ts: Unexpected token 'c', "concurrent"... is not valid JSON` | GitLab Runner sets `CONFIG_FILE=/Users/alex/.gitlab-runner/config.toml` in the job environment, which collides with vite's `process.env.CONFIG_FILE ?? "./ditto.json"` lookup | Already fixed: use `DITTO_CONFIG_FILE` for the override env var |
|
||||
| `whatsNew is missing` from `submit_for_review` | `metadata_path: "./metadata"` resolves relative to fastlane's cwd (`ios/`), not its config dir (`ios/fastlane/`); fastlane silently uploads zero locales | Use `metadata_path: "./fastlane/metadata"` (already configured) |
|
||||
| `appStoreVersions ... is not in valid state` | Apple won't accept submission because the version is past `PREPARE_FOR_SUBMISSION` (already submitted, in review, or shipped) | "Remove from Review" in App Store Connect if `WAITING_FOR_REVIEW`, or cut a new version |
|
||||
| `An attribute value is not acceptable for the current resource state. - contentRightsDeclaration` | Apple rejects PATCH on locked App-level fields when `submission_information` includes `content_rights_*` | Drop `content_rights_*` from `submission_information` in the Fastfile (already configured) |
|
||||
|
||||
## When the Mac dies
|
||||
|
||||
1. Get a replacement Mac. Install Xcode from the App Store.
|
||||
2. Run the **`ci-cd-publishing`** skill's "Initial setup" — but skip the App Store Connect API key step (you already have it). Re-register the runner with the same `macos` tag.
|
||||
3. Restore signing identity: `cd ditto/ios && fastlane match appstore --readonly` decrypts the existing certs/profiles using `MATCH_PASSWORD`.
|
||||
4. No reissuance, no revocation, no GitLab variable updates needed. The certificates repo is the source of truth.
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: nip19-routing
|
||||
description: Implement or populate the root-level NIP-19 router (/:nip19) that handles npub, nprofile, note, nevent, and naddr identifiers. Covers decoding, secure filter construction, and type-specific rendering for profiles, notes, events, and addressable events.
|
||||
---
|
||||
|
||||
# NIP-19 Identifier Routing
|
||||
|
||||
NIP-19 defines the bech32-encoded identifiers used throughout Nostr (`npub1...`, `note1...`, `naddr1...`, etc.). This project routes all of them through a single root-level page at `/:nip19`, implemented by `src/pages/NIP19Page.tsx`.
|
||||
|
||||
Use this skill when the user wants to populate the `NIP19Page` sections with real views, add a new identifier type, or build links that point into the Nostr routing system.
|
||||
|
||||
## Identifier Reference
|
||||
|
||||
| Prefix | Payload | Use when… |
|
||||
|--------------|------------------------------------------------------------------|--------------------------------------------------------------|
|
||||
| `npub1` | 32-byte public key | Simple user reference |
|
||||
| `nprofile1` | Public key + optional relay hints + petname | User reference with relay context |
|
||||
| `note1` | 32-byte event ID (kind:1 text notes only, per NIP-10) | Referencing a short text note/thread |
|
||||
| `nevent1` | Event ID + optional relay hints + author pubkey + kind | Any event kind, or notes where you need relay/author context |
|
||||
| `naddr1` | `kind` + `pubkey` + `identifier` (`d` tag) + optional relay hints | Addressable events (kind 30000-39999): articles, products |
|
||||
| `nsec1` | Private key | **Never display or route** — treat as a 404 |
|
||||
| `nrelay1` | Relay URL | Deprecated |
|
||||
|
||||
### `note1` vs `nevent1`
|
||||
|
||||
- `note1` carries only an event ID, and is canonically tied to kind:1 text notes.
|
||||
- `nevent1` can reference **any** kind and can carry relay hints + author pubkey. Prefer `nevent1` for non-kind-1 events or when you want to ship relay hints with a link.
|
||||
|
||||
### `npub1` vs `nprofile1`
|
||||
|
||||
- `npub1` is just a pubkey.
|
||||
- `nprofile1` adds relay hints and a petname. Prefer it for shareable profile links where discoverability matters.
|
||||
|
||||
## Routing Rules
|
||||
|
||||
1. **All NIP-19 identifiers are handled at the URL root**: `/:nip19` in `AppRouter.tsx`. Never nest them under paths like `/note/:id` or `/profile/:npub`.
|
||||
2. **Invalid, vacant, or unsupported identifiers** (including `nsec1` and `nrelay1`) render the 404 page. The `NIP19Page` boilerplate already handles this.
|
||||
3. **Addressable event URLs must include the author**. `naddr1` already encodes `pubkey` + `kind` + `identifier`, which is exactly what a secure query filter needs. If you ever design an alternative URL, use the shape `/:npub/:dtag`, never `/:dtag` alone — otherwise anyone can publish a conflicting event with the same `d` tag.
|
||||
|
||||
## Decoding and Filtering
|
||||
|
||||
Nostr relay filters only accept hex strings. Always decode the NIP-19 identifier before building a filter.
|
||||
|
||||
```ts
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
const decoded = nip19.decode(value); // throws on invalid input
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub': {
|
||||
const pubkey = decoded.data; // hex string
|
||||
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'nprofile': {
|
||||
const { pubkey /*, relays */ } = decoded.data;
|
||||
return nostr.query([{ kinds: [0], authors: [pubkey], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'note': {
|
||||
const id = decoded.data;
|
||||
return nostr.query([{ ids: [id], kinds: [1], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'nevent': {
|
||||
const { id /*, relays, author, kind */ } = decoded.data;
|
||||
return nostr.query([{ ids: [id], limit: 1 }]);
|
||||
}
|
||||
|
||||
case 'naddr': {
|
||||
const { kind, pubkey, identifier } = decoded.data;
|
||||
return nostr.query([{
|
||||
kinds: [kind],
|
||||
authors: [pubkey], // critical: prevents d-tag spoofing
|
||||
'#d': [identifier],
|
||||
limit: 1,
|
||||
}]);
|
||||
}
|
||||
|
||||
default:
|
||||
// nsec, nrelay, unknown → 404
|
||||
throw new Error('Unsupported Nostr identifier');
|
||||
}
|
||||
```
|
||||
|
||||
### Common mistakes
|
||||
|
||||
```ts
|
||||
// ❌ Passing bech32 into a filter
|
||||
nostr.query([{ ids: [naddr] }]);
|
||||
|
||||
// ❌ Addressable lookup without the author — anyone can spoof the d-tag
|
||||
nostr.query([{ kinds: [30023], '#d': [slug] }]);
|
||||
|
||||
// ✅ Decode first, then include author
|
||||
const { kind, pubkey, identifier } = nip19.decode(naddr).data;
|
||||
nostr.query([{ kinds: [kind], authors: [pubkey], '#d': [identifier] }]);
|
||||
```
|
||||
|
||||
## Populating `NIP19Page`
|
||||
|
||||
`src/pages/NIP19Page.tsx` already:
|
||||
|
||||
- Decodes `params.nip19` with `nip19.decode`.
|
||||
- Branches on `decoded.type` with a section for each supported identifier.
|
||||
- Redirects invalid / unsupported identifiers to the 404 page.
|
||||
- Provides a responsive container wrapper.
|
||||
|
||||
To turn it into a real router, replace each placeholder section with a concrete component:
|
||||
|
||||
| `decoded.type` | Typical view |
|
||||
|-----------------------|---------------------------------------------------------------|
|
||||
| `npub` / `nprofile` | Profile page: header from kind 0, feed of the user's events |
|
||||
| `note` | Single kind:1 text note with thread + replies |
|
||||
| `nevent` | Generic event renderer; branch on `kind` for specialized UIs |
|
||||
| `naddr` | Addressable-event view (article, product, community, etc.) |
|
||||
|
||||
Inside each branch, pass the decoded payload (not the raw bech32 string) to a child component. That keeps filter construction colocated with the fetching hook and removes any chance of a re-decode mismatch.
|
||||
|
||||
## Linking to NIP-19 Routes
|
||||
|
||||
When building links elsewhere in the app:
|
||||
|
||||
```tsx
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
// To a profile
|
||||
<Link to={`/${nip19.npubEncode(pubkey)}`}>Profile</Link>
|
||||
|
||||
// To an addressable event (article, product, …)
|
||||
<Link to={`/${nip19.naddrEncode({ kind, pubkey, identifier, relays })}`}>
|
||||
Open
|
||||
</Link>
|
||||
|
||||
// To a specific event of any kind, with relay hints
|
||||
<Link to={`/${nip19.neventEncode({ id, relays, author, kind })}`}>Open</Link>
|
||||
```
|
||||
|
||||
Always encode with the **most specific** identifier you have context for (`nprofile` > `npub`, `nevent` > `note`, `naddr` for addressable). The extra metadata makes links more robust across relays.
|
||||
|
||||
## Security Recap
|
||||
|
||||
- Decode **before** querying.
|
||||
- For addressable events, always include `authors: [pubkey]` in the filter — the `d` tag alone is not a trust boundary.
|
||||
- Treat `nsec1` and any unknown/invalid identifier as 404. Never render, log, or echo a decoded `nsec`.
|
||||
@@ -0,0 +1,190 @@
|
||||
---
|
||||
name: nip85-stats
|
||||
description: Fetch pre-computed engagement stats (follower count, post count, reply count, reaction count, zap amounts, etc.) for users, events, and addressable events via a NIP-85 Trusted Assertion provider. Provides useNip85UserStats, useNip85EventStats, and useNip85AddrStats hooks backed by a configurable provider pubkey in AppConfig.
|
||||
---
|
||||
|
||||
# NIP-85 Trusted Assertion Stats
|
||||
|
||||
[NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) defines "Trusted Assertions" — events published by a service provider that carry pre-computed stats (follower counts, reaction counts, zap totals, etc.) for users and events. Clients that would otherwise need to load thousands of events to compute these numbers can instead query a single addressable event from a trusted provider.
|
||||
|
||||
This skill adds three hooks — `useNip85UserStats`, `useNip85EventStats`, `useNip85AddrStats` — and a configurable `nip85StatsPubkey` field on `AppConfig` so you can swap providers.
|
||||
|
||||
## Kinds Used
|
||||
|
||||
| Kind | Subject | `d` tag value |
|
||||
| ----- | ---------------------------- | -------------------------- |
|
||||
| 30382 | User | user pubkey (hex) |
|
||||
| 30383 | Event (regular, kind 1 etc.) | event id (hex) |
|
||||
| 30384 | Addressable event | `<kind>:<pubkey>:<d-tag>` |
|
||||
|
||||
The hooks query one replaceable event at a time (`limit: 1`), filtered by `authors: [statsPubkey]` and `#d`. **Filtering by `authors` is required** — without it, anyone could publish a fake assertion with the same `d` tag and the client would accept it.
|
||||
|
||||
## Files Provided by This Skill
|
||||
|
||||
| Skill file | Copy to |
|
||||
|---|---|
|
||||
| `files/hooks/useNip85Stats.ts` | `src/hooks/useNip85Stats.ts` |
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Copy the Hooks File
|
||||
|
||||
Copy `.agents/skills/nip85-stats/files/hooks/useNip85Stats.ts` into `src/hooks/useNip85Stats.ts`. It imports `@nostrify/react`, `@tanstack/react-query`, and `@/hooks/useAppContext`, all already present in the template.
|
||||
|
||||
### 2. Add `nip85StatsPubkey` to `AppConfig`
|
||||
|
||||
In `src/contexts/AppContext.ts`, add the field to the `AppConfig` interface:
|
||||
|
||||
```typescript
|
||||
export interface AppConfig {
|
||||
// ...existing fields...
|
||||
/** Hex pubkey of the NIP-85 Trusted Assertion provider. Empty = disabled. */
|
||||
nip85StatsPubkey: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update the Zod Schema in `AppProvider.tsx`
|
||||
|
||||
In `src/components/AppProvider.tsx`, add the field to `AppConfigSchema`:
|
||||
|
||||
```typescript
|
||||
const AppConfigSchema = z.object({
|
||||
// ...existing fields...
|
||||
nip85StatsPubkey: z.string().refine(
|
||||
(val) => val.length === 0 || /^[0-9a-f]{64}$/i.test(val),
|
||||
{ message: 'Must be empty or a 64-character hex pubkey' },
|
||||
),
|
||||
}) satisfies z.ZodType<AppConfig>;
|
||||
```
|
||||
|
||||
### 4. Set the Default in `App.tsx`
|
||||
|
||||
Pick a provider pubkey and add it to `defaultConfig`. The ditto.pub provider is a reasonable default:
|
||||
|
||||
```typescript
|
||||
const defaultConfig: AppConfig = {
|
||||
// ...existing fields...
|
||||
nip85StatsPubkey: '5f68e85ee174102ca8978eef302129f081f03456c884185d5ec1c1224ab633ea',
|
||||
};
|
||||
```
|
||||
|
||||
Set to `''` to ship with stats disabled.
|
||||
|
||||
### 5. Update `TestApp.tsx`
|
||||
|
||||
In `src/test/TestApp.tsx`, add the field to the test default config. Use an empty string so tests don't hit a live provider:
|
||||
|
||||
```typescript
|
||||
const defaultConfig: AppConfig = {
|
||||
// ...existing fields...
|
||||
nip85StatsPubkey: '',
|
||||
};
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### User stats (kind 30382)
|
||||
|
||||
```tsx
|
||||
import { useNip85UserStats } from '@/hooks/useNip85Stats';
|
||||
|
||||
function FollowerCount({ pubkey }: { pubkey: string }) {
|
||||
const { data: stats } = useNip85UserStats(pubkey);
|
||||
if (!stats) return null; // no provider configured or no assertion yet
|
||||
return <span>{stats.followers.toLocaleString()} followers</span>;
|
||||
}
|
||||
```
|
||||
|
||||
### Event stats (kind 30383)
|
||||
|
||||
```tsx
|
||||
import { useNip85EventStats } from '@/hooks/useNip85Stats';
|
||||
|
||||
function NoteStats({ eventId }: { eventId: string }) {
|
||||
const { data: stats } = useNip85EventStats(eventId);
|
||||
if (!stats) return null;
|
||||
return (
|
||||
<div className="flex gap-3 text-sm text-muted-foreground">
|
||||
<span>{stats.reactionCount} reactions</span>
|
||||
<span>{stats.repostCount} reposts</span>
|
||||
<span>{stats.commentCount} comments</span>
|
||||
<span>{stats.zapAmount} sats</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Addressable event stats (kind 30384)
|
||||
|
||||
The `addr` argument is the full NIP-01 event address `<kind>:<pubkey>:<d-tag>`:
|
||||
|
||||
```tsx
|
||||
import { useNip85AddrStats } from '@/hooks/useNip85Stats';
|
||||
|
||||
function ArticleStats({ kind, pubkey, identifier }: { kind: number; pubkey: string; identifier: string }) {
|
||||
const { data: stats } = useNip85AddrStats(`${kind}:${pubkey}:${identifier}`);
|
||||
if (!stats) return null;
|
||||
return <span>{stats.reactionCount} reactions</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior Notes
|
||||
|
||||
- **Graceful degradation:** The hooks return `null` (not an error) when `nip85StatsPubkey` is empty or the provider has no assertion for the subject. Always render defensively — NIP-85 is an optimization, not a source of truth.
|
||||
- **Short timeouts:** Each query is wrapped in a 2-second `AbortSignal.timeout` so a slow stats relay never blocks the UI.
|
||||
- **Cached by TanStack Query:** `staleTime` is 30s for event/addr stats and 60s for user stats. Results are keyed on `[kind, subject, statsPubkey]`, so swapping providers invalidates the cache automatically.
|
||||
- **Missing tags = 0:** A tag absent from the assertion is reported as `0` rather than `undefined`, matching NIP-85's "no data" semantics.
|
||||
- **Not the source of truth:** For interactive features (did *this* user like *this* post?) you still need to query the underlying reaction/zap/repost events. NIP-85 only provides aggregate counts.
|
||||
|
||||
## Extending the Stats
|
||||
|
||||
The hooks expose a small subset of the tags defined in NIP-85. To surface more (e.g. `zap_amt_sent`, `rank`, `first_created_at`), extend the return types and pull additional tags via `getIntTag`:
|
||||
|
||||
```typescript
|
||||
export interface Nip85UserStats {
|
||||
followers: number;
|
||||
postCount: number;
|
||||
rank: number; // new
|
||||
zapAmtReceived: number; // new
|
||||
}
|
||||
|
||||
// inside useNip85UserStats queryFn
|
||||
return {
|
||||
followers: getIntTag(tags, 'followers'),
|
||||
postCount: getIntTag(tags, 'post_cnt'),
|
||||
rank: getIntTag(tags, 'rank'),
|
||||
zapAmtReceived: getIntTag(tags, 'zap_amt_recd'),
|
||||
};
|
||||
```
|
||||
|
||||
See the full tag table in [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md).
|
||||
|
||||
## Exposing a Provider Picker (Optional)
|
||||
|
||||
If you want the user to change providers at runtime, add an input bound to `config.nip85StatsPubkey` and call `updateConfig` with a validated 64-char hex value:
|
||||
|
||||
```tsx
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
function StatsProviderInput() {
|
||||
const { config, updateConfig } = useAppContext();
|
||||
return (
|
||||
<input
|
||||
value={config.nip85StatsPubkey}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim().toLowerCase();
|
||||
if (v === '' || /^[0-9a-f]{64}$/.test(v)) {
|
||||
updateConfig(() => ({ nip85StatsPubkey: v }));
|
||||
}
|
||||
}}
|
||||
placeholder="64-char hex pubkey (blank to disable)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Related NIPs
|
||||
|
||||
- [NIP-85](https://github.com/nostr-protocol/nips/blob/master/85.md) — Trusted Assertions (this skill)
|
||||
- [NIP-01](https://github.com/nostr-protocol/nips/blob/master/01.md) — Addressable event addressing (`<kind>:<pubkey>:<d-tag>`)
|
||||
- [NIP-57](https://github.com/nostr-protocol/nips/blob/master/57.md) — Zaps (the underlying events `zap_amount` / `zap_cnt` aggregate)
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
|
||||
/** Engagement counts exposed by NIP-85 kind 30383 (events) and 30384 (addressable events). */
|
||||
export interface Nip85EventStats {
|
||||
commentCount: number;
|
||||
repostCount: number;
|
||||
reactionCount: number;
|
||||
zapCount: number;
|
||||
/** Zap amount in sats. */
|
||||
zapAmount: number;
|
||||
}
|
||||
|
||||
/** A subset of NIP-85 kind 30382 (user) stats — extend as needed. */
|
||||
export interface Nip85UserStats {
|
||||
followers: number;
|
||||
postCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an integer tag value from a NIP-85 assertion event. Returns 0 when missing
|
||||
* or unparseable, which mirrors the semantics of "no data" in NIP-85.
|
||||
*/
|
||||
function getIntTag(tags: string[][], tagName: string): number {
|
||||
const tag = tags.find(([name]) => name === tagName);
|
||||
if (!tag?.[1]) return 0;
|
||||
const n = parseInt(tag[1], 10);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches NIP-85 event stats (kind 30383) from the configured stats pubkey.
|
||||
* Returns `null` when no stats pubkey is configured or the provider has no
|
||||
* assertion for this event.
|
||||
*/
|
||||
export function useNip85EventStats(eventId: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
|
||||
return useQuery<Nip85EventStats | null>({
|
||||
queryKey: ['nip85-event-stats', eventId, statsPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!eventId || !statsPubkey) return null;
|
||||
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30383], authors: [statsPubkey], '#d': [eventId], limit: 1 }],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const { tags } = events[0];
|
||||
return {
|
||||
commentCount: getIntTag(tags, 'comment_cnt'),
|
||||
repostCount: getIntTag(tags, 'repost_cnt'),
|
||||
reactionCount: getIntTag(tags, 'reaction_cnt'),
|
||||
zapCount: getIntTag(tags, 'zap_cnt'),
|
||||
zapAmount: getIntTag(tags, 'zap_amount'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!eventId && !!statsPubkey,
|
||||
staleTime: 30 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches NIP-85 user stats (kind 30382) from the configured stats pubkey.
|
||||
* Returns `null` when no stats pubkey is configured or the provider has no
|
||||
* assertion for this pubkey.
|
||||
*/
|
||||
export function useNip85UserStats(pubkey: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
|
||||
return useQuery<Nip85UserStats | null>({
|
||||
queryKey: ['nip85-user-stats', pubkey, statsPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!pubkey || !statsPubkey) return null;
|
||||
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30382], authors: [statsPubkey], '#d': [pubkey], limit: 1 }],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const { tags } = events[0];
|
||||
return {
|
||||
followers: getIntTag(tags, 'followers'),
|
||||
postCount: getIntTag(tags, 'post_cnt'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!pubkey && !!statsPubkey,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches NIP-85 addressable event stats (kind 30384) from the configured
|
||||
* stats pubkey. The `addr` argument is the full NIP-01 event address string,
|
||||
* e.g. `30023:<pubkey>:<d-tag>`.
|
||||
*/
|
||||
export function useNip85AddrStats(addr: string | undefined) {
|
||||
const { nostr } = useNostr();
|
||||
const { config } = useAppContext();
|
||||
const statsPubkey = config.nip85StatsPubkey;
|
||||
|
||||
return useQuery<Nip85EventStats | null>({
|
||||
queryKey: ['nip85-addr-stats', addr, statsPubkey],
|
||||
queryFn: async ({ signal }) => {
|
||||
if (!addr || !statsPubkey) return null;
|
||||
|
||||
const combined = AbortSignal.any([signal, AbortSignal.timeout(2000)]);
|
||||
|
||||
try {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [30384], authors: [statsPubkey], '#d': [addr], limit: 1 }],
|
||||
{ signal: combined },
|
||||
);
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const { tags } = events[0];
|
||||
return {
|
||||
commentCount: getIntTag(tags, 'comment_cnt'),
|
||||
repostCount: getIntTag(tags, 'repost_cnt'),
|
||||
reactionCount: getIntTag(tags, 'reaction_cnt'),
|
||||
zapCount: getIntTag(tags, 'zap_cnt'),
|
||||
zapAmount: getIntTag(tags, 'zap_amount'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
enabled: !!addr && !!statsPubkey,
|
||||
staleTime: 30 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
---
|
||||
name: nostr-direct-messages
|
||||
description: Implement Nostr direct messaging features, build chat interfaces, or work with encrypted peer-to-peer communication (NIP-04 and NIP-17).
|
||||
---
|
||||
|
||||
# Direct Messaging on Nostr
|
||||
|
||||
This project includes a complete direct messaging system supporting both NIP-04 (legacy) and NIP-17 (modern, more private) encrypted messages with real-time subscriptions, optimistic updates, and a persistent cache-first local storage.
|
||||
|
||||
**The DM system is not enabled by default** - follow the setup instructions below to add messaging functionality to your application.
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Add DMProvider to Your App
|
||||
|
||||
First, add the `DMProvider` to your app's provider tree in `src/App.tsx`:
|
||||
|
||||
```tsx
|
||||
// Add these imports at the top of src/App.tsx
|
||||
import { DMProvider, type DMConfig } from '@/components/DMProvider';
|
||||
import { PROTOCOL_MODE } from '@/lib/dmConstants';
|
||||
|
||||
// Add this configuration before your App component
|
||||
const dmConfig: DMConfig = {
|
||||
// Enable or disable DMs entirely
|
||||
enabled: true, // Set to true to enable messaging functionality
|
||||
|
||||
// Choose one protocol mode:
|
||||
// PROTOCOL_MODE.NIP04_ONLY - Force NIP-04 (legacy) only
|
||||
// PROTOCOL_MODE.NIP17_ONLY - Force NIP-17 (private) only
|
||||
// PROTOCOL_MODE.NIP04_OR_NIP17 - Allow users to choose between NIP-04 and NIP-17 (defaults to NIP-17)
|
||||
protocolMode: PROTOCOL_MODE.NIP17_ONLY, // Recommended for new apps
|
||||
};
|
||||
|
||||
// Then wrap your app components with DMProvider:
|
||||
export function App() {
|
||||
return (
|
||||
<UnheadProvider head={head}>
|
||||
<AppProvider storageKey="nostr:app-config" defaultConfig={defaultConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NostrLoginProvider storageKey='nostr:login'>
|
||||
<NostrProvider>
|
||||
<NostrSync />
|
||||
<DMProvider config={dmConfig}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Suspense>
|
||||
<AppRouter />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</DMProvider>
|
||||
</NostrProvider>
|
||||
</NostrLoginProvider>
|
||||
</QueryClientProvider>
|
||||
</AppProvider>
|
||||
</UnheadProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Configure DM Settings
|
||||
|
||||
The `DMConfig` object supports the following options:
|
||||
|
||||
- `enabled` (boolean, default: `false`) - Enable/disable entire DM system. When false, no messages are loaded, stored, or processed.
|
||||
- `protocolMode` (ProtocolMode, default: `PROTOCOL_MODE.NIP17_ONLY`) - Which protocols to support:
|
||||
- `PROTOCOL_MODE.NIP04_ONLY` - Legacy encryption only
|
||||
- `PROTOCOL_MODE.NIP17_ONLY` - Modern private messages (recommended)
|
||||
- `PROTOCOL_MODE.NIP04_OR_NIP17` - Support both protocols (for backwards compatibility)
|
||||
|
||||
**Note**: The DM system uses domain-based IndexedDB naming (`nostr-dm-store-${hostname}`) to prevent conflicts between multiple apps on the same domain.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Send Messages
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
|
||||
|
||||
function ComposeMessage({ recipientPubkey }: { recipientPubkey: string }) {
|
||||
const { sendMessage } = useDMContext();
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
const handleSend = async () => {
|
||||
await sendMessage({
|
||||
recipientPubkey,
|
||||
content,
|
||||
protocol: MESSAGE_PROTOCOL.NIP17, // Uses NIP-44 encryption + gift wrapping
|
||||
});
|
||||
setContent('');
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
<button type="submit">Send</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Display Conversations
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
||||
function ConversationList({ onSelectConversation }: { onSelectConversation: (pubkey: string) => void }) {
|
||||
const { conversations, isLoading } = useDMContext();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading conversations...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{conversations.map((conversation) => (
|
||||
<ConversationItem
|
||||
key={conversation.pubkey}
|
||||
conversation={conversation}
|
||||
onClick={() => onSelectConversation(conversation.pubkey)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationItem({ conversation, onClick }: {
|
||||
conversation: ConversationSummary;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const author = useAuthor(conversation.pubkey);
|
||||
const displayName = author.data?.metadata?.name || genUserName(conversation.pubkey);
|
||||
const avatarUrl = author.data?.metadata?.picture;
|
||||
|
||||
return (
|
||||
<button onClick={onClick} className="w-full p-3 hover:bg-accent rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={avatarUrl} />
|
||||
<AvatarFallback>{displayName.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium">{displayName}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{conversation.lastMessage?.decryptedContent || 'No messages yet'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Display Messages in a Conversation
|
||||
|
||||
```tsx
|
||||
import { useConversationMessages } from '@/hooks/useConversationMessages';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
|
||||
function MessageThread({ conversationPubkey }: { conversationPubkey: string }) {
|
||||
const { user } = useCurrentUser();
|
||||
const { messages, hasMoreMessages, loadEarlierMessages } = useConversationMessages(conversationPubkey);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-2">
|
||||
{hasMoreMessages && (
|
||||
<button onClick={loadEarlierMessages} className="text-sm text-muted-foreground">
|
||||
Load earlier messages
|
||||
</button>
|
||||
)}
|
||||
|
||||
{messages.map((message) => {
|
||||
const isFromMe = message.pubkey === user?.pubkey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex",
|
||||
isFromMe ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"max-w-[70%] rounded-lg px-4 py-2",
|
||||
isFromMe ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}>
|
||||
{message.error ? (
|
||||
<span className="text-red-500">🔒 {message.error}</span>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
{message.decryptedContent}
|
||||
</p>
|
||||
)}
|
||||
{message.isSending && (
|
||||
<span className="text-xs opacity-50">Sending...</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using the Complete Messaging Interface
|
||||
|
||||
For a fully-featured messaging UI out of the box, use the `DMMessagingInterface` component:
|
||||
|
||||
```tsx
|
||||
import { DMMessagingInterface } from "@/components/dm/DMMessagingInterface";
|
||||
|
||||
function MessagesPage() {
|
||||
return (
|
||||
<div className="container mx-auto p-4 h-screen">
|
||||
<DMMessagingInterface />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `DMMessagingInterface` component provides a complete messaging UI with:
|
||||
- Conversation list with Active/Requests tabs
|
||||
- Message thread view with pagination
|
||||
- Compose area with file upload support
|
||||
- Real-time message updates
|
||||
- Mobile-responsive layout (shows one panel at a time on mobile)
|
||||
|
||||
It requires no props and works automatically when wrapped in `DMProvider`.
|
||||
|
||||
**For custom layouts**, see the "Building Custom Messaging UIs" section below for individual components (`DMConversationList`, `DMChatArea`, `DMStatusInfo`).
|
||||
|
||||
## Sending Files with Messages
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { MESSAGE_PROTOCOL } from '@/lib/dmConstants';
|
||||
import type { FileAttachment } from '@/contexts/DMContext';
|
||||
|
||||
function ComposeWithFiles({ recipientPubkey }: { recipientPubkey: string }) {
|
||||
const { sendMessage } = useDMContext();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
const [content, setContent] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
const handleSend = async () => {
|
||||
let attachments: FileAttachment[] | undefined;
|
||||
|
||||
// Upload file if one is selected
|
||||
if (selectedFile) {
|
||||
const tags = await uploadFile(selectedFile);
|
||||
|
||||
attachments = [{
|
||||
url: tags[0][1], // URL from first tag
|
||||
mimeType: selectedFile.type,
|
||||
size: selectedFile.size,
|
||||
name: selectedFile.name,
|
||||
tags: tags
|
||||
}];
|
||||
}
|
||||
|
||||
await sendMessage({
|
||||
recipientPubkey,
|
||||
content,
|
||||
protocol: MESSAGE_PROTOCOL.NIP17,
|
||||
attachments,
|
||||
});
|
||||
|
||||
setContent('');
|
||||
setSelectedFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSend(); }}>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Type a message..."
|
||||
/>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
|
||||
/>
|
||||
|
||||
{selectedFile && <div>Selected: {selectedFile.name}</div>}
|
||||
|
||||
<button type="submit" disabled={isUploading}>
|
||||
{isUploading ? 'Uploading...' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Protocol Comparison
|
||||
|
||||
### NIP-04 (Legacy)
|
||||
- **Encryption**: NIP-04 (simpler, older)
|
||||
- **Metadata**: Sender and recipient visible to relays
|
||||
- **Event Kind**: Kind 4
|
||||
- **Use When**: Compatibility with older clients
|
||||
|
||||
### NIP-17 (Modern & Private)
|
||||
- **Encryption**: NIP-44 (stronger)
|
||||
- **Metadata**: Hidden via gift wrapping (NIP-59)
|
||||
- **Event Kinds**: Kind 14 (text), Kind 15 (files)
|
||||
- **Wrapped In**: Kind 1059 (Gift Wrap) with ephemeral keys
|
||||
- **Use When**: Maximum privacy (recommended)
|
||||
|
||||
**Key Privacy Features of NIP-17:**
|
||||
- Sender identity hidden (uses random ephemeral keys)
|
||||
- Timestamps randomized (±2 days) to hide send time
|
||||
- Dual gift wraps (recipient + sender) for message history
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Conversation Categorization
|
||||
|
||||
The system automatically categorizes conversations:
|
||||
|
||||
```tsx
|
||||
const { conversations } = useDMContext();
|
||||
|
||||
// Filter by category
|
||||
const knownConversations = conversations.filter(c => c.isKnown);
|
||||
const requestConversations = conversations.filter(c => c.isRequest);
|
||||
|
||||
// isKnown = true if user has sent at least one message
|
||||
// isRequest = true if only received messages, never replied
|
||||
```
|
||||
|
||||
### Loading States
|
||||
|
||||
```tsx
|
||||
const { isLoading, loadingPhase, scanProgress } = useDMContext();
|
||||
|
||||
// Check overall loading state
|
||||
if (isLoading) {
|
||||
console.log('Current phase:', loadingPhase);
|
||||
// LOADING_PHASES.CACHE - Loading from local cache
|
||||
// LOADING_PHASES.RELAYS - Querying relays
|
||||
// LOADING_PHASES.SUBSCRIPTIONS - Setting up real-time updates
|
||||
// LOADING_PHASES.READY - Fully loaded
|
||||
}
|
||||
|
||||
// Display scan progress for large message histories
|
||||
if (scanProgress.nip17) {
|
||||
console.log(`NIP-17: ${scanProgress.nip17.current} messages - ${scanProgress.nip17.status}`);
|
||||
}
|
||||
```
|
||||
|
||||
### Clear Cache and Refresh
|
||||
|
||||
```tsx
|
||||
import { useDMContext } from '@/hooks/useDMContext';
|
||||
|
||||
function SettingsButton() {
|
||||
const { clearCacheAndRefetch } = useDMContext();
|
||||
|
||||
const handleClearCache = async () => {
|
||||
await clearCacheAndRefetch();
|
||||
// Clears IndexedDB cache and reloads all messages from relays
|
||||
};
|
||||
|
||||
return (
|
||||
<button onClick={handleClearCache}>
|
||||
Clear Message Cache
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Data Flow
|
||||
1. **Cache First**: Messages load instantly from encrypted IndexedDB cache
|
||||
2. **Background Sync**: New messages fetched from relays in parallel
|
||||
3. **Real-time Updates**: WebSocket subscriptions for live messages
|
||||
4. **Optimistic UI**: Sent messages appear immediately, confirmed on relay response
|
||||
|
||||
### Storage
|
||||
- **IndexedDB**: All messages stored locally with NIP-44 encryption
|
||||
- **Per-User Storage**: Separate encrypted store for each logged-in user
|
||||
- **Automatic Sync**: Debounced writes (15s) + immediate on new messages
|
||||
|
||||
### Performance
|
||||
- **Parallel Queries**: NIP-04 and NIP-17 messages fetched simultaneously
|
||||
- **Batched Loading**: Messages loaded in batches (1000/batch, 20k limit)
|
||||
- **Pagination**: Conversation messages paginated (25/page)
|
||||
- **Deduplication**: Automatic filtering of duplicate messages by ID
|
||||
|
||||
### Security
|
||||
- **NIP-44 Encryption**: Modern authenticated encryption for all NIP-17 messages
|
||||
- **Local Encryption**: IndexedDB storage encrypted with user's NIP-44 key
|
||||
- **Ephemeral Keys**: Random keys for NIP-17 gift wraps (sender anonymity)
|
||||
- **No Plaintext**: Decrypted content never persisted unencrypted
|
||||
- **Domain Isolation**: IndexedDB databases are namespaced by hostname to prevent data conflicts
|
||||
|
||||
## Building Custom Messaging UIs
|
||||
|
||||
For advanced use cases, you can use the individual DM components to build custom layouts:
|
||||
|
||||
### Available Components
|
||||
|
||||
**`DMConversationList`** - Conversation sidebar with tabs
|
||||
```tsx
|
||||
import { DMConversationList } from '@/components/dm/DMConversationList';
|
||||
|
||||
<DMConversationList
|
||||
selectedPubkey={selectedPubkey}
|
||||
onSelectConversation={(pubkey) => setSelectedPubkey(pubkey)}
|
||||
onStatusClick={() => setShowStatus(true)} // optional
|
||||
className="h-full"
|
||||
/>
|
||||
```
|
||||
|
||||
**`DMChatArea`** - Message thread and compose area
|
||||
```tsx
|
||||
import { DMChatArea } from '@/components/dm/DMChatArea';
|
||||
|
||||
<DMChatArea
|
||||
pubkey={selectedPubkey}
|
||||
onBack={() => setSelectedPubkey(null)} // optional, for mobile back button
|
||||
className="h-full"
|
||||
/>
|
||||
```
|
||||
|
||||
**`DMStatusInfo`** - Debug/status panel
|
||||
```tsx
|
||||
import { DMStatusInfo } from '@/components/dm/DMStatusInfo';
|
||||
|
||||
<DMStatusInfo clearCacheAndRefetch={clearCacheAndRefetch} />
|
||||
```
|
||||
|
||||
### Custom Layout Example
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { DMConversationList } from '@/components/dm/DMConversationList';
|
||||
import { DMChatArea } from '@/components/dm/DMChatArea';
|
||||
|
||||
function CustomMessagingLayout() {
|
||||
const [selectedPubkey, setSelectedPubkey] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Custom sidebar */}
|
||||
<aside className="w-64 border-r">
|
||||
<DMConversationList
|
||||
selectedPubkey={selectedPubkey}
|
||||
onSelectConversation={setSelectedPubkey}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
{/* Custom main area */}
|
||||
<main className="flex-1">
|
||||
{selectedPubkey ? (
|
||||
<DMChatArea pubkey={selectedPubkey} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p>Select a conversation to start messaging</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: nostr-encryption
|
||||
description: Encrypt and decrypt content for Nostr direct messages, gift wraps, or any feature that needs NIP-44 (or legacy NIP-04) ciphertext, using the logged-in user's signer.
|
||||
---
|
||||
|
||||
# Nostr Encryption and Decryption
|
||||
|
||||
The logged-in user exposes a `signer` object that matches the NIP-07 signer interface. The signer handles all cryptographic operations internally — including ECDH, conversation-key derivation, and AEAD — so your code never touches a private key.
|
||||
|
||||
**Always use the signer interface for encryption. Never ask the user for their private key, and never derive a shared secret yourself.**
|
||||
|
||||
## NIP-44 (preferred)
|
||||
|
||||
NIP-44 is the modern, authenticated encryption scheme used for DMs (NIP-17), gift wraps (NIP-59), and most new encrypted payloads.
|
||||
|
||||
```ts
|
||||
import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
|
||||
function useEncryptedNote() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
if (!user) throw new Error("Must be logged in");
|
||||
|
||||
// Guard: older signers may not support NIP-44 yet.
|
||||
if (!user.signer.nip44) {
|
||||
throw new Error(
|
||||
"Please upgrade your signer extension to a version that supports NIP-44 encryption",
|
||||
);
|
||||
}
|
||||
|
||||
// Encrypt a message to a recipient (use your own pubkey to encrypt to self).
|
||||
const ciphertext = await user.signer.nip44.encrypt(
|
||||
recipientPubkey,
|
||||
"hello world",
|
||||
);
|
||||
|
||||
// Decrypt a message from a sender (use the *other party's* pubkey).
|
||||
const plaintext = await user.signer.nip44.decrypt(senderPubkey, ciphertext);
|
||||
|
||||
return plaintext;
|
||||
}
|
||||
```
|
||||
|
||||
### Key points
|
||||
|
||||
- `encrypt(peerPubkey, plaintext)` — `peerPubkey` is the **other party's** hex public key. For self-encryption (notes, backups), pass `user.pubkey`.
|
||||
- `decrypt(peerPubkey, ciphertext)` — `peerPubkey` is the author of the ciphertext you're decrypting (for an incoming DM, this is the sender's pubkey).
|
||||
- Both methods are async and may throw if the signer rejects the request or the ciphertext is malformed. Wrap calls in `try/catch`.
|
||||
- The signer handles conversation-key caching; repeated calls for the same peer are cheap.
|
||||
|
||||
## NIP-04 (legacy)
|
||||
|
||||
NIP-04 is only needed when interacting with older clients that haven't adopted NIP-44. The API mirrors NIP-44:
|
||||
|
||||
```ts
|
||||
if (!user.signer.nip04) {
|
||||
throw new Error("Signer does not support NIP-04");
|
||||
}
|
||||
|
||||
const ciphertext = await user.signer.nip04.encrypt(peerPubkey, plaintext);
|
||||
const plaintext = await user.signer.nip04.decrypt(peerPubkey, ciphertext);
|
||||
```
|
||||
|
||||
Prefer NIP-44 for anything new. Only fall back to NIP-04 when a spec or peer explicitly requires it.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Encrypt-to-self (drafts, private notes)
|
||||
|
||||
```ts
|
||||
const ciphertext = await user.signer.nip44.encrypt(user.pubkey, draft);
|
||||
createEvent({ kind: 30078, content: ciphertext, tags: [["d", "my-draft"]] });
|
||||
```
|
||||
|
||||
### Decrypt an incoming DM (NIP-17 / NIP-59)
|
||||
|
||||
For gift-wrapped DMs, you'll typically decrypt the outer wrap, then the inner seal, then read the rumor's content. Each decryption uses the *sender* of that specific layer as the peer pubkey.
|
||||
|
||||
### Guarding the UI
|
||||
|
||||
Always check `user.signer.nip44` (or `nip04`) before calling encryption methods. Remote signers and older browser extensions may not implement every interface, and catching the missing-capability case lets you show a useful message ("Please upgrade your signer") instead of an unhandled promise rejection.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: nostr-kind-design
|
||||
description: Decide whether to reuse an existing NIP or mint a new kind, design tag structures that relays can index, choose what goes in content vs. tags, and document new kinds or extensions in NIP.md. Load when authoring a new schema — not when wiring up rendering for a kind that already exists (use nostr-kind-rendering for that).
|
||||
---
|
||||
|
||||
# Nostr Kinds — Design and Schema
|
||||
|
||||
Load this skill when:
|
||||
|
||||
- Minting a new event kind for a Ditto feature.
|
||||
- Extending an existing NIP with new tags.
|
||||
- Deciding whether an existing NIP covers a use case or whether a custom kind is warranted.
|
||||
- Documenting a custom kind or extension in `NIP.md`.
|
||||
|
||||
**Not this skill** — if an existing NIP/kind covers your use case and you only need to render it in Ditto's UI, use the **`nostr-kind-rendering`** skill instead.
|
||||
|
||||
## Choosing Between Existing NIPs and Custom Kinds
|
||||
|
||||
1. **Thorough NIP review first.** Browse the NIP index, then read candidate NIPs in detail. The goal is to find the closest existing solution.
|
||||
2. **Prefer extending existing NIPs** over creating custom kinds, even at the cost of minor schema compromises. Custom kinds fragment the ecosystem.
|
||||
3. **When an existing NIP is close but not perfect**, use its kind as the base and add domain-specific tags. Document the extension in `NIP.md`.
|
||||
4. **Only mint a new kind** when no existing NIP covers the core functionality, the data structure is fundamentally different, or the use case requires different storage characteristics (regular vs. replaceable vs. addressable).
|
||||
5. **If a tool to generate a new kind number is available, you MUST call it.** Never pick an arbitrary number.
|
||||
6. **Custom kinds MUST include a NIP-31 `alt` tag** with a human-readable description of the event's purpose.
|
||||
|
||||
**Example decision:**
|
||||
|
||||
```
|
||||
Need: Equipment marketplace for farmers
|
||||
Options:
|
||||
1. NIP-15 (Marketplace) — too structured for peer-to-peer sales
|
||||
2. NIP-99 (Classifieds) — good fit, extensible with farming tags
|
||||
3. Custom kind — perfect fit, no interoperability
|
||||
|
||||
Decision: NIP-99 + farming-specific tags.
|
||||
```
|
||||
|
||||
## Kind Ranges
|
||||
|
||||
An event's kind number determines storage semantics:
|
||||
|
||||
- **Regular** (1000 ≤ kind < 10000) — stored permanently by relays. Notes, articles, etc.
|
||||
- **Replaceable** (10000 ≤ kind < 20000) — only the latest event per `pubkey+kind` is kept. Profile metadata, contact lists, mute lists.
|
||||
- **Addressable** (30000 ≤ kind < 40000) — identified by `pubkey+kind+d-tag`; only the latest per combo is kept. Long-form content, products, definitions.
|
||||
|
||||
Kinds below 1000 are "legacy"; storage is per-kind (e.g. kind 1 is regular, kind 3 is replaceable).
|
||||
|
||||
## Tag Design Principles
|
||||
|
||||
- **Kind = schema, tags = semantics.** Don't mint a new kind just to represent a different category of the same data.
|
||||
- **Relays only index single-letter tags.** Use `t` for categories so filters like `'#t': ['electronics']` work at the relay level. Multi-letter tags (`product_type`, etc.) force inefficient client-side filtering.
|
||||
- **Filter at the relay**, not in JavaScript:
|
||||
|
||||
```ts
|
||||
// ❌ Fetch everything, filter locally
|
||||
const events = await nostr.query([{ kinds: [30402] }]);
|
||||
const filtered = events.filter((e) => hasTag(e, 'product_type', 'electronics'));
|
||||
|
||||
// ✅ Filter at the relay
|
||||
const events = await nostr.query([{ kinds: [30402], '#t': ['electronics'] }]);
|
||||
```
|
||||
|
||||
- **For Ditto-specific niches** (community apps, regional variants), tag events with a `t` value and query on it. Don't do this for generic platforms — it would silo content.
|
||||
|
||||
## Content vs. Tags
|
||||
|
||||
- **`content`** — large freeform text or existing industry-standard JSON (GeoJSON, FHIR, Tiled maps). Kind 0 is the one exception where structured JSON goes in content.
|
||||
- **Tags** — queryable metadata, structured data, anything you might filter on.
|
||||
- **Empty content is fine.** `content: ""` is idiomatic for tag-only events.
|
||||
- **If you need to filter by a field, it must be a tag** — relays don't index content.
|
||||
|
||||
```json
|
||||
// ✅ Queryable
|
||||
{ "kind": 30402, "content": "",
|
||||
"tags": [["d", "product-123"], ["title", "Camera"], ["price", "250"], ["t", "photography"]] }
|
||||
|
||||
// ❌ Structured data buried in content
|
||||
{ "kind": 30402, "content": "{\"title\":\"Camera\",\"price\":250}", "tags": [["d", "product-123"]] }
|
||||
```
|
||||
|
||||
## `NIP.md`
|
||||
|
||||
`NIP.md` documents Ditto's custom kinds and any extensions to existing NIPs. Whenever you mint a new kind or change a custom schema, **create or update `NIP.md`** with the tag list, content format, and intended usage. If a kind you add is effectively the same shape as an existing NIP, note the NIP reference rather than duplicating the spec.
|
||||
|
||||
Standard NIPs (like NIP-84 Highlights, NIP-23 Articles) do **not** go in `NIP.md` — only Ditto-custom kinds and Ditto-specific extensions.
|
||||
|
||||
## After Designing — What's Next?
|
||||
|
||||
Once you've settled on a kind number and tag shape, you still need to render it in Ditto's UI. Load the **`nostr-kind-rendering`** skill for the full multi-location registration checklist (feed cards, detail pages, embedded previews, kind-label maps, notifications, feed-toggle registration).
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
name: nostr-kind-rendering
|
||||
description: Add UI rendering for an event kind Ditto doesn't yet display — feed cards, detail pages, embedded previews, notifications, routes, feed-toggle registration, and the several kind-label maps (KIND_LABELS, KIND_HEADER_MAP, NOTIFICATION_KIND_NOUNS, CommentContext) that must stay in sync. Load when asked to "support / display / render" a NIP or kind number, when a kind renders blank or as "Kind 12345", or when quote embeds of a kind show "This event kind is not supported".
|
||||
---
|
||||
|
||||
# Nostr Kinds — UI Rendering Checklist
|
||||
|
||||
Ditto's kind dispatch is **spread across many files** by design — feed cards, detail pages, embedded previews, notifications, and several kind-label maps each have their own rendering requirements. The central `KIND_LABELS` registry covers the easy cases, but most context-specific maps (grammar, icons, verbs) cannot be derived mechanically and must be updated manually.
|
||||
|
||||
**Missing any location causes visible bugs**: a kind might render blank in quote posts, show "Kind 12345" as a label, skip its action header, tombstone as "This event kind is not supported" in embeds, or — worst of all — have its content fed through the kind-1 tokenizer and auto-linkify URLs/hashtags that weren't authored by the event creator.
|
||||
|
||||
**When in doubt, grep for an existing kind number like `30617` or `9802`** — you'll find every registration point you need to mirror.
|
||||
|
||||
## Decision: Feed-toggle + dedicated page, or just rendering?
|
||||
|
||||
Before touching code, pick one:
|
||||
|
||||
- **Just render it everywhere Nostr content appears** (no feed toggle, no dedicated page). Use when the kind is niche or only reached via direct links / quote embeds. Minimal surface — steps 1–6 below.
|
||||
- **Add a feed toggle + optional dedicated page.** Use when users should be able to browse events of this kind or opt them in/out of their home feed. Requires the feed registration (step 7) and `AppConfig` triple (step 8).
|
||||
|
||||
When the user asks generally to "support" a kind, ask which direction they want if it's not obvious from context.
|
||||
|
||||
## Checklist
|
||||
|
||||
### 1. Content card component (`src/components/`)
|
||||
|
||||
Create `<MyKindCard event={event} />` that renders the event's tags/content appropriately.
|
||||
|
||||
- **Never run event content through the kind-1 tokenizer** (`<NoteContent>` / `<TruncatedNoteContent>`) unless the kind's content is actually free-form user prose. Quote-type content (highlights, snippets, citations) contains URLs and hashtags from the *source*, not the event author — tokenizing them is misleading.
|
||||
- Render plaintext with `whitespace-pre-wrap break-words` inside a `<p>` instead.
|
||||
- Route any event-sourced URLs (`r` tags, media URLs, source links) through `sanitizeUrl()` from `@/lib/sanitizeUrl` before using them in `href`/`src`.
|
||||
- Support an `expanded` prop if the card looks different on the detail page than in the feed.
|
||||
|
||||
### 2. Feed card dispatch (`src/components/NoteCard.tsx`)
|
||||
|
||||
Three edits in this file:
|
||||
|
||||
1. **Flag block** (around lines 384–435): add `const isMyKind = event.kind === XXXX;`.
|
||||
2. **`isTextNote` negation list** (around lines 440–475): add `&& !isMyKind`. Without this, unknown kinds fall through to `UnknownKindContent` (showing only the `alt` tag).
|
||||
3. **Content dispatch ternary** (around lines 578–692): add `) : isMyKind ? (<MyKindCard event={event} />`.
|
||||
4. **`KIND_HEADER_MAP`** (around lines 1710+): add an entry so the feed shows "Alice shared a *noun*" or similar. Pattern:
|
||||
```ts
|
||||
9802: {
|
||||
icon: Highlighter,
|
||||
action: "shared a",
|
||||
noun: "highlight",
|
||||
nounRoute: "/highlights", // omit if no dedicated page
|
||||
},
|
||||
```
|
||||
5. Import the card component and any new lucide icons.
|
||||
|
||||
### 3. Detail page dispatch (`src/pages/PostDetailPage.tsx`)
|
||||
|
||||
Mirror the three NoteCard edits:
|
||||
|
||||
1. **Flag block** (around lines 1021–1098): `const isMyKind = event.kind === XXXX;`.
|
||||
2. **`isTextNote` negation list**: add `&& !isMyKind`.
|
||||
3. **Content dispatch ternary** (around lines 2147–2251): add `) : isMyKind ? (<MyKindCard event={event} expanded />`.
|
||||
|
||||
The loading-state title uses `shellTitleForKind()`, which falls through to `KIND_LABELS` — no override needed unless the kind belongs to a group ("Music Details") or needs composite grammar.
|
||||
|
||||
### 4. Central kind label (`src/lib/kindLabels.ts`)
|
||||
|
||||
Add a **capitalized noun phrase, no articles** to the `KIND_LABELS` map:
|
||||
|
||||
```ts
|
||||
9802: 'Highlight',
|
||||
```
|
||||
|
||||
This is consumed by the detail-page loading title, nsite permission prompt, signer nudge toasts, and addressable-event preview headers. Ignoring this gives "Kind 9802" everywhere it appears.
|
||||
|
||||
### 5. Context-specific label and icon maps
|
||||
|
||||
Each of these maps exists because the surrounding UI needs a different grammatical form. They are **not derived** from `KIND_LABELS` and must be updated manually.
|
||||
|
||||
- **`src/components/CommentContext.tsx`** — `KIND_LABELS` (uses articles: `'a highlight'`, `'an article'`) and `KIND_ICONS` (lucide component reference). Rendered as "Commenting on {label}". Without an entry you get "an unsupported event".
|
||||
- **`src/pages/NotificationsPage.tsx`** — `NOTIFICATION_KIND_NOUNS` (bare lowercase nouns: `'highlight'`, `'article'`). Rendered as "reacted to your {noun}". Without an entry you get "post" as a fallback.
|
||||
- **`src/components/NoteCard.tsx`** — `KIND_HEADER_MAP` (already covered in step 2).
|
||||
|
||||
### 6. Embedded previews (`src/components/EmbeddedNote.tsx`)
|
||||
|
||||
The quote-embed dispatcher in `EmbeddedNote` (around lines 65–110) routes kinds to dedicated compact cards. **Without a branch here, non-content kinds fall through to `EmbeddedNoteCard`, which either:**
|
||||
|
||||
- Shows only the NIP-31 `alt` tag (if present), or
|
||||
- Tombstones as "This event kind is not supported", or
|
||||
- **Feeds the event's `content` through the kind-1 tokenizer** if the kind is mistakenly treated as a content-kind — auto-linkifying URLs and hashtags that weren't authored by the event creator. This is a security/UX bug.
|
||||
|
||||
For any kind whose `content` isn't freeform user prose, add an explicit dispatch branch even if it just renders a minimal compact card. Pattern:
|
||||
|
||||
```tsx
|
||||
if (event.kind === 9802) {
|
||||
return <EmbeddedHighlightCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
```
|
||||
|
||||
Then define the compact card using `EmbeddedCardShell` for the author row + navigation, and render the kind-specific body inside. See `EmbeddedHighlightCard` and `EmbeddedBadgeAwardCard` for reference.
|
||||
|
||||
`src/components/EmbeddedNaddr.tsx` works similarly for addressable kinds — add a branch there if your kind is addressable.
|
||||
|
||||
### 7. Feed/sidebar registration (`src/lib/extraKinds.ts`)
|
||||
|
||||
Only needed if you decided on "feed-toggle + dedicated page" above. Add an `ExtraKindDef`:
|
||||
|
||||
```ts
|
||||
{
|
||||
kind: 9802,
|
||||
id: 'highlights',
|
||||
showKey: 'showHighlights',
|
||||
feedKey: 'feedIncludeHighlights',
|
||||
label: 'Highlights',
|
||||
description: 'Noteworthy excerpts from articles, posts, and the web (NIP-84)',
|
||||
route: 'highlights', // omit for feed-only registration
|
||||
addressable: false,
|
||||
section: 'social', // feed | media | social | development | whimsy
|
||||
blurb: 'Longer marketing copy shown in the info modal.',
|
||||
},
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
- **Sidebar icon** (`src/lib/sidebarItems.tsx`) — add `{ id: "highlights", label: "Highlights", path: "/highlights", icon: Highlighter }` to `SIDEBAR_ITEMS`, and import the icon at the top. `CONTENT_KIND_ICONS` picks up the icon automatically from the sidebar definition.
|
||||
- **Route** (`src/AppRouter.tsx`) — add `const highlightsDef = getExtraKindDef("highlights")!;` at the top of the file and a `<Route path="/highlights" element={<KindFeedPage kind={highlightsDef.kind} title={highlightsDef.label} icon={sidebarItemIcon("highlights", "size-5")} />} />` above the catch-all `*` route.
|
||||
|
||||
### 8. `AppConfig` triple (required if you added feed/sidebar toggle keys in step 7)
|
||||
|
||||
Three files must stay in sync, or the build fails or the setting silently no-ops:
|
||||
|
||||
1. **`src/contexts/AppContext.ts`** — add the fields to the `FeedSettings` interface with JSDoc comments.
|
||||
2. **`src/lib/schemas.ts`** — add the same fields to `FeedSettingsSchema` as `z.boolean().optional()`. `DittoConfigSchema` is derived from `AppConfigSchema` with `.strict()` mode, so any `ditto.json` field missing from Zod is a build error.
|
||||
3. **`src/App.tsx`** — add the default value in the initial `feedSettings` block.
|
||||
4. **`src/test/TestApp.tsx`** — mirror the default in test config so component tests work.
|
||||
|
||||
Convention: `show*` toggles default to `true` (sidebar entries visible), `feedInclude*` toggles default to `false` for niche content, `true` for core feed content.
|
||||
|
||||
### 9. Notification integration (if applicable)
|
||||
|
||||
Load this step when the kind represents an interaction with the user's content (reactions, reposts, highlights, awards, etc.) — i.e. when an event author "does something with" another user's content via an `e`/`a`/`p` tag.
|
||||
|
||||
**Six files** to update:
|
||||
|
||||
1. **`src/hooks/useEncryptedSettings.ts`** — add `highlights?: boolean` (or equivalent) to the `notificationPreferences` object.
|
||||
2. **`src/lib/notificationKinds.ts`** — add the kind to `ALL_NOTIFICATION_KINDS` and add a `if (p.X !== false) kinds.push(XXXX);` line in `getEnabledNotificationKinds`.
|
||||
3. **`src/lib/notificationTemplates.ts`** — add a `NOTIFICATION_TEMPLATES` entry with a title and body for nostr-push server-side notifications.
|
||||
4. **`src/pages/NotificationSettings.tsx`** — extend `NotificationPrefKey` union, add a row to `NOTIFICATION_TYPES` with icon/label/description/kinds.
|
||||
5. **`src/hooks/useNotifications.ts`** — extend `groupKey` (decide if events of this kind group by referenced event or stand alone), and if it's a "did something to your content" kind, add it to the author-ownership filter so users only get notified for interactions with their own content.
|
||||
6. **`src/pages/NotificationsPage.tsx`** — add a case to `GroupedNotificationView`'s switch; write `MyKindNotification` + `MyKindNotificationGroup` components modeled on `RepostNotification` / `LikeNotification`.
|
||||
|
||||
### 10. Spam guards (`src/lib/feedUtils.ts`)
|
||||
|
||||
If the kind has required tags (NIP-spec-mandated references, minimum content, etc.), add a check in `shouldHideFeedEvent` to hide events that don't meet the minimum bar. This pre-filters events before `NoteCard` mounts them, avoiding layout shifts from components that would return `null`.
|
||||
|
||||
Example:
|
||||
|
||||
```ts
|
||||
// NIP-84 highlights with no excerpt AND no source reference.
|
||||
if (event.kind === 9802) {
|
||||
const hasContent = event.content.trim().length > 0;
|
||||
const hasSource = event.tags.some(([n]) => n === 'a' || n === 'e' || n === 'r');
|
||||
if (!hasContent && !hasSource) return true;
|
||||
}
|
||||
```
|
||||
|
||||
### 11. `NIP.md` (custom kinds only)
|
||||
|
||||
If the kind is a Ditto-custom kind or a Ditto-specific extension of an existing NIP, document it in `NIP.md` — see the **`nostr-kind-design`** skill for the format. Standard NIPs (like NIP-84, NIP-23) do not go in `NIP.md`.
|
||||
|
||||
## Validation
|
||||
|
||||
After making changes, run `npm run test` — it runs `tsc --noEmit`, `eslint`, `vitest`, and `vite build` in sequence. All must pass. Additions to the `AppConfig` triple in particular frequently break the build if one of the four files is missed.
|
||||
|
||||
## Why so many locations?
|
||||
|
||||
These are genuinely different UI contexts (feed cards, detail pages, embedded previews, comment-context labels, notifications, sidebar routes) with different rendering requirements and grammar needs. The central `KIND_LABELS` in `src/lib/kindLabels.ts` handles the common "what to call this kind" case, but feed headers, comment-context text, and notification verbs each need their own grammar, and notification integration involves a whole independent subsystem.
|
||||
|
||||
## Bugs that signal a missed step
|
||||
|
||||
- **"Kind 12345" shown as a label** → step 4 (`KIND_LABELS`).
|
||||
- **"an unsupported event" in CommentContext** → step 5 (`CommentContext` maps).
|
||||
- **"reacted to your **post**"** when it should say "highlight" → step 5 (`NOTIFICATION_KIND_NOUNS`).
|
||||
- **No action header above a feed card** → step 2.4 (`KIND_HEADER_MAP`).
|
||||
- **Blank / `alt`-only card in quote embeds** → step 6 (`EmbeddedNote` dispatcher).
|
||||
- **URLs/hashtags in quoted text auto-linkified** → step 6 (embedded dispatcher forgot to bypass the kind-1 tokenizer).
|
||||
- **Kind doesn't appear in the home feed even with the toggle on** → step 7 (`ExtraKindDef` missing `feedKey`).
|
||||
- **Build error mentioning a missing `FeedSettings` field** → step 8 (one of the three files out of sync).
|
||||
- **Users not notified when their content is interacted with** → step 9 (notification stack).
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
name: nostr-publishing
|
||||
description: Publish Nostr events with useNostrPublish. Covers the basic publishing pattern, safely mutating replaceable and addressable events (read-modify-write via fetchFreshEvent + prev), published_at preservation, and d-tag collision prevention for new addressable content.
|
||||
---
|
||||
|
||||
# Publishing Nostr Events
|
||||
|
||||
Use this skill when a feature needs to publish events — notes, reactions, list updates, profile edits, addressable content, etc. Covers the `useNostrPublish` hook, the correct read-modify-write pattern for replaceable/addressable lists, and d-tag collision prevention.
|
||||
|
||||
## The `useNostrPublish` Hook
|
||||
|
||||
`useNostrPublish` publishes an event through the app's connection pool and auto-adds a `client` tag. Always guard calls with `useCurrentUser` — publishing requires a signer.
|
||||
|
||||
```tsx
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
|
||||
export function PostForm() {
|
||||
const { user } = useCurrentUser();
|
||||
const { mutate: createEvent } = useNostrPublish();
|
||||
|
||||
if (!user) return <span>You must be logged in to post.</span>;
|
||||
|
||||
return (
|
||||
<button onClick={() => createEvent({ kind: 1, content: 'hello' })}>
|
||||
Post
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `mutateAsync` over `mutate` when the caller needs to `await` the published event (e.g. to navigate to the new event's page, or to chain another publish).
|
||||
|
||||
## Mutating Replaceable and Addressable Events (CRITICAL)
|
||||
|
||||
Replaceable (kind 10000-19999) and addressable (kind 30000-39999) events require a **read-modify-write** cycle: fetch the current event, modify its tags, publish a new version. **Never read from the TanStack Query cache before mutating** — the cache can be stale from another device or a rapid prior operation, and republishing stale data silently drops the user's data.
|
||||
|
||||
Use `fetchFreshEvent()` from `src/lib/fetchFreshEvent.ts` inside every mutation, and **always pass the fetched event as `prev`** so `useNostrPublish` can preserve `published_at`:
|
||||
|
||||
```typescript
|
||||
import { fetchFreshEvent } from '@/lib/fetchFreshEvent';
|
||||
|
||||
// Inside a mutation function:
|
||||
const prev = await fetchFreshEvent(nostr, {
|
||||
kinds: [10003],
|
||||
authors: [user.pubkey],
|
||||
});
|
||||
const currentTags = prev?.tags ?? [];
|
||||
// ...modify tags...
|
||||
await publishEvent({
|
||||
kind: 10003,
|
||||
content: prev?.content ?? '',
|
||||
tags: newTags,
|
||||
prev: prev ?? undefined,
|
||||
});
|
||||
```
|
||||
|
||||
This applies to all list-type hooks (bookmarks, pins, interests, follow sets, badges, etc.). See `useFollowActions` and `useMuteList` for complete examples.
|
||||
|
||||
### The `prev` Property on Event Templates
|
||||
|
||||
`useNostrPublish` accepts an optional `prev` property on the event template — the **previous version** of the event being replaced. The hook uses it to manage the `published_at` tag (NIP-24) automatically:
|
||||
|
||||
- **First publish (no `prev`)** — `published_at` is set equal to `created_at`.
|
||||
- **Update (`prev` provided)** — `published_at` is preserved from the old event.
|
||||
- **Old event lacks `published_at`** — nothing is fabricated.
|
||||
- **Caller already set `published_at` in tags** — left alone.
|
||||
|
||||
**Convention**: name the local variable `prev` at the call site (not `freshEvent` or `latestEvent`) so it reads naturally when passed to `publishEvent`:
|
||||
|
||||
```typescript
|
||||
const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] });
|
||||
// ...
|
||||
await publishEvent({ kind: 3, content: prev?.content ?? '', tags: newTags, prev: prev ?? undefined });
|
||||
```
|
||||
|
||||
`prev` is stripped from the template before signing — it never appears in the published Nostr event.
|
||||
|
||||
## D-Tag Collision Prevention for Addressable Events
|
||||
|
||||
Addressable events (kind 30000-39999) are identified by `pubkey + kind + d-tag`. Publishing an event with the same d-tag as an existing one **silently replaces** it. This is by design for intentional updates (edit flows), but dangerous when creating *new* content with user-derived d-tags (slugs from titles, user-entered identifiers, etc.).
|
||||
|
||||
### When to check for collisions
|
||||
|
||||
- **Must check** when the d-tag is derived from user input (slugified titles, user-entered identifiers, etc.).
|
||||
- **No check needed** when the d-tag is a `crypto.randomUUID()`, a canonical format with an embedded pubkey prefix, or intentionally the same as an existing event (edit/update flows).
|
||||
|
||||
### Implementation pattern
|
||||
|
||||
Before publishing a **new** addressable event with a user-derived d-tag, query for an existing event with that d-tag. If one exists, block the publish and tell the user to change the identifier.
|
||||
|
||||
```typescript
|
||||
// Before publishing a new addressable event:
|
||||
const slug = slugify(title, { lower: true, strict: true });
|
||||
|
||||
const existing = await nostr.query([
|
||||
{ kinds: [30023], authors: [user.pubkey], '#d': [slug], limit: 1 },
|
||||
]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
toast({
|
||||
title: 'Slug already in use',
|
||||
description: 'Change the slug or edit the existing item.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe to publish
|
||||
publishEvent({ kind: 30023, content, tags: [['d', slug], ...otherTags] });
|
||||
```
|
||||
|
||||
**Skip the check in edit mode** — when the user explicitly loaded an existing event to update, overwriting is the intended behavior.
|
||||
|
||||
Prefer UUID or canonical formats when the d-tag doesn't need to be human-readable. Only use slugified input when the d-tag will appear in URLs or needs to be meaningful to users, and always add a collision check.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: nostr-queries
|
||||
description: Query Nostr events efficiently with useNostr + TanStack Query. Covers the standard useQuery pattern, combining related kinds into a single request to avoid rate limiting, and validating events with required tags or strict schemas.
|
||||
---
|
||||
|
||||
# Querying Nostr Events
|
||||
|
||||
Use this skill when building a hook that fetches Nostr events. Covers the standard `useNostr` + `useQuery` pattern, efficient query design (combining kinds to avoid relay round-trips), and event validation for kinds with required tags.
|
||||
|
||||
## The Standard Pattern
|
||||
|
||||
Combine `useNostr` with TanStack Query in a custom hook. Pass the abort signal from `c.signal` into `nostr.query` so cancelled queries free relay resources:
|
||||
|
||||
```typescript
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
function usePosts() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['posts'],
|
||||
queryFn: async (c) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [1], limit: 20 }],
|
||||
{ signal: c.signal },
|
||||
);
|
||||
return events;
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Transform events into a domain model inside the `queryFn` if needed — callers should rarely see raw `NostrEvent`s. Multiple calls to `nostr.query()` inside one `queryFn` are fine for compound queries that can't be expressed as a single filter.
|
||||
|
||||
## Efficient Query Design
|
||||
|
||||
**Always minimize the number of separate round-trips** to relays. Each query consumes relay capacity and may count against rate limits.
|
||||
|
||||
**✅ Efficient — single query with multiple kinds:**
|
||||
|
||||
```typescript
|
||||
// Query repost variants in one request
|
||||
const events = await nostr.query([{
|
||||
kinds: [1, 6, 16],
|
||||
'#e': [eventId],
|
||||
limit: 150,
|
||||
}]);
|
||||
|
||||
// Separate by kind in JavaScript
|
||||
const notes = events.filter((e) => e.kind === 1);
|
||||
const reposts = events.filter((e) => e.kind === 6);
|
||||
const genericReposts = events.filter((e) => e.kind === 16);
|
||||
```
|
||||
|
||||
**❌ Inefficient — three separate round-trips:**
|
||||
|
||||
```typescript
|
||||
const [notes, reposts, genericReposts] = await Promise.all([
|
||||
nostr.query([{ kinds: [1], '#e': [eventId] }]),
|
||||
nostr.query([{ kinds: [6], '#e': [eventId] }]),
|
||||
nostr.query([{ kinds: [16], '#e': [eventId] }]),
|
||||
]);
|
||||
```
|
||||
|
||||
### Optimization rules
|
||||
|
||||
1. **Combine kinds** into one filter: `kinds: [1, 6, 16]`.
|
||||
2. **Use multiple filter objects** in a single `nostr.query()` call when different tag filters are needed simultaneously.
|
||||
3. **Raise the `limit`** when combining kinds so you still receive enough of each type.
|
||||
4. **Split by kind in JavaScript**, not by making separate requests.
|
||||
5. **Respect relay capacity** — heavy parallel queries can trigger rate limits even when each individually would be fine.
|
||||
|
||||
## Event Validation
|
||||
|
||||
For kinds with required tags or strict schemas (most custom kinds, anything beyond kind 1), filter query results through a validator before returning them. Loose kinds (kind 1 text notes) rarely need validation — all tags are optional and `content` is freeform.
|
||||
|
||||
```typescript
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
// Example validator for NIP-52 calendar events
|
||||
function validateCalendarEvent(event: NostrEvent): boolean {
|
||||
if (![31922, 31923].includes(event.kind)) return false;
|
||||
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
const title = event.tags.find(([n]) => n === 'title')?.[1];
|
||||
const start = event.tags.find(([n]) => n === 'start')?.[1];
|
||||
if (!d || !title || !start) return false;
|
||||
|
||||
// Date-based events require YYYY-MM-DD
|
||||
if (event.kind === 31922 && !/^\d{4}-\d{2}-\d{2}$/.test(start)) return false;
|
||||
|
||||
// Time-based events require a unix timestamp
|
||||
if (event.kind === 31923) {
|
||||
const ts = parseInt(start);
|
||||
if (isNaN(ts) || ts <= 0) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function useCalendarEvents() {
|
||||
const { nostr } = useNostr();
|
||||
return useQuery({
|
||||
queryKey: ['calendar-events'],
|
||||
queryFn: async (c) => {
|
||||
const events = await nostr.query(
|
||||
[{ kinds: [31922, 31923], limit: 20 }],
|
||||
{ signal: c.signal },
|
||||
);
|
||||
return events.filter(validateCalendarEvent);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Validation is a correctness layer, not a security layer. For trust-sensitive queries (admin actions, addressable events, moderator approvals), also constrain `authors` — see the `nostr-security` skill.
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: nostr-relay-pools
|
||||
description: Query or publish to specific Nostr relays or curated relay groups using nostr.relay() and nostr.group(), instead of the default connection pool. Useful for debugging, testing, specialized relays, or geographically-targeted publishing.
|
||||
---
|
||||
|
||||
# Targeted Nostr Relay Connections
|
||||
|
||||
By default, the `nostr` object returned from `useNostr` uses the app's connection pool: it reads from one of the configured relays and publishes to all of them. For most features this is exactly what you want.
|
||||
|
||||
Use this skill when you need **more granular control** — talking to a single relay, a curated group of relays, or debugging a specific relay's behavior.
|
||||
|
||||
## Single Relay: `nostr.relay(url)`
|
||||
|
||||
```ts
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
function useSpecificRelay() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Connect to a specific relay
|
||||
const relay = nostr.relay('wss://relay.damus.io');
|
||||
|
||||
// Query from this relay only
|
||||
const events = await relay.query([{ kinds: [1], limit: 15 }]);
|
||||
|
||||
// Publish to this relay only
|
||||
await relay.event({ kind: 1, content: 'Hello from a specific relay!' });
|
||||
}
|
||||
```
|
||||
|
||||
**Good fits:**
|
||||
|
||||
- Testing a relay's behavior in isolation
|
||||
- Debugging connectivity or rate-limiting issues
|
||||
- Querying content that only lives on a specialized relay (paid relays, private relays, niche communities)
|
||||
- Health checks / admin tooling
|
||||
|
||||
## Relay Group: `nostr.group(urls)`
|
||||
|
||||
```ts
|
||||
import { useNostr } from '@nostrify/react';
|
||||
|
||||
function useRelayGroup() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
// Create a group of specific relays
|
||||
const relayGroup = nostr.group([
|
||||
'wss://relay.damus.io',
|
||||
'wss://relay.primal.net',
|
||||
'wss://nos.lol',
|
||||
]);
|
||||
|
||||
// Query from all relays in the group (deduplicated)
|
||||
const events = await relayGroup.query([{ kinds: [1], limit: 15 }]);
|
||||
|
||||
// Publish to all relays in the group
|
||||
await relayGroup.event({ kind: 1, content: 'Hello from a relay group!' });
|
||||
}
|
||||
```
|
||||
|
||||
**Good fits:**
|
||||
|
||||
- Publishing to a curated set of trusted relays for a specific feature
|
||||
- Community-scoped queries (e.g. a set of relays known to host a particular topic)
|
||||
- Geographic/region-targeted delivery
|
||||
- Load-balancing reads across a known-good subset
|
||||
|
||||
## API Consistency
|
||||
|
||||
Both the `relay` object and the `group` object expose the **same interface** as the top-level `nostr` object:
|
||||
|
||||
- `.query(filters, opts?)` — request events matching filters
|
||||
- `.req(filters, opts?)` — open a streaming subscription
|
||||
- `.event(event)` — publish a signed event
|
||||
- All other Nostrify methods
|
||||
|
||||
This means you can drop them into any existing hook or helper that expects a `nostr`-shaped object.
|
||||
|
||||
## Choosing Between Pool, Group, and Single Relay
|
||||
|
||||
| Scenario | Use |
|
||||
|----------------------------------------------------|---------------------|
|
||||
| Default app queries, best reach for publishing | `nostr` (pool) |
|
||||
| Trusted subset, community-specific publishing | `nostr.group([…])` |
|
||||
| Single-relay debugging or specialized relay access | `nostr.relay(url)` |
|
||||
|
||||
## Tips
|
||||
|
||||
- **Don't hard-code user-facing relay lists.** If a feature should publish to "the user's write relays", read from `AppContext.config.relayMetadata` (NIP-65) instead of hard-coding URLs.
|
||||
- **Compose with TanStack Query.** Wrap `relay.query(...)` / `group.query(...)` inside a `useQuery` hook exactly as you would with the default `nostr` object; the caching layer is identical.
|
||||
- **Handle unreachable relays.** Specific relays can be offline, rate-limited, or slow. Always wrap calls in `try/catch` and respect the abort signal from the query function (`c.signal`).
|
||||
- **Avoid leaking subscriptions.** When using `.req(...)` for streaming, always close the subscription on unmount (`controller.abort()` or the returned disposer).
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: nostr-security
|
||||
description: Threat model and defenses for Ditto as a web Nostr client — why XSS is catastrophic when nsec keys live in localStorage, CSP as defense-in-depth, URL and CSS sanitization for untrusted event data, and author filtering for trust-sensitive queries (admin actions, moderators, addressable events, NIP-72 communities). Load when building trust-boundary features, rendering user-controlled URLs or markup, interpolating event data into CSS, or reviewing the app's security posture.
|
||||
---
|
||||
|
||||
# Nostr Security
|
||||
|
||||
## Threat model
|
||||
|
||||
**Nostr private keys (`nsec`) are stored in plaintext in `localStorage`.** Any JavaScript running on the origin can read them with `localStorage.getItem('nostr-login')`. A successful XSS = instant, silent, irreversible key theft — no rotation, no revocation, permanent impersonation across every Nostr client the user ever touches. External signers (NIP-07 extension, NIP-46 bunker) don't change this: an XSS can still ask the active signer to sign arbitrary events, drain funds via zaps, or scrape DMs as they decrypt.
|
||||
|
||||
**Treat every piece of untrusted data as a script-injection vector** — event tags, `content`, metadata, URL params, relay responses.
|
||||
|
||||
## Defense-in-depth
|
||||
|
||||
**Content Security Policy.** `index.html` ships a restrictive CSP: `default-src 'none'`, `script-src 'self'` (no inline scripts, no `eval`), `base-uri 'self'`, `connect-src 'self' https: wss:`. The one intentional gap is `style-src 'unsafe-inline'` — required by Tailwind/shadcn — which means **CSS injection is not blocked by CSP; sanitization is on you**. When modifying CSP, only narrow it. Never add `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcard sources.
|
||||
|
||||
**Never use `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML`, or `document.write`** with event data, URL params, or any other untrusted string. React's JSX auto-escapes interpolated strings — the moment you bypass that, CSP alone won't save you. If you must render HTML from event data, pipe it through a strict allowlist sanitizer (DOMPurify, already installed) at the parse layer.
|
||||
|
||||
**Sanitize URLs and CSS values** — see §1 and §2.
|
||||
|
||||
## 1. URL sanitization
|
||||
|
||||
Any URL from event tags, `content`, metadata fields (`picture`, `banner`, `website`, `nip05`, etc.), or relay hints is untrusted. Threats beyond `javascript:` XSS: `data:` resource exhaustion / phishing, `http://` IP leaks, relative paths triggering same-origin requests, malformed strings crashing downstream parsers.
|
||||
|
||||
**Use the shipped helper at `src/lib/sanitizeUrl.ts`:**
|
||||
|
||||
```ts
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
// Single URL — returns the normalised href, or undefined if not valid https
|
||||
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
||||
if (url) {
|
||||
// safe to use in any context
|
||||
}
|
||||
|
||||
// Array of URLs — filter out invalid entries
|
||||
const links = getAllTags(event.tags, 'r')
|
||||
.map(([, v]) => sanitizeUrl(v))
|
||||
.filter((v): v is string => !!v);
|
||||
```
|
||||
|
||||
`sanitizeUrl` returns the normalised `href` string only when the URL parses successfully **and** uses the `https:` protocol. All other inputs (malformed URLs, `javascript:`, `data:`, `http:`, relative paths, etc.) return `undefined`.
|
||||
|
||||
**Sanitize at the parse layer.** When writing a parser function that extracts URLs from event tags (e.g. `parseThemeDefinition`, `parseBadgeDefinition`), apply `sanitizeUrl()` before returning the parsed data. This way every downstream consumer is automatically protected without needing to remember to sanitize at each usage site.
|
||||
|
||||
**When sanitization is NOT required:** URLs matched by a regex that constrains the protocol (e.g. `NoteContent`'s tokenizer matching `https?://...` — the regex *is* the sanitizer), hardcoded/app-generated URLs (relay configs, internal routes), and strings rendered as plain text that never land in an attribute, CSS value, or network request.
|
||||
|
||||
## 2. CSS injection
|
||||
|
||||
Event data interpolated into CSS (a `<style>` element, `style=""`, or an injected stylesheet) is a CSS injection vector. A `"`, `)`, `}`, or `;` in the value can break out of the string context and inject rules — overlay phishing, hide UI, exfiltrate via `background-image: url()` requests.
|
||||
|
||||
Common surfaces: `background-image: url("${url}")`, `font-family: "${family}"`, `@font-face { src: url("${url}") }`.
|
||||
|
||||
**Mitigation:**
|
||||
|
||||
- **URLs in `url()`** — use `sanitizeUrl()`. The `URL` constructor percent-encodes `"`, `)`, `\` and rejects non-`https:`. This is already done for theme event background and font URLs in `src/lib/themeEvent.ts`.
|
||||
- **Non-URL strings** (font-family, animation names) — use `sanitizeCssString()` from `src/lib/fontLoader.ts`, which allowlists Unicode letters/numbers, spaces, hyphens, underscores, apostrophes, and periods:
|
||||
|
||||
```ts
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { sanitizeCssString } from '@/lib/fontLoader';
|
||||
|
||||
// ❌ UNSAFE
|
||||
style.textContent = `body { background-image: url("${rawUrl}"); font-family: "${rawFamily}"; }`;
|
||||
|
||||
// ✅ SAFE — validate URLs, allowlist identifiers
|
||||
const bgUrl = sanitizeUrl(rawUrl);
|
||||
const family = sanitizeCssString(rawFamily ?? '');
|
||||
if (bgUrl && family) {
|
||||
style.textContent = `body { background-image: url("${bgUrl}"); font-family: "${family}"; }`;
|
||||
}
|
||||
```
|
||||
|
||||
If you can't justify the exact characters you're allowing, the policy is wrong.
|
||||
|
||||
## 3. Author filtering for trust-sensitive queries
|
||||
|
||||
Even with perfect XSS defenses, an attacker can publish forged events your UI will trust unless queries constrain `authors`. Relays are dumb pipes — any matching event comes back.
|
||||
|
||||
**Filter by `authors` when:**
|
||||
|
||||
- Querying admin/moderator/owner events — use a hardcoded trusted-pubkey list (e.g. `ADMIN_PUBKEYS` from `src/lib/admins`).
|
||||
- Querying addressable events (kinds 30000–39999) — the `d` tag alone is not a trust boundary; the `(kind, pubkey, d)` triple is.
|
||||
- Querying user-owned replaceable events (profile metadata, relay lists, mute lists) — `authors: [userPubkey]`.
|
||||
|
||||
**Do NOT filter by `authors`** for public UGC (kind 1 notes, reactions, zaps, discovery feeds) — anyone can post there by design.
|
||||
|
||||
```ts
|
||||
// ❌ Anyone can publish kind 30078 with this d-tag and self-appoint
|
||||
nostr.query([{ kinds: [30078], '#d': ['pathos-organizers'], limit: 1 }]);
|
||||
|
||||
// ✅ Only trust the admin list
|
||||
nostr.query([{ kinds: [30078], authors: ADMIN_PUBKEYS, '#d': ['pathos-organizers'], limit: 1 }]);
|
||||
```
|
||||
|
||||
**Routes for addressable/replaceable events must include the author** — otherwise the route handler can't construct a secure filter:
|
||||
|
||||
```tsx
|
||||
// ❌ Any pubkey can squat the slug
|
||||
<Route path="/article/:slug" element={<Article />} />
|
||||
// ✅ Filter can include authors
|
||||
<Route path="/article/:npub/:slug" element={<Article />} />
|
||||
```
|
||||
|
||||
### NIP-72 community moderation
|
||||
|
||||
Kind 4550 approvals are only trustworthy if signed by a moderator from the community definition (kind 34550). Two-step query:
|
||||
|
||||
```ts
|
||||
// 1. Fetch community definition — author-filter by the owner.
|
||||
const [community] = await nostr.query([{
|
||||
kinds: [34550], authors: [communityOwnerPubkey], '#d': [communityId], limit: 1,
|
||||
}]);
|
||||
if (!community) return [];
|
||||
|
||||
// 2. Extract moderator pubkeys from `p` tags with role "moderator".
|
||||
const moderators = community.tags
|
||||
.filter(([n, , , role]) => n === 'p' && role === 'moderator')
|
||||
.map(([, pubkey]) => pubkey);
|
||||
|
||||
// 3. Query approvals — only from moderators.
|
||||
const approvals = await nostr.query([{
|
||||
kinds: [4550],
|
||||
authors: moderators,
|
||||
'#a': [`34550:${communityOwnerPubkey}:${communityId}`],
|
||||
limit: 100,
|
||||
}]);
|
||||
```
|
||||
|
||||
Without step 3's `authors` filter, anyone can publish a kind 4550 "approval".
|
||||
|
||||
## Pre-merge checklist
|
||||
|
||||
- [ ] No `dangerouslySetInnerHTML` / `innerHTML` / `document.write` with untrusted data.
|
||||
- [ ] CSP unchanged or narrowed; no new `'unsafe-eval'`, `'unsafe-inline'` on `script-src`, `http:`, or wildcards.
|
||||
- [ ] Every event-sourced URL passes `sanitizeUrl()` before reaching `href`, `src`, `srcSet`, `poster`, iframe `src`, or CSS.
|
||||
- [ ] Every event-sourced string in CSS passes `sanitizeUrl()` (URLs) or `sanitizeCssString()` (identifiers).
|
||||
- [ ] Every trust-sensitive query includes `authors`.
|
||||
- [ ] Routes for addressable/replaceable events carry the author in the URL.
|
||||
@@ -87,6 +87,8 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
```markdown
|
||||
## [X.Y.Z] - YYYY-MM-DD
|
||||
|
||||
A short single-paragraph summary of this release written in plain prose -- max 500 characters. This appears on the App Store, Google Play, and the in-app "what's new" toast.
|
||||
|
||||
### Added
|
||||
- Description of new features
|
||||
|
||||
@@ -100,7 +102,100 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Description of removed features
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
#### The Summary Paragraph
|
||||
|
||||
Every release section MUST start with a single plaintext paragraph (not a bullet, not a heading) that summarises the release for app-store-style audiences:
|
||||
|
||||
- **Single paragraph, plain prose.** No bullets, no headings, no Markdown formatting beyond plain text.
|
||||
- **Max ~500 characters.** Apple App Store and Google Play both cap "What's new" text at 500. The CI `release-notes` job warns when the summary is longer.
|
||||
- **Audience: end users discovering the update.** Describe the most noticeable user-visible changes; omit internal cleanups even if they're in the bullets below.
|
||||
- **Tone matches the bullets.** Present-tense, no Nostr jargon, no NIP/kind numbers (see Rules below).
|
||||
- **Maintenance releases** -- write a one-sentence summary like `A behind-the-scenes maintenance release with no user-facing changes.` Don't leave it blank; the CI fallback `Ditto vX.Y.Z` is a last resort for legacy entries, not new ones.
|
||||
|
||||
The same paragraph is used in three places automatically:
|
||||
- **App Store** -- "What's New in This Version" via fastlane `deliver`
|
||||
- **Google Play** -- "What's new in this version" via fastlane `supply` `metadata/android/<lang>/changelogs/<versionCode>.txt`
|
||||
- **In-app toast** -- the `What's new in vX.Y.Z` toast that fires when users load a new version (see `src/components/VersionCheck.tsx`)
|
||||
- The full section (summary + lists) goes into the GitLab Release description.
|
||||
|
||||
Extraction is handled by `scripts/extract-release-notes.mjs`; you don't have to write store-specific copy.
|
||||
|
||||
#### Changelog Quality Checklist
|
||||
|
||||
Before drafting any entries, run through this checklist. It is NOT optional -- skipping steps here is the most common way a release goes out with misleading notes.
|
||||
|
||||
##### 5.1. Diff the code, not just the commit log
|
||||
|
||||
Commit messages describe intent at the moment of commit; they over- and under-represent the cumulative effect at release time. Before drafting entries, **run a real diff** for each area of substantial change:
|
||||
|
||||
```bash
|
||||
# Full diff between tags
|
||||
git diff v<prev>..HEAD
|
||||
|
||||
# Or narrowed to an area you're unsure about
|
||||
git diff v<prev>..HEAD -- src/components/ComposeBox.tsx
|
||||
```
|
||||
|
||||
Only the diff reveals intra-release churn (commits that cancel each other out, bugs introduced and then fixed, refactors that land and get reverted). Reading commit messages alone is insufficient.
|
||||
|
||||
##### 5.2. Trace every candidate "Fixed" entry to its origin commit
|
||||
|
||||
For each bug fix you're considering listing, find the commit that introduced the bug.
|
||||
|
||||
**Fast path -- check for `Regression-of:` trailers** (see AGENTS.md "Attributing Regressions"). If the fix commit declares its origin in a trailer, you don't need to hunt:
|
||||
|
||||
```bash
|
||||
# List all commits in the release window with their Regression-of trailers (if any)
|
||||
git log v<prev>..HEAD --no-merges \
|
||||
--format='%h %s%n Regression-of: %(trailers:key=Regression-of,valueonly,separator=%x20)'
|
||||
```
|
||||
|
||||
For each `Regression-of: <sha>` entry, check whether `<sha>` is also in the release window:
|
||||
|
||||
```bash
|
||||
# Returns 0 if <sha> is BEFORE v<prev> (pre-existing bug -> legit "Fixed" entry)
|
||||
# Returns non-zero if <sha> is AFTER v<prev> (intra-release -> omit from "Fixed")
|
||||
git merge-base --is-ancestor <sha> v<prev>
|
||||
```
|
||||
|
||||
**Fallback -- manual tracing** (when no trailer is present):
|
||||
|
||||
```bash
|
||||
# Show the history of a file across all commits
|
||||
git log --oneline v<prev>..HEAD -- path/to/file.tsx
|
||||
|
||||
# Or blame the specific lines the fix touched
|
||||
git blame -L <start>,<end> -- path/to/file.tsx
|
||||
```
|
||||
|
||||
**If the introducing commit is also in this release window (i.e. after the previous tag), the bug is intra-release.** The user on the previous version never experienced it. Do NOT list it as a "Fixed" entry. Fold it into the relevant "Added" or "Changed" entry, or omit it entirely.
|
||||
|
||||
##### 5.3. The "Would a user on the previous version notice this?" test
|
||||
|
||||
The changelog describes the delta between the previous release and this one **from the user's perspective** -- not the development history. Before writing each entry, ask:
|
||||
|
||||
> "Did a user on the previous published version experience this exact thing?"
|
||||
|
||||
- If they experienced a broken state that is now fixed: **"Fixed" entry**
|
||||
- If they experienced the old behavior and now see new behavior: **"Changed" or "Added" entry**
|
||||
- If they never saw either state (introduced AND resolved within this release window): **omit entirely**
|
||||
|
||||
This applies to more than just bugs:
|
||||
- A feature added and then reverted in the same release: omit both
|
||||
- A refactor that was done and then undone: omit both
|
||||
- A performance regression introduced and then fixed: omit both
|
||||
- A typo introduced in a new string and then corrected: mention the new string (if user-facing) as a single "Added"/"Changed" entry, with no "Fixed" entry
|
||||
|
||||
##### 5.4. Worked example -- intra-release bug
|
||||
|
||||
> **Scenario:** Commit A overhauls the compose box and, as a side effect, breaks the background of the expanded emoji picker. Commit B, later in the same release window, restores the background.
|
||||
>
|
||||
> **Correct changelog:** One "Added" entry describing the compose box overhaul. The emoji picker background is part of the finished state the user receives.
|
||||
>
|
||||
> **Incorrect changelog:** An "Added" entry for the overhaul AND a "Fixed" entry for the emoji picker background. The user on the previous version never saw the broken background; listing it invents a problem they didn't have and makes the release notes read like a developer changelog.
|
||||
|
||||
#### Rules
|
||||
|
||||
- Only include categories that have entries (omit empty categories)
|
||||
- Write **user-facing descriptions**, not raw commit messages
|
||||
- Keep descriptions concise -- one line per change
|
||||
@@ -109,9 +204,9 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading
|
||||
- Focus on what the user sees/experiences, not internal implementation details
|
||||
- Use the current date in YYYY-MM-DD format
|
||||
- **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers.
|
||||
- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Only ship what the user sees.** If a bug was introduced AND fixed within this release, the user never saw it -- omit the fix entirely (or fold the net result into the relevant Added/Changed entry). The same applies to features that were added and reverted, refactors that cancel out, and any other intra-release churn. See the Changelog Quality Checklist above (especially 5.2 and 5.3) for the procedure to verify this.
|
||||
- **Collapse related work into one entry.** If a feature was added and then tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history.
|
||||
- **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers.
|
||||
- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes.
|
||||
|
||||
### Step 6: Update Version in All Files
|
||||
|
||||
@@ -190,8 +285,12 @@ git push origin main vX.Y.Z
|
||||
|
||||
This triggers the GitLab CI pipeline which will:
|
||||
1. Build a signed Android APK and AAB
|
||||
2. Create a GitLab Release with download links
|
||||
3. Publish the APK to Zapstore
|
||||
2. Build a signed iOS IPA on the self-hosted Mac runner
|
||||
3. Extract release notes (full body + summary paragraph) from `CHANGELOG.md`
|
||||
4. Create a GitLab Release with APK / AAB / IPA download links
|
||||
5. Publish the APK to Zapstore
|
||||
6. Publish the AAB to Google Play (production track) with the summary as the "What's new" text
|
||||
7. Submit the iOS IPA to App Store Connect for review with the summary as the "What's New" text
|
||||
|
||||
### Step 12: Confirm
|
||||
|
||||
@@ -212,11 +311,15 @@ After pushing, inform the user:
|
||||
|
||||
## CI Pipeline
|
||||
|
||||
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs three jobs:
|
||||
The CI pipeline (`.gitlab-ci.yml`) is triggered by tags matching the pattern `/^v\d+\.\d+\.\d+$/` (e.g., `v2.1.0`). It runs seven jobs:
|
||||
|
||||
1. **build-apk**: Builds signed Android APK and AAB, stamps `versionName` and `versionCode` into the build
|
||||
2. **release**: Creates a GitLab Release with the changelog content and download links
|
||||
3. **publish-zapstore**: Publishes the APK to Zapstore
|
||||
2. **build-ipa**: Builds the signed App Store IPA on the self-hosted Mac runner (`tags: [macos]`); stamps `MARKETING_VERSION` and `CFBundleVersion` into the Xcode project. The IPA is uploaded to GitLab's Generic Packages registry and exposed as a CI artifact for downstream jobs
|
||||
3. **release-notes**: Extracts the version's changelog section and summary paragraph from `CHANGELOG.md` into two artifacts (`release-notes.md` and `release-notes-summary.txt`) consumed by `release`, `publish-app-store`, and `publish-google-play`
|
||||
4. **release**: Creates a GitLab Release with the full changelog section and APK / AAB / IPA download links
|
||||
5. **publish-zapstore**: Publishes the APK to Zapstore
|
||||
6. **publish-google-play**: Uploads the AAB to Google Play production track and writes the release summary to `metadata/android/en-US/changelogs/<versionCode>.txt`
|
||||
7. **publish-app-store**: Submits the prebuilt IPA to App Store Connect for review with the release summary as the "What's New" text. Runs on the self-hosted Mac runner (`tags: [macos]`) because `fastlane deliver` shells out to Apple's iTMSTransporter to upload the IPA, and that tool only ships inside Xcode — the previous Linux runner crashed at the upload step with `No such file or directory @ dir_chdir0` because `Helper.itms_path` resolved to a missing Xcode path. The build appears in App Store Connect within ~30 minutes; Apple's human review then takes 24-48 hours typically. Once approved, you must release manually in App Store Connect (`automatic_release: false`) — this is the final human gate. For runner operations, match cert rotation, and debugging, load the **`mac-runner`** skill.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: testing
|
||||
description: Write Vitest unit tests for React components and hooks using the project's `TestApp` wrapper, jsdom environment, and pre-mocked browser APIs (localStorage, matchMedia, scrollTo, IntersectionObserver, ResizeObserver). Also covers the project policy on when to create new test files.
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
Load this skill when the user asks you to write a test, diagnose a bug with a test, or add coverage for a component/hook. Running the existing test script is a standing requirement (see `AGENTS.md` → *Validating Your Changes*) and doesn't require this skill.
|
||||
|
||||
## Policy: when to create new test files
|
||||
|
||||
**Do not create new test files unless one of these applies:**
|
||||
|
||||
1. The user explicitly asks for tests.
|
||||
2. The user describes a specific bug and asks for tests to diagnose it.
|
||||
3. The user says a problem persists after you tried to fix it.
|
||||
|
||||
Never write tests because tool results show failures, because you think tests would be helpful, or because you added a new feature. The request must come from the user.
|
||||
|
||||
If none of the above apply, stop — don't create a test file. Keep running the existing test script as usual.
|
||||
|
||||
## Test setup
|
||||
|
||||
The project uses **Vitest + jsdom** with **React Testing Library** and **jest-dom** matchers. Global setup lives in `src/test/setup.ts` and mocks these browser APIs that jsdom doesn't provide (or that Node's built-ins conflict with):
|
||||
|
||||
- `localStorage` — a Map-backed mock, because Node 22's built-in `localStorage` lacks the Web Storage API surface jsdom expects
|
||||
- `window.matchMedia`
|
||||
- `window.scrollTo`
|
||||
- `IntersectionObserver`
|
||||
- `ResizeObserver`
|
||||
|
||||
If your component needs another browser API, extend `src/test/setup.ts` rather than mocking per-file.
|
||||
|
||||
## Writing a component test
|
||||
|
||||
Wrap rendered components in `TestApp` (`src/test/TestApp.tsx`) so all context providers — `UnheadProvider`, `AppProvider`, `QueryClientProvider`, `NostrLoginProvider`, `NostrProvider`, `BrowserRouter`, etc. — are available. Without it, hooks like `useQuery`, `useNostr`, `useAppContext`, or `useNavigate` will throw.
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { TestApp } from '@/test/TestApp';
|
||||
import { MyComponent } from './MyComponent';
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<TestApp><MyComponent /></TestApp>);
|
||||
expect(screen.getByText('Expected text')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Writing a hook test
|
||||
|
||||
Use `renderHook` from `@testing-library/react` and pass `TestApp` as the `wrapper`:
|
||||
|
||||
```tsx
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { TestApp } from '@/test/TestApp';
|
||||
import { useMyHook } from './useMyHook';
|
||||
|
||||
describe('useMyHook', () => {
|
||||
it('returns expected data', async () => {
|
||||
const { result } = renderHook(() => useMyHook(), { wrapper: TestApp });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
expect(result.current.data).toBeDefined();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Files placed next to the code under test with the `.test.ts` / `.test.tsx` suffix are picked up automatically. For reference, see `src/test/ErrorBoundary.test.tsx`.
|
||||
|
||||
## Running tests
|
||||
|
||||
The `npm test` script runs `tsc --noEmit`, `eslint`, `vitest run`, and `vite build` in sequence. Always run it after changes — a passing test file alone doesn't mean your task is done.
|
||||
|
||||
For fast iteration, run just Vitest:
|
||||
|
||||
```bash
|
||||
npx vitest run
|
||||
```
|
||||
|
||||
Or in watch mode while editing:
|
||||
|
||||
```bash
|
||||
npx vitest
|
||||
```
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: theming
|
||||
description: Customize Ditto's visual design — install Google Fonts via @fontsource, change the color scheme, configure light/dark themes, and apply consistent component styling patterns with Tailwind and CSS variables.
|
||||
---
|
||||
|
||||
# Theming, Fonts, and Color Schemes
|
||||
|
||||
Use this skill when the user wants to change fonts, colors, light/dark appearance, or general visual styling. Ditto ships with a light/dark theme system built on CSS custom properties and Tailwind v3, plus a `useTheme` hook for runtime switching.
|
||||
|
||||
## Adding Fonts
|
||||
|
||||
Any Google Font can be installed via the `@fontsource` / `@fontsource-variable` packages.
|
||||
|
||||
1. **Install the font package.** Prefer the variable version when available.
|
||||
```bash
|
||||
npm install @fontsource-variable/inter
|
||||
```
|
||||
Package naming:
|
||||
- `@fontsource-variable/<font-name>` — variable fonts (preferred; one file, all weights)
|
||||
- `@fontsource/<font-name>` — static fonts
|
||||
|
||||
2. **Import the font once** in `src/main.tsx`:
|
||||
```ts
|
||||
import '@fontsource-variable/inter';
|
||||
```
|
||||
|
||||
3. **Register the family** in `tailwind.config.ts`:
|
||||
```ts
|
||||
export default {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter Variable', 'Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### Suggested families by use case
|
||||
|
||||
- **Modern / Clean:** Inter Variable, Outfit Variable, Manrope
|
||||
- **Professional / Corporate:** Roboto, Open Sans, Source Sans Pro
|
||||
- **Creative / Artistic:** Poppins, Nunito, Comfortaa
|
||||
- **Monospace / Code:** JetBrains Mono, Fira Code, Source Code Pro
|
||||
|
||||
For expressive hierarchies, pair a sans body font with a display/serif heading font (e.g. Inter + Playfair Display) and expose the second family as `fontFamily.serif` or `fontFamily.display` in Tailwind.
|
||||
|
||||
### Runtime font loading from Nostr events
|
||||
|
||||
Ditto also supports loading fonts referenced from Nostr events (theme events, letter stationery, etc.) through `src/lib/fontLoader.ts`. That path is separate from the build-time `@fontsource` approach — it constructs `@font-face` rules at runtime from sanitized URLs. Never feed event data through the `@fontsource` path; always go through `fontLoader` so the URL and family name are passed through `sanitizeUrl()` and `sanitizeCssString()` (see the `nostr-security` skill).
|
||||
|
||||
## Color Schemes
|
||||
|
||||
Colors are defined as CSS custom properties in `src/index.css` under two selectors:
|
||||
|
||||
- `:root` — light-mode values
|
||||
- `.dark` — dark-mode overrides
|
||||
|
||||
When the user requests a new color scheme:
|
||||
|
||||
1. **Update both `:root` and `.dark`** in `src/index.css`. Each variable is an HSL triplet (no `hsl()` wrapper), e.g. `--primary: 222 47% 11%;`.
|
||||
2. **Keep contrast ratios ≥ 4.5:1** for body text and interactive elements. Test both modes.
|
||||
3. **Prefer extending Tailwind's palette** (`tailwind.config.ts`) over hard-coding hex values in components — this keeps the theme consistent and dark-mode-friendly.
|
||||
4. **Apply colors through semantic tokens** (`bg-primary`, `text-muted-foreground`, `border-input`) rather than raw palette names when possible, so future theme changes propagate.
|
||||
|
||||
The shadcn/ui components consume these semantic tokens, so changing the variables automatically restyles the entire component library.
|
||||
|
||||
## Light/Dark Theme Switching
|
||||
|
||||
Ditto includes:
|
||||
|
||||
- **`useTheme` hook** (`src/hooks/useTheme.ts`) — read and set the current theme programmatically.
|
||||
- **CSS custom properties** in `src/index.css` — one set in `:root`, dark overrides in `.dark`.
|
||||
- **Automatic persistence** via the `AppContext` config (`config.theme`), saved to local storage.
|
||||
|
||||
To add a theme toggle:
|
||||
|
||||
```tsx
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Component Styling Patterns
|
||||
|
||||
- **Class merging:** use the `cn()` utility (`@/lib/utils`) to combine conditional classes and override defaults without class-order bugs.
|
||||
- **Variants:** follow shadcn/ui's `class-variance-authority` pattern for component variants (`variant`, `size`). Copy an existing `ui/` component as a template.
|
||||
- **Responsive design:** lean on Tailwind breakpoints (`sm:`, `md:`, `lg:`) rather than JS media queries. Use `useIsMobile` only when layout must change based on JS-measured viewport.
|
||||
- **Interactive states:** always define `hover:`, `focus-visible:`, and `disabled:` states for clickable elements. Focus rings should use `ring-ring` / `ring-offset-background` so they pick up theme colors.
|
||||
- **Spacing:** an 8px grid (Tailwind's default 4-based scale) keeps visual rhythm consistent. Common paddings: `p-4`, `p-6`; gaps: `gap-2`, `gap-4`.
|
||||
- **Depth:** soft shadows (`shadow-sm`, `shadow-md`), subtle gradients, and `rounded-lg` / `rounded-xl` corners match Ditto's aesthetic. Avoid heavy drop shadows.
|
||||
|
||||
### Negative z-index gotcha
|
||||
|
||||
When placing decorative elements behind content with `-z-10` (e.g. blurred background gradients), **add `isolate` to the parent container**. Without `isolate`, the negative z-index escapes the local stacking context and the element disappears behind the page's background color.
|
||||
|
||||
```tsx
|
||||
<section className="relative isolate">
|
||||
<div className="absolute inset-0 -z-10 bg-gradient-to-br from-primary/20 to-transparent" />
|
||||
{/* content */}
|
||||
</section>
|
||||
```
|
||||
|
||||
## Design Quality Checklist
|
||||
|
||||
Before finishing a visual change, verify:
|
||||
|
||||
- [ ] Both light and dark modes look correct — no hard-coded colors, all text readable.
|
||||
- [ ] Contrast ratios meet WCAG AA (≥ 4.5:1 for body, ≥ 3:1 for large text).
|
||||
- [ ] Interactive elements have visible `hover`, `focus-visible`, and `disabled` states.
|
||||
- [ ] Layout is responsive down to ~360px width without horizontal scroll.
|
||||
- [ ] Animations respect `prefers-reduced-motion` (Tailwind: `motion-safe:` / `motion-reduce:`).
|
||||
- [ ] Spacing is consistent — no one-off `p-[13px]` style values.
|
||||
@@ -3,5 +3,9 @@ VITE_PLAUSIBLE_DOMAIN="example.tld"
|
||||
VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event"
|
||||
# Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey")
|
||||
VITE_NOSTR_PUSH_PUBKEY=""
|
||||
# Canonical origin used when generating shareable URLs (QR codes, copy-link, remote-login callbacks).
|
||||
# Primarily useful for native (Capacitor) builds, where window.location.origin is capacitor://localhost.
|
||||
# Example: VITE_SHARE_ORIGIN="https://ditto.pub"
|
||||
VITE_SHARE_ORIGIN=""
|
||||
# Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain)
|
||||
# ALLOWED_HOSTS="*"
|
||||
@@ -11,6 +11,8 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.eslintcache
|
||||
.tsbuildinfo
|
||||
yarn.lock
|
||||
deploy.sh
|
||||
|
||||
|
||||
+193
-14
@@ -77,6 +77,39 @@ build-web:
|
||||
paths:
|
||||
- dist/
|
||||
|
||||
release-notes:
|
||||
stage: build
|
||||
timeout: 2 minutes
|
||||
needs: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
# Extract release notes from CHANGELOG.md for this tag.
|
||||
# release-notes.md is the full section (summary + bulleted lists), used as
|
||||
# the GitLab Release description. release-notes-summary.txt is the leading
|
||||
# plaintext paragraph only, used as the App Store / Play Store release
|
||||
# blurb. Falls back to "Ditto vX.Y.Z" when the section has no summary.
|
||||
- mkdir -p artifacts
|
||||
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" > artifacts/release-notes.md
|
||||
- node scripts/extract-release-notes.mjs "$CI_COMMIT_TAG" --summary > artifacts/release-notes-summary.txt
|
||||
- echo "--- release-notes.md ---"
|
||||
- cat artifacts/release-notes.md
|
||||
- echo "--- release-notes-summary.txt (length $(wc -c < artifacts/release-notes-summary.txt)) ---"
|
||||
- cat artifacts/release-notes-summary.txt
|
||||
- echo "------------------------"
|
||||
# Warn (don't fail) when the summary exceeds the documented 500-character
|
||||
# limit so the user spots it before App Store / Play Store reject the upload.
|
||||
- |
|
||||
SUMMARY_LEN=$(wc -c < artifacts/release-notes-summary.txt)
|
||||
if [ "$SUMMARY_LEN" -gt 501 ]; then
|
||||
echo "WARNING: release-notes-summary.txt is $SUMMARY_LEN bytes; convention is <=500."
|
||||
fi
|
||||
artifacts:
|
||||
paths:
|
||||
- artifacts/release-notes.md
|
||||
- artifacts/release-notes-summary.txt
|
||||
expire_in: 90 days
|
||||
|
||||
build-apk:
|
||||
stage: build
|
||||
image: eclipse-temurin:21-jdk
|
||||
@@ -183,28 +216,99 @@ build-apk:
|
||||
- android/.gradle/
|
||||
- .gradle/
|
||||
|
||||
build-ipa:
|
||||
stage: build
|
||||
tags:
|
||||
- macos
|
||||
timeout: 20 minutes
|
||||
needs: []
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
before_script:
|
||||
# PATH is set up via ~/.bash_profile on the runner host (brew + Ruby 3.3 + user gems)
|
||||
- node --version
|
||||
- ruby --version
|
||||
- fastlane --version | head -3
|
||||
|
||||
# Decode the App Store Connect API key (.p8) into a private location.
|
||||
# The Fastfile reads this directly via File.binread. We pass the API
|
||||
# key into match so it contacts Apple's portal to verify the cert is
|
||||
# still valid for the team — fails fast on a revoked / expired cert.
|
||||
- mkdir -p "$HOME/.private_keys"
|
||||
- chmod 700 "$HOME/.private_keys"
|
||||
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
|
||||
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
|
||||
- chmod 600 "$ASC_KEY_PATH"
|
||||
# Avoid env-var collision: match's APP_STORE_CONNECT_API_KEY_PATH expects
|
||||
# a JSON descriptor; we pass the API key inline via the Fastfile.
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
|
||||
# Build web assets and sync to Capacitor iOS project
|
||||
- npm ci
|
||||
- npx vite build -l error
|
||||
- cp dist/index.html dist/404.html
|
||||
- npx cap sync ios
|
||||
- node scripts/patch-cap-config.mjs
|
||||
script:
|
||||
# Stamp marketing version from the git tag (e.g. v2.1.0 -> 2.1.0)
|
||||
- VERSION="${CI_COMMIT_TAG#v}"
|
||||
- echo "Building iOS version $VERSION (build ${CI_PIPELINE_IID}) from tag $CI_COMMIT_TAG"
|
||||
- >-
|
||||
/usr/bin/sed -i ''
|
||||
"s/MARKETING_VERSION = [0-9.]*;/MARKETING_VERSION = ${VERSION};/g"
|
||||
ios/App/App.xcodeproj/project.pbxproj
|
||||
|
||||
# Run match (cert verify + decrypt) and build_app to produce the IPA.
|
||||
# build_app writes ./artifacts/Ditto.ipa relative to the project root.
|
||||
- cd ios
|
||||
- fastlane build_ipa
|
||||
- cd ..
|
||||
|
||||
# Move the IPA to a stable name in the artifact directory.
|
||||
- ls -lh artifacts/
|
||||
- test -f artifacts/Ditto.ipa
|
||||
|
||||
# Upload to the Generic Packages registry for a stable public download URL,
|
||||
# mirroring how build-apk publishes the APK and AAB.
|
||||
- |
|
||||
curl --fail --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \
|
||||
--upload-file "artifacts/Ditto.ipa" \
|
||||
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa"
|
||||
after_script:
|
||||
# Wipe the API key so nothing sensitive sticks around between jobs.
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
artifacts:
|
||||
paths:
|
||||
- artifacts/Ditto.ipa
|
||||
expire_in: 90 days
|
||||
|
||||
release:
|
||||
stage: release
|
||||
image: registry.gitlab.com/gitlab-org/release-cli:latest
|
||||
needs:
|
||||
- build-apk
|
||||
- job: build-apk
|
||||
artifacts: false
|
||||
- job: build-ipa
|
||||
artifacts: false
|
||||
- job: release-notes
|
||||
artifacts: true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
- echo "Creating release for $CI_COMMIT_TAG"
|
||||
# Extract the latest changelog section for the release description.
|
||||
# Reads from "## [version]" to the next "## [" or end of file.
|
||||
- |
|
||||
VERSION="${CI_COMMIT_TAG#v}"
|
||||
RELEASE_NOTES=$(awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md)
|
||||
if [ -z "$RELEASE_NOTES" ]; then
|
||||
RELEASE_NOTES="Agora ${CI_COMMIT_TAG}"
|
||||
fi
|
||||
- echo "$RELEASE_NOTES" > release-notes.md
|
||||
- test -f artifacts/release-notes.md
|
||||
- echo "--- release-notes.md ---"
|
||||
- cat artifacts/release-notes.md
|
||||
- echo "------------------------"
|
||||
release:
|
||||
tag_name: $CI_COMMIT_TAG
|
||||
name: $CI_COMMIT_TAG
|
||||
description: './release-notes.md'
|
||||
description: './artifacts/release-notes.md'
|
||||
assets:
|
||||
links:
|
||||
- name: Agora-${CI_COMMIT_TAG}.apk
|
||||
@@ -213,6 +317,9 @@ release:
|
||||
- name: Agora-${CI_COMMIT_TAG}.aab
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/agora/${CI_COMMIT_TAG}/Agora-${CI_COMMIT_TAG}.aab
|
||||
link_type: package
|
||||
- name: Ditto-${CI_COMMIT_TAG}.ipa
|
||||
url: ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ditto/${CI_COMMIT_TAG}/Ditto-${CI_COMMIT_TAG}.ipa
|
||||
link_type: package
|
||||
|
||||
publish-zapstore:
|
||||
stage: publish
|
||||
@@ -244,7 +351,10 @@ publish-google-play:
|
||||
stage: publish
|
||||
image: ruby:3.3
|
||||
needs:
|
||||
- build-apk
|
||||
- job: build-apk
|
||||
artifacts: true
|
||||
- job: release-notes
|
||||
artifacts: true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
script:
|
||||
@@ -253,18 +363,87 @@ publish-google-play:
|
||||
# Decode base64-encoded service account JSON to a temp file
|
||||
- echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" | base64 -d > /tmp/play-service-account.json
|
||||
|
||||
# Upload the AAB to Google Play production track
|
||||
# Build the fastlane supply metadata layout for the changelog.
|
||||
# supply maps changelogs/<versionCode>.txt to the Play Console "What's
|
||||
# new in this version" field. versionCode matches what build-apk stamped
|
||||
# into build.gradle (= CI_PIPELINE_IID).
|
||||
- VERSION_CODE="${CI_PIPELINE_IID}"
|
||||
- CHANGELOG_DIR="android/fastlane/metadata/android/en-US/changelogs"
|
||||
- mkdir -p "$CHANGELOG_DIR"
|
||||
- cp artifacts/release-notes-summary.txt "${CHANGELOG_DIR}/${VERSION_CODE}.txt"
|
||||
- echo "--- ${CHANGELOG_DIR}/${VERSION_CODE}.txt ---"
|
||||
- cat "${CHANGELOG_DIR}/${VERSION_CODE}.txt"
|
||||
- echo "-------------------------------------------"
|
||||
|
||||
# Upload the AAB to Google Play production track with the changelog.
|
||||
- >-
|
||||
fastlane supply
|
||||
--aab artifacts/Agora.aab
|
||||
--package_name pub.agora.app
|
||||
--track production
|
||||
--json_key /tmp/play-service-account.json
|
||||
--metadata_path android/fastlane/metadata/android
|
||||
--skip_upload_metadata
|
||||
--skip_upload_changelogs
|
||||
--skip_upload_images
|
||||
--skip_upload_screenshots
|
||||
--skip_upload_apk
|
||||
|
||||
# Clean up
|
||||
- rm -f /tmp/play-service-account.json
|
||||
|
||||
publish-app-store:
|
||||
stage: publish
|
||||
# Runs on the self-hosted Mac runner, same as build-ipa. fastlane's `deliver`
|
||||
# action shells out to Apple's iTMSTransporter / altool to upload the IPA
|
||||
# binary, and those tools ship inside Xcode. On a generic Linux container
|
||||
# the upload step crashes with `No such file or directory @ dir_chdir0`
|
||||
# because `Helper.itms_path` resolves to a path inside Xcode that doesn't
|
||||
# exist. The IPA is already signed in `build-ipa`; we just need an Apple
|
||||
# tool to push it, which means macOS.
|
||||
tags:
|
||||
- macos
|
||||
needs:
|
||||
- job: build-ipa
|
||||
artifacts: true
|
||||
- job: release-notes
|
||||
artifacts: true
|
||||
rules:
|
||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
|
||||
variables:
|
||||
LANG: en_US.UTF-8
|
||||
LC_ALL: en_US.UTF-8
|
||||
FASTLANE_HIDE_CHANGELOG: "1"
|
||||
FASTLANE_SKIP_UPDATE_CHECK: "1"
|
||||
before_script:
|
||||
# PATH is set up via ~/.bash_profile on the runner host (brew + Ruby 3.3 + user gems)
|
||||
- ruby --version
|
||||
- fastlane --version | head -3
|
||||
|
||||
# Decode the App Store Connect API key (.p8) into a private location.
|
||||
# The Fastfile reads this directly via File.binread.
|
||||
- mkdir -p "$HOME/.private_keys"
|
||||
- chmod 700 "$HOME/.private_keys"
|
||||
- export ASC_KEY_PATH="$HOME/.private_keys/AuthKey_${APP_STORE_CONNECT_API_KEY_ID}.p8"
|
||||
- echo "$APP_STORE_CONNECT_API_KEY_P8_BASE64" | base64 -d > "$ASC_KEY_PATH"
|
||||
- chmod 600 "$ASC_KEY_PATH"
|
||||
# Avoid env-var collision: match's APP_STORE_CONNECT_API_KEY_PATH expects
|
||||
# a JSON descriptor; we pass the API key inline via the Fastfile.
|
||||
- unset APP_STORE_CONNECT_API_KEY_PATH || true
|
||||
script:
|
||||
- test -f artifacts/Ditto.ipa
|
||||
- test -f artifacts/release-notes-summary.txt
|
||||
|
||||
# Use the release summary paragraph as the App Store "What's New" text.
|
||||
# Generated by the release-notes job from CHANGELOG.md.
|
||||
- mkdir -p ios/fastlane/metadata/en-US
|
||||
- cp artifacts/release-notes-summary.txt ios/fastlane/metadata/en-US/release_notes.txt
|
||||
- echo "--- release_notes.txt ---"
|
||||
- cat ios/fastlane/metadata/en-US/release_notes.txt
|
||||
- echo "-------------------------"
|
||||
|
||||
# Submit the prebuilt IPA from build-ipa to App Store Connect for review.
|
||||
- export IPA_PATH="$CI_PROJECT_DIR/artifacts/Ditto.ipa"
|
||||
- cd ios
|
||||
- fastlane submit_release
|
||||
after_script:
|
||||
- rm -f "$HOME/.private_keys"/AuthKey_*.p8 || true
|
||||
|
||||
@@ -44,6 +44,8 @@ Read the full "Understanding Agora" section above for the complete vision.
|
||||
|
||||
One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit.
|
||||
|
||||
When the bug was introduced by an identifiable prior commit, add a `Regression-of: <short-sha>` trailer to the bottom of your commit message. See AGENTS.md "Attributing Regressions" for the convention.
|
||||
|
||||
### New features and significant changes
|
||||
|
||||
Every feature MR must link to an existing open issue and clearly align with the "Understanding Agora" section in this file. The philosophy alignment section in the MR template is where you make the case for why your change belongs in Agora. If you can't articulate that clearly, the change probably doesn't belong.
|
||||
@@ -131,6 +133,7 @@ maintain it long-term. For each finding, state the file, line, and issue.
|
||||
- [ ] Are there any new images >100KB or other large binary assets that should be hosted externally?
|
||||
- [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation?
|
||||
- [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation?
|
||||
- [ ] If this is a bug fix and the offending commit is identifiable, does the commit message include a `Regression-of: <short-sha>` trailer? (See AGENTS.md "Attributing Regressions".)
|
||||
|
||||
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
|
||||
|
||||
|
||||
@@ -46,12 +46,112 @@ These event kinds were created by community contributors and are supported by Di
|
||||
|
||||
| Kind | Name | Description | Spec |
|
||||
|-------|------------------------|------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| 2473 | Bird Detection | Bird-by-ear observation log (species heard in the wild) | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) |
|
||||
| 12473 | Birdex | Author's cumulative life list of confirmed bird species | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) |
|
||||
| 3367 | Color Moment | Color palette post expressing a mood | [NIP](https://gitlab.com/chad.curtis/espy/-/blob/main/NIP.md) |
|
||||
| 4223 | Weather Reading | Sensor readings from a weather station | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
|
||||
| 7516 | Found Log | Log entry recording a user finding a geocache | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
|
||||
| 8211 | Encrypted Letter | Encrypted personal letter with visual stationery | [NIP](https://gitlab.com/chad.curtis/lief/-/blob/main/NIP.md) |
|
||||
| 16158 | Weather Station | Weather station metadata (location, sensors, connectivity) | [Draft NIP](https://github.com/nostr-protocol/nips/pull/2163) |
|
||||
| 37516 | Geocache | Geocache listing for real-world treasure hunting | [NIP-GC](https://gitlab.com/chad.curtis/treasures/-/blob/main/NIP-GC.md) |
|
||||
| 36787 | Music Track | Addressable event for a music audio file with metadata | See [Music Tracks & Playlists](#music-tracks--playlists) below |
|
||||
| 34139 | Music Playlist | Ordered list of music track references (also used for albums) | See [Music Tracks & Playlists](#music-tracks--playlists) below |
|
||||
| 30621 | Custom Constellation | User-drawn star figure with Hipparcos-numbered edges | [NIP](https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md) |
|
||||
|
||||
---
|
||||
|
||||
## Kind 8333: Onchain Zap
|
||||
|
||||
### Summary
|
||||
|
||||
Regular event kind that records a **Bitcoin on-chain payment** ("onchain zap") sent in appreciation of a Nostr event or profile. Functions as the on-chain analogue of NIP-57 zap receipts (kind 9735), but without the LNURL round-trip: the event is self-attested by the sender and references a real Bitcoin transaction that clients can verify directly on-chain.
|
||||
|
||||
The kind number mirrors the convention of NIP-57: kind **9735** is the Lightning P2P port (per BOLT spec), and kind **8333** is the Bitcoin mainnet P2P port — a natural semantic pairing for Lightning vs. on-chain settlement.
|
||||
|
||||
Because every Nostr keypair deterministically maps to a Bitcoin Taproot (P2TR) address (both use 32-byte x-only secp256k1 keys, per BIP-340/BIP-341), an on-chain zap is simply a Bitcoin transaction whose output pays the recipient's derived Taproot address. The kind 8333 event links that transaction to the Nostr event or profile being zapped.
|
||||
|
||||
### Event Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 8333,
|
||||
"pubkey": "<sender-pubkey>",
|
||||
"content": "Great post!",
|
||||
"tags": [
|
||||
["e", "<target-event-id>", "<relay-hint>"],
|
||||
["p", "<target-pubkey>"],
|
||||
["i", "bitcoin:tx:<txid>"],
|
||||
["amount", "<sats>"],
|
||||
["alt", "On-chain zap: 25000 sats"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
The `content` field is a human-readable comment from the sender (may be empty). It is NOT a zap request JSON (unlike NIP-57 kind 9735).
|
||||
|
||||
### Tags
|
||||
|
||||
| Tag | Required | Description |
|
||||
|----------|----------|----------------------------------------------------------------------------------------------|
|
||||
| `i` | Yes | NIP-73 external content identifier. MUST be `bitcoin:tx:<txid>` where `<txid>` is a 64-char lowercase hex Bitcoin transaction ID. |
|
||||
| `p` | Yes | 32-byte hex pubkey of the zap **recipient** (the author being paid). |
|
||||
| `amount` | Yes | Amount paid to the recipient in **satoshis** (decimal integer). This is the sum of outputs in the tx that paid the recipient's derived Taproot address — *not* the total tx value. |
|
||||
| `e` | If zapping an event | 32-byte hex ID of the event being zapped. Include a relay hint as the 3rd element where possible. |
|
||||
| `a` | If zapping an addressable event | Addressable event coordinate `<kind>:<pubkey>:<d-tag>`. Used instead of (or alongside) `e` for kinds 30000–39999. |
|
||||
| `alt` | Yes | NIP-31 human-readable fallback. |
|
||||
|
||||
If neither `e` nor `a` is present, the zap targets the recipient's **profile** (i.e. a tip to the pubkey, not to a specific event).
|
||||
|
||||
### Publishing Flow
|
||||
|
||||
1. Sender builds a Bitcoin transaction paying the recipient's derived Taproot address (`nostrPubkeyToBitcoinAddress(recipientPubkey)`).
|
||||
2. Sender broadcasts the transaction to the Bitcoin network and obtains the `txid`.
|
||||
3. Sender signs and publishes a kind 8333 event referencing that `txid` with the appropriate `e`/`a`/`p` tags.
|
||||
4. The event is published **after** broadcast; the txid is already final at that point.
|
||||
|
||||
### Client Behavior
|
||||
|
||||
**Querying onchain zaps for an event:**
|
||||
|
||||
```json
|
||||
{ "kinds": [8333], "#e": ["<target-event-id>"], "limit": 100 }
|
||||
```
|
||||
|
||||
For addressable events, use `"#a": ["<kind>:<pubkey>:<d-tag>"]` instead. For profile-level zaps, use `"#p": ["<pubkey>"]`.
|
||||
|
||||
**Verification (REQUIRED before trusting amounts):**
|
||||
|
||||
Clients MUST verify a kind 8333 event on-chain before counting it toward a zap total or displaying its amount. The `amount` tag is self-reported by the sender and would otherwise be trivially spoofable. To verify:
|
||||
|
||||
1. Extract the txid from the `i` tag.
|
||||
2. Fetch the transaction from a Bitcoin data source (e.g. a mempool.space-compatible Esplora API).
|
||||
3. Derive the recipient's expected Taproot address from the `p` tag pubkey.
|
||||
4. Sum the values of all outputs in the transaction that pay that address. This is the **verified amount**. Change outputs paying back to the **sender's** derived Taproot address MUST NOT be counted toward the verified amount — only outputs to the recipient.
|
||||
5. If the verified amount is 0, the event SHOULD be discarded.
|
||||
6. If the sender's `amount` tag exceeds the verified amount, clients MAY discard the event or MAY display the smaller verified amount (capping). Clients MUST NOT display or count the claimed amount when it exceeds the verified amount.
|
||||
7. Unconfirmed transactions MAY be displayed as pending; clients MAY require confirmation before counting them toward public totals. Because unconfirmed transactions can be evicted (RBF, double-spend), clients SHOULD either exclude them from aggregate zap totals or clearly label them as pending.
|
||||
|
||||
**Sender/recipient identity:** Clients SHOULD reject events where the sender's pubkey (`event.pubkey`) equals the recipient pubkey from the `p` tag. Self-zaps are trivial to fabricate (the sender already controls the destination address) and contribute nothing meaningful to zap totals.
|
||||
|
||||
**Deduplication:** Clients SHOULD deduplicate events that reference the same `txid` (an attacker could publish many events pointing at one real transaction). One kind 8333 event per (txid, target) pair is canonical — when multiple events reference the same `txid` for the same target, the earliest is preferred.
|
||||
|
||||
**Network scope:** This specification applies to Bitcoin **mainnet** only. Testnet, signet, and other networks are out of scope; addresses and txids on those networks MUST NOT be used in kind 8333 events.
|
||||
|
||||
### Comparison with NIP-57 (Lightning Zaps)
|
||||
|
||||
| Aspect | NIP-57 (kind 9735) | This spec (kind 8333) |
|
||||
|--------|---------------------|------------------------|
|
||||
| Settlement | Lightning Network | Bitcoin L1 |
|
||||
| Invoice / payment | LNURL + BOLT-11 invoice | Raw Bitcoin tx |
|
||||
| Event issuer | Recipient's LNURL provider | Sender |
|
||||
| Availability | Requires `lud06`/`lud16` on recipient profile | Always available (every Nostr pubkey has a derived Taproot addr) |
|
||||
| Verification | Recipient zap-provider pubkey + bolt11 amount | On-chain tx verified against derived recipient address |
|
||||
| Finality | Instant | Confirms in ~10 min (mempool first) |
|
||||
| Fees | Sub-satoshi typical | Significant at low amounts |
|
||||
|
||||
The two zap kinds are complementary. Clients SHOULD sum verified amounts from both kinds when displaying total zap stats for a post or profile.
|
||||
|
||||
---
|
||||
|
||||
@@ -924,6 +1024,18 @@ The following specifications are maintained by their respective authors. Ditto i
|
||||
|
||||
Color palette posts capturing 3-6 colors from a beautiful moment, optionally accompanied by an emoji and layout preference. Supports horizontal, vertical, grid, star, checkerboard, and diagonal stripe layouts. A form of pre-verbal visual communication through color and emotion.
|
||||
|
||||
### Birdstar (Kinds 2473, 12473, 30621)
|
||||
|
||||
**Author:** Alex Gleason
|
||||
**Spec:** https://gitlab.com/alexgleason/birdstar/-/blob/main/NIP.md
|
||||
**App:** https://birdstar.app
|
||||
|
||||
Birdstar merges Birdsong Spotter (a bird-by-ear checklist) and Starpoint (an interactive sky map with community constellations) into a single client.
|
||||
|
||||
- **Kind 2473 — Bird Detection.** A regular event representing a single identified bird observation. The species is identified by a NIP-73 `i`/`k` pair pointing at the species' Wikidata entity URI (e.g. `https://www.wikidata.org/entity/Q26825` for the American Robin). The `content` field holds an optional freeform human note about the detection. Required tags: NIP-31 `alt`, NIP-73 `i` (Wikidata URL) + `k` (`web`). Ditto renders detections as a species card with the Wikipedia thumbnail, common/scientific name, and article summary.
|
||||
- **Kind 12473 — Birdex.** A replaceable event (one per author) indexing every distinct species the author has ever confirmed via kind 2473. Each species is a positional `i`/`n` pair — the Wikidata entity URI followed immediately by the scientific binomial name — emitted in chronological order of first detection. Ditto renders a Birdex as a tiled grid of species, each tile showing the Wikipedia thumbnail with the common name overlaid. In feeds, only the most recent few tiles are shown with a "+N" capstone mirroring how kind 3 follow lists preview members; the post-detail page shows every species.
|
||||
- **Kind 30621 — Custom Constellation.** An addressable event (`d` tag) representing a single user-drawn star figure. Each `edge` tag (`["edge", from, to]`) references two Hipparcos catalog numbers as decimal strings — e.g. `["edge", "32349", "37279"]` for Sirius → Procyon. Required tags: `d`, `title`, `alt`, and at least one valid `edge`. The `content` field is a freeform description. Ditto renders constellations as a stylized SVG star-map (gnomonically projected onto a tangent plane at the figure's centroid, with stars sized by magnitude) using a bundled Hipparcos catalog that is code-split so the data only loads when a constellation is actually viewed.
|
||||
|
||||
### Geocaching (Kinds 37516, 7516)
|
||||
|
||||
**Author:** Chad Curtis
|
||||
@@ -948,3 +1060,63 @@ NIP-44 encrypted personal letters with visual stationery, hand-drawn stickers, d
|
||||
**Firmware:** https://github.com/samthomson/weather-station
|
||||
|
||||
Kind 16158 (replaceable) describes a weather station's configuration: name, geohash location, elevation, power source, connectivity, and sensor inventory. Kind 4223 (regular) carries individual sensor readings as 3-parameter tags `[sensor_type, value, model]`, enabling historical queries and cross-station comparison. Each station has its own keypair.
|
||||
|
||||
---
|
||||
|
||||
## Music Tracks & Playlists
|
||||
|
||||
### Kind 36787: Music Track
|
||||
|
||||
An addressable event containing metadata about an audio file. Full spec maintained externally.
|
||||
|
||||
**Required tags:** `d`, `title`, `artist`, `url`, `t` (with value `"music"`)
|
||||
|
||||
**Optional tags:** `image`, `video`, `album`, `track_number`, `released`, `duration`, `format`, `bitrate`, `sample_rate`, `language`, `explicit`, `zap`, `alt`
|
||||
|
||||
### Kind 34139: Music Playlist
|
||||
|
||||
An addressable event containing an ordered list of music track references.
|
||||
|
||||
**Required tags:** `d`, `title`, `alt`
|
||||
|
||||
**Optional tags:** `description`, `image`, `a` (track references), `t`, `public`, `private`, `collaborative`
|
||||
|
||||
Track references use `a` tags in the format `["a", "36787:<pubkey>:<d-tag>"]`.
|
||||
|
||||
### Albums (Convention)
|
||||
|
||||
Albums are represented as kind 34139 playlist events with a `["t", "album"]` tag. This reuses the existing playlist infrastructure while allowing clients to distinguish albums from user-curated playlists.
|
||||
|
||||
**Additional optional tags for albums:**
|
||||
- `released` — ISO 8601 release date (e.g. `"2024-06-15"`)
|
||||
- `label` — Record label name
|
||||
|
||||
**Example album event:**
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": 34139,
|
||||
"content": "Debut studio album featuring 12 tracks of ambient electronic music.",
|
||||
"tags": [
|
||||
["d", "endless-summer-2024"],
|
||||
["title", "Endless Summer"],
|
||||
["image", "https://cdn.blossom.example/img/album-art.jpg"],
|
||||
["t", "album"],
|
||||
["t", "electronic"],
|
||||
["t", "ambient"],
|
||||
["released", "2024-06-15"],
|
||||
["label", "Sunset Records"],
|
||||
["a", "36787:abc123...:track-1"],
|
||||
["a", "36787:abc123...:track-2"],
|
||||
["a", "36787:abc123...:track-3"],
|
||||
["alt", "Album: Endless Summer by The Midnight Collective"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Client behavior:**
|
||||
- Clients detect albums by checking for a `t` tag with value `"album"` (case-insensitive)
|
||||
- Albums display release date and label information when available
|
||||
- Track ordering follows the order of `a` tags in the event
|
||||
- The same detail view, playback, and commenting features apply to both albums and playlists
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "2.8.0"
|
||||
versionName "2.14.4"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -58,4 +58,6 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
</manifest>
|
||||
|
||||
@@ -19,7 +19,6 @@ public class MainActivity extends BridgeActivity {
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
// Register native plugins before super.onCreate.
|
||||
registerPlugin(DittoNotificationPlugin.class);
|
||||
registerPlugin(SandboxPlugin.class);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
package pub.ditto.app;
|
||||
|
||||
import android.graphics.Color;
|
||||
import android.graphics.PorterDuff;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Base64;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebResourceRequest;
|
||||
import android.webkit.WebResourceResponse;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.webkit.WebViewClient;
|
||||
import android.widget.FrameLayout;
|
||||
import android.widget.ProgressBar;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
|
||||
import com.getcapacitor.JSObject;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Capacitor plugin that creates isolated Android WebViews for sandboxed content.
|
||||
*
|
||||
* Each sandbox uses shouldInterceptRequest to intercept all requests and forward
|
||||
* them to the JS layer as fetch events — the same protocol iframe.diy uses.
|
||||
* The React code can serve files identically regardless of platform.
|
||||
*/
|
||||
@CapacitorPlugin(name = "SandboxPlugin")
|
||||
public class SandboxPlugin extends Plugin {
|
||||
|
||||
private static final String TAG = "SandboxPlugin";
|
||||
private final Map<String, SandboxInstance> sandboxes = new HashMap<>();
|
||||
private final Handler mainHandler = new Handler(Looper.getMainLooper());
|
||||
|
||||
@PluginMethod
|
||||
public void create(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
JSObject frame = call.getObject("frame");
|
||||
if (frame == null) {
|
||||
call.reject("Missing required parameter: frame");
|
||||
return;
|
||||
}
|
||||
|
||||
int x = frame.optInt("x", 0);
|
||||
int y = frame.optInt("y", 0);
|
||||
int width = frame.optInt("width", 0);
|
||||
int height = frame.optInt("height", 0);
|
||||
|
||||
if (sandboxes.containsKey(sandboxId)) {
|
||||
call.reject("Sandbox already exists: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
float density = getActivity().getResources().getDisplayMetrics().density;
|
||||
int pxX = Math.round(x * density);
|
||||
int pxY = Math.round(y * density);
|
||||
int pxWidth = Math.round(width * density);
|
||||
int pxHeight = Math.round(height * density);
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = new SandboxInstance(sandboxId, this);
|
||||
sandboxes.put(sandboxId, sandbox);
|
||||
|
||||
// Add the container (WebView + spinner overlay) on top of the
|
||||
// Capacitor WebView. The parent is a CoordinatorLayout — using
|
||||
// the wrong LayoutParams type causes a ClassCastException when
|
||||
// it intercepts touch events.
|
||||
View capWebView = getBridge().getWebView();
|
||||
ViewGroup parent = (ViewGroup) capWebView.getParent();
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
parent.addView(sandbox.container, params);
|
||||
|
||||
// The spinner is now visible. Navigation is deferred until the
|
||||
// JS layer calls navigate() — this allows the caller to
|
||||
// pre-fetch blobs while the spinner animates.
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void navigate(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
sandbox.webView.loadUrl("https://" + sandboxId + ".sandbox.native/index.html");
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void updateFrame(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
JSObject frame = call.getObject("frame");
|
||||
if (frame == null) {
|
||||
call.reject("Missing required parameter: frame");
|
||||
return;
|
||||
}
|
||||
|
||||
int x = frame.optInt("x", 0);
|
||||
int y = frame.optInt("y", 0);
|
||||
int width = frame.optInt("width", 0);
|
||||
int height = frame.optInt("height", 0);
|
||||
|
||||
float density = getActivity().getResources().getDisplayMetrics().density;
|
||||
int pxX = Math.round(x * density);
|
||||
int pxY = Math.round(y * density);
|
||||
int pxWidth = Math.round(width * density);
|
||||
int pxHeight = Math.round(height * density);
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
CoordinatorLayout.LayoutParams params = new CoordinatorLayout.LayoutParams(pxWidth, pxHeight);
|
||||
params.leftMargin = pxX;
|
||||
params.topMargin = pxY;
|
||||
sandbox.container.setLayoutParams(params);
|
||||
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void respondToFetch(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
String requestId = call.getString("requestId");
|
||||
if (requestId == null) {
|
||||
call.reject("Missing required parameter: requestId");
|
||||
return;
|
||||
}
|
||||
JSObject response = call.getObject("response");
|
||||
if (response == null) {
|
||||
call.reject("Missing required parameter: response");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
int status = response.optInt("status", 200);
|
||||
String statusText = response.optString("statusText", "OK");
|
||||
String bodyBase64 = response.optString("body", null);
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
JSONObject headersObj = response.optJSONObject("headers");
|
||||
if (headersObj != null) {
|
||||
for (java.util.Iterator<String> it = headersObj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
headers.put(key, headersObj.optString(key));
|
||||
}
|
||||
}
|
||||
|
||||
sandbox.resolveRequest(requestId, status, statusText, headers, bodyBase64);
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void postMessage(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
JSObject message = call.getObject("message");
|
||||
if (message == null) {
|
||||
call.reject("Missing required parameter: message");
|
||||
return;
|
||||
}
|
||||
|
||||
SandboxInstance sandbox = sandboxes.get(sandboxId);
|
||||
if (sandbox == null) {
|
||||
call.reject("Sandbox not found: " + sandboxId);
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> sandbox.postMessageToWebView(message.toString()));
|
||||
|
||||
call.resolve();
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
public void destroy(PluginCall call) {
|
||||
String sandboxId = call.getString("id");
|
||||
if (sandboxId == null) {
|
||||
call.reject("Missing required parameter: id");
|
||||
return;
|
||||
}
|
||||
|
||||
mainHandler.post(() -> {
|
||||
SandboxInstance sandbox = sandboxes.remove(sandboxId);
|
||||
if (sandbox != null) {
|
||||
ViewGroup parent = (ViewGroup) sandbox.container.getParent();
|
||||
if (parent != null) {
|
||||
parent.removeView(sandbox.container);
|
||||
}
|
||||
sandbox.webView.destroy();
|
||||
}
|
||||
call.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
void emitFetchRequest(String sandboxId, String requestId, JSObject request) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("requestId", requestId);
|
||||
data.put("request", request);
|
||||
notifyListeners("fetch", data);
|
||||
}
|
||||
|
||||
void emitScriptMessage(String sandboxId, JSObject message) {
|
||||
JSObject data = new JSObject();
|
||||
data.put("id", sandboxId);
|
||||
data.put("message", message);
|
||||
notifyListeners("scriptMessage", data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single sandboxed WebView instance.
|
||||
*/
|
||||
private static class SandboxInstance {
|
||||
final String id;
|
||||
/** Wrapper layout that holds the WebView and the loading overlay. */
|
||||
final FrameLayout container;
|
||||
final WebView webView;
|
||||
final SandboxPlugin plugin;
|
||||
private final ConcurrentHashMap<String, PendingRequest> pendingRequests = new ConcurrentHashMap<>();
|
||||
/** Native spinner overlay, shown while the sandbox content loads. */
|
||||
private ProgressBar spinner;
|
||||
|
||||
SandboxInstance(String id, SandboxPlugin plugin) {
|
||||
this.id = id;
|
||||
this.plugin = plugin;
|
||||
|
||||
this.container = new FrameLayout(plugin.getActivity());
|
||||
this.webView = new WebView(plugin.getActivity());
|
||||
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setJavaScriptEnabled(true);
|
||||
settings.setDomStorageEnabled(true);
|
||||
settings.setAllowFileAccess(false);
|
||||
settings.setAllowContentAccess(false);
|
||||
settings.setDatabaseEnabled(true);
|
||||
|
||||
webView.setBackgroundColor(Color.parseColor("#14161f"));
|
||||
|
||||
// Add JavaScript interface for script->native communication.
|
||||
webView.addJavascriptInterface(new SandboxBridge(this), "__sandboxNative");
|
||||
|
||||
// Inject the bridge script and intercept requests.
|
||||
webView.setWebViewClient(new SandboxWebViewClient(this));
|
||||
|
||||
// Build the container: WebView fills it, spinner overlays on top.
|
||||
container.addView(webView, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
|
||||
// Native spinner overlay — uses the Android indeterminate
|
||||
// ProgressBar which animates on the render thread, so it keeps
|
||||
// spinning even when the main/IO threads are busy.
|
||||
spinner = new ProgressBar(plugin.getActivity());
|
||||
spinner.setIndeterminate(true);
|
||||
spinner.getIndeterminateDrawable().setColorFilter(
|
||||
Color.parseColor("#7c5cdc"), PorterDuff.Mode.SRC_IN);
|
||||
FrameLayout.LayoutParams spinnerParams = new FrameLayout.LayoutParams(
|
||||
dpToPx(plugin, 32), dpToPx(plugin, 32), Gravity.CENTER);
|
||||
container.addView(spinner, spinnerParams);
|
||||
|
||||
// Dark background behind the spinner.
|
||||
View overlay = new View(plugin.getActivity());
|
||||
overlay.setBackgroundColor(Color.parseColor("#14161f"));
|
||||
// Insert the overlay between the WebView (index 0) and spinner (index 1)
|
||||
// so it covers the WebView but sits behind the spinner.
|
||||
container.addView(overlay, 1, new FrameLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT));
|
||||
}
|
||||
|
||||
/** Remove the native loading overlay. Safe to call multiple times. */
|
||||
void hideSpinner() {
|
||||
if (spinner != null) {
|
||||
// Remove spinner and overlay (indices 2 and 1 after WebView at 0).
|
||||
if (container.getChildCount() > 2) container.removeViewAt(2); // spinner
|
||||
if (container.getChildCount() > 1) container.removeViewAt(1); // overlay
|
||||
spinner = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static int dpToPx(SandboxPlugin plugin, int dp) {
|
||||
float density = plugin.getActivity().getResources().getDisplayMetrics().density;
|
||||
return Math.round(dp * density);
|
||||
}
|
||||
|
||||
void postMessageToWebView(String jsonString) {
|
||||
String js = "(function() { " +
|
||||
"if (window.__sandboxBridge && window.__sandboxBridge.onMessage) { " +
|
||||
"window.__sandboxBridge.onMessage(" + jsonString + "); " +
|
||||
"} " +
|
||||
"})();";
|
||||
webView.evaluateJavascript(js, null);
|
||||
}
|
||||
|
||||
void resolveRequest(String requestId, int status, String statusText,
|
||||
Map<String, String> headers, String bodyBase64) {
|
||||
PendingRequest pending = pendingRequests.remove(requestId);
|
||||
if (pending == null) return;
|
||||
|
||||
byte[] bodyBytes = null;
|
||||
if (bodyBase64 != null && !bodyBase64.equals("null")) {
|
||||
try {
|
||||
bodyBytes = Base64.decode(bodyBase64, Base64.DEFAULT);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "Base64 decode failed for request " + requestId, e);
|
||||
}
|
||||
}
|
||||
|
||||
String contentType = headers.getOrDefault("Content-Type", "application/octet-stream");
|
||||
String encoding = contentType.contains("text/") ? "UTF-8" : null;
|
||||
|
||||
InputStream body = bodyBytes != null
|
||||
? new ByteArrayInputStream(bodyBytes)
|
||||
: new ByteArrayInputStream(new byte[0]);
|
||||
|
||||
WebResourceResponse response = new WebResourceResponse(
|
||||
contentType, encoding, status, statusText, headers, body
|
||||
);
|
||||
|
||||
pending.resolve(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WebViewClient that intercepts all requests and forwards them to JS.
|
||||
*/
|
||||
private static class SandboxWebViewClient extends WebViewClient {
|
||||
private final SandboxInstance sandbox;
|
||||
private boolean bridgeInjected = false;
|
||||
|
||||
SandboxWebViewClient(SandboxInstance sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
@Override
|
||||
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
|
||||
String url = request.getUrl().toString();
|
||||
|
||||
// Only intercept requests to the sandbox domain.
|
||||
if (!url.contains(".sandbox.native")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
|
||||
// Create a pending request with a blocking latch.
|
||||
PendingRequest pending = new PendingRequest();
|
||||
sandbox.pendingRequests.put(requestId, pending);
|
||||
|
||||
// Rewrite URL to include the sandbox ID for the JS handler.
|
||||
String path = request.getUrl().getPath();
|
||||
if (path == null || path.isEmpty()) path = "/";
|
||||
String rewrittenURL = "https://" + sandbox.id + ".sandbox.native" + path;
|
||||
|
||||
// Serialise the request.
|
||||
JSObject serialisedRequest = new JSObject();
|
||||
serialisedRequest.put("url", rewrittenURL);
|
||||
serialisedRequest.put("method", request.getMethod());
|
||||
|
||||
JSObject headers = new JSObject();
|
||||
for (Map.Entry<String, String> entry : request.getRequestHeaders().entrySet()) {
|
||||
headers.put(entry.getKey(), entry.getValue());
|
||||
}
|
||||
serialisedRequest.put("headers", headers);
|
||||
serialisedRequest.put("body", JSONObject.NULL);
|
||||
|
||||
// Emit to JS.
|
||||
sandbox.plugin.emitFetchRequest(sandbox.id, requestId, serialisedRequest);
|
||||
|
||||
// Block until JS responds. Each asset is fetched from a Blossom
|
||||
// server over the network, so we need a generous timeout. The
|
||||
// WebView IO thread pool has ~6 threads; if all are blocked,
|
||||
// subsequent requests queue until a thread frees up.
|
||||
WebResourceResponse response = pending.awaitResponse(60000);
|
||||
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Timeout — return error response.
|
||||
sandbox.pendingRequests.remove(requestId);
|
||||
return new WebResourceResponse(
|
||||
"text/plain", "UTF-8", 504,
|
||||
"Gateway Timeout", new HashMap<>(),
|
||||
new ByteArrayInputStream("Request timed out".getBytes())
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPageFinished(WebView view, String url) {
|
||||
super.onPageFinished(view, url);
|
||||
|
||||
if (!bridgeInjected) {
|
||||
bridgeInjected = true;
|
||||
view.evaluateJavascript(getBridgeScript(), null);
|
||||
}
|
||||
|
||||
// Remove the native spinner once the first page has finished
|
||||
// loading (all initial resources resolved). This runs on the
|
||||
// main thread, so the removal is safe.
|
||||
sandbox.hideSpinner();
|
||||
}
|
||||
|
||||
private String getBridgeScript() {
|
||||
return "(function() {" +
|
||||
"'use strict';" +
|
||||
"var messageListeners = [];" +
|
||||
"window.__sandboxBridge = {" +
|
||||
" onMessage: function(data) {" +
|
||||
" var event = {" +
|
||||
" data: data," +
|
||||
" origin: 'https://" + sandbox.id + ".sandbox.native'," +
|
||||
" source: window.parent," +
|
||||
" type: 'message'" +
|
||||
" };" +
|
||||
" for (var i = 0; i < messageListeners.length; i++) {" +
|
||||
" try { messageListeners[i](event); } catch(e) {}" +
|
||||
" }" +
|
||||
" }" +
|
||||
"};" +
|
||||
"var origAdd = window.addEventListener;" +
|
||||
"window.addEventListener = function(type, fn, opts) {" +
|
||||
" if (type === 'message' && typeof fn === 'function') messageListeners.push(fn);" +
|
||||
" return origAdd.call(window, type, fn, opts);" +
|
||||
"};" +
|
||||
"var origRemove = window.removeEventListener;" +
|
||||
"window.removeEventListener = function(type, fn, opts) {" +
|
||||
" if (type === 'message') {" +
|
||||
" var idx = messageListeners.indexOf(fn);" +
|
||||
" if (idx !== -1) messageListeners.splice(idx, 1);" +
|
||||
" }" +
|
||||
" return origRemove.call(window, type, fn, opts);" +
|
||||
"};" +
|
||||
"if (!window.parent || window.parent === window) window.parent = {};" +
|
||||
"window.parent.postMessage = function(data) {" +
|
||||
" if (data && typeof data === 'object' && data.jsonrpc === '2.0') {" +
|
||||
" try { window.__sandboxNative.postMessage(JSON.stringify(data)); } catch(e) {}" +
|
||||
" }" +
|
||||
"};" +
|
||||
"})();";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JavaScript interface exposed to the sandbox WebView.
|
||||
*/
|
||||
private static class SandboxBridge {
|
||||
private final SandboxInstance sandbox;
|
||||
|
||||
SandboxBridge(SandboxInstance sandbox) {
|
||||
this.sandbox = sandbox;
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void postMessage(String json) {
|
||||
try {
|
||||
JSONObject obj = new JSONObject(json);
|
||||
JSObject jsObj = new JSObject();
|
||||
for (java.util.Iterator<String> it = obj.keys(); it.hasNext(); ) {
|
||||
String key = it.next();
|
||||
jsObj.put(key, obj.get(key));
|
||||
}
|
||||
sandbox.plugin.emitScriptMessage(sandbox.id, jsObj);
|
||||
} catch (JSONException e) {
|
||||
Log.w(TAG, "Failed to parse script message", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pending request that blocks the WebViewClient IO thread until JS
|
||||
* responds with the complete resource.
|
||||
*/
|
||||
private static class PendingRequest {
|
||||
private volatile WebResourceResponse response;
|
||||
private final CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
void resolve(WebResourceResponse response) {
|
||||
this.response = response;
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
WebResourceResponse awaitResponse(long timeoutMs) {
|
||||
try {
|
||||
latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import htmlParser from "@html-eslint/parser";
|
||||
import customRules from "./eslint-rules/index.js";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "android", "ios"] },
|
||||
{ ignores: ["dist", "android", "ios", ".agents"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40001000100000002 /* SandboxPlugin.swift */; };
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */; };
|
||||
B1A2C3D40005000100000001 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */; };
|
||||
B1A2C3D40006000100000001 /* DittoNotificationPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */; };
|
||||
@@ -33,7 +32,6 @@
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; };
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SandboxPlugin.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DittoBridgeViewController.swift; sourceTree = "<group>"; };
|
||||
B1A2C3D40004000100000002 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = "<group>"; };
|
||||
B1A2C3D40005000100000002 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
@@ -76,7 +74,6 @@
|
||||
50379B222058CBB4000EE86E /* capacitor.config.json */,
|
||||
B1A2C3D40004000100000002 /* App.entitlements */,
|
||||
504EC3071FED79650016851F /* AppDelegate.swift */,
|
||||
B1A2C3D40001000100000002 /* SandboxPlugin.swift */,
|
||||
B1A2C3D40002000100000002 /* DittoBridgeViewController.swift */,
|
||||
B1A2C3D40006000100000002 /* DittoNotificationPlugin.swift */,
|
||||
B1A2C3D40007000100000002 /* NostrPoller.swift */,
|
||||
@@ -174,7 +171,6 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
504EC3081FED79650016851F /* AppDelegate.swift in Sources */,
|
||||
B1A2C3D40001000100000001 /* SandboxPlugin.swift in Sources */,
|
||||
B1A2C3D40002000100000001 /* DittoBridgeViewController.swift in Sources */,
|
||||
B1A2C3D40006000100000001 /* DittoNotificationPlugin.swift in Sources */,
|
||||
B1A2C3D40007000100000001 /* NostrPoller.swift in Sources */,
|
||||
@@ -327,7 +323,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.8.0;
|
||||
MARKETING_VERSION = 2.14.4;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = pub.agora.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
import Foundation
|
||||
import Capacitor
|
||||
import WebKit
|
||||
|
||||
// MARK: - Plugin
|
||||
|
||||
/// Capacitor plugin that creates isolated WKWebViews for sandboxed content.
|
||||
///
|
||||
/// Each sandbox gets a unique custom URL scheme (`sbx-<id>://`) so that
|
||||
/// every embedded app has its own origin (separate localStorage, cookies, etc.).
|
||||
/// All requests on the custom scheme are intercepted via `WKURLSchemeHandler`
|
||||
/// and forwarded to the JS layer as fetch events — the same protocol
|
||||
/// iframe.diy uses. This lets the existing React code serve files identically.
|
||||
@objc(SandboxPlugin)
|
||||
public class SandboxPlugin: CAPPlugin, CAPBridgedPlugin {
|
||||
public let identifier = "SandboxPlugin"
|
||||
public let jsName = "SandboxPlugin"
|
||||
public let pluginMethods: [CAPPluginMethod] = [
|
||||
CAPPluginMethod(name: "create", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "navigate", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "updateFrame", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "respondToFetch", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "postMessage", returnType: CAPPluginReturnPromise),
|
||||
CAPPluginMethod(name: "destroy", returnType: CAPPluginReturnPromise),
|
||||
]
|
||||
|
||||
/// Active sandbox instances, keyed by sandbox ID.
|
||||
private var sandboxes: [String: SandboxInstance] = [:]
|
||||
|
||||
// MARK: - Plugin Methods
|
||||
|
||||
@objc func create(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let frame = call.getObject("frame"),
|
||||
let x = frame["x"] as? Double,
|
||||
let y = frame["y"] as? Double,
|
||||
let width = frame["width"] as? Double,
|
||||
let height = frame["height"] as? Double else {
|
||||
call.reject("Missing or invalid parameter: frame")
|
||||
return
|
||||
}
|
||||
|
||||
if sandboxes[sandboxId] != nil {
|
||||
call.reject("Sandbox already exists: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let webViewFrame = CGRect(x: x, y: y, width: width, height: height)
|
||||
let sandbox = SandboxInstance(
|
||||
id: sandboxId,
|
||||
frame: webViewFrame,
|
||||
plugin: self
|
||||
)
|
||||
self.sandboxes[sandboxId] = sandbox
|
||||
|
||||
// Add the container (WebView + spinner overlay) on top of
|
||||
// the Capacitor WebView.
|
||||
if let bridge = self.bridge,
|
||||
let webView = bridge.webView {
|
||||
webView.superview?.addSubview(sandbox.containerView)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func navigate(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let sandbox = self?.sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.navigateToApp()
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updateFrame(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let frame = call.getObject("frame"),
|
||||
let x = frame["x"] as? Double,
|
||||
let y = frame["y"] as? Double,
|
||||
let width = frame["width"] as? Double,
|
||||
let height = frame["height"] as? Double else {
|
||||
call.reject("Missing or invalid parameter: frame")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let sandbox = self?.sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
sandbox.containerView.frame = CGRect(x: x, y: y, width: width, height: height)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@objc func respondToFetch(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let requestId = call.getString("requestId") else {
|
||||
call.reject("Missing required parameter: requestId")
|
||||
return
|
||||
}
|
||||
guard let response = call.getObject("response") else {
|
||||
call.reject("Missing required parameter: response")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sandbox = sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
sandbox.schemeHandler.resolveRequest(
|
||||
requestId: requestId,
|
||||
status: response["status"] as? Int ?? 200,
|
||||
statusText: response["statusText"] as? String ?? "OK",
|
||||
headers: response["headers"] as? [String: String] ?? [:],
|
||||
bodyBase64: response["body"] as? String
|
||||
)
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func postMessage(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
guard let message = call.getObject("message") else {
|
||||
call.reject("Missing required parameter: message")
|
||||
return
|
||||
}
|
||||
|
||||
guard let sandbox = sandboxes[sandboxId] else {
|
||||
call.reject("Sandbox not found: \(sandboxId)")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async {
|
||||
sandbox.postMessageToWebView(message)
|
||||
}
|
||||
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@objc func destroy(_ call: CAPPluginCall) {
|
||||
guard let sandboxId = call.getString("id") else {
|
||||
call.reject("Missing required parameter: id")
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let sandbox = self.sandboxes.removeValue(forKey: sandboxId) {
|
||||
sandbox.containerView.removeFromSuperview()
|
||||
sandbox.schemeHandler.cancelAll()
|
||||
}
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Forwarding
|
||||
|
||||
/// Forward a fetch request from the native WebView to JS.
|
||||
func emitFetchRequest(sandboxId: String, requestId: String, request: [String: Any]) {
|
||||
notifyListeners("fetch", data: [
|
||||
"id": sandboxId,
|
||||
"requestId": requestId,
|
||||
"request": request,
|
||||
])
|
||||
}
|
||||
|
||||
/// Forward a script message from the sandbox to JS.
|
||||
func emitScriptMessage(sandboxId: String, message: [String: Any]) {
|
||||
notifyListeners("scriptMessage", data: [
|
||||
"id": sandboxId,
|
||||
"message": message,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SandboxInstance
|
||||
|
||||
/// Manages a single sandboxed WKWebView instance.
|
||||
private class SandboxInstance: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
|
||||
let id: String
|
||||
let webView: WKWebView
|
||||
let schemeHandler: SandboxSchemeHandler
|
||||
private weak var plugin: SandboxPlugin?
|
||||
private let customScheme: String
|
||||
|
||||
/// Container view that holds the WebView and spinner overlay.
|
||||
let containerView: UIView
|
||||
|
||||
/// Native spinner overlay, removed when the first page finishes loading.
|
||||
private var spinnerOverlay: UIView?
|
||||
|
||||
init(id: String, frame: CGRect, plugin: SandboxPlugin) {
|
||||
self.id = id
|
||||
self.plugin = plugin
|
||||
|
||||
// Each sandbox gets a unique custom URL scheme so that WKWebView
|
||||
// assigns a distinct origin, isolating localStorage/IndexedDB/cookies.
|
||||
self.customScheme = "sbx-\(id)"
|
||||
|
||||
self.schemeHandler = SandboxSchemeHandler(
|
||||
sandboxId: id,
|
||||
scheme: self.customScheme,
|
||||
plugin: plugin
|
||||
)
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.setURLSchemeHandler(self.schemeHandler, forURLScheme: self.customScheme)
|
||||
|
||||
// Add a script message handler for communication from injected scripts.
|
||||
let userContentController = WKUserContentController()
|
||||
|
||||
// Inject a bridge script that:
|
||||
// 1. Provides window.parent.postMessage()-like functionality
|
||||
// 2. Routes messages through the native bridge
|
||||
let bridgeScript = WKUserScript(
|
||||
source: SandboxInstance.bridgeScript(scheme: self.customScheme),
|
||||
injectionTime: .atDocumentStart,
|
||||
forMainFrameOnly: false
|
||||
)
|
||||
userContentController.addUserScript(bridgeScript)
|
||||
|
||||
config.userContentController = userContentController
|
||||
config.preferences.javaScriptCanOpenWindowsAutomatically = false
|
||||
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
|
||||
// Container view that holds the WebView + spinner overlay.
|
||||
self.containerView = UIView(frame: frame)
|
||||
|
||||
self.webView = WKWebView(frame: containerView.bounds, configuration: config)
|
||||
self.webView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
self.webView.isOpaque = false
|
||||
self.webView.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
|
||||
self.webView.scrollView.backgroundColor = self.webView.backgroundColor
|
||||
self.webView.scrollView.bounces = false
|
||||
self.containerView.addSubview(self.webView)
|
||||
|
||||
// Dark overlay behind the spinner.
|
||||
let overlay = UIView(frame: containerView.bounds)
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
overlay.backgroundColor = UIColor(red: 0x14/255.0, green: 0x16/255.0, blue: 0x1f/255.0, alpha: 1)
|
||||
self.containerView.addSubview(overlay)
|
||||
|
||||
// Native spinner — uses UIActivityIndicatorView which animates on
|
||||
// the render thread independently of JS/main-thread work.
|
||||
let spinner = UIActivityIndicatorView(style: .medium)
|
||||
spinner.color = UIColor(red: 124/255.0, green: 92/255.0, blue: 220/255.0, alpha: 1)
|
||||
spinner.translatesAutoresizingMaskIntoConstraints = false
|
||||
spinner.startAnimating()
|
||||
overlay.addSubview(spinner)
|
||||
NSLayoutConstraint.activate([
|
||||
spinner.centerXAnchor.constraint(equalTo: overlay.centerXAnchor),
|
||||
spinner.centerYAnchor.constraint(equalTo: overlay.centerYAnchor),
|
||||
])
|
||||
|
||||
self.spinnerOverlay = overlay
|
||||
|
||||
super.init()
|
||||
|
||||
// Register the message handler and navigation delegate after super.init().
|
||||
userContentController.add(self, name: "sandboxBridge")
|
||||
self.webView.navigationDelegate = self
|
||||
}
|
||||
|
||||
/// Navigate the WebView to the sandbox's entry point.
|
||||
func navigateToApp() {
|
||||
let initialURL = URL(string: "\(customScheme)://app/index.html")!
|
||||
webView.load(URLRequest(url: initialURL))
|
||||
}
|
||||
|
||||
/// Remove the native loading overlay. Safe to call multiple times.
|
||||
func hideSpinner() {
|
||||
spinnerOverlay?.removeFromSuperview()
|
||||
spinnerOverlay = nil
|
||||
}
|
||||
|
||||
/// Post a JSON-RPC message to injected scripts inside the WebView.
|
||||
func postMessageToWebView(_ message: [String: Any]) {
|
||||
guard let jsonData = try? JSONSerialization.data(withJSONObject: message),
|
||||
let jsonString = String(data: jsonData, encoding: .utf8) else {
|
||||
return
|
||||
}
|
||||
|
||||
let js = """
|
||||
(function() {
|
||||
if (window.__sandboxBridge && window.__sandboxBridge.onMessage) {
|
||||
window.__sandboxBridge.onMessage(\(jsonString));
|
||||
}
|
||||
})();
|
||||
"""
|
||||
webView.evaluateJavaScript(js, completionHandler: nil)
|
||||
}
|
||||
|
||||
// MARK: - WKScriptMessageHandler
|
||||
|
||||
/// Receive messages from injected scripts via webkit.messageHandlers.sandboxBridge.
|
||||
func userContentController(
|
||||
_ userContentController: WKUserContentController,
|
||||
didReceive message: WKScriptMessage
|
||||
) {
|
||||
guard message.name == "sandboxBridge",
|
||||
let body = message.body as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
plugin?.emitScriptMessage(sandboxId: id, message: body)
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
/// Remove the spinner overlay once the first page finishes loading.
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
hideSpinner()
|
||||
}
|
||||
|
||||
// MARK: - Bridge Script
|
||||
|
||||
/// JavaScript injected at document start that provides:
|
||||
/// - `window.parent.postMessage()` emulation via WKScriptMessageHandler
|
||||
/// - `window.__sandboxBridge.onMessage()` for receiving messages from parent
|
||||
/// - `window.addEventListener("message", ...)` support for injected scripts
|
||||
private static func bridgeScript(scheme: String) -> String {
|
||||
return """
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Message listeners registered by injected scripts.
|
||||
var messageListeners = [];
|
||||
|
||||
// Bridge object for native communication.
|
||||
window.__sandboxBridge = {
|
||||
onMessage: function(data) {
|
||||
// Dispatch to all registered message listeners.
|
||||
var event = {
|
||||
data: data,
|
||||
origin: '\(scheme)://app',
|
||||
source: window.parent,
|
||||
type: 'message'
|
||||
};
|
||||
for (var i = 0; i < messageListeners.length; i++) {
|
||||
try {
|
||||
messageListeners[i](event);
|
||||
} catch (e) {
|
||||
console.error('[SandboxBridge] Listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Override addEventListener to capture "message" listeners.
|
||||
var originalAddEventListener = window.addEventListener;
|
||||
window.addEventListener = function(type, listener, options) {
|
||||
if (type === 'message' && typeof listener === 'function') {
|
||||
messageListeners.push(listener);
|
||||
}
|
||||
return originalAddEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
var originalRemoveEventListener = window.removeEventListener;
|
||||
window.removeEventListener = function(type, listener, options) {
|
||||
if (type === 'message') {
|
||||
var idx = messageListeners.indexOf(listener);
|
||||
if (idx !== -1) messageListeners.splice(idx, 1);
|
||||
}
|
||||
return originalRemoveEventListener.call(window, type, listener, options);
|
||||
};
|
||||
|
||||
// Emulate window.parent.postMessage for scripts that use it
|
||||
// (e.g. the webxdc bridge script, preview injected script).
|
||||
if (!window.parent || window.parent === window) {
|
||||
window.parent = {};
|
||||
}
|
||||
window.parent.postMessage = function(data, targetOrigin, transfer) {
|
||||
if (data && typeof data === 'object' && data.jsonrpc === '2.0') {
|
||||
try {
|
||||
window.webkit.messageHandlers.sandboxBridge.postMessage(data);
|
||||
} catch (e) {
|
||||
console.error('[SandboxBridge] postMessage failed:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
})();
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SandboxSchemeHandler
|
||||
|
||||
/// WKURLSchemeHandler that intercepts all requests on the sandbox's custom
|
||||
/// URL scheme and forwards them to the JS layer as fetch events.
|
||||
private class SandboxSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
private let sandboxId: String
|
||||
private let scheme: String
|
||||
private weak var plugin: SandboxPlugin?
|
||||
|
||||
/// Pending scheme tasks waiting for a response from JS.
|
||||
/// Key: requestId (UUID string), Value: the WKURLSchemeTask to respond to.
|
||||
private var pendingTasks: [String: WKURLSchemeTask] = [:]
|
||||
private let lock = NSLock()
|
||||
|
||||
init(sandboxId: String, scheme: String, plugin: SandboxPlugin) {
|
||||
self.sandboxId = sandboxId
|
||||
self.scheme = scheme
|
||||
self.plugin = plugin
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
|
||||
let request = urlSchemeTask.request
|
||||
guard let url = request.url else {
|
||||
urlSchemeTask.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No URL in request"]
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
let requestId = UUID().uuidString
|
||||
|
||||
lock.lock()
|
||||
pendingTasks[requestId] = urlSchemeTask
|
||||
lock.unlock()
|
||||
|
||||
// Serialise the request for the fetch event.
|
||||
// Rewrite the URL so it looks like a normal HTTP URL to the parent
|
||||
// (e.g. "sbx-abc123://app/index.html" -> "https://<sandboxId>.sandbox.native/index.html")
|
||||
// The JS side only cares about the pathname.
|
||||
var headers: [String: String] = [:]
|
||||
if let allHeaders = request.allHTTPHeaderFields {
|
||||
headers = allHeaders
|
||||
}
|
||||
|
||||
var bodyBase64: String? = nil
|
||||
if let bodyData = request.httpBody {
|
||||
bodyBase64 = bodyData.base64EncodedString()
|
||||
}
|
||||
|
||||
let path = url.path.isEmpty ? "/" : url.path
|
||||
let rewrittenURL = "https://\(sandboxId).sandbox.native\(path)"
|
||||
|
||||
let serialisedRequest: [String: Any] = [
|
||||
"url": rewrittenURL,
|
||||
"method": request.httpMethod ?? "GET",
|
||||
"headers": headers,
|
||||
"body": bodyBase64 as Any,
|
||||
]
|
||||
|
||||
plugin?.emitFetchRequest(
|
||||
sandboxId: sandboxId,
|
||||
requestId: requestId,
|
||||
request: serialisedRequest
|
||||
)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
|
||||
// Remove the task from pending — JS response will be ignored if it arrives later.
|
||||
lock.lock()
|
||||
let removed = pendingTasks.first(where: { $0.value === urlSchemeTask })
|
||||
if let key = removed?.key {
|
||||
pendingTasks.removeValue(forKey: key)
|
||||
}
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
/// Called by the plugin when JS responds to a fetch request.
|
||||
func resolveRequest(
|
||||
requestId: String,
|
||||
status: Int,
|
||||
statusText: String,
|
||||
headers: [String: String],
|
||||
bodyBase64: String?
|
||||
) {
|
||||
lock.lock()
|
||||
guard let task = pendingTasks.removeValue(forKey: requestId) else {
|
||||
lock.unlock()
|
||||
return
|
||||
}
|
||||
lock.unlock()
|
||||
|
||||
// Decode the base64 body.
|
||||
var bodyData: Data? = nil
|
||||
if let b64 = bodyBase64 {
|
||||
bodyData = Data(base64Encoded: b64)
|
||||
}
|
||||
|
||||
// Build the response.
|
||||
// Use the task's original URL for the response.
|
||||
let responseURL = task.request.url ?? URL(string: "\(scheme)://app/")!
|
||||
let response = HTTPURLResponse(
|
||||
url: responseURL,
|
||||
statusCode: status,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: headers
|
||||
)!
|
||||
|
||||
DispatchQueue.main.async {
|
||||
task.didReceive(response)
|
||||
if let data = bodyData {
|
||||
task.didReceive(data)
|
||||
}
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel all pending tasks (called on destroy).
|
||||
func cancelAll() {
|
||||
lock.lock()
|
||||
let tasks = pendingTasks
|
||||
pendingTasks.removeAll()
|
||||
lock.unlock()
|
||||
|
||||
for (_, task) in tasks {
|
||||
task.didFailWithError(NSError(
|
||||
domain: "SandboxPlugin", code: -999,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Sandbox destroyed"]
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
app_identifier("pub.ditto.app")
|
||||
team_id("GZLTTH5DLM")
|
||||
@@ -0,0 +1,146 @@
|
||||
default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
# ─── Lanes ────────────────────────────────────────────────────────────
|
||||
|
||||
desc "Build and sign the App Store IPA. Output at ../artifacts/Ditto.ipa."
|
||||
lane :build_ipa do
|
||||
setup_lane_signing!
|
||||
build_release_ipa!
|
||||
end
|
||||
|
||||
desc "Submit an already-built IPA to App Store Connect for review. " \
|
||||
"Set IPA_PATH to the IPA's location."
|
||||
lane :submit_release do
|
||||
ipa_path = ENV.fetch("IPA_PATH") do
|
||||
UI.user_error!("submit_release requires the IPA_PATH env var")
|
||||
end
|
||||
UI.user_error!("IPA not found at #{ipa_path}") unless File.exist?(ipa_path)
|
||||
submit_release_for_review!(ipa_path)
|
||||
end
|
||||
|
||||
desc "Build, sign, and submit Ditto to the App Store for review (single-step convenience)."
|
||||
lane :release do
|
||||
setup_lane_signing!
|
||||
build_release_ipa!
|
||||
# Use the IPA path set by build_app rather than recomputing it from
|
||||
# __dir__, which gets fragile across fastlane-relative paths.
|
||||
ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH]
|
||||
UI.user_error!("build_app did not set IPA_OUTPUT_PATH") unless ipa_path
|
||||
submit_release_for_review!(ipa_path)
|
||||
end
|
||||
|
||||
desc "Submit an already-uploaded build for review (skip build/upload). " \
|
||||
"Use BUILD_NUMBER and VERSION env vars."
|
||||
lane :submit_only do
|
||||
submit_release_for_review!(nil)
|
||||
end
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
def setup_lane_signing!
|
||||
# Create an ephemeral keychain so we never touch the login keychain.
|
||||
setup_ci
|
||||
|
||||
api_key = build_api_key!
|
||||
|
||||
# Fetch encrypted distribution cert + provisioning profile from the
|
||||
# shared certificates repo. --readonly: never mutate from CI.
|
||||
# Passing api_key makes match contact Apple's portal to verify the
|
||||
# cert is still valid for the team — fails fast on revoked/expired
|
||||
# certs instead of letting xcodebuild stumble later.
|
||||
match(type: "appstore", readonly: true, api_key: api_key)
|
||||
|
||||
api_key
|
||||
end
|
||||
|
||||
def build_api_key!
|
||||
# Build the API key hash inline. We avoid the app_store_connect_api_key
|
||||
# action because it sets APP_STORE_CONNECT_API_KEY_PATH (path to the .p8)
|
||||
# which collides with match's APP_STORE_CONNECT_API_KEY_PATH (path to a
|
||||
# JSON descriptor). Same env name, different formats.
|
||||
@api_key ||= {
|
||||
key_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ID"),
|
||||
issuer_id: ENV.fetch("APP_STORE_CONNECT_API_KEY_ISSUER_ID"),
|
||||
key: File.binread(ENV.fetch("ASC_KEY_PATH")),
|
||||
duration: 1200,
|
||||
in_house: false,
|
||||
}
|
||||
end
|
||||
|
||||
def build_release_ipa!
|
||||
# Stamp build number from CI pipeline ID so every release is monotonically increasing.
|
||||
increment_build_number(
|
||||
xcodeproj: "App/App.xcodeproj",
|
||||
build_number: ENV.fetch("CI_PIPELINE_IID"),
|
||||
)
|
||||
|
||||
# Marketing version is set externally (sed in CI) before this lane runs.
|
||||
|
||||
build_app(
|
||||
project: "App/App.xcodeproj",
|
||||
scheme: "App",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
output_directory: "../artifacts",
|
||||
output_name: "Ditto.ipa",
|
||||
clean: true,
|
||||
# Override the Xcode project's Automatic signing for this build only.
|
||||
# Match has already installed the AppStore cert + profile into the
|
||||
# ephemeral keychain; tell xcodebuild to use them explicitly so it
|
||||
# doesn't also try to find an iOS Development cert (which we never
|
||||
# provision in CI).
|
||||
xcargs: [
|
||||
"CODE_SIGN_STYLE=Manual",
|
||||
"CODE_SIGN_IDENTITY='Apple Distribution'",
|
||||
"PROVISIONING_PROFILE_SPECIFIER='match AppStore pub.ditto.app'",
|
||||
"DEVELOPMENT_TEAM=GZLTTH5DLM",
|
||||
].join(" "),
|
||||
export_options: {
|
||||
method: "app-store",
|
||||
signingStyle: "manual",
|
||||
teamID: "GZLTTH5DLM",
|
||||
provisioningProfiles: {
|
||||
"pub.ditto.app" => "match AppStore pub.ditto.app",
|
||||
},
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
# If ipa_path is nil, deliver picks up the latest processed build for the
|
||||
# configured app version (used by the submit_only lane).
|
||||
def submit_release_for_review!(ipa_path)
|
||||
api_key = build_api_key!
|
||||
|
||||
options = {
|
||||
api_key: api_key,
|
||||
submit_for_review: true,
|
||||
automatic_release: false,
|
||||
force: true,
|
||||
precheck_include_in_app_purchases: false,
|
||||
# Don't try to PATCH content rights on every submit — Apple's API
|
||||
# rejects updates to contentRightsDeclaration once the listing has
|
||||
# an established state. The values stay as set in the App Store
|
||||
# Connect UI / from a prior submission.
|
||||
submission_information: {
|
||||
export_compliance_uses_encryption: false,
|
||||
},
|
||||
skip_screenshots: true,
|
||||
# Keep skip_app_version_update=false: deliver needs to PATCH the
|
||||
# version's whatsNew (release notes) and platform-version metadata
|
||||
# before submit_for_review will accept the version.
|
||||
skip_app_version_update: false,
|
||||
skip_metadata: false,
|
||||
metadata_path: "./fastlane/metadata",
|
||||
run_precheck_before_submit: false,
|
||||
}
|
||||
options[:ipa] = ipa_path if ipa_path
|
||||
if ENV["BUILD_NUMBER"]
|
||||
options[:build_number] = ENV["BUILD_NUMBER"]
|
||||
options[:skip_binary_upload] = true
|
||||
end
|
||||
options[:app_version] = ENV["VERSION"] if ENV["VERSION"]
|
||||
|
||||
deliver(**options)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
git_url("https://gitlab.com/soapbox-pub/certificates.git")
|
||||
storage_mode("git")
|
||||
type("appstore")
|
||||
app_identifier(["pub.ditto.app"])
|
||||
team_id("GZLTTH5DLM")
|
||||
@@ -0,0 +1 @@
|
||||
Placeholder. CI overwrites this file with the release summary paragraph from CHANGELOG.md (the leading plaintext paragraph in the section for the current version).
|
||||
Generated
+33
-17
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agora",
|
||||
"version": "2.8.0",
|
||||
"version": "2.14.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agora",
|
||||
"version": "2.8.0",
|
||||
"version": "2.14.4",
|
||||
"dependencies": {
|
||||
"@breeztech/breez-sdk-spark": "^0.13.2-dev1",
|
||||
"@capacitor/app": "^8.0.0",
|
||||
@@ -64,9 +64,9 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@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",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
@@ -107,6 +107,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"d3-celestial": "^0.7.35",
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
@@ -2601,11 +2602,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/nostrify": {
|
||||
"version": "0.51.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.51.1.tgz",
|
||||
"integrity": "sha512-oPJhUiO1TlV5sGYizqAP4GvLijib34Uwh48wxlFimR/2MoCuSmab4AppcztGPNwxQoTKkJbLJwsSpl42V+WIXA==",
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/nostrify/-/nostrify-0.52.0.tgz",
|
||||
"integrity": "sha512-x+gc8rxJ4C+mnoFgd4Zzi0JnXUz0acQA69nKqR0fnWhpc/KiQosgIILfaNUTWkecTPJ92iazT4Es+TrUUSFcRg==",
|
||||
"dependencies": {
|
||||
"@nostrify/types": "0.36.9",
|
||||
"@nostrify/types": "0.37.0",
|
||||
"@scure/base": "^2.0.0",
|
||||
"@smithy/util-base64": "^4.3.0",
|
||||
"@smithy/util-hex-encoding": "^4.2.0",
|
||||
@@ -2642,12 +2643,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nostrify/react": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.5.1.tgz",
|
||||
"integrity": "sha512-gQUct8A7KLKvoLtv4bHpVDfmvzJlIHjZZI6DMui8vrSuzm8IqMRdAYADbR3ry1mlIQp8/c4EeR24piBpHK0WUw==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/react/-/react-0.6.0.tgz",
|
||||
"integrity": "sha512-6vjF5UagAW5QRpxAu/of9lyI7837wwoyX/NLGQbEs6fcMQXjTo/m7wUBPipoj0E460QvyNXff5O8Byn72enWbQ==",
|
||||
"dependencies": {
|
||||
"@nostrify/nostrify": "0.51.1",
|
||||
"@nostrify/types": "0.36.9"
|
||||
"@nostrify/nostrify": "0.52.0",
|
||||
"@nostrify/types": "0.37.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
@@ -2657,9 +2658,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nostrify/types": {
|
||||
"version": "0.36.9",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/types/-/types-0.36.9.tgz",
|
||||
"integrity": "sha512-tMx/r0W+QoVRRgs8d6ltaSgrftasOXuFsi33kW8WirswCy2b3UR1tqRgc0iBU9zRa9XR0nlej/wJZW+6wUFi+Q=="
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@nostrify/types/-/types-0.37.0.tgz",
|
||||
"integrity": "sha512-P0AKR+qcNeBBTA5UDnJM6SxLAQbgud2+ZcdVyheoP37XGQvi7rUncQUDKwebG+Ui5kswp/IPEmvqNtCMQpwRoA=="
|
||||
},
|
||||
"node_modules/@ocavue/utils": {
|
||||
"version": "1.6.0",
|
||||
@@ -8346,6 +8347,12 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3": {
|
||||
"version": "3.5.17",
|
||||
"resolved": "https://registry.npmjs.org/d3/-/d3-3.5.17.tgz",
|
||||
"integrity": "sha512-yFk/2idb8OHPKkbAL8QaOaqENNoMhIaSHZerk3oQsECwkObkCpJyjYwCe+OHiq6UEdhe1m8ZGARRRO3ljFjlKg==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
@@ -8358,6 +8365,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-celestial": {
|
||||
"version": "0.7.35",
|
||||
"resolved": "https://registry.npmjs.org/d3-celestial/-/d3-celestial-0.7.35.tgz",
|
||||
"integrity": "sha512-cURxIl0E+FGWnYj6gTDt80SjuiM9lklcGykj/skVy7glDg5nj/QxTUoPPArU+bpEQ+1fLy5hi920OvJ/TgliRw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
|
||||
+5
-4
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"test": "npm i --silent && tsc --noEmit && eslint --cache && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||
"cap:sync": "npx cap sync && node scripts/patch-cap-config.mjs",
|
||||
"keygen": "keytool -genkey -v -keystore android/app/my-upload-key.keystore -keyalg RSA -keysize 2048 -validity 10000 -alias upload",
|
||||
"icons": "bash scripts/generate-icons.sh"
|
||||
@@ -71,9 +71,9 @@
|
||||
"@milkdown/utils": "^7.20.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@nostrify/nostrify": "^0.51.1",
|
||||
"@nostrify/react": "^0.5.1",
|
||||
"@nostrify/types": "^0.36.9",
|
||||
"@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",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.1",
|
||||
@@ -114,6 +114,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"d3-celestial": "^0.7.35",
|
||||
"d3-scale": "^4.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"dompurify": "^3.3.3",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
@@ -87,5 +87,18 @@
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow"
|
||||
}
|
||||
]
|
||||
],
|
||||
"related_applications": [
|
||||
{
|
||||
"platform": "play",
|
||||
"url": "https://play.google.com/store/apps/details?id=pub.ditto.app",
|
||||
"id": "pub.ditto.app"
|
||||
},
|
||||
{
|
||||
"platform": "itunes",
|
||||
"url": "https://apps.apple.com/us/app/ditto-fun-social-media/id6761851821",
|
||||
"id": "6761851821"
|
||||
}
|
||||
],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extract release notes from CHANGELOG.md for a given version.
|
||||
*
|
||||
* The CHANGELOG follows Keep a Changelog format with one extension: each release
|
||||
* section MAY begin with a single plaintext paragraph (the "summary") before any
|
||||
* `### Added` / `### Changed` / etc. heading. The summary is used as the release
|
||||
* blurb on the App Store, Play Store, and the in-app version-update toast. The
|
||||
* full section body is used as the GitLab Release description.
|
||||
*
|
||||
* Format:
|
||||
*
|
||||
* ## [X.Y.Z] - YYYY-MM-DD
|
||||
*
|
||||
* A short single-paragraph summary (max 500 characters by convention).
|
||||
*
|
||||
* ### Added
|
||||
* - bullet
|
||||
* - bullet
|
||||
*
|
||||
* ### Changed
|
||||
* - bullet
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/extract-release-notes.mjs <version> [--summary] [--changelog <path>]
|
||||
*
|
||||
* --summary Print only the summary paragraph (no headings, no bullets).
|
||||
* Falls back to "Ditto vX.Y.Z" if the section has no summary.
|
||||
* --changelog Path to the changelog file. Defaults to CHANGELOG.md.
|
||||
*
|
||||
* Exits 0 with the extracted text on stdout. Exits non-zero if the version is
|
||||
* not found in the changelog.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { argv, exit, stderr, stdout } from 'node:process';
|
||||
|
||||
function parseArgs(args) {
|
||||
let version;
|
||||
let summary = false;
|
||||
let changelog = 'CHANGELOG.md';
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--summary') summary = true;
|
||||
else if (arg === '--changelog') changelog = args[++i];
|
||||
else if (!arg.startsWith('--') && !version) version = arg;
|
||||
else {
|
||||
stderr.write(`Unknown argument: ${arg}\n`);
|
||||
exit(2);
|
||||
}
|
||||
}
|
||||
if (!version) {
|
||||
stderr.write('Usage: extract-release-notes.mjs <version> [--summary] [--changelog <path>]\n');
|
||||
exit(2);
|
||||
}
|
||||
// Strip a leading "v" so callers can pass either "v2.14.3" or "2.14.3".
|
||||
if (version.startsWith('v')) version = version.slice(1);
|
||||
return { version, summary, changelog };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the lines belonging to a single version section from changelog text,
|
||||
* not including the version heading itself.
|
||||
*/
|
||||
function extractSection(markdown, version) {
|
||||
const lines = markdown.split('\n');
|
||||
const headingPattern = new RegExp(
|
||||
`^## \\[${version.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\]`,
|
||||
);
|
||||
const nextHeadingPattern = /^## \[/;
|
||||
let inSection = false;
|
||||
const out = [];
|
||||
for (const line of lines) {
|
||||
if (!inSection) {
|
||||
if (headingPattern.test(line)) {
|
||||
inSection = true;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (nextHeadingPattern.test(line)) break;
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
return inSection ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the leading non-blank paragraph from a section, stopping at the first
|
||||
* `###` category heading or `-` bullet. Returns null if no summary paragraph.
|
||||
*/
|
||||
function extractSummary(sectionLines) {
|
||||
const paragraph = [];
|
||||
let started = false;
|
||||
for (const line of sectionLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!started) {
|
||||
if (!trimmed) continue;
|
||||
// If the very first non-blank line is a heading or bullet, there's no summary.
|
||||
if (trimmed.startsWith('#') || trimmed.startsWith('- ')) return null;
|
||||
started = true;
|
||||
paragraph.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
// We're inside the paragraph. A blank line, a heading, or a bullet ends it.
|
||||
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('- ')) break;
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
return paragraph.length ? paragraph.join(' ') : null;
|
||||
}
|
||||
|
||||
/** Trim leading and trailing blank lines from a list of lines. */
|
||||
function trimBlankEdges(lines) {
|
||||
let start = 0;
|
||||
let end = lines.length;
|
||||
while (start < end && !lines[start].trim()) start++;
|
||||
while (end > start && !lines[end - 1].trim()) end--;
|
||||
return lines.slice(start, end);
|
||||
}
|
||||
|
||||
const { version, summary, changelog } = parseArgs(argv.slice(2));
|
||||
const markdown = readFileSync(changelog, 'utf8');
|
||||
const section = extractSection(markdown, version);
|
||||
|
||||
if (!section) {
|
||||
stderr.write(`Version ${version} not found in ${changelog}\n`);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
const text = extractSummary(section);
|
||||
stdout.write(text ?? `Ditto v${version}`);
|
||||
stdout.write('\n');
|
||||
} else {
|
||||
const body = trimBlankEdges(section).join('\n');
|
||||
if (body) {
|
||||
stdout.write(body);
|
||||
stdout.write('\n');
|
||||
} else {
|
||||
stdout.write(`Ditto v${version}\n`);
|
||||
}
|
||||
}
|
||||
+16
-15
@@ -1,12 +1,10 @@
|
||||
// NOTE: This file should normally not be modified unless you are adding a new provider.
|
||||
// To add new routes, edit the AppRouter.tsx file.
|
||||
|
||||
import { Capacitor, SystemBars, SystemBarsStyle } from "@capacitor/core";
|
||||
import { NostrLoginProvider } from "@nostrify/react/login";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { InferSeoMetaPlugin } from "@unhead/addons";
|
||||
import { createHead, UnheadProvider } from "@unhead/react/client";
|
||||
import { useEffect } from "react";
|
||||
import { AppProvider } from "@/components/AppProvider";
|
||||
import { DMProviderWrapper } from "@/components/DMProviderWrapper";
|
||||
import { InitialSyncGate } from "@/components/InitialSyncGate";
|
||||
@@ -45,11 +43,13 @@ const queryClient = new QueryClient({
|
||||
const hardcodedConfig: AppConfig = {
|
||||
appName: "Agora",
|
||||
appId: "agora",
|
||||
shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined,
|
||||
homePage: "feed",
|
||||
client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp",
|
||||
magicMouse: false,
|
||||
theme: "system",
|
||||
useAppRelays: true,
|
||||
useUserRelays: false,
|
||||
relayMetadata: {
|
||||
relays: [],
|
||||
updatedAt: 0,
|
||||
@@ -59,8 +59,12 @@ const hardcodedConfig: AppConfig = {
|
||||
feedIncludeComments: true,
|
||||
feedIncludeReposts: true,
|
||||
feedIncludeGenericReposts: true,
|
||||
feedIncludeReactions: false,
|
||||
feedIncludeZaps: false,
|
||||
feedIncludeArticles: true,
|
||||
showArticles: true,
|
||||
showHighlights: true,
|
||||
feedIncludeHighlights: false,
|
||||
showEvents: true,
|
||||
feedIncludeEvents: true,
|
||||
showVines: true,
|
||||
@@ -69,13 +73,13 @@ const hardcodedConfig: AppConfig = {
|
||||
showTreasureGeocaches: true,
|
||||
showTreasureFoundLogs: true,
|
||||
showColors: true,
|
||||
showPacks: true,
|
||||
showPeopleLists: true,
|
||||
feedIncludeVines: true,
|
||||
feedIncludePolls: true,
|
||||
feedIncludeTreasureGeocaches: true,
|
||||
feedIncludeTreasureFoundLogs: true,
|
||||
feedIncludeColors: true,
|
||||
feedIncludePacks: true,
|
||||
feedIncludePeopleLists: true,
|
||||
showDecks: true,
|
||||
feedIncludeDecks: true,
|
||||
showWebxdc: true,
|
||||
@@ -103,9 +107,15 @@ const hardcodedConfig: AppConfig = {
|
||||
showBadges: true,
|
||||
showBadgeDefinitions: true,
|
||||
showProfileBadges: true,
|
||||
showBadgeAwards: true,
|
||||
feedIncludeBadgeDefinitions: true,
|
||||
feedIncludeProfileBadges: true,
|
||||
feedIncludeBadgeAwards: true,
|
||||
feedIncludeVanish: true,
|
||||
showBirdstar: true,
|
||||
feedIncludeBirdDetections: true,
|
||||
feedIncludeBirdex: true,
|
||||
feedIncludeConstellations: true,
|
||||
followsFeedShowReplies: true,
|
||||
},
|
||||
sidebarOrder: [
|
||||
@@ -138,9 +148,11 @@ const hardcodedConfig: AppConfig = {
|
||||
plausibleDomain: import.meta.env.VITE_PLAUSIBLE_DOMAIN || "",
|
||||
plausibleEndpoint: import.meta.env.VITE_PLAUSIBLE_ENDPOINT || "",
|
||||
savedFeeds: [],
|
||||
autoplayVideos: false,
|
||||
imageQuality: 'compressed',
|
||||
curatorPubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d',
|
||||
sandboxDomain: 'iframe.diy',
|
||||
esploraBaseUrl: 'https://mempool.space/api',
|
||||
sidebarWidgets: [
|
||||
{ id: 'trends' },
|
||||
{ id: 'hot-posts' },
|
||||
@@ -185,17 +197,6 @@ const defaultConfig: AppConfig = {
|
||||
export function App() {
|
||||
useNsecPasteGuard();
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize system bars for mobile apps.
|
||||
// On Android 16+ (API 36), edge-to-edge is enforced by the OS so
|
||||
// setOverlaysWebView / setBackgroundColor no longer work. The new
|
||||
// SystemBars API (bundled with @capacitor/core 8+) is the replacement.
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
SystemBars.setStyle({ style: SystemBarsStyle.Dark }).catch(() => {
|
||||
// SystemBars may not be available on all platforms
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UnheadProvider head={head}>
|
||||
|
||||
+119
-5
@@ -21,18 +21,25 @@ import MessagesPage from "./pages/Messages";
|
||||
// Lazy-loaded compose modal (pulls in emoji-mart ~620K)
|
||||
const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").then(m => ({ default: m.ReplyComposeModal })));
|
||||
|
||||
// Lazy-loaded emoji pack dialog
|
||||
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
|
||||
|
||||
// HomePage eagerly imported all page components; now lazy-loaded
|
||||
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
|
||||
|
||||
// All other pages: code-split via React.lazy
|
||||
const ActionsPage = lazy(() => import("./pages/ActionsPage"));
|
||||
const AdvancedSettingsPage = lazy(() => import("./pages/AdvancedSettingsPage").then(m => ({ default: m.AdvancedSettingsPage })));
|
||||
const AIChatPage = lazy(() => import("./pages/AIChatPage").then(m => ({ default: m.AIChatPage })));
|
||||
const AppearanceSettingsPage = lazy(() => import("./pages/AppearanceSettingsPage").then(m => ({ default: m.AppearanceSettingsPage })));
|
||||
const ArchivePage = lazy(() => import("./pages/ArchivePage").then(m => ({ default: m.ArchivePage })));
|
||||
const ArticleEditorPage = lazy(() => import("./pages/ArticleEditorPage").then(m => ({ default: m.ArticleEditorPage })));
|
||||
const BadgesPage = lazy(() => import("./pages/BadgesPage").then(m => ({ default: m.BadgesPage })));
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const BlueskyPage = lazy(() => import("./pages/BlueskyPage").then(m => ({ default: m.BlueskyPage })));
|
||||
const BookmarksPage = lazy(() => import("./pages/BookmarksPage").then(m => ({ default: m.BookmarksPage })));
|
||||
const BooksPage = lazy(() => import("./pages/BooksPage").then(m => ({ default: m.BooksPage })));
|
||||
const ChangelogPage = lazy(() => import("./pages/ChangelogPage").then(m => ({ default: m.ChangelogPage })));
|
||||
const CommunitiesPage = lazy(() => import("./pages/CommunitiesPage").then(m => ({ default: m.CommunitiesPage })));
|
||||
const ContentPage = lazy(() => import("./pages/ContentPage").then(m => ({ default: m.ContentPage })));
|
||||
const ContentSettingsPage = lazy(() => import("./pages/ContentSettingsPage").then(m => ({ default: m.ContentSettingsPage })));
|
||||
const CSAEPolicyPage = lazy(() => import("./pages/CSAEPolicyPage").then(m => ({ default: m.CSAEPolicyPage })));
|
||||
@@ -48,27 +55,41 @@ const LetterPreferencesPage = lazy(() => import("./pages/LetterPreferencesPage")
|
||||
const LettersPage = lazy(() => import("./pages/LettersPage").then(m => ({ default: m.LettersPage })));
|
||||
const MagicSettingsPage = lazy(() => import("./pages/MagicSettingsPage").then(m => ({ default: m.MagicSettingsPage })));
|
||||
const MessagingSettingsPage = lazy(() => import("./pages/MessagingSettingsPage").then(m => ({ default: m.MessagingSettingsPage })));
|
||||
const MusicPage = lazy(() => import("./pages/MusicPage").then(m => ({ default: m.MusicPage })));
|
||||
const NetworkSettingsPage = lazy(() => import("./pages/NetworkSettingsPage").then(m => ({ default: m.NetworkSettingsPage })));
|
||||
const NIP19Page = lazy(() => import("./pages/NIP19Page").then(m => ({ default: m.NIP19Page })));
|
||||
const NotificationSettings = lazy(() => import("./pages/NotificationSettings").then(m => ({ default: m.NotificationSettings })));
|
||||
const NotificationsPage = lazy(() => import("./pages/NotificationsPage").then(m => ({ default: m.NotificationsPage })));
|
||||
const OrganizersPage = lazy(() => import("./pages/OrganizersPage").then(m => ({ default: m.OrganizersPage })));
|
||||
const PhotosFeedPage = lazy(() => import("./pages/PhotosFeedPage").then(m => ({ default: m.PhotosFeedPage })));
|
||||
const PodcastsFeedPage = lazy(() => import("./pages/PodcastsFeedPage").then(m => ({ default: m.PodcastsFeedPage })));
|
||||
const PrivacyPolicyPage = lazy(() => import("./pages/PrivacyPolicyPage").then(m => ({ default: m.PrivacyPolicyPage })));
|
||||
const ProfileSettings = lazy(() => import("./pages/ProfileSettings").then(m => ({ default: m.ProfileSettings })));
|
||||
const RelayPage = lazy(() => import("./pages/RelayPage").then(m => ({ default: m.RelayPage })));
|
||||
const SearchPage = lazy(() => import("./pages/SearchPage").then(m => ({ default: m.SearchPage })));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage").then(m => ({ default: m.SettingsPage })));
|
||||
const TreasuresPage = lazy(() => import("./pages/TreasuresPage").then(m => ({ default: m.TreasuresPage })));
|
||||
const TrendsPage = lazy(() => import("./pages/TrendsPage").then(m => ({ default: m.TrendsPage })));
|
||||
const UserListsPage = lazy(() => import("./pages/UserListsPage").then(m => ({ default: m.UserListsPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const VerifiedPage = lazy(() => import("./pages/VerifiedPage").then(m => ({ default: m.VerifiedPage })));
|
||||
const VideosFeedPage = lazy(() => import("./pages/VideosFeedPage").then(m => ({ default: m.VideosFeedPage })));
|
||||
const VinesFeedPage = lazy(() => import("./pages/VinesFeedPage").then(m => ({ default: m.VinesFeedPage })));
|
||||
const WalletPage = lazy(() => import("./pages/WalletPage").then(m => ({ default: m.WalletPage })));
|
||||
const WalletSettingsPage = lazy(() => import("./pages/WalletSettingsPage").then(m => ({ default: m.WalletSettingsPage })));
|
||||
const WebxdcFeedPage = lazy(() => import("./pages/WebxdcFeedPage").then(m => ({ default: m.WebxdcFeedPage })));
|
||||
const WikipediaPage = lazy(() => import("./pages/WikipediaPage").then(m => ({ default: m.WikipediaPage })));
|
||||
const WorldPage = lazy(() => import("./pages/WorldPage").then(m => ({ default: m.WorldPage })));
|
||||
const FollowPage = lazy(() => import("./pages/FollowPage").then(m => ({ default: m.FollowPage })));
|
||||
const RemoteLoginSuccessPage = lazy(() => import("./pages/RemoteLoginSuccessPage").then(m => ({ default: m.RemoteLoginSuccessPage })));
|
||||
|
||||
const pollsDef = getExtraKindDef("polls")!;
|
||||
const colorsDef = getExtraKindDef("colors")!;
|
||||
const packsDef = getExtraKindDef("packs")!;
|
||||
const articlesDef = getExtraKindDef("articles")!;
|
||||
const decksDef = getExtraKindDef("decks")!;
|
||||
const emojisDef = getExtraKindDef("emojis")!;
|
||||
const developmentDef = getExtraKindDef("development")!;
|
||||
const highlightsDef = getExtraKindDef("highlights")!;
|
||||
|
||||
/** Polls feed page with a FAB that opens the compose modal (poll mode via + menu). */
|
||||
function PollsFeedPage() {
|
||||
@@ -90,6 +111,26 @@ function PollsFeedPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Emoji feed page with a FAB that opens the emoji pack creation dialog. */
|
||||
function EmojiFeedPage() {
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<KindFeedPage
|
||||
kind={emojisDef.kind}
|
||||
title={emojisDef.label}
|
||||
icon={sidebarItemIcon("emojis", "size-5")}
|
||||
onFabClick={() => setComposeOpen(true)}
|
||||
/>
|
||||
{composeOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<EmojiPackDialog open={composeOpen} onOpenChange={setComposeOpen} />
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Redirects /profile to the user's canonical profile URL (nip05 or npub). */
|
||||
function ProfileRedirect() {
|
||||
const { user, metadata } = useCurrentUser();
|
||||
@@ -119,6 +160,7 @@ export function AppRouter() {
|
||||
<Route path="/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/trends" element={<TrendsPage />} />
|
||||
<Route path="/profile" element={<ProfileRedirect />} />
|
||||
<Route path="/t/:tag" element={<HashtagPage />} />
|
||||
<Route path="/g/:geohash" element={<GeotagPage />} />
|
||||
@@ -143,7 +185,38 @@ export function AppRouter() {
|
||||
<Route path="/lists" element={<UserListsPage />} />
|
||||
<Route path="/events" element={<EventsFeedPage />} />
|
||||
<Route path="/photos" element={<PhotosFeedPage />} />
|
||||
<Route path="/videos" element={<VideosFeedPage />} />
|
||||
{/* /streams redirects to /videos for backward compatibility */}
|
||||
<Route
|
||||
path="/streams"
|
||||
element={<Navigate to="/videos" replace />}
|
||||
/>
|
||||
<Route path="/vines" element={<VinesFeedPage />} />
|
||||
<Route path="/music" element={<MusicPage />} />
|
||||
<Route path="/podcasts" element={<PodcastsFeedPage />} />
|
||||
<Route path="/polls" element={<PollsFeedPage />} />
|
||||
<Route path="/treasures" element={<TreasuresPage />} />
|
||||
<Route
|
||||
path="/colors"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={colorsDef.kind}
|
||||
title={colorsDef.label}
|
||||
icon={sidebarItemIcon("colors", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packs"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={packsDef.kind}
|
||||
title={packsDef.label}
|
||||
icon={sidebarItemIcon("packs", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/webxdc" element={<WebxdcFeedPage />} />
|
||||
<Route path="/articles/new" element={<ArticleEditorPage />} />
|
||||
<Route path="/articles/edit/:naddr" element={<ArticleEditorPage />} />
|
||||
<Route
|
||||
@@ -157,11 +230,52 @@ export function AppRouter() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route
|
||||
path="/highlights"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={highlightsDef.kind}
|
||||
title={highlightsDef.label}
|
||||
icon={sidebarItemIcon("highlights", "size-5")}
|
||||
showFAB={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/decks"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={decksDef.kind}
|
||||
title={decksDef.label}
|
||||
icon={sidebarItemIcon("decks", "size-5")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/emojis" element={<EmojiFeedPage />} />
|
||||
<Route
|
||||
path="/development"
|
||||
element={
|
||||
<KindFeedPage
|
||||
kind={[
|
||||
developmentDef.kind,
|
||||
...(developmentDef.extraFeedKinds ?? []),
|
||||
]}
|
||||
title={developmentDef.label}
|
||||
icon={sidebarItemIcon("development", "size-5")}
|
||||
showFAB={false}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/ai-chat" element={<AIChatPage />} />
|
||||
<Route path="/verified" element={<VerifiedPage />} />
|
||||
<Route path="/world" element={<WorldPage />} />
|
||||
<Route path="/badges" element={<BadgesPage />} />
|
||||
<Route path="/books" element={<BooksPage />} />
|
||||
<Route path="/archive" element={<ArchivePage />} />
|
||||
<Route path="/bluesky" element={<BlueskyPage />} />
|
||||
<Route path="/wikipedia" element={<WikipediaPage />} />
|
||||
<Route path="/communities" element={<CommunitiesPage />} />
|
||||
<Route path="/letters" element={<LettersPage />} />
|
||||
<Route path="/letters/compose" element={<LetterComposePage />} />
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Award, Check, Loader2 } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAcceptBadge } from '@/hooks/useAcceptBadge';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProfileBadges } from '@/hooks/useProfileBadges';
|
||||
import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface AcceptBadgeButtonProps {
|
||||
/** The kind 8 badge award event. */
|
||||
awardEvent: NostrEvent;
|
||||
/** Prominent pill style (large, rounded-full, colored). Otherwise compact outline variant. */
|
||||
prominent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Button that lets the logged-in recipient accept a NIP-58 badge award by
|
||||
* adding it to their profile badges event. Shows a muted "Accepted" label
|
||||
* once the badge is already in the user's collection. Renders `null` if the
|
||||
* user is not logged in or the award is malformed.
|
||||
*/
|
||||
export function AcceptBadgeButton({ awardEvent, prominent }: AcceptBadgeButtonProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { refs } = useProfileBadges(user?.pubkey);
|
||||
const { mutate: acceptBadge, isPending, isSuccess } = useAcceptBadge();
|
||||
|
||||
const aTag = awardEvent.tags.find(
|
||||
([n, v]) => n === 'a' && v?.startsWith(`${BADGE_DEFINITION_KIND}:`),
|
||||
)?.[1];
|
||||
|
||||
// Already accepted if the user's profile badges event references this a-tag,
|
||||
// or if the mutation just succeeded (before the cache refetches).
|
||||
const alreadyAccepted = refs.some((r) => r.aTag === aTag) || isSuccess;
|
||||
|
||||
if (!aTag || !user) return null;
|
||||
|
||||
if (alreadyAccepted) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 text-muted-foreground',
|
||||
prominent ? 'text-sm' : 'text-xs',
|
||||
)}
|
||||
>
|
||||
<Check className={prominent ? 'size-4' : 'size-3'} />
|
||||
Accepted
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (prominent) {
|
||||
return (
|
||||
<Button
|
||||
className="rounded-full px-6 h-10 text-sm font-semibold gap-2 shadow-md hover:scale-105 active:scale-95 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
acceptBadge({ aTag, awardEventId: awardEvent.id });
|
||||
}}
|
||||
disabled={isPending}
|
||||
style={{ filter: 'drop-shadow(0 2px 8px hsl(var(--primary) / 0.25))' }}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Award className="size-4" />
|
||||
Accept Badge
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2.5 text-xs font-medium gap-1 transition-colors hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
acceptBadge({ aTag, awardEventId: awardEvent.id });
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Award className="size-3" />
|
||||
Accept
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export function AddMembersDialog({ open, onOpenChange, listId, listPubkeys }: Ad
|
||||
try {
|
||||
await addToList.mutateAsync({ listId, pubkey: profile.pubkey });
|
||||
setAddedPubkeys((prev) => new Set(prev).add(profile.pubkey));
|
||||
const name = profile.metadata.display_name || profile.metadata.name || genUserName(profile.pubkey);
|
||||
const name = profile.metadata.name || profile.metadata.display_name || genUserName(profile.pubkey);
|
||||
toast({ title: `Added ${name} to list` });
|
||||
} catch {
|
||||
toast({ title: 'Failed to add member', variant: 'destructive' });
|
||||
@@ -141,7 +141,7 @@ export function AddMembersDialog({ open, onOpenChange, listId, listPubkeys }: Ad
|
||||
</div>
|
||||
) : (
|
||||
filteredResults.map((profile, idx) => {
|
||||
const name = profile.metadata.display_name || profile.metadata.name || genUserName(profile.pubkey);
|
||||
const name = profile.metadata.name || profile.metadata.display_name || genUserName(profile.pubkey);
|
||||
const isAdding = addingPubkeys.has(profile.pubkey);
|
||||
const isAdded = addedPubkeys.has(profile.pubkey);
|
||||
const isSelected = idx === selectedIdx;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import Markdown from 'react-markdown';
|
||||
import { Children, createElement, type ReactNode } from 'react';
|
||||
import Markdown, { type Components } from 'react-markdown';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Gets a tag value by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
return tags.find(([n]) => n === name)?.[1];
|
||||
@@ -14,6 +19,115 @@ interface ArticleContentProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Options controlling how text leaves are enriched inside a particular markdown tag. */
|
||||
interface EnrichOptions {
|
||||
/** When true, `nostr:nevent/note/naddr` URIs render as inline links (not block-level quote cards),
|
||||
* and media embeds (images/video/audio) are suppressed. Used for headings and other tight contexts. */
|
||||
inlineOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walk markdown children, replacing each string leaf with a
|
||||
* `<NoteContent as="span">` instance so Nostr URIs, URLs, hashtags, and
|
||||
* custom emoji render with identical behavior to regular note content
|
||||
* (mentions, quoted-note cards, link-preview cards, images, custom emoji).
|
||||
*
|
||||
* The synthetic event clones the article's own tags so NIP-30 emoji,
|
||||
* imeta metadata, and q-tag relay hints resolve correctly for each run.
|
||||
*/
|
||||
function enrichChildren(
|
||||
children: ReactNode,
|
||||
event: NostrEvent,
|
||||
opts: EnrichOptions = {},
|
||||
): ReactNode {
|
||||
return Children.map(children, (child, i) => {
|
||||
if (typeof child === 'string') {
|
||||
const synthetic: NostrEvent = { ...event, content: child };
|
||||
return (
|
||||
<NoteContent
|
||||
key={i}
|
||||
event={synthetic}
|
||||
as="span"
|
||||
disableNoteEmbeds={opts.inlineOnly}
|
||||
disableMediaEmbeds={opts.inlineOnly}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
/** Build react-markdown component overrides for this article's event. */
|
||||
function buildComponents(event: NostrEvent): Components {
|
||||
// Wrap a text-bearing block/inline element so its string leaves are enriched.
|
||||
// Uses `createElement` to sidestep TS widening issues when spreading
|
||||
// unknown rehype-passed props onto a generic intrinsic tag.
|
||||
function wrap(Tag: keyof React.JSX.IntrinsicElements, opts: EnrichOptions = {}) {
|
||||
return function Wrapped(
|
||||
props: { children?: ReactNode } & Record<string, unknown>,
|
||||
) {
|
||||
const { children, node: _node, ...rest } = props;
|
||||
return createElement(Tag, rest, enrichChildren(children, event, opts));
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// Paragraphs render as `<div>` so block-level embeds (quoted notes,
|
||||
// images, link-preview cards) inside them produce valid HTML.
|
||||
// Reproduce prose-sm paragraph spacing with utility classes.
|
||||
p: ({ children, node: _node, ...rest }: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
createElement(
|
||||
'div',
|
||||
{
|
||||
...rest,
|
||||
className: cn('my-[1em] first:mt-0 last:mb-0', rest.className as string | undefined),
|
||||
},
|
||||
enrichChildren(children, event),
|
||||
),
|
||||
li: wrap('li'),
|
||||
// Headings: keep inline linkification (mentions, hashtags, URL links)
|
||||
// but suppress block embeds so a heading can't contain a giant quote card.
|
||||
h1: wrap('h1', { inlineOnly: true }),
|
||||
h2: wrap('h2', { inlineOnly: true }),
|
||||
h3: wrap('h3', { inlineOnly: true }),
|
||||
h4: wrap('h4', { inlineOnly: true }),
|
||||
h5: wrap('h5', { inlineOnly: true }),
|
||||
h6: wrap('h6', { inlineOnly: true }),
|
||||
strong: wrap('strong'),
|
||||
em: wrap('em'),
|
||||
blockquote: wrap('blockquote'),
|
||||
td: wrap('td'),
|
||||
th: wrap('th'),
|
||||
a: ({ href, children, node: _node, ...rest }) => {
|
||||
const safe = sanitizeUrl(href);
|
||||
if (!safe) {
|
||||
// Unsafe href — render label as plain text so we don't emit a dead/dangerous link.
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
href={safe}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
'text-primary no-underline hover:underline break-all',
|
||||
rest.className as string | undefined,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt, node: _node, ...rest }) => {
|
||||
const safe = typeof src === 'string' ? sanitizeUrl(src) : undefined;
|
||||
if (!safe) return null;
|
||||
return <img {...rest} src={safe} alt={alt ?? ''} loading="lazy" />;
|
||||
},
|
||||
} as Components;
|
||||
}
|
||||
|
||||
/** Renders kind 30023 long-form article content with Markdown. */
|
||||
export function ArticleContent({ event, preview, className }: ArticleContentProps) {
|
||||
const title = getTag(event.tags, 'title');
|
||||
@@ -25,7 +139,7 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp
|
||||
return (
|
||||
<div className={className}>
|
||||
{title && (
|
||||
<h3 className="text-base font-bold leading-snug">{title}</h3>
|
||||
<h3 dir="auto" className="text-base font-bold leading-snug">{title}</h3>
|
||||
)}
|
||||
{image && (
|
||||
<img
|
||||
@@ -35,9 +149,9 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp
|
||||
/>
|
||||
)}
|
||||
{summary ? (
|
||||
<p className="text-[15px] leading-relaxed line-clamp-3 mt-2">{summary}</p>
|
||||
<p dir="auto" className="text-[15px] leading-relaxed line-clamp-3 mt-2">{summary}</p>
|
||||
) : (
|
||||
<p className="text-[15px] leading-relaxed line-clamp-3 mt-2">
|
||||
<p dir="auto" className="text-[15px] leading-relaxed line-clamp-3 mt-2">
|
||||
{event.content.slice(0, 280)}{event.content.length > 280 ? '...' : ''}
|
||||
</p>
|
||||
)}
|
||||
@@ -46,10 +160,12 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp
|
||||
);
|
||||
}
|
||||
|
||||
const components = buildComponents(event);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{title && (
|
||||
<h1 className="text-2xl font-bold leading-tight mb-4">{title}</h1>
|
||||
<h1 dir="auto" className="text-2xl font-bold leading-tight mb-4">{title}</h1>
|
||||
)}
|
||||
{image && (
|
||||
<img
|
||||
@@ -58,8 +174,8 @@ export function ArticleContent({ event, preview, className }: ArticleContentProp
|
||||
className="w-full rounded-xl object-cover max-h-96 mb-6"
|
||||
/>
|
||||
)}
|
||||
<div className="prose prose-sm max-w-none break-words text-foreground prose-headings:text-foreground prose-headings:font-bold prose-strong:text-foreground prose-a:text-primary prose-img:rounded-lg prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:bg-muted prose-pre:text-foreground prose-code:text-[13px] prose-code:text-foreground prose-code:before:content-none prose-code:after:content-none prose-code:bg-muted prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:font-normal prose-li:marker:text-muted-foreground prose-blockquote:text-muted-foreground prose-blockquote:border-border prose-hr:border-border prose-th:text-foreground">
|
||||
<Markdown rehypePlugins={[rehypeSanitize]}>
|
||||
<div dir="auto" className="prose prose-sm max-w-none break-words text-foreground prose-headings:text-foreground prose-headings:font-bold prose-strong:text-foreground prose-a:text-primary prose-a:no-underline hover:prose-a:underline prose-img:rounded-lg prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:bg-muted prose-pre:text-foreground prose-code:text-[13px] prose-code:text-foreground prose-code:before:content-none prose-code:after:content-none prose-code:bg-muted prose-code:rounded prose-code:px-1.5 prose-code:py-0.5 prose-code:font-normal prose-li:marker:text-muted-foreground prose-blockquote:text-muted-foreground prose-blockquote:border-border prose-hr:border-border prose-th:text-foreground">
|
||||
<Markdown rehypePlugins={[rehypeSanitize]} components={components}>
|
||||
{event.content}
|
||||
</Markdown>
|
||||
</div>
|
||||
|
||||
@@ -113,7 +113,7 @@ export function AwardBadgeDialog({ open, onOpenChange, badgeATag, badgeName }: A
|
||||
<div className="px-4 pb-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selected.map((profile) => {
|
||||
const name = profile.metadata.display_name || profile.metadata.name || genUserName(profile.pubkey);
|
||||
const name = profile.metadata.name || profile.metadata.display_name || genUserName(profile.pubkey);
|
||||
return (
|
||||
<button
|
||||
key={profile.pubkey}
|
||||
@@ -191,7 +191,7 @@ export function AwardBadgeDialog({ open, onOpenChange, badgeATag, badgeName }: A
|
||||
}
|
||||
|
||||
function SearchResultItem({ profile, onSelect }: { profile: SearchProfile; onSelect: (p: SearchProfile) => void }) {
|
||||
const name = profile.metadata.display_name || profile.metadata.name || genUserName(profile.pubkey);
|
||||
const name = profile.metadata.name || profile.metadata.display_name || genUserName(profile.pubkey);
|
||||
const about = profile.metadata.about;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { Award } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { AcceptBadgeButton } from '@/components/AcceptBadgeButton';
|
||||
import { BadgeContent } from '@/components/BadgeContent';
|
||||
import { EmojifiedText } from '@/components/CustomEmoji';
|
||||
import { ProfileHoverCard } from '@/components/ProfileHoverCard';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBadgeDefinitions } from '@/hooks/useBadgeDefinitions';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import {
|
||||
BADGE_DEFINITION_KIND,
|
||||
getBadgeRecipients,
|
||||
isAwardedTo,
|
||||
parseBadgeATag,
|
||||
unslugify,
|
||||
} from '@/lib/badgeUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
||||
interface BadgeAwardCardProps {
|
||||
/** The kind 8 badge award event. */
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed card for NIP-58 badge award events (kind 8). Shows a linked recipient
|
||||
* row, the full badge showcase (via `BadgeContent`), and an Accept button when
|
||||
* the logged-in user is a recipient. The issuer's avatar and name are rendered
|
||||
* by the surrounding `NoteCard`.
|
||||
*/
|
||||
export function BadgeAwardCard({ event }: BadgeAwardCardProps) {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
const recipients = useMemo(() => getBadgeRecipients(event), [event]);
|
||||
const parsed = useMemo(() => parseBadgeATag(event), [event]);
|
||||
|
||||
// NIP-58: only the badge owner can validly award their own badge. Ignore
|
||||
// definitions whose a-tag pubkey doesn't match the award's issuer.
|
||||
const validParsed = parsed && parsed.pubkey === event.pubkey ? parsed : undefined;
|
||||
const badgeRef = useMemo(() => (validParsed ? [validParsed] : []), [validParsed]);
|
||||
const { badgeMap } = useBadgeDefinitions(badgeRef);
|
||||
|
||||
const aTag = validParsed
|
||||
? `${BADGE_DEFINITION_KIND}:${validParsed.pubkey}:${validParsed.identifier}`
|
||||
: undefined;
|
||||
const definition = aTag ? badgeMap.get(aTag) : undefined;
|
||||
const definitionEvent = definition?.event;
|
||||
|
||||
const badgeNaddr = useMemo(
|
||||
() =>
|
||||
validParsed
|
||||
? nip19.naddrEncode({
|
||||
kind: BADGE_DEFINITION_KIND,
|
||||
pubkey: validParsed.pubkey,
|
||||
identifier: validParsed.identifier,
|
||||
})
|
||||
: undefined,
|
||||
[validParsed],
|
||||
);
|
||||
|
||||
const isRecipient = user ? isAwardedTo(event, user.pubkey) : false;
|
||||
const firstRecipient = recipients[0];
|
||||
const extraRecipientCount = Math.max(recipients.length - 1, 0);
|
||||
|
||||
return (
|
||||
<div className="mt-1 space-y-2">
|
||||
{/* Recipient(s) row — "to @Alice" / "to @Alice and 2 others" */}
|
||||
{firstRecipient && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<span>to</span>
|
||||
<RecipientName pubkey={firstRecipient} />
|
||||
{extraRecipientCount > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
and {extraRecipientCount}{' '}
|
||||
{extraRecipientCount === 1 ? 'other' : 'others'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badge showcase — click-through to the badge detail page */}
|
||||
{definitionEvent ? (
|
||||
badgeNaddr ? (
|
||||
<Link
|
||||
to={`/${badgeNaddr}`}
|
||||
className="block"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<BadgeContent event={definitionEvent} />
|
||||
</Link>
|
||||
) : (
|
||||
<BadgeContent event={definitionEvent} />
|
||||
)
|
||||
) : (
|
||||
<BadgeShowcaseFallback
|
||||
name={validParsed ? unslugify(validParsed.identifier) : undefined}
|
||||
href={badgeNaddr ? `/${badgeNaddr}` : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Accept button — only shown when the logged-in user is a recipient */}
|
||||
{isRecipient && (
|
||||
<div className="flex justify-center pt-1">
|
||||
<AcceptBadgeButton awardEvent={event} prominent />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Linked display name for a recipient pubkey, with loading skeleton and hover card. */
|
||||
function RecipientName({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
||||
const url = useProfileUrl(pubkey, metadata);
|
||||
|
||||
if (author.isLoading) {
|
||||
return <Skeleton className="h-3.5 w-24 inline-block" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link
|
||||
to={url}
|
||||
className="font-semibold text-foreground hover:underline truncate max-w-[14rem]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fallback shown while the badge definition is loading or missing. */
|
||||
function BadgeShowcaseFallback({
|
||||
name,
|
||||
href,
|
||||
}: {
|
||||
name: string | undefined;
|
||||
href: string | undefined;
|
||||
}) {
|
||||
const body = (
|
||||
<div className="mt-3 rounded-2xl border border-dashed border-border py-10 px-6 flex flex-col items-center gap-3">
|
||||
<div className="size-20 rounded-2xl bg-gradient-to-br from-primary/10 via-primary/5 to-transparent flex items-center justify-center">
|
||||
<Award className="size-8 text-primary/40" />
|
||||
</div>
|
||||
{name ? (
|
||||
<p className="text-sm font-semibold text-center">{name}</p>
|
||||
) : (
|
||||
<Skeleton className="h-4 w-32" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!href) return body;
|
||||
|
||||
return (
|
||||
<Link to={href} className="block" onClick={(e) => e.stopPropagation()}>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { buildEmojiMap } from '@/lib/customEmoji';
|
||||
import { HASHTAG_PATTERN } from '@/lib/hashtag';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
|
||||
/** Regex matching `:shortcode:` patterns in text. */
|
||||
@@ -19,7 +20,7 @@ type BioToken =
|
||||
*/
|
||||
function tokenizeBio(text: string): BioToken[] {
|
||||
// Match: URLs (http/https) | hashtags (#word)
|
||||
const regex = /(https?:\/\/[^\s]+)|(#[\p{L}\p{N}_]+)/gu;
|
||||
const regex = new RegExp(`(https?:\\/\\/[^\\s]+)|(${HASHTAG_PATTERN})`, 'gu');
|
||||
|
||||
const result: BioToken[] = [];
|
||||
let lastIndex = 0;
|
||||
@@ -131,7 +132,7 @@ export function BioContent({ children, tags, className }: BioContentProps) {
|
||||
const tokens = useMemo(() => tokenizeBio(children), [children]);
|
||||
|
||||
return (
|
||||
<span className={className}>
|
||||
<span dir="auto" className={className}>
|
||||
{tokens.map((token, i) => {
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { BirdSongPlayer } from '@/components/BirdSongPlayer';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useWikidataEntity } from '@/hooks/useWikidataEntity';
|
||||
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Birdstar kind 2473 — Bird Detection.
|
||||
*
|
||||
* The species is identified by a NIP-73 `i`/`k` pair pointing at a Wikidata
|
||||
* entity URI (e.g. `https://www.wikidata.org/entity/Q26825`). We resolve it
|
||||
* through Wikidata → English Wikipedia to get a display name, a short
|
||||
* summary, and a thumbnail image.
|
||||
*
|
||||
* Structured data lives in tags; `content` is an optional freeform note.
|
||||
*/
|
||||
|
||||
const WIKIDATA_URL_RE = /^https:\/\/www\.wikidata\.org\/entity\/(Q\d+)$/;
|
||||
|
||||
interface BirdDetectionContentProps {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function extractWikidata(tags: string[][]): { id: string; url: string } | null {
|
||||
// A valid detection pairs an `i` tag with `k: web`. There may be multiple
|
||||
// i/k pairs in principle; we take the first `i` whose URL matches.
|
||||
for (const tag of tags) {
|
||||
if (tag[0] !== 'i') continue;
|
||||
const value = tag[1];
|
||||
if (typeof value !== 'string') continue;
|
||||
const m = value.match(WIKIDATA_URL_RE);
|
||||
if (m) return { id: m[1], url: value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pull a species label from the `alt` tag: "Bird detection: American Robin (Turdus migratorius)". */
|
||||
function extractAltSpecies(tags: string[][]): { common?: string; scientific?: string } | null {
|
||||
const alt = tags.find(([n]) => n === 'alt')?.[1];
|
||||
if (!alt) return null;
|
||||
// Match "Bird detection: <Common> (<Scientific>)" — keep the parser loose
|
||||
// so subtly different NIP-31 prefixes still yield a usable label.
|
||||
const m = alt.match(/^[^:]*:\s*([^()]+?)\s*(?:\(([^)]+)\))?\s*$/);
|
||||
if (!m) return { common: alt };
|
||||
return { common: m[1]?.trim(), scientific: m[2]?.trim() };
|
||||
}
|
||||
|
||||
/** Extract the scientific name from the `n` tag (Birdstar NIP §"Kind 2473" — the
|
||||
* authoritative scientific-name field, added so clients can label a detection
|
||||
* without round-tripping Wikidata). Falls through to parsing the `alt` tag for
|
||||
* older events authored before `n` was part of the NIP. */
|
||||
function extractScientificName(
|
||||
tags: string[][],
|
||||
altScientific: string | undefined,
|
||||
): string | undefined {
|
||||
const n = tags.find(([name]) => name === 'n')?.[1];
|
||||
if (typeof n === 'string' && n.trim()) return n.trim();
|
||||
return altScientific;
|
||||
}
|
||||
|
||||
export function BirdDetectionContent({ event, className }: BirdDetectionContentProps) {
|
||||
const wikidata = useMemo(() => extractWikidata(event.tags), [event.tags]);
|
||||
const altSpecies = useMemo(() => extractAltSpecies(event.tags), [event.tags]);
|
||||
const scientificName = useMemo(
|
||||
() => extractScientificName(event.tags, altSpecies?.scientific),
|
||||
[event.tags, altSpecies?.scientific],
|
||||
);
|
||||
const note = event.content.trim();
|
||||
|
||||
// Resolve Wikidata → English Wikipedia title, then fetch the Wikipedia
|
||||
// summary (extract + thumbnail) for the title.
|
||||
const { data: entity, isLoading: entityLoading } = useWikidataEntity(wikidata?.id ?? null);
|
||||
const wikipediaTitle = entity?.wikipediaTitle ?? null;
|
||||
const { data: summary, isLoading: summaryLoading } = useWikipediaSummary(wikipediaTitle);
|
||||
|
||||
const isLoading = entityLoading || summaryLoading;
|
||||
|
||||
// Prefer the Wikipedia page title for the display name when available,
|
||||
// but fall back to the species parsed from the `alt` tag so the card is
|
||||
// still meaningful while the Wikipedia fetch is in flight (or has failed).
|
||||
const commonName = summary?.title ?? altSpecies?.common ?? 'Unknown species';
|
||||
const extract = summary?.extract;
|
||||
const thumbnail = sanitizeUrl(summary?.thumbnail?.source);
|
||||
|
||||
// The whole card routes to Ditto's external-content page for this
|
||||
// species' Wikidata URL. Other users' kind 2473 detections and
|
||||
// NIP-22 comments both attach to the same `i`-tag identifier, so
|
||||
// the discussion thread aggregates naturally across clients.
|
||||
const discussPath = wikidata ? `/i/${encodeURIComponent(wikidata.url)}` : undefined;
|
||||
|
||||
// When the user's own freeform note exists we show it above the
|
||||
// Wikipedia-derived summary. `content` can be empty per the NIP.
|
||||
const timeStr = new Date(event.created_at * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
if (!wikidata) {
|
||||
// Shouldn't happen for a valid kind 2473 (the NIP requires the i tag),
|
||||
// but render something useful rather than silently dropping the event.
|
||||
return (
|
||||
<div className={cn('mt-2 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground', className)}>
|
||||
Bird detection with no species reference.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2', className)}>
|
||||
<Link
|
||||
to={discussPath ?? '#'}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="block overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex">
|
||||
{/* Thumbnail panel */}
|
||||
<div className="relative w-32 shrink-0 bg-gradient-to-br from-emerald-100 via-sky-100 to-amber-100 sm:w-40 dark:from-indigo-950 dark:via-indigo-900 dark:to-amber-900/40">
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : thumbnail ? (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={commonName}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Bird
|
||||
aria-hidden
|
||||
strokeWidth={1.5}
|
||||
className="size-10 text-emerald-700/60 dark:text-amber-300/60"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-black/40 to-transparent" />
|
||||
<div className="absolute bottom-1.5 left-2 font-mono text-[10px] uppercase tracking-wider text-white/85">
|
||||
{timeStr}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text panel */}
|
||||
<div className="flex min-w-0 flex-1 items-start gap-3 p-3.5">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-[15px] font-semibold leading-tight">
|
||||
{commonName}
|
||||
</h3>
|
||||
{scientificName && (
|
||||
<p className="mt-0.5 truncate text-xs italic text-muted-foreground">
|
||||
{scientificName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-1.5 pt-0.5">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
<Skeleton className="h-3 w-2/3" />
|
||||
</div>
|
||||
) : extract ? (
|
||||
<p className="line-clamp-3 text-[13px] leading-relaxed text-muted-foreground">
|
||||
{extract}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs italic text-muted-foreground/70">
|
||||
Heard at {new Date(event.created_at * 1000).toLocaleString()}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reference recording from Wikipedia/Commons, when available.
|
||||
* `BirdSongPlayer` returns null when the article has no
|
||||
* usable audio, so the right-hand column collapses
|
||||
* cleanly and the text reflows across the full card.
|
||||
* The click handler stops propagation so toggling
|
||||
* playback doesn't also navigate to `/i/...`. */}
|
||||
{wikipediaTitle && (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
<BirdSongPlayer title={wikipediaTitle} ariaLabel={`${commonName} recording`} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{note && (
|
||||
<p className="mt-2 text-[15px] leading-relaxed whitespace-pre-wrap break-words">
|
||||
{note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useBirdSong, type BirdSong } from '@/hooks/useBirdSong';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Inline bird-song play button for Wikipedia species pages.
|
||||
*
|
||||
* Looks up a reference recording from the article (via
|
||||
* `useBirdSong` → Wikipedia/Commons) and renders a circular
|
||||
* toggle. Clicking plays the song on loop; the play triangle is
|
||||
* replaced by an animated equaliser so the single control both
|
||||
* triggers and indicates playback. The `<audio>` element is rendered
|
||||
* hidden inside the component — callers don't need to thread it
|
||||
* through the tree.
|
||||
*
|
||||
* Returns `null` when no usable recording exists, so the caller can
|
||||
* spread it into a header/title row without worrying about a
|
||||
* disabled/broken state.
|
||||
*
|
||||
* Adapted from Birdstar's BirdInfoDialog `useSongPlayer` (see
|
||||
* `~/Projects/birdstar/src/components/BirdInfoDialog.tsx`). The
|
||||
* iNaturalist fallback from the original is deliberately omitted —
|
||||
* per the user's request Ditto only uses Wikipedia/Commons.
|
||||
*/
|
||||
|
||||
interface BirdSongPlayerProps {
|
||||
/**
|
||||
* Wikipedia article title. We resolve it to an audio file on
|
||||
* Wikimedia Commons.
|
||||
*/
|
||||
title: string | null;
|
||||
className?: string;
|
||||
/** Rendered in a surrounding flex row; supply a label for a11y when
|
||||
* the surrounding header doesn't already describe the subject. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function BirdSongPlayer({ title, className, ariaLabel }: BirdSongPlayerProps) {
|
||||
const { data: song, isLoading } = useBirdSong(title);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Skeleton
|
||||
className={cn('size-10 shrink-0 rounded-full', className)}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!song) return null;
|
||||
|
||||
return (
|
||||
<BirdSongButton song={song} className={className} ariaLabel={ariaLabel} />
|
||||
);
|
||||
}
|
||||
|
||||
function BirdSongButton({
|
||||
song,
|
||||
className,
|
||||
ariaLabel,
|
||||
}: {
|
||||
song: BirdSong;
|
||||
className?: string;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
// When the song source changes (user navigates to a different
|
||||
// species while one is playing), reset to the paused state — the
|
||||
// previous <audio> element unmounts, and we don't want the button
|
||||
// inheriting a stale `isPlaying=true`.
|
||||
useEffect(() => {
|
||||
setIsPlaying(false);
|
||||
}, [song.audioUrl]);
|
||||
|
||||
const toggle = () => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
if (isPlaying) {
|
||||
el.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
// `play()` returns a Promise that rejects when autoplay is
|
||||
// blocked or the source fails to load. Swallow the rejection —
|
||||
// the button stays in the paused state and the user can retry.
|
||||
el.play().then(
|
||||
() => setIsPlaying(true),
|
||||
() => setIsPlaying(false),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={
|
||||
isPlaying
|
||||
? `Pause ${ariaLabel ?? 'reference recording'}`
|
||||
: `Play ${ariaLabel ?? 'reference recording'}`
|
||||
}
|
||||
aria-pressed={isPlaying}
|
||||
title={song.attribution}
|
||||
className={cn(
|
||||
'group inline-flex size-10 shrink-0 items-center justify-center rounded-full',
|
||||
'bg-emerald-500 text-white shadow-md ring-1 ring-emerald-400/40',
|
||||
'transition-[transform,background-color,box-shadow] duration-200',
|
||||
'hover:bg-emerald-600 hover:shadow-lg active:scale-95',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:ring-offset-2',
|
||||
'focus-visible:ring-offset-background',
|
||||
'dark:bg-emerald-400 dark:text-emerald-950 dark:hover:bg-emerald-300',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<EqualiserBars />
|
||||
) : (
|
||||
// Nudge the play triangle right by 1px — its centroid sits
|
||||
// left of its bounding box and would otherwise look
|
||||
// off-center inside the circle.
|
||||
<Play className="size-4 translate-x-px fill-current" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={song.audioUrl}
|
||||
preload="none"
|
||||
// Loop the reference recording. Commons bird songs are
|
||||
// typically a few seconds of a single phrase, and users want
|
||||
// to hear it repeatedly to compare with what they heard in
|
||||
// the field. The button (same hit region as the equaliser)
|
||||
// is the explicit stop.
|
||||
loop
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
className="hidden"
|
||||
>
|
||||
Your browser does not support embedded audio.
|
||||
</audio>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Four vertical bars bouncing with staggered CSS animations,
|
||||
* rendered inside the button while playback is active. Color is
|
||||
* `currentColor` so it inherits the button's text colour — white on
|
||||
* the emerald background in light mode, emerald-950 (matching
|
||||
* foreground) in dark mode. Respects `prefers-reduced-motion` via
|
||||
* Tailwind's `motion-reduce:` variant so the bars freeze rather
|
||||
* than bouncing for users who've asked for less motion.
|
||||
*/
|
||||
function EqualiserBars() {
|
||||
const delays = ['0ms', '120ms', '60ms', '180ms'];
|
||||
return (
|
||||
<span className="flex h-4 items-end gap-[2px]" aria-hidden>
|
||||
{delays.map((delay, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
'block w-[2px] rounded-full bg-current',
|
||||
// `origin-bottom` keeps the scaleY transform anchored to
|
||||
// the baseline so the bar "grows up" rather than
|
||||
// expanding from its center.
|
||||
'h-full origin-bottom motion-safe:animate-equaliser-bar',
|
||||
// Static midpoint height when motion is reduced, so the
|
||||
// UI still conveys "audio is playing" without movement.
|
||||
'motion-reduce:scale-y-75',
|
||||
)}
|
||||
style={{ animationDelay: delay }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useBirdSong } from '@/hooks/useBirdSong';
|
||||
import { useWikidataEntity } from '@/hooks/useWikidataEntity';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Chorus play/pause button for a Birdex (kind 12473) life list.
|
||||
*
|
||||
* A single control that fires every species' reference recording from
|
||||
* Wikipedia/Commons *at the same time*, producing an overlapping
|
||||
* dawn-chorus effect. Each recording loops independently so the
|
||||
* chorus sustains until the user hits pause.
|
||||
*
|
||||
* Architecture: the parent owns a single `isPlaying` flag. It renders
|
||||
* one `BirdexChorusVoice` per species, each of which:
|
||||
* 1. Resolves its Wikidata ID → Wikipedia title → Commons audio URL
|
||||
* via the same hooks the tile thumbnails already call (so the
|
||||
* title-resolution round-trips are cache hits).
|
||||
* 2. Owns a hidden `<audio loop>` element.
|
||||
* 3. Reacts to `isPlaying` by calling `play()` or `pause()` on its
|
||||
* element, and reports ready-state / error-state back up so the
|
||||
* button knows when to show a spinner and whether there's
|
||||
* anything audible to play at all.
|
||||
*
|
||||
* Voices that fail to resolve audio (species whose Wikipedia article
|
||||
* has no usable field recording) are silently skipped — the chorus
|
||||
* plays whatever subset has audio. If nothing at all has audio the
|
||||
* button hides itself rather than rendering a dead control.
|
||||
*
|
||||
* Note: every species' audio URL is fetched eagerly on mount. The
|
||||
* cost is bounded by the number of species the user already sees as
|
||||
* tiles, and every request is cached for 24h via TanStack Query, so
|
||||
* a second visit is free.
|
||||
*/
|
||||
|
||||
export interface BirdexChorusSpecies {
|
||||
/** Wikidata entity ID, e.g. "Q26825". */
|
||||
entityId: string;
|
||||
}
|
||||
|
||||
interface BirdexChorusButtonProps {
|
||||
species: BirdexChorusSpecies[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type VoiceState = 'loading' | 'ready' | 'missing';
|
||||
|
||||
export function BirdexChorusButton({ species, className }: BirdexChorusButtonProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
// Track each voice's readiness so we can disable the button while
|
||||
// anything is still resolving, and hide it entirely when no voice
|
||||
// has usable audio. A Map keyed by entityId so voices can update
|
||||
// their slot without racing by array index.
|
||||
const [voiceStates, setVoiceStates] = useState<Map<string, VoiceState>>(
|
||||
() => new Map(species.map((s) => [s.entityId, 'loading' as VoiceState])),
|
||||
);
|
||||
|
||||
// Keep the map in sync when the species list changes (e.g. the
|
||||
// Birdex event is replaced with a newer version). Entries that
|
||||
// disappear are dropped; new ones start as `loading`.
|
||||
useEffect(() => {
|
||||
setVoiceStates((prev) => {
|
||||
const next = new Map<string, VoiceState>();
|
||||
for (const s of species) {
|
||||
next.set(s.entityId, prev.get(s.entityId) ?? 'loading');
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [species]);
|
||||
|
||||
const reportState = useCallback((entityId: string, state: VoiceState) => {
|
||||
setVoiceStates((prev) => {
|
||||
if (prev.get(entityId) === state) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(entityId, state);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const anyLoading = useMemo(
|
||||
() => Array.from(voiceStates.values()).some((s) => s === 'loading'),
|
||||
[voiceStates],
|
||||
);
|
||||
const readyCount = useMemo(
|
||||
() => Array.from(voiceStates.values()).filter((s) => s === 'ready').length,
|
||||
[voiceStates],
|
||||
);
|
||||
|
||||
// Hide the button entirely once resolution settles and not a single
|
||||
// species produced playable audio. While loading we still render
|
||||
// (the skeleton indicates the chorus is being assembled).
|
||||
//
|
||||
// Crucially, the `BirdexChorusVoice` children must always render
|
||||
// regardless of UI state — they're the hooks that drive the
|
||||
// resolution we're waiting on. Returning early before rendering
|
||||
// them would freeze the button in its initial "loading" state
|
||||
// forever.
|
||||
const hideButton = !anyLoading && readyCount === 0;
|
||||
const showSkeleton = !hideButton && anyLoading && readyCount === 0;
|
||||
|
||||
const toggle = () => setIsPlaying((p) => !p);
|
||||
|
||||
return (
|
||||
<>
|
||||
{hideButton ? null : showSkeleton ? (
|
||||
<Skeleton
|
||||
className={cn('size-10 shrink-0 rounded-full', className)}
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-pressed={isPlaying}
|
||||
aria-label={
|
||||
isPlaying
|
||||
? `Pause dawn chorus of ${readyCount} species`
|
||||
: `Play dawn chorus of ${readyCount} species`
|
||||
}
|
||||
className={cn(
|
||||
'group inline-flex size-10 shrink-0 items-center justify-center rounded-full',
|
||||
'bg-emerald-500 text-white shadow-md ring-1 ring-emerald-400/40',
|
||||
'transition-[transform,background-color,box-shadow] duration-200',
|
||||
'hover:bg-emerald-600 hover:shadow-lg active:scale-95',
|
||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 focus-visible:ring-offset-2',
|
||||
'focus-visible:ring-offset-background',
|
||||
'dark:bg-emerald-400 dark:text-emerald-950 dark:hover:bg-emerald-300',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<EqualiserBars />
|
||||
) : (
|
||||
// Nudge the play triangle right by 1px so its visual
|
||||
// centroid aligns with the circle's centre — the glyph's
|
||||
// bounding box is wider on the right than the left.
|
||||
<Play className="size-4 translate-x-px fill-current" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{species.map((s) => (
|
||||
<BirdexChorusVoice
|
||||
key={s.entityId}
|
||||
entityId={s.entityId}
|
||||
isPlaying={isPlaying}
|
||||
onStateChange={reportState}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Four vertical bars bouncing with staggered CSS animations, shown
|
||||
* inside the button while the chorus is playing. Matches the
|
||||
* equaliser used by `BirdSongPlayer` so the chorus button and the
|
||||
* per-species buttons are visually indistinguishable.
|
||||
*/
|
||||
function EqualiserBars() {
|
||||
const delays = ['0ms', '120ms', '60ms', '180ms'];
|
||||
return (
|
||||
<span className="flex h-4 items-end gap-[2px]" aria-hidden>
|
||||
{delays.map((delay, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
'block w-[2px] rounded-full bg-current',
|
||||
'h-full origin-bottom motion-safe:animate-equaliser-bar',
|
||||
'motion-reduce:scale-y-75',
|
||||
)}
|
||||
style={{ animationDelay: delay }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface BirdexChorusVoiceProps {
|
||||
entityId: string;
|
||||
isPlaying: boolean;
|
||||
onStateChange: (entityId: string, state: VoiceState) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single voice in the chorus. Resolves Wikidata → Wikipedia title →
|
||||
* Commons audio URL and renders a hidden `<audio loop>` element that
|
||||
* tracks the shared play/pause state. Renders nothing visible.
|
||||
*/
|
||||
function BirdexChorusVoice({ entityId, isPlaying, onStateChange }: BirdexChorusVoiceProps) {
|
||||
const { data: entity, isLoading: entityLoading, isError: entityError } =
|
||||
useWikidataEntity(entityId);
|
||||
const wikipediaTitle = entity?.wikipediaTitle ?? null;
|
||||
|
||||
// `useBirdSong` only fires once we have a Wikipedia title. While
|
||||
// it's disabled its `isLoading` is false but `data` is undefined,
|
||||
// so we gate readiness on the parent query's state too.
|
||||
const { data: song, isLoading: songLoading, isError: songError } =
|
||||
useBirdSong(wikipediaTitle);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const audioUrl = song?.audioUrl ?? null;
|
||||
|
||||
// Report our state upward whenever it changes. "missing" fires both
|
||||
// when Wikidata has no enwiki sitelink and when Wikipedia has no
|
||||
// usable recording — the UI treats both the same.
|
||||
useEffect(() => {
|
||||
if (entityError || (!entityLoading && !wikipediaTitle)) {
|
||||
onStateChange(entityId, 'missing');
|
||||
return;
|
||||
}
|
||||
if (entityLoading || songLoading) {
|
||||
onStateChange(entityId, 'loading');
|
||||
return;
|
||||
}
|
||||
if (songError || !audioUrl) {
|
||||
onStateChange(entityId, 'missing');
|
||||
return;
|
||||
}
|
||||
onStateChange(entityId, 'ready');
|
||||
}, [
|
||||
entityId,
|
||||
entityError,
|
||||
entityLoading,
|
||||
wikipediaTitle,
|
||||
songLoading,
|
||||
songError,
|
||||
audioUrl,
|
||||
onStateChange,
|
||||
]);
|
||||
|
||||
// Drive the hidden `<audio>` element from the shared flag. We
|
||||
// don't forward `onPlay`/`onPause` events upward because the
|
||||
// parent is the source of truth; bubbling them back would create
|
||||
// feedback loops when (e.g.) the browser auto-pauses on tab hide.
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el || !audioUrl) return;
|
||||
if (isPlaying) {
|
||||
// `play()` rejects when autoplay is blocked or the source
|
||||
// fails to load. Swallow it — the voice just drops out of the
|
||||
// chorus rather than taking the whole button down.
|
||||
el.play().catch(() => {});
|
||||
} else {
|
||||
el.pause();
|
||||
// Reset to the start so the next Play gives a fresh chorus
|
||||
// rather than picking up mid-phrase with every voice out of
|
||||
// sync with wherever it happened to be paused.
|
||||
try {
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
/* Some browsers throw on seek before metadata loads. */
|
||||
}
|
||||
}
|
||||
}, [isPlaying, audioUrl]);
|
||||
|
||||
if (!audioUrl) return null;
|
||||
|
||||
return (
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
preload="auto"
|
||||
loop
|
||||
className="hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Bird } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { BirdexChorusButton } from '@/components/BirdexChorusButton';
|
||||
import { BirdexTile } from '@/components/BirdexTile';
|
||||
import { parseBirdexEvent } from '@/lib/parseBirdex';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Birdstar kind 12473 — Birdex (life list).
|
||||
*
|
||||
* A replaceable per-author index of every distinct bird species the
|
||||
* author has ever logged via kind 2473. Each species is a positional
|
||||
* `i`/`n` pair (Wikidata entity URI + scientific name), emitted in
|
||||
* chronological order of first detection.
|
||||
*
|
||||
* Feed variant: a small tiled preview of the most recently-added
|
||||
* species plus a "+N" capstone, mirroring how kind 3 follow lists
|
||||
* render as a compact avatar stack with a "+N more" suffix. Full
|
||||
* variant: the whole life list laid out as a responsive grid so
|
||||
* visitors can browse every species the author has ever seen.
|
||||
*/
|
||||
|
||||
/** Tiles rendered in the compact feed preview before collapsing into "+N". */
|
||||
const FEED_PREVIEW_LIMIT = 8;
|
||||
|
||||
interface BirdexContentProps {
|
||||
event: NostrEvent;
|
||||
/**
|
||||
* When true, render every species on the life list instead of the
|
||||
* truncated feed preview. Used on the post-detail page.
|
||||
*/
|
||||
expanded?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BirdexContent({ event, expanded, className }: BirdexContentProps) {
|
||||
const entries = useMemo(() => parseBirdexEvent(event), [event]);
|
||||
|
||||
// Empty Birdex — either a malformed event or a newly-published
|
||||
// placeholder. Render a minimal dashed card so the feed row still
|
||||
// has a meaningful anchor.
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-2 flex items-center gap-2 rounded-xl border border-dashed border-border p-4 text-sm text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Bird className="size-4" aria-hidden />
|
||||
Empty Birdex — no confirmed species yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
return (
|
||||
<div className={cn('mt-2', className)}>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Bird className="size-4 shrink-0 text-emerald-600 dark:text-amber-300" aria-hidden />
|
||||
<h3 className="truncate text-[15px] font-semibold leading-tight">
|
||||
Birdex
|
||||
<span className="ml-1.5 text-sm font-normal text-muted-foreground">
|
||||
{entries.length} species
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Single control that plays every species' Wikipedia
|
||||
* recording simultaneously — a dawn chorus for the whole
|
||||
* life list. Hides itself when no species has usable audio. */}
|
||||
<BirdexChorusButton
|
||||
species={entries.map(({ entityId }) => ({ entityId }))}
|
||||
className="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6">
|
||||
{entries.map((entry) => (
|
||||
<BirdexTile
|
||||
key={entry.entityUri}
|
||||
entityUri={entry.entityUri}
|
||||
entityId={entry.entityId}
|
||||
scientificName={entry.scientificName || undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Feed variant — show the *most recent* species (tail of the list)
|
||||
// so the preview reflects the author's latest additions, with an
|
||||
// overflow capstone on the final tile when the Birdex is larger
|
||||
// than the preview. The capstone displaces one species slot, so
|
||||
// when overflowing we render (LIMIT - 1) real tiles + the capstone;
|
||||
// the capstone's count is "species not shown", which includes the
|
||||
// one species the capstone itself displaced.
|
||||
const overflowing = entries.length > FEED_PREVIEW_LIMIT;
|
||||
const visibleSpeciesCount = overflowing ? FEED_PREVIEW_LIMIT - 1 : entries.length;
|
||||
const previewEntries = entries.slice(-visibleSpeciesCount);
|
||||
const overflowCount = entries.length - visibleSpeciesCount;
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2', className)}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Bird className="size-4 shrink-0 text-emerald-600 dark:text-amber-300" aria-hidden />
|
||||
<span className="text-[15px] font-semibold leading-tight">
|
||||
Birdex
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
· {entries.length} species
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Same chorus button as the expanded view — plays the
|
||||
* entire life list, not just the preview slice, because the
|
||||
* button represents the whole event. Wrapped in a click
|
||||
* swallower: the feed variant sits inside a clickable
|
||||
* NoteCard, and toggling playback must not navigate away. */}
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
<BirdexChorusButton
|
||||
species={entries.map(({ entityId }) => ({ entityId }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-1.5 sm:grid-cols-6 md:grid-cols-8">
|
||||
{previewEntries.map((entry) => (
|
||||
<BirdexTile
|
||||
key={entry.entityUri}
|
||||
entityUri={entry.entityUri}
|
||||
entityId={entry.entityId}
|
||||
scientificName={entry.scientificName || undefined}
|
||||
/>
|
||||
))}
|
||||
{overflowing && <OverflowTile count={overflowCount} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Final capstone tile that reads "+N" when the life list overflows
|
||||
* the feed preview. Mirrors the "+N more" suffix on kind 3 follow-list
|
||||
* avatar stacks.
|
||||
*/
|
||||
function OverflowTile({ count }: { count: number }) {
|
||||
return (
|
||||
<div
|
||||
className="flex aspect-square items-center justify-center overflow-hidden rounded-xl border border-border bg-muted/60 text-muted-foreground"
|
||||
aria-label={`${count} more species`}
|
||||
>
|
||||
<span className="text-xs font-semibold sm:text-sm">
|
||||
+{count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Bird } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useWikidataEntity } from '@/hooks/useWikidataEntity';
|
||||
import { useWikipediaSummary } from '@/hooks/useWikipediaSummary';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* A single tile in a Birdex grid — one species.
|
||||
*
|
||||
* Resolves Wikidata → English Wikipedia to pull a thumbnail and common
|
||||
* name. The scientific name (optional, from the paired `n` tag on the
|
||||
* Birdex event) is used as a fallback label while the remote fetch is
|
||||
* in flight or fails.
|
||||
*
|
||||
* Clicking the tile routes to Ditto's external-content page for the
|
||||
* species' Wikidata URL, so the species page aggregates detections,
|
||||
* comments, and other Birdex authors who have this species on their
|
||||
* life lists — the same landing spot used by kind 2473 bird-detection
|
||||
* cards.
|
||||
*/
|
||||
interface BirdexTileProps {
|
||||
entityUri: string;
|
||||
entityId: string;
|
||||
/** Optional scientific name from the paired `n` tag. */
|
||||
scientificName?: string;
|
||||
/** Extra classes applied to the tile container. */
|
||||
className?: string;
|
||||
/** Drop the navigation link (used by disabled-hover embeds). */
|
||||
nonInteractive?: boolean;
|
||||
}
|
||||
|
||||
export function BirdexTile({
|
||||
entityUri,
|
||||
entityId,
|
||||
scientificName,
|
||||
className,
|
||||
nonInteractive,
|
||||
}: BirdexTileProps) {
|
||||
const { data: entity, isLoading: entityLoading } = useWikidataEntity(entityId);
|
||||
const wikipediaTitle = entity?.wikipediaTitle ?? null;
|
||||
const { data: summary, isLoading: summaryLoading } = useWikipediaSummary(wikipediaTitle);
|
||||
|
||||
const isLoading = entityLoading || summaryLoading;
|
||||
|
||||
// Prefer the Wikipedia page title for the display label; fall back to
|
||||
// the scientific name from the Birdex's `n` tag while fetches are in
|
||||
// flight or when no English article exists.
|
||||
const commonName = summary?.title ?? (scientificName || 'Unknown species');
|
||||
const thumbnail = sanitizeUrl(summary?.thumbnail?.source);
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative aspect-square overflow-hidden rounded-xl bg-gradient-to-br from-emerald-100 via-sky-100 to-amber-100 shadow-sm',
|
||||
'dark:from-indigo-950 dark:via-indigo-900 dark:to-amber-900/40',
|
||||
!nonInteractive && 'transition-shadow hover:shadow-md focus-visible:shadow-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Skeleton className="absolute inset-0 h-full w-full" />
|
||||
) : thumbnail ? (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={commonName}
|
||||
className={cn(
|
||||
'absolute inset-0 h-full w-full object-cover',
|
||||
!nonInteractive && 'motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]',
|
||||
)}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Bird
|
||||
aria-hidden
|
||||
strokeWidth={1.5}
|
||||
className="size-8 text-emerald-700/60 dark:text-amber-300/60"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name overlay — always rendered, even during skeleton, so the
|
||||
tile's shape is stable. Common name on top (from Wikipedia
|
||||
when available, scientific fallback otherwise); scientific
|
||||
name from the Birdex's paired `n` tag as a persistent
|
||||
italic sub-label underneath, mirroring how kind 2473
|
||||
detection cards stack the two labels. */}
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/75 via-black/40 to-transparent pt-6">
|
||||
<div className="px-2 pb-1.5">
|
||||
<p className="truncate text-[11px] font-semibold leading-tight text-white drop-shadow sm:text-xs">
|
||||
{isLoading && !scientificName ? '\u00A0' : commonName}
|
||||
</p>
|
||||
{scientificName && scientificName !== commonName && (
|
||||
<p className="truncate text-[10px] italic leading-tight text-white/80">
|
||||
{scientificName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (nonInteractive) return inner;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/i/${encodeURIComponent(entityUri)}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="block focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-xl"
|
||||
aria-label={commonName}
|
||||
>
|
||||
{inner}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -18,12 +18,12 @@ import { ZapDialog } from '@/components/ZapDialog';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { useUserZap } from '@/hooks/useUserZap';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useOpenPost } from '@/hooks/useOpenPost';
|
||||
import { useBookSummary } from '@/hooks/useBookSummary';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { timeAgo } from '@/lib/timeAgo';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BOOKSTR_KINDS, extractISBNFromEvent, parseBookReview, ratingToStars } from '@/lib/bookstr';
|
||||
@@ -58,7 +58,8 @@ export function BookFeedItem({ event, className }: BookFeedItemProps) {
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
|
||||
const canZapAuthor = user && canZap(metadata);
|
||||
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
|
||||
const isZapped = useUserZap(canZapAuthor ? event.id : undefined) === true;
|
||||
|
||||
const isbn = useMemo(() => extractISBNFromEvent(event), [event]);
|
||||
const isReview = event.kind === BOOKSTR_KINDS.BOOK_REVIEW;
|
||||
@@ -251,10 +252,15 @@ export function BookFeedItem({ event, className }: BookFeedItemProps) {
|
||||
{canZapAuthor && (
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className="flex items-center gap-1.5 p-2 rounded-full text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10 transition-colors"
|
||||
title="Zap"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 p-2 rounded-full transition-colors',
|
||||
isZapped
|
||||
? 'text-amber-500 hover:text-amber-500/80 hover:bg-amber-500/10'
|
||||
: 'text-muted-foreground hover:text-amber-500 hover:bg-amber-500/10',
|
||||
)}
|
||||
title={isZapped ? 'Zapped' : 'Zap'}
|
||||
>
|
||||
<Zap className="size-5" />
|
||||
<Zap className="size-5" fill={isZapped ? 'currentColor' : 'none'} />
|
||||
{stats?.zapAmount ? <span className="text-sm tabular-nums">{formatNumber(stats.zapAmount)}</span> : null}
|
||||
</button>
|
||||
</ZapDialog>
|
||||
|
||||
@@ -3,10 +3,10 @@ import { type ReactNode, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
Award, BarChart3, BookOpen, Camera, Clapperboard, FileText, Film,
|
||||
GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music,
|
||||
Award, BarChart3, Bird, BookOpen, Camera, Clapperboard, FileText, Film,
|
||||
GitBranch, GitPullRequest, Highlighter, Mail, MapPin, MessageSquare, Mic, Music,
|
||||
Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus,
|
||||
Target, Users, Vote, Zap,
|
||||
Stars, Target, Users, UserCheck, Vote, Zap,
|
||||
} from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
@@ -26,9 +26,12 @@ import { usePollVoteLabel } from '@/hooks/usePollVoteLabel';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useBookInfo } from '@/hooks/useBookInfo';
|
||||
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { useScryfallCard } from '@/hooks/useScryfallCard';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getCountryInfo } from '@/lib/countries';
|
||||
import { extractGathererCard, type GathererCard } from '@/lib/linkEmbed';
|
||||
import { cardPrimaryImage } from '@/lib/scryfall';
|
||||
|
||||
|
||||
/** Default classes shared by all comment context rows. */
|
||||
@@ -95,9 +98,11 @@ function parseCommentRoot(event: NostrEvent): CommentRoot | undefined {
|
||||
const KIND_LABELS: Record<number, string> = {
|
||||
0: 'a profile',
|
||||
1: 'a post',
|
||||
3: 'a follow list',
|
||||
4: 'an encrypted message',
|
||||
6: 'a repost',
|
||||
7: 'a reaction',
|
||||
8: 'a badge award',
|
||||
16: 'a repost',
|
||||
20: 'a photo',
|
||||
21: 'a video',
|
||||
@@ -111,6 +116,8 @@ const KIND_LABELS: Record<number, string> = {
|
||||
8211: 'a letter',
|
||||
1617: 'a patch',
|
||||
1618: 'a pull request',
|
||||
2473: 'a bird detection',
|
||||
12473: 'a Birdex',
|
||||
3367: 'a color moment',
|
||||
7516: 'a found log',
|
||||
15128: 'an nsite',
|
||||
@@ -140,8 +147,11 @@ const KIND_LABELS: Record<number, string> = {
|
||||
36787: 'a track',
|
||||
37381: 'a Magic deck',
|
||||
37516: 'a treasure',
|
||||
30000: 'a follow set',
|
||||
30621: 'a constellation',
|
||||
39089: 'a follow pack',
|
||||
9735: 'a zap',
|
||||
9802: 'a highlight',
|
||||
};
|
||||
|
||||
/** Kind-specific icons — matches sidebar and NoteCard icons. */
|
||||
@@ -150,6 +160,7 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
1: MessageSquare,
|
||||
4: Mail,
|
||||
6: RepostIcon,
|
||||
8: Award,
|
||||
16: RepostIcon,
|
||||
20: Camera,
|
||||
21: Film,
|
||||
@@ -183,19 +194,27 @@ const KIND_ICONS: Partial<Record<number, React.ComponentType<{ className?: strin
|
||||
37381: CardsIcon,
|
||||
37516: ChestIcon,
|
||||
7516: ChestIcon,
|
||||
3: UserCheck,
|
||||
30000: Users,
|
||||
39089: PartyPopper,
|
||||
3367: Palette,
|
||||
9041: Target,
|
||||
9735: Zap,
|
||||
9802: Highlighter,
|
||||
2473: Bird,
|
||||
12473: Bird,
|
||||
30621: Stars,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a singular comment-context label for a kind number.
|
||||
* Only uses KIND_LABELS (which has proper singular forms with articles).
|
||||
* Never falls through to EXTRA_KINDS labels since those are plural/categorical.
|
||||
* Unknown kinds render as "an unsupported event" — never as "a post", which
|
||||
* would misrepresent arbitrary event kinds as text notes.
|
||||
*/
|
||||
function getKindLabel(kind: number): string {
|
||||
return KIND_LABELS[kind] ?? 'a post';
|
||||
return KIND_LABELS[kind] ?? 'an unsupported event';
|
||||
}
|
||||
|
||||
/** Parse a rootKind string into a label, handling both numeric and external content kinds. */
|
||||
@@ -216,9 +235,11 @@ function getRootKindLabel(rootKind: string | undefined): string {
|
||||
const KIND_SUFFIXES: Partial<Record<number, string>> = {
|
||||
30009: 'badge',
|
||||
30030: 'emoji pack',
|
||||
30000: 'follow set',
|
||||
39089: 'follow pack',
|
||||
37381: 'deck',
|
||||
37516: 'treasure',
|
||||
30621: 'constellation',
|
||||
34550: 'community',
|
||||
30054: 'episode',
|
||||
30055: 'trailer',
|
||||
@@ -258,6 +279,7 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co
|
||||
const title = event.tags.find(([name]) => name === 'title')?.[1];
|
||||
const name = event.tags.find(([name]) => name === 'name')?.[1];
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
const alt = event.tags.find(([name]) => name === 'alt')?.[1]?.trim();
|
||||
const displayTitle = title || name || dTag;
|
||||
|
||||
// Kinds with a custom postfix (e.g. "Ditto on Zapstore")
|
||||
@@ -272,10 +294,17 @@ function getEventDisplayName(event: NostrEvent): { text: string; icon?: React.Co
|
||||
return { text: `${displayTitle} ${suffix}`, icon };
|
||||
}
|
||||
|
||||
// Generic: just use the title if available
|
||||
if (displayTitle) return { text: displayTitle, icon };
|
||||
// Known kinds: use the conventional title/name/d tag if available.
|
||||
if (KIND_LABELS[event.kind] && displayTitle) {
|
||||
return { text: displayTitle, icon };
|
||||
}
|
||||
|
||||
// Fall back to kind label
|
||||
// Unknown kinds: only trust the NIP-31 `alt` tag. title/name/d have
|
||||
// kind-specific semantics we can't interpret; `d` in particular is often
|
||||
// an opaque compound identifier.
|
||||
if (alt) return { text: alt, icon };
|
||||
|
||||
// Fall back to kind label ("an unsupported event" for unknown kinds).
|
||||
return { text: getKindLabel(event.kind), icon };
|
||||
}
|
||||
|
||||
@@ -388,7 +417,7 @@ export function CommentContext({ event, className }: CommentContextProps) {
|
||||
function ReplyToCommentContext({ pubkey, eventId, className }: { pubkey: string; eventId?: string; className?: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
||||
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
const parentLink = useMemo(() => {
|
||||
if (!eventId) return undefined;
|
||||
@@ -422,14 +451,53 @@ function AddrCommentContext({ root, className }: { root: CommentRoot; className?
|
||||
return <ProfileBadgesCommentContext root={root} className={className} />;
|
||||
}
|
||||
|
||||
// Kind 3 follow lists have no title of their own — synthesize one from the author's name
|
||||
if (root.addr?.kind === 3) {
|
||||
return <FollowListCommentContext pubkey={root.addr.pubkey} className={className} />;
|
||||
}
|
||||
|
||||
return <GenericAddrCommentContext root={root} className={className} />;
|
||||
}
|
||||
|
||||
/** Comment context for kind 3 (follow list) roots — shows "Commenting on @Name's follow list". */
|
||||
function FollowListCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
||||
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
const listLink = useMemo(
|
||||
() => `/${nip19.naddrEncode({ kind: 3, pubkey, identifier: '' })}`,
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link
|
||||
to={`/${npubEncoded}`}
|
||||
className="text-primary hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{displayName}'s
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<Link
|
||||
to={listLink}
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<UserCheck className="size-3.5 shrink-0" />
|
||||
follow list
|
||||
</Link>
|
||||
</CommentContextRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** Comment context for kind 0 (profile) roots — shows "Commenting on @Name". */
|
||||
function ProfileCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
||||
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
|
||||
return (
|
||||
@@ -452,7 +520,7 @@ function ProfileBadgesCommentContext({ root, className }: { root: CommentRoot; c
|
||||
const pubkey = root.addr?.pubkey ?? '';
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
const displayName = metadata?.name ?? metadata?.display_name ?? genUserName(pubkey);
|
||||
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
|
||||
// Build naddr link for the profile badges event
|
||||
@@ -658,8 +726,14 @@ function ExternalCommentContext({ root, className }: { root: CommentRoot; classN
|
||||
return <IsbnCommentContext identifier={identifier} className={className} />;
|
||||
}
|
||||
|
||||
// URL identifiers get special treatment — show page title with favicon
|
||||
// URL identifiers get special treatment — show page title with favicon.
|
||||
// Gatherer URLs are routed to a Scryfall-backed renderer that shows the
|
||||
// actual card name instead of the raw URL.
|
||||
if (identifier.startsWith('http://') || identifier.startsWith('https://')) {
|
||||
const gathererCard = extractGathererCard(identifier);
|
||||
if (gathererCard) {
|
||||
return <GathererCardCommentContext card={gathererCard} url={identifier} className={className} />;
|
||||
}
|
||||
return <UrlCommentContext url={identifier} className={className} />;
|
||||
}
|
||||
|
||||
@@ -843,3 +917,82 @@ function IsbnCommentContext({ identifier, className }: { identifier: string; cla
|
||||
</CommentContextRow>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Comment context for gatherer.wizards.com URLs — resolves the URL to a
|
||||
* Magic: The Gathering card via Scryfall and shows the card's real name
|
||||
* (e.g. "Xenagos, God of Revels") instead of the raw URL.
|
||||
*/
|
||||
function GathererCardCommentContext({
|
||||
card,
|
||||
url,
|
||||
className,
|
||||
}: {
|
||||
card: GathererCard;
|
||||
url: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const lookup = useMemo(() => (
|
||||
card.kind === 'multiverse'
|
||||
? { kind: 'multiverse' as const, multiverseId: card.multiverseId }
|
||||
: { kind: 'set' as const, set: card.set, number: card.number, lang: card.lang }
|
||||
), [card]);
|
||||
const { data: scryCard, isLoading } = useScryfallCard(lookup);
|
||||
const link = `/i/${encodeURIComponent(url)}`;
|
||||
|
||||
const displayText = scryCard?.name ?? 'Magic card';
|
||||
const coverUrl = scryCard ? cardPrimaryImage(scryCard, 'small') : undefined;
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className} loading={isLoading}>
|
||||
<HoverCard openDelay={300} closeDelay={150}>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link
|
||||
to={link}
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline truncate cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CardsIcon className="size-3.5 shrink-0" />
|
||||
{displayText}
|
||||
</Link>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="w-72 p-0 rounded-2xl shadow-lg"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
{coverUrl ? (
|
||||
<img
|
||||
src={coverUrl}
|
||||
alt={scryCard?.name ?? 'Magic card'}
|
||||
className="w-9 h-12 rounded object-cover shrink-0"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-12 rounded bg-secondary flex items-center justify-center shrink-0">
|
||||
<CardsIcon className="size-4 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<CardsIcon className="size-3 shrink-0" />
|
||||
<span>Magic Card</span>
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate mt-0.5">
|
||||
{scryCard?.name ?? 'Unknown card'}
|
||||
</p>
|
||||
{scryCard?.set_name && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{scryCard.set_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</CommentContextRow>
|
||||
);
|
||||
}
|
||||
|
||||
+232
-155
@@ -9,9 +9,8 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useCustomEmojis } from '@/hooks/useCustomEmojis';
|
||||
import { useFeedSettings } from '@/hooks/useFeedSettings';
|
||||
@@ -19,8 +18,8 @@ import { GifPicker } from '@/components/GifPicker';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { MentionAutocomplete } from '@/components/MentionAutocomplete';
|
||||
import { CustomEmojiImg } from '@/components/CustomEmoji';
|
||||
import { EmojiShortcodeAutocomplete } from '@/components/EmojiShortcodeAutocomplete';
|
||||
import { StickerPicker } from '@/components/StickerPicker';
|
||||
|
||||
import { NoteContent } from '@/components/NoteContent';
|
||||
|
||||
@@ -45,6 +44,8 @@ import { formatTime } from '@/lib/formatTime';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { DITTO_RELAY } from '@/lib/appRelays';
|
||||
import { resizeImage } from '@/lib/resizeImage';
|
||||
import { extractHashtags } from '@/lib/hashtag';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
|
||||
const MAX_CHARS = 5000;
|
||||
|
||||
@@ -103,20 +104,30 @@ async function getImageMeta(file: File): Promise<{ dim?: string; blurhash?: stri
|
||||
}
|
||||
}
|
||||
|
||||
/** Root target for a compose action that isn't a Nostr event — a URL or a NIP-73 hashtag-style identifier (e.g. `bitcoin:tx:...`, `isbn:...`, `iso3166:...`). */
|
||||
export type ExternalReplyRoot = URL | `#${string}`;
|
||||
|
||||
/** True if `replyTo` is an external (non-Nostr-event) root. */
|
||||
function isExternalRoot(replyTo: NostrEvent | ExternalReplyRoot | undefined): replyTo is ExternalReplyRoot {
|
||||
return replyTo instanceof URL || typeof replyTo === 'string';
|
||||
}
|
||||
|
||||
interface ComposeBoxProps {
|
||||
onSuccess?: () => void;
|
||||
/** Callback with the freshly published event, useful for optimistic parent caches. */
|
||||
onPublished?: (event: NostrEvent) => void;
|
||||
placeholder?: string;
|
||||
compact?: boolean;
|
||||
/** Event being replied to – adds NIP-10 reply tags when set. A URL triggers NIP-22 comment mode. */
|
||||
replyTo?: NostrEvent | URL;
|
||||
/** Event being replied to – adds NIP-10 reply tags when set. A URL or NIP-73 identifier triggers NIP-22 comment mode. */
|
||||
replyTo?: NostrEvent | ExternalReplyRoot;
|
||||
/** Event being quoted – shows embedded preview and adds quote tags. */
|
||||
quotedEvent?: NostrEvent;
|
||||
/** If true, the compose area is always expanded (e.g. inside a modal). */
|
||||
forceExpanded?: boolean;
|
||||
/** If true, hides the avatar (useful inside modals with their own layout). */
|
||||
hideAvatar?: boolean;
|
||||
/** If true, suppresses the bottom border. Use when the composer sits directly above a visually distinct section (e.g. tabs with an arc background) that already provides separation. */
|
||||
hideBorder?: boolean;
|
||||
/** Controlled preview mode (for modal usage). */
|
||||
previewMode?: boolean;
|
||||
/** Callback to notify parent of previewable content changes. */
|
||||
@@ -186,6 +197,7 @@ export function ComposeBox({
|
||||
quotedEvent,
|
||||
forceExpanded = false,
|
||||
hideAvatar = false,
|
||||
hideBorder = false,
|
||||
previewMode: controlledPreviewMode,
|
||||
onHasPreviewableContentChange,
|
||||
initialContent = '',
|
||||
@@ -207,8 +219,26 @@ export function ComposeBox({
|
||||
const { toast } = useToast();
|
||||
const { config } = useAppContext();
|
||||
const imageQuality = config.imageQuality;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [content, setContent] = useState(initialContent);
|
||||
// Build a stable localStorage key based on compose context.
|
||||
// Different contexts (new post, reply, quote) each get their own draft slot.
|
||||
const draftKey = useMemo(() => {
|
||||
if (replyTo instanceof URL) return `compose-draft:url:${replyTo.href}`;
|
||||
if (typeof replyTo === 'string') return `compose-draft:ext:${replyTo}`;
|
||||
if (replyTo) return `compose-draft:reply:${replyTo.id}`;
|
||||
if (quotedEvent) return `compose-draft:quote:${quotedEvent.id}`;
|
||||
return 'compose-draft:new';
|
||||
}, [replyTo, quotedEvent]);
|
||||
|
||||
const [content, setContent] = useState(() => {
|
||||
if (initialContent) return initialContent;
|
||||
try {
|
||||
return localStorage.getItem(draftKey) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cwEnabled, setCwEnabled] = useState(false);
|
||||
const [cwText, setCwText] = useState('');
|
||||
@@ -226,7 +256,6 @@ export function ComposeBox({
|
||||
const [pollType, setPollType] = useState<'singlechoice' | 'multiplechoice'>('singlechoice');
|
||||
const [pollDuration, setPollDuration] = useState<7 | 3 | 1 | 0>(7);
|
||||
const [removedEmbeds, setRemovedEmbeds] = useState<Set<string>>(new Set());
|
||||
const [_uploadedFileTags, setUploadedFileTags] = useState<string[][]>([]);
|
||||
/** Maps uploaded file URLs to their NIP-94 tags (grouped per upload). */
|
||||
const [uploadedFileGroups, setUploadedFileGroups] = useState<Map<string, string[][]>>(new Map());
|
||||
/** Maps .xdc URLs to their generated webxdc UUIDs. */
|
||||
@@ -254,30 +283,66 @@ export function ComposeBox({
|
||||
setPollType('singlechoice');
|
||||
setPollDuration(7);
|
||||
setRemovedEmbeds(new Set());
|
||||
setUploadedFileTags([]);
|
||||
setUploadedFileGroups(new Map());
|
||||
setWebxdcUuids(new Map());
|
||||
setWebxdcMetas(new Map());
|
||||
}, [initialMode]);
|
||||
// Clear the auto-saved draft
|
||||
try { localStorage.removeItem(draftKey); } catch { /* ignore */ }
|
||||
}, [initialMode, draftKey]);
|
||||
|
||||
// Use controlled preview mode if provided, otherwise use internal state
|
||||
const previewMode = controlledPreviewMode !== undefined ? controlledPreviewMode : internalPreviewMode;
|
||||
|
||||
// Auto-expand when quotedEvent is provided
|
||||
// Auto-expand when quotedEvent is provided or draft is restored
|
||||
useEffect(() => {
|
||||
if (quotedEvent) {
|
||||
if (quotedEvent || content) {
|
||||
setExpanded(true);
|
||||
}
|
||||
}, [quotedEvent]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [quotedEvent]); // Only run on mount / quotedEvent change, not on every content change
|
||||
|
||||
// Auto-resize textarea height as content grows/shrinks
|
||||
// Auto-resize textarea height as content grows/shrinks.
|
||||
// Also re-run when previewMode toggles off so the remounted textarea
|
||||
// is sized to fit its content immediately.
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
// Reset to auto so shrinking is detected correctly
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [content]);
|
||||
}, [content, previewMode]);
|
||||
|
||||
// Auto-save draft content to localStorage (debounced to avoid thrashing)
|
||||
useEffect(() => {
|
||||
if (initialContent) return; // Don't auto-save when content was pre-filled
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
if (content.trim()) {
|
||||
localStorage.setItem(draftKey, content);
|
||||
} else {
|
||||
localStorage.removeItem(draftKey);
|
||||
}
|
||||
} catch {
|
||||
// localStorage might be full or unavailable
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [content, draftKey, initialContent]);
|
||||
|
||||
// On mobile, blur the textarea when the picker opens to dismiss the keyboard.
|
||||
const pickerWasOpen = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (pickerOpen) {
|
||||
textareaRef.current?.blur();
|
||||
pickerWasOpen.current = true;
|
||||
} else if (pickerWasOpen.current) {
|
||||
// Refocus after picker closes so the user can keep typing
|
||||
pickerWasOpen.current = false;
|
||||
const timer = setTimeout(() => textareaRef.current?.focus(), 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pickerOpen, isMobile]);
|
||||
|
||||
const charCount = content.length;
|
||||
const remaining = MAX_CHARS - charCount;
|
||||
@@ -302,6 +367,17 @@ export function ComposeBox({
|
||||
}, [cwEnabled, cwText]);
|
||||
|
||||
|
||||
// When the compose box transitions from collapsed → expanded (feed context),
|
||||
// ensure the textarea keeps focus. The height change re-render can
|
||||
// occasionally drop focus on desktop browsers. On iOS the native tap
|
||||
// already handles focus, so this is mainly a desktop safety net.
|
||||
const wasExpanded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (expanded && !wasExpanded.current) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
wasExpanded.current = expanded;
|
||||
}, [expanded]);
|
||||
|
||||
// Detect embeds in content (nevent, note, naddr, URLs) with their positions
|
||||
const detectedEmbeds = useMemo(() => {
|
||||
@@ -459,8 +535,8 @@ export function ComposeBox({
|
||||
const mockEvent = useMemo(() => {
|
||||
if (!user || !content) return null;
|
||||
|
||||
const hashtags = content.match(/#[\p{L}\p{N}_]+/gu)?.map((t) => t.slice(1)) || [];
|
||||
const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]);
|
||||
const hashtags = extractHashtags(content);
|
||||
const tags: string[][] = hashtags.map((t) => ['t', t]);
|
||||
|
||||
// NIP-30: Add emoji tags for custom emojis referenced in content
|
||||
if (customEmojis.length > 0) {
|
||||
@@ -578,7 +654,6 @@ export function ComposeBox({
|
||||
}
|
||||
|
||||
// Store the full NIP-94 tags for later use in imeta
|
||||
setUploadedFileTags((prev) => [...prev, ...tags]);
|
||||
setUploadedFileGroups((prev) => new Map(prev).set(url, tags));
|
||||
setContent((prev) => (prev ? prev + '\n' + url : url));
|
||||
|
||||
@@ -689,8 +764,8 @@ export function ComposeBox({
|
||||
onPublished?.(event);
|
||||
} else {
|
||||
// Determine kind: 1244 for NIP-22 replies, 1222 for root messages
|
||||
const isNip22Reply = replyTo && (replyTo instanceof URL || replyTo.kind !== 1);
|
||||
const isKind1Reply = replyTo && !(replyTo instanceof URL) && replyTo.kind === 1;
|
||||
const isNip22Reply = replyTo && (isExternalRoot(replyTo) || replyTo.kind !== 1);
|
||||
const isKind1Reply = replyTo && !isExternalRoot(replyTo) && replyTo.kind === 1;
|
||||
|
||||
if (isNip22Reply) {
|
||||
// NIP-22 voice reply (kind 1244) — use postComment infrastructure
|
||||
@@ -699,11 +774,18 @@ export function ComposeBox({
|
||||
const voiceTags: string[][] = [imetaTag, ...cwTags];
|
||||
|
||||
if (replyTo instanceof URL) {
|
||||
const kLabel = replyTo.protocol === 'http:' || replyTo.protocol === 'https:' ? 'web' : replyTo.protocol.replace(/:$/, '');
|
||||
voiceTags.push(['I', replyTo.toString()]);
|
||||
voiceTags.push(['K', replyTo.protocol === 'http:' || replyTo.protocol === 'https:' ? 'web' : replyTo.protocol.replace(/:$/, '')]);
|
||||
voiceTags.push(['K', kLabel]);
|
||||
// lowercase reply tags pointing to same root
|
||||
voiceTags.push(['i', replyTo.toString()]);
|
||||
voiceTags.push(['k', replyTo.protocol === 'http:' || replyTo.protocol === 'https:' ? 'web' : replyTo.protocol.replace(/:$/, '')]);
|
||||
voiceTags.push(['k', kLabel]);
|
||||
} else if (typeof replyTo === 'string') {
|
||||
// NIP-73 hashtag-style identifier (e.g. `bitcoin:tx:...`, `isbn:...`, `iso3166:...`)
|
||||
voiceTags.push(['I', replyTo]);
|
||||
voiceTags.push(['K', '#']);
|
||||
voiceTags.push(['i', replyTo]);
|
||||
voiceTags.push(['k', '#']);
|
||||
} else {
|
||||
voiceTags.push(['E', replyTo.id]);
|
||||
voiceTags.push(['K', replyTo.kind.toString()]);
|
||||
@@ -719,7 +801,7 @@ export function ComposeBox({
|
||||
content: audioUrl,
|
||||
tags: voiceTags,
|
||||
});
|
||||
} else if (isKind1Reply && !(replyTo instanceof URL)) {
|
||||
} else if (isKind1Reply && !isExternalRoot(replyTo)) {
|
||||
// NIP-10 voice reply to a kind 1 note — still publish as kind 1222 with reply tags
|
||||
const voiceTags: string[][] = [imetaTag, ...cwTags];
|
||||
const rootTag = replyTo.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
|
||||
@@ -749,7 +831,7 @@ export function ComposeBox({
|
||||
// Reset state
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
if (replyTo) {
|
||||
if (replyTo instanceof URL) {
|
||||
if (isExternalRoot(replyTo)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: ['replies', replyTo.id] });
|
||||
@@ -772,8 +854,8 @@ export function ComposeBox({
|
||||
if (!content.trim() || !user || charCount > MAX_CHARS) return;
|
||||
|
||||
try {
|
||||
const hashtags = content.match(/#[\p{L}\p{N}_]+/gu)?.map((t) => t.slice(1)) || [];
|
||||
const tags: string[][] = hashtags.map((t) => ['t', t.toLowerCase()]);
|
||||
const hashtags = extractHashtags(content);
|
||||
const tags: string[][] = hashtags.map((t) => ['t', t]);
|
||||
|
||||
// NIP-27 mention p tags — extract nostr:npub1... from content
|
||||
const mentionMatches = content.matchAll(/nostr:(npub1[023456789acdefghjklmnpqrstuvwxyz]+)/g);
|
||||
@@ -794,10 +876,10 @@ export function ComposeBox({
|
||||
tags.push(['p', pk]);
|
||||
}
|
||||
|
||||
// Reply tags: NIP-10 for kind 1 targets, NIP-22 for non-kind-1 targets and URLs
|
||||
const isNip22Reply = replyTo && (replyTo instanceof URL || replyTo.kind !== 1);
|
||||
// Reply tags: NIP-10 for kind 1 targets, NIP-22 for non-kind-1 targets and external roots (URL or NIP-73 id)
|
||||
const isNip22Reply = replyTo && (isExternalRoot(replyTo) || replyTo.kind !== 1);
|
||||
|
||||
if (replyTo && !isNip22Reply && !(replyTo instanceof URL)) {
|
||||
if (replyTo && !isNip22Reply && !isExternalRoot(replyTo)) {
|
||||
// NIP-10 reply tags (kind 1 targets only)
|
||||
const rootTag = replyTo.tags.find(([name, , , marker]) => name === 'e' && marker === 'root');
|
||||
if (rootTag) {
|
||||
@@ -927,6 +1009,9 @@ export function ComposeBox({
|
||||
if (replyTo instanceof URL) {
|
||||
// External content root — the URL is the root directly
|
||||
root = replyTo;
|
||||
} else if (typeof replyTo === 'string') {
|
||||
// NIP-73 hashtag-style identifier root (e.g. `bitcoin:tx:...`, `isbn:...`)
|
||||
root = replyTo;
|
||||
} else if (replyTo.kind === 1111) {
|
||||
// Replying to a comment: replyTo is the parent, root is derived from its uppercase tags
|
||||
reply = replyTo;
|
||||
@@ -995,14 +1080,14 @@ export function ComposeBox({
|
||||
|
||||
resetComposeState();
|
||||
// Optimistically bump the reply count on the parent event
|
||||
if (replyTo && !(replyTo instanceof URL)) {
|
||||
if (replyTo && !isExternalRoot(replyTo)) {
|
||||
queryClient.setQueryData<EventStats>(['event-stats', replyTo.id], (prev) =>
|
||||
prev ? { ...prev, replies: prev.replies + 1 } : prev,
|
||||
);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['feed'] });
|
||||
if (replyTo) {
|
||||
if (replyTo instanceof URL) {
|
||||
if (isExternalRoot(replyTo)) {
|
||||
queryClient.invalidateQueries({ queryKey: ['nostr', 'comments'] });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: ['replies', replyTo.id] });
|
||||
@@ -1094,7 +1179,12 @@ export function ComposeBox({
|
||||
if (!user && compact) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-3 bg-background/85 rounded-2xl")}>
|
||||
<div className={cn(
|
||||
"px-4 pt-3 bg-background/85 flex flex-col",
|
||||
forceExpanded ? "flex-1 min-h-0 rounded-2xl" : "",
|
||||
pickerOpen ? "pb-0" : "pb-3",
|
||||
!forceExpanded && !hideBorder && "border-b border-border",
|
||||
)}>
|
||||
{/* Preview toggle at top when not controlled and has previewable content */}
|
||||
{hasPreviewableContent && controlledPreviewMode === undefined && (
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
@@ -1125,7 +1215,7 @@ export function ComposeBox({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<div className={cn("flex gap-3", forceExpanded && "flex-1 min-h-0")}>
|
||||
{!hideAvatar && user && (
|
||||
isProfileLoading ? (
|
||||
<Skeleton className="size-12 shrink-0 mt-0.5 rounded-full" />
|
||||
@@ -1134,19 +1224,22 @@ export function ComposeBox({
|
||||
<Avatar className="size-12 shrink-0 mt-0.5">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.display_name || metadata?.name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
{(metadata?.name || metadata?.display_name || genUserName(user?.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn("flex-1 min-w-0", forceExpanded && "flex flex-col min-h-0")}>
|
||||
{/* Scrollable content area (textarea, poll, CW, quoted event) */}
|
||||
<div className={cn(forceExpanded && "flex-1 min-h-0 overflow-y-auto")}>
|
||||
{!previewMode ? (
|
||||
/* ── Edit mode — Textarea ────────────────────────────── */
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
dir="auto"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onPointerDown={expand}
|
||||
@@ -1159,6 +1252,10 @@ export function ComposeBox({
|
||||
)}
|
||||
rows={1}
|
||||
disabled={!user}
|
||||
// In modal context, auto-focus the textarea so the keyboard
|
||||
// opens immediately — especially important on iOS where
|
||||
// programmatic focus() outside a user gesture is ignored.
|
||||
autoFocus={forceExpanded}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
handleSubmit();
|
||||
@@ -1179,8 +1276,8 @@ export function ComposeBox({
|
||||
) : (
|
||||
/* Preview mode - Show how post will look */
|
||||
mockEvent && (
|
||||
<div className="pt-2.5 pb-2 min-h-[100px]">
|
||||
<div className="text-lg opacity-85">
|
||||
<div className="pt-2.5 pb-2 min-h-[100px] overflow-hidden">
|
||||
<div className="text-lg opacity-85 [&_img]:max-w-full [&_img]:h-auto">
|
||||
<NoteContent event={mockEvent} className="text-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1305,7 +1402,7 @@ export function ComposeBox({
|
||||
|
||||
{/* Quoted event preview */}
|
||||
{showQuotedEvent && quotedEvent && quotedEventKey && (
|
||||
<div className="mt-4 mb-3">
|
||||
<div className="mt-4 mb-3 overflow-hidden">
|
||||
{quotedEvent.kind >= 30000 && quotedEvent.kind < 40000 ? (
|
||||
<EmbeddedNaddr addr={{
|
||||
kind: quotedEvent.kind,
|
||||
@@ -1315,10 +1412,14 @@ export function ComposeBox({
|
||||
) : (
|
||||
<EmbeddedNote eventId={quotedEvent.id} authorHint={quotedEvent.pubkey} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>{/* end scrollable content area */}
|
||||
|
||||
{/* Toolbar + post button */}
|
||||
</div>{/* end flex-1 content column */}
|
||||
</div>{/* end avatar + content row */}
|
||||
|
||||
{/* Toolbar + post button — full width, not indented by avatar */}
|
||||
{isExpanded && (
|
||||
voiceRecorder.isRecording || isPublishingVoice ? (
|
||||
/* ── Voice recording UI ─────────────────────────────── */
|
||||
@@ -1377,9 +1478,9 @@ export function ComposeBox({
|
||||
</div>
|
||||
) : (
|
||||
/* ── Normal toolbar ──────────────────────────────────── */
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<div className={cn("flex items-center justify-between mt-3", forceExpanded && "shrink-0")}>
|
||||
{/* Left: action icons */}
|
||||
<div className="flex items-center gap-1 -ml-2">
|
||||
<div className="flex items-center gap-1">
|
||||
{/* File upload */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1427,12 +1528,12 @@ export function ComposeBox({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Emoji / GIF picker */}
|
||||
{/* Emoji / GIF picker toggle — inline panel renders below the toolbar */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
onClick={() => setPickerOpen((v) => !v)}
|
||||
className={cn(
|
||||
'p-2 rounded-full transition-colors',
|
||||
pickerOpen
|
||||
@@ -1445,115 +1546,6 @@ export function ComposeBox({
|
||||
</TooltipTrigger>
|
||||
{!pickerOpen && <TooltipContent>Emoji / GIF</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<DialogContent
|
||||
className="w-auto max-w-fit p-0 gap-0 border-border rounded-xl overflow-hidden [&>button]:hidden"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Tab bar */}
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('emoji')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
|
||||
pickerTab === 'emoji'
|
||||
? 'text-primary border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground border-border',
|
||||
)}
|
||||
>
|
||||
<Smile className="size-3.5" />
|
||||
Emoji
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('gif')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
|
||||
pickerTab === 'gif'
|
||||
? 'text-primary border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground border-border',
|
||||
)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="1" y="1" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.5" fill="none"/>
|
||||
<text x="9" y="9" textAnchor="middle" dominantBaseline="central" fontSize="7" fontWeight="700" fontFamily="system-ui,sans-serif" fill="currentColor" letterSpacing="0.5">GIF</text>
|
||||
</svg>
|
||||
GIF
|
||||
</button>
|
||||
{customEmojis.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('stickers')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-medium transition-colors border-b-2',
|
||||
pickerTab === 'stickers'
|
||||
? 'text-primary border-primary'
|
||||
: 'text-muted-foreground hover:text-foreground border-border',
|
||||
)}
|
||||
>
|
||||
<Sticker className="size-3.5" />
|
||||
Stickers
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Picker content */}
|
||||
{pickerTab === 'emoji' ? (
|
||||
<Suspense fallback={<div className="w-[316px] h-[435px] flex items-center justify-center"><Loader2 className="size-6 animate-spin text-muted-foreground" /></div>}>
|
||||
<LazyEmojiPicker
|
||||
customEmojis={customEmojis}
|
||||
onSelect={(selection) => {
|
||||
if (selection.type === 'native') {
|
||||
insertEmoji(selection.emoji);
|
||||
} else {
|
||||
insertEmoji(`:${selection.shortcode}:`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
) : pickerTab === 'stickers' ? (
|
||||
<div className="w-[316px] h-[435px]">
|
||||
{customEmojis.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Sticker className="size-8 opacity-40" />
|
||||
<p className="text-sm">No sticker packs yet</p>
|
||||
<p className="text-xs">Add emoji packs to your profile to use stickers</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="grid grid-cols-4 gap-1.5 p-2">
|
||||
{customEmojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji.shortcode}
|
||||
type="button"
|
||||
title={emoji.shortcode}
|
||||
onClick={() => {
|
||||
setContent((prev) => (prev ? prev + '\n' + emoji.url : emoji.url));
|
||||
setPickerOpen(false);
|
||||
expand();
|
||||
}}
|
||||
className="aspect-square rounded-lg overflow-hidden hover:bg-muted transition-colors p-1 group"
|
||||
>
|
||||
<CustomEmojiImg
|
||||
name={emoji.shortcode}
|
||||
url={emoji.url}
|
||||
className="w-full h-full object-contain group-hover:scale-110 transition-transform duration-150"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<GifPicker onSelect={(gif) => {
|
||||
setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url));
|
||||
setPickerOpen(false);
|
||||
expand();
|
||||
}} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Overflow: Poll + CW */}
|
||||
<Popover open={trayOpen} onOpenChange={setTrayOpen}>
|
||||
@@ -1576,7 +1568,7 @@ export function ComposeBox({
|
||||
</TooltipTrigger>
|
||||
{!trayOpen && <TooltipContent>More</TooltipContent>}
|
||||
</Tooltip>
|
||||
<PopoverContent side="bottom" align="start" sideOffset={6} className="w-44 p-1.5 rounded-xl border-border shadow-lg">
|
||||
<PopoverContent side="top" align="start" sideOffset={6} className="w-44 p-1.5 rounded-xl border-border shadow-lg">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* Polls are top-level events (kind 1068), so they only make sense as a
|
||||
standalone post or rooted on an external-content URL (e.g. iso3166: country
|
||||
@@ -1643,8 +1635,93 @@ export function ComposeBox({
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline emoji / GIF / sticker picker panel — rendered outside the
|
||||
padded content area so it bleeds edge-to-edge. */}
|
||||
{pickerOpen && (
|
||||
<div className={cn("-mx-4 shrink-0 overflow-hidden animate-in fade-in-0 duration-150", forceExpanded && "rounded-b-2xl")}>
|
||||
{/* Tab bar — pill highlight style for inline mode */}
|
||||
<div className="flex gap-1 px-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('emoji')}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
pickerTab === 'emoji'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Smile className="size-3.5" />
|
||||
Emoji
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('gif')}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
pickerTab === 'gif'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="1" y="1" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.5" fill="none"/>
|
||||
<text x="9" y="9" textAnchor="middle" dominantBaseline="central" fontSize="7" fontWeight="700" fontFamily="system-ui,sans-serif" fill="currentColor" letterSpacing="0.5">GIF</text>
|
||||
</svg>
|
||||
GIF
|
||||
</button>
|
||||
{customEmojis.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerTab('stickers')}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1.5 px-4 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
pickerTab === 'stickers'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Sticker className="size-3.5" />
|
||||
Stickers
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* Picker content */}
|
||||
{pickerTab === 'emoji' ? (
|
||||
<Suspense fallback={<div className="w-full h-[280px] flex items-center justify-center"><Loader2 className="size-6 animate-spin text-muted-foreground" /></div>}>
|
||||
<LazyEmojiPicker
|
||||
customEmojis={customEmojis}
|
||||
onSelect={(selection) => {
|
||||
if (selection.type === 'native') {
|
||||
insertEmoji(selection.emoji);
|
||||
} else {
|
||||
insertEmoji(`:${selection.shortcode}:`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
) : pickerTab === 'stickers' ? (
|
||||
<StickerPicker
|
||||
customEmojis={customEmojis}
|
||||
height={280}
|
||||
autoFocus={!isMobile}
|
||||
onSelect={(emoji) => {
|
||||
setContent((prev) => (prev ? prev + '\n' + emoji.url : emoji.url));
|
||||
setPickerOpen(false);
|
||||
expand();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<GifPicker onSelect={(gif) => {
|
||||
setContent((prev) => (prev ? prev + '\n' + gif.url : gif.url));
|
||||
setPickerOpen(false);
|
||||
expand();
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
import { ExternalLink, Sparkles } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Birdstar kind 30621 — Custom Constellation.
|
||||
*
|
||||
* An addressable event carrying a user-drawn star-figure: a title, a
|
||||
* freeform description in `content`, and one or more `edge` tags referencing
|
||||
* pairs of Hipparcos catalog numbers (e.g. `["edge", "32349", "37279"]`).
|
||||
*
|
||||
* Rendering the figure requires the full Hipparcos star catalog (~1.3 MB),
|
||||
* so the preview component is code-split via `lazy()` — the catalog data
|
||||
* only loads when a user actually scrolls a constellation event into view.
|
||||
*/
|
||||
|
||||
const ConstellationStarMap = lazy(() =>
|
||||
import('./ConstellationStarMap').then((m) => ({ default: m.ConstellationStarMap })),
|
||||
);
|
||||
|
||||
interface ConstellationContentProps {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ParsedConstellation {
|
||||
title: string;
|
||||
description: string;
|
||||
edges: Array<readonly [number, number]>;
|
||||
}
|
||||
|
||||
function parseConstellation(event: NostrEvent): ParsedConstellation {
|
||||
const title = event.tags.find(([n]) => n === 'title')?.[1]
|
||||
?? event.tags.find(([n]) => n === 'd')?.[1]
|
||||
?? 'Untitled constellation';
|
||||
|
||||
const edges: Array<readonly [number, number]> = [];
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== 'edge' || tag.length < 3) continue;
|
||||
const from = Number(tag[1]);
|
||||
const to = Number(tag[2]);
|
||||
// Reject non-positive-integer HIP numbers per the NIP's validation rules.
|
||||
if (!Number.isInteger(from) || from <= 0) continue;
|
||||
if (!Number.isInteger(to) || to <= 0) continue;
|
||||
edges.push([from, to] as const);
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
description: event.content.trim(),
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
export function ConstellationContent({ event, className }: ConstellationContentProps) {
|
||||
const { title, description, edges } = useMemo(() => parseConstellation(event), [event]);
|
||||
|
||||
// Birdstar routes constellations at `/:nip19` using the event's naddr1
|
||||
// coordinate (kind 30621 is addressable). Build the link once so we can
|
||||
// drop it into a "View on Birdstar" action below the map.
|
||||
const birdstarUrl = useMemo(() => {
|
||||
const dTag = event.tags.find(([n]) => n === 'd')?.[1];
|
||||
if (!dTag) return undefined;
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
identifier: dTag,
|
||||
});
|
||||
return `https://birdstar.app/${naddr}`;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [event]);
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2', className)}>
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-sm transition-shadow hover:shadow-md">
|
||||
{/* Star map */}
|
||||
<div className="aspect-[4/3] w-full">
|
||||
<Suspense fallback={<Skeleton className="size-full" />}>
|
||||
<ConstellationStarMap edges={edges} title={title} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Title + description */}
|
||||
<div className="space-y-1.5 p-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles aria-hidden className="size-3.5 shrink-0 text-amber-500" />
|
||||
<h3 className="truncate text-[15px] font-semibold leading-tight">
|
||||
{title}
|
||||
</h3>
|
||||
{birdstarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUrl(birdstarUrl);
|
||||
}}
|
||||
className="ml-auto inline-flex shrink-0 items-center gap-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
View on Birdstar
|
||||
<ExternalLink aria-hidden className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="whitespace-pre-wrap break-words text-[13px] leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useId, useMemo } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { starByHip } from '@/lib/starCatalog';
|
||||
|
||||
/**
|
||||
* Renders a custom constellation as an SVG star-map.
|
||||
*
|
||||
* This component is code-split via `lazy()` from `ConstellationContent`:
|
||||
* the Hipparcos star catalog it imports is ~1.3 MB of JSON and must never
|
||||
* ship in the main bundle.
|
||||
*
|
||||
* The figure is gnomonically projected onto a tangent plane centered on the
|
||||
* centroid of its stars (on the unit sphere) and then normalized to fit the
|
||||
* SVG viewBox with equal aspect, so shapes are never distorted. Stars are
|
||||
* sized by apparent magnitude, with the brightest few getting a soft glow
|
||||
* to evoke a real sky.
|
||||
*
|
||||
* Adapted from the `ConstellationPreview` component in the Birdstar
|
||||
* reference client.
|
||||
*/
|
||||
|
||||
export interface ConstellationStarMapProps {
|
||||
edges: ReadonlyArray<readonly [number, number]>;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
const HOUR = (15 * Math.PI) / 180; // 1h = 15°
|
||||
|
||||
interface ResolvedStar {
|
||||
hip: number;
|
||||
ra: number; // hours
|
||||
dec: number; // degrees
|
||||
mag: number;
|
||||
}
|
||||
|
||||
interface ProjectedPoint {
|
||||
hip: number;
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
}
|
||||
|
||||
interface ProjectedEdge {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
interface BackgroundStar {
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
o: number;
|
||||
}
|
||||
|
||||
interface ProjectionResult {
|
||||
points: Map<number, ProjectedPoint>;
|
||||
edges: ProjectedEdge[];
|
||||
backgroundStars: BackgroundStar[];
|
||||
}
|
||||
|
||||
export function ConstellationStarMap({ edges, title, className }: ConstellationStarMapProps) {
|
||||
// A stable unique id keeps multiple previews on the page from colliding on
|
||||
// the shared <filter> id.
|
||||
const rawId = useId();
|
||||
const uid = rawId.replace(/:/g, '');
|
||||
const glowId = `cm-glow-${uid}`;
|
||||
|
||||
const projected = useMemo(() => project(edges), [edges]);
|
||||
|
||||
if (!projected || projected.points.size === 0) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-full items-center justify-center rounded-xl ring-1 ring-border bg-[radial-gradient(ellipse_at_50%_40%,#1e1b4b_0%,#0b1026_55%,#020617_100%)] text-xs text-white/60',
|
||||
className,
|
||||
)}
|
||||
role="img"
|
||||
aria-label={title ?? 'Constellation preview'}
|
||||
>
|
||||
No recognizable stars.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { points, edges: projEdges, backgroundStars } = projected;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full w-full overflow-hidden rounded-xl ring-1 ring-border',
|
||||
'bg-[radial-gradient(ellipse_at_50%_40%,#1e1b4b_0%,#0b1026_55%,#020617_100%)]',
|
||||
className,
|
||||
)}
|
||||
role="img"
|
||||
aria-label={title ?? 'Constellation preview'}
|
||||
>
|
||||
{/* Background field stars — cover the whole container regardless of
|
||||
aspect ratio, so corners never look bare. */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
className="absolute inset-0 size-full"
|
||||
aria-hidden
|
||||
>
|
||||
<g fill="rgba(255, 255, 255, 0.5)">
|
||||
{backgroundStars.map((s, i) => (
|
||||
<circle key={i} cx={s.x} cy={s.y} r={s.r} opacity={s.o} />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Figure — preserves aspect so stick-figures never distort. */}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
className="absolute inset-0 size-full"
|
||||
>
|
||||
<defs>
|
||||
<filter
|
||||
id={glowId}
|
||||
x="-100%"
|
||||
y="-100%"
|
||||
width="300%"
|
||||
height="300%"
|
||||
colorInterpolationFilters="sRGB"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="1.1" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Edges */}
|
||||
<g
|
||||
stroke="rgba(253, 230, 138, 0.8)"
|
||||
strokeWidth={0.9}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
pointerEvents="none"
|
||||
>
|
||||
{projEdges.map((e, i) => (
|
||||
<line key={i} x1={e.x1} y1={e.y1} x2={e.x2} y2={e.y2} />
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Figure stars with soft glow */}
|
||||
<g fill="rgb(254, 243, 199)" filter={`url(#${glowId})`}>
|
||||
{Array.from(points.values()).map((p) => (
|
||||
<circle key={p.hip} cx={p.x} cy={p.y} r={p.r} pointerEvents="none" />
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function project(edges: ReadonlyArray<readonly [number, number]>): ProjectionResult | null {
|
||||
// Collect unique stars referenced by the figure. Unknown HIP numbers are
|
||||
// silently dropped per the NIP's validation rules.
|
||||
const stars = new Map<number, ResolvedStar>();
|
||||
for (const [a, b] of edges) {
|
||||
if (!stars.has(a)) {
|
||||
const s = starByHip(a);
|
||||
if (s) stars.set(a, { hip: s.hip, ra: s.ra, dec: s.dec, mag: s.mag });
|
||||
}
|
||||
if (!stars.has(b)) {
|
||||
const s = starByHip(b);
|
||||
if (s) stars.set(b, { hip: s.hip, ra: s.ra, dec: s.dec, mag: s.mag });
|
||||
}
|
||||
}
|
||||
if (stars.size === 0) return null;
|
||||
|
||||
// Mean unit-vector as the projection tangent point — handles wrap-around
|
||||
// at RA=0h/24h and the poles without special-casing.
|
||||
let mx = 0;
|
||||
let my = 0;
|
||||
let mz = 0;
|
||||
for (const s of stars.values()) {
|
||||
const raRad = s.ra * HOUR;
|
||||
const decRad = s.dec * DEG;
|
||||
const cosDec = Math.cos(decRad);
|
||||
mx += cosDec * Math.cos(raRad);
|
||||
my += cosDec * Math.sin(raRad);
|
||||
mz += Math.sin(decRad);
|
||||
}
|
||||
const norm = Math.hypot(mx, my, mz) || 1;
|
||||
mx /= norm;
|
||||
my /= norm;
|
||||
mz /= norm;
|
||||
|
||||
const centerDec = Math.asin(Math.max(-1, Math.min(1, mz)));
|
||||
const centerRa = Math.atan2(my, mx);
|
||||
const sinC = Math.sin(centerDec);
|
||||
const cosC = Math.cos(centerDec);
|
||||
|
||||
// Gnomonic projection onto a tangent plane at (centerRa, centerDec).
|
||||
const raw = new Map<number, { x: number; y: number; mag: number }>();
|
||||
for (const s of stars.values()) {
|
||||
const ra = s.ra * HOUR;
|
||||
const dec = s.dec * DEG;
|
||||
const cosDec = Math.cos(dec);
|
||||
const sinDec = Math.sin(dec);
|
||||
const dRa = ra - centerRa;
|
||||
const cosDRa = Math.cos(dRa);
|
||||
const sinDRa = Math.sin(dRa);
|
||||
const cosDistance = sinC * sinDec + cosC * cosDec * cosDRa;
|
||||
if (cosDistance <= 1e-6) continue;
|
||||
const x = (cosDec * sinDRa) / cosDistance;
|
||||
const y = (cosC * sinDec - sinC * cosDec * cosDRa) / cosDistance;
|
||||
// Flip x so RA increases to the left (conventional sky orientation).
|
||||
raw.set(s.hip, { x: -x, y, mag: s.mag });
|
||||
}
|
||||
if (raw.size === 0) return null;
|
||||
|
||||
// Bounding box.
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minY = Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const p of raw.values()) {
|
||||
if (p.x < minX) minX = p.x;
|
||||
if (p.x > maxX) maxX = p.x;
|
||||
if (p.y < minY) minY = p.y;
|
||||
if (p.y > maxY) maxY = p.y;
|
||||
}
|
||||
|
||||
const PADDING = 14;
|
||||
const AVAILABLE = 100 - PADDING * 2;
|
||||
const spanX = maxX - minX;
|
||||
const spanY = maxY - minY;
|
||||
const span = Math.max(spanX, spanY);
|
||||
const scale = span > 1e-9 ? AVAILABLE / span : 0;
|
||||
const offsetX = (AVAILABLE - spanX * scale) / 2 + PADDING;
|
||||
const offsetY = (AVAILABLE - spanY * scale) / 2 + PADDING;
|
||||
|
||||
const points = new Map<number, ProjectedPoint>();
|
||||
for (const [hip, p] of raw) {
|
||||
const x = (p.x - minX) * scale + offsetX;
|
||||
// Invert SVG y so north-ish stars sit on top.
|
||||
const y = 100 - ((p.y - minY) * scale + offsetY);
|
||||
points.set(hip, { hip, x, y, r: magToRadius(p.mag) });
|
||||
}
|
||||
|
||||
const projEdges: ProjectedEdge[] = [];
|
||||
for (const [a, b] of edges) {
|
||||
const pa = points.get(a);
|
||||
const pb = points.get(b);
|
||||
if (!pa || !pb) continue;
|
||||
projEdges.push({ x1: pa.x, y1: pa.y, x2: pb.x, y2: pb.y });
|
||||
}
|
||||
|
||||
// Deterministic scatter of faint background stars seeded from the edge
|
||||
// list, so the same figure always renders identically.
|
||||
const backgroundStars = makeBackgroundStars(edges, points);
|
||||
|
||||
return { points, edges: projEdges, backgroundStars };
|
||||
}
|
||||
|
||||
function makeBackgroundStars(
|
||||
edges: ReadonlyArray<readonly [number, number]>,
|
||||
figure: Map<number, ProjectedPoint>,
|
||||
): BackgroundStar[] {
|
||||
let seed = 2166136261;
|
||||
for (const [a, b] of edges) {
|
||||
seed ^= a * 16777619;
|
||||
seed = Math.imul(seed, 16777619);
|
||||
seed ^= b * 2246822519;
|
||||
seed = Math.imul(seed, 16777619);
|
||||
}
|
||||
const rand = mulberry32(seed >>> 0);
|
||||
|
||||
const MIN_DIST = 5; // clearance from figure stars (viewBox units)
|
||||
const out: BackgroundStar[] = [];
|
||||
const figurePts = Array.from(figure.values());
|
||||
let attempts = 0;
|
||||
while (out.length < 22 && attempts < 120) {
|
||||
attempts++;
|
||||
const x = rand() * 100;
|
||||
const y = rand() * 100;
|
||||
let tooClose = false;
|
||||
for (const p of figurePts) {
|
||||
if (Math.hypot(p.x - x, p.y - y) < MIN_DIST) {
|
||||
tooClose = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (tooClose) continue;
|
||||
out.push({ x, y, r: 0.2 + rand() * 0.5, o: 0.3 + rand() * 0.55 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mulberry32(a: number): () => number {
|
||||
return function () {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map apparent magnitude to a preview dot radius in viewBox units.
|
||||
* Brighter stars (lower magnitude) get larger dots, clamped to keep mag~6
|
||||
* stars visible and mag~0 stars from dominating the thumbnail.
|
||||
*/
|
||||
function magToRadius(mag: number): number {
|
||||
const r = 2.3 - 0.25 * mag;
|
||||
if (r < 0.8) return 0.8;
|
||||
if (r > 2.4) return 2.4;
|
||||
return r;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { useAwardBadge } from '@/hooks/useAwardBadge';
|
||||
import { useAcceptBadge } from '@/hooks/useAcceptBadge';
|
||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils';
|
||||
|
||||
/** Convert a badge name into a URL-safe slug for the d-tag identifier. */
|
||||
@@ -43,6 +44,7 @@ interface CreateBadgeDialogProps {
|
||||
export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const shareOrigin = useShareOrigin();
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
@@ -175,11 +177,11 @@ export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps
|
||||
pubkey: createdBadge.pubkey,
|
||||
identifier: dTag,
|
||||
});
|
||||
navigator.clipboard.writeText(`${window.location.origin}/${naddr}`);
|
||||
navigator.clipboard.writeText(`${shareOrigin}/${naddr}`);
|
||||
setCopied(true);
|
||||
toast({ title: 'Link copied to clipboard!' });
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [createdBadge, toast]);
|
||||
}, [createdBadge, toast, shareOrigin]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ export function CreateGoalDialog({ communityATag, children, open: controlledOpen
|
||||
const msats = sats * 1000;
|
||||
|
||||
// NIP-75 relay hints are where zap receipts should be published and tallied.
|
||||
const relayUrls = getEffectiveRelays(config.relayMetadata, true).relays
|
||||
const relayUrls = getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays).relays
|
||||
.filter((r) => r.write)
|
||||
.map((r) => r.url);
|
||||
if (relayUrls.length === 0) {
|
||||
|
||||
@@ -58,10 +58,10 @@ export function DMProviderWrapper({ children }: DMProviderWrapperProps) {
|
||||
return messaging.discoveryRelays;
|
||||
}
|
||||
|
||||
return getEffectiveRelays(config.relayMetadata, config.useAppRelays).relays
|
||||
return getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays).relays
|
||||
.filter((relay) => relay.read)
|
||||
.map((relay) => relay.url);
|
||||
}, [messaging.discoveryRelays, config.relayMetadata, config.useAppRelays]);
|
||||
}, [messaging.discoveryRelays, config.relayMetadata, config.useAppRelays, config.useUserRelays]);
|
||||
|
||||
const relayMode = messaging.relayMode ?? "hybrid";
|
||||
const protocolMode = messaging.protocolMode;
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Users, PartyPopper, UserCheck } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { EmbeddedCardShell } from '@/components/EmbeddedCardShell';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { encodeEventAddress } from '@/lib/encodeEvent';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { parsePeopleList, getDisplayPubkeys } from '@/lib/packUtils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/** Max avatars shown in the embedded preview stack. */
|
||||
const EMBED_AVATAR_LIMIT = 6;
|
||||
|
||||
interface EmbeddedPeopleListCardProps {
|
||||
event: NostrEvent;
|
||||
className?: string;
|
||||
disableHoverCards?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact embedded card for people-list events — kind 3 (follow list),
|
||||
* 30000 (follow set), and 39089 (follow pack).
|
||||
*
|
||||
* The generic `EmbeddedNoteCard` / `EmbeddedNaddrCard` fallbacks render an
|
||||
* empty shell for these kinds because the meaningful data (the list of
|
||||
* pubkeys) lives in `p` tags, not in `content` or title tags. This card
|
||||
* shows the title, an avatar stack, and a member count — matching the
|
||||
* visual language of the full feed card `PeopleListContent`.
|
||||
*/
|
||||
export function EmbeddedPeopleListCard({ event, className, disableHoverCards }: EmbeddedPeopleListCardProps) {
|
||||
// For kind 3 follow lists we synthesize a title from the author's display name.
|
||||
const needsAuthorMeta = event.kind === 3;
|
||||
const author = useAuthor(needsAuthorMeta ? event.pubkey : '');
|
||||
const authorMetadata = needsAuthorMeta ? author.data?.metadata : undefined;
|
||||
|
||||
const { title, description, image, pubkeys, variant } = useMemo(
|
||||
() => parsePeopleList(event, {
|
||||
authorMetadata,
|
||||
authorDisplayName: authorMetadata?.name || authorMetadata?.display_name,
|
||||
}),
|
||||
[event, authorMetadata],
|
||||
);
|
||||
|
||||
const nip19Id = useMemo(() => encodeEventAddress(event), [event]);
|
||||
|
||||
const previewPubkeys = useMemo(
|
||||
() => getDisplayPubkeys(event, pubkeys).slice(0, EMBED_AVATAR_LIMIT),
|
||||
[event, pubkeys],
|
||||
);
|
||||
const { data: membersMap } = useAuthors(previewPubkeys);
|
||||
|
||||
const safeImage = useMemo(() => sanitizeUrl(image), [image]);
|
||||
|
||||
const TitleIcon = variant === 'follow-list' ? UserCheck : variant === 'follow-set' ? Users : PartyPopper;
|
||||
const memberLabel = pubkeys.length === 1 ? '1 member' : `${pubkeys.length} members`;
|
||||
|
||||
return (
|
||||
<EmbeddedCardShell
|
||||
pubkey={event.pubkey}
|
||||
createdAt={event.created_at}
|
||||
navigateTo={nip19Id}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
>
|
||||
{/* Title with variant icon */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<TitleIcon className="size-3.5 text-primary shrink-0" />
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-1">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Cover image — only for packs/sets that declare one */}
|
||||
{safeImage && (
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={safeImage}
|
||||
alt={title}
|
||||
className="w-full max-h-[140px] object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar stack + member count */}
|
||||
{pubkeys.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-1.5">
|
||||
{previewPubkeys.map((pk) => {
|
||||
const member = membersMap?.get(pk);
|
||||
const name = member?.metadata?.name || member?.metadata?.display_name || genUserName(pk);
|
||||
const shape = getAvatarShape(member?.metadata);
|
||||
return (
|
||||
<Avatar
|
||||
key={pk}
|
||||
shape={shape}
|
||||
className="size-5 ring-1 ring-background"
|
||||
>
|
||||
<AvatarImage src={member?.metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[9px]">
|
||||
{name[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{memberLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</EmbeddedCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { ProfilePreview } from '@/components/ExternalContentHeader';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface EmbeddedPostProps {
|
||||
/** The event to render. */
|
||||
event: NostrEvent;
|
||||
/** Extra classes applied to the outermost wrapper. */
|
||||
className?: string;
|
||||
/** When true, ProfileHoverCards inside the card are disabled to prevent nested hover cards. */
|
||||
disableHoverCards?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact embedded preview of a Nostr event.
|
||||
*
|
||||
* Delegates to the shared `EmbeddedNote` / `EmbeddedNaddr` components used by
|
||||
* quote posts, reply indicators, comment context, and hover cards so every
|
||||
* surface that previews an event renders it consistently — regardless of
|
||||
* whether it's a text note, an addressable event (article, people list,
|
||||
* badge…), or a profile (kind 0).
|
||||
*/
|
||||
export function EmbeddedPost({ event, className, disableHoverCards }: EmbeddedPostProps) {
|
||||
// Kind 0 (profile) — show a profile card instead of trying to render the raw JSON content
|
||||
if (event.kind === 0) {
|
||||
return (
|
||||
<div className={cn('rounded-xl border border-border bg-secondary/30 overflow-hidden', className)}>
|
||||
<ProfilePreview pubkey={event.pubkey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Addressable events (kind 30000-39999) — use EmbeddedNaddr
|
||||
if (event.kind >= 30000 && event.kind < 40000) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
||||
return (
|
||||
<EmbeddedNaddr
|
||||
addr={{ kind: event.kind, pubkey: event.pubkey, identifier: dTag }}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Everything else — use EmbeddedNote (the event is already in the query cache)
|
||||
return (
|
||||
<EmbeddedNote
|
||||
eventId={event.id}
|
||||
authorHint={event.pubkey}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -106,9 +106,9 @@ export function EmojiPicker({ onSelect, customEmojis }: EmojiPickerProps) {
|
||||
previewPosition: "none",
|
||||
skinTonePosition: "search",
|
||||
set: "native",
|
||||
maxFrequentRows: 2,
|
||||
maxFrequentRows: 1,
|
||||
navPosition: "bottom",
|
||||
perLine: 8,
|
||||
dynamicWidth: true,
|
||||
parent: container,
|
||||
// Auto-focus the search input on desktop so users can type immediately.
|
||||
// Disabled on mobile to avoid the virtual keyboard popping up unexpectedly.
|
||||
@@ -134,19 +134,42 @@ export function EmojiPicker({ onSelect, customEmojis }: EmojiPickerProps) {
|
||||
const picker = new Picker(pickerOptions);
|
||||
pickerRef.current = picker;
|
||||
|
||||
// Inject style into shadow DOM to remove backdrop-filter blur on the sticky category bar
|
||||
// Inject overrides into the shadow DOM.
|
||||
// emoji-mart hardcodes `width: min-content; height: 435px` on :host
|
||||
// and sets a calculated pixel width on #root. We override both so
|
||||
// the picker fills its container and matches the app theme.
|
||||
requestAnimationFrame(() => {
|
||||
const shadowRoot = (container.firstChild as HTMLElement)?.shadowRoot;
|
||||
if (shadowRoot) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = [
|
||||
".sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: var(--em-color-background) !important; }",
|
||||
":host { width: 100% !important; height: 280px !important; min-height: 160px !important; border-radius: 0 !important; box-shadow: none !important; }",
|
||||
"#root { width: 100% !important; background-color: transparent !important; --sidebar-width: 0px !important; }",
|
||||
".scroll { padding-right: var(--padding) !important; }",
|
||||
".sticky { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background-color: transparent !important; }",
|
||||
// Match the app's input style (same as StickerPicker / GifPicker)
|
||||
".search input[type='search'] { background-color: hsl(var(--muted) / 0.5) !important; border: 0 !important; border-radius: 0.5rem !important; padding: 0.5rem 2rem 0.5rem 2.2rem !important; height: 36px !important; }",
|
||||
".search input[type='search']:focus { box-shadow: 0 0 0 1px hsl(var(--ring)) !important; background-color: hsl(var(--background)) !important; }",
|
||||
".search input[type='search']::placeholder { color: hsl(var(--muted-foreground)) !important; opacity: 1 !important; }",
|
||||
".search .icon { color: hsl(var(--muted-foreground)) !important; }",
|
||||
"input { font-size: 16px !important; }",
|
||||
"#nav button { color: rgba(var(--em-rgb-color), .85) !important; }",
|
||||
"#nav button[aria-selected] { color: rgb(var(--em-rgb-accent)) !important; }",
|
||||
// Fix SVGs without intrinsic width/height collapsing to 0x0 in custom emoji grid.
|
||||
// emoji-mart only sets max-width/max-height on <img>, which can't size a dimensionless SVG.
|
||||
// The <img> lives inside <span class="emoji-mart-emoji" data-emoji-set="...">
|
||||
// Nav — prevent icon clipping from height constraint
|
||||
"#nav { flex-shrink: 0 !important; overflow: visible !important; }",
|
||||
"#nav svg, #nav img { overflow: visible !important; }",
|
||||
"#nav button { color: hsl(var(--muted-foreground)) !important; overflow: visible !important; }",
|
||||
"#nav button:hover { color: hsl(var(--foreground)) !important; }",
|
||||
"#nav button[aria-selected] { color: hsl(var(--primary)) !important; }",
|
||||
"#nav .bar { background-color: hsl(var(--primary)) !important; }",
|
||||
// Hover state on emoji buttons
|
||||
".category button .background { background-color: hsl(var(--muted)) !important; }",
|
||||
// Scrollbar — hide the custom scrollbar, use native overlay
|
||||
".scroll::-webkit-scrollbar { width: 6px !important; }",
|
||||
".scroll::-webkit-scrollbar-thumb { background-color: transparent !important; border: 0 !important; border-radius: 9999px !important; }",
|
||||
".scroll:hover::-webkit-scrollbar-thumb { background-color: hsl(var(--border)) !important; }",
|
||||
".scroll::-webkit-scrollbar-track { background: transparent !important; }",
|
||||
// Category headers
|
||||
".sticky { color: hsl(var(--muted-foreground)) !important; font-size: 11px !important; text-transform: uppercase !important; letter-spacing: 0.05em !important; }",
|
||||
// Fix SVGs without intrinsic dimensions collapsing in custom emoji grid
|
||||
".emoji-mart-emoji img[src] { width: 1em; height: 1em; object-fit: contain; }",
|
||||
].join(" ");
|
||||
shadowRoot.appendChild(style);
|
||||
@@ -167,7 +190,7 @@ export function EmojiPicker({ onSelect, customEmojis }: EmojiPickerProps) {
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="emoji-mart-wrapper"
|
||||
className="emoji-mart-wrapper w-full"
|
||||
style={{ isolation: "isolate" }}
|
||||
onWheel={(e) => {
|
||||
// Prevent scroll from bubbling to the page
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useState } from 'react';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
import { Check, Loader2, RotateCcw } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmbeddedPost } from '@/components/EmbeddedPost';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { isAddressableKind } from '@/lib/eventKinds';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Query all events matching a filter using `req()` instead of `query()`.
|
||||
* This bypasses NSet deduplication in NPool.query(), which discards older
|
||||
* versions of replaceable events. We need every historical version for recovery.
|
||||
*/
|
||||
async function queryAllEvents(
|
||||
nostr: {
|
||||
req(
|
||||
filters: NostrFilter[],
|
||||
opts?: { signal?: AbortSignal },
|
||||
): AsyncIterable<
|
||||
['EVENT', string, NostrEvent] | ['EOSE', string] | ['CLOSED', string, string]
|
||||
>;
|
||||
},
|
||||
filters: NostrFilter[],
|
||||
signal: AbortSignal,
|
||||
): Promise<NostrEvent[]> {
|
||||
const events: NostrEvent[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for await (const msg of nostr.req(filters, { signal })) {
|
||||
if (msg[0] === 'EOSE' || msg[0] === 'CLOSED') break;
|
||||
if (msg[0] === 'EVENT') {
|
||||
const event = msg[2];
|
||||
if (!seen.has(event.id)) {
|
||||
seen.add(event.id);
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Format a unix timestamp into a human-readable date string. */
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
interface EventRecoveryDialogProps {
|
||||
/** The current event whose history should be browsed. */
|
||||
event: NostrEvent;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic recovery dialog for replaceable and addressable events.
|
||||
*
|
||||
* Lists every historical version of the event matching `(kind, author[, d])`,
|
||||
* sorted newest first, and lets the user republish a chosen version with a
|
||||
* fresh `created_at`. `published_at` is preserved via the `prev` property on
|
||||
* `useNostrPublish`.
|
||||
*
|
||||
* The dialog only queries by `(kind, authors[, #d])` — the same filter shape
|
||||
* used by all other recovery dialogs. Without `authors` (and without `#d` for
|
||||
* addressable kinds), relays would either reject the request or return an
|
||||
* unbounded firehose.
|
||||
*/
|
||||
export function EventRecoveryDialog({ event, open, onOpenChange }: EventRecoveryDialogProps) {
|
||||
const close = () => onOpenChange(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg p-0 gap-0 rounded-2xl overflow-hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4">
|
||||
<DialogTitle className="text-lg font-bold">Restore previous version</DialogTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Browse and restore older versions of this event.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="h-[420px]">
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Mounting key forces a fresh refetch each time the dialog reopens
|
||||
so a user who rapidly edits, then reopens, sees the latest history. */}
|
||||
{open && <RecoveryContent key={event.id} event={event} onClose={close} />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface RecoveryContentProps {
|
||||
event: NostrEvent;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function RecoveryContent({ event, onClose }: RecoveryContentProps) {
|
||||
const { nostr } = useNostr();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [restoringId, setRestoringId] = useState<string | null>(null);
|
||||
|
||||
const dTag = isAddressableKind(event.kind)
|
||||
? event.tags.find(([name]) => name === 'd')?.[1] ?? ''
|
||||
: undefined;
|
||||
|
||||
const queryKey = ['event-recovery', event.kind, event.pubkey, dTag ?? null] as const;
|
||||
|
||||
const history = useQuery<NostrEvent[]>({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
const filter: NostrFilter = {
|
||||
kinds: [event.kind],
|
||||
authors: [event.pubkey],
|
||||
};
|
||||
if (dTag !== undefined) {
|
||||
filter['#d'] = [dTag];
|
||||
}
|
||||
const events = await queryAllEvents(
|
||||
nostr,
|
||||
[filter],
|
||||
AbortSignal.timeout(10_000),
|
||||
);
|
||||
return events.sort((a, b) => b.created_at - a.created_at);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
if (history.isLoading) {
|
||||
return <SnapshotSkeleton />;
|
||||
}
|
||||
|
||||
const events = history.data ?? [];
|
||||
|
||||
if (events.length === 0) {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
// The newest event by created_at is treated as "current". This may differ
|
||||
// from the `event` we were called with (e.g. user edited from another device
|
||||
// since this menu was opened) — in that case we still mark the actual newest
|
||||
// as current to avoid letting the user "restore" what is already current.
|
||||
const currentId = events[0].id;
|
||||
|
||||
const handleRestore = async (snapshot: NostrEvent) => {
|
||||
setRestoringId(snapshot.id);
|
||||
try {
|
||||
await publishEvent({
|
||||
kind: snapshot.kind,
|
||||
content: snapshot.content,
|
||||
tags: snapshot.tags,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
// Pass the snapshot as `prev` so useNostrPublish preserves the
|
||||
// original `published_at` tag (NIP-24) instead of resetting it.
|
||||
prev: snapshot,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'Event restored',
|
||||
description: `Successfully restored from ${formatDate(snapshot.created_at)}.`,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to restore event:', error);
|
||||
toast({
|
||||
title: 'Restore failed',
|
||||
description: 'Could not republish the event. Please try again.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setRestoringId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{events.map((snapshot) => {
|
||||
const isCurrent = snapshot.id === currentId;
|
||||
const isRestoring = restoringId === snapshot.id;
|
||||
|
||||
return (
|
||||
<div key={snapshot.id} className="relative">
|
||||
<EmbeddedPost
|
||||
event={snapshot}
|
||||
disableHoverCards
|
||||
className={cn(isCurrent && 'ring-1 ring-primary/40')}
|
||||
/>
|
||||
|
||||
{/* Overlay the Restore button / Current badge in the top-right
|
||||
corner of the embedded card. The card itself is a clickable
|
||||
link, so the button stops propagation to keep navigation and
|
||||
restore distinct interactions. */}
|
||||
<div
|
||||
className="absolute top-2 right-2 z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isCurrent ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
<Check className="size-3" />
|
||||
Current
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRestore(snapshot)}
|
||||
disabled={restoringId !== null}
|
||||
className="h-7 gap-1.5 px-2.5 text-xs shadow-sm"
|
||||
>
|
||||
{isRestoring ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-3" />
|
||||
)}
|
||||
Restore
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No previous versions found. Your relays may not store historical events.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SnapshotSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-xl border p-4 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-40" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getStorageKey } from '@/lib/storageKey';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FeedEmptyStateProps {
|
||||
@@ -26,6 +28,17 @@ export function FeedEmptyState({
|
||||
showDiscover,
|
||||
className,
|
||||
}: FeedEmptyStateProps) {
|
||||
const { config } = useAppContext();
|
||||
|
||||
// The /packs page defaults to the Follows tab, which is also empty when the
|
||||
// user doesn't follow anyone. Pre-seed its saved tab to Global so the link
|
||||
// lands on a populated view.
|
||||
const handleDiscoverClick = () => {
|
||||
try {
|
||||
sessionStorage.setItem(getStorageKey(config.appId, 'feed-tab:packs'), 'global');
|
||||
} catch { /* sessionStorage unavailable */ }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('py-20 px-8 flex flex-col items-center text-center', className)}>
|
||||
<div className="size-12 rounded-full bg-muted flex items-center justify-center mb-4">
|
||||
@@ -38,7 +51,7 @@ export function FeedEmptyState({
|
||||
<div className="flex flex-col gap-2 mt-5 w-full max-w-xs">
|
||||
{showDiscover && (
|
||||
<Button asChild className="rounded-full">
|
||||
<Link to="/packs">Discover people to follow</Link>
|
||||
<Link to="/packs" onClick={handleDiscoverClick}>Discover people to follow</Link>
|
||||
</Button>
|
||||
)}
|
||||
{onSwitchToGlobal && (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Extract the first value of a tag by name. */
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
@@ -24,10 +25,18 @@ function formatBytes(bytes: number): string {
|
||||
}
|
||||
|
||||
/** YouTube-style description card rendered below media. */
|
||||
function DescriptionCard({ text }: { text: string }) {
|
||||
function DescriptionCard({ title, text }: { title?: string; text?: string }) {
|
||||
if (!title && !text) return null;
|
||||
return (
|
||||
<div className="mt-2.5 rounded-xl bg-secondary/50 px-3.5 py-2.5">
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
|
||||
{title && (
|
||||
<p className="text-base font-semibold text-foreground break-words">{title}</p>
|
||||
)}
|
||||
{text && (
|
||||
<p className={cn('text-sm leading-relaxed text-muted-foreground break-words', title && 'mt-1')}>
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -99,10 +108,10 @@ export function FileMetadataContent({ event, compact }: FileMetadataContentProps
|
||||
<WebxdcEmbed
|
||||
url={url}
|
||||
uuid={webxdcId}
|
||||
name={appName}
|
||||
icon={thumb}
|
||||
showNameCard={false}
|
||||
/>
|
||||
{description && <DescriptionCard text={description} />}
|
||||
<DescriptionCard title={appName} text={description} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Check, ChevronDown, Loader2, UserPlus, VolumeX } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowActions } from '@/hooks/useFollowActions';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FollowAllSplitButtonProps {
|
||||
/** Pubkeys (hex) to follow / mute in bulk. */
|
||||
pubkeys: string[];
|
||||
/**
|
||||
* Pubkeys the current user is already following, used to compute the
|
||||
* "Already following all" state and the "N new for you" counts. Optional —
|
||||
* if omitted, the button always shows "Follow All (N)" until pressed.
|
||||
*/
|
||||
followedPubkeys?: Set<string>;
|
||||
/**
|
||||
* Human-readable noun for the list (e.g. "this list", "this pack",
|
||||
* "the team", "this badge"). Used in the Mute All confirmation copy.
|
||||
* Defaults to "this list".
|
||||
*/
|
||||
listNoun?: string;
|
||||
/**
|
||||
* If true, also add the pack/list author to the follow list. Used by kind 3
|
||||
* follow-list views where the viewed event IS the author's own follow list.
|
||||
* Pass the author pubkey to include; ignored if omitted.
|
||||
*/
|
||||
includeAuthorPubkey?: string;
|
||||
/**
|
||||
* Optional className applied to the outer wrapper (controls layout, e.g.
|
||||
* "flex-1"). The split button itself is always an inline-flex group.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Optional size to apply to the buttons. Defaults to "default". Use "sm"
|
||||
* for compact cards.
|
||||
*/
|
||||
size?: 'default' | 'sm' | 'lg';
|
||||
/**
|
||||
* Optional variant for the main button when the user is already following
|
||||
* everyone in the list. Defaults to keeping the same "default" variant with
|
||||
* a check icon. Set to "outline" for a more subdued completed state.
|
||||
*/
|
||||
followedVariant?: 'default' | 'outline';
|
||||
/** Optional toast title to show on successful Follow All. */
|
||||
followSuccessTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split button that combines "Follow All" with a dropdown caret offering
|
||||
* "Mute all" — letting a viewer treat any list (NIP-02 follow list, NIP-51
|
||||
* follow set, follow pack, badge awardees, etc.) as either a follow source
|
||||
* or a mute source. Mute All shows a confirmation AlertDialog before merging
|
||||
* the pubkeys into the user's NIP-51 kind 10000 mute list.
|
||||
*
|
||||
* Follow and mute are independent — a viewer can follow AND mute the same
|
||||
* pubkeys. Mute filtering in feed queries is what makes the second case
|
||||
* meaningful (mute wins).
|
||||
*/
|
||||
export function FollowAllSplitButton({
|
||||
pubkeys,
|
||||
followedPubkeys,
|
||||
listNoun = 'this list',
|
||||
includeAuthorPubkey,
|
||||
className,
|
||||
size = 'default',
|
||||
followedVariant = 'default',
|
||||
followSuccessTitle,
|
||||
}: FollowAllSplitButtonProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { followMany, isPending: isFollowing } = useFollowActions();
|
||||
const { muteManyPubkeys } = useMuteList();
|
||||
const { toast } = useToast();
|
||||
const [muteDialogOpen, setMuteDialogOpen] = useState(false);
|
||||
|
||||
// "Already following all" is only meaningful if the caller provided a
|
||||
// followedPubkeys set; otherwise we always show the active CTA.
|
||||
const allFollowed = !!followedPubkeys
|
||||
&& pubkeys.length > 0
|
||||
&& pubkeys.every((pk) => followedPubkeys.has(pk));
|
||||
|
||||
const muteCount = pubkeys.filter((pk) => pk !== user?.pubkey).length;
|
||||
const isMuting = muteManyPubkeys.isPending;
|
||||
const isBusy = isFollowing || isMuting;
|
||||
|
||||
const handleFollowAll = useCallback(async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to follow users.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const candidates = includeAuthorPubkey
|
||||
? [...pubkeys, includeAuthorPubkey]
|
||||
: pubkeys;
|
||||
const added = await followMany(candidates);
|
||||
toast({
|
||||
title: followSuccessTitle ?? 'Following all!',
|
||||
description: added > 0
|
||||
? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.`
|
||||
: `You were already following everyone in ${listNoun}.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to follow all:', error);
|
||||
toast({
|
||||
title: 'Failed to follow',
|
||||
description: 'There was an error updating your follow list.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}, [user, pubkeys, includeAuthorPubkey, followMany, toast, listNoun, followSuccessTitle]);
|
||||
|
||||
const handleMuteAll = useCallback(async () => {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: 'Not logged in',
|
||||
description: 'Please log in to mute users.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Don't mute yourself even if the list happens to include you.
|
||||
const candidates = pubkeys.filter((pk) => pk !== user.pubkey);
|
||||
const added = await muteManyPubkeys.mutateAsync(candidates);
|
||||
toast({
|
||||
title: 'Muted',
|
||||
description: added > 0
|
||||
? `Added ${added} account${added !== 1 ? 's' : ''} to your mute list.`
|
||||
: `Everyone in ${listNoun} was already muted.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to mute all:', error);
|
||||
toast({
|
||||
title: 'Failed to mute',
|
||||
description: 'There was an error updating your mute list.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setMuteDialogOpen(false);
|
||||
}
|
||||
}, [user, pubkeys, muteManyPubkeys, toast, listNoun]);
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex', className)}>
|
||||
{/* Main "Follow All" button — flush against the caret, with no rounded right corners */}
|
||||
<Button
|
||||
className="flex-1 rounded-r-none gap-2"
|
||||
size={size}
|
||||
variant={allFollowed ? followedVariant : 'default'}
|
||||
onClick={handleFollowAll}
|
||||
disabled={isBusy || !user || pubkeys.length === 0}
|
||||
>
|
||||
{isFollowing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Following…
|
||||
</>
|
||||
) : allFollowed ? (
|
||||
<>
|
||||
<Check className="size-4" />
|
||||
Already following all
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="size-4" />
|
||||
Follow All ({pubkeys.length})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Caret dropdown — visible divider line on the left, no rounded left corners */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-l-none border-l border-l-primary-foreground/25 px-2',
|
||||
// When the main button is in "outline" (allFollowed + followedVariant=outline),
|
||||
// the divider should match the outline border instead.
|
||||
allFollowed && followedVariant === 'outline'
|
||||
&& 'border-l-border',
|
||||
)}
|
||||
size={size}
|
||||
variant={allFollowed ? followedVariant : 'default'}
|
||||
disabled={isBusy || !user || pubkeys.length === 0}
|
||||
aria-label="More follow options"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{/*
|
||||
* Use `onClick` (not `onSelect` + `e.preventDefault()`) so the menu
|
||||
* fully closes before the AlertDialog opens. Otherwise Radix's
|
||||
* DismissableLayer for the menu overlaps with the dialog's and can
|
||||
* leave `pointer-events: none` on the body, freezing the page until
|
||||
* a refresh. Defer the dialog open by a microtask so the menu's
|
||||
* unmount/cleanup runs first.
|
||||
*/}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
queueMicrotask(() => setMuteDialogOpen(true));
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<VolumeX className="size-4" />
|
||||
Mute all
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Confirmation dialog before bulk-muting */}
|
||||
<AlertDialog open={muteDialogOpen} onOpenChange={setMuteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
Mute {muteCount} account{muteCount !== 1 ? 's' : ''}?
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will add everyone in {listNoun} to your mute list. Their
|
||||
posts won't appear in your feeds, even if you also follow them.
|
||||
You can unmute individual accounts later from Settings.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isMuting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void handleMuteAll();
|
||||
}}
|
||||
disabled={isMuting}
|
||||
>
|
||||
{isMuting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Muting…
|
||||
</>
|
||||
) : (
|
||||
<>Mute all</>
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { getThemedQRColors } from '@/lib/qrColors';
|
||||
|
||||
@@ -18,14 +19,15 @@ interface FollowQRDialogProps {
|
||||
export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const author = useAuthor(user?.pubkey ?? '');
|
||||
const shareOrigin = useShareOrigin();
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = user ? metadata?.name || genUserName(user.pubkey) : '';
|
||||
const displayName = user ? metadata?.name || metadata?.display_name || genUserName(user.pubkey) : '';
|
||||
|
||||
const npub = user ? nip19.npubEncode(user.pubkey) : '';
|
||||
const followUrl = npub ? `${window.location.origin}/follow/${npub}` : '';
|
||||
const followUrl = npub ? `${shareOrigin}/follow/${npub}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!followUrl || !open) return;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { CardsIcon } from '@/components/icons/CardsIcon';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Lightbox } from '@/components/ImageGallery';
|
||||
import { useScryfallCard } from '@/hooks/useScryfallCard';
|
||||
import { useCardTilt } from '@/hooks/useCardTilt';
|
||||
import { cardPrimaryImage, type ScryfallCard, type ScryfallCardFace } from '@/lib/scryfall';
|
||||
import type { GathererCard } from '@/lib/linkEmbed';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Max rendered width of the card image. */
|
||||
const CARD_MAX_WIDTH = 280;
|
||||
|
||||
/** Magic cards have a printed corner radius of roughly 4.75% of their width. */
|
||||
const CARD_CORNER_RADIUS = 'rounded-[4.75%]';
|
||||
|
||||
export function GathererCardHeader({
|
||||
card: lookup,
|
||||
url,
|
||||
}: {
|
||||
card: GathererCard;
|
||||
url: string;
|
||||
}) {
|
||||
const scryfallLookup = useMemo(() => (
|
||||
lookup.kind === 'multiverse'
|
||||
? { kind: 'multiverse' as const, multiverseId: lookup.multiverseId }
|
||||
: { kind: 'set' as const, set: lookup.set, number: lookup.number, lang: lookup.lang }
|
||||
), [lookup]);
|
||||
|
||||
const { data: card, isLoading, isError } = useScryfallCard(scryfallLookup);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-4">
|
||||
<Skeleton
|
||||
className={cn('w-full aspect-[5/7]', CARD_CORNER_RADIUS)}
|
||||
style={{ maxWidth: CARD_MAX_WIDTH }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to the generic link preview when Scryfall has no record of the
|
||||
// card (e.g. name-only searches, promos not yet indexed, API errors).
|
||||
if (isError || !card) {
|
||||
return <LinkEmbed url={url} showActions={false} />;
|
||||
}
|
||||
|
||||
return <CardDisplay card={card} url={url} />;
|
||||
}
|
||||
|
||||
function CardDisplay({ card, url }: { card: ScryfallCard; url: string }) {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [faceIndex, setFaceIndex] = useState(0);
|
||||
|
||||
// Collect all display images (one per face for multi-face layouts).
|
||||
const images = useMemo(() => {
|
||||
if (card.card_faces && card.card_faces[0]?.image_uris) {
|
||||
return card.card_faces.map((f) => f.image_uris?.large).filter((s): s is string => !!s);
|
||||
}
|
||||
const primary = cardPrimaryImage(card, 'large');
|
||||
return primary ? [primary] : [];
|
||||
}, [card]);
|
||||
|
||||
const faces: Array<ScryfallCardFace | ScryfallCard> = useMemo(() => {
|
||||
if (card.card_faces && card.card_faces.length > 0) return card.card_faces;
|
||||
return [card];
|
||||
}, [card]);
|
||||
|
||||
const activeFace = faces[faceIndex] ?? faces[0];
|
||||
const heroImage = images[faceIndex] ?? images[0];
|
||||
const hasMultipleFaces = faces.length > 1;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center py-4">
|
||||
{/* 3D-tilt card image */}
|
||||
<div className="w-full" style={{ maxWidth: CARD_MAX_WIDTH }}>
|
||||
{heroImage ? (
|
||||
<CardImageTilt
|
||||
src={heroImage}
|
||||
name={activeFace.name}
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full aspect-[5/7] bg-secondary flex items-center justify-center',
|
||||
CARD_CORNER_RADIUS,
|
||||
)}
|
||||
>
|
||||
<CardsIcon className="size-12 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Face toggle for DFC/MDFC/split cards — essential when only the image is shown */}
|
||||
{hasMultipleFaces && (
|
||||
<div className="flex gap-1.5 mt-4">
|
||||
{faces.map((f, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setFaceIndex(i)}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1 rounded-full transition-colors',
|
||||
i === faceIndex
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
)}
|
||||
>
|
||||
{f.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source links */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 mt-4">
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<CardsIcon className="size-3.5" />
|
||||
<span>View on Gatherer</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
<a
|
||||
href={card.scryfall_uri}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>View on Scryfall</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Full-size image lightbox */}
|
||||
{lightboxOpen && images.length > 0 && (
|
||||
<Lightbox
|
||||
images={images}
|
||||
currentIndex={faceIndex}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
onNext={() => setFaceIndex((i) => (i + 1) % images.length)}
|
||||
onPrev={() => setFaceIndex((i) => (i - 1 + images.length) % images.length)}
|
||||
showDownload={false}
|
||||
maxDotIndicators={10}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card image with a 3D tilt effect matching the badge showcase. Supports
|
||||
* mouse, pen, and touch input: on touch, press-and-drag drives the tilt,
|
||||
* while a quick tap still opens the lightbox via the inner button.
|
||||
*/
|
||||
function CardImageTilt({
|
||||
src,
|
||||
name,
|
||||
onClick,
|
||||
}: {
|
||||
src: string;
|
||||
name: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const tilt = useCardTilt(18, 1.04);
|
||||
const glareRef = useRef<HTMLDivElement>(null);
|
||||
const glareFadeTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const updateGlare = useCallback((clientX: number, clientY: number) => {
|
||||
const el = tilt.ref.current;
|
||||
const glare = glareRef.current;
|
||||
if (!el || !glare) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = ((clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((clientY - rect.top) / rect.height) * 100;
|
||||
glare.style.background = `radial-gradient(circle at ${x}% ${y}%, rgba(255,255,255,0.35) 0%, rgba(255,255,255,0.08) 35%, transparent 65%)`;
|
||||
glare.style.opacity = '1';
|
||||
}, [tilt.ref]);
|
||||
|
||||
const fadeGlare = useCallback(() => {
|
||||
const glare = glareRef.current;
|
||||
if (glare) glare.style.opacity = '0';
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
tilt.onPointerDown(e);
|
||||
if (e.pointerType === 'touch') {
|
||||
clearTimeout(glareFadeTimerRef.current);
|
||||
updateGlare(e.clientX, e.clientY);
|
||||
}
|
||||
},
|
||||
[tilt, updateGlare],
|
||||
);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
tilt.onPointerMove(e);
|
||||
// Mirror useCardTilt: for touch, only update while finger is down.
|
||||
if (e.pointerType === 'touch' && !tilt.isTouchActive) return;
|
||||
updateGlare(e.clientX, e.clientY);
|
||||
},
|
||||
[tilt, updateGlare],
|
||||
);
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
tilt.onPointerUp(e);
|
||||
if (e.pointerType === 'touch') {
|
||||
clearTimeout(glareFadeTimerRef.current);
|
||||
glareFadeTimerRef.current = setTimeout(fadeGlare, 600);
|
||||
}
|
||||
},
|
||||
[tilt, fadeGlare],
|
||||
);
|
||||
|
||||
const handlePointerLeave = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
tilt.onPointerLeave(e);
|
||||
if (e.pointerType === 'touch') {
|
||||
clearTimeout(glareFadeTimerRef.current);
|
||||
glareFadeTimerRef.current = setTimeout(fadeGlare, 600);
|
||||
} else {
|
||||
fadeGlare();
|
||||
}
|
||||
},
|
||||
[tilt, fadeGlare],
|
||||
);
|
||||
|
||||
// Allow vertical page scrolling to still work on touch — tilt is driven
|
||||
// by horizontal drags and brief holds.
|
||||
const style: React.CSSProperties = {
|
||||
...tilt.style,
|
||||
touchAction: 'pan-y',
|
||||
transformStyle: 'preserve-3d',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={tilt.ref}
|
||||
style={style}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
className="relative select-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={`View ${name} full size`}
|
||||
className={cn(
|
||||
'block w-full focus:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
CARD_CORNER_RADIUS,
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
loading="eager"
|
||||
draggable={false}
|
||||
className={cn(
|
||||
'w-full aspect-[5/7] object-cover shadow-[0_14px_40px_-12px_rgba(0,0,0,0.45)]',
|
||||
CARD_CORNER_RADIUS,
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{/* Specular glare overlay, clipped to the card's rounded corners */}
|
||||
<div
|
||||
ref={glareRef}
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute inset-0 pointer-events-none',
|
||||
CARD_CORNER_RADIUS,
|
||||
)}
|
||||
style={{
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.4s ease-out',
|
||||
mixBlendMode: 'overlay',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -118,7 +118,7 @@ export function GifPicker({ onSelect }: GifPickerProps) {
|
||||
}, [onSelect]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-[316px] h-[435px] bg-popover rounded-lg overflow-hidden">
|
||||
<div className="flex flex-col w-full h-[280px] bg-popover rounded-lg overflow-hidden">
|
||||
{/* Search input */}
|
||||
<div className="px-3 pt-3 pb-2">
|
||||
<div className="relative">
|
||||
@@ -128,9 +128,9 @@ export function GifPicker({ onSelect }: GifPickerProps) {
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search GIFs..."
|
||||
className="pl-8 pr-8 h-9 text-base md:text-sm bg-muted/50 border-0 rounded-lg"
|
||||
className="pl-8 pr-20 h-9 text-base md:text-sm bg-muted/50 border-0 rounded-lg"
|
||||
/>
|
||||
{query && (
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearQuery}
|
||||
@@ -138,6 +138,10 @@ export function GifPicker({ onSelect }: GifPickerProps) {
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[10px] text-muted-foreground/50 pointer-events-none select-none">
|
||||
Powered by Tenor
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,23 +186,7 @@ export function GifPicker({ onSelect }: GifPickerProps) {
|
||||
<GifGrid results={results} onSelect={handleSelect} />
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Tenor attribution */}
|
||||
<div className="px-3 py-1.5 border-t border-border/50 flex items-center justify-end gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground/60">Powered by</span>
|
||||
<TenorLogo />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Tenor brand wordmark for required attribution. */
|
||||
function TenorLogo() {
|
||||
return (
|
||||
<svg width="42" height="12" viewBox="0 0 42 12" fill="none" xmlns="http://www.w3.org/2000/svg" className="opacity-40">
|
||||
<text x="0" y="10" fontSize="10" fontWeight="600" fontFamily="system-ui, -apple-system, sans-serif" className="fill-muted-foreground">
|
||||
Tenor
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion';
|
||||
import { FAQ_CATEGORIES, type FAQCategory, type FAQItem } from '@/lib/helpContent';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getFAQCategories, type FAQCategory, type FAQItem } from '@/lib/helpContent';
|
||||
|
||||
// ── Inline markup renderer ────────────────────────────────────────────────────
|
||||
|
||||
@@ -87,8 +88,10 @@ interface HelpFAQSectionProps {
|
||||
* <HelpFAQSection items={['what-are-relays', 'what-are-blossom']} hideHeadings />
|
||||
*/
|
||||
export function HelpFAQSection({ categories, items, hideHeadings, className }: HelpFAQSectionProps) {
|
||||
const { config } = useAppContext();
|
||||
|
||||
const filteredCategories = useMemo(() => {
|
||||
let cats: FAQCategory[] = FAQ_CATEGORIES;
|
||||
let cats: FAQCategory[] = getFAQCategories(config.appName);
|
||||
|
||||
// Filter to specific categories
|
||||
if (categories) {
|
||||
@@ -106,7 +109,7 @@ export function HelpFAQSection({ categories, items, hideHeadings, className }: H
|
||||
}
|
||||
|
||||
return cats;
|
||||
}, [categories, items]);
|
||||
}, [categories, items, config.appName]);
|
||||
|
||||
if (filteredCategories.length === 0) return null;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { HelpCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { getFAQItem } from '@/lib/helpContent';
|
||||
|
||||
/**
|
||||
@@ -62,7 +63,8 @@ interface HelpTipProps {
|
||||
* <label>Relays <HelpTip faqId="what-are-relays" /></label>
|
||||
*/
|
||||
export function HelpTip({ faqId, iconSize = 'size-4', className }: HelpTipProps) {
|
||||
const item = getFAQItem(faqId);
|
||||
const { config } = useAppContext();
|
||||
const item = getFAQItem(config.appName, faqId);
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Highlighter, ExternalLink, Quote } from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface HighlightContentProps {
|
||||
event: NostrEvent;
|
||||
/** When true, render a larger variant for the detail page. */
|
||||
expanded?: boolean;
|
||||
className?: string;
|
||||
/** When true, skip the embedded source event preview (used inside embeds to avoid nesting). */
|
||||
disableSourceEmbed?: boolean;
|
||||
}
|
||||
|
||||
/** Parse an `a` tag value in the `kind:pubkey:identifier` form. */
|
||||
function parseAddr(value: string): { kind: number; pubkey: string; identifier: string } | undefined {
|
||||
const [kindStr, pubkey, ...rest] = value.split(':');
|
||||
const kind = Number(kindStr);
|
||||
if (!Number.isFinite(kind) || !pubkey || pubkey.length !== 64) return undefined;
|
||||
return { kind, pubkey, identifier: rest.join(':') };
|
||||
}
|
||||
|
||||
/** Extract the hostname (without leading `www.`) from a URL, or `undefined` on failure. */
|
||||
function hostnameOf(url: string): string | undefined {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a NIP-84 Highlight event (kind 9802).
|
||||
*
|
||||
* - `content` is the highlighted excerpt — displayed as a blockquote-style pull
|
||||
* quote with an accent border and the Highlighter icon.
|
||||
* - A `context` tag (if present and longer than `content`) wraps the highlight
|
||||
* in its surrounding paragraph with the highlighted portion emphasized.
|
||||
* - The source is shown as either an embedded Nostr event card (`a` tag for
|
||||
* addressable events like wiki/articles, `e` tag for regular events) or, for
|
||||
* non-Nostr sources, a clickable URL chip (`r` tag).
|
||||
*/
|
||||
export function HighlightContent({ event, expanded = false, className, disableSourceEmbed = false }: HighlightContentProps) {
|
||||
const { highlight, context, source } = useMemo(() => {
|
||||
const rawHighlight = event.content.trim();
|
||||
|
||||
// NIP-84 `context` tag: surrounding prose that contains the highlight.
|
||||
const contextTag = event.tags.find(([n]) => n === 'context')?.[1]?.trim();
|
||||
const contextText = contextTag && contextTag.length > rawHighlight.length ? contextTag : undefined;
|
||||
|
||||
// Source precedence: `a` (addressable event) > `e` (regular event) > `r` (URL).
|
||||
// Skip tags marked `mention` (NIP-84 quote-highlight attribution).
|
||||
//
|
||||
// For `r` tags the spec uses a `source`/`mention` marker to distinguish the
|
||||
// cited source from URLs that appear in a companion `comment`. If no marker
|
||||
// is present, fall back to the first `r`.
|
||||
const aTag = event.tags.find(([n, , , marker]) => n === 'a' && marker !== 'mention')?.[1];
|
||||
const eTag = event.tags.find(([n, , , marker]) => n === 'e' && marker !== 'mention');
|
||||
const rSourceTag = event.tags.find(([n, , , marker]) => n === 'r' && marker === 'source')?.[1]
|
||||
?? event.tags.find(([n, , , marker]) => n === 'r' && marker !== 'mention')?.[1];
|
||||
|
||||
let src:
|
||||
| { kind: 'addr'; addr: { kind: number; pubkey: string; identifier: string }; relays?: string[] }
|
||||
| { kind: 'event'; id: string; relays?: string[]; authorHint?: string }
|
||||
| { kind: 'url'; url: string }
|
||||
| undefined;
|
||||
|
||||
if (aTag) {
|
||||
const addr = parseAddr(aTag);
|
||||
if (addr) {
|
||||
const relayHint = event.tags.find(([n, v]) => n === 'a' && v === aTag)?.[2];
|
||||
src = { kind: 'addr', addr, relays: relayHint ? [relayHint] : undefined };
|
||||
}
|
||||
}
|
||||
if (!src && eTag?.[1]) {
|
||||
const [, id, relayHint, , authorHint] = eTag;
|
||||
src = {
|
||||
kind: 'event',
|
||||
id,
|
||||
relays: relayHint ? [relayHint] : undefined,
|
||||
authorHint: authorHint && authorHint.length === 64 ? authorHint : undefined,
|
||||
};
|
||||
}
|
||||
if (!src && rSourceTag) {
|
||||
const sanitized = sanitizeUrl(rSourceTag);
|
||||
if (sanitized) src = { kind: 'url', url: sanitized };
|
||||
}
|
||||
|
||||
return { highlight: rawHighlight, context: contextText, source: src };
|
||||
}, [event.tags, event.content]);
|
||||
|
||||
// The blockquote: highlight text with a prominent left accent border.
|
||||
// When `context` is present, render the context with the highlighted portion
|
||||
// wrapped in a `<mark>` so the reader sees the selection in situ.
|
||||
const quoteBlock = context
|
||||
? <ContextualHighlight context={context} highlight={highlight} expanded={expanded} />
|
||||
: <Blockquote text={highlight} expanded={expanded} />;
|
||||
|
||||
return (
|
||||
<div className={cn(expanded ? 'mt-3 space-y-3' : 'mt-2 space-y-2.5', className)}>
|
||||
{quoteBlock}
|
||||
|
||||
{/* Source attribution */}
|
||||
{source && !disableSourceEmbed && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<Quote className="size-3" />
|
||||
Highlighted from
|
||||
</div>
|
||||
{source.kind === 'addr' ? (
|
||||
<EmbeddedNaddr addr={source.addr} className="my-0" />
|
||||
) : source.kind === 'event' ? (
|
||||
<EmbeddedNote
|
||||
eventId={source.id}
|
||||
relays={source.relays}
|
||||
authorHint={source.authorHint}
|
||||
className="my-0"
|
||||
/>
|
||||
) : (
|
||||
<SourceUrlChip url={source.url} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compact source link when embeds are disabled (e.g. inside another embed) */}
|
||||
{source && disableSourceEmbed && (
|
||||
<SourceChipCompact source={source} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Pull-quote style highlighted text. */
|
||||
function Blockquote({ text, expanded }: { text: string; expanded: boolean }) {
|
||||
if (!text) {
|
||||
// Per NIP-84, content may be empty for highlights of non-text media.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-dashed border-border px-4 py-3 text-center text-sm text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
Highlighted media
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'relative rounded-r-xl border-l-4 border-primary/70 bg-primary/5 pl-4 pr-4 py-3',
|
||||
)}
|
||||
>
|
||||
<Highlighter
|
||||
className={cn(
|
||||
'absolute right-3 top-3 text-primary/60',
|
||||
expanded ? 'size-4' : 'size-3.5',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-words font-serif text-foreground',
|
||||
expanded ? 'text-[17px] leading-relaxed' : 'text-[15px] leading-relaxed',
|
||||
'pr-6',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `context` paragraph with the highlighted portion emphasized.
|
||||
*
|
||||
* If the highlight can be located verbatim inside the context, the matching
|
||||
* span is wrapped in `<mark>`. Otherwise the context is shown as-is followed
|
||||
* by the highlight as a pull-quote (fallback, shouldn't happen per spec).
|
||||
*/
|
||||
function ContextualHighlight({
|
||||
context,
|
||||
highlight,
|
||||
expanded,
|
||||
}: {
|
||||
context: string;
|
||||
highlight: string;
|
||||
expanded: boolean;
|
||||
}) {
|
||||
const matchIndex = highlight ? context.indexOf(highlight) : -1;
|
||||
|
||||
if (matchIndex < 0 || !highlight) {
|
||||
// Fallback: show context above, then the highlight as a quote.
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-words text-muted-foreground',
|
||||
expanded ? 'text-[15px] leading-relaxed' : 'text-sm leading-relaxed',
|
||||
)}
|
||||
>
|
||||
{context}
|
||||
</p>
|
||||
<Blockquote text={highlight} expanded={expanded} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const before = context.slice(0, matchIndex);
|
||||
const after = context.slice(matchIndex + highlight.length);
|
||||
|
||||
return (
|
||||
<blockquote
|
||||
className={cn(
|
||||
'relative rounded-r-xl border-l-4 border-primary/70 bg-primary/5 pl-4 pr-4 py-3',
|
||||
)}
|
||||
>
|
||||
<Highlighter
|
||||
className={cn(
|
||||
'absolute right-3 top-3 text-primary/60',
|
||||
expanded ? 'size-4' : 'size-3.5',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
<p
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-words font-serif pr-6',
|
||||
expanded ? 'text-[17px] leading-relaxed' : 'text-[15px] leading-relaxed',
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground">{before}</span>
|
||||
<mark className="rounded bg-primary/25 px-0.5 text-foreground">{highlight}</mark>
|
||||
<span className="text-muted-foreground">{after}</span>
|
||||
</p>
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
/** External-URL source chip (rendered when the highlight came from a plain web page). */
|
||||
function SourceUrlChip({ url }: { url: string }) {
|
||||
const host = hostnameOf(url);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow ugc"
|
||||
className="flex items-center gap-2 rounded-xl border border-border bg-secondary/30 px-3 py-2 text-sm transition-colors hover:bg-secondary/60"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{host && <div className="truncate text-xs font-medium text-muted-foreground">{host}</div>}
|
||||
<div className="truncate text-[13px] text-foreground">{url}</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact source indicator used when embeds are suppressed. */
|
||||
function SourceChipCompact({
|
||||
source,
|
||||
}: {
|
||||
source:
|
||||
| { kind: 'addr'; addr: { kind: number; pubkey: string; identifier: string } }
|
||||
| { kind: 'event'; id: string }
|
||||
| { kind: 'url'; url: string };
|
||||
}) {
|
||||
if (source.kind === 'url') {
|
||||
const host = hostnameOf(source.url) ?? source.url;
|
||||
return (
|
||||
<a
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow ugc"
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
{host}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const to = source.kind === 'addr'
|
||||
? `/${nip19.naddrEncode(source.addr)}`
|
||||
: `/${nip19.neventEncode({ id: source.id })}`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Quote className="size-3" />
|
||||
Highlighted from Nostr
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
+164
-91
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isValidBlurhash } from '@/lib/blurhash';
|
||||
import { openUrl } from '@/lib/downloadFile';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useBlossomFallback } from '@/hooks/useBlossomFallback';
|
||||
@@ -228,7 +229,7 @@ function GridImage({
|
||||
>
|
||||
{/* Placeholder shown while the image is loading */}
|
||||
{!loaded && (
|
||||
blurhash ? (
|
||||
isValidBlurhash(blurhash) ? (
|
||||
// Blurhash canvas fills the container via CSS — pass small integer decode
|
||||
// resolution; the canvas is stretched to 100%×100% by the style prop.
|
||||
<Blurhash
|
||||
@@ -369,6 +370,11 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
const EASING = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
const DURATION = 280;
|
||||
|
||||
// ── Vertical swipe-to-dismiss state ───────────────────────────────────────
|
||||
const verticalOffsetRef = useRef(0);
|
||||
/** Whether a child LightboxImage is currently zoomed (scale > 1). */
|
||||
const childZoomedRef = useRef(false);
|
||||
|
||||
// Refs to each rendered slot keyed by image index
|
||||
const slotRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
|
||||
@@ -385,13 +391,30 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
slotRefs.current.forEach((_, idx) => setSlotTransform(idx, offsetPx, 'none'));
|
||||
}, [setSlotTransform]);
|
||||
|
||||
/** Apply vertical drag offset + opacity to the lightbox for swipe-to-dismiss.
|
||||
* Backdrop fades in-place; all visible content (top bar, nav, images, dots,
|
||||
* bottom bar) translates together via [data-lightbox-content]. */
|
||||
const applyVerticalDismiss = useCallback((offsetY: number, transition: string) => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const progress = Math.min(Math.abs(offsetY) / (window.innerHeight * 0.4), 1);
|
||||
el.style.transition = transition ? `opacity ${transition.split(' ').slice(1).join(' ')}` : 'none';
|
||||
el.style.opacity = String(1 - progress * 0.6);
|
||||
const content = el.querySelector<HTMLDivElement>('[data-lightbox-content]');
|
||||
if (content) {
|
||||
content.style.transition = transition;
|
||||
content.style.transform = `translateY(${offsetY}px)`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// When currentIndex changes (keyboard/button nav), snap all slots into position instantly
|
||||
useEffect(() => {
|
||||
dragOffsetRef.current = 0;
|
||||
snapAll(0);
|
||||
}, [currentIndex, snapAll]);
|
||||
|
||||
|
||||
// Safety: clear animating lock on unmount so stale refs can't block controls
|
||||
useEffect(() => () => { animating.current = false; }, []);
|
||||
|
||||
const onTouchStart = (e: React.TouchEvent) => {
|
||||
if (animating.current) return;
|
||||
@@ -402,6 +425,9 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
axis.current = null;
|
||||
// Kill any in-flight transition
|
||||
slotRefs.current.forEach((_, idx) => setSlotTransform(idx, dragOffsetRef.current, 'none'));
|
||||
// Reset vertical dismiss offset
|
||||
applyVerticalDismiss(0, 'none');
|
||||
verticalOffsetRef.current = 0;
|
||||
};
|
||||
|
||||
// Registered via addEventListener with { passive: false } to allow preventDefault
|
||||
@@ -414,6 +440,14 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
if (Math.abs(dx) < 4 && Math.abs(dy) < 4) return;
|
||||
axis.current = Math.abs(dx) >= Math.abs(dy) ? 'h' : 'v';
|
||||
}
|
||||
if (axis.current === 'v') {
|
||||
// Vertical swipe-to-dismiss — only when not zoomed
|
||||
if (childZoomedRef.current) return;
|
||||
e.preventDefault();
|
||||
verticalOffsetRef.current = dy;
|
||||
applyVerticalDismiss(dy, 'none');
|
||||
return;
|
||||
}
|
||||
if (axis.current !== 'h') return;
|
||||
e.preventDefault();
|
||||
const atEdge = (dx > 0 && !canGoPrev) || (dx < 0 && !canGoNext);
|
||||
@@ -430,8 +464,31 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
}, []);
|
||||
|
||||
const onTouchEnd = (e: React.TouchEvent) => {
|
||||
// Handle vertical swipe-to-dismiss
|
||||
if (axis.current === 'v' && dragY.current !== null && !childZoomedRef.current) {
|
||||
const dy = e.changedTouches[0].clientY - dragY.current;
|
||||
dragX.current = null; dragY.current = null; axis.current = null;
|
||||
const committed = Math.abs(dy) > window.innerHeight * 0.15;
|
||||
if (committed) {
|
||||
// Animate out in the swipe direction and dismiss
|
||||
animating.current = true;
|
||||
const targetY = dy > 0 ? window.innerHeight : -window.innerHeight;
|
||||
applyVerticalDismiss(targetY, `transform ${DURATION}ms ${EASING}`);
|
||||
setTimeout(() => {
|
||||
verticalOffsetRef.current = 0;
|
||||
onClose();
|
||||
animating.current = false;
|
||||
}, DURATION);
|
||||
} else {
|
||||
// Spring back
|
||||
applyVerticalDismiss(0, `transform ${DURATION}ms ${EASING}`);
|
||||
verticalOffsetRef.current = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragX.current === null || axis.current !== 'h') {
|
||||
dragX.current = null; axis.current = null;
|
||||
dragX.current = null; dragY.current = null; axis.current = null;
|
||||
// Spring back
|
||||
slotRefs.current.forEach((_, idx) =>
|
||||
setSlotTransform(idx, 0, `transform ${DURATION}ms ${EASING}`)
|
||||
@@ -440,7 +497,7 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
return;
|
||||
}
|
||||
const dx = e.changedTouches[0].clientX - dragX.current;
|
||||
dragX.current = null; axis.current = null;
|
||||
dragX.current = null; dragY.current = null; axis.current = null;
|
||||
|
||||
const committed = Math.abs(dx) > window.innerWidth * 0.2;
|
||||
const goingNext = dx < 0 && canGoNext && committed;
|
||||
@@ -491,96 +548,98 @@ export function Lightbox({ images, currentIndex, onClose, onNext, onPrev, mediaT
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
{/* Backdrop — fades in-place, never translates */}
|
||||
<div className="absolute inset-0 bg-black/90 backdrop-blur-md" />
|
||||
|
||||
{/* Top bar */}
|
||||
<div data-gallery-topbar className="absolute left-0 right-0 z-10 flex items-center justify-between px-4 py-3 safe-area-inset-top">
|
||||
{topBarLeft !== undefined ? topBarLeft : (
|
||||
<>
|
||||
{hasMultiple && <span className="text-white/80 text-sm font-medium tabular-nums">{currentIndex + 1} / {images.length}</span>}
|
||||
{!hasMultiple && <span />}
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{showDownload && (
|
||||
<button onClick={handleDownload} className="p-2.5 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors" title="Open original">
|
||||
<Download className="size-5" />
|
||||
</button>
|
||||
{/* All interactive content — translated together during swipe-to-dismiss */}
|
||||
<div data-lightbox-content className="absolute inset-0">
|
||||
{/* Top bar */}
|
||||
<div data-gallery-topbar className="absolute left-0 right-0 z-10 flex items-center justify-between px-4 py-3 safe-area-inset-top">
|
||||
{topBarLeft !== undefined ? topBarLeft : (
|
||||
<>
|
||||
{hasMultiple && <span className="text-white/80 text-sm font-medium tabular-nums">{currentIndex + 1} / {images.length}</span>}
|
||||
{!hasMultiple && <span />}
|
||||
</>
|
||||
)}
|
||||
<button onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClose(); }} className="p-2.5 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors" title="Close (Esc)">
|
||||
<X className="size-5" />
|
||||
<div className="flex items-center gap-1">
|
||||
{showDownload && (
|
||||
<button onClick={handleDownload} className="p-2.5 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors" title="Open original">
|
||||
<Download className="size-5" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={(e) => { e.stopPropagation(); e.preventDefault(); onClose(); }} className="p-2.5 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors" title="Close (Esc)">
|
||||
<X className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Prev/next buttons (desktop) */}
|
||||
{canGoPrev && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onPrev(); }} className="absolute left-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex" title="Previous">
|
||||
<ChevronLeft className="size-6" />
|
||||
</button>
|
||||
)}
|
||||
{canGoNext && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onNext(); }} className="absolute right-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex" title="Next">
|
||||
<ChevronRight className="size-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Per-image slots — each absolutely positioned by index offset */}
|
||||
<div data-lightbox-strip className="absolute inset-0 overflow-hidden">
|
||||
{visibleIndices.map((i) => {
|
||||
const url = images[i];
|
||||
const isCurrent = i === currentIndex;
|
||||
const initialX = (i - currentIndex) * window.innerWidth;
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
ref={(el) => {
|
||||
if (el) slotRefs.current.set(i, el);
|
||||
else slotRefs.current.delete(i);
|
||||
}}
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center will-change-transform',
|
||||
bottomBar ? 'pb-24 pt-14 px-4 sm:px-12' : 'py-6 pt-14 px-4 sm:px-12',
|
||||
)}
|
||||
style={{ transform: `translateX(${initialX}px)` }}
|
||||
>
|
||||
{isCurrent && !isLoaded && (mediaTypes?.[i] ?? 'image') === 'image' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="size-8 border-2 border-white/20 border-t-white/80 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<LightboxSlot
|
||||
url={url}
|
||||
type={mediaTypes?.[i] ?? 'image'}
|
||||
meta={mediaMeta?.[i]}
|
||||
isActive={isCurrent}
|
||||
isLoaded={isCurrent ? isLoaded : true}
|
||||
onLoad={markLoaded}
|
||||
onSwipeBlocked={() => { dragX.current = null; axis.current = null; }}
|
||||
onZoomChange={(zoomed) => { childZoomedRef.current = zoomed; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Dot indicators */}
|
||||
{hasMultiple && images.length <= maxDotIndicators && (
|
||||
<div className={cn('absolute left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5 sm:hidden', bottomBar ? 'bottom-20' : 'bottom-6')}>
|
||||
{images.map((_, i) => (
|
||||
<div key={i} className={cn('rounded-full transition-all duration-200', i === currentIndex ? 'size-2 bg-white' : 'size-1.5 bg-white/40')} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom bar — author info, reactions, captions, etc. */}
|
||||
{bottomBar && (
|
||||
<div className="absolute inset-x-0 bottom-0 z-10" onClick={(e) => e.stopPropagation()}>
|
||||
{bottomBar}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prev/next buttons (desktop) */}
|
||||
{canGoPrev && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onPrev(); }} className="absolute left-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex" title="Previous">
|
||||
<ChevronLeft className="size-6" />
|
||||
</button>
|
||||
)}
|
||||
{canGoNext && (
|
||||
<button onClick={(e) => { e.stopPropagation(); onNext(); }} className="absolute right-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-black/40 text-white/80 hover:text-white hover:bg-black/60 backdrop-blur-sm transition-all hidden sm:flex" title="Next">
|
||||
<ChevronRight className="size-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Per-image slots — each absolutely positioned by index offset */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{visibleIndices.map((i) => {
|
||||
const url = images[i];
|
||||
const isCurrent = i === currentIndex;
|
||||
const initialX = (i - currentIndex) * window.innerWidth;
|
||||
return (
|
||||
<div
|
||||
key={url}
|
||||
ref={(el) => {
|
||||
if (el) slotRefs.current.set(i, el);
|
||||
else slotRefs.current.delete(i);
|
||||
}}
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center will-change-transform',
|
||||
bottomBar ? 'pb-24 pt-14 px-4 sm:px-12' : 'py-6 pt-14 px-4 sm:px-12',
|
||||
)}
|
||||
style={{ transform: `translateX(${initialX}px)` }}
|
||||
>
|
||||
{isCurrent && !isLoaded && (mediaTypes?.[i] ?? 'image') === 'image' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="size-8 border-2 border-white/20 border-t-white/80 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<LightboxSlot
|
||||
url={url}
|
||||
type={mediaTypes?.[i] ?? 'image'}
|
||||
meta={mediaMeta?.[i]}
|
||||
isActive={isCurrent}
|
||||
isLoaded={isCurrent ? isLoaded : true}
|
||||
onLoad={markLoaded}
|
||||
onSwipeBlocked={() => { dragX.current = null; axis.current = null; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Dot indicators */}
|
||||
{hasMultiple && images.length <= maxDotIndicators && (
|
||||
<div className={cn('absolute left-1/2 -translate-x-1/2 z-10 flex items-center gap-1.5 sm:hidden', bottomBar ? 'bottom-20' : 'bottom-6')}>
|
||||
{images.map((_, i) => (
|
||||
<div key={i} className={cn('rounded-full transition-all duration-200', i === currentIndex ? 'size-2 bg-white' : 'size-1.5 bg-white/40')} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom bar — author info, reactions, captions, etc. */}
|
||||
{bottomBar && (
|
||||
<div className="absolute inset-x-0 bottom-0 z-10" onClick={(e) => e.stopPropagation()}>
|
||||
{bottomBar}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
@@ -590,12 +649,14 @@ const MIN_SCALE = 1;
|
||||
const MAX_SCALE = 8;
|
||||
|
||||
/** Lightbox image with pinch/wheel zoom and pan support. */
|
||||
function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked, onZoomChange }: {
|
||||
url: string;
|
||||
isLoaded: boolean;
|
||||
onLoad: (url: string) => void;
|
||||
/** Called when a horizontal swipe is intercepted by pan (image is zoomed). */
|
||||
onSwipeBlocked?: () => void;
|
||||
/** Called when the image zoom state changes (zoomed in or back to 1x). */
|
||||
onZoomChange?: (zoomed: boolean) => void;
|
||||
}) {
|
||||
const { src, onError } = useBlossomFallback(url);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
@@ -622,13 +683,19 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
if (imgRef.current?.complete && imgRef.current.naturalWidth > 0) handleLoaded();
|
||||
}, [src, handleLoaded]);
|
||||
|
||||
/** Notify parent when zoom state changes. */
|
||||
const notifyZoom = useCallback(() => {
|
||||
onZoomChange?.(scale.current > 1);
|
||||
}, [onZoomChange]);
|
||||
|
||||
// Reset zoom when url changes
|
||||
useEffect(() => {
|
||||
scale.current = 1;
|
||||
panX.current = 0;
|
||||
panY.current = 0;
|
||||
applyTransform();
|
||||
}, [url]);
|
||||
notifyZoom();
|
||||
}, [url, notifyZoom]);
|
||||
|
||||
function applyTransform(animated = false) {
|
||||
const el = wrapRef.current;
|
||||
@@ -693,6 +760,7 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
}
|
||||
}
|
||||
applyTransform(true);
|
||||
notifyZoom();
|
||||
}
|
||||
lastTap.current = now;
|
||||
}
|
||||
@@ -710,6 +778,7 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
panY.current = p.panY + (midY - p.midY);
|
||||
clampPan(newScale);
|
||||
applyTransform();
|
||||
notifyZoom();
|
||||
} else if (e.touches.length === 1 && panStart.current && scale.current > 1) {
|
||||
e.preventDefault();
|
||||
const p = panStart.current;
|
||||
@@ -729,6 +798,7 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
if (scale.current < MIN_SCALE) {
|
||||
scale.current = MIN_SCALE; panX.current = 0; panY.current = 0;
|
||||
applyTransform(true);
|
||||
notifyZoom();
|
||||
} else {
|
||||
clampPan();
|
||||
applyTransform(true);
|
||||
@@ -745,6 +815,7 @@ function LightboxImage({ url, isLoaded, onLoad, onSwipeBlocked }: {
|
||||
if (scale.current === MIN_SCALE) { panX.current = 0; panY.current = 0; }
|
||||
else clampPan();
|
||||
applyTransform();
|
||||
notifyZoom();
|
||||
} else if (scale.current > 1) {
|
||||
e.preventDefault();
|
||||
panX.current -= e.deltaX;
|
||||
@@ -824,6 +895,7 @@ function LightboxSlot({
|
||||
isLoaded,
|
||||
onLoad,
|
||||
onSwipeBlocked,
|
||||
onZoomChange,
|
||||
}: {
|
||||
url: string;
|
||||
type: 'image' | 'video' | 'audio';
|
||||
@@ -832,6 +904,7 @@ function LightboxSlot({
|
||||
isLoaded: boolean;
|
||||
onLoad: (url: string) => void;
|
||||
onSwipeBlocked?: () => void;
|
||||
onZoomChange?: (zoomed: boolean) => void;
|
||||
}) {
|
||||
const author = useAuthor(type === 'audio' ? meta?.pubkey : undefined);
|
||||
const authorMeta = author.data?.metadata;
|
||||
@@ -866,5 +939,5 @@ function LightboxSlot({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <LightboxImage url={url} isLoaded={isLoaded} onLoad={onLoad} onSwipeBlocked={onSwipeBlocked} />;
|
||||
return <LightboxImage url={url} isLoaded={isLoaded} onLoad={onLoad} onSwipeBlocked={onSwipeBlocked} onZoomChange={onZoomChange} />;
|
||||
}
|
||||
|
||||
@@ -408,6 +408,18 @@ function SetupQuestionnaire({
|
||||
feedIncludeBadgeDefinitions: false,
|
||||
feedIncludeProfileBadges: false,
|
||||
feedIncludeVanish: true,
|
||||
showBadgeAwards: true,
|
||||
feedIncludeBadgeAwards: true,
|
||||
showBirdstar: true,
|
||||
feedIncludeBirdDetections: true,
|
||||
feedIncludeBirdex: true,
|
||||
feedIncludeConstellations: true,
|
||||
showHighlights: true,
|
||||
feedIncludeHighlights: false,
|
||||
showPeopleLists: true,
|
||||
feedIncludePeopleLists: true,
|
||||
feedIncludeReactions: false,
|
||||
feedIncludeZaps: false,
|
||||
followsFeedShowReplies: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ export function LeftSidebar() {
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const getDisplayName = (account: Account) => account.metadata.display_name || account.metadata.name || genUserName(account.pubkey);
|
||||
const getDisplayName = (account: Account) => account.metadata.name || account.metadata.display_name || genUserName(account.pubkey);
|
||||
|
||||
const handleLogout = async () => {
|
||||
setAccountPopoverOpen(false);
|
||||
@@ -83,7 +83,7 @@ export function LeftSidebar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex flex-col h-screen sticky top-0 py-3 px-4 w-[300px] shrink-0">
|
||||
<aside className="hidden sidebar:flex flex-col h-screen sticky top-0 py-3 px-4 w-[300px] lg:w-1/4 lg:max-w-[300px] shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center px-3 mb-1">
|
||||
<Link to="/" onClick={scrollToTopIfCurrent('/')} className="flex items-center gap-2.5">
|
||||
@@ -155,7 +155,7 @@ export function LeftSidebar() {
|
||||
<Avatar className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={metadata?.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{(metadata?.display_name || metadata?.name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
{(metadata?.name || metadata?.display_name || genUserName(user.pubkey))[0]?.toUpperCase() ?? '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
@@ -165,9 +165,9 @@ export function LeftSidebar() {
|
||||
) : (
|
||||
<>
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{currentUserEvent && metadata?.name
|
||||
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name}</EmojifiedText>
|
||||
: (metadata?.name || genUserName(user.pubkey))}
|
||||
{currentUserEvent && (metadata?.name || metadata?.display_name)
|
||||
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name || metadata.display_name || ''}</EmojifiedText>
|
||||
: (metadata?.name || metadata?.display_name || genUserName(user.pubkey))}
|
||||
</span>
|
||||
{metadata?.nip05 && (
|
||||
<VerifiedNip05Text nip05={metadata.nip05} pubkey={user.pubkey} className="text-xs text-muted-foreground truncate" />
|
||||
@@ -309,7 +309,7 @@ export function LeftSidebar() {
|
||||
</button>
|
||||
<button onClick={handleLogout} className="flex items-center gap-3 w-full px-4 py-2.5 text-sm font-medium text-destructive hover:bg-destructive/10 transition-colors">
|
||||
<LogOut className="size-4" />
|
||||
<span>Log out @{metadata?.name || genUserName(user.pubkey)}</span>
|
||||
<span>Log out @{metadata?.name || metadata?.display_name || genUserName(user.pubkey)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -21,7 +21,6 @@ import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { getEffectiveStreamStatus } from '@/lib/streamStatus';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -121,7 +120,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
}, []);
|
||||
|
||||
const chatSidebar = (
|
||||
<aside className="hidden xl:flex xl:flex-col xl:w-[340px] xl:shrink-0 h-screen sticky top-0">
|
||||
<aside className="hidden lg:flex lg:flex-col lg:w-[340px] lg:shrink-0 h-screen sticky top-0">
|
||||
<LiveStreamChat aTag={aTag} className="h-full" />
|
||||
</aside>
|
||||
);
|
||||
@@ -137,7 +136,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
const detailsBlock = (
|
||||
<div className="space-y-4">
|
||||
{/* Author — mobile only (desktop shows it above) */}
|
||||
<div className="xl:hidden">
|
||||
<div className="lg:hidden">
|
||||
<StreamAuthorRow event={event} participants={participants} />
|
||||
</div>
|
||||
|
||||
@@ -179,7 +178,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="xl:max-sidebar:flex max-sidebar:flex max-sidebar:flex-col max-sidebar:livestream-height max-sidebar:overflow-hidden">
|
||||
<main className="lg:max-sidebar:flex max-sidebar:flex max-sidebar:flex-col max-sidebar:livestream-height max-sidebar:overflow-hidden">
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
title="Live Stream"
|
||||
@@ -194,7 +193,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
</PageHeader>
|
||||
|
||||
{/* Video Player */}
|
||||
<div className="xl:px-4 shrink-0">
|
||||
<div className="lg:px-4 shrink-0">
|
||||
{playUrl ? (
|
||||
<LiveStreamPlayer
|
||||
src={playUrl}
|
||||
@@ -203,7 +202,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
title={title}
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-video xl:rounded-2xl bg-muted flex items-center justify-center border-y xl:border border-border">
|
||||
<div className="aspect-video lg:rounded-2xl bg-muted flex items-center justify-center border-y lg:border border-border">
|
||||
<div className="text-center space-y-2">
|
||||
<Radio className="size-8 text-muted-foreground/40 mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -241,7 +240,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
</div>
|
||||
|
||||
{/* Author / Host — desktop only (on mobile it's inside the expandable details) */}
|
||||
<div className="hidden xl:block">
|
||||
<div className="hidden lg:block">
|
||||
<StreamAuthorRow event={event} participants={participants} />
|
||||
</div>
|
||||
|
||||
@@ -249,7 +248,7 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
{hasExpandable && (
|
||||
<button
|
||||
onClick={() => setDescExpanded((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors xl:hidden"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors lg:hidden"
|
||||
>
|
||||
{descExpanded ? <ChevronUp className="size-3.5" /> : <ChevronDown className="size-3.5" />}
|
||||
{descExpanded ? 'Hide details' : 'Show details'}
|
||||
@@ -258,24 +257,24 @@ export function LiveStreamPage({ event }: LiveStreamPageProps) {
|
||||
|
||||
{/* Mobile: collapsible details */}
|
||||
{descExpanded && (
|
||||
<div className="xl:hidden">
|
||||
<div className="lg:hidden">
|
||||
{detailsBlock}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop: always show details */}
|
||||
<div className="hidden xl:block">
|
||||
<div className="hidden lg:block">
|
||||
{detailsBlock}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile chat — fills remaining viewport, scrollbox sits above bottom nav */}
|
||||
<div className="xl:hidden mt-2 border-t border-border flex-1 min-h-0 overflow-hidden">
|
||||
<div className="lg:hidden mt-2 border-t border-border flex-1 min-h-0 overflow-hidden">
|
||||
<LiveStreamChat aTag={aTag} className="h-full" />
|
||||
</div>
|
||||
|
||||
{/* Bottom spacer (desktop only) */}
|
||||
<div className="hidden xl:block h-8" />
|
||||
<div className="hidden lg:block h-8" />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
@@ -349,11 +348,9 @@ function StreamAuthorRow({ event, participants }: { event: NostrEvent; participa
|
||||
}
|
||||
|
||||
function ZapButton({ event }: { event: NostrEvent }) {
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
|
||||
if (!canZap(metadata)) return null;
|
||||
|
||||
// ZapDialog handles the self-zap guard internally, so we only need to
|
||||
// render the trigger. On-chain zaps are always available for any author;
|
||||
// Lightning is an opt-in tab inside the dialog.
|
||||
return (
|
||||
<ZapDialog target={event}>
|
||||
<Button variant="outline" size="icon" className="shrink-0 size-9 rounded-full text-amber-500 hover:text-amber-400 hover:bg-amber-500/10">
|
||||
|
||||
@@ -188,7 +188,7 @@ export function LiveStreamPlayer({ src, poster, className, title, artist }: Live
|
||||
|
||||
if (hasError) {
|
||||
return (
|
||||
<div className={cn('relative xl:rounded-2xl overflow-hidden bg-black aspect-video flex items-center justify-center', className)}>
|
||||
<div className={cn('relative lg:rounded-2xl overflow-hidden bg-black aspect-video flex items-center justify-center', className)}>
|
||||
<div className="text-center space-y-2 px-4">
|
||||
<p className="text-white/80 text-sm font-medium">Stream unavailable</p>
|
||||
<p className="text-white/50 text-xs">The live stream could not be loaded. It may have ended or the URL is unreachable.</p>
|
||||
@@ -201,7 +201,7 @@ export function LiveStreamPlayer({ src, poster, className, title, artist }: Live
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'relative xl:rounded-2xl overflow-hidden bg-black group aspect-video',
|
||||
'relative lg:rounded-2xl overflow-hidden bg-black group aspect-video',
|
||||
className,
|
||||
)}
|
||||
onMouseMove={revealControls}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CardsIcon } from '@/components/icons/CardsIcon';
|
||||
import { Lightbox } from '@/components/ImageGallery';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { scryfallImageUrl } from '@/lib/scryfall';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
function getTag(tags: string[][], name: string): string | undefined {
|
||||
@@ -41,18 +42,6 @@ function parseCardTag(tag: string[]): CardEntry | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Scryfall image URL for a card.
|
||||
* Uses set/collector_number when available for exact printing,
|
||||
* otherwise falls back to exact name lookup.
|
||||
*/
|
||||
function scryfallImageUrl(card: CardEntry, version: 'small' | 'normal' | 'large' = 'small'): string {
|
||||
if (card.setId && card.artId) {
|
||||
return `https://api.scryfall.com/cards/${encodeURIComponent(card.setId.toLowerCase())}/${encodeURIComponent(card.artId)}?format=image&version=${version}`;
|
||||
}
|
||||
return `https://api.scryfall.com/cards/named?exact=${encodeURIComponent(card.name)}&format=image&version=${version}`;
|
||||
}
|
||||
|
||||
/** Format labels for MTG formats. */
|
||||
const FORMAT_LABELS: Record<string, string> = {
|
||||
standard: 'Standard',
|
||||
|
||||
@@ -296,7 +296,7 @@ function MentionItem({
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { metadata, pubkey } = profile;
|
||||
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
|
||||
const displayName = metadata.name || metadata.display_name || genUserName(pubkey);
|
||||
const nip05 = metadata.nip05;
|
||||
const { data: nip05Verified } = useNip05Verify(nip05, pubkey);
|
||||
const nip05Display = nip05Verified && nip05 ? (nip05.startsWith('_@') ? nip05.slice(2) : nip05) : undefined;
|
||||
|
||||
@@ -98,8 +98,8 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
|
||||
const handleClose = () => { onOpenChange(false); setMoreMenuOpen(false); };
|
||||
const handleLogout = async () => { await logout(); handleClose(); navigate('/'); };
|
||||
const getDisplayName = (account: Account) => account.metadata.name ?? genUserName(account.pubkey);
|
||||
const displayName = metadata?.name || (user ? genUserName(user.pubkey) : 'Anonymous');
|
||||
const getDisplayName = (account: Account) => account.metadata.name || account.metadata.display_name || genUserName(account.pubkey);
|
||||
const displayName = metadata?.name || metadata?.display_name || (user ? genUserName(user.pubkey) : 'Anonymous');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -148,8 +148,8 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
</Avatar>
|
||||
<div className="flex flex-col min-w-0 flex-1">
|
||||
<span className="font-semibold text-sm truncate">
|
||||
{currentUserEvent && metadata?.name
|
||||
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name}</EmojifiedText>
|
||||
{currentUserEvent && (metadata?.name || metadata?.display_name)
|
||||
? <EmojifiedText tags={currentUserEvent.tags}>{metadata.name || metadata.display_name || ''}</EmojifiedText>
|
||||
: displayName}
|
||||
</span>
|
||||
{metadata?.nip05 && (
|
||||
@@ -288,7 +288,7 @@ export function MobileDrawer({ open, onOpenChange }: MobileDrawerProps) {
|
||||
className="flex items-center gap-4 w-full px-4 py-2.5 text-sm font-normal text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<LogOut className="size-5 shrink-0" />
|
||||
<span>Log out @{metadata?.name || genUserName(user.pubkey)}</span>
|
||||
<span>Log out @{metadata?.name || metadata?.display_name || genUserName(user.pubkey)}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -749,7 +749,7 @@ function SearchProfileItem({
|
||||
onClick: (profile: SearchProfile) => void;
|
||||
}) {
|
||||
const { metadata, pubkey } = profile;
|
||||
const displayName = metadata.display_name || metadata.name || genUserName(pubkey);
|
||||
const displayName = metadata.name || metadata.display_name || genUserName(pubkey);
|
||||
const nip05 = metadata.nip05;
|
||||
const { data: nip05Verified } = useNip05Verify(nip05, pubkey);
|
||||
const nip05Display = nip05Verified && nip05 ? (nip05.startsWith('_@') ? nip05.slice(2) : nip05) : undefined;
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
* Shown when navigating to a track/playlist's naddr page.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Play, Pause, Music, ListMusic, Zap, Clock } from 'lucide-react';
|
||||
import { ArrowLeft, Play, Pause, Music, ListMusic, Disc3, Zap, Clock, Calendar, Tag } from 'lucide-react';
|
||||
import { RepostIcon } from '@/components/icons/RepostIcon';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
@@ -18,7 +19,6 @@ import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { formatTime } from '@/lib/formatTime';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
@@ -31,6 +31,8 @@ import { InteractionsModal, type InteractionTab } from '@/components/Interaction
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { useAudioPlayer } from '@/contexts/audioPlayerContextDef';
|
||||
import { parseMusicTrack, parseMusicPlaylist, toAudioTrack } from '@/lib/musicHelpers';
|
||||
import { usePlaylistTracks } from '@/hooks/usePlaylistTracks';
|
||||
import { MusicTrackRowSkeleton } from '@/components/music/MusicTrackRow';
|
||||
|
||||
|
||||
/** Format a full date. */
|
||||
@@ -64,6 +66,7 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
const [replyOpen, setReplyOpen] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [interactionsTab, setInteractionsTab] = useState<InteractionTab | null>(null);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
// Comments (NIP-22)
|
||||
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
||||
@@ -105,8 +108,8 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
<div className="px-4 flex gap-5 items-start">
|
||||
{/* Artwork */}
|
||||
<div className="shrink-0 w-32 sm:w-40 aspect-square rounded-2xl overflow-hidden bg-muted shadow-lg">
|
||||
{parsed?.artwork ? (
|
||||
<img src={parsed.artwork} alt={parsed.title} className="w-full h-full object-cover" />
|
||||
{parsed?.artwork && !imgError ? (
|
||||
<img src={parsed.artwork} alt={parsed.title} className="w-full h-full object-cover" onError={() => setImgError(true)} />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10">
|
||||
<Music className="size-12 text-primary/30" />
|
||||
@@ -181,7 +184,7 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
)}
|
||||
</RepostMenu>
|
||||
|
||||
{user && canZap(metadata) && (
|
||||
{user && user.pubkey !== event.pubkey && (
|
||||
<ZapDialog target={event}>
|
||||
<button
|
||||
className="size-11 rounded-full bg-secondary/50 text-muted-foreground hover:bg-secondary flex items-center justify-center transition-colors"
|
||||
@@ -274,17 +277,107 @@ function TrackDetail({ event }: { event: NostrEvent }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Playlist description with expand/collapse ────────────────────────────────
|
||||
|
||||
const DESC_LINE_CLAMP = 3;
|
||||
|
||||
/** Collapsible description that truncates after ~3 lines with a "more" toggle. */
|
||||
function PlaylistDescription({ text }: { text: string }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [clamped, setClamped] = useState(false);
|
||||
const ref = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// Check if the text overflows the clamped height
|
||||
setClamped(el.scrollHeight > el.clientHeight + 1);
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div className="px-4 mt-4">
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap',
|
||||
!expanded && 'line-clamp-[3]',
|
||||
)}
|
||||
style={!expanded ? { WebkitLineClamp: DESC_LINE_CLAMP } : undefined}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{(clamped || expanded) && (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-xs text-primary font-medium mt-1 hover:underline"
|
||||
>
|
||||
{expanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Playlist detail ───────────────────────────────────────────────────────────
|
||||
|
||||
function PlaylistDetail({ event }: { event: NostrEvent }) {
|
||||
const navigate = useNavigate();
|
||||
const player = useAudioPlayer();
|
||||
const parsed = useMemo(() => parseMusicPlaylist(event), [event]);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const author = useAuthor(event.pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = getDisplayName(metadata, event.pubkey);
|
||||
const profileUrl = useProfileUrl(event.pubkey, metadata);
|
||||
|
||||
// Resolve track references to actual events
|
||||
const { data: trackEvents, isLoading: tracksLoading } = usePlaylistTracks(parsed?.trackRefs ?? []);
|
||||
|
||||
// Build AudioTrack[] for the player
|
||||
const audioTracks = useMemo(() => {
|
||||
if (!trackEvents) return [];
|
||||
return trackEvents
|
||||
.map((ev) => {
|
||||
const p = parseMusicTrack(ev);
|
||||
return p ? toAudioTrack(ev, p) : null;
|
||||
})
|
||||
.filter((t): t is NonNullable<typeof t> => t !== null);
|
||||
}, [trackEvents]);
|
||||
|
||||
// Cover art: playlist's own artwork, or first track's artwork as fallback
|
||||
const coverArt = useMemo(() => {
|
||||
if (parsed?.artwork && !imgError) return parsed.artwork;
|
||||
if (!trackEvents || trackEvents.length === 0) return undefined;
|
||||
const firstTrack = parseMusicTrack(trackEvents[0]);
|
||||
return firstTrack?.artwork;
|
||||
}, [parsed?.artwork, imgError, trackEvents]);
|
||||
|
||||
const trackCount = parsed?.trackRefs.length ?? 0;
|
||||
const isAlbum = parsed?.isAlbum ?? false;
|
||||
const typeLabel = isAlbum ? 'Album' : 'Playlist';
|
||||
const FallbackIcon = isAlbum ? Disc3 : ListMusic;
|
||||
|
||||
// Check if the player is currently playing this playlist
|
||||
const isPlayingThisPlaylist = audioTracks.length > 0
|
||||
&& player.playlist.length > 0
|
||||
&& player.playlist[0]?.id === audioTracks[0]?.id
|
||||
&& player.playlist.length === audioTracks.length;
|
||||
|
||||
const handlePlayAll = () => {
|
||||
if (audioTracks.length === 0) return;
|
||||
if (isPlayingThisPlaylist && player.isPlaying) {
|
||||
player.pause();
|
||||
} else if (isPlayingThisPlaylist) {
|
||||
player.resume();
|
||||
} else {
|
||||
player.playPlaylist(audioTracks, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayFromIndex = (index: number) => {
|
||||
if (audioTracks.length === 0) return;
|
||||
player.playPlaylist(audioTracks, index);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="">
|
||||
@@ -293,23 +386,31 @@ function PlaylistDetail({ event }: { event: NostrEvent }) {
|
||||
<button onClick={() => window.history.length > 1 ? navigate(-1) : navigate('/')} className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors">
|
||||
<ArrowLeft className="size-5" />
|
||||
</button>
|
||||
<h1 className="text-xl font-bold truncate">Playlist Details</h1>
|
||||
<h1 className="text-xl font-bold truncate">{typeLabel} Details</h1>
|
||||
</div>
|
||||
|
||||
{/* Hero */}
|
||||
<div className="px-4 flex gap-5 items-start">
|
||||
<div className="shrink-0 w-32 sm:w-40 aspect-square rounded-2xl overflow-hidden bg-muted shadow-lg">
|
||||
{parsed?.artwork ? (
|
||||
<img src={parsed.artwork} alt={parsed.title} className="w-full h-full object-cover" />
|
||||
{coverArt ? (
|
||||
<img src={coverArt} alt={parsed?.title ?? ''} className="w-full h-full object-cover" onError={() => setImgError(true)} />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10">
|
||||
<ListMusic className="size-12 text-primary/30" />
|
||||
<FallbackIcon className="size-12 text-primary/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-2 pt-1">
|
||||
<h2 className="text-xl sm:text-2xl font-bold leading-tight">{parsed?.title ?? 'Untitled'}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl sm:text-2xl font-bold leading-tight">{parsed?.title ?? 'Untitled'}</h2>
|
||||
</div>
|
||||
|
||||
{isAlbum && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-primary/10 text-primary w-fit">
|
||||
<Disc3 className="size-3" />Album
|
||||
</span>
|
||||
)}
|
||||
|
||||
<Link to={profileUrl} className="flex items-center gap-2 group">
|
||||
<Avatar className="size-6">
|
||||
@@ -319,23 +420,177 @@ function PlaylistDetail({ event }: { event: NostrEvent }) {
|
||||
<span className="text-sm text-muted-foreground group-hover:text-foreground transition-colors">{displayName}</span>
|
||||
</Link>
|
||||
|
||||
{trackCount > 0 && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<ListMusic className="size-3" />{trackCount} track{trackCount !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{trackCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<ListMusic className="size-3" />{trackCount} track{trackCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{parsed?.released && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="size-3" />{parsed.released}
|
||||
</span>
|
||||
)}
|
||||
{parsed?.label && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag className="size-3" />{parsed.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Play All button */}
|
||||
{audioTracks.length > 0 && (
|
||||
<button
|
||||
onClick={handlePlayAll}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium transition-colors mt-1',
|
||||
isPlayingThisPlaylist && player.isPlaying
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-primary/15 text-primary hover:bg-primary/25',
|
||||
)}
|
||||
>
|
||||
{isPlayingThisPlaylist && player.isPlaying
|
||||
? <><Pause className="size-4" fill="currentColor" />Pause</>
|
||||
: <><Play className="size-4 ml-0.5" fill="currentColor" />Play All</>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{parsed?.description && (
|
||||
<div className="px-4 mt-4">
|
||||
<p className="text-sm text-muted-foreground leading-relaxed whitespace-pre-wrap">{parsed.description}</p>
|
||||
</div>
|
||||
<PlaylistDescription text={parsed.description} />
|
||||
)}
|
||||
|
||||
<div className="px-4 mt-3 text-xs text-muted-foreground">
|
||||
{formatFullDate(event.created_at)}
|
||||
</div>
|
||||
|
||||
{/* Track list */}
|
||||
{(trackCount > 0) && (
|
||||
<div className="mt-6">
|
||||
<div className="px-4 mb-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Tracks</h3>
|
||||
</div>
|
||||
|
||||
{tracksLoading ? (
|
||||
<div>
|
||||
{Array.from({ length: Math.min(trackCount, 8) }).map((_, i) => (
|
||||
<MusicTrackRowSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : trackEvents && trackEvents.length > 0 ? (
|
||||
<div>
|
||||
{trackEvents.map((trackEvent, index) => (
|
||||
<PlaylistTrackRow
|
||||
key={trackEvent.id}
|
||||
event={trackEvent}
|
||||
index={index}
|
||||
onPlayFromIndex={handlePlayFromIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-4 py-6 text-sm text-muted-foreground text-center">
|
||||
No tracks could be loaded.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track row within a playlist context. Clicking play starts the full playlist
|
||||
* from this track's index rather than playing just the single track.
|
||||
*/
|
||||
function PlaylistTrackRow({
|
||||
event,
|
||||
index,
|
||||
onPlayFromIndex,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
index: number;
|
||||
onPlayFromIndex: (index: number) => void;
|
||||
}) {
|
||||
const player = useAudioPlayer();
|
||||
const parsed = useMemo(() => parseMusicTrack(event), [event]);
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
const naddrPath = useMemo(() => {
|
||||
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
|
||||
return '/' + nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: d });
|
||||
}, [event]);
|
||||
|
||||
if (!parsed) return null;
|
||||
|
||||
const isNowPlaying = player.currentTrack?.id === event.id;
|
||||
const dur = parsed.duration ? formatTime(parsed.duration) : undefined;
|
||||
|
||||
const handlePlay = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isNowPlaying && player.isPlaying) {
|
||||
player.pause();
|
||||
} else if (isNowPlaying) {
|
||||
player.resume();
|
||||
} else {
|
||||
onPlayFromIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={naddrPath}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2.5 transition-colors cursor-pointer group',
|
||||
isNowPlaying ? 'bg-primary/5' : 'hover:bg-secondary/30',
|
||||
)}
|
||||
>
|
||||
{/* Index / Play button */}
|
||||
<button
|
||||
onClick={handlePlay}
|
||||
className="size-8 flex items-center justify-center shrink-0"
|
||||
aria-label={isNowPlaying && player.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{isNowPlaying && player.isPlaying ? (
|
||||
<Pause className="size-4 text-primary" fill="currentColor" />
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-muted-foreground group-hover:hidden tabular-nums">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Play className="size-4 text-muted-foreground hidden group-hover:block" fill="currentColor" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Artwork */}
|
||||
<div className="size-12 rounded-lg overflow-hidden shrink-0 bg-muted">
|
||||
{parsed.artwork && !imgError ? (
|
||||
<img src={parsed.artwork} alt={parsed.title} className="size-full object-cover" loading="lazy" onError={() => setImgError(true)} />
|
||||
) : (
|
||||
<div className="size-full flex items-center justify-center bg-primary/10">
|
||||
<Music className="size-5 text-primary/30" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={cn(
|
||||
'text-sm font-medium truncate',
|
||||
isNowPlaying && 'text-primary',
|
||||
)}>
|
||||
{parsed.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{parsed.artist}</p>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
{dur && (
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0">{dur}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
@@ -302,6 +303,7 @@ function SnapshotSkeleton() {
|
||||
function MuteHistoryContent({ onClose }: { onClose: () => void }) {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { config } = useAppContext();
|
||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -369,7 +371,7 @@ function MuteHistoryContent({ onClose }: { onClose: () => void }) {
|
||||
// Update the local mute cache with the restored items
|
||||
const summary = summaries?.get(event.id);
|
||||
if (summary && user) {
|
||||
setCachedMuteItems(user.pubkey, summary.items);
|
||||
setCachedMuteItems(config.appId, user.pubkey, summary.items);
|
||||
}
|
||||
|
||||
toast({
|
||||
|
||||
@@ -61,7 +61,7 @@ function ProfileSidebarLabel({ pubkey }: { pubkey: string }) {
|
||||
|
||||
return (
|
||||
<span className="truncate">
|
||||
{metadata?.display_name || metadata?.name || genUserName(pubkey)}
|
||||
{metadata?.name || metadata?.display_name || genUserName(pubkey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||
const pool = useRef<NPool | undefined>(undefined);
|
||||
|
||||
// Use refs so the pool always has the latest data
|
||||
const effectiveRelays = useRef(getEffectiveRelays(config.relayMetadata, config.useAppRelays));
|
||||
const effectiveRelays = useRef(getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays));
|
||||
|
||||
// Stable ref to the current user's signer for NIP-42 AUTH.
|
||||
// The `open()` callback reads from this ref when a relay sends an AUTH
|
||||
@@ -64,8 +64,8 @@ const NostrProvider: React.FC<NostrProviderProps> = (props) => {
|
||||
// the next natural refetch. Blanket invalidation caused a disruptive
|
||||
// full-feed rerender ~3s after page load when NostrSync synced relays.
|
||||
useEffect(() => {
|
||||
effectiveRelays.current = getEffectiveRelays(config.relayMetadata, config.useAppRelays);
|
||||
}, [config.relayMetadata, config.useAppRelays]);
|
||||
effectiveRelays.current = getEffectiveRelays(config.relayMetadata, config.useAppRelays, config.useUserRelays);
|
||||
}, [config.relayMetadata, config.useAppRelays, config.useUserRelays]);
|
||||
|
||||
// Initialize NPool only once
|
||||
if (!pool.current) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCurrentUser } from "@/hooks/useCurrentUser";
|
||||
import { useEncryptedSettings, setLocalSettingsSync } from "@/hooks/useEncryptedSettings";
|
||||
import { isSyncDone } from "@/hooks/useInitialSync";
|
||||
import { parseBlossomServerList } from "@/lib/appBlossom";
|
||||
import { getStorageKey } from "@/lib/storageKey";
|
||||
|
||||
|
||||
/**
|
||||
@@ -208,7 +209,7 @@ export function NostrSync() {
|
||||
// Only reset theme/sidebar for real account switches, not fresh signups.
|
||||
// During signup, isSyncDone returns false and the onboarding
|
||||
// questionnaire owns theme state until it saves settings.
|
||||
if (isSyncDone(user.pubkey)) {
|
||||
if (isSyncDone(config.appId, user.pubkey)) {
|
||||
updateConfig((current) => {
|
||||
let changed = false;
|
||||
const updates = { ...current };
|
||||
@@ -319,6 +320,14 @@ export function NostrSync() {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (
|
||||
encryptedSettings.useUserRelays !== undefined &&
|
||||
encryptedSettings.useUserRelays !== current.useUserRelays
|
||||
) {
|
||||
updates.useUserRelays = encryptedSettings.useUserRelays;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (encryptedSettings.feedSettings) {
|
||||
const currentFeed = current.feedSettings;
|
||||
const remoteFeed = encryptedSettings.feedSettings;
|
||||
@@ -421,17 +430,19 @@ export function NostrSync() {
|
||||
|
||||
// Sync feed tab settings (stored directly in localStorage, not AppConfig)
|
||||
if (encryptedSettings.showGlobalFeed !== undefined) {
|
||||
const current = localStorage.getItem("ditto:showGlobalFeed");
|
||||
const key = getStorageKey(config.appId, "showGlobalFeed");
|
||||
const current = localStorage.getItem(key);
|
||||
const incoming = String(encryptedSettings.showGlobalFeed);
|
||||
if (current !== incoming) {
|
||||
localStorage.setItem("ditto:showGlobalFeed", incoming);
|
||||
localStorage.setItem(key, incoming);
|
||||
}
|
||||
}
|
||||
if (encryptedSettings.showCommunityFeed !== undefined) {
|
||||
const current = localStorage.getItem("ditto:showCommunityFeed");
|
||||
const key = getStorageKey(config.appId, "showCommunityFeed");
|
||||
const current = localStorage.getItem(key);
|
||||
const incoming = String(encryptedSettings.showCommunityFeed);
|
||||
if (current !== incoming) {
|
||||
localStorage.setItem("ditto:showCommunityFeed", incoming);
|
||||
localStorage.setItem(key, incoming);
|
||||
}
|
||||
}
|
||||
if (encryptedSettings.communityData) {
|
||||
@@ -440,12 +451,13 @@ export function NostrSync() {
|
||||
label: encryptedSettings.communityData.label,
|
||||
userCount: encryptedSettings.communityData.userCount,
|
||||
};
|
||||
const currentRaw = localStorage.getItem("ditto:community");
|
||||
const communityKey = getStorageKey(config.appId, "community");
|
||||
const currentRaw = localStorage.getItem(communityKey);
|
||||
const incoming = JSON.stringify(community);
|
||||
if (currentRaw !== incoming) {
|
||||
localStorage.setItem("ditto:community", incoming);
|
||||
localStorage.setItem(communityKey, incoming);
|
||||
localStorage.setItem(
|
||||
"ditto:communityData",
|
||||
getStorageKey(config.appId, "communityData"),
|
||||
JSON.stringify({ names: encryptedSettings.communityData.nip05 }),
|
||||
);
|
||||
}
|
||||
@@ -463,6 +475,7 @@ export function NostrSync() {
|
||||
updateConfig,
|
||||
recentlyWritten,
|
||||
seededTimestamp,
|
||||
config.appId,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -102,6 +102,32 @@ describe('NoteContent', () => {
|
||||
expect(bitcoinHashtag).toHaveAttribute('href', '/t/bitcoin');
|
||||
});
|
||||
|
||||
it('renders hashtags containing internal hyphens as a single link', async () => {
|
||||
const event: NostrEvent = {
|
||||
id: 'test-id',
|
||||
pubkey: 'test-pubkey',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
kind: 1,
|
||||
tags: [],
|
||||
// `#70-706` is a full hashtag; `#nostr-` has a trailing hyphen that should be excluded.
|
||||
content: 'Reporte #70-706 from #nostr- community.',
|
||||
sig: 'test-sig',
|
||||
};
|
||||
|
||||
render(
|
||||
<TestApp>
|
||||
<NoteContent event={event} />
|
||||
</TestApp>
|
||||
);
|
||||
|
||||
const codeHashtag = await screen.findByRole('link', { name: '#70-706' });
|
||||
expect(codeHashtag).toHaveAttribute('href', '/t/70-706');
|
||||
|
||||
// Trailing hyphen must not be captured into the hashtag.
|
||||
const nostrHashtag = screen.getByRole('link', { name: '#nostr' });
|
||||
expect(nostrHashtag).toHaveAttribute('href', '/t/nostr');
|
||||
});
|
||||
|
||||
it('generates deterministic names for users without metadata and styles them differently', async () => {
|
||||
// Use a valid npub for testing
|
||||
const event: NostrEvent = {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { COUNTRIES } from '@/lib/countries';
|
||||
import { IMAGE_URL_REGEX, EMBED_MEDIA_URL_REGEX } from '@/lib/mediaUrls';
|
||||
import { parseImetaMap } from '@/lib/imeta';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
import { HASHTAG_PATTERN } from '@/lib/hashtag';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AddrCoords } from '@/hooks/useEvent';
|
||||
|
||||
@@ -40,6 +41,9 @@ interface NoteContentProps {
|
||||
* whitespace) while link preview cards and other non-media embeds are preserved.
|
||||
* Used inside embedded quote cards to keep them lightweight. */
|
||||
disableMediaEmbeds?: boolean;
|
||||
/** Root wrapper element. Defaults to `'div'`. Use `'span'` when embedding
|
||||
* inside an already-block container (e.g. inside a markdown `<p>`). */
|
||||
as?: 'div' | 'span';
|
||||
}
|
||||
|
||||
/** Regex matching `:shortcode:` patterns in text. */
|
||||
@@ -249,13 +253,21 @@ export function NoteContent({
|
||||
hideEmbedImages = false,
|
||||
disableNoteEmbeds = false,
|
||||
disableMediaEmbeds = false,
|
||||
as: Wrapper = 'div',
|
||||
}: NoteContentProps) {
|
||||
const tokens = useMemo(() => {
|
||||
const text = event.content;
|
||||
// Match: BOLT11 invoices | URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags
|
||||
// BOLT11: optional "lightning:" prefix + lnbc/lntb/lnbcrt/lntbs + bech32 data (case-insensitive)
|
||||
// NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed)
|
||||
const regex = /(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)|((?:https?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/giu;
|
||||
const regex = new RegExp(
|
||||
'(?:lightning:)?(ln(?:bc|tb|bcrt|tbs)\\d*[munp]?1[023456789acdefghjklmnpqrstuvwxyz]+)'
|
||||
+ '|((?:https?|wss?):\\/\\/[^\\s]+)'
|
||||
+ '|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)'
|
||||
+ '|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)'
|
||||
+ `|(${HASHTAG_PATTERN})`,
|
||||
'giu',
|
||||
);
|
||||
|
||||
const result: ContentToken[] = [];
|
||||
let lastIndex = 0;
|
||||
@@ -592,7 +604,7 @@ export function NoteContent({
|
||||
}, [groupedTokens]);
|
||||
|
||||
return (
|
||||
<div className={cn('whitespace-pre-wrap break-words overflow-hidden', className, isEmojiOnly && 'text-5xl leading-tight')}>
|
||||
<Wrapper dir="auto" className={cn('whitespace-pre-wrap break-words overflow-hidden', className, isEmojiOnly && 'text-5xl leading-tight')}>
|
||||
{groupedTokens.map((token, i) => {
|
||||
switch (token.type) {
|
||||
case 'text':
|
||||
@@ -802,7 +814,7 @@ export function NoteContent({
|
||||
onPrev={goPrev}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -834,8 +846,8 @@ function InlineImage({ url, onClick }: { url: string; onClick: (e: React.MouseEv
|
||||
|
||||
function NostrMention({ pubkey }: { pubkey: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const hasRealName = !!author.data?.metadata?.name;
|
||||
const displayName = author.data?.metadata?.name ?? genUserName(pubkey);
|
||||
const hasRealName = !!(author.data?.metadata?.name || author.data?.metadata?.display_name);
|
||||
const displayName = author.data?.metadata?.name ?? author.data?.metadata?.display_name ?? genUserName(pubkey);
|
||||
const profileUrl = useProfileUrl(pubkey, author.data?.metadata);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import type { NostrEvent } from "@nostrify/nostrify";
|
||||
import { ExternalLink, FileText, Globe, Play, Server } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ExternalLink, FileText, Globe, Pin, PinOff, Play, Server } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ExternalFavicon } from "@/components/ExternalFavicon";
|
||||
import { NsitePreviewDialog } from "@/components/NsitePreviewDialog";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useNsitePlayer } from "@/contexts/NsitePlayerContext";
|
||||
import { useFeedSettings } from "@/hooks/useFeedSettings";
|
||||
import { useLinkPreview } from "@/hooks/useLinkPreview";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { getNsiteSubdomain } from "@/lib/nsiteSubdomain";
|
||||
import { sanitizeUrl } from "@/lib/sanitizeUrl";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NsiteCardProps {
|
||||
event: NostrEvent;
|
||||
}
|
||||
|
||||
/** Build the nsite.lol gateway URL for an nsite event. */
|
||||
function getNsiteUrl(event: NostrEvent): string {
|
||||
return `https://${getNsiteSubdomain(event)}.nsite.lol`;
|
||||
/**
|
||||
* When set, automatically open the nsite preview. Change the value
|
||||
* (e.g. increment a counter) to re-trigger even if the component is
|
||||
* already mounted. `undefined` / `0` = don't auto-play.
|
||||
*/
|
||||
autoPlayKey?: number;
|
||||
}
|
||||
|
||||
/** Renders an nsite deployment card with a rich link preview. */
|
||||
export function NsiteCard({ event }: NsiteCardProps) {
|
||||
export function NsiteCard({ event, autoPlayKey }: NsiteCardProps) {
|
||||
const title = event.tags.find(([n]) => n === "title")?.[1];
|
||||
const description = event.tags.find(([n]) => n === "description")?.[1];
|
||||
const dTag = event.tags.find(([n]) => n === "d")?.[1];
|
||||
@@ -30,14 +34,62 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
const serverTags = event.tags.filter(([n]) => n === "server");
|
||||
|
||||
const isNamed = event.kind === 35128 && !!dTag;
|
||||
const siteUrl = getNsiteUrl(event);
|
||||
const nsiteSubdomain = getNsiteSubdomain(event);
|
||||
const siteUrl = `https://${nsiteSubdomain}.nsite.lol`;
|
||||
const displayName = title || (isNamed ? dTag : "Root Site");
|
||||
|
||||
const { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings();
|
||||
const sidebarUri = isNamed ? `nsite://${nsiteSubdomain}` : undefined;
|
||||
const isPinned = sidebarUri ? orderedItems.includes(sidebarUri) : false;
|
||||
|
||||
const { data: preview, isLoading } = useLinkPreview(siteUrl);
|
||||
const image = preview?.thumbnail_url;
|
||||
const previewTitle = preview?.title;
|
||||
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const { activeSubdomain, setActiveSubdomain } = useNsitePlayer();
|
||||
const [previewOpen, setPreviewOpen] = useState(!!autoPlayKey);
|
||||
|
||||
// Ref tracks the latest activeSubdomain so the unmount cleanup can
|
||||
// guard against clearing a *different* nsite's active state.
|
||||
const activeRef = useRef(activeSubdomain);
|
||||
activeRef.current = activeSubdomain;
|
||||
|
||||
const handleTogglePin = useCallback(() => {
|
||||
if (!sidebarUri) return;
|
||||
if (isPinned) {
|
||||
removeFromSidebar(sidebarUri);
|
||||
toast({ title: 'Removed from sidebar' });
|
||||
} else {
|
||||
addToSidebar(sidebarUri);
|
||||
toast({ title: 'Added to sidebar' });
|
||||
}
|
||||
}, [sidebarUri, isPinned, addToSidebar, removeFromSidebar]);
|
||||
|
||||
// Sync open/close state with the global NsitePlayerContext.
|
||||
const handlePreviewOpenChange = useCallback((open: boolean) => {
|
||||
setPreviewOpen(open);
|
||||
setActiveSubdomain(open ? nsiteSubdomain : null);
|
||||
}, [nsiteSubdomain, setActiveSubdomain]);
|
||||
|
||||
// Open the player when autoPlayKey changes (e.g. sidebar clicked again).
|
||||
useEffect(() => {
|
||||
if (autoPlayKey) {
|
||||
handlePreviewOpenChange(true);
|
||||
}
|
||||
}, [autoPlayKey, handlePreviewOpenChange]);
|
||||
|
||||
// Register on mount if auto-playing, and clean up on unmount.
|
||||
useEffect(() => {
|
||||
if (previewOpen) {
|
||||
setActiveSubdomain(nsiteSubdomain);
|
||||
}
|
||||
return () => {
|
||||
// Only clear if we are still the active subdomain.
|
||||
if (activeRef.current === nsiteSubdomain) {
|
||||
setActiveSubdomain(null);
|
||||
}
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (isLoading) {
|
||||
return <NsiteCardSkeleton />;
|
||||
@@ -115,7 +167,7 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
|
||||
onClick={(e) => { e.stopPropagation(); handlePreviewOpenChange(true); }}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
Run
|
||||
@@ -145,6 +197,17 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{sidebarUri && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs ml-auto text-muted-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); handleTogglePin(); }}
|
||||
>
|
||||
{isPinned ? <PinOff className="size-3 mr-1" /> : <Pin className="size-3 mr-1" />}
|
||||
{isPinned ? 'Unpin' : 'Pin'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +216,7 @@ export function NsiteCard({ event }: NsiteCardProps) {
|
||||
appName={previewTitle || displayName || "nsite"}
|
||||
appPicture={undefined}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
onOpenChange={handlePreviewOpenChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useSyncExternalStore } from 'react';
|
||||
import { Shield, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import {
|
||||
clearNsitePermissions,
|
||||
getNsiteAllowance,
|
||||
getPermissionLabel,
|
||||
removeNsitePermission,
|
||||
type NsiteAllowance,
|
||||
type NsitePermission,
|
||||
} from '@/lib/nsitePermissions';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Subscribe to localStorage changes so the component re-renders when
|
||||
// permissions are modified (e.g. by the prompt granting a new permission).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STORAGE_KEY = 'nostr:nsite-permissions';
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
// Listen for changes from other tabs/windows.
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY) callback();
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
|
||||
// For same-tab mutations, we override the localStorage setter to also
|
||||
// dispatch a custom event. This is necessary because the `storage` event
|
||||
// only fires across tabs, not within the same tab.
|
||||
const onLocal = () => callback();
|
||||
window.addEventListener('nsite-permissions-changed', onLocal);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener('nsite-permissions-changed', onLocal);
|
||||
};
|
||||
}
|
||||
|
||||
let _snapshotCache: string | null = null;
|
||||
|
||||
function getSnapshot(): string | null {
|
||||
const current = localStorage.getItem(STORAGE_KEY);
|
||||
if (current !== _snapshotCache) {
|
||||
_snapshotCache = current;
|
||||
}
|
||||
return _snapshotCache;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NsitePermissionManagerProps {
|
||||
/** Canonical nsite subdomain identifier. */
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popover triggered from the nsite preview nav bar that shows and manages
|
||||
* stored permissions for the current site.
|
||||
*/
|
||||
export function NsitePermissionManager({ siteId }: NsitePermissionManagerProps) {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// Subscribe to permission changes so the list stays in sync.
|
||||
useSyncExternalStore(subscribe, getSnapshot);
|
||||
const allowance: NsiteAllowance | undefined = user
|
||||
? getNsiteAllowance(siteId, user.pubkey)
|
||||
: undefined;
|
||||
const permissions = allowance?.permissions ?? [];
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(perm: NsitePermission) => {
|
||||
if (!user) return;
|
||||
removeNsitePermission(siteId, user.pubkey, perm.type, perm.kind);
|
||||
},
|
||||
[siteId, user],
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
if (!user) return;
|
||||
clearNsitePermissions(siteId, user.pubkey);
|
||||
}, [siteId, user]);
|
||||
|
||||
// Don't render the manager if no user is logged in.
|
||||
if (!user) return null;
|
||||
|
||||
const hasPermissions = permissions.length > 0;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`h-7 w-7 p-0 shrink-0 ${hasPermissions ? 'text-primary' : ''}`}
|
||||
title="Site permissions"
|
||||
>
|
||||
<Shield className="size-3.5" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||
<p className="text-sm font-medium truncate">Permissions</p>
|
||||
{hasPermissions && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
Revoke all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Permission list */}
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{!hasPermissions ? (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<Shield className="size-8 text-muted-foreground/30 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No permissions granted
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground/60 mt-1">
|
||||
Permissions will appear here when the app requests them.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{permissions.map((perm) => (
|
||||
<div
|
||||
key={`${perm.type}-${perm.kind}`}
|
||||
className="flex items-center gap-3 px-4 py-2.5"
|
||||
>
|
||||
{/* Label */}
|
||||
<span className="text-sm flex-1 min-w-0 truncate">
|
||||
{getPermissionLabel(perm.type, perm.kind)}
|
||||
</span>
|
||||
|
||||
{/* Remove */}
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={() => handleRemove(perm)}
|
||||
title="Remove"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, KeyRound, Lock, Pen, ShieldAlert, X } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { getKindLabel } from '@/lib/nsitePermissions';
|
||||
import type { NsitePromptState, NsitePromptDecision } from '@/hooks/useNsiteSignerRpc';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface NsitePermissionPromptProps {
|
||||
/** App icon URL, if available. */
|
||||
appPicture?: string;
|
||||
/** Human-readable app name. */
|
||||
appName: string;
|
||||
/** The nsite gateway URL, used to fetch the site favicon. */
|
||||
siteUrl?: string;
|
||||
/** The pending prompt state from useNsiteSignerRpc. */
|
||||
prompt: NsitePromptState;
|
||||
/** Callback to resolve the prompt. */
|
||||
onResolve: (decision: NsitePromptDecision) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getPromptIcon(type: NsitePromptState['type']) {
|
||||
switch (type) {
|
||||
case 'signEvent':
|
||||
return <Pen className="size-5 text-amber-500" />;
|
||||
case 'nip04.encrypt':
|
||||
case 'nip44.encrypt':
|
||||
return <Lock className="size-5 text-blue-500" />;
|
||||
case 'nip04.decrypt':
|
||||
case 'nip44.decrypt':
|
||||
return <KeyRound className="size-5 text-violet-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getPromptTitle(type: NsitePromptState['type'], kind: number | null): string {
|
||||
switch (type) {
|
||||
case 'signEvent':
|
||||
return kind !== null
|
||||
? `Sign: ${getKindLabel(kind)}`
|
||||
: 'Sign event';
|
||||
case 'nip04.encrypt':
|
||||
return 'Encrypt message (NIP-04)';
|
||||
case 'nip04.decrypt':
|
||||
return 'Decrypt message (NIP-04)';
|
||||
case 'nip44.encrypt':
|
||||
return 'Encrypt message (NIP-44)';
|
||||
case 'nip44.decrypt':
|
||||
return 'Decrypt message (NIP-44)';
|
||||
}
|
||||
}
|
||||
|
||||
function getPromptDescription(type: NsitePromptState['type']): string {
|
||||
switch (type) {
|
||||
case 'signEvent':
|
||||
return 'This app wants to sign a Nostr event on your behalf.';
|
||||
case 'nip04.encrypt':
|
||||
case 'nip44.encrypt':
|
||||
return 'This app wants to encrypt a message using your keys.';
|
||||
case 'nip04.decrypt':
|
||||
case 'nip44.decrypt':
|
||||
return 'This app wants to decrypt a message using your keys.';
|
||||
}
|
||||
}
|
||||
|
||||
/** Truncate a string to a maximum character length. */
|
||||
function truncate(str: string, max: number): string {
|
||||
if (str.length <= max) return str;
|
||||
return str.slice(0, max) + '\u2026';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Overlay prompt shown when an nsite requests a signer operation that requires
|
||||
* user approval. Renders on top of the nsite iframe within the preview panel.
|
||||
*/
|
||||
export function NsitePermissionPrompt({
|
||||
appPicture,
|
||||
appName,
|
||||
siteUrl,
|
||||
prompt,
|
||||
onResolve,
|
||||
}: NsitePermissionPromptProps) {
|
||||
const [remember, setRemember] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
|
||||
const handleAllow = () => onResolve({ allowed: true, remember });
|
||||
const handleDeny = () => onResolve({ allowed: false, remember });
|
||||
|
||||
const icon = getPromptIcon(prompt.type);
|
||||
const title = getPromptTitle(prompt.type, prompt.kind);
|
||||
const description = getPromptDescription(prompt.type);
|
||||
|
||||
// For signEvent, show a preview of the event content.
|
||||
const eventContent = prompt.event?.content as string | undefined;
|
||||
const eventJson = prompt.event ? JSON.stringify(prompt.event, null, 2) : null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-sm rounded-xl border bg-card shadow-lg overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-5 pt-5 pb-3">
|
||||
<div className="flex items-center justify-center size-10 rounded-full bg-muted">
|
||||
<ExternalFavicon
|
||||
url={siteUrl}
|
||||
size={22}
|
||||
fallback={<ShieldAlert className="size-5 text-muted-foreground" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold truncate">{appName}</p>
|
||||
<p className="text-xs text-muted-foreground">Permission request</p>
|
||||
</div>
|
||||
{appPicture && (
|
||||
<img
|
||||
src={appPicture}
|
||||
alt={appName}
|
||||
className="size-8 rounded-md object-cover shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 pb-4 space-y-3">
|
||||
{/* Operation */}
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<div className="shrink-0 mt-0.5">{icon}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{title}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event content preview (signEvent only) */}
|
||||
{prompt.type === 'signEvent' && eventContent && (
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="text-xs text-muted-foreground mb-1">Content</p>
|
||||
<p className="text-sm break-words whitespace-pre-wrap">
|
||||
{truncate(eventContent, 280)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw event details (collapsible) */}
|
||||
{eventJson && (
|
||||
<Collapsible open={showDetails} onOpenChange={setShowDetails}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{showDetails ? 'Hide details' : 'Show details'}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<pre className="mt-2 rounded-lg border bg-muted/30 p-3 text-xs font-mono max-h-40 overflow-auto whitespace-pre-wrap break-all">
|
||||
{eventJson}
|
||||
</pre>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Remember checkbox */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="nsite-remember"
|
||||
checked={remember}
|
||||
onCheckedChange={(checked) => setRemember(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="nsite-remember"
|
||||
className="text-xs text-muted-foreground cursor-pointer select-none"
|
||||
>
|
||||
Remember for this site
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 px-5 pb-5">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 gap-1.5"
|
||||
onClick={handleDeny}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
Deny
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 gap-1.5"
|
||||
onClick={handleAllow}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
Allow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,14 +2,19 @@ import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Package, X } from 'lucide-react';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { NsitePermissionManager } from '@/components/NsitePermissionManager';
|
||||
import { NsitePermissionPrompt } from '@/components/NsitePermissionPrompt';
|
||||
import { SandboxFrame } from '@/components/SandboxFrame';
|
||||
import { useCenterColumn } from '@/contexts/LayoutContext';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useNsiteSignerRpc } from '@/hooks/useNsiteSignerRpc';
|
||||
import { APP_BLOSSOM_SERVERS, getEffectiveBlossomServers } from '@/lib/appBlossom';
|
||||
import { deriveIframeSubdomain } from '@/lib/iframeSubdomain';
|
||||
import { getNsiteNostrProviderScript } from '@/lib/nsiteNostrProvider';
|
||||
import { getNsiteSubdomain } from '@/lib/nsiteSubdomain';
|
||||
import { getPreviewInjectedScript } from '@/lib/previewInjectedScript';
|
||||
import { getMimeType } from '@/lib/sandbox';
|
||||
@@ -118,59 +123,6 @@ async function fetchFromBlossom(sha256: string, servers: string[]): Promise<Resp
|
||||
throw lastError ?? new Error(`Failed to fetch blob ${sha256} from all servers`);
|
||||
}
|
||||
|
||||
/** Max concurrent Blossom fetches during pre-fetch. */
|
||||
const PREFETCH_CONCURRENCY = 12;
|
||||
|
||||
/**
|
||||
* Pre-fetch all unique blobs from the manifest into an in-memory cache.
|
||||
*
|
||||
* **Android only.** Android's WebView uses `shouldInterceptRequest` which
|
||||
* blocks a pool of ~6 IO threads via `CountDownLatch` until JS responds.
|
||||
* If each response requires a network round-trip to Blossom, the 6-at-a-time
|
||||
* serialisation makes loading 200+ files extremely slow. By downloading
|
||||
* every blob *before* the WebView starts loading, each bridge round-trip
|
||||
* drops from seconds (network) to ~1-5ms (memory).
|
||||
*
|
||||
* iOS does NOT need this — `WKURLSchemeHandler` is fully async and can
|
||||
* handle many concurrent requests without any thread pool bottleneck.
|
||||
*
|
||||
* Uses bounded concurrency to saturate the network without overwhelming it.
|
||||
*/
|
||||
async function prefetchAllBlobs(
|
||||
manifest: Map<string, string>,
|
||||
servers: string[],
|
||||
cache: Map<string, Uint8Array>,
|
||||
): Promise<void> {
|
||||
// Deduplicate — many paths may share the same hash (e.g. SPA fallbacks).
|
||||
const uniqueHashes = [...new Set(manifest.values())];
|
||||
// Skip hashes already in the cache (e.g. from a previous open).
|
||||
const toFetch = uniqueHashes.filter((h) => !cache.has(h));
|
||||
if (toFetch.length === 0) return;
|
||||
|
||||
let cursor = 0;
|
||||
const total = toFetch.length;
|
||||
|
||||
async function worker(): Promise<void> {
|
||||
while (cursor < total) {
|
||||
const idx = cursor++;
|
||||
const sha256 = toFetch[idx];
|
||||
try {
|
||||
const res = await fetchFromBlossom(sha256, servers);
|
||||
const buffer = await res.arrayBuffer();
|
||||
cache.set(sha256, new Uint8Array(buffer));
|
||||
} catch {
|
||||
// Non-fatal — resolveFile will fetch on demand for cache misses.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(PREFETCH_CONCURRENCY, total) },
|
||||
() => worker(),
|
||||
);
|
||||
await Promise.all(workers);
|
||||
}
|
||||
|
||||
interface NsitePreviewDialogProps {
|
||||
/** The nsite event (kind 15128 or 35128) containing path and server tags. */
|
||||
event: NostrEvent;
|
||||
@@ -197,24 +149,25 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
const centerColumn = useCenterColumn();
|
||||
const columnRect = useElementRect(open ? centerColumn : null);
|
||||
const { config } = useAppContext();
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
// Use the NIP-5A canonical subdomain as the stable identifier, then derive
|
||||
// a private HMAC-SHA256 subdomain so the raw identifier is never exposed as
|
||||
// a sandbox origin (preventing cross-app localStorage/IndexedDB collisions).
|
||||
const nsiteSubdomain = getNsiteSubdomain(event);
|
||||
const previewSubdomain = useMemo(() => deriveIframeSubdomain('nsite', nsiteSubdomain), [nsiteSubdomain]);
|
||||
const siteUrl = `https://${nsiteSubdomain}.nsite.lol`;
|
||||
const previewSubdomain = useMemo(() => deriveIframeSubdomain(config.appId, 'nsite', nsiteSubdomain), [config.appId, nsiteSubdomain]);
|
||||
|
||||
// NIP-07 signer proxy — only active when a user is logged in.
|
||||
const signerRpc = useNsiteSignerRpc({
|
||||
siteId: nsiteSubdomain,
|
||||
siteName: appName,
|
||||
});
|
||||
|
||||
// Build the manifest and server list from the event (memoised per event identity)
|
||||
const manifest = useRef<Map<string, string>>(new Map());
|
||||
const servers = useRef<string[]>([]);
|
||||
|
||||
/**
|
||||
* In-memory blob cache: sha256 → raw bytes.
|
||||
* On Android, populated by a blocking pre-fetch in `onReady` so every
|
||||
* `resolveFile` call is an instant cache hit with no network wait.
|
||||
*/
|
||||
const blobCache = useRef<Map<string, Uint8Array>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
manifest.current = buildManifest(event);
|
||||
const appServers = getEffectiveBlossomServers(
|
||||
@@ -224,31 +177,24 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
servers.current = resolveServers(event, appServers.length > 0 ? appServers : APP_BLOSSOM_SERVERS.servers);
|
||||
}, [event, config.blossomServerMetadata, config.useAppBlossomServers]);
|
||||
|
||||
/** Injected scripts: just the path normalisation snippet for SPA support. */
|
||||
const injectedScripts = useMemo<InjectedScript[]>(() => [{
|
||||
path: '__injected__/preview.js',
|
||||
content: getPreviewInjectedScript(),
|
||||
}], []);
|
||||
/** Injected scripts: SPA path normalisation + NIP-07 provider (when logged in). */
|
||||
const injectedScripts = useMemo<InjectedScript[]>(() => {
|
||||
const scripts: InjectedScript[] = [{
|
||||
path: '__injected__/preview.js',
|
||||
content: getPreviewInjectedScript(),
|
||||
}];
|
||||
|
||||
/**
|
||||
* Called by SandboxFrame before the native WebView is created.
|
||||
*
|
||||
* On Android: blocks until all blobs are pre-fetched. Android's WebView
|
||||
* uses `shouldInterceptRequest` which blocks ~6 IO threads — if each
|
||||
* response requires a network fetch the whole thing is painfully slow.
|
||||
* The native ProgressBar spinner (render thread) stays visible and
|
||||
* animating during the download. Once the WebView starts, every
|
||||
* resolveFile call is an instant cache hit.
|
||||
*
|
||||
* On iOS: no-op. WKURLSchemeHandler is async and handles concurrent
|
||||
* requests without a thread pool bottleneck.
|
||||
*
|
||||
* On web: no-op. iframe.diy's service worker handles fetches efficiently.
|
||||
*/
|
||||
const onReady = useCallback(async () => {
|
||||
if (Capacitor.getPlatform() !== 'android') return;
|
||||
await prefetchAllBlobs(manifest.current, servers.current, blobCache.current);
|
||||
}, []);
|
||||
// When a user is logged in, inject a NIP-07 provider so the nsite can
|
||||
// use window.nostr to interact with the user's signer.
|
||||
if (user) {
|
||||
scripts.push({
|
||||
path: '__injected__/nostr-provider.js',
|
||||
content: getNsiteNostrProviderScript(user.pubkey),
|
||||
});
|
||||
}
|
||||
|
||||
return scripts;
|
||||
}, [user]);
|
||||
|
||||
/** Resolve a pathname to file content from the Blossom manifest. */
|
||||
const resolveFile = useCallback(async (pathname: string): Promise<FileResponse | null> => {
|
||||
@@ -264,21 +210,11 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
|
||||
if (!sha256) return null;
|
||||
|
||||
// Serve from cache if available (pre-fetched on Android).
|
||||
const cached = blobCache.current.get(sha256);
|
||||
if (cached) {
|
||||
const contentType = getMimeType(servingPath);
|
||||
return { status: 200, contentType, body: cached };
|
||||
}
|
||||
|
||||
// Cache miss — fetch from Blossom (normal path on iOS/web).
|
||||
// Fetch from Blossom.
|
||||
const res = await fetchFromBlossom(sha256, servers.current);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const body = new Uint8Array(buffer);
|
||||
|
||||
// Store in cache for future requests (e.g. SPA navigations).
|
||||
blobCache.current.set(sha256, body);
|
||||
|
||||
// Always determine content type from the file extension.
|
||||
// Blossom servers commonly return incorrect types (e.g. text/plain for .js
|
||||
// files), which causes browsers to reject module scripts. The file path from
|
||||
@@ -307,46 +243,68 @@ export function NsitePreviewDialog({ event, appName, appPicture, open, onOpenCha
|
||||
}}
|
||||
>
|
||||
{/* Nav bar */}
|
||||
<div className="min-h-11 flex items-center gap-2 px-3 border-b bg-muted/30 shrink-0 safe-area-top">
|
||||
{/* App icon + name */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{appPicture ? (
|
||||
<img
|
||||
src={appPicture}
|
||||
alt={appName}
|
||||
className="size-6 rounded-md object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Package className="size-3.5 text-primary/50" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{appName}</span>
|
||||
</div>
|
||||
<div className="min-h-11 border-b bg-muted/30 shrink-0 safe-area-top">
|
||||
<div className="px-3 py-2 flex items-center gap-2 w-full">
|
||||
{/* App icon + name */}
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{appPicture ? (
|
||||
<img
|
||||
src={appPicture}
|
||||
alt={appName}
|
||||
className="size-6 rounded-md object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="size-6 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<ExternalFavicon
|
||||
url={siteUrl}
|
||||
size={18}
|
||||
fallback={<Package className="size-3.5 text-primary/50" />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm font-medium truncate">{appName}</span>
|
||||
</div>
|
||||
|
||||
{/* Close */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => onOpenChange(false)}
|
||||
title="Close"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
{/* Permissions manager (only when logged in) */}
|
||||
{user && (
|
||||
<NsitePermissionManager siteId={nsiteSubdomain} />
|
||||
)}
|
||||
|
||||
{/* Close */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 shrink-0"
|
||||
onClick={() => onOpenChange(false)}
|
||||
title="Close"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sandboxed iframe */}
|
||||
<div className="flex-1 min-h-0 bg-background">
|
||||
<div className="flex-1 min-h-0 bg-background relative">
|
||||
<SandboxFrame
|
||||
key={`${previewSubdomain}-${open}`}
|
||||
id={previewSubdomain}
|
||||
resolveFile={resolveFile}
|
||||
onReady={onReady}
|
||||
onRpc={user ? signerRpc.onRpc : undefined}
|
||||
injectedScripts={injectedScripts}
|
||||
className="w-full h-full border-0"
|
||||
title={`${appName} preview`}
|
||||
/>
|
||||
|
||||
{/* Permission prompt overlay */}
|
||||
{signerRpc.pendingPrompt && (
|
||||
<NsitePermissionPrompt
|
||||
appPicture={appPicture}
|
||||
appName={appName}
|
||||
siteUrl={siteUrl}
|
||||
prompt={signerRpc.pendingPrompt}
|
||||
onResolve={signerRpc.resolvePrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { GripVertical, Rocket, X } from 'lucide-react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { nsiteUriToSubdomain } from '@/lib/sidebarItems';
|
||||
import { parseNsiteSubdomain } from '@/lib/nsiteSubdomain';
|
||||
import { ExternalFavicon } from '@/components/ExternalFavicon';
|
||||
import { useNsitePlayer } from '@/contexts/NsitePlayerContext';
|
||||
import { useLinkPreview } from '@/hooks/useLinkPreview';
|
||||
import { useNostrEventSidebar } from '@/hooks/useNostrEventSidebar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface NsiteSidebarItemProps {
|
||||
/** The full nsite:// URI, e.g. "nsite://3cbg51pm00nms2dp8rm..." */
|
||||
id: string;
|
||||
/** Ignored -- active state is derived from NsitePlayerContext instead. Kept for caller consistency with other sidebar item types. */
|
||||
active?: boolean;
|
||||
editing: boolean;
|
||||
onRemove: (id: string, index?: number) => void;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
/** Extra classes on the link. */
|
||||
linkClassName?: string;
|
||||
}
|
||||
|
||||
// ── Label sub-component ───────────────────────────────────────────────────────
|
||||
|
||||
function NsiteSidebarLabel({ subdomain, parsed }: { subdomain: string; parsed: ReturnType<typeof parseNsiteSubdomain> }) {
|
||||
const siteUrl = `https://${subdomain}.nsite.lol`;
|
||||
const { data: preview } = useLinkPreview(siteUrl);
|
||||
|
||||
const addr = parsed && parsed.kind === 35128
|
||||
? { kind: parsed.kind, pubkey: parsed.pubkey, identifier: parsed.identifier }
|
||||
: undefined;
|
||||
|
||||
const { data: eventData, isLoading } = useNostrEventSidebar({ addr });
|
||||
|
||||
if (isLoading && !eventData && !preview) {
|
||||
return <Skeleton className="h-4 w-20" />;
|
||||
}
|
||||
|
||||
// Prefer the link preview title (the live site <title>), then the event tag label
|
||||
const label = preview?.title || eventData?.label || 'Nsite';
|
||||
|
||||
return (
|
||||
<span className="truncate">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
export function NsiteSidebarItem({
|
||||
id, editing, onRemove, onClick, linkClassName,
|
||||
}: NsiteSidebarItemProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled: !editing });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
const navigate = useNavigate();
|
||||
|
||||
const subdomain = nsiteUriToSubdomain(id);
|
||||
const parsed = useMemo(() => parseNsiteSubdomain(subdomain), [subdomain]);
|
||||
|
||||
// Highlight when the nsite player is open for this subdomain.
|
||||
const { activeSubdomain } = useNsitePlayer();
|
||||
const active = activeSubdomain === subdomain;
|
||||
|
||||
// Build the naddr path for navigation. For named sites (35128), encode as naddr.
|
||||
// For root sites (15128), we'd need a nevent which requires the event ID — fall back to null.
|
||||
const naddrPath = useMemo(() => {
|
||||
if (!parsed) return null;
|
||||
if (parsed.kind === 35128) {
|
||||
const naddr = nip19.naddrEncode({
|
||||
kind: parsed.kind,
|
||||
pubkey: parsed.pubkey,
|
||||
identifier: parsed.identifier,
|
||||
});
|
||||
return `/${naddr}`;
|
||||
}
|
||||
// Root site (15128) — we can't construct an naddr without a d-tag,
|
||||
// and nevent requires event ID. For now, root site nsite:// URIs are not supported.
|
||||
return null;
|
||||
}, [parsed]);
|
||||
|
||||
// Navigate with a fresh timestamp on every click so the detail page
|
||||
// can detect repeated clicks and re-open the player.
|
||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||
onClick?.(e);
|
||||
if (e.defaultPrevented || !naddrPath) return;
|
||||
e.preventDefault();
|
||||
navigate(naddrPath, { state: { nsiteAutoPlay: true, nsiteAutoPlayTs: Date.now() } });
|
||||
}, [naddrPath, navigate, onClick]);
|
||||
|
||||
if (!parsed || !naddrPath) {
|
||||
// Invalid or unsupported nsite URI — render nothing
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn('flex items-center rounded-full transition-colors relative bg-background/85', isDragging && 'z-10 opacity-80 shadow-lg')}
|
||||
>
|
||||
{editing && (
|
||||
<button
|
||||
className="flex items-center justify-center w-8 shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={naddrPath}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'flex items-center gap-4 py-3 rounded-full transition-colors hover:bg-secondary/60 flex-1 min-w-0',
|
||||
editing ? 'px-2' : 'px-3',
|
||||
active ? 'font-bold text-primary' : 'font-normal text-foreground',
|
||||
linkClassName ?? 'text-lg',
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0">
|
||||
<ExternalFavicon
|
||||
url={`https://${subdomain}.nsite.lol`}
|
||||
size={20}
|
||||
fallback={<Rocket className="size-5" />}
|
||||
className="size-6 flex items-center justify-center"
|
||||
/>
|
||||
</span>
|
||||
<span className="truncate" style={{ fontFamily: 'var(--title-font-family, inherit)' }}>
|
||||
<NsiteSidebarLabel subdomain={subdomain} parsed={parsed} />
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{editing && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(id); }}
|
||||
className="flex items-center justify-center size-8 shrink-0 rounded-full transition-all text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
title="Remove"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type AvatarSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
/** Tailwind size classes per avatar size. */
|
||||
const sizeClasses: Record<AvatarSize, string> = {
|
||||
sm: 'size-6',
|
||||
md: 'size-7',
|
||||
lg: 'size-9',
|
||||
};
|
||||
|
||||
/** Tailwind negative-space class for overlap amount. */
|
||||
const overlapClasses: Record<AvatarSize, string> = {
|
||||
sm: '-space-x-1.5',
|
||||
md: '-space-x-2',
|
||||
lg: '-space-x-3',
|
||||
};
|
||||
|
||||
/** Fallback text size per avatar size. */
|
||||
const fallbackTextClasses: Record<AvatarSize, string> = {
|
||||
sm: 'text-[10px]',
|
||||
md: 'text-[10px]',
|
||||
lg: 'text-xs',
|
||||
};
|
||||
|
||||
interface PeopleAvatarStackProps {
|
||||
/** Pubkeys to render, in display order. Only the first `maxVisible` are rendered. */
|
||||
pubkeys: string[];
|
||||
/** How many avatars to show before collapsing into "+N more". Default 6. */
|
||||
maxVisible?: number;
|
||||
/** Avatar size preset. Default 'md'. */
|
||||
size?: AvatarSize;
|
||||
/** Class applied to the outer container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal stack of overlapping avatars with a "+N more" suffix.
|
||||
*
|
||||
* Used to render a compact preview of a list of people — follow lists,
|
||||
* follow sets, follow packs, and profile-recovery snapshots of kind 3.
|
||||
* Batch-fetches metadata for only the visible pubkeys via `useAuthors`.
|
||||
*/
|
||||
export function PeopleAvatarStack({
|
||||
pubkeys,
|
||||
maxVisible = 6,
|
||||
size = 'md',
|
||||
className,
|
||||
}: PeopleAvatarStackProps) {
|
||||
const previewPubkeys = useMemo(() => pubkeys.slice(0, maxVisible), [pubkeys, maxVisible]);
|
||||
const { data: membersMap } = useAuthors(previewPubkeys);
|
||||
|
||||
if (pubkeys.length === 0) return null;
|
||||
|
||||
const overflow = pubkeys.length - previewPubkeys.length;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<div className={cn('flex', overlapClasses[size])}>
|
||||
{previewPubkeys.map((pk) => {
|
||||
const member = membersMap?.get(pk);
|
||||
const displayName =
|
||||
member?.metadata?.name || member?.metadata?.display_name || genUserName(pk);
|
||||
const shape = getAvatarShape(member?.metadata);
|
||||
return (
|
||||
<Tooltip key={pk}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
to={`/${nip19.npubEncode(pk)}`}
|
||||
aria-label={displayName}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'rounded-full relative transition-transform duration-150 ease-out',
|
||||
'hover:z-10 motion-safe:hover:scale-110 focus-visible:z-10 motion-safe:focus-visible:scale-110',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
shape={shape}
|
||||
className={cn(sizeClasses[size], 'ring-2 ring-background')}
|
||||
>
|
||||
<AvatarImage src={member?.metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className={cn('bg-primary/20 text-primary', fallbackTextClasses[size])}>
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
{displayName}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{overflow > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{overflow} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Users, PartyPopper, UserCheck } from 'lucide-react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { PeopleAvatarStack } from '@/components/PeopleAvatarStack';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { getDisplayPubkeys, parsePeopleList } from '@/lib/packUtils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
/**
|
||||
* Compact feed card for kind 3 (follow list), 30000 (follow set), or 39089 (follow pack).
|
||||
* Shows title + optional description + optional cover image + member count + avatar stack.
|
||||
*
|
||||
* For kind 3 the event has no tags describing it, so we fetch the author's metadata
|
||||
* and derive a title like "Alice's follows" with about/banner as description/image.
|
||||
*/
|
||||
export function PeopleListContent({ event }: { event: NostrEvent }) {
|
||||
const needsAuthorMeta = event.kind === 3;
|
||||
const author = useAuthor(needsAuthorMeta ? event.pubkey : '');
|
||||
const authorMetadata = needsAuthorMeta ? author.data?.metadata : undefined;
|
||||
|
||||
const { title, description, image, pubkeys, variant } = useMemo(
|
||||
() => parsePeopleList(event, {
|
||||
authorMetadata,
|
||||
authorDisplayName: authorMetadata?.name || authorMetadata?.display_name,
|
||||
}),
|
||||
[event, authorMetadata],
|
||||
);
|
||||
|
||||
const displayPubkeys = useMemo(() => getDisplayPubkeys(event, pubkeys), [event, pubkeys]);
|
||||
|
||||
const safeImage = useMemo(() => sanitizeUrl(image), [image]);
|
||||
|
||||
const TitleIcon = variant === 'follow-list' ? UserCheck : variant === 'follow-set' ? Users : PartyPopper;
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<TitleIcon className="size-4 text-primary shrink-0" />
|
||||
<span className="text-[15px] font-semibold leading-snug">{title}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-[15px] leading-relaxed text-foreground/90 line-clamp-3 mb-3">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Cover image */}
|
||||
{safeImage && (
|
||||
<div className="rounded-2xl overflow-hidden mb-3">
|
||||
<img
|
||||
src={safeImage}
|
||||
alt={title}
|
||||
className="w-full max-h-[200px] object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar stack */}
|
||||
<PeopleAvatarStack pubkeys={displayPubkeys} maxVisible={8} size="md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
/**
|
||||
* PeopleListDetailContent
|
||||
*
|
||||
* Unified full-page detail view for all "people list" event kinds:
|
||||
* - Kind 3 (NIP-02 follow list)
|
||||
* - Kind 30000 (NIP-51 follow set)
|
||||
* - Kind 39089 (follow pack / starter pack)
|
||||
*
|
||||
* Renders a hero image, author row, title + description, action row (Follow All,
|
||||
* Save, Share, Add-to-sidebar, etc.), and tabs for Feed and Members.
|
||||
*
|
||||
* Owner-mode features (remove members, add members) are enabled automatically
|
||||
* when the current user owns a kind 30000 list.
|
||||
*/
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
Loader2,
|
||||
Copy,
|
||||
X,
|
||||
MessageCircle,
|
||||
} from 'lucide-react';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import type { NostrEvent, NostrFilter, NostrMetadata } from '@nostrify/nostrify';
|
||||
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NoteCard } from '@/components/NoteCard';
|
||||
import { TabButton } from '@/components/TabButton';
|
||||
import { SubHeaderBar } from '@/components/SubHeaderBar';
|
||||
import { VerifiedNip05Text } from '@/components/Nip05Badge';
|
||||
import { AddMembersDialog } from '@/components/AddMembersDialog';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { FlatThreadedReplyList } from '@/components/ThreadedReplyList';
|
||||
import { ARC_OVERHANG_PX } from '@/components/ArcBackground';
|
||||
import { PostActionBar } from '@/components/PostActionBar';
|
||||
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
|
||||
import { FollowAllSplitButton } from '@/components/FollowAllSplitButton';
|
||||
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useAuthors } from '@/hooks/useAuthors';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useFollowList, useFollowActions } from '@/hooks/useFollowActions';
|
||||
import { useTabFeed } from '@/hooks/useProfileFeed';
|
||||
import { useMuteList } from '@/hooks/useMuteList';
|
||||
import { useUserLists } from '@/hooks/useUserLists';
|
||||
|
||||
import { isEventMuted } from '@/lib/muteHelpers';
|
||||
import { shouldHideFeedEvent } from '@/lib/feedUtils';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { isReplyEvent } from '@/lib/nostrEvents';
|
||||
import { getDisplayPubkeys, parsePeopleList } from '@/lib/packUtils';
|
||||
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
||||
|
||||
type Tab = 'feed' | 'members' | 'comments';
|
||||
|
||||
// ─── Feed Tab ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Paginated feed of posts from a list of member pubkeys.
|
||||
*
|
||||
* Uses `useTabFeed` (TanStack Query-backed infinite scroll) plus an
|
||||
* IntersectionObserver sentinel for infinite scroll. Filters kind 1 posts
|
||||
* (excluding replies) and kinds 6/16 reposts from the given authors.
|
||||
*
|
||||
* @param tabKey - A stable cache namespace, typically the list's naddr.
|
||||
*/
|
||||
export function PeopleListFeedTab({ pubkeys, tabKey }: { pubkeys: string[]; tabKey: string }) {
|
||||
const { muteItems } = useMuteList();
|
||||
const { ref: sentinelRef, inView } = useInView({ threshold: 0, rootMargin: '400px' });
|
||||
|
||||
// Build the TabFeed filter. Scope to kind 1 posts + kind 6/16 reposts so the
|
||||
// feed behaves like a normal timeline of people's posts (not their follow
|
||||
// sets, emoji packs, etc.). Replies are filtered out below in the render
|
||||
// step since the relay doesn't expose a "no-replies" filter.
|
||||
const filter = useMemo<NostrFilter | null>(
|
||||
() => (pubkeys.length > 0 ? { kinds: [1, 6, 16], authors: pubkeys } : null),
|
||||
[pubkeys],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTabFeed(filter, `people-list-${tabKey}`, pubkeys.length > 0);
|
||||
|
||||
// Fetch next page when the sentinel scrolls into view.
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Flatten pages, dedupe, and apply mute / content-warning / reply filters.
|
||||
const feedItems = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
return data.pages
|
||||
.flatMap((page) => page.items)
|
||||
.filter((item) => {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
if (shouldHideFeedEvent(item.event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
// Hide replies — this tab should show top-level posts only (reposts of
|
||||
// replies are fine, so only check original kind 1 events, not reposts).
|
||||
if (item.event.kind === 1 && !item.repostedBy && isReplyEvent(item.event)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [data?.pages, muteItems]);
|
||||
|
||||
if (pubkeys.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<Users className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No members in this list yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && feedItems.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-11 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground text-sm">
|
||||
No posts from list members yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{feedItems.map((item) => (
|
||||
<NoteCard
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-6">
|
||||
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Members Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface MembersTabProps {
|
||||
pubkeys: string[];
|
||||
membersMap: Map<string, { metadata?: NostrMetadata }> | undefined;
|
||||
membersLoading: boolean;
|
||||
followedPubkeys: Set<string>;
|
||||
currentUserPubkey: string | undefined;
|
||||
/** When true, show per-member "Remove" buttons. Enabled for owners of kind 30000 lists. */
|
||||
canRemove: boolean;
|
||||
/** Kind 30000 d-tag — required when canRemove is true. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
export function PeopleListMembersTab({
|
||||
pubkeys,
|
||||
membersMap,
|
||||
membersLoading,
|
||||
followedPubkeys,
|
||||
currentUserPubkey,
|
||||
canRemove,
|
||||
listId,
|
||||
}: MembersTabProps) {
|
||||
if (membersLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => (
|
||||
<MemberCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{pubkeys.map((pk) => {
|
||||
const member = membersMap?.get(pk);
|
||||
const isFollowed = followedPubkeys.has(pk);
|
||||
return (
|
||||
<MemberCard
|
||||
key={pk}
|
||||
pubkey={pk}
|
||||
metadata={member?.metadata}
|
||||
isFollowed={isFollowed}
|
||||
isSelf={pk === currentUserPubkey}
|
||||
canRemove={canRemove}
|
||||
listId={listId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Comments Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PeopleListCommentsTab({
|
||||
event,
|
||||
orderedReplies,
|
||||
commentsLoading,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
orderedReplies: Array<{ reply: NostrEvent; firstSubReply?: NostrEvent }>;
|
||||
commentsLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<ComposeBox compact replyTo={event} />
|
||||
{commentsLoading ? (
|
||||
<CommentsSkeleton />
|
||||
) : orderedReplies.length > 0 ? (
|
||||
<FlatThreadedReplyList replies={orderedReplies} />
|
||||
) : (
|
||||
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
|
||||
<MessageCircle className="size-8 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No comments yet. Be the first to comment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Detail Component ────────────────────────────────────────────────────
|
||||
|
||||
export function PeopleListDetailContent({ event }: { event: NostrEvent }) {
|
||||
const { toast } = useToast();
|
||||
const { user } = useCurrentUser();
|
||||
const { data: followList } = useFollowList();
|
||||
const { lists: ownLists, createList } = useUserLists();
|
||||
|
||||
const isOwnList = user && event.pubkey === user.pubkey;
|
||||
const isFollowList = event.kind === 3;
|
||||
const isFollowSet = event.kind === 30000;
|
||||
const dTag = useMemo(
|
||||
() => event.tags.find(([n]) => n === 'd')?.[1] ?? '',
|
||||
[event.tags],
|
||||
);
|
||||
|
||||
// Author
|
||||
const author = useAuthor(event.pubkey);
|
||||
const authorMetadata = author.data?.metadata;
|
||||
const authorAvatarShape = getAvatarShape(authorMetadata);
|
||||
const authorName = authorMetadata?.name || authorMetadata?.display_name || genUserName(event.pubkey);
|
||||
const authorNpub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
|
||||
|
||||
// Parsed list (for kind 3 uses author metadata as fallback)
|
||||
const { title, description, image, pubkeys } = useMemo(
|
||||
() => parsePeopleList(event, {
|
||||
authorMetadata,
|
||||
authorDisplayName: authorName,
|
||||
}),
|
||||
[event, authorMetadata, authorName],
|
||||
);
|
||||
// Reversed for kind 3 follow lists so newest follows show first; identity
|
||||
// for curated kinds. Used only for display — mutations and filters continue
|
||||
// to use the original `pubkeys` array.
|
||||
const displayPubkeys = useMemo(() => getDisplayPubkeys(event, pubkeys), [event, pubkeys]);
|
||||
const safeImage = useMemo(() => sanitizeUrl(image), [image]);
|
||||
|
||||
// Batch-fetch all member profiles
|
||||
const { data: membersMap, isLoading: membersLoading } = useAuthors(pubkeys);
|
||||
|
||||
// Comments (NIP-22 kind 1111, indexed by #A for replaceable / addressable roots)
|
||||
const { muteItems } = useMuteList();
|
||||
const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500);
|
||||
const orderedReplies = useMemo(() => {
|
||||
const topLevel = commentsData?.topLevelComments ?? [];
|
||||
const filtered = muteItems.length > 0
|
||||
? topLevel.filter((r) => !isEventMuted(r, muteItems))
|
||||
: topLevel;
|
||||
return [...filtered]
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
.map((reply) => {
|
||||
const directReplies = commentsData?.getDirectReplies(reply.id) ?? [];
|
||||
return {
|
||||
reply,
|
||||
firstSubReply: directReplies[0] as NostrEvent | undefined,
|
||||
};
|
||||
});
|
||||
}, [commentsData, muteItems]);
|
||||
|
||||
// Follow state
|
||||
const followedPubkeys = useMemo(
|
||||
() => new Set(followList?.pubkeys ?? []),
|
||||
[followList],
|
||||
);
|
||||
const newPubkeys = useMemo(
|
||||
() => pubkeys.filter((pk) => !followedPubkeys.has(pk)),
|
||||
[pubkeys, followedPubkeys],
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>('feed');
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [addMembersOpen, setAddMembersOpen] = useState(false);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
|
||||
// Owner-mode remove is only available for lists we manage locally (kind 30000)
|
||||
const ownerCanRemove = !!(isOwnList && isFollowSet && ownLists.some((l) => l.id === dTag));
|
||||
|
||||
// Stable cache-key for the feed tab — the naddr uniquely identifies this list.
|
||||
const shareNip19 = useMemo(() => {
|
||||
if (isFollowList) {
|
||||
// Kind 3 is replaceable, no d-tag
|
||||
return nip19.naddrEncode({ kind: 3, pubkey: event.pubkey, identifier: '' });
|
||||
}
|
||||
return nip19.naddrEncode({
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
identifier: dTag,
|
||||
});
|
||||
}, [event, dTag, isFollowList]);
|
||||
|
||||
// ── Clone (save a copy of this list as my own kind 30000) ─────────────────
|
||||
const handleClone = useCallback(async () => {
|
||||
if (!user || cloning) return;
|
||||
setCloning(true);
|
||||
try {
|
||||
await createList.mutateAsync({
|
||||
title,
|
||||
description: description || undefined,
|
||||
pubkeys,
|
||||
});
|
||||
toast({ title: `Saved "${title}" to your lists` });
|
||||
} catch {
|
||||
toast({ title: 'Failed to save list', variant: 'destructive' });
|
||||
} finally {
|
||||
setCloning(false);
|
||||
}
|
||||
}, [user, cloning, createList, title, description, pubkeys, toast]);
|
||||
|
||||
// When the user is viewing their own kind 3, Follow All makes no sense.
|
||||
const showFollowAllButton = !(isOwnList && isFollowList);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero image */}
|
||||
{safeImage && (
|
||||
<div className="w-full overflow-hidden bg-muted border-b border-border">
|
||||
<img
|
||||
src={safeImage}
|
||||
alt={title}
|
||||
className="w-full h-auto max-h-[300px] object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to={`/${authorNpub}`}>
|
||||
<Avatar shape={authorAvatarShape} className="size-11">
|
||||
<AvatarImage src={authorMetadata?.picture} alt={authorName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{authorName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
to={`/${authorNpub}`}
|
||||
className="font-bold text-[15px] hover:underline block truncate"
|
||||
>
|
||||
{authorName}
|
||||
</Link>
|
||||
{authorMetadata?.nip05 && (
|
||||
<VerifiedNip05Text
|
||||
nip05={authorMetadata.nip05}
|
||||
pubkey={event.pubkey}
|
||||
className="text-sm text-muted-foreground truncate block"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-bold mt-4 leading-snug">{title}</h2>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-[15px] text-muted-foreground leading-relaxed mt-2 whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* "N new for you" hint */}
|
||||
{newPubkeys.length > 0 && user && !isOwnList && (
|
||||
<div className="mt-4 text-sm text-green-600 dark:text-green-400">
|
||||
{newPubkeys.length} new for you
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-3">
|
||||
{showFollowAllButton && (
|
||||
<FollowAllSplitButton
|
||||
pubkeys={pubkeys}
|
||||
followedPubkeys={followedPubkeys}
|
||||
listNoun={isFollowList ? "this person's follow list" : 'this list'}
|
||||
includeAuthorPubkey={isFollowList ? event.pubkey : undefined}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save (clone) — available to logged-in viewers who don't own the list, not for kind 3 (that's your follow list, you don't clone it) */}
|
||||
{user && !isOwnList && !isFollowList && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={showFollowAllButton ? undefined : 'flex-1'}
|
||||
onClick={handleClone}
|
||||
disabled={cloning}
|
||||
title="Save a copy to your lists"
|
||||
>
|
||||
{cloning ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interaction bar — reply / repost / react / zap / share / more */}
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comments"
|
||||
onReply={() => setActiveTab('comments')}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
className="px-4"
|
||||
/>
|
||||
|
||||
{/* Tab bar */}
|
||||
<SubHeaderBar pinned>
|
||||
<TabButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
|
||||
<TabButton
|
||||
label="Members"
|
||||
active={activeTab === 'members'}
|
||||
onClick={() => setActiveTab('members')}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
Members
|
||||
<span className="text-xs text-muted-foreground">({pubkeys.length})</span>
|
||||
</span>
|
||||
</TabButton>
|
||||
<TabButton
|
||||
label="Comments"
|
||||
active={activeTab === 'comments'}
|
||||
onClick={() => setActiveTab('comments')}
|
||||
/>
|
||||
</SubHeaderBar>
|
||||
|
||||
{/* Spacer below the pinned tabs (matches ProfilePage / BadgeDetailContent). */}
|
||||
<div style={{ height: ARC_OVERHANG_PX }} />
|
||||
|
||||
{/* Owner "Add members" row — above members tab content */}
|
||||
{ownerCanRemove && activeTab === 'members' && (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setAddMembersOpen(true)}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
Add Members
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'feed' ? (
|
||||
<PeopleListFeedTab pubkeys={pubkeys} tabKey={shareNip19} />
|
||||
) : activeTab === 'members' ? (
|
||||
<PeopleListMembersTab
|
||||
pubkeys={displayPubkeys}
|
||||
membersMap={membersMap}
|
||||
membersLoading={membersLoading}
|
||||
followedPubkeys={followedPubkeys}
|
||||
currentUserPubkey={user?.pubkey}
|
||||
canRemove={ownerCanRemove}
|
||||
listId={dTag}
|
||||
/>
|
||||
) : (
|
||||
<PeopleListCommentsTab
|
||||
event={event}
|
||||
orderedReplies={orderedReplies}
|
||||
commentsLoading={commentsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ownerCanRemove && (
|
||||
<AddMembersDialog
|
||||
open={addMembersOpen}
|
||||
onOpenChange={setAddMembersOpen}
|
||||
listId={dTag}
|
||||
listPubkeys={pubkeys}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Member Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface MemberCardProps {
|
||||
pubkey: string;
|
||||
metadata?: NostrMetadata;
|
||||
isFollowed: boolean;
|
||||
isSelf: boolean;
|
||||
/** When true, renders a "remove" button that calls useUserLists().removeFromList. */
|
||||
canRemove?: boolean;
|
||||
/** Kind 30000 d-tag — required when canRemove is true. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
export function MemberCard({
|
||||
pubkey,
|
||||
metadata,
|
||||
isFollowed,
|
||||
isSelf,
|
||||
canRemove,
|
||||
listId,
|
||||
}: MemberCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey);
|
||||
const about = metadata?.about;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const { follow, unfollow, isPending } = useFollowActions();
|
||||
const { removeFromList } = useUserLists();
|
||||
const { toast } = useToast();
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
const handleFollowToggle = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isFollowed) {
|
||||
await unfollow(pubkey);
|
||||
} else {
|
||||
await follow(pubkey);
|
||||
}
|
||||
},
|
||||
[isFollowed, pubkey, follow, unfollow],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!listId) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
await removeFromList.mutateAsync({ listId, pubkey });
|
||||
toast({ title: 'Removed from list' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to remove', variant: 'destructive' });
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
},
|
||||
[listId, pubkey, removeFromList, toast],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors cursor-pointer group"
|
||||
onClick={() => navigate(`/${npub}`)}
|
||||
>
|
||||
<Link to={`/${npub}`} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
to={`/${npub}`}
|
||||
className="font-bold text-[15px] hover:underline block truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayName}
|
||||
</Link>
|
||||
{about && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{about}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{!isSelf && (
|
||||
<Button
|
||||
variant={isFollowed ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={handleFollowToggle}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : isFollowed ? (
|
||||
'Following'
|
||||
) : (
|
||||
'Follow'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canRemove && listId && (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={removing}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-all"
|
||||
aria-label="Remove from list"
|
||||
>
|
||||
{removing
|
||||
? <Loader2 className="size-4 animate-spin" />
|
||||
: <X className="size-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberCardSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,9 +20,9 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useProfileUrl } from '@/hooks/useProfileUrl';
|
||||
import { useEventStats } from '@/hooks/useTrending';
|
||||
import { useUserZap } from '@/hooks/useUserZap';
|
||||
import { getDisplayName } from '@/lib/getDisplayName';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
import { canZap } from '@/lib/canZap';
|
||||
import { formatNumber } from '@/lib/formatNumber';
|
||||
|
||||
interface PhotoBottomBarProps {
|
||||
@@ -38,7 +38,8 @@ export function PhotoBottomBar({ event }: PhotoBottomBarProps) {
|
||||
const { data: stats } = useEventStats(event.id, event);
|
||||
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
|
||||
const [commentsOpen, setCommentsOpen] = useState(false);
|
||||
const canZapAuthor = user && canZap(metadata);
|
||||
const canZapAuthor = !!user && user.pubkey !== event.pubkey;
|
||||
const isZapped = useUserZap(canZapAuthor ? event.id : undefined) === true;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -97,8 +98,13 @@ export function PhotoBottomBar({ event }: PhotoBottomBarProps) {
|
||||
|
||||
{canZapAuthor && (
|
||||
<ZapDialog target={event}>
|
||||
<button className="flex items-center gap-1 p-2.5 text-white hover:text-amber-400 transition-colors">
|
||||
<Zap className="size-5" />
|
||||
<button
|
||||
className={`flex items-center gap-1 p-2.5 transition-colors ${
|
||||
isZapped ? 'text-amber-400 hover:text-amber-300' : 'text-white hover:text-amber-400'
|
||||
}`}
|
||||
title={isZapped ? 'Zapped' : 'Zap'}
|
||||
>
|
||||
<Zap className="size-5" fill={isZapped ? 'currentColor' : 'none'} />
|
||||
{!!stats?.zapAmount && <span className="text-sm tabular-nums drop-shadow">{formatNumber(stats.zapAmount)}</span>}
|
||||
</button>
|
||||
</ZapDialog>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user