Browse Source

more performance improvements

update workflow implemented
updated ReadMe.md
added getting started page
got rid of broken zap stuff
master
Silberengel 1 month ago
parent
commit
c9e68430e4
  1. 963
      README.md
  2. 6
      src/app.html
  3. 454
      src/lib/components/modals/UpdateModal.svelte
  4. 84
      src/lib/modules/comments/CommentThread.svelte
  5. 47
      src/lib/modules/discussions/DiscussionCard.svelte
  6. 47
      src/lib/modules/discussions/DiscussionList.svelte
  7. 10
      src/lib/modules/feed/FeedPost.svelte
  8. 4
      src/lib/modules/feed/Reply.svelte
  9. 237
      src/lib/modules/feed/ZapReceiptReply.svelte
  10. 12
      src/lib/modules/profiles/PaymentAddresses.svelte
  11. 5
      src/lib/modules/profiles/ProfilePage.svelte
  12. 184
      src/lib/modules/zaps/ZapButton.svelte
  13. 174
      src/lib/modules/zaps/ZapInvoiceModal.svelte
  14. 132
      src/lib/modules/zaps/ZapReceipt.svelte
  15. 173
      src/lib/services/cache/cache-prewarmer.ts
  16. 1
      src/lib/services/keyboard-shortcuts.ts
  17. 4
      src/lib/services/nostr/config.ts
  18. 23
      src/lib/services/nostr/nostr-client.ts
  19. 6
      src/lib/services/nostr/relay-manager.ts
  20. 72
      src/lib/services/version-manager.ts
  21. 6
      src/lib/types/kind-lookup.ts
  22. 38
      src/routes/+layout.svelte
  23. 329
      src/routes/about/+page.svelte
  24. 21
      src/routes/lists/+page.svelte
  25. 2
      src/routes/manifest.webmanifest/+server.ts
  26. 20
      src/routes/settings/+page.svelte

963
README.md

@ -1,963 +1,6 @@ @@ -1,963 +1,6 @@
# aitherboard Specification
# aitherboard
A decentralized messageboard built on the Nostr protocol. This document defines the complete specification for implementation.
A decentralized messageboard built on the Nostr protocol.
This is a client from [silberengel@gitcitadel.com](https://jumble.imwald.eu/users/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z)
This is a client from [silberengel@gitcitadel.com](https://aitherboard.imwald.eu/profile/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z)
## Table of Contents
- [Technology Stack](#technology-stack)
- [Project Structure](#project-structure)
- [Event Types](#event-types)
- [Relay Management](#relay-management)
- [Authentication](#authentication)
- [Content Modules](#content-modules)
- [User Interface](#user-interface)
- [Performance Requirements](#performance-requirements)
- [Accessibility Requirements](#accessibility-requirements)
- [Security Requirements](#security-requirements)
- [Configuration](#configuration)
- [Deployment](#deployment)
## Technology Stack
**REQUIRED**:
- Frontend: Svelte 5 (with runes: `$state`, `$derived`, `$effect`) + TypeScript + Vite
- Styling: Tailwind CSS (4chan-style minimal design)
- Nostr Library: [`nostr-tools`](https://github.com/nbd-wtf/nostr-tools)
- Markdown: `marked` + `DOMPurify` for sanitization
- Storage: IndexedDB (via `idb` or `localforage`)
- Deployment: Docker + Apache httpd (static serving on port 9876)
- PWA: Progressive Web App support
## Project Structure
```
aitherboard/
├── src/
│ ├── lib/
│ │ ├── services/
│ │ │ ├── nostr/
│ │ │ │ ├── nostr-client.ts
│ │ │ │ ├── auth-handler.ts
│ │ │ │ └── config.ts
│ │ │ ├── auth/
│ │ │ │ ├── nip07-signer.ts
│ │ │ │ ├── nsec-signer.ts
│ │ │ │ ├── bunker-signer.ts
│ │ │ │ ├── anonymous-signer.ts
│ │ │ │ ├── session-manager.ts
│ │ │ │ ├── profile-fetcher.ts
│ │ │ │ ├── relay-list-fetcher.ts
│ │ │ │ ├── user-preferences-fetcher.ts
│ │ │ │ ├── user-status-fetcher.ts
│ │ │ │ └── activity-tracker.ts
│ │ │ ├── cache/
│ │ │ │ ├── indexeddb-store.ts
│ │ │ │ ├── event-cache.ts
│ │ │ │ ├── profile-cache.ts
│ │ │ │ ├── search-index.ts
│ │ │ │ └── anonymous-key-store.ts
│ │ │ └── security/
│ │ │ ├── key-management.ts
│ │ │ ├── bech32-utils.ts
│ │ │ ├── event-validator.ts
│ │ │ └── sanitizer.ts
│ │ ├── modules/
│ │ │ ├── threads/
│ │ │ ├── comments/
│ │ │ ├── zaps/
│ │ │ ├── reactions/
│ │ │ ├── profiles/
│ │ │ └── feed/
│ │ └── components/
│ │ ├── content/
│ │ ├── interactions/
│ │ ├── layout/
│ │ ├── modals/
│ │ └── preferences/
│ └── routes/
├── public/
├── scripts/
├── Dockerfile
├── docker-compose.yml
├── httpd.conf.template
└── docker-entrypoint.sh
```
## Event Types
### Supported Event Kinds
| Kind | Name | NIP | Purpose |
|------|------|-----|---------|
| 0 | Metadata/Profile | 1 | User profile information |
| 1 | Note/Feed Post | 10 | Twitter-like feed posts |
| 7 | Reaction | 25 | Upvote/downvote/like |
| 11 | Thread | 7D | Discussion threads |
| 1111 | Comment | 22 | Threaded comments |
| 9734 | Zap Request | 57 | Lightning payment request (not published to relays) |
| 9735 | Zap Receipt | 57 | Lightning payment confirmation |
| 10000 | Mute List | 51 | User mute preferences |
| 10002 | Relay List | 65 | User relay preferences (inbox/outbox) |
| 10006 | Blocked Relays | 51 | User blocked relay list |
| 10432 | Local Relays | - | Local relay preferences (used alongside 10002) |
| 10133 | Payment Targets | A3 | Payment address information (RFC-8905) |
| 30315 | General User Status | 38 | General user status (NIP-38) |
**Note**: Media attachments use tags defined in NIP-92 (imeta), NIP-94 (file attachments), and NIP-23 (image tags).
### Event Structure Examples
#### Kind 0 (Metadata/Profile)
```json
{
"kind": 0,
"content": "{\"name\":\"Alice\",\"about\":\"Developer\",\"picture\":\"https://example.com/alice.jpg\",\"website\":\"https://alice.example.com\",\"lud16\":\"alice@wallet.example.com\",\"nip05\":\"alice@example.com\"}",
"tags": [
["name", "Alice"],
["about", "Developer"],
["picture", "https://example.com/alice.jpg"],
["website", "https://alice.example.com"],
["lud16", "alice@wallet.example.com"],
["nip05", "alice@example.com"],
["nip05", "alice@otherexample.com"]
],
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
"created_at": 1679673265,
"id": "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446",
"sig": "f2cb581a84ed10e4dc84937bd98e27acac71ab057255f6aa8dfa561808c981fe8870f4a03c1e3666784d82a9c802d3704e174371aa13d63e2aeaf24ff5374d9d"
}
```
**REQUIREMENTS**:
- Parse tags (preferred) OR fallback to stringified JSON content
- Support multiple tags for: `lud16`, `website`, `nip05`
- Extract `lud16` tags for payment address display
#### Kind 1 (Note/Feed Post)
```json
{
"kind": 1,
"content": "Hello nostr!",
"tags": [
["e", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446", "wss://relay.example.com", "reply", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["root", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446"],
["client", "aitherboard"]
],
"pubkey": "a55c15f5e41d5aebd236eca5e0142789c5385703f1a7485aa4b38d94fd18dcc4",
"created_at": 1679673300,
"id": "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d"
}
```
**REQUIREMENTS**:
- Support NIP-10 threading (`e`, `p`, `root`, `reply` tags)
- Render markdown content
- Flat threading display (no indentation)
#### Kind 7 (Reaction)
```json
{
"kind": 7,
"content": "+",
"tags": [
["e", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446", "wss://relay.example.com", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d", "wss://relay.example.com"],
["k", "1"],
["client", "aitherboard"]
],
"pubkey": "a55c15f5e41d5aebd236eca5e0142789c5385703f1a7485aa4b38d94fd18dcc4",
"created_at": 1679673300,
"id": "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d"
}
```
**REQUIREMENTS**: See [Reaction Module](#reaction-module-srclibmodulesreactions) for full requirements.
#### Kind 11 (Thread)
```json
{
"kind": 11,
"content": "Good morning everyone!",
"tags": [
["title", "GM"],
["t", "tech"],
["image", "https://example.com/cover.jpg"],
["imeta", "url https://example.com/image1.jpg", "m image/jpeg", "x 1920", "y 1080"],
["imeta", "url https://example.com/video1.mp4", "m video/mp4", "x 1920", "y 1080", "dim 1920x1080x30"],
["file", "https://example.com/document.pdf", "application/pdf", "size 1048576"],
["client", "aitherboard"]
],
"pubkey": "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d",
"created_at": 1679673265,
"id": "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446",
"sig": "f2cb581a84ed10e4dc84937bd98e27acac71ab057255f6aa8dfa561808c981fe8870f4a03c1e3666784d82a9c802d3704e174371aa13d63e2aeaf24ff5374d9d"
}
```
**REQUIREMENTS**:
- Max 3 `t` tags (topics)
- Threads without topics appear under "General"
- 30-day timeout (configurable)
- Render markdown content
- **Cover image**: Support `image` tag (NIP-23) for thread cover image
- **Media attachments**: Support NIP-92 (imeta) and NIP-94 (file attachments) tags
#### Kind 1111 (Comment)
```json
{
"kind": 1111,
"content": "Cool beans",
"tags": [
["K", "11"],
["E", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446", "wss://relay.example.com", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["P", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["e", "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446", "wss://relay.example.com", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["k", "11"],
["p", "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],
["client", "aitherboard"]
],
"pubkey": "a55c15f5e41d5aebd236eca5e0142789c5385703f1a7485aa4b38d94fd18dcc4",
"created_at": 1679673300,
"id": "5c83da77af1dec6d7289834998ad7aafbd9e2191396d75ec3cc27f5a77226f36",
"sig": "77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d"
}
```
**REQUIREMENTS**:
- Parse NIP-22 tags: `K`, `E`, `e`, `k`, `p`, `P`
- Flat list display (no indentation)
- Gray blurb showing parent preview
- Click blurb to highlight/scroll to parent
- Render markdown content
#### Kind 9734 (Zap Request)
```json
{
"kind": 9734,
"content": "Zap!",
"tags": [
["relays", "wss://nostr-pub.wellorder.com", "wss://anotherrelay.example.com"],
["amount", "21000"],
["lnurl", "lnurl1dp68gurn8ghj7um5v93kketj9ehx2amn9uh8wetvdskkkmn0wahz7mrww4excup0dajx2mrv92x9xp"],
["p", "04c915daefee38317fa734444acee390a8269fe5810b2241e5e6dd343dfbecc9"],
["e", "9ae37aa68f48645127299e9453eb5d908a0cbb6058ff340d528ed4d37c8994fb"],
["k", "1"],
["client", "aitherboard"]
],
"pubkey": "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322",
"created_at": 1679673265,
"id": "30efed56a035b2549fcaeec0bf2c1595f9a9b3bb4b1a38abaf8ee9041c4b7d93",
"sig": "f2cb581a84ed10e4dc84937bd98e27acac71ab057255f6aa8dfa561808c981fe8870f4a03c1e3666784d82a9c802d3704e174371aa13d63e2aeaf24ff5374d9d"
}
```
**REQUIREMENTS**:
- **NOT published to relays** - sent directly to wallet lnurl callback
- **Primary method**: Send via `lightning:` URI to external wallet
- **Fallback method**: Fetch invoice, display QR code + copyable text
- User pays with external wallet (no in-app wallet)
#### Kind 9735 (Zap Receipt)
```json
{
"id": "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446",
"pubkey": "9630f464cca6a5147aa8a35f0bcdd3ce485324e732fd39e09233b1d848238f31",
"created_at": 1674164545,
"kind": 9735,
"tags": [
["p", "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245"],
["P", "97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322"],
["e", "3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8"],
["k", "1"],
["bolt11", "lnbc10u1p3unwfusp5t9r3yymhpfqculx78u027lxspgxcr2n2987mx2j55nnfs95nxnzqpp5jmrh92pfld78spqs78v9euf2385t83uvpwk9ldrlvf6ch7tpascqhp5zvkrmemgth3tufcvflmzjzfvjt023nazlhljz2n9hattj4f8jq8qxqyjw5qcqpjrzjqtc4fc44feggv7065fqe5m4ytjarg3repr5j9el35xhmtfexc42yczarjuqqfzqqqqqqqqlgqqqqqqgq9q9qxpqysgq079nkq507a5tw7xgttmj4u990j7wfggtrasah5gd4ywfr2pjcn29383tphp4t48gquelz9z78p4cq7ml3nrrphw5w6eckhjwmhezhnqpy6gyf0"],
["description", "{\"pubkey\":\"97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322\",\"content\":\"\",\"id\":\"d9cc14d50fcb8c27539aacf776882942c1a11ea4472f8cdec1dea82fab66279d\",\"created_at\":1674164539,\"sig\":\"77127f636577e9029276be060332ea565deaf89ff215a494ccff16ae3f757065e2bc59b2e8c113dd407917a010b3abd36c8d7ad84c0e3ab7dab3a0b0caa9835d\",\"kind\":9734,\"tags\":[[\"e\",\"3624762a1274dd9636e0c552b53086d70bc88c165bc4dc0f9e836a1eaf86c3b8\"],[\"p\",\"32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245\"],[\"relays\",\"wss://relay.damus.io\"]]}"],
["preimage", "5d006d2cf1e73c7148e7519a4c68adc81642ce0e25a432b2434c99f97344c15f"]
],
"content": "",
"sig": "f2cb581a84ed10e4dc84937bd98e27acac71ab057255f6aa8dfa561808c981fe8870f4a03c1e3666784d82a9c802d3704e174371aa13d63e2aeaf24ff5374d9d"
}
```
**REQUIREMENTS**:
- Display with ⚡ emoji
- Only display if amount ≥ configured threshold (default: 1 sat)
- Published by wallet, not client
#### Kind 10002 (Relay List)
```json
{
"kind": 10002,
"tags": [
["r", "wss://alicerelay.example.com"],
["r", "wss://brando-relay.com"],
["r", "wss://expensive-relay.example2.com", "write"],
["r", "wss://nostr-relay.example.com", "read"]
],
"content": "",
"pubkey": "854043ae8f1f97430ca8c1f1a090bdde6488bd5115c7a45307a2a212750ae4cb",
"created_at": 1699597889,
"id": "a92a316b75e44cfdc19986c634049158d4206fcc0b7b9c7ccbcdabe28beebcd0",
"sig": "1173822c53261f8cffe7efbf43ba4a97a9198b3e402c2a1df130f42a8985a2d0d3430f4de350db184141e45ca844ab4e5364ea80f11d720e36357e1853dba6ca"
}
```
**REQUIREMENTS**:
- Replaceable event (one per user)
- `r` tags with relay URLs
- Optional `read` or `write` marker (if omitted, relay is both)
- **Inbox relays** (marked `read` or unmarked): Added to reading operations
- **Outbox relays** (marked `write` or unmarked): Added to publishing operations
- Used alongside kind 10432 (local relays) - both kinds combined for relay selection
#### Kind 10432 (Local Relays)
```json
{
"kind": 10432,
"tags": [
["r", "ws://localhost:7777"],
["r", "ws://local-relay.example.com", "write"],
["r", "ws://localhost:8080", "read"]
],
"content": "",
"pubkey": "854043ae8f1f97430ca8c1f1a090bdde6488bd5115c7a45307a2a212750ae4cb",
"created_at": 1699597889,
"id": "a92a316b75e44cfdc19986c634049158d4206fcc0b7b9c7ccbcdabe28beebcd0",
"sig": "1173822c53261f8cffe7efbf43ba4a97a9198b3e402c2a1df130f42a8985a2d0d3430f4de350db184141e45ca844ab4e5364ea80f11d720e36357e1853dba6ca"
}
```
**REQUIREMENTS**:
- Replaceable event (one per user)
- `r` tags with relay URLs (typically local network relays)
- Optional `read` or `write` marker (if omitted, relay is both)
- **Inbox relays** (marked `read` or unmarked): Added to reading operations
- **Outbox relays** (marked `write` or unmarked): Added to publishing operations
- Used alongside kind 10002 - both kinds combined for relay selection
- **NOTE**: Kind number 10432 should be verified against current Nostr specifications
#### Kind 10000 (Mute List)
```json
{
"id": "a92a316b75e44cfdc19986c634049158d4206fcc0b7b9c7ccbcdabe28beebcd0",
"pubkey": "854043ae8f1f97430ca8c1f1a090bdde6488bd5115c7a45307a2a212750ae4cb",
"created_at": 1699597889,
"kind": 10000,
"tags": [
["p", "07caba282f76441955b695551c3c5c742e5b9202a3784780f8086fdcdc1da3a9"],
["p", "a55c15f5e41d5aebd236eca5e0142789c5385703f1a7485aa4b38d94fd18dcc4"]
],
"content": "",
"sig": "1173822c53261f8cffe7efbf43ba4a97a9198b3e402c2a1df130f42a8985a2d0d3430f4de350db184141e45ca844ab4e5364ea80f11d720e36357e1853dba6ca"
}
```
**REQUIREMENTS**:
- Replaceable event (one per user)
- Filter out all events from pubkeys in `p` tags
- Applied automatically when user is logged in
#### Kind 10006 (Blocked Relays)
```json
{
"kind": 10006,
"content": "",
"tags": [
["relay", "wss://blocked-relay1.com"],
["relay", "wss://blocked-relay2.com"]
],
"pubkey": "854043ae8f1f97430ca8c1f1a090bdde6488bd5115c7a45307a2a212750ae4cb",
"created_at": 1699597889,
"id": "a92a316b75e44cfdc19986c634049158d4206fcc0b7b9c7ccbcdabe28beebcd0",
"sig": "1173822c53261f8cffe7efbf43ba4a97a9198b3e402c2a1df130f42a8985a2d0d3430f4de350db184141e45ca844ab4e5364ea80f11d720e36357e1853dba6ca"
}
```
**REQUIREMENTS**:
- Replaceable event (one per user)
- Remove relays in `relay` tags from all operations (read and write)
- Applied automatically when user is logged in
- Filtered before establishing connections
#### Kind 10133 (Payment Targets, NIP-A3)
```json
{
"kind": 10133,
"content": "",
"tags": [
["payto", "bitcoin", "bc1qxq66e0t8d7ugdecwnmv58e90tpry23nc84pg9k"],
["payto", "nano", "nano_1dctqbmqxfppo9pswbm6kg9d4s4mbraqn8i4m7ob9gnzz91aurmuho48jx3c"],
["payto", "lightning", "user@wallet.example.com"]
],
"pubkey": "afc93622eb4d79c0fb75e56e0c14553f7214b0a466abeba14cb38968c6755e6a",
"created_at": 1679673265,
"id": "67b48a14fb66c60c8f9070bdeb37afdfcc3d08ad01989460448e4081eddda446",
"sig": "f2cb581a84ed10e4dc84937bd98e27acac71ab057255f6aa8dfa561808c981fe8870f4a03c1e3666784d82a9c802d3704e174371aa13d63e2aeaf24ff5374d9d"
}
```
**REQUIREMENTS**:
- Replaceable event (one per user)
- Extract `payto` tags for payment address display
- Combine with kind 0 `lud16` tags (deduplicated)
- All addresses copyable
- Lightning addresses zappable
- Recognized types: bitcoin, lightning, ethereum, nano, monero, cashme, revolut, venmo
- Unrecognized types displayed generically
#### Kind 30315 (General User Status, NIP-38)
```json
{
"kind": 30315,
"content": "Working on a new project",
"tags": [
["d", "general"],
["client", "aitherboard"]
],
"pubkey": "854043ae8f1f97430ca8c1f1a090bdde6488bd5115c7a45307a2a212750ae4cb",
"created_at": 1699597889,
"id": "a92a316b75e44cfdc19986c634049158d4206fcc0b7b9c7ccbcdabe28beebcd0",
"sig": "1173822c53261f8cffe7efbf43ba4a97a9198b3e402c2a1df130f42a8985a2d0d3430f4de350db184141e45ca844ab4e5364ea80f11d720e36357e1853dba6ca"
}
```
**REQUIREMENTS**:
- Replaceable event (one per user)
- Display status content on profile page
- Fetch from same relays as kind 0 profiles
- Defined in NIP-38
## Relay Management
### Base Relay Lists
| Category | Relays | Purpose |
|----------|--------|---------|
| **Default Relays** | `wss://theforest.nostr1.com`<br>`wss://nostr21.com`<br>`wss://nostr.land`<br>`wss://orly-relay.imwald.eu`<br>`wss://nostr.wine` | Base relays for all operations |
| **Profile Relays** | `wss://relay.damus.io`<br>`wss://aggr.nostr.land`<br>`wss://profiles.nostr1.com` | Additional relays for profile/kind 1 content |
### Relay Selection by Operation
| Operation | Relays Used | Notes |
|-----------|------------|-------|
| **Reading: Threads (kind 11)** | Default + User inbox (if logged in) | Blocked relays removed |
| **Reading: Comments (kind 1111)** | Default + User inbox (if logged in) | Blocked relays removed |
| **Reading: Kind 1 feed** | Default + User inbox (if logged in) | Blocked relays removed |
| **Reading: Kind 1 responses** | Default + User inbox + `wss://aggr.nostr.land` | For threads beneath kind 1 OP events |
| **Reading: Zap receipts (kind 9735)** | Default + User inbox (if logged in) | Blocked relays removed |
| **Reading: Profiles (kind 0)** | Default + Profile + User inbox (if logged in) | Blocked relays removed |
| **Reading: Payment targets (kind 10133)** | Default + Profile + User inbox (if logged in) | Blocked relays removed |
| **Reading: User status (kind 30315)** | Default + Profile + User inbox (if logged in) | Blocked relays removed |
| **Publishing: Threads (kind 11)** | Default + `wss://thecitadel.nostr1.com` + User outbox | Blocked relays removed |
| **Publishing: Comments (kind 1111)** | Default + User outbox + Reply target's inbox | If replying, include target's inbox |
| **Publishing: Kind 1 posts** | Default + User outbox + Reply target's inbox | If replying, include target's inbox |
| **Publishing: Reactions (kind 7)** | Default + User outbox | Blocked relays removed |
| **Publishing: Zap requests (kind 9734)** | Not published to relays | Sent to wallet lnurl callback |
| **Publishing: Zap receipts (kind 9735)** | Relays from zap request | Published by wallet, not client |
### Dynamic Relay Management
| Source | Kind | When Applied | Effect |
|--------|------|--------------|--------|
| **User Inbox Relays** | 10002 + 10432 (marked `read` or unmarked) | When user logs in | Added to reading operations |
| **User Outbox Relays** | 10002 + 10432 (marked `write` or unmarked) | When user logs in | Added to publishing operations |
| **Blocked Relays** | 10006 | When user logs in | Removed from all operations |
**REQUIREMENTS**:
- Relays combined, normalized, and deduplicated automatically
- Relay list updates dynamically when user logs in/out
- Blocked relays filtered before establishing connections
- All operations respect user's kind 10002, kind 10432, and kind 10006 preferences
- Kind 10002 and kind 10432 relays are combined (deduplicated) for each operation
## Authentication
### Authentication Methods
| Method | Implementation | Key Storage |
|--------|----------------|------------|
| **NIP-07** | Browser extension (Alby, nos2x, etc.) | No storage (extension manages) |
| **Nsec** | Direct bech32 nsec or hex private key, stored in the in-browser cache | **REQUIRED**: NIP-49 encrypted in IndexedDB |
| **NIP-46 Bunker** | Remote signer via `bunker://` URI | No local storage |
| **Anonymous** | Generated on the fly when publishing | **REQUIRED**: NIP-49 encrypted in IndexedDB |
### Key Storage & Encryption
**CRITICAL**: NO SECRET KEYS STORED ON THE SERVER
- All keys stored client-side only in IndexedDB
- Server only serves static files
- All key management in browser
- **REQUIRED**: All nsec keys (including anonymous) MUST be encrypted with NIP-49 (password-based) before storage
- Store as ncryptsec format (never plaintext nsec)
- All encrypted keys persist in IndexedDB across sessions
- Users can provide their own anonymous key (must be encrypted)
### Anonymous User Behavior
- Pattern-based avatar
- Handle: `Aitherite{random}`
- Display with this format, whenever a kind 0 cannot be found, for the npub.
## Content Modules
### Event Publishing
**REQUIREMENTS** (applies to all published events):
- **NIP-89 client tag**: Add `["client", "aitherboard"]` tag to all published events when checkbox is selected
- Checkbox "Include client tag." displayed in all publish forms
- Checkbox selected by default
- Only include client tag if checkbox is selected (not deselected)
- Applies to: kind 1, kind 7, kind 11, kind 1111 (all user-published events)
### Thread Module (`src/lib/modules/threads/`)
**Components**:
- `ThreadList.svelte` - Main thread listing
- `ThreadCard.svelte` - Thread preview card (landing page)
- `ThreadView.svelte` - Full thread display
- `CreateThreadForm.svelte` - Thread creation
**REQUIREMENTS**:
- Max 3 `t` tags (topics) per thread
- Threads without topics under "General"
- 30-day timeout (configurable, checkbox to show older)
- Sorting: newest, most active, most upvoted
- Landing page: Title, relative times, profile badge, vote stats, first 250 chars plaintext (no markdown/images)
- **Client badge**: Display client name from `client` tag (NIP-89) on thread cards - subtle, muted color
- Render markdown in full thread view
- **Cover image**: Display image from `image` tag (NIP-23) prominently on thread view
- **Media attachments**: Handle NIP-92 (imeta) and NIP-94 (file) tags (see [Media Attachments](#media-attachments))
- Display all target relays with deselection option
- Publication status modal: Success/failure per relay, error messages (hyperlink URLs), auto-close 30s or manual
- Include NIP-89 client tag (see [Event Publishing](#event-publishing))
### Comment Module (`src/lib/modules/comments/`)
**Components**:
- `Comment.svelte` - Individual comment
- `CommentThread.svelte` - Threaded comment list
- `CommentForm.svelte` - Reply form
**REQUIREMENTS**:
- Flat list (no indentation)
- Gray blurb showing parent preview
- Click blurb to highlight/scroll to parent
- Parse NIP-22 tags: `K`, `E`, `e`, `k`, `p`, `P`
- Render markdown content
- **Client badge**: Display client name from `client` tag (NIP-89) on comment cards - subtle, muted color
- Include NIP-89 client tag (see [Event Publishing](#event-publishing))
### Zap Module (`src/lib/modules/zaps/`)
**Components**:
- `ZapButton.svelte` - Zap action button
- `ZapReceipt.svelte` - Zap receipt display
- `ZapInvoiceModal.svelte` - Invoice display (fallback)
**REQUIREMENTS**:
- **Primary**: Send zap request (kind 9734) via `lightning:` URI to external wallet
- **Fallback**: Fetch invoice from lnurl callback, display QR code + copyable text
- No in-app wallet connections
- Display kind 9735 receipts (≥ configured threshold, default: 1 sat)
- Display with ⚡ emoji
- Zap requests NOT published to relays (sent to wallet)
### Reaction Module (`src/lib/modules/reactions/`)
**Components**:
- `ReactionButtons.svelte` - For threads/comments (kind 11/1111)
- `FeedReactionButtons.svelte` - For kind 1 feed
**REQUIREMENTS**:
- **Kind 11/1111**: Only `+` and `-` allowed
- `+` renders as upvote
- `-` renders as downvote
- **Backward compatibility**: `⬆` (up arrow) counted as upvote, `⬇` (down arrow) counted as downvote
- **Kind 1**: All reactions allowed
- `+` renders as ❤
- Default suggested: `+`
- Other reactions in submenu
- One reaction per user per event
- Clicking same reaction twice deletes it
- Display reaction counts
- Include NIP-89 client tag (see [Event Publishing](#event-publishing))
### Profile Module (`src/lib/modules/profiles/`)
**Components**:
- `ProfilePage.svelte` - User profile display
- `ProfileBadge.svelte` - Clickable profile badge with activity indicator and user status
- `PaymentAddresses.svelte` - Unified payment addresses
**REQUIREMENTS**:
- **Kind 0**: Parse tags (preferred) OR fallback to JSON
- Support multiple: `lud16`, `website`, `nip05`
- Display: name, about, picture, NIP-05, website, lightning addresses
- **Kind 30315 (NIP-38)**: Fetch and display general user status
- Display status content on profile page
- Display status in profile badges on cards (thread cards, comment cards, etc.)
- Replaceable event (one per user)
- **Payment addresses**: Fetch kind 10133, combine with kind 0 `lud16`
- Deduplicate addresses
- All addresses copyable
- Lightning addresses zappable
- Support recognized types: bitcoin, lightning, ethereum, nano, monero, cashme, revolut, venmo
- Display unrecognized types generically
- **Profile badge activity indicator**: See [Content Rendering](#content-rendering) section
- Show kind 1 feed (see relay selection)
- Show kind 1 responses (see relay selection)
- Allow zapping profile owner
- Allow replying to kind 1 with kind 1 or zap
### Kind 1 Feed Module (`src/lib/modules/feed/`)
**Components**:
- `FeedPage.svelte` - Main feed page
- `FeedPost.svelte` - Individual kind 1 post
- `FeedReply.svelte` - Kind 1 reply display
- `ZapReceiptReply.svelte` - Zap receipt as reply (with ⚡)
- `CreateFeedForm.svelte` - Create new kind 1 events
- `ReplyToFeedForm.svelte` - Reply to kind 1 events
**REQUIREMENTS**:
- "View feed" button on landing page opens `/feed`
- Fetch kind 1 events (see relay selection)
- Create new kind 1 events with markdown editor
- Reply to kind 1 with kind 1 (NIP-10 threading)
- Reply to kind 1 with zap (see zap module)
- Reply to kind 1 replies with kind 1
- Reply to zap receipts with zap receipts or kind 1111 comments
- Display all reactions (not just upvotes)
- Default suggested: `+` (rendered as ❤)
- Other reactions in submenu
- **Client badge**: Display client name from `client` tag (NIP-89) on kind 1 post cards - subtle, muted color
- Render all content as markdown
- **Media attachments**: Handle NIP-92 (imeta) and NIP-94 (file) tags (see [Media Attachments](#media-attachments))
- Flat threading display (no indentation)
- Real-time updates
- Infinite scroll
- Include NIP-89 client tag (see [Event Publishing](#event-publishing))
## User Interface
### User Preferences & Readability
**REQUIREMENTS**:
- **Text size control**: Simple control (small/medium/large) to adjust base font size
- Small: ~14px base
- Medium: ~16px base (default)
- Large: ~18px base
- Preference stored in localStorage
- Applied via CSS custom properties or data attributes
- **Line spacing**: Adjustable line height for better readability
- Tight: 1.4
- Normal: 1.6 (default)
- Loose: 1.8
- **Content width**: Maximum content width control
- Narrow: ~600px
- Medium: ~800px (default)
- Wide: ~1200px
- **Theme**: Light/dark mode toggle
- Support system preference detection (`prefers-color-scheme`)
- Manual toggle to override system preference
- Preference stored in localStorage
- Smooth transition between modes
- All preferences persist in localStorage
- Preferences accessible from main navigation/settings menu
- Clear visual feedback when preferences change
### Content Rendering
**REQUIREMENTS**:
- All content supports full markdown rendering
- Videos MUST NOT autoplay (require user interaction)
- Images lazy-loaded for performance
- NIP-21 link parsing:
- `nostr:npub...` → Clickable profile badge (with activity indicator and user status)
- `nostr:nevent/note/naddr...` → Event card (250 char preview) → Event viewer
- **Client badges**: Display client name from `client` tag (NIP-89) on all event cards
- Display on: thread cards, comment cards, kind 1 post cards
- Subtle, muted color styling
- Small badge/chip format
- **Profile badge**:
- **Activity indicator** (custom feature, separate from kind 30315):
- Track last activity from all events in relay pool per pubkey
- **Red**: ≥168 hours (7 days) since last interaction
- **Yellow**: ≥48 hours (2 days) but <168 hours since last interaction
- **Green**: <48 hours since last interaction
- Display indicator on all profile badges
- **User status (kind 30315, NIP-38)**: Display user status text in profile badges on cards
- **Content filtering**: Hide content with sensitive tags or #NSFW hashtag
- Hide events with `content-warning` tag or `sensitive` tag
- Hide events with `#NSFW` hashtag in content or tags
- Content is completely hidden (not displayed with warning)
- **Media handling**: See [Media Attachments](#media-attachments) section
### Media Attachments
**REQUIREMENTS**:
- **Support NIP-92 (imeta tags)**: Media metadata for images, videos, audio
- Format: `["imeta", "url <URL>", "m <mime-type>", "x <width>", "y <height>", ...]`
- Additional fields: `dim` (dimensions for video), `blurhash`, `thumb`, etc.
- **Support NIP-94 (file tags)**: File attachments
- Format: `["file", "<URL>", "<mime-type>", "size <bytes>", ...]`
- **Support NIP-23 image tags**: Cover images for threads/articles
- Format: `["image", "<URL>"]`
- **Media URL normalization and deduplication**:
- Extract media URLs from:
- `imeta` tags (url field)
- `file` tags (first element after "file")
- `image` tags (second element)
- Markdown image syntax in content: `![alt](url)`
- HTML `<img>`, `<video>`, `<audio>` tags in rendered markdown
- Normalize URLs (remove query params, fragments, trailing slashes for comparison)
- Deduplicate: Each unique URL displayed only once per event
- Display order: `image` tag → `imeta` tags → `file` tags → content-extracted URLs
- **Display requirements**:
- Cover images (from `image` tag): Display prominently (e.g., at top of thread/article)
- Images from `imeta`: Display in media gallery
- Videos from `imeta`: Display with play button, no autoplay
- Audio from `imeta`: Display with audio player controls
- Files from `file` tags: Display as download links with file type icons
- Content-extracted media: Display inline with markdown rendering
### Thread Organization
**REQUIREMENTS**:
- Threads sorted by `t` tags (topics)
- Max 3 topics per thread
- Threads without topics under "General"
- Sorting: newest, most active, most upvoted
- 30-day timeout (configurable, checkbox to show older)
### Comment Threading
**REQUIREMENTS**:
- Flat list (no indentation)
- Gray blurb showing parent preview
- Click blurb to highlight/scroll to parent
- Parse NIP-22 tags for threading
### Advanced Features
**REQUIREMENTS**:
- Full-text search (keyboard shortcut `/`)
- Local caching (IndexedDB) for offline access
- Thread statistics (comment counts, zap totals, activity)
- Real-time updates (live indicators)
- Relay health monitoring (connection status, latency, auto-retry)
- User muting: Fetch kind 10000, suppress events from listed pubkeys (implemented in `event-store.ts`)
- Activity tracking: Track last activity timestamp per pubkey from all events in relay pool (implemented in `event-store.ts` and `activity-tracker.ts`)
- User relay blocking: Fetch kind 10006, remove blocked relays (see relay management)
- **Content filtering**: Hide content with sensitive tags or #NSFW hashtag
- Hide events with `content-warning` tag or `sensitive` tag
- Hide events with `#NSFW` hashtag in content or tags
- Content is completely hidden (not displayed with warning)
- Thread locking (prevent replies to old/closed threads)
- Keyboard shortcuts (j/k navigation, r reply, z zap, etc.)
- Thread bumping (active threads rise to top)
## Performance Requirements
**REQUIREMENTS**:
- Aggressive caching: IndexedDB for offline access and faster loads
- Lazy loading: Images and non-critical content on demand
- Code splitting: Route-based to minimize initial bundle
- Connection resilience: Graceful handling of slow/failed relays
- Progressive loading: Show cached content immediately while fetching updates
- Request batching: Batch relay queries to reduce overhead
- Compression: Gzip/Brotli for all assets
- CDN-ready: Static assets optimized for CDN
- Timeout management: Configurable timeouts for relay connections
- Retry logic: Automatic retry with exponential backoff
- Connection pooling: Efficient WebSocket management
- Graceful degradation: Show cached content when relays slow/unavailable
- Loading states: Clear feedback during slow operations
- Offline support: PWA caching for previously loaded content
## Accessibility Requirements
**REQUIREMENTS** (WCAG AA minimum):
### Visual
- High contrast: 4.5:1 for text, 3:1 for UI components
- Readable typography: Clear, legible fonts with appropriate sizing
- **User-adjustable text size**: Small/medium/large controls (see User Preferences)
- **User-adjustable line spacing**: Tight/normal/loose controls
- **User-adjustable content width**: Narrow/medium/wide controls
- Color independence: Information not conveyed by color alone
- Focus indicators: Clear, visible for all interactive elements
- Responsive text: Scales up to 200% without horizontal scrolling (browser zoom)
### Semantic HTML & ARIA
- Semantic HTML5 elements (`<nav>`, `<main>`, `<article>`, etc.)
- All interactive elements have `aria-label` or `aria-labelledby`
- Appropriate ARIA roles for custom components
- ARIA live regions for dynamic content updates
- All form inputs have associated `<label>` or `aria-labelledby`
- All images have descriptive `alt` (decorative use empty `alt=""`)
- Proper heading hierarchy (h1 → h2 → h3, no skipping)
### Keyboard Navigation
- Full keyboard access: All interactive elements accessible via keyboard
- Logical tab order: Follows visual flow and content structure
- Skip links: "Skip to main content" for screen reader users
- Keyboard shortcuts: j/k navigation, r reply, z zap, etc.
- Focus management: Properly managed in modals, dropdowns, dynamic content
- No keyboard traps: Users can navigate away from all elements
### Screen Reader Support
- Descriptive text or ARIA labels for all UI elements
- Status announcements: Loading, errors, success messages
- ARIA landmarks: `navigation`, `main`, `complementary`, `contentinfo`
- Form validation: Errors associated with inputs and announced
- Dynamic content: Changes announced via ARIA live regions
### Additional
- Reduced motion: Respect `prefers-reduced-motion` media query
- High contrast mode: Support Windows High Contrast mode
- Text alternatives: For emoji and icons where meaning important
- Link context: Descriptive text (avoid "click here", "read more")
- Video autoplay: MUST NOT autoplay (require user interaction)
- Error handling: Clear, actionable error messages
- Loading states: Clear indication of loading and progress
- Form instructions: Clear instructions and help text
- **User preferences**: Text size, line spacing, content width controls (see User Preferences section)
### Input Validation
- Validate bech32 strings (NIP-19)
- Validate event signatures
- Sanitize all HTML content (DOMPurify)
### Content Security
- Sanitize markdown output
- Validate NIP-21 URIs
- Escape user content properly
## Configuration
### Environment Variables
| Variable | Type | Default | Validation |
|----------|------|---------|------------|
| `VITE_DEFAULT_RELAYS` | Comma-separated URLs | `wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://orly-relay.imwald.eu` | Empty/invalid falls back to defaults |
| `VITE_ZAP_THRESHOLD` | Integer | `1` | Must be 0 or positive, invalid defaults to 1 |
| `VITE_THREAD_TIMEOUT_DAYS` | Integer | `30` | - |
| `VITE_PWA_ENABLED` | Boolean | `true` | - |
| `PORT` | Integer | `9876` | Must be 1-65535, invalid defaults to 9876 |
## Deployment
### Dockerfile
```dockerfile
# Multi-stage build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_DEFAULT_RELAYS
ARG VITE_ZAP_THRESHOLD
ARG VITE_THREAD_TIMEOUT_DAYS
ARG VITE_PWA_ENABLED
ENV VITE_DEFAULT_RELAYS=${VITE_DEFAULT_RELAYS}
ENV VITE_ZAP_THRESHOLD=${VITE_ZAP_THRESHOLD}
ENV VITE_THREAD_TIMEOUT_DAYS=${VITE_THREAD_TIMEOUT_DAYS}
ENV VITE_PWA_ENABLED=${VITE_PWA_ENABLED}
RUN npm run build
FROM httpd:alpine
COPY --from=builder /app/dist /usr/local/apache2/htdocs/
COPY httpd.conf.template /usr/local/apache2/conf/httpd.conf.template
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ARG PORT=9876
ENV PORT=${PORT}
EXPOSE ${PORT}
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
```
### Apache Configuration (`httpd.conf.template`)
```apache
Listen ${PORT}
ServerName localhost
<Directory "/usr/local/apache2/htdocs">
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
<Location "/healthz">
Header set Content-Type "application/json"
Header set Cache-Control "public, max-age=5"
</Location>
RewriteEngine On
RewriteBase /
RewriteRule ^healthz$ /healthz.json [L]
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
<IfModule mod_headers.c>
Header set Service-Worker-Allowed "/"
</IfModule>
```
### Entrypoint Script (`docker-entrypoint.sh`)
```bash
#!/bin/sh
set -e
PORT=${PORT:-9876}
if ! [ "$PORT" -ge 1 ] 2>/dev/null || ! [ "$PORT" -le 65535 ] 2>/dev/null; then
echo "Warning: Invalid PORT '$PORT', using default 9876"
PORT=9876
fi
envsubst '${PORT}' < /usr/local/apache2/conf/httpd.conf.template > /usr/local/apache2/conf/httpd.conf
exec httpd -D FOREGROUND
```
### Health Check
**REQUIREMENTS**:
- Endpoint: `/healthz`
- Static JSON file generated at build time
- Contains: status, service, version, buildTime, gitCommit, timestamp
- Build script: `scripts/generate-healthz.js`
- Vite plugin integration to generate on build
## Links
- **Nostr Protocol**: https://nostr.com
- **nostr-tools Library**: https://github.com/nbd-wtf/nostr-tools
- **NIP-7D (Kind 11 Threads)**: [Nostr Improvement Proposals]
- **NIP-22 (Kind 1111 Comments)**: [Nostr Improvement Proposals]
- **NIP-57 (Zaps)**: [Nostr Improvement Proposals]
- **NIP-A3 (Kind 10133 Payment Targets)**: [Nostr Improvement Proposals]
- **RFC-8905 (payto: URI scheme)**: https://www.rfc-editor.org/rfc/rfc8905.html

6
src/app.html

@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
<!-- Primary Meta Tags -->
<title>aitherboard - Decentralized Messageboard on Nostr</title>
<meta name="title" content="aitherboard - Decentralized Messageboard on Nostr" />
<meta name="description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment." />
<meta name="description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, and react in a censorship-resistant environment." />
<meta name="keywords" content="nostr, decentralized, messageboard, forum, social media, censorship resistant" />
<meta name="author" content="silberengel@gitcitadel.com" />
@ -19,7 +19,7 @@ @@ -19,7 +19,7 @@
<meta property="og:type" content="website" />
<meta property="og:url" content="https://aitherboard.imwald.eu/" />
<meta property="og:title" content="aitherboard - Decentralized Messageboard on Nostr" />
<meta property="og:description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment." />
<meta property="og:description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, and react in a censorship-resistant environment." />
<meta property="og:image" content="%sveltekit.assets%/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
@ -31,7 +31,7 @@ @@ -31,7 +31,7 @@
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://aitherboard.imwald.eu/" />
<meta name="twitter:title" content="aitherboard - Decentralized Messageboard on Nostr" />
<meta name="twitter:description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment." />
<meta name="twitter:description" content="A decentralized messageboard built on the Nostr protocol. Create threads, comment, and react in a censorship-resistant environment." />
<meta name="twitter:image" content="%sveltekit.assets%/og-image.png" />
<meta name="twitter:image:alt" content="aitherboard - Decentralized Messageboard on Nostr" />

454
src/lib/components/modals/UpdateModal.svelte

@ -0,0 +1,454 @@ @@ -0,0 +1,454 @@
<script lang="ts">
import { prewarmCaches, type PrewarmProgress } from '../../services/cache/cache-prewarmer.js';
import { markVersionUpdated, getAppVersion } from '../../services/version-manager.js';
import { getDB } from '../../services/cache/indexeddb-store.js';
import Icon from '../ui/Icon.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
interface Props {
onComplete: () => void;
}
let { onComplete }: Props = $props();
let updating = $state(false);
let updateComplete = $state(false);
let showPWAUpdatePrompt = $state(false);
let appVersion = $state('0.2.0');
let isPWAInstalled = $state(false);
let progress = $state<PrewarmProgress>({
step: 'Preparing update...',
progress: 0,
total: 7,
current: 0
});
onMount(async () => {
appVersion = await getAppVersion();
// Check if PWA is installed
if (typeof window !== 'undefined') {
const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
const isIOSStandalone = (window.navigator as any).standalone === true;
isPWAInstalled = isStandalone || isIOSStandalone;
}
});
async function handleUpdate() {
updating = true;
progress = {
step: 'Starting update...',
progress: 0,
total: 7,
current: 0
};
try {
// Step 1: Ensure database is up to date
progress = {
step: 'Updating database structure...',
progress: 5,
total: 7,
current: 1
};
await getDB(); // This will trigger IndexedDB upgrade if needed
// Step 2: Prewarm caches
await prewarmCaches((prog) => {
progress = {
...prog,
progress: 10 + Math.round((prog.progress * 80) / 100) // Map 0-100 to 10-90
};
});
// Step 3: Mark version as updated
progress = {
step: 'Finalizing update...',
progress: 95,
total: 7,
current: 7
};
await markVersionUpdated();
// Step 4: Complete
progress = {
step: 'Update complete!',
progress: 100,
total: 7,
current: 7
};
// Small delay to show completion
await new Promise(resolve => setTimeout(resolve, 500));
updateComplete = true;
// If PWA is installed, ask if they want to update it
if (isPWAInstalled) {
showPWAUpdatePrompt = true;
} else {
// Not a PWA, route to about page and complete
onComplete();
goto('/about');
}
} catch (error) {
progress = {
step: `Update error: ${error instanceof Error ? error.message : 'Unknown error'}`,
progress: 0,
total: 7,
current: 0
};
console.error('Update failed:', error);
}
}
async function handlePWAUpdate() {
try {
// Check for service worker update
if ('serviceWorker' in navigator) {
const registration = await navigator.serviceWorker.getRegistration();
if (registration) {
// Check for updates
await registration.update();
// If there's a waiting service worker, skip waiting and reload
if (registration.waiting) {
registration.waiting.postMessage({ type: 'SKIP_WAITING' });
}
// Store route to about page before reload
sessionStorage.setItem('postUpdateRedirect', '/about');
// Reload to apply update
window.location.reload();
return;
}
}
// If no service worker or update mechanism, route to about and reload
sessionStorage.setItem('postUpdateRedirect', '/about');
window.location.reload();
} catch (error) {
console.error('PWA update failed:', error);
// Still reload to ensure update is applied
sessionStorage.setItem('postUpdateRedirect', '/about');
window.location.reload();
}
}
</script>
<div
class="update-modal-overlay"
role="dialog"
aria-modal="true"
aria-labelledby="update-modal-title"
onclick={(e) => e.target === e.currentTarget && !updating && onComplete()}
onkeydown={(e) => {
if (e.key === 'Escape' && !updating) {
onComplete();
}
}}
tabindex="-1"
>
<div class="update-modal">
<div class="update-modal-header">
<h2 id="update-modal-title" class="update-modal-title">New Version Available</h2>
<p class="update-modal-subtitle">
Version {appVersion} is now available. Update to get the latest features and improvements.
</p>
</div>
{#if !updating}
<div class="update-modal-content">
<p class="update-modal-description">
This update will:
</p>
<ul class="update-modal-list">
<li>Update the database structure</li>
<li>Preload common data for faster performance</li>
<li>Prepare caches for quick access</li>
</ul>
</div>
<div class="update-modal-actions">
<button
onclick={handleUpdate}
class="update-button"
aria-label="Update to new version"
>
<Icon name="download" size={16} />
<span>Update Now</span>
</button>
<button
onclick={onComplete}
class="update-button-secondary"
aria-label="Update later"
>
Update Later
</button>
</div>
{:else if !updateComplete}
<div class="update-modal-content">
<div class="update-progress">
<div class="update-progress-bar-container">
<div
class="update-progress-bar"
style="width: {progress.progress}%"
></div>
</div>
<p class="update-progress-text">
{progress.step} ({progress.progress}%)
</p>
<p class="update-progress-step">
Step {progress.current} of {progress.total}
</p>
</div>
</div>
{:else if showPWAUpdatePrompt}
<div class="update-modal-content">
<p class="update-modal-description">
The app update is complete! Since you have aitherboard installed as a PWA, would you like to update it now?
</p>
<p class="update-modal-note">
This will reload the app to apply the PWA update.
</p>
</div>
<div class="update-modal-actions">
<button
onclick={handlePWAUpdate}
class="update-button"
aria-label="Update PWA now"
>
<Icon name="download" size={16} />
<span>Update PWA Now</span>
</button>
<button
onclick={() => {
onComplete();
goto('/about');
}}
class="update-button-secondary"
aria-label="Update PWA later"
>
Update Later
</button>
</div>
{/if}
</div>
</div>
<style>
.update-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 1rem;
}
.update-modal {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
max-width: 500px;
width: 100%;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
:global(.dark) .update-modal {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
}
.update-modal-header {
padding: 1.5rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .update-modal-header {
border-bottom-color: var(--fog-dark-border, #475569);
}
.update-modal-title {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1f2937);
}
:global(.dark) .update-modal-title {
color: var(--fog-dark-text, #f9fafb);
}
.update-modal-subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--fog-text-light, #52667a);
}
:global(.dark) .update-modal-subtitle {
color: var(--fog-dark-text-light, #a8b8d0);
}
.update-modal-content {
padding: 1.5rem;
}
.update-modal-description {
margin: 0 0 1rem 0;
color: var(--fog-text, #1f2937);
}
:global(.dark) .update-modal-description {
color: var(--fog-dark-text, #f9fafb);
}
.update-modal-list {
margin: 0;
padding-left: 1.5rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .update-modal-list {
color: var(--fog-dark-text, #f9fafb);
}
.update-modal-list li {
margin-bottom: 0.5rem;
}
.update-modal-note {
margin: 1rem 0 0 0;
font-size: 0.875rem;
color: var(--fog-text-light, #52667a);
font-style: italic;
}
:global(.dark) .update-modal-note {
color: var(--fog-dark-text-light, #a8b8d0);
}
.update-modal-actions {
padding: 1.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
:global(.dark) .update-modal-actions {
border-top-color: var(--fog-dark-border, #475569);
}
.update-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: opacity 0.2s;
}
.update-button:hover {
opacity: 0.9;
}
:global(.dark) .update-button {
background: var(--fog-dark-accent, #94a3b8);
}
.update-button-secondary {
padding: 0.75rem 1.5rem;
background: var(--fog-highlight, #f3f4f6);
color: var(--fog-text, #1f2937);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
}
.update-button-secondary:hover {
background: var(--fog-accent, #64748b);
color: white;
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .update-button-secondary {
background: var(--fog-dark-highlight, #475569);
color: var(--fog-dark-text, #f9fafb);
border-color: var(--fog-dark-border, #475569);
}
:global(.dark) .update-button-secondary:hover {
background: var(--fog-dark-accent, #94a3b8);
color: white;
border-color: var(--fog-dark-accent, #94a3b8);
}
.update-progress {
display: flex;
flex-direction: column;
gap: 1rem;
}
.update-progress-bar-container {
width: 100%;
height: 8px;
background: var(--fog-highlight, #f3f4f6);
border-radius: 4px;
overflow: hidden;
}
:global(.dark) .update-progress-bar-container {
background: var(--fog-dark-highlight, #475569);
}
.update-progress-bar {
height: 100%;
background: var(--fog-accent, #64748b);
border-radius: 4px;
transition: width 0.3s ease;
}
:global(.dark) .update-progress-bar {
background: var(--fog-dark-accent, #94a3b8);
}
.update-progress-text {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: var(--fog-text, #1f2937);
text-align: center;
}
:global(.dark) .update-progress-text {
color: var(--fog-dark-text, #f9fafb);
}
.update-progress-step {
margin: 0;
font-size: 0.75rem;
color: var(--fog-text-light, #52667a);
text-align: center;
}
:global(.dark) .update-progress-step {
color: var(--fog-dark-text-light, #a8b8d0);
}
</style>

84
src/lib/modules/comments/CommentThread.svelte

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
<script lang="ts">
import Comment from './Comment.svelte';
import CommentForm from './CommentForm.svelte';
import ZapReceiptReply from '../feed/ZapReceiptReply.svelte';
import FeedPost from '../feed/FeedPost.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
@ -23,7 +22,6 @@ @@ -23,7 +22,6 @@
let comments = $state<NostrEvent[]>([]); // kind 1111
let kind1Replies = $state<NostrEvent[]>([]); // kind 1 replies (should only be for kind 1 events, but some apps use them for everything)
let yakBacks = $state<NostrEvent[]>([]); // kind 1244 (voice replies)
let zapReceipts = $state<NostrEvent[]>([]); // kind 9735 (zap receipts)
let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null);
let loadingPromise: Promise<void> | null = null; // Track ongoing load to prevent concurrent calls
@ -57,7 +55,6 @@ @@ -57,7 +55,6 @@
comments = [];
kind1Replies = [];
yakBacks = [];
zapReceipts = [];
nestedSubscriptionActive = false;
isProcessingUpdate = false;
return;
@ -95,7 +92,6 @@ @@ -95,7 +92,6 @@
* For kind 1111: checks both E/e and A/a tags (NIP-22)
* For kind 1: checks e tag (NIP-10)
* For kind 1244: checks E/e and A/a tags (follows NIP-22)
* For kind 9735: checks e tag
*/
function getParentEventId(replyEvent: NostrEvent): string | null {
// For kind 1111, check both uppercase and lowercase E and A tags
@ -134,8 +130,8 @@ @@ -134,8 +130,8 @@
}
}
// For kind 1, 1244, 9735: check e tag
if (replyEvent.kind === KIND.SHORT_TEXT_NOTE || replyEvent.kind === KIND.VOICE_REPLY || replyEvent.kind === KIND.ZAP_RECEIPT) {
// For kind 1, 1244: check e tag
if (replyEvent.kind === KIND.SHORT_TEXT_NOTE || replyEvent.kind === KIND.VOICE_REPLY) {
// For kind 1, check all e tags (NIP-10)
const eTags = replyEvent.tags.filter((t) => t[0] === 'e' && t[1] && t[1] !== replyEvent.id);
// Prefer e tag with 'reply' marker, otherwise use first e tag
@ -199,12 +195,11 @@ @@ -199,12 +195,11 @@
const commentsMap = new Map(comments.map(c => [c.id, c]));
const kind1RepliesMap = new Map(kind1Replies.map(r => [r.id, r]));
const yakBacksMap = new Map(yakBacks.map(y => [y.id, y]));
const zapReceiptsMap = new Map(zapReceipts.map(z => [z.id, z]));
for (const reply of updated) {
// Skip if we already have this reply
if (commentsMap.has(reply.id) || kind1RepliesMap.has(reply.id) ||
yakBacksMap.has(reply.id) || zapReceiptsMap.has(reply.id)) {
yakBacksMap.has(reply.id)) {
continue;
}
@ -215,8 +210,7 @@ @@ -215,8 +210,7 @@
parentId === threadId ||
commentsMap.has(parentId) ||
kind1RepliesMap.has(parentId) ||
yakBacksMap.has(parentId) ||
zapReceiptsMap.has(parentId)
yakBacksMap.has(parentId)
);
if (!isReplyToRoot && !isReplyToExisting) {
@ -233,9 +227,6 @@ @@ -233,9 +227,6 @@
} else if (reply.kind === KIND.VOICE_REPLY) {
yakBacksMap.set(reply.id, reply);
hasNewReplies = true;
} else if (reply.kind === KIND.ZAP_RECEIPT) {
zapReceiptsMap.set(reply.id, reply);
hasNewReplies = true;
}
}
@ -246,7 +237,6 @@ @@ -246,7 +237,6 @@
const allComments = Array.from(commentsMap.values());
const allKind1Replies = Array.from(kind1RepliesMap.values());
const allYakBacks = Array.from(yakBacksMap.values());
const allZapReceipts = Array.from(zapReceiptsMap.values());
// Limit array sizes to prevent memory bloat (keep most recent 500 of each type)
const MAX_COMMENTS = 500;
@ -262,9 +252,6 @@ @@ -262,9 +252,6 @@
yakBacks = allYakBacks
.sort((a, b) => b.created_at - a.created_at)
.slice(0, MAX_REPLIES);
zapReceipts = allZapReceipts
.sort((a, b) => b.created_at - a.created_at)
.slice(0, MAX_REPLIES);
// Notify parent when comments are loaded
if (onCommentsLoaded) {
@ -305,7 +292,7 @@ @@ -305,7 +292,7 @@
const { getRecentCachedEvents } = await import('../../services/cache/event-cache.js');
// Batch all kinds into one call (optimized in getRecentCachedEvents to use single transaction)
const allCached = await getRecentCachedEvents(
[KIND.COMMENT, KIND.SHORT_TEXT_NOTE, KIND.VOICE_REPLY, KIND.ZAP_RECEIPT],
[KIND.COMMENT, KIND.SHORT_TEXT_NOTE, KIND.VOICE_REPLY],
60 * 60 * 1000,
config.feedLimit * 4 // Get more since we're filtering by thread
);
@ -332,8 +319,7 @@ @@ -332,8 +319,7 @@
{ kinds: [KIND.COMMENT], '#a': [threadId], limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#A': [threadId], limit: config.feedLimit },
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': [threadId], limit: config.feedLimit },
{ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: config.feedLimit },
{ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: config.feedLimit }
{ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: config.feedLimit }
];
// Stream fresh data from relays (progressive enhancement)
@ -365,8 +351,7 @@ @@ -365,8 +351,7 @@
const existingIds = new Set([
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id),
...zapReceipts.map(z => z.id)
...yakBacks.map(y => y.id)
]);
const newRootReplies = rootReplies.filter(r => !existingIds.has(r.id));
@ -376,13 +361,11 @@ @@ -376,13 +361,11 @@
const allComments = [...comments, ...newRootReplies.filter(e => e.kind === KIND.COMMENT)];
const allKind1Replies = [...kind1Replies, ...newRootReplies.filter(e => e.kind === KIND.SHORT_TEXT_NOTE)];
const allYakBacks = [...yakBacks, ...newRootReplies.filter(e => e.kind === KIND.VOICE_REPLY)];
const allZapReceipts = [...zapReceipts, ...newRootReplies.filter(e => e.kind === KIND.ZAP_RECEIPT)];
// Deduplicate
comments = Array.from(new Map(allComments.map(c => [c.id, c])).values());
kind1Replies = Array.from(new Map(allKind1Replies.map(r => [r.id, r])).values());
yakBacks = Array.from(new Map(allYakBacks.map(y => [y.id, y])).values());
zapReceipts = Array.from(new Map(allZapReceipts.map(z => [z.id, z])).values());
// Notify parent when comments are loaded
if (onCommentsLoaded) {
@ -438,8 +421,7 @@ @@ -438,8 +421,7 @@
const allReplyIds = new Set([
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id),
...zapReceipts.map(z => z.id)
...yakBacks.map(y => y.id)
]);
if (allReplyIds.size === 0) {
@ -456,8 +438,7 @@ @@ -456,8 +438,7 @@
{ kinds: [KIND.COMMENT], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.COMMENT], '#E': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: config.feedLimit },
{ kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: config.feedLimit }
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: config.feedLimit }
];
if (!isMounted) return; // Don't subscribe if unmounted
@ -499,8 +480,7 @@ @@ -499,8 +480,7 @@
const allReplyIds = Array.from(new Set([
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id),
...zapReceipts.map(z => z.id)
...yakBacks.map(y => y.id)
]));
// Limit the number of reply IDs to prevent massive queries
@ -516,9 +496,7 @@ @@ -516,9 +496,7 @@
// Fetch nested kind 1 replies
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': limitedReplyIds, limit: config.feedLimit },
// Fetch nested yak backs
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: config.feedLimit },
// Fetch nested zap receipts
{ kinds: [KIND.ZAP_RECEIPT], '#e': limitedReplyIds, limit: config.feedLimit }
{ kinds: [KIND.VOICE_REPLY], '#e': limitedReplyIds, limit: config.feedLimit }
];
const fetchPromise = nostrClient.fetchEvents(
@ -543,9 +521,6 @@ @@ -543,9 +521,6 @@
} else if (reply.kind === KIND.VOICE_REPLY && !yakBacks.some(y => y.id === reply.id)) {
yakBacks.push(reply);
hasNewReplies = true;
} else if (reply.kind === KIND.ZAP_RECEIPT && !zapReceipts.some(z => z.id === reply.id)) {
zapReceipts.push(reply);
hasNewReplies = true;
}
}
}
@ -565,17 +540,16 @@ @@ -565,17 +540,16 @@
// Find parent in loaded events
return comments.find(c => c.id === parentId)
|| kind1Replies.find(r => r.id === parentId)
|| yakBacks.find(y => y.id === parentId)
|| zapReceipts.find(z => z.id === parentId);
|| yakBacks.find(y => y.id === parentId);
}
/**
* Sort thread items with proper nesting
*/
function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> {
const eventMap = new Map<string, { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }>();
function sortThreadItems(items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' }>): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' }> {
const eventMap = new Map<string, { event: NostrEvent; type: 'comment' | 'reply' | 'yak' }>();
const replyMap = new Map<string, string[]>(); // parentId -> childIds[]
const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [];
const rootItems: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' }> = [];
const allEventIds = new Set<string>();
// First pass: build event map
@ -611,10 +585,10 @@ @@ -611,10 +585,10 @@
}
// Third pass: recursively collect all items in thread order
const result: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [];
const result: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' }> = [];
const processed = new Set<string>();
function addThread(item: { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }) {
function addThread(item: { event: NostrEvent; type: 'comment' | 'reply' | 'yak' }) {
if (processed.has(item.event.id)) return;
processed.add(item.event.id);
@ -624,7 +598,7 @@ @@ -624,7 +598,7 @@
const replies = replyMap.get(item.event.id) || [];
const replyItems = replies
.map(id => eventMap.get(id))
.filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' } => item !== undefined)
.filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' } => item !== undefined)
.sort((a, b) => a.event.created_at - b.event.created_at);
for (const reply of replyItems) {
@ -636,7 +610,7 @@ @@ -636,7 +610,7 @@
const rootReplies = replyMap.get(threadId) || [];
const rootReplyItems = rootReplies
.map(id => eventMap.get(id))
.filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' } => item !== undefined)
.filter((item): item is { event: NostrEvent; type: 'comment' | 'reply' | 'yak' } => item !== undefined)
.sort((a, b) => a.event.created_at - b.event.created_at);
for (const reply of rootReplyItems) {
@ -652,12 +626,11 @@ @@ -652,12 +626,11 @@
return result;
}
function getThreadItems(): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> {
const items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' | 'zap' }> = [
function getThreadItems(): Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' }> {
const items: Array<{ event: NostrEvent; type: 'comment' | 'reply' | 'yak' }> = [
...comments.map(c => ({ event: c, type: 'comment' as const })),
...kind1Replies.map(r => ({ event: r, type: 'reply' as const })),
...yakBacks.map(y => ({ event: y, type: 'yak' as const })),
...zapReceipts.map(z => ({ event: z, type: 'zap' as const }))
...yakBacks.map(y => ({ event: y, type: 'yak' as const }))
];
return sortThreadItems(items);
}
@ -703,9 +676,6 @@ @@ -703,9 +676,6 @@
// Fetch yak backs (kind 1244) - voice replies
replyFilters.push({ kinds: [KIND.VOICE_REPLY], '#e': [threadId], limit: config.feedLimit });
// Fetch zap receipts (kind 9735)
replyFilters.push({ kinds: [KIND.ZAP_RECEIPT], '#e': [threadId], limit: config.feedLimit });
// Don't use cache when reloading after publishing - we want fresh data
// Use high priority to ensure comments load before background fetches
const allReplies = await nostrClient.fetchEvents(
@ -721,7 +691,6 @@ @@ -721,7 +691,6 @@
comments = rootReplies.filter(e => e.kind === KIND.COMMENT);
kind1Replies = rootReplies.filter(e => e.kind === KIND.SHORT_TEXT_NOTE);
yakBacks = rootReplies.filter(e => e.kind === KIND.VOICE_REPLY);
zapReceipts = rootReplies.filter(e => e.kind === KIND.ZAP_RECEIPT);
// Recursively fetch all nested replies (non-blocking - let it run in background)
fetchNestedReplies().then(() => {
@ -776,7 +745,7 @@ @@ -776,7 +745,7 @@
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading comments...</p>
{:else if comments.length === 0 && kind1Replies.length === 0 && yakBacks.length === 0 && zapReceipts.length === 0}
{:else if comments.length === 0 && kind1Replies.length === 0 && yakBacks.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No replies yet. Be the first to reply!</p>
{:else}
<div class="comments-list">
@ -810,13 +779,6 @@ @@ -810,13 +779,6 @@
preloadedReactions={preloadedReactions?.get(item.event.id)}
/>
</div>
{:else if item.type === 'zap'}
<!-- Zap receipt - render with lightning bolt -->
<ZapReceiptReply
zapReceipt={item.event}
parentEvent={parent}
onReply={handleReply}
/>
{/if}
{/each}
</div>

47
src/lib/modules/discussions/DiscussionCard.svelte

@ -43,8 +43,6 @@ @@ -43,8 +43,6 @@
$effect(() => {
commentCount = providedCommentCount;
});
let zapTotal = $state(0);
let zapCount = $state(0);
let latestResponseTime = $state<number | null>(null);
let loadingStats = $state(true);
let expanded = $state(false);
@ -121,41 +119,13 @@ @@ -121,41 +119,13 @@
commentCount = commentEvents.length;
}
// Load zap receipts (kind 9735) - only if we don't already have zap data
// Use low priority and cache aggressively to avoid repeated fetches
const zapRelays = relayManager.getZapReceiptReadRelays();
const zapReceipts = await nostrClient.fetchEvents(
[{ kinds: [KIND.ZAP_RECEIPT], '#e': [thread.id], limit: config.feedLimit }],
zapRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' }
);
// Calculate zap totals
const threshold = config.zapThreshold;
zapCount = 0;
zapTotal = 0;
for (const receipt of zapReceipts) {
const amountTag = receipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
if (!isNaN(amount) && amount >= threshold) {
zapTotal += amount;
zapCount++;
}
}
}
// Find latest response time (most recent comment or zap)
// Find latest response time (most recent comment)
// Note: Vote counting is handled by DiscussionVoteButtons, so we don't load reactions here
let latestTime = thread.created_at;
if (commentEvents.length > 0) {
const latestComment = commentEvents.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at)[0];
latestTime = Math.max(latestTime, latestComment.created_at);
}
if (zapReceipts.length > 0) {
const latestZap = zapReceipts.sort((a, b) => b.created_at - a.created_at)[0];
latestTime = Math.max(latestTime, latestZap.created_at);
}
latestResponseTime = latestTime > thread.created_at ? latestTime : null;
})(),
timeoutPromise
@ -164,8 +134,6 @@ @@ -164,8 +134,6 @@
console.error('Error loading thread stats:', error);
// On timeout or error, show zero stats instead of loading forever
commentCount = 0;
zapTotal = 0;
zapCount = 0;
latestResponseTime = null;
} finally {
loadingStats = false;
@ -367,13 +335,6 @@ @@ -367,13 +335,6 @@
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{/if}
{#if zapCount > 0}
<span class="font-medium flex items-center gap-1">
<Icon name="zap" size={14} />
<span>{zapTotal.toLocaleString()} sats ({zapCount})</span>
</span>
{/if}
{/if}
{:else}
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
@ -381,12 +342,6 @@ @@ -381,12 +342,6 @@
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{/if}
{#if zapCount > 0}
<span class="font-medium flex items-center gap-1">
<Icon name="zap" size={14} />
<span>{zapTotal.toLocaleString()} sats ({zapCount})</span>
</span>
{/if}
{/if}
{/if}
</div>

47
src/lib/modules/discussions/DiscussionList.svelte

@ -47,7 +47,6 @@ @@ -47,7 +47,6 @@
// Data maps - threads and stats for sorting only (DiscussionCard loads its own stats for display)
let threadsMap = $state<Map<string, NostrEvent>>(new Map()); // threadId -> thread
let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> reactions[] (for sorting only)
let zapReceiptsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> zapReceipts[] (for sorting only)
let commentsMap = $state<Map<string, number>>(new Map()); // threadId -> commentCount (batch-loaded for display)
let voteCountsMap = $state<Map<string, { upvotes: number; downvotes: number }>>(new Map()); // threadId -> {upvotes, downvotes} (calculated from reactionsMap)
let voteCountsReady = $state(false); // Track when vote counts are fully calculated
@ -173,7 +172,6 @@ @@ -173,7 +172,6 @@
const threadRelays = relayManager.getThreadReadRelays();
// Use getProfileReadRelays() for reactions to include defaultRelays + profileRelays + user inbox + localRelays
const reactionRelays = relayManager.getProfileReadRelays();
const zapRelays = relayManager.getZapReceiptReadRelays();
const commentRelays = relayManager.getCommentReadRelays();
// Load from cache first, then stream updates from relays (optimized for slow connections)
@ -229,7 +227,7 @@ @@ -229,7 +227,7 @@
const threadIds = Array.from(threadsMap.keys());
if (threadIds.length > 0) {
// Load reactions and zaps for sorting purposes only
// Load reactions for sorting purposes only
// DiscussionCard components will load their own stats for display
// Fetch all reactions in parallel (for sorting)
@ -330,43 +328,19 @@ @@ -330,43 +328,19 @@
updateVoteCountsMap();
voteCountsReady = true;
// Optimized: Fetch zaps and comments in parallel (they're independent)
// Fetch comments
if (!isMounted) return;
const zapFetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds, limit: config.feedLimit }],
zapRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout }
);
const commentsFetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.COMMENT], '#E': threadIds, '#K': ['11'], limit: config.feedLimit }],
commentRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout, priority: 'low' }
);
// Track both promises for cleanup
activeFetchPromises.add(zapFetchPromise);
// Track promise for cleanup
activeFetchPromises.add(commentsFetchPromise);
try {
const [allZapReceipts, allComments] = await Promise.all([
zapFetchPromise,
commentsFetchPromise
]);
if (!isMounted) return;
// Group zap receipts by thread ID (for sorting)
const newZapReceiptsMap = new Map<string, NostrEvent[]>();
for (const zapReceipt of allZapReceipts) {
const threadId = zapReceipt.tags.find((t: string[]) => t[0] === 'e')?.[1];
if (threadId && threadsMap.has(threadId)) {
if (!newZapReceiptsMap.has(threadId)) {
newZapReceiptsMap.set(threadId, []);
}
newZapReceiptsMap.get(threadId)!.push(zapReceipt);
}
}
zapReceiptsMap = newZapReceiptsMap;
const allComments = await commentsFetchPromise;
if (!isMounted) return;
@ -391,8 +365,7 @@ @@ -391,8 +365,7 @@
}
commentsMap = newCommentsMap;
} finally {
// Clean up both promises
activeFetchPromises.delete(zapFetchPromise);
// Clean up promise
activeFetchPromises.delete(commentsFetchPromise);
}
}
@ -432,23 +405,17 @@ @@ -432,23 +405,17 @@
case 'newest':
return [...events].sort((a, b) => b.created_at - a.created_at);
case 'active':
// Sort by most recent activity (reactions or zaps)
// Sort by most recent activity (reactions)
const activeSorted = events.map((event) => {
const reactions = reactionsMap.get(event.id) || [];
const zapReceipts = zapReceiptsMap.get(event.id) || [];
const lastReactionTime = reactions.length > 0
? Math.max(...reactions.map(r => r.created_at))
: 0;
const lastZapTime = zapReceipts.length > 0
? Math.max(...zapReceipts.map(z => z.created_at))
: 0;
const lastActivity = Math.max(
event.created_at,
lastReactionTime,
lastZapTime
lastReactionTime
);
return { event, lastActivity };

10
src/lib/modules/feed/FeedPost.svelte

@ -64,18 +64,8 @@ @@ -64,18 +64,8 @@
}
});
const unregisterZap = keyboardShortcuts.register('z', (e) => {
if (isFocused && isLoggedIn) {
e.preventDefault();
// Trigger zap - we'll need to expose a method or use an event
// For now, we'll need to check if ZapButton is available
return false;
}
});
return () => {
unregisterShortcuts();
unregisterZap();
};
});

4
src/lib/modules/feed/Reply.svelte

@ -2,8 +2,6 @@ @@ -2,8 +2,6 @@
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import ZapButton from '../zaps/ZapButton.svelte';
import ZapReceipt from '../zaps/ZapReceipt.svelte';
import EventMenu from '../../components/EventMenu.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
@ -120,8 +118,6 @@ @@ -120,8 +118,6 @@
<div class="reply-actions flex items-center gap-4">
<FeedReactionButtons event={reply} />
<ZapButton event={reply} />
<ZapReceipt eventId={reply.id} pubkey={reply.pubkey} />
{#if onReply}
<button
onclick={() => onReply(reply)}

237
src/lib/modules/feed/ZapReceiptReply.svelte

@ -1,237 +0,0 @@ @@ -1,237 +0,0 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import EventMenu from '../../components/EventMenu.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
import Icon from '../../components/ui/Icon.svelte';
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
interface Props {
zapReceipt: NostrEvent; // Kind 9735 zap receipt
parentEvent?: NostrEvent; // The event this zap receipt is for
onReply?: (receipt: NostrEvent) => void;
}
let { zapReceipt, parentEvent, onReply }: Props = $props();
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - zapReceipt.created_at;
const hours = Math.floor(diff / 3600);
const days = Math.floor(diff / 86400);
const minutes = Math.floor(diff / 60);
if (days > 0) return `${days}d ago`;
if (hours > 0) return `${hours}h ago`;
if (minutes > 0) return `${minutes}m ago`;
return 'just now';
}
function getAmount(): number {
const amountTag = zapReceipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return isNaN(amount) ? 0 : amount;
}
return 0;
}
function getZappedPubkey(): string | null {
const pTag = zapReceipt.tags.find((t) => t[0] === 'p');
return pTag?.[1] || null;
}
function getZappedEventId(): string | null {
const eTag = zapReceipt.tags.find((t) => t[0] === 'e');
return eTag?.[1] || null;
}
function getZapperPubkey(): string {
return zapReceipt.pubkey;
}
function isReply(): boolean {
// Check if this zap receipt is a reply (has e tag pointing to another event)
return zapReceipt.tags.some((t) => t[0] === 'e' && t[1] !== zapReceipt.id);
}
$effect(() => {
if (contentElement) {
checkContentHeight();
// Use ResizeObserver to detect when content changes (e.g., images loading)
const observer = new ResizeObserver(() => {
checkContentHeight();
});
observer.observe(contentElement);
return () => observer.disconnect();
}
});
function checkContentHeight() {
if (contentElement) {
// Use requestAnimationFrame to ensure DOM is fully updated
requestAnimationFrame(() => {
if (contentElement) {
needsExpansion = contentElement.scrollHeight > 500;
}
});
}
}
function toggleExpanded() {
expanded = !expanded;
}
</script>
<article class="zap-receipt-reply" id="event-{zapReceipt.id}" data-event-id={zapReceipt.id}>
<div class="card-content" class:expanded bind:this={contentElement}>
{#if parentEvent}
<div class="zap-reply-context">
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light flex items-center gap-1">
<Icon name="zap" size={14} />
<span>Zapping</span>
</span>
<ReplyContext {parentEvent} targetId="event-{parentEvent.id}" />
</div>
{/if}
<div class="zap-header flex items-center gap-2 mb-2">
<ProfileBadge pubkey={getZapperPubkey()} />
<Icon name="zap" size={18} />
<span class="text-sm font-semibold">{getAmount().toLocaleString()} sats</span>
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
<div class="ml-auto flex items-center gap-2">
<IconButton
icon="eye"
label="View"
size={16}
onclick={() => goto(getEventLink(zapReceipt))}
/>
{#if onReply}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => onReply(zapReceipt)}
/>
{/if}
<EventMenu event={zapReceipt} showContentActions={true} />
</div>
</div>
{#if zapReceipt.content}
<div class="zap-content mb-2 text-sm text-fog-text dark:text-fog-dark-text">
{zapReceipt.content}
</div>
{/if}
<div class="zap-actions flex items-center gap-4">
{#if onReply}
<button
onclick={() => onReply(zapReceipt)}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
{/if}
</div>
</div>
{#if needsExpansion}
<button
onclick={toggleExpanded}
class="show-more-button text-sm text-fog-accent dark:text-fog-dark-accent hover:underline mt-2"
>
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(zapReceipt.kind).number}</span>
<span class="kind-description">{getKindInfo(zapReceipt.kind).description}</span>
</div>
</article>
<style>
.zap-receipt-reply {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
border-left: 3px solid #fbbf24; /* Gold/yellow for zaps */
position: relative;
}
:global(.dark) .zap-receipt-reply {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
border-left-color: #fbbf24;
}
.zap-content {
line-height: 1.6;
}
.zap-actions {
padding-top: 0.5rem;
padding-right: 6rem; /* Reserve space for kind badge */
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
}
:global(.dark) .zap-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
}
.card-content.expanded {
max-height: none;
}
.show-more-button {
width: 100%;
text-align: center;
padding: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
}
.kind-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #52667a);
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #a8b8d0);
}
.kind-number {
font-weight: 600;
}
.kind-description {
font-size: 0.625rem;
opacity: 0.8;
}
</style>

12
src/lib/modules/profiles/PaymentAddresses.svelte

@ -77,9 +77,6 @@ @@ -77,9 +77,6 @@
alert('Address copied to clipboard');
}
function isZappable(type: string): boolean {
return type === 'lightning';
}
function getTypeLabel(type: string): string {
return recognizedTypes.includes(type) ? type.charAt(0).toUpperCase() + type.slice(1) : type;
@ -112,15 +109,6 @@ @@ -112,15 +109,6 @@
>
Copy
</button>
{#if isZappable(type)}
<a
href="lightning:{address}"
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline flex items-center gap-1"
>
<Icon name="zap" size={14} />
<span>Zap</span>
</a>
{/if}
</div>
{/each}
</div>

5
src/lib/modules/profiles/ProfilePage.svelte

@ -415,13 +415,12 @@ @@ -415,13 +415,12 @@
const userPostIds = new Set(userPosts.map(p => p.id));
// Fetch notifications: replies, mentions, reactions, zaps with cache-first and streaming
// Fetch notifications: replies, mentions, reactions with cache-first and streaming
const notificationEvents = await nostrClient.fetchEvents(
[
{ kinds: [KIND.SHORT_TEXT_NOTE], '#e': Array.from(userPostIds).slice(0, 50), limit: 100 }, // Replies to user's posts
{ kinds: [KIND.SHORT_TEXT_NOTE], '#p': [pubkey], limit: 100 }, // Mentions
{ kinds: [KIND.REACTION], '#p': [pubkey], limit: 100 }, // Reactions
{ kinds: [KIND.ZAP_RECEIPT], '#p': [pubkey], limit: 100 } // Zaps
{ kinds: [KIND.REACTION], '#p': [pubkey], limit: 100 } // Reactions
],
notificationRelays,
{

184
src/lib/modules/zaps/ZapButton.svelte

@ -1,184 +0,0 @@ @@ -1,184 +0,0 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import ZapInvoiceModal from './ZapInvoiceModal.svelte';
import Icon from '../../components/ui/Icon.svelte';
interface Props {
event: NostrEvent;
pubkey?: string; // Optional: zap a specific pubkey (for profile zaps)
}
let { event, pubkey }: Props = $props();
let showInvoiceModal = $state(false);
let invoice = $state<string | null>(null);
let lnurl = $state<string | null>(null);
let amount = $state<number>(1000); // Default 1000 sats
const targetPubkey = $derived(pubkey || event.pubkey);
async function handleZap() {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to zap');
return;
}
try {
// Fetch profile to get lud16 or lnurl
const config = nostrClient.getConfig();
const profileEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: [targetPubkey], limit: 1 }],
[...config.defaultRelays, ...config.profileRelays],
{ useCache: true }
);
let zapRequest: NostrEvent | null = null;
if (profileEvents.length > 0) {
const profile = profileEvents[0];
// Extract lud16 from profile tags
const lud16Tag = profile.tags.find((t) => t[0] === 'lud16');
const lud16 = lud16Tag?.[1];
if (lud16) {
// Create zap request
const tags: string[][] = [
['relays', ...config.defaultRelays],
['amount', amount.toString()],
['lnurl', lud16],
['p', targetPubkey]
];
if (event.kind !== KIND.METADATA) {
// Zap to an event, not just a profile
tags.push(['e', event.id]);
tags.push(['k', event.kind.toString()]);
}
const currentPubkey = sessionManager.getCurrentPubkey()!;
const zapRequestEvent = {
kind: 9734,
pubkey: currentPubkey,
created_at: Math.floor(Date.now() / 1000),
tags,
content: 'Zap!'
};
const signed = await sessionManager.signEvent(zapRequestEvent);
zapRequest = signed;
// Try to send via lightning: URI (primary method)
const lightningUri = `lightning:${lud16}?amount=${amount}&nostr=${encodeURIComponent(JSON.stringify(zapRequest))}`;
try {
window.location.href = lightningUri;
return; // Success, wallet should handle it
} catch (error) {
console.log('lightning: URI not supported, falling back to lnurl');
}
// Fallback: Fetch invoice from lnurl
await fetchInvoiceFromLnurl(lud16, zapRequest);
} else {
alert('User has no lightning address configured');
}
} else {
alert('Could not fetch user profile');
}
} catch (error) {
console.error('Error creating zap:', error);
alert('Error creating zap');
}
}
async function fetchInvoiceFromLnurl(lud16: string, zapRequest: NostrEvent) {
try {
// Parse lud16 (format: user@domain.com)
const [username, domain] = lud16.split('@');
const callbackUrl = `https://${domain}/.well-known/lnurlp/${username}`;
// Fetch lnurlp
const response = await fetch(callbackUrl);
const data = await response.json();
if (data.callback) {
// Create zap request JSON
const zapRequestJson = JSON.stringify(zapRequest);
// Call callback with zap request
const callbackUrlWithParams = `${data.callback}?amount=${amount * 1000}&nostr=${encodeURIComponent(zapRequestJson)}`;
const invoiceResponse = await fetch(callbackUrlWithParams);
const invoiceData = await invoiceResponse.json();
if (invoiceData.pr) {
invoice = invoiceData.pr;
lnurl = lud16;
showInvoiceModal = true;
} else {
alert('Failed to get invoice from wallet');
}
}
} catch (error) {
console.error('Error fetching invoice:', error);
alert('Error fetching invoice from wallet');
}
}
</script>
<button
onclick={handleZap}
class="zap-button"
data-zap-button
title="Zap (z)"
aria-label="Zap"
>
<Icon name="zap" size={16} />
<span>Zap</span>
</button>
{#if showInvoiceModal && invoice}
<ZapInvoiceModal
{invoice}
{lnurl}
{amount}
onClose={() => {
showInvoiceModal = false;
invoice = null;
}}
/>
{/if}
<style>
.zap-button {
padding: 0.25rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
line-height: 1.5;
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
:global(.dark) .zap-button {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.zap-button:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .zap-button:hover {
background: var(--fog-dark-highlight, #374151);
}
</style>

174
src/lib/modules/zaps/ZapInvoiceModal.svelte

@ -1,174 +0,0 @@ @@ -1,174 +0,0 @@
<script lang="ts">
interface Props {
invoice: string;
lnurl: string | null;
amount: number;
onClose: () => void;
}
let { invoice, lnurl, amount, onClose }: Props = $props();
function copyInvoice() {
navigator.clipboard.writeText(invoice);
alert('Invoice copied to clipboard');
}
// Generate QR code (simplified - in production, use a QR library)
let qrCodeUrl = $state<string>('');
$effect(() => {
// Use a QR code API service
qrCodeUrl = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(invoice)}`;
});
</script>
<div
class="modal-overlay"
onclick={onClose}
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="zap-invoice-title"
tabindex="-1"
>
<div class="modal-content">
<div class="modal-header">
<h2 id="zap-invoice-title">Pay Invoice</h2>
<button onclick={onClose} class="close-button" aria-label="Close invoice modal">×</button>
</div>
<div class="modal-body">
<p class="mb-4">Scan this QR code with your lightning wallet or copy the invoice:</p>
{#if qrCodeUrl}
<div class="qr-code-container mb-4">
<img src={qrCodeUrl} alt="Lightning invoice QR code" />
</div>
{/if}
<div class="invoice-container mb-4">
<label for="invoice-textarea" class="block text-sm font-semibold mb-2">Invoice:</label>
<textarea
id="invoice-textarea"
readonly
value={invoice}
class="w-full p-2 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text text-xs font-mono"
rows="4"
aria-label="Lightning invoice"
></textarea>
<button
onclick={copyInvoice}
class="mt-2 px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90"
>
Copy Invoice
</button>
</div>
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">
Amount: {amount} sats
</p>
</div>
<div class="modal-footer">
<button onclick={onClose}>Close</button>
</div>
</div>
</div>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 8px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: auto;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
:global(.dark) .modal-content {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .modal-header {
border-bottom-color: var(--fog-dark-border, #374151);
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .close-button {
color: var(--fog-dark-text, #f9fafb);
}
.modal-body {
padding: 1rem;
}
.qr-code-container {
display: flex;
justify-content: center;
}
.qr-code-container img {
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 4px;
padding: 1rem;
background: white;
}
.invoice-container textarea {
resize: none;
}
.modal-footer {
padding: 1rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
text-align: right;
}
:global(.dark) .modal-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.modal-footer button {
padding: 0.5rem 1rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>

132
src/lib/modules/zaps/ZapReceipt.svelte

@ -1,132 +0,0 @@ @@ -1,132 +0,0 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import Icon from '../../components/ui/Icon.svelte';
interface Props {
eventId: string; // The event that was zapped
pubkey?: string; // Optional: filter by zapped pubkey
}
let { eventId, pubkey }: Props = $props();
let zapReceipts = $state<NostrEvent[]>([]);
let totalAmount = $state<number>(0);
let loading = $state(true);
onMount(async () => {
await nostrClient.initialize();
loadZapReceipts();
});
async function loadZapReceipts() {
loading = true;
const timeout = 30000; // 30 seconds
try {
const config = nostrClient.getConfig();
const threshold = config.zapThreshold;
// Create a timeout promise
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Zap loading timeout')), timeout);
});
// Fetch zap receipts (kind 9735) for this event
const filters: any[] = [
{
kinds: [KIND.ZAP_RECEIPT],
'#e': [eventId]
}
];
if (pubkey) {
filters[0]['#p'] = [pubkey];
}
// Race between loading and timeout
const receipts = await Promise.race([
nostrClient.fetchEvents(
filters,
[...config.defaultRelays],
{ useCache: true, cacheResults: true, onUpdate: (updated) => {
processReceipts(updated);
}}
),
timeoutPromise
]);
processReceipts(receipts);
} catch (error) {
console.error('Error loading zap receipts:', error);
// On timeout or error, show empty state
zapReceipts = [];
totalAmount = 0;
} finally {
loading = false;
}
}
function processReceipts(receipts: NostrEvent[]) {
const config = nostrClient.getConfig();
const threshold = config.zapThreshold;
// Filter by threshold and extract amounts
const validReceipts = receipts.filter((receipt) => {
const amountTag = receipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return !isNaN(amount) && amount >= threshold;
}
return false;
});
zapReceipts = validReceipts;
// Calculate total
totalAmount = validReceipts.reduce((sum, receipt) => {
const amountTag = receipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return sum + (isNaN(amount) ? 0 : amount);
}
return sum;
}, 0);
}
function getAmount(receipt: NostrEvent): number {
const amountTag = receipt.tags.find((t) => t[0] === 'amount');
if (amountTag && amountTag[1]) {
const amount = parseInt(amountTag[1], 10);
return isNaN(amount) ? 0 : amount;
}
return 0;
}
</script>
<div class="zap-receipts">
{#if loading}
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">Loading zaps...</span>
{:else if zapReceipts.length > 0}
<div class="flex items-center gap-2">
<Icon name="zap" size={18} />
<span class="text-sm font-semibold">{totalAmount.toLocaleString()} sats</span>
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">
({zapReceipts.length} {zapReceipts.length === 1 ? 'zap' : 'zaps'})
</span>
</div>
{:else}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">No zaps</span>
{/if}
</div>
<style>
.zap-receipts {
display: flex;
align-items: center;
display: inline-flex;
align-items: center;
}
</style>

173
src/lib/services/cache/cache-prewarmer.ts vendored

@ -0,0 +1,173 @@ @@ -0,0 +1,173 @@
/**
* Cache prewarming service
* Preloads common data to make the app work quickly after update
*/
import { nostrClient } from '../nostr/nostr-client.js';
import { relayManager } from '../nostr/relay-manager.js';
import { config } from '../nostr/config.js';
import { KIND, getFeedKinds } from '../../types/kind-lookup.js';
import { sessionManager } from '../auth/session-manager.js';
import { getDB } from './indexeddb-store.js';
export interface PrewarmProgress {
step: string;
progress: number; // 0-100
total: number;
current: number;
}
type ProgressCallback = (progress: PrewarmProgress) => void;
/**
* Prewarm all caches to make app work quickly
*/
export async function prewarmCaches(
onProgress?: ProgressCallback
): Promise<void> {
const steps = [
{ name: 'Initializing database...', weight: 5 },
{ name: 'Loading user profile...', weight: 10 },
{ name: 'Loading user lists...', weight: 15 },
{ name: 'Loading recent events...', weight: 20 },
{ name: 'Loading profiles...', weight: 15 },
{ name: 'Loading RSS feeds...', weight: 10 },
{ name: 'Finalizing...', weight: 5 }
];
let totalProgress = 0;
const totalWeight = steps.reduce((sum, s) => sum + s.weight, 0);
const updateProgress = (stepIndex: number, stepProgress: number = 100) => {
// Calculate progress up to current step
let progress = 0;
for (let i = 0; i < stepIndex; i++) {
progress += steps[i].weight;
}
// Add progress for current step
progress += (steps[stepIndex].weight * stepProgress) / 100;
totalProgress = Math.round((progress / totalWeight) * 100);
if (onProgress) {
onProgress({
step: steps[stepIndex].name,
progress: totalProgress,
total: steps.length,
current: stepIndex + 1
});
}
};
try {
// Step 1: Initialize database
updateProgress(0, 0);
await getDB();
updateProgress(0, 100);
// Step 2: Load user profile if logged in
updateProgress(1, 0);
if (sessionManager.isLoggedIn()) {
const pubkey = sessionManager.getSession()?.pubkey;
if (pubkey) {
const profileRelays = relayManager.getProfileReadRelays();
await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: [pubkey], limit: 1 }],
profileRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout }
);
}
}
updateProgress(1, 100);
// Step 3: Load user lists (contacts and follow sets)
updateProgress(2, 0);
if (sessionManager.isLoggedIn()) {
const pubkey = sessionManager.getSession()?.pubkey;
if (pubkey) {
const relays = [
...config.defaultRelays,
...config.profileRelays,
...relayManager.getFeedReadRelays()
];
const uniqueRelays = [...new Set(relays)];
await Promise.all([
nostrClient.fetchEvents(
[{ kinds: [KIND.CONTACTS], authors: [pubkey], limit: 1 }],
uniqueRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout }
),
nostrClient.fetchEvents(
[{ kinds: [KIND.FOLLOW_SET], authors: [pubkey] }],
uniqueRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout }
)
]);
}
}
updateProgress(2, 100);
// Step 4: Load recent feed events
updateProgress(3, 0);
const feedKinds = getFeedKinds();
const feedRelays = relayManager.getFeedReadRelays();
await nostrClient.fetchEvents(
[{ kinds: feedKinds.slice(0, 10), limit: 50 }], // Load first 10 feed kinds, limit 50 events
feedRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout }
);
updateProgress(3, 100);
// Step 5: Load profiles for recent events (if logged in)
updateProgress(4, 0);
if (sessionManager.isLoggedIn()) {
// Get some recent events to extract pubkeys
const { getRecentCachedEvents } = await import('./event-cache.js');
const recentEvents = await getRecentCachedEvents(feedKinds, 24 * 60 * 60 * 1000, 20);
const pubkeys = [...new Set(recentEvents.map(e => e.pubkey))].slice(0, 20);
if (pubkeys.length > 0) {
const profileRelays = relayManager.getProfileReadRelays();
await nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: pubkeys, limit: 1 }],
profileRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout }
);
}
}
updateProgress(4, 100);
// Step 6: Load RSS feeds (if logged in)
updateProgress(5, 0);
if (sessionManager.isLoggedIn()) {
const pubkey = sessionManager.getSession()?.pubkey;
if (pubkey) {
const relays = [
...config.defaultRelays,
...config.profileRelays,
...relayManager.getFeedReadRelays()
];
const uniqueRelays = [...new Set(relays)];
await nostrClient.fetchEvents(
[{ kinds: [KIND.RSS_FEED], authors: [pubkey], limit: 10 }],
uniqueRelays,
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout }
);
}
}
updateProgress(5, 100);
// Step 7: Finalize
updateProgress(6, 0);
// Small delay to ensure all operations complete
await new Promise(resolve => setTimeout(resolve, 100));
updateProgress(6, 100);
} catch (error) {
// Prewarming errors are non-critical - app can still work
// Just log and continue
console.warn('Cache prewarming encountered errors (non-critical):', error);
}
}

1
src/lib/services/keyboard-shortcuts.ts

@ -22,7 +22,6 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [ @@ -22,7 +22,6 @@ export const KEYBOARD_SHORTCUTS: KeyboardShortcut[] = [
// Actions
{ key: 'r', description: 'Reply to current post/thread', category: 'actions' },
{ key: 'z', description: 'Zap current post/thread', category: 'actions' },
{ key: 'u', description: 'Upvote current post/thread', category: 'actions' },
{ key: 'd', description: 'Downvote current post/thread', category: 'actions' },
{ key: 'b', description: 'Bookmark current post/thread', category: 'actions' },

4
src/lib/services/nostr/config.ts

@ -32,8 +32,6 @@ const GIF_RELAYS = [ @@ -32,8 +32,6 @@ const GIF_RELAYS = [
const RELAY_TIMEOUT = 10000;
const ZAP_THRESHOLD = 1;
// Fetch limits
const FEED_LIMIT = 100; // Standard limit for feed events per kind
const SINGLE_EVENT_LIMIT = 1; // For fetching a single event
@ -52,7 +50,6 @@ const SINGLE_RELAY_TIMEOUT = 15000; // Timeout for single relay operations @@ -52,7 +50,6 @@ const SINGLE_RELAY_TIMEOUT = 15000; // Timeout for single relay operations
export interface NostrConfig {
defaultRelays: string[];
profileRelays: string[];
zapThreshold: number;
threadTimeoutDays: number;
threadPublishRelays: string[];
relayTimeout: number;
@ -92,7 +89,6 @@ export function getConfig(): NostrConfig { @@ -92,7 +89,6 @@ export function getConfig(): NostrConfig {
return {
defaultRelays: parseRelays(import.meta.env.VITE_DEFAULT_RELAYS, DEFAULT_RELAYS),
profileRelays: PROFILE_RELAYS,
zapThreshold: parseIntEnv(import.meta.env.VITE_ZAP_THRESHOLD, 1, 0),
threadTimeoutDays: parseIntEnv(import.meta.env.VITE_THREAD_TIMEOUT_DAYS, 30),
threadPublishRelays: THREAD_PUBLISH_RELAYS,
relayTimeout: RELAY_TIMEOUT,

23
src/lib/services/nostr/nostr-client.ts

@ -404,16 +404,7 @@ class NostrClient { @@ -404,16 +404,7 @@ class NostrClient {
return this.relays.get(url) || null;
}
private shouldFilterZapReceipt(event: NostrEvent): boolean {
if (event.kind !== KIND.ZAP_RECEIPT) return false;
const amountTag = event.tags.find((t) => t[0] === 'amount');
if (!amountTag || !amountTag[1]) return true;
const amount = parseInt(amountTag[1], 10);
return isNaN(amount) || amount < config.zapThreshold;
}
private addToCache(event: NostrEvent): void {
if (this.shouldFilterZapReceipt(event)) return;
cacheEvent(event).catch(() => {
// Silently fail
});
@ -849,7 +840,6 @@ class NostrClient { @@ -849,7 +840,6 @@ class NostrClient {
onevent: (event: NostrEvent) => {
try {
if (!client.relays.has(url)) return;
if (client.shouldFilterZapReceipt(event)) return;
client.addToCache(event);
onEvent(event, url);
} catch (err) {
@ -1077,7 +1067,6 @@ class NostrClient { @@ -1077,7 +1067,6 @@ class NostrClient {
try {
if (!client.relays.has(relayUrl) || resolved) return;
if (shouldHideEvent(event)) return;
if (client.shouldFilterZapReceipt(event)) return;
// Deduplicate events
if (seenEventIds.has(event.id)) return;
@ -1127,15 +1116,14 @@ class NostrClient { @@ -1127,15 +1116,14 @@ class NostrClient {
if ((onUpdate || onUpdateWithRelay) && !resolved) {
try {
const filtered = filterEvents([event]);
const zapFiltered = filtered.filter(e => !client.shouldFilterZapReceipt(e));
if (zapFiltered.length > 0) {
if (filtered.length > 0) {
// Call onUpdate if provided (backward compatible)
if (onUpdate) {
onUpdate(zapFiltered);
onUpdate(filtered);
}
// Call onUpdateWithRelay if provided (includes relay info)
if (onUpdateWithRelay) {
onUpdateWithRelay(zapFiltered.map(e => ({ event: e, relay: relayUrl })));
onUpdateWithRelay(filtered.map(e => ({ event: e, relay: relayUrl })));
}
}
} catch (error) {
@ -1894,13 +1882,12 @@ class NostrClient { @@ -1894,13 +1882,12 @@ class NostrClient {
const eventArray = Array.from(events.values());
const filtered = filterEvents(eventArray);
const zapFiltered = filtered.filter(event => !this.shouldFilterZapReceipt(event));
// Clear events Map after processing to free memory
events.clear();
if (options.cacheResults && zapFiltered.length > 0) {
cacheEvents(zapFiltered).catch(() => {
if (options.cacheResults && filtered.length > 0) {
cacheEvents(filtered).catch(() => {
// Silently fail
});
}

6
src/lib/services/nostr/relay-manager.ts

@ -223,12 +223,6 @@ class RelayManager { @@ -223,12 +223,6 @@ class RelayManager {
]);
}
/**
* Get relays for reading zap receipts (kind 9735)
*/
getZapReceiptReadRelays(): string[] {
return this.getReadRelays(config.defaultRelays);
}
/**
* Get relays for reading profiles (kind 0)

72
src/lib/services/version-manager.ts

@ -0,0 +1,72 @@ @@ -0,0 +1,72 @@
/**
* Version management and update system
*/
const VERSION_STORAGE_KEY = 'aitherboard_version';
let cachedVersion: string | null = null;
/**
* Get the current app version
* Reads from healthz.json generated at build time, or falls back to package.json version
*/
export async function getAppVersion(): Promise<string> {
if (cachedVersion) return cachedVersion;
try {
// Try to read from healthz.json (generated at build time)
const response = await fetch('/healthz.json');
if (response.ok) {
const data = await response.json();
cachedVersion = (data.version || '0.2.0') as string;
return cachedVersion;
}
} catch (error) {
// Failed to fetch healthz.json, use fallback
}
// Fallback version (should match package.json)
cachedVersion = '0.2.0';
return cachedVersion;
}
/**
* Get the current app version synchronously (uses cached value or fallback)
*/
export function getAppVersionSync(): string {
return cachedVersion || '0.2.0';
}
/**
* Get the stored version (last version user had)
*/
export function getStoredVersion(): string | null {
if (typeof window === 'undefined') return null;
return localStorage.getItem(VERSION_STORAGE_KEY);
}
/**
* Check if a new version is available
*/
export async function isNewVersionAvailable(): Promise<boolean> {
const stored = getStoredVersion();
if (!stored) return true; // First time user
const current = await getAppVersion();
return stored !== current;
}
/**
* Store the current version
*/
export async function storeVersion(version?: string): Promise<void> {
if (typeof window === 'undefined') return;
const versionToStore = version || await getAppVersion();
localStorage.setItem(VERSION_STORAGE_KEY, versionToStore);
}
/**
* Mark version as updated (user has seen and updated to this version)
*/
export async function markVersionUpdated(): Promise<void> {
const current = await getAppVersion();
await storeVersion(current);
}

6
src/lib/types/kind-lookup.ts

@ -82,8 +82,6 @@ export const KIND = { @@ -82,8 +82,6 @@ export const KIND = {
PAYMENT_ADDRESSES: 10133,
LABEL: 1985,
REPORT: 1984,
ZAP_REQUEST: 9734, // NIP-57 Zap Request (not published to relays)
ZAP_RECEIPT: 9735,
RELAY_LIST: 10002,
BLOCKED_RELAYS: 10006,
FAVORITE_RELAYS: 10012,
@ -145,10 +143,6 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -145,10 +143,6 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
[KIND.LABEL]: { number: KIND.LABEL, description: 'Label', showInFeed: false, isSecondaryKind: false },
[KIND.REPORT]: { number: KIND.REPORT, description: 'Report', showInFeed: false, isSecondaryKind: false },
// Zaps
[KIND.ZAP_REQUEST]: { number: KIND.ZAP_REQUEST, description: 'Zap Request', showInFeed: false, isSecondaryKind: false },
[KIND.ZAP_RECEIPT]: { number: KIND.ZAP_RECEIPT, description: 'Zap Receipt', showInFeed: false, isSecondaryKind: true },
// Relay lists
[KIND.RELAY_LIST]: { number: KIND.RELAY_LIST, description: 'Relay List Metadata', showInFeed: false, isSecondaryKind: false },
[KIND.BLOCKED_RELAYS]: { number: KIND.BLOCKED_RELAYS, description: 'Blocked Relays', showInFeed: false, isSecondaryKind: false },

38
src/routes/+layout.svelte

@ -5,12 +5,15 @@ @@ -5,12 +5,15 @@
import { browser } from '$app/environment';
import { page } from '$app/stores';
import type { Snippet } from 'svelte';
import { isNewVersionAvailable } from '../lib/services/version-manager.js';
import UpdateModal from '../lib/components/modals/UpdateModal.svelte';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
let showUpdateModal = $state(false);
// Initialize theme and preferences from localStorage immediately (before any components render)
if (browser) {
@ -55,6 +58,33 @@ @@ -55,6 +58,33 @@
await sessionManager.restoreSession();
}
if (browser) {
// Check for post-update redirect
const postUpdateRedirect = sessionStorage.getItem('postUpdateRedirect');
if (postUpdateRedirect) {
sessionStorage.removeItem('postUpdateRedirect');
const { goto } = await import('$app/navigation');
goto(postUpdateRedirect);
return;
}
// Check for new version
const { isNewVersionAvailable, getStoredVersion } = await import('../lib/services/version-manager.js');
const storedVersion = getStoredVersion();
// If no stored version, this is a first-time user - route to about page
if (!storedVersion) {
const { goto } = await import('$app/navigation');
const currentPath = window.location.pathname;
// Only redirect if not already on about page or login page
if (currentPath !== '/about' && currentPath !== '/login') {
goto('/about');
}
} else if (await isNewVersionAvailable()) {
showUpdateModal = true;
}
}
// Start archive scheduler (background compression of old events)
const { startArchiveScheduler } = await import('../lib/services/cache/archive-scheduler.js');
startArchiveScheduler();
@ -63,6 +93,10 @@ @@ -63,6 +93,10 @@
}
});
function handleUpdateComplete() {
showUpdateModal = false;
}
// Track current route when user is logged in (for redirect after logout)
$effect(() => {
if (sessionManager.isLoggedIn() && browser) {
@ -76,3 +110,7 @@ @@ -76,3 +110,7 @@
</script>
{@render children()}
{#if showUpdateModal}
<UpdateModal onComplete={handleUpdateComplete} />
{/if}

329
src/routes/about/+page.svelte

@ -0,0 +1,329 @@ @@ -0,0 +1,329 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import Icon from '../../lib/components/ui/Icon.svelte';
import { getAppVersion } from '../../lib/services/version-manager.js';
let appVersion = $state('0.2.0');
onMount(async () => {
appVersion = await getAppVersion();
});
function handleBack() {
if (typeof window !== 'undefined' && window.history.length > 1) {
window.history.back();
} else {
goto('/');
}
}
// Changelog for current version
const changelog: Record<string, string[]> = {
'0.2.0': [
'Version management and update system',
'About page with product information',
'Improved user experience with automatic routing to About page on first visit and after updates',
'Enhanced settings page with About button',
'Better version tracking and display'
]
};
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="about-page">
<div class="about-header">
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/About</h1>
<button
onclick={handleBack}
class="back-button flex items-center gap-2"
aria-label="Go back to previous page"
>
<Icon name="arrow-left" size={16} />
<span>Back</span>
</button>
</div>
<div class="about-content space-y-8">
<!-- Product Information -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">About aitherboard</h2>
<div class="section-content">
<p>
<strong>aitherboard</strong> is a decentralized messageboard built on the <a href="https://nostr.com" target="_blank" rel="noopener noreferrer" class="link">Nostr protocol</a>.
It provides a modern, accessible interface for participating in the decentralized social web.
</p>
<p>
Built with <strong>Svelte 5</strong> and <strong>TypeScript</strong>, aitherboard offers a clean, minimal design
inspired by traditional messageboards while leveraging the power of decentralized protocols.
</p>
<p>
All data is stored client-side in your browser using IndexedDB, ensuring privacy and offline access.
The application works as a Progressive Web App (PWA), allowing you to install it on your device
for a native-like experience.
The developer has a strong interest in accessibility and usability, and aitherboard is designed to be accessible to all users, including those who want to stay anonymous.
</p>
</div>
</section>
<!-- Features -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">Key Features</h2>
<div class="section-content">
<ul class="features-list">
<li><strong>Threads & Discussions</strong> - Create and participate in threaded conversations</li>
<li><strong>Feed</strong> - Twitter-like feed posts (kind 1) with real-time updates</li>
<li><strong>Comments</strong> - Flat-threaded comments on threads and posts</li>
<li><strong>Reactions</strong> - Upvote, downvote, and react to content. Use custom GIFs and emojis.</li>
<li><strong>Profiles</strong> - View and manage user profiles with payment addresses</li>
<li><strong>Offline Support</strong> - Full offline access with IndexedDB caching and archiving.</li>
<li><strong>PWA</strong> - Install as a Progressive Web App</li>
<li><strong>Search</strong> - Full-text search across content</li>
<li><strong>Keyboard Shortcuts</strong> - Navigate efficiently with keyboard shortcuts</li>
<li><strong>Full Search Utility</strong> - Search for content using advanced filters and parameters</li>
<li><strong>Advanced Markup Editor</strong> - Edit content using advanced markup and formatting options: Markdown and AsciiDoc supported</li>
<li><strong>Follows supported</strong> - Use any list, including your contact list, to create a feed.</li>
<li><strong>Repo Viewer</strong> - View and navigate git repositories directly in the app. Report issues to developers, over Nostr. See the project's documenation page.</li>
<li><strong>Relay Feeds</strong> - See what is happening on the relays you are connected to. Explore new relays.</li>
<li><strong>Universal Write</strong> - Create events for any kind, with hints for which information is required or optionalfor each kind.</li>
<li><strong>Everything Marked-up</strong> - Every event displayed supports Markdown or AsciiDoc formatting.</li>
<li><strong>Universal Read</strong> - View any event, with all its metadata and content. Supports e-books and other publications!</li>
<li><strong>Hashtag Browsing</strong> - Browse events by hashtags, with real-time updates and search capabilities.</li>
</ul>
</div>
</section>
<!-- Version Information -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">Version Information</h2>
<div class="section-content">
<p class="version-info">
<strong>Current Version:</strong> <span class="version-badge">{appVersion}</span>
</p>
</div>
</section>
<!-- Changelog -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">What's New in Version {appVersion}</h2>
<div class="section-content">
{#if changelog[appVersion]}
<ul class="changelog-list">
{#each changelog[appVersion] as change}
<li>{change}</li>
{/each}
</ul>
{:else}
<p>No changelog available for this version.</p>
{/if}
</div>
</section>
<!-- Links -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">Links</h2>
<div class="section-content">
<ul class="links-list">
<li>
<a href="https://gitcitadel.com/" target="_blank" rel="noopener noreferrer" class="link">
<Icon name="link" size={16} />
<span>Homepage</span>
</a>
</li>
<li>
<a href="https://git.imwald.eu/silberengel/aitherboard.git" target="_blank" rel="noopener noreferrer" class="link">
<Icon name="code" size={16} />
<span>Repository</span>
</a>
</li>
<li>
<a href="https://github.com/nostr-protocol/nips" target="_blank" rel="noopener noreferrer" class="link">
<Icon name="link" size={16} />
<span>Nostr spec repo: Nostr Implementation Proposals (NIPS)</span>
</a>
</li>
</ul>
</div>
</section>
<!-- Author -->
<section class="about-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-6 rounded">
<h2 class="section-title">Author</h2>
<div class="section-content">
<p>
Created by <a href="https://aitherboard.imwald.eu/profile/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z" target="_blank" rel="noopener noreferrer" class="link">silberengel@gitcitadel.com</a>
</p>
</div>
</section>
</div>
</div>
</main>
<style>
.about-page {
max-width: var(--content-width);
margin: 0 auto;
padding: 0;
}
.about-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
}
.back-button {
padding: 0.5rem 1rem;
border: 1px solid var(--fog-border, #cbd5e1);
border-radius: 4px;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1e293b);
cursor: pointer;
transition: all 0.2s;
font-size: 0.875em;
white-space: nowrap;
}
.back-button:hover {
background: var(--fog-highlight, #f1f5f9);
border-color: var(--fog-accent, #94a3b8);
}
:global(.dark) .back-button {
background: var(--fog-dark-post, #334155);
border-color: var(--fog-dark-border, #475569);
color: var(--fog-dark-text, #f1f5f9);
}
:global(.dark) .back-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #64748b);
}
.about-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.about-section {
margin-bottom: 0;
}
.section-title {
margin: 0 0 1rem 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--fog-text, #1e293b);
}
:global(.dark) .section-title {
color: var(--fog-dark-text, #f1f5f9);
}
.section-content {
color: var(--fog-text, #475569);
line-height: 1.6;
}
:global(.dark) .section-content {
color: var(--fog-dark-text, #cbd5e1);
}
.section-content p {
margin: 0 0 1rem 0;
}
.section-content p:last-child {
margin-bottom: 0;
}
.link {
color: var(--fog-accent, #64748b);
text-decoration: underline;
transition: color 0.2s;
}
.link:hover {
color: var(--fog-text, #1e293b);
}
:global(.dark) .link {
color: var(--fog-dark-accent, #94a3b8);
}
:global(.dark) .link:hover {
color: var(--fog-dark-text, #f1f5f9);
}
.features-list {
list-style: disc;
padding-left: 1.5rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.features-list li {
margin: 0;
}
.version-info {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.version-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: var(--fog-accent, #64748b);
color: white;
border-radius: 4px;
font-family: monospace;
font-weight: 600;
}
:global(.dark) .version-badge {
background: var(--fog-dark-accent, #94a3b8);
}
.changelog-list {
list-style: disc;
padding-left: 1.5rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.changelog-list li {
margin: 0;
}
.links-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.links-list li {
margin: 0;
}
.links-list .link {
display: flex;
align-items: center;
gap: 0.5rem;
}
</style>

21
src/routes/lists/+page.svelte

@ -129,7 +129,24 @@ @@ -129,7 +129,24 @@
}
// Enhance with fresh data from relays (non-blocking)
// Only fetch if we don't have cached lists or cache is stale
try {
// If we already have cached lists displayed, only fetch from relays if cache is stale
// Check cache age - if lists were cached recently (within 1 hour), skip relay fetch
if (lists.length > 0) {
const { getRecentCachedEvents } = await import('../../lib/services/cache/event-cache.js');
const recentCachedLists = await getRecentCachedEvents([KIND.CONTACTS, KIND.FOLLOW_SET], 60 * 60 * 1000, 10);
const userRecentCachedLists = recentCachedLists.filter(e => e.pubkey === currentPubkey);
// If we have recent cached lists, skip relay fetch (they're already shown)
// Only fetch from relays in background if cache is older than 1 hour
if (userRecentCachedLists.length > 0) {
// Cache is fresh, skip relay fetch to avoid unnecessary network requests
// Lists are already displayed from cache
return;
}
}
const relays = getAllRelays();
// Fetch both kinds in parallel
@ -137,12 +154,12 @@ @@ -137,12 +154,12 @@
nostrClient.fetchEvents(
[{ kinds: [KIND.CONTACTS], authors: [currentPubkey], limit: 1 }],
relays,
{ useCache: 'cache-first', cacheResults: true }
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout }
),
nostrClient.fetchEvents(
[{ kinds: [KIND.FOLLOW_SET], authors: [currentPubkey] }],
relays,
{ useCache: 'cache-first', cacheResults: true }
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout }
)
]);

2
src/routes/manifest.webmanifest/+server.ts

@ -6,7 +6,7 @@ export const GET: RequestHandler = async () => { @@ -6,7 +6,7 @@ export const GET: RequestHandler = async () => {
const manifest = {
name: 'aitherboard - Decentralized Messageboard on Nostr',
short_name: 'aitherboard',
description: 'A decentralized messageboard built on the Nostr protocol. Create threads, comment, react, and zap in a censorship-resistant environment.',
description: 'A decentralized messageboard built on the Nostr protocol. Create threads, comment, and react in a censorship-resistant environment.',
theme_color: '#f1f5f9',
background_color: '#ffffff',
display: 'standalone',

20
src/routes/settings/+page.svelte

@ -218,6 +218,26 @@ @@ -218,6 +218,26 @@
</div>
<div class="space-y-6">
<!-- About -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded">
<div class="preference-label mb-3">
<span class="font-semibold text-fog-text dark:text-fog-dark-text">About</span>
</div>
<div class="preference-controls">
<button
onclick={() => goto('/about')}
class="toggle-button"
aria-label="View About page"
>
<Icon name="eye" size={16} />
<span>About aitherboard</span>
</button>
</div>
<p class="text-fog-text-light dark:text-fog-dark-text-light mt-2" style="font-size: 0.875em;">
Learn more about aitherboard, view version information, and see what's new.
</p>
</div>
<!-- PWA Install - Always visible -->
<div class="preference-section bg-fog-post dark:bg-fog-dark-post border border-fog-border dark:border-fog-dark-border p-4 rounded" style="display: block !important; visibility: visible !important;">
<div class="preference-label mb-3">

Loading…
Cancel
Save