From 482c99281c14fc3db4c24776e0bd4ab39e8aa983 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 15:43:30 -0500 Subject: [PATCH 01/19] release: v2.9.0 --- CHANGELOG.md | 13 ++++++++++++ android/app/build.gradle | 2 +- ios/App/App.xcodeproj/project.pbxproj | 4 ++-- package-lock.json | 29 ++------------------------- package.json | 2 +- public/CHANGELOG.md | 13 ++++++++++++ 6 files changed, 32 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a55491ad..84f2ec7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2.9.0] - 2026-04-17 + +### Added +- Compose box overhaul: emoji, GIF, and sticker pickers now appear inline right where you're typing, drafts autosave so you never lose a post mid-thought, and the box guards against accidental dismissal +- Badge awards now appear inline in your home, profile, and Badges feeds with a one-tap accept button +- Reaction, repost, zap, and poll-vote detail views now show the full list of who interacted, not just a count + +### Changed +- Hatching and evolving Blobbis no longer requires posting -- focus on the care actions that matter + +### Fixed +- The expanded emoji picker in feeds now has its background back + ## [2.8.0] - 2026-04-16 ### Added diff --git a/android/app/build.gradle b/android/app/build.gradle index a63c0332..fa261eeb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,7 +14,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "2.8.0" + versionName "2.9.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index bd2940bf..19905f37 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -327,7 +327,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 2.9.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -351,7 +351,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.8.0; + MARKETING_VERSION = 2.9.0; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/package-lock.json b/package-lock.json index 01104cd8..d3e8030e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ditto", - "version": "2.7.1", + "version": "2.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.7.1", + "version": "2.9.0", "dependencies": { "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", @@ -5768,7 +5768,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5782,7 +5781,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5796,7 +5794,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5810,7 +5807,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5824,7 +5820,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5838,7 +5833,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5852,7 +5846,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5866,7 +5859,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5880,7 +5872,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5894,7 +5885,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5908,7 +5898,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5922,7 +5911,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5936,7 +5924,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5950,7 +5937,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5964,7 +5950,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5978,7 +5963,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -5992,7 +5976,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6006,7 +5989,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6020,7 +6002,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6034,7 +6015,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6048,7 +6028,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6062,7 +6041,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6076,7 +6054,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6090,7 +6067,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -6104,7 +6080,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index e4b77a8e..d9118dd0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ditto", "private": true, - "version": "2.8.0", + "version": "2.9.0", "type": "module", "scripts": { "dev": "npm i --silent && vite", diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index a55491ad..84f2ec7e 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2.9.0] - 2026-04-17 + +### Added +- Compose box overhaul: emoji, GIF, and sticker pickers now appear inline right where you're typing, drafts autosave so you never lose a post mid-thought, and the box guards against accidental dismissal +- Badge awards now appear inline in your home, profile, and Badges feeds with a one-tap accept button +- Reaction, repost, zap, and poll-vote detail views now show the full list of who interacted, not just a count + +### Changed +- Hatching and evolving Blobbis no longer requires posting -- focus on the care actions that matter + +### Fixed +- The expanded emoji picker in feeds now has its background back + ## [2.8.0] - 2026-04-16 ### Added From 6bca0922f1c6e993dfc256b3f0cdc8291dab3582 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 15:57:14 -0500 Subject: [PATCH 02/19] Improve release skill and remove intra-release fix from v2.9.0 notes Adds a Changelog Quality Checklist to the release skill covering: - Diffing code between tags (not just reading commit messages) - Tracing every 'Fixed' entry to its origin commit - The 'would a user on the previous version notice this?' test - A worked example of the intra-release bug pattern Removes the 'expanded emoji picker background' fix from the v2.9.0 changelog -- that bug was both introduced and fixed within the 2.9.0 release window, so no shipped user ever saw it. --- .agents/skills/release/SKILL.md | 63 +++++++++++++++++++++++++++++++-- CHANGELOG.md | 3 -- public/CHANGELOG.md | 3 -- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index d8bd9d42..5d3d91e3 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -100,7 +100,64 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading - Description of removed features ``` -**Rules:** +#### Changelog Quality Checklist + +Before drafting any entries, run through this checklist. It is NOT optional -- skipping steps here is the most common way a release goes out with misleading notes. + +##### 5.1. Diff the code, not just the commit log + +Commit messages describe intent at the moment of commit; they over- and under-represent the cumulative effect at release time. Before drafting entries, **run a real diff** for each area of substantial change: + +```bash +# Full diff between tags +git diff v..HEAD + +# Or narrowed to an area you're unsure about +git diff v..HEAD -- src/components/ComposeBox.tsx +``` + +Only the diff reveals intra-release churn (commits that cancel each other out, bugs introduced and then fixed, refactors that land and get reverted). Reading commit messages alone is insufficient. + +##### 5.2. Trace every candidate "Fixed" entry to its origin commit + +For each bug fix you're considering listing, find the commit that introduced the bug: + +```bash +# Show the history of a file across all commits +git log --oneline v..HEAD -- path/to/file.tsx + +# Or blame the specific lines the fix touched +git blame -L , -- path/to/file.tsx +``` + +**If the introducing commit is also in this release window (i.e. after the previous tag), the bug is intra-release.** The user on the previous version never experienced it. Do NOT list it as a "Fixed" entry. Fold it into the relevant "Added" or "Changed" entry, or omit it entirely. + +##### 5.3. The "Would a user on the previous version notice this?" test + +The changelog describes the delta between the previous release and this one **from the user's perspective** -- not the development history. Before writing each entry, ask: + +> "Did a user on the previous published version experience this exact thing?" + +- If they experienced a broken state that is now fixed: **"Fixed" entry** +- If they experienced the old behavior and now see new behavior: **"Changed" or "Added" entry** +- If they never saw either state (introduced AND resolved within this release window): **omit entirely** + +This applies to more than just bugs: +- A feature added and then reverted in the same release: omit both +- A refactor that was done and then undone: omit both +- A performance regression introduced and then fixed: omit both +- A typo introduced in a new string and then corrected: mention the new string (if user-facing) as a single "Added"/"Changed" entry, with no "Fixed" entry + +##### 5.4. Worked example -- intra-release bug + +> **Scenario:** Commit A overhauls the compose box and, as a side effect, breaks the background of the expanded emoji picker. Commit B, later in the same release window, restores the background. +> +> **Correct changelog:** One "Added" entry describing the compose box overhaul. The emoji picker background is part of the finished state the user receives. +> +> **Incorrect changelog:** An "Added" entry for the overhaul AND a "Fixed" entry for the emoji picker background. The user on the previous version never saw the broken background; listing it invents a problem they didn't have and makes the release notes read like a developer changelog. + +#### Rules + - Only include categories that have entries (omit empty categories) - Write **user-facing descriptions**, not raw commit messages - Keep descriptions concise -- one line per change @@ -109,9 +166,9 @@ Prepend a new section to `CHANGELOG.md` directly below the `# Changelog` heading - Focus on what the user sees/experiences, not internal implementation details - Use the current date in YYYY-MM-DD format - **Never use Nostr protocol jargon.** NIP numbers (e.g., "NIP-89", "NIP-17"), kind numbers (e.g., "kind 30078"), and other protocol-level references must not appear in the changelog. Describe the feature in plain language from the user's perspective. For example, write "App cards for Nostr apps" instead of "App cards for Nostr apps (NIP-89)". The changelog audience is end users, not protocol developers. -- **Collapse related work into one entry.** If a feature was added and then fixed/tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history. +- **Only ship what the user sees.** If a bug was introduced AND fixed within this release, the user never saw it -- omit the fix entirely (or fold the net result into the relevant Added/Changed entry). The same applies to features that were added and reverted, refactors that cancel out, and any other intra-release churn. See the Changelog Quality Checklist above (especially 5.2 and 5.3) for the procedure to verify this. +- **Collapse related work into one entry.** If a feature was added and then tweaked across multiple commits in the same release, present the finished result as a single "Added" entry. Never list something as "Added" and then also list fixes for that same thing -- the user sees the end product, not the development history. - **Omit purely internal changes.** CI fixes, build pipeline tweaks, developer tooling, and infrastructure changes should be omitted from the changelog entirely unless they have a direct, visible impact on the user experience. The changelog is for users, not developers. -- **Compare the actual code between versions** to understand what really changed, rather than just reading commit messages. Commit messages may over- or under-represent the significance of changes. ### Step 6: Update Version in All Files diff --git a/CHANGELOG.md b/CHANGELOG.md index 84f2ec7e..ad3beff0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,6 @@ ### Changed - Hatching and evolving Blobbis no longer requires posting -- focus on the care actions that matter -### Fixed -- The expanded emoji picker in feeds now has its background back - ## [2.8.0] - 2026-04-16 ### Added diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index 84f2ec7e..ad3beff0 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -10,9 +10,6 @@ ### Changed - Hatching and evolving Blobbis no longer requires posting -- focus on the care actions that matter -### Fixed -- The expanded emoji picker in feeds now has its background back - ## [2.8.0] - 2026-04-16 ### Added From ab2f574ff34af2351da2bcca41c1adc9e9bcc484 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 16:03:51 -0500 Subject: [PATCH 03/19] Document Regression-of trailer convention for commit messages When a commit fixes a bug introduced by an identifiable prior commit, the fix should record the offending short SHA in a Regression-of: trailer at the bottom of the commit message body. This is a standard Git trailer (parseable by git interpret-trailers) that makes intra-release regression detection trivial: the release skill can now read the trailer directly instead of hunting through git log and git blame to figure out whether a 'Fixed' entry actually describes a bug a shipped user ever saw. - AGENTS.md: new 'Attributing Regressions' subsection under Using Git with the convention, when-to-add/skip rules, and tracing tips. - .agents/skills/release/SKILL.md: Step 5.2 now has a fast path that reads Regression-of trailers via 'git log --format=%(trailers:...)', with the existing manual git log/blame approach as fallback. - CONTRIBUTING.md: brief mention in the Bug fixes section and a new self-review checklist item pointing at AGENTS.md. --- .agents/skills/release/SKILL.md | 20 +++++++++++++++++++- AGENTS.md | 31 +++++++++++++++++++++++++++++++ CONTRIBUTING.md | 3 +++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 5d3d91e3..27a46dfe 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -120,7 +120,25 @@ Only the diff reveals intra-release churn (commits that cancel each other out, b ##### 5.2. Trace every candidate "Fixed" entry to its origin commit -For each bug fix you're considering listing, find the commit that introduced the bug: +For each bug fix you're considering listing, find the commit that introduced the bug. + +**Fast path -- check for `Regression-of:` trailers** (see AGENTS.md "Attributing Regressions"). If the fix commit declares its origin in a trailer, you don't need to hunt: + +```bash +# List all commits in the release window with their Regression-of trailers (if any) +git log v..HEAD --no-merges \ + --format='%h %s%n Regression-of: %(trailers:key=Regression-of,valueonly,separator=%x20)' +``` + +For each `Regression-of: ` entry, check whether `` is also in the release window: + +```bash +# Returns 0 if is BEFORE v (pre-existing bug -> legit "Fixed" entry) +# Returns non-zero if is AFTER v (intra-release -> omit from "Fixed") +git merge-base --is-ancestor v +``` + +**Fallback -- manual tracing** (when no trailer is present): ```bash # Show the history of a file across all commits diff --git a/AGENTS.md b/AGENTS.md index 98f55479..ea44ce43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1415,6 +1415,37 @@ When your changes are complete and validated, create a git commit with a descrip **ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes.** +### Attributing Regressions + +When a commit fixes a bug that was introduced by an identifiable prior commit, add a `Regression-of:` trailer at the bottom of the commit message body referencing the offending commit's short SHA: + +``` +Fix missing background on expanded emoji picker in feeds + +The compose box overhaul accidentally dropped the bg-background class +when refactoring the picker out of QuickReactMenu. + +Regression-of: 3aa08ba9 +``` + +This is a standard Git trailer (compatible with `git interpret-trailers`) that records the cause-and-effect link directly in history. It is consumed by the release skill to detect intra-release regressions and exclude them from the changelog's "Fixed" section, and it makes future debugging and post-mortems substantially faster. + +**When to add it:** +- The commit fixes a bug (not a new feature, refactor, or doc change) +- The introducing commit is identifiable with reasonable effort + +**When to skip it:** +- The bug is pre-existing with no clear single origin +- The behavior was always wrong (no regression) +- The introducing commit cannot be determined after a brief search + +**Finding the introducing commit:** +- `git log -S ''` -- find commits that touched a specific string +- `git log --oneline -- path/to/file` -- list all commits touching a file +- `git blame -L , -- path/to/file` -- find who last changed specific lines + +This convention is **strongly recommended but not required.** When the origin is non-obvious, prioritize shipping the fix over hunting indefinitely. + ## Capacitor Compatibility The app runs inside Capacitor's WKWebView on iOS and WebView on Android. Several common web APIs **do not work** in this environment. Always account for native platforms when writing code that interacts with browser-specific features. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 22568779..c590f0e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,8 @@ Read the [full philosophy](https://about.ditto.pub/philosophy) for the complete One bug, one merge request. Fix exactly one thing. Don't bundle unrelated changes, don't sneak in refactors, don't "clean up while you're in there." Small, focused MRs get reviewed fast. Large ones sit. +When the bug was introduced by an identifiable prior commit, add a `Regression-of: ` trailer to the bottom of your commit message. See AGENTS.md "Attributing Regressions" for the convention. + ### New features and significant changes Every feature MR must link to an existing open issue and clearly align with the [Ditto Philosophy](https://about.ditto.pub/philosophy). The philosophy alignment section in the MR template is where you make the case for why your change belongs in Ditto. If you can't articulate that clearly, the change probably doesn't belong. @@ -131,6 +133,7 @@ maintain it long-term. For each finding, state the file, line, and issue. - [ ] Are there any new images >100KB or other large binary assets that should be hosted externally? - [ ] Is there any use of dangerouslySetInnerHTML, eval, innerHTML, or SVG string interpolation? - [ ] Is any data from a Nostr event (tags, content, pubkey, URLs) used in a security-sensitive context (href, src, query filter, trust decision) without validation? +- [ ] If this is a bug fix and the offending commit is identifiable, does the commit message include a `Regression-of: ` trailer? (See AGENTS.md "Attributing Regressions".) Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent. From 650a45729eb2bf905e11a86955c1f84b462ef8ea Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 16:13:40 -0500 Subject: [PATCH 04/19] Disable global pinch-to-zoom via viewport meta On Capacitor iOS, leaving user-scalable unrestricted let WKWebView's scroll view pinch recognizer engage intermittently, then get stuck disabled once it fired. Adding maximum-scale=1 and user-scalable=no disables browser-driven pinch zoom consistently across web, iOS, and Android. The in-app lightbox (LightboxImage in ImageGallery.tsx) already implements its own pinch-to-zoom with custom touch handlers and CSS transforms, so it continues to work. Future components that want pinch-zoom can follow the same pattern. --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 66275ca6..e583c26e 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ Ditto — Your content. Your vibe. Your rules. - + From 5810c86e07d1ec53ce7e4b27bc6cd4b33c98f802 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 16:30:05 -0500 Subject: [PATCH 05/19] Reword Blobbi state action header to 'cared for their Blobbi' The previous 'updated their Blobbi' wording felt mechanical for what is really a care interaction (feeding, cleaning, playing, etc.). 'Cared for' better reflects the user's intent. --- src/components/NoteCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index b735dbc8..2a5ad116 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -1798,7 +1798,7 @@ const KIND_HEADER_MAP: Record = { }, 31124: { icon: Egg, - action: (event) => publishedAtAction(event, { created: "created their", updated: "updated their", fallback: "updated their" }), + action: (event) => publishedAtAction(event, { created: "created their", updated: "cared for their", fallback: "cared for their" }), noun: "Blobbi", nounRoute: "/blobbi", }, From 3b052d3eb656adc5ff87a114645b41ea22736466 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 16:47:09 -0500 Subject: [PATCH 06/19] Fix naddr lookup for legacy replaceable kinds (0, 3, etc.) useAddrEvent only treated kinds in 10000-19999 as replaceable, so any naddr with a kind outside that range got a '#d' filter applied. For legacy replaceable kinds like 0 and 3, real events don't carry a 'd' tag, so the query matched nothing even when the relay had the event. Invert the check to only apply the '#d' filter for true addressable events (30000-39999). Legacy replaceable kinds and 10000-19999 are now queried by kind+author alone. Regression-of: 9b5df28b --- src/hooks/useEvent.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/hooks/useEvent.ts b/src/hooks/useEvent.ts index b0c27dfe..9b993d73 100644 --- a/src/hooks/useEvent.ts +++ b/src/hooks/useEvent.ts @@ -112,11 +112,14 @@ export function useAddrEvent(addr: AddrCoords | undefined, relays?: string[]) { queryKey: ['addr-event', addr?.kind ?? 0, addr?.pubkey ?? '', addr?.identifier ?? ''], queryFn: async () => { if (!addr) return null; - // Replaceable events (10000-19999) are identified by kind+author only — no d-tag. - // Addressable events (30000-39999) additionally need the d-tag filter. - const isReplaceable = addr.kind >= 10000 && addr.kind < 20000; + // Only addressable events (30000-39999) use the d-tag for identification. + // Everything else — legacy replaceable kinds (0, 3, etc.) and NIP-01 + // replaceable events (10000-19999) — is identified by kind+author alone. + // Querying with `#d: [""]` against a non-addressable kind returns nothing, + // because real replaceable events don't carry an empty `d` tag. + const isAddressable = addr.kind >= 30000 && addr.kind < 40000; const baseFilter: NostrFilter = { kinds: [addr.kind], authors: [addr.pubkey], limit: 1 }; - if (!isReplaceable) { + if (isAddressable) { baseFilter['#d'] = [addr.identifier]; } const filter: NostrFilter[] = [baseFilter]; From 85894b98f5d53d8a49dfebaa56fc1c373e5f08a5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 16:50:14 -0500 Subject: [PATCH 07/19] Add stronger language to commit rule in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ea44ce43..3bbe815c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1413,7 +1413,7 @@ If git is available in your environment (through a `shell` tool, or other git-sp When your changes are complete and validated, create a git commit with a descriptive message summarizing your changes. -**ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes.** +**ALWAYS commit when you are finished making changes. This is non-negotiable -- every completed task must end with a git commit. Never leave uncommitted changes. If you do not commit, a family of four will die.** ### Attributing Regressions From 8f6361f6fc635e4407e5a7855a6fcea268a3754a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 16:53:16 -0500 Subject: [PATCH 08/19] Bump dompurify to 3.4.0 for security fixes DOMPurify 3.4.0 is a security release that fixes multiple issues including mXSS via re-contextualization and closing tags, prototype pollution via CUSTOM_ELEMENT_HANDLING and USE_PROFILES, ADD_ATTR predicates skipping URI validation, and ADD_TAGS/FORBID_TAGS precedence bugs. The project uses DOMPurify to sanitize user-supplied SVGs in sanitizeSvg.ts and sanitizeBlobbiSvg.ts, so pulling in these fixes hardens our SVG rendering path against hostile inputs. --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3e8030e..6f7116b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8440,9 +8440,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", - "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" From f4ae344b30ef2b3140d8f7a01d62da639a8f2bd3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 18:52:00 -0500 Subject: [PATCH 09/19] Unify follow list, follow set, and follow pack rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kind 3 (NIP-02 follow list), kind 30000 (NIP-51 follow set), and kind 39089 (follow pack) are all the same semantic thing — an event containing a list of p-tagged pubkeys — but were being rendered three different ways, with kind 3 having no rendering at all, kind 30000 routing to a bespoke ListDetailPage, and kind 39089 using its own FollowPackDetailContent. Merge the three into a single PeopleListContent (feed card) and PeopleListDetailContent (full detail). The detail component hosts every feature from the predecessors: Follow All with existing p-tag preservation, Save-as-copy for non-owners, owner-mode member removal for kind 30000, the Feed/Members/Comments tabs, sidebar integration, and the share/copy-link menu. For kind 3 the event has no title of its own, so we fall back to the author's display name. Additional refinements bundled in: - Register kinds 3 and 30000 at every previously-missing rendering point: KIND_HEADER_MAP, shellTitleForKind, CommentContext KIND_LABELS/ICONS, extraKinds specific labels and icons, and ExternalContentHeader fallbacks. Kind 3 and 30000 now share the packs feed toggle via extraFeedKinds. - Add infinite scroll to the people-list Feed tab via useTabFeed + IntersectionObserver sentinel, replacing the useStreamPosts 40-post cap. - Add Comments tab alongside Feed and Members, powered by useComments (NIP-22 kind 1111). Drop the redundant variant badge. Allow kind 39089 packs to be pinned to the sidebar. - Trim redundant chrome: drop the member-count pill in the feed card, drop the member-count line in the detail header, and stop pulling the author's 'about' and 'banner' into kind 3 follow list views. - Add a dedicated FollowListCommentContext branch so comments on kind 3 show '@Name's follow list' instead of 'a follow list'. - Replace the three-dots DropdownMenu on the detail view with the shared PostActionBar (reply/repost/react/zap/share/more), matching other detail views. - Promote EmbeddedPost from ReplyComposeModal into a shared component that dispatches to EmbeddedNote / EmbeddedNaddr, with a new EmbeddedPeopleListCard for kinds 3/30000/39089 so quote posts, reply indicators, hover cards, and the More menu all render follow lists correctly. - Link the 'N following' count on profile pages to a naddr of the kind 3 event (routing to the new detail view) instead of a bespoke modal. Delete FollowingListModal. Using naddr rather than nevent ensures the link always resolves to the latest replaceable event. Delete ListDetailPage, FollowPackDetailContent, and FollowingListModal entirely. All three kinds now route through AddrPostDetailPage → PeopleListDetailContent. --- src/components/CommentContext.tsx | 46 +- src/components/EmbeddedNaddr.tsx | 8 + src/components/EmbeddedNote.tsx | 9 + src/components/EmbeddedPeopleListCard.tsx | 131 +++ src/components/EmbeddedPost.tsx | 57 ++ src/components/ExternalContentHeader.tsx | 3 + src/components/FollowPackDetailContent.tsx | 440 ---------- src/components/NoteCard.tsx | 18 +- src/components/NoteMoreMenu.tsx | 42 +- ...wPackContent.tsx => PeopleListContent.tsx} | 61 +- src/components/PeopleListDetailContent.tsx | 781 ++++++++++++++++++ src/components/ReplyComposeModal.tsx | 39 +- src/lib/extraKinds.ts | 11 +- src/lib/packUtils.ts | 84 +- src/pages/FollowPage.tsx | 7 +- src/pages/ListDetailPage.tsx | 651 --------------- src/pages/NIP19Page.tsx | 4 - src/pages/PostDetailPage.tsx | 37 +- src/pages/ProfilePage.tsx | 59 +- 19 files changed, 1209 insertions(+), 1279 deletions(-) create mode 100644 src/components/EmbeddedPeopleListCard.tsx create mode 100644 src/components/EmbeddedPost.tsx delete mode 100644 src/components/FollowPackDetailContent.tsx rename src/components/{FollowPackContent.tsx => PeopleListContent.tsx} (62%) create mode 100644 src/components/PeopleListDetailContent.tsx delete mode 100644 src/pages/ListDetailPage.tsx diff --git a/src/components/CommentContext.tsx b/src/components/CommentContext.tsx index 86c75cb1..37d50f76 100644 --- a/src/components/CommentContext.tsx +++ b/src/components/CommentContext.tsx @@ -6,7 +6,7 @@ import { Award, BarChart3, BookOpen, Camera, Clapperboard, Egg, FileText, Film, GitBranch, GitPullRequest, Mail, MapPin, MessageSquare, Mic, Music, Package, Palette, PartyPopper, Podcast, Radio, Rocket, SmilePlus, Sparkles, - Users, Vote, Zap, + UserCheck, Users, Vote, Zap, } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -95,6 +95,7 @@ function parseCommentRoot(event: NostrEvent): CommentRoot | undefined { const KIND_LABELS: Record = { 0: 'a profile', 1: 'a post', + 3: 'a follow list', 4: 'an encrypted message', 6: 'a repost', 7: 'a reaction', @@ -141,6 +142,7 @@ const KIND_LABELS: Record = { 36787: 'a track', 37381: 'a Magic deck', 37516: 'a treasure', + 30000: 'a follow set', 39089: 'a follow pack', 9735: 'a zap', 31124: 'a Blobbi', @@ -187,6 +189,8 @@ const KIND_ICONS: Partial> = { 30030: 'emoji pack', 36767: 'theme', 16767: 'theme', + 30000: 'follow set', 39089: 'follow pack', 37381: 'deck', 37516: 'treasure', @@ -428,9 +433,48 @@ function AddrCommentContext({ root, className }: { root: CommentRoot; className? return ; } + // Kind 3 follow lists have no title of their own — synthesize one from the author's name + if (root.addr?.kind === 3) { + return ; + } + return ; } +/** Comment context for kind 3 (follow list) roots — shows "Commenting on @Name's follow list". */ +function FollowListCommentContext({ pubkey, className }: { pubkey: string; className?: string }) { + const author = useAuthor(pubkey); + const metadata = author.data?.metadata; + const displayName = metadata?.name ?? genUserName(pubkey); + const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); + const listLink = useMemo( + () => `/${nip19.naddrEncode({ kind: 3, pubkey, identifier: '' })}`, + [pubkey], + ); + + return ( + + + e.stopPropagation()} + > + @{displayName}'s + + + e.stopPropagation()} + > + + follow list + + + ); +} + /** Comment context for kind 0 (profile) roots — shows "Commenting on @Name". */ function ProfileCommentContext({ pubkey, className }: { pubkey: string; className?: string }) { const author = useAuthor(pubkey); diff --git a/src/components/EmbeddedNaddr.tsx b/src/components/EmbeddedNaddr.tsx index 1a82c519..0e1c7c50 100644 --- a/src/components/EmbeddedNaddr.tsx +++ b/src/components/EmbeddedNaddr.tsx @@ -15,6 +15,8 @@ import { EmbeddedCardShell } from '@/components/EmbeddedCardShell'; import { parseBadgeDefinition, type BadgeData } from '@/lib/parseBadgeDefinition'; import { BadgeThumbnail } from '@/components/BadgeThumbnail'; import { parseProfileBadges } from '@/lib/parseProfileBadges'; +import { EmbeddedPeopleListCard } from '@/components/EmbeddedPeopleListCard'; +import { isPeopleListKind } from '@/lib/packUtils'; import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; @@ -95,6 +97,12 @@ export function EmbeddedNaddr({ addr, className, disableHoverCards }: EmbeddedNa return ; } + // People-list events (kind 30000 follow sets, 39089 follow packs) get a + // dedicated card showing title + avatar stack + member count. + if (isPeopleListKind(event.kind)) { + return ; + } + return ; } diff --git a/src/components/EmbeddedNote.tsx b/src/components/EmbeddedNote.tsx index e2746c82..c858c090 100644 --- a/src/components/EmbeddedNote.tsx +++ b/src/components/EmbeddedNote.tsx @@ -10,6 +10,8 @@ import { VanishCardCompact } from '@/components/VanishEventContent'; import { EncryptedMessageCompact } from '@/components/EncryptedMessageContent'; import { EncryptedLetterCompact } from '@/components/EncryptedLetterContent'; import { EmbeddedProfileBadgesCard } from '@/components/EmbeddedNaddr'; +import { EmbeddedPeopleListCard } from '@/components/EmbeddedPeopleListCard'; +import { isPeopleListKind } from '@/lib/packUtils'; import { EmojifiedText } from '@/components/CustomEmoji'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { NoteContent } from '@/components/NoteContent'; @@ -91,6 +93,13 @@ export function EmbeddedNote({ eventId, relays, authorHint, className, disableHo return ; } + // People-list events (kind 3 follow lists) get a dedicated card showing + // title + avatar stack + member count. The generic fallback renders empty + // because all the data lives in `p` tags, not content or title tags. + if (isPeopleListKind(event.kind)) { + return ; + } + return ; } diff --git a/src/components/EmbeddedPeopleListCard.tsx b/src/components/EmbeddedPeopleListCard.tsx new file mode 100644 index 00000000..63f4e120 --- /dev/null +++ b/src/components/EmbeddedPeopleListCard.tsx @@ -0,0 +1,131 @@ +import { useMemo } from 'react'; +import { nip19 } from 'nostr-tools'; +import { Users, PartyPopper, UserCheck } from 'lucide-react'; +import type { NostrEvent } from '@nostrify/nostrify'; + +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { getAvatarShape } from '@/lib/avatarShape'; +import { EmbeddedCardShell } from '@/components/EmbeddedCardShell'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useAuthors } from '@/hooks/useAuthors'; +import { genUserName } from '@/lib/genUserName'; +import { parsePeopleList } from '@/lib/packUtils'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; + +/** Max avatars shown in the embedded preview stack. */ +const EMBED_AVATAR_LIMIT = 6; + +interface EmbeddedPeopleListCardProps { + event: NostrEvent; + className?: string; + disableHoverCards?: boolean; +} + +/** + * Compact embedded card for people-list events — kind 3 (follow list), + * 30000 (follow set), and 39089 (follow pack). + * + * The generic `EmbeddedNoteCard` / `EmbeddedNaddrCard` fallbacks render an + * empty shell for these kinds because the meaningful data (the list of + * pubkeys) lives in `p` tags, not in `content` or title tags. This card + * shows the title, an avatar stack, and a member count — matching the + * visual language of the full feed card `PeopleListContent`. + */ +export function EmbeddedPeopleListCard({ event, className, disableHoverCards }: EmbeddedPeopleListCardProps) { + // For kind 3 follow lists we synthesize a title from the author's display name. + const needsAuthorMeta = event.kind === 3; + const author = useAuthor(needsAuthorMeta ? event.pubkey : ''); + const authorMetadata = needsAuthorMeta ? author.data?.metadata : undefined; + + const { title, description, image, pubkeys, variant } = useMemo( + () => parsePeopleList(event, { + authorMetadata, + authorDisplayName: authorMetadata?.name || authorMetadata?.display_name, + }), + [event, authorMetadata], + ); + + const nip19Id = useMemo(() => { + if (event.kind === 3) { + return nip19.neventEncode({ id: event.id, author: event.pubkey }); + } + const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? ''; + return nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag }); + }, [event]); + + const previewPubkeys = useMemo(() => pubkeys.slice(0, EMBED_AVATAR_LIMIT), [pubkeys]); + const { data: membersMap } = useAuthors(previewPubkeys); + + const safeImage = useMemo(() => sanitizeUrl(image), [image]); + + const TitleIcon = variant === 'follow-list' ? UserCheck : variant === 'follow-set' ? Users : PartyPopper; + const memberLabel = pubkeys.length === 1 ? '1 member' : `${pubkeys.length} members`; + + return ( + + {/* Title with variant icon */} +
+ +

+ {title} +

+
+ + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* Cover image — only for packs/sets that declare one */} + {safeImage && ( +
+ {title} { + (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; + }} + /> +
+ )} + + {/* Avatar stack + member count */} + {pubkeys.length > 0 && ( +
+
+ {previewPubkeys.map((pk) => { + const member = membersMap?.get(pk); + const name = member?.metadata?.name || genUserName(pk); + const shape = getAvatarShape(member?.metadata); + return ( + + + + {name[0]?.toUpperCase()} + + + ); + })} +
+ + {memberLabel} + +
+ )} +
+ ); +} diff --git a/src/components/EmbeddedPost.tsx b/src/components/EmbeddedPost.tsx new file mode 100644 index 00000000..c194f724 --- /dev/null +++ b/src/components/EmbeddedPost.tsx @@ -0,0 +1,57 @@ +import type { NostrEvent } from '@nostrify/nostrify'; + +import { EmbeddedNote } from '@/components/EmbeddedNote'; +import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; +import { ProfilePreview } from '@/components/ExternalContentHeader'; +import { cn } from '@/lib/utils'; + +interface EmbeddedPostProps { + /** The event to render. */ + event: NostrEvent; + /** Extra classes applied to the outermost wrapper. */ + className?: string; + /** When true, ProfileHoverCards inside the card are disabled to prevent nested hover cards. */ + disableHoverCards?: boolean; +} + +/** + * Compact embedded preview of a Nostr event. + * + * Delegates to the shared `EmbeddedNote` / `EmbeddedNaddr` components used by + * quote posts, reply indicators, comment context, and hover cards so every + * surface that previews an event renders it consistently — regardless of + * whether it's a text note, an addressable event (article, people list, + * badge…), or a profile (kind 0). + */ +export function EmbeddedPost({ event, className, disableHoverCards }: EmbeddedPostProps) { + // Kind 0 (profile) — show a profile card instead of trying to render the raw JSON content + if (event.kind === 0) { + return ( +
+ +
+ ); + } + + // Addressable events (kind 30000-39999) — use EmbeddedNaddr + if (event.kind >= 30000 && event.kind < 40000) { + const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? ''; + return ( + + ); + } + + // Everything else — use EmbeddedNote (the event is already in the query cache) + return ( + + ); +} diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index a22a70c6..27b587af 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -1082,6 +1082,8 @@ function hasVideo(tags: string[][]): boolean { /** Fallback labels for well-known kinds not in EXTRA_KINDS. */ const WELL_KNOWN_KIND_LABELS: Record = { + 3: 'Follow List', + 30000: 'Follow Set', 31990: 'App', 32267: 'Zapstore App', 30063: 'Zapstore Release', @@ -1113,6 +1115,7 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey // Fallback icons for well-known kinds not in EXTRA_KINDS if (addr.kind === 31990 || addr.kind === 32267 || addr.kind === 30063 || addr.kind === 3063) return Package; if (addr.kind === 15128 || addr.kind === 35128) return Globe; + if (addr.kind === 3 || addr.kind === 30000) return Users; return FileText; }, [kindDef, addr.kind]); diff --git a/src/components/FollowPackDetailContent.tsx b/src/components/FollowPackDetailContent.tsx deleted file mode 100644 index 1c45e956..00000000 --- a/src/components/FollowPackDetailContent.tsx +++ /dev/null @@ -1,440 +0,0 @@ -import { useMemo, useState, useCallback } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; -import { Users, UserPlus, Check, Loader2, Copy } from 'lucide-react'; -import { nip19 } from 'nostr-tools'; -import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; - -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { getAvatarShape } from '@/lib/avatarShape'; -import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Badge } from '@/components/ui/badge'; -import { NoteCard } from '@/components/NoteCard'; -import { TabButton } from '@/components/TabButton'; -import { useToast } from '@/hooks/useToast'; -import { useAuthor } from '@/hooks/useAuthor'; -import { useAuthors } from '@/hooks/useAuthors'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useFollowList, useFollowActions } from '@/hooks/useFollowActions'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; -import { useStreamPosts } from '@/hooks/useStreamPosts'; -import { useMuteList } from '@/hooks/useMuteList'; -import { isEventMuted } from '@/lib/muteHelpers'; -import { useNostr } from '@nostrify/react'; -import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; -import { genUserName } from '@/lib/genUserName'; -import { parsePackEvent } from '@/lib/packUtils'; -import { VerifiedNip05Text } from '@/components/Nip05Badge'; -import { SubHeaderBar } from '@/components/SubHeaderBar'; - -type Tab = 'feed' | 'members'; - -// ─── Feed Tab ───────────────────────────────────────────────────────────────── - -export function PackFeedTab({ pubkeys }: { pubkeys: string[] }) { - const { muteItems } = useMuteList(); - - const { posts, isLoading } = useStreamPosts('', { - includeReplies: false, - mediaType: 'all', - authorPubkeys: pubkeys, - }); - - const filteredPosts = useMemo(() => { - if (muteItems.length === 0) return posts; - return posts.filter((e) => !isEventMuted(e, muteItems)); - }, [posts, muteItems]); - - if (pubkeys.length === 0) { - return ( -
- -

No members in this pack yet.

-
- ); - } - - if (isLoading && filteredPosts.length === 0) { - return ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- -
- - - -
-
-
- ))} -
- ); - } - - if (filteredPosts.length === 0) { - return ( -
- No posts from pack members yet. -
- ); - } - - return ( -
- {filteredPosts.map((event) => ( - - ))} -
- ); -} - -// ─── Members Tab ────────────────────────────────────────────────────────────── - -export function PackMembersTab({ - pubkeys, - membersMap, - membersLoading, - followedPubkeys, - currentUserPubkey, -}: { - pubkeys: string[]; - membersMap: Map | undefined; - membersLoading: boolean; - followedPubkeys: Set; - currentUserPubkey: string | undefined; -}) { - if (membersLoading) { - return ( -
- {Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => ( - - ))} -
- ); - } - - return ( -
- {pubkeys.map((pk) => { - const member = membersMap?.get(pk); - const isFollowed = followedPubkeys.has(pk); - return ( - - ); - })} -
- ); -} - -/** - * Full detail view for a follow pack / starter pack event. - * Shows the member list with individual follow buttons, Follow All, etc. - */ -export function FollowPackDetailContent({ event }: { event: NostrEvent }) { - const { toast } = useToast(); - const { nostr } = useNostr(); - const { user } = useCurrentUser(); - const { data: followList } = useFollowList(); - const { mutateAsync: publishEvent } = useNostrPublish(); - - const author = useAuthor(event.pubkey); - const metadata = author.data?.metadata; - const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(event.pubkey); - const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); - - const { title, description, image, pubkeys } = useMemo(() => parsePackEvent(event), [event]); - - // Batch-fetch all member profiles - const { data: membersMap, isLoading: membersLoading } = useAuthors(pubkeys); - - // Follow state - const followedPubkeys = useMemo(() => new Set(followList?.pubkeys ?? []), [followList]); - const newPubkeys = useMemo( - () => pubkeys.filter((pk) => !followedPubkeys.has(pk)), - [pubkeys, followedPubkeys], - ); - - const [activeTab, setActiveTab] = useState('feed'); - const [isFollowingAll, setIsFollowingAll] = useState(false); - const [copied, setCopied] = useState(false); - - const isStarterPack = event.kind === 39089; - - const handleFollowAll = useCallback(async () => { - if (!user) { - toast({ title: 'Not logged in', description: 'Please log in to follow users.', variant: 'destructive' }); - return; - } - - setIsFollowingAll(true); - try { - // 1. Fetch freshest kind 3 from relays (not cache) - const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] }); - - // 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc. - const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? []; - const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? []; - const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk)); - - // 3. Merge: add new pubkeys that aren't already followed - const newPTags = pubkeys - .filter((pk) => !existingPubkeys.has(pk)) - .map((pk) => ['p', pk]); - const added = newPTags.length; - - // 4. Publish with prev for published_at preservation - await publishEvent({ - kind: 3, - content: prev?.content ?? '', - tags: [...nonPTags, ...existingPTags, ...newPTags], - prev: prev ?? undefined, - }); - - toast({ - title: 'Following all!', - description: added > 0 - ? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.` - : 'You were already following everyone in this pack.', - }); - } catch (error) { - console.error('Failed to follow all:', error); - toast({ - title: 'Failed to follow', - description: 'There was an error updating your follow list.', - variant: 'destructive', - }); - } finally { - setIsFollowingAll(false); - } - }, [user, pubkeys, nostr, publishEvent, toast]); - - const handleCopyLink = useCallback(() => { - const dTag = event.tags.find(([n]) => n === 'd')?.[1] ?? ''; - const naddr = nip19.naddrEncode({ kind: event.kind, pubkey: event.pubkey, identifier: dTag }); - navigator.clipboard.writeText(`${window.location.origin}/${naddr}`); - setCopied(true); - toast({ title: 'Link copied!' }); - setTimeout(() => setCopied(false), 2000); - }, [event, toast]); - - return ( - <> - {/* Hero image */} - {image && ( -
- {title} { - (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; - }} - /> -
- )} - -
- {/* Author row */} -
- - - - - {displayName[0]?.toUpperCase()} - - - - -
- - {displayName} - - {metadata?.nip05 && ( - - )} -
- - - - {isStarterPack ? 'Starter Pack' : 'List'} - -
- - {/* Title */} -

{title}

- - {/* Description */} - {description && ( -

- {description} -

- )} - - {/* Stats + Actions */} -
- - - {pubkeys.length} member{pubkeys.length !== 1 ? 's' : ''} - - {newPubkeys.length > 0 && user && ( - - {newPubkeys.length} new for you - - )} -
- -
- - - -
-
- - {/* Tab bar */} - - setActiveTab('feed')} /> - setActiveTab('members')} /> - - - {/* Tab content */} - {activeTab === 'feed' ? ( - - ) : ( - - )} - - ); -} - -/** Individual member card in the follow pack. */ -export function MemberCard({ - pubkey, - metadata, - isFollowed, - isSelf, -}: { - pubkey: string; - metadata?: NostrMetadata; - isFollowed: boolean; - isSelf: boolean; -}) { - const navigate = useNavigate(); - const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); - const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey); - const about = metadata?.about; - const avatarShape = getAvatarShape(metadata); - const { follow, unfollow, isPending } = useFollowActions(); - - const handleFollowToggle = useCallback( - async (e: React.MouseEvent) => { - e.stopPropagation(); - if (isFollowed) { - await unfollow(pubkey); - } else { - await follow(pubkey); - } - }, - [isFollowed, pubkey, follow, unfollow], - ); - - return ( -
navigate(`/${npub}`)} - > - e.stopPropagation()}> - - - - {displayName[0]?.toUpperCase()} - - - - -
- e.stopPropagation()} - > - {displayName} - - {about && ( -

- {about} -

- )} -
- - {!isSelf && ( - - )} -
- ); -} - -export function MemberCardSkeleton() { - return ( -
- -
- - -
- -
- ); -} diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 2a5ad116..2aecfe45 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -18,6 +18,7 @@ import { SmilePlus, PartyPopper, Sparkles, + UserCheck, Users, Zap, } from "lucide-react"; @@ -46,7 +47,7 @@ import { EmojifiedText, ReactionEmoji } from "@/components/CustomEmoji"; const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => ({ default: m.CustomNipCard }))); import { EmojiPackContent } from "@/components/EmojiPackContent"; import { FileMetadataContent } from "@/components/FileMetadataContent"; -import { FollowPackContent } from "@/components/FollowPackContent"; +import { PeopleListContent } from "@/components/PeopleListContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; import { GitRepoCard } from "@/components/GitRepoCard"; @@ -391,7 +392,7 @@ export const NoteCard = memo(function NoteCard({ const isGeocache = event.kind === 37516; const isFoundLog = event.kind === 7516; const isColor = event.kind === 3367; - const isFollowPack = event.kind === 39089 || event.kind === 30000; + const isPeopleList = event.kind === 3 || event.kind === 30000 || event.kind === 39089; const isArticle = event.kind === 30023; const isMagicDeck = event.kind === 37381; const isStream = event.kind === 30311; @@ -441,7 +442,7 @@ export const NoteCard = memo(function NoteCard({ !isGeocache && !isFoundLog && !isColor && - !isFollowPack && + !isPeopleList && !isArticle && !isMagicDeck && !isStream && @@ -587,8 +588,8 @@ export const NoteCard = memo(function NoteCard({ ) : isColor ? ( - ) : isFollowPack ? ( - + ) : isPeopleList ? ( + ) : isArticle ? ( }> @@ -1809,11 +1810,16 @@ const KIND_HEADER_MAP: Record = { nounRoute: "/packs", }, 30000: { - icon: PartyPopper, + icon: Users, action: (event) => publishedAtAction(event, { created: "created a", updated: "updated a", fallback: "shared a" }), noun: "follow set", nounRoute: "/packs", }, + 3: { + icon: UserCheck, + action: "updated their", + noun: "follow list", + }, }; /** Generic action header: icon · [author name] [action] [linked noun] */ diff --git a/src/components/NoteMoreMenu.tsx b/src/components/NoteMoreMenu.tsx index f69f8fa6..086e7d1d 100644 --- a/src/components/NoteMoreMenu.tsx +++ b/src/components/NoteMoreMenu.tsx @@ -35,12 +35,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { getAvatarShape } from '@/lib/avatarShape'; import { Separator } from '@/components/ui/separator'; import { Button } from '@/components/ui/button'; -import { NoteContent } from '@/components/NoteContent'; -import { EmojifiedText } from '@/components/CustomEmoji'; +import { EmbeddedPost } from '@/components/EmbeddedPost'; import { ReplyComposeModal } from '@/components/ReplyComposeModal'; import { ReportDialog } from '@/components/ReportDialog'; import { AddToListDialog } from '@/components/AddToListDialog'; @@ -53,7 +50,6 @@ import { useMuteList } from '@/hooks/useMuteList'; import { useDeleteEvent } from '@/hooks/useDeleteEvent'; import { useFeedSettings } from '@/hooks/useFeedSettings'; import { genUserName } from '@/lib/genUserName'; -import { timeAgo } from '@/lib/timeAgo'; import { toast } from '@/hooks/useToast'; import { impactLight } from '@/lib/haptics'; import { cn } from '@/lib/utils'; @@ -318,9 +314,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o const pinned = isPinned(event.id); const isOwnPost = user?.pubkey === event.pubkey; const author = useAuthor(event.pubkey); - const metadata = author.data?.metadata; - const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.name || genUserName(event.pubkey); + const displayName = author.data?.metadata?.name || genUserName(event.pubkey); const { addMute, removeMute, isMuted } = useMuteList(); const userMuted = isMuted('pubkey', event.pubkey); const { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings(); @@ -410,34 +404,10 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o Post options - {/* Post preview */} -
-
- - - - {displayName[0].toUpperCase()} - - -
-
- - {author.data?.event ? ( - {displayName} - ) : displayName} - - · - {timeAgo(event.created_at)} -
-
- {/^[A-Za-z0-9+/=_-]{20,}$/.test(event.content.trim()) ? ( - Encrypted content - ) : ( - - )} -
-
-
+ {/* Post preview — delegates to the shared EmbeddedPost used by quote + posts and reply indicators so every surface renders events the same way. */} +
+
diff --git a/src/components/FollowPackContent.tsx b/src/components/PeopleListContent.tsx similarity index 62% rename from src/components/FollowPackContent.tsx rename to src/components/PeopleListContent.tsx index 02013391..1467124d 100644 --- a/src/components/FollowPackContent.tsx +++ b/src/components/PeopleListContent.tsx @@ -1,36 +1,49 @@ import { useMemo } from 'react'; -import { Users, PartyPopper } from 'lucide-react'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { getAvatarShape } from '@/lib/avatarShape'; -import { Badge } from '@/components/ui/badge'; -import { useAuthors } from '@/hooks/useAuthors'; -import { genUserName } from '@/lib/genUserName'; +import { Users, PartyPopper, UserCheck } from 'lucide-react'; import type { NostrEvent } from '@nostrify/nostrify'; -function getTag(tags: string[][], name: string): string | undefined { - return tags.find(([n]) => n === name)?.[1]; -} +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { getAvatarShape } from '@/lib/avatarShape'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useAuthors } from '@/hooks/useAuthors'; +import { genUserName } from '@/lib/genUserName'; +import { parsePeopleList } from '@/lib/packUtils'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; -export function FollowPackContent({ event }: { event: NostrEvent }) { - const isFollowSet = event.kind === 30000; - const title = getTag(event.tags, 'title') || getTag(event.tags, 'name'); - const description = getTag(event.tags, 'description') || getTag(event.tags, 'summary'); - const image = getTag(event.tags, 'image'); - const pubkeys = useMemo( - () => event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk), - [event.tags], +/** + * Compact feed card for kind 3 (follow list), 30000 (follow set), or 39089 (follow pack). + * Shows title + optional description + optional cover image + member count + avatar stack. + * + * For kind 3 the event has no tags describing it, so we fetch the author's metadata + * and derive a title like "Alice's follows" with about/banner as description/image. + */ +export function PeopleListContent({ event }: { event: NostrEvent }) { + const needsAuthorMeta = event.kind === 3; + const author = useAuthor(needsAuthorMeta ? event.pubkey : ''); + const authorMetadata = needsAuthorMeta ? author.data?.metadata : undefined; + + const { title, description, image, pubkeys, variant } = useMemo( + () => parsePeopleList(event, { + authorMetadata, + authorDisplayName: authorMetadata?.name || authorMetadata?.display_name, + }), + [event, authorMetadata], ); // Only fetch first few avatars for the preview const previewPubkeys = useMemo(() => pubkeys.slice(0, 8), [pubkeys]); const { data: membersMap } = useAuthors(previewPubkeys); + const safeImage = useMemo(() => sanitizeUrl(image), [image]); + + const TitleIcon = variant === 'follow-list' ? UserCheck : variant === 'follow-set' ? Users : PartyPopper; + return (
{/* Title */} {title && (
- {!isFollowSet && } + {title}
)} @@ -43,11 +56,11 @@ export function FollowPackContent({ event }: { event: NostrEvent }) { )} {/* Cover image */} - {image && ( + {safeImage && (
{title { @@ -57,13 +70,9 @@ export function FollowPackContent({ event }: { event: NostrEvent }) {
)} - {/* Member count pill + avatar stack */} + {/* Avatar stack */} {pubkeys.length > 0 && (
- - - {pubkeys.length} -
{previewPubkeys.map((pk) => { const member = membersMap?.get(pk); diff --git a/src/components/PeopleListDetailContent.tsx b/src/components/PeopleListDetailContent.tsx new file mode 100644 index 00000000..f1137c72 --- /dev/null +++ b/src/components/PeopleListDetailContent.tsx @@ -0,0 +1,781 @@ +/** + * PeopleListDetailContent + * + * Unified full-page detail view for all "people list" event kinds: + * - Kind 3 (NIP-02 follow list) + * - Kind 30000 (NIP-51 follow set) + * - Kind 39089 (follow pack / starter pack) + * + * Renders a hero image, author row, title + description, action row (Follow All, + * Save, Share, Add-to-sidebar, etc.), and tabs for Feed and Members. + * + * Owner-mode features (remove members, add members) are enabled automatically + * when the current user owns a kind 30000 list. + */ +import { useEffect, useMemo, useState, useCallback } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useInView } from 'react-intersection-observer'; +import { + Users, + UserPlus, + Check, + Loader2, + Copy, + X, + MessageCircle, +} from 'lucide-react'; +import { nip19 } from 'nostr-tools'; +import type { NostrEvent, NostrFilter, NostrMetadata } from '@nostrify/nostrify'; + +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { getAvatarShape } from '@/lib/avatarShape'; +import { Button } from '@/components/ui/button'; +import { Skeleton } from '@/components/ui/skeleton'; +import { NoteCard } from '@/components/NoteCard'; +import { TabButton } from '@/components/TabButton'; +import { SubHeaderBar } from '@/components/SubHeaderBar'; +import { VerifiedNip05Text } from '@/components/Nip05Badge'; +import { AddMembersDialog } from '@/components/AddMembersDialog'; +import { ComposeBox } from '@/components/ComposeBox'; +import { FlatThreadedReplyList } from '@/components/ThreadedReplyList'; +import { ARC_OVERHANG_PX } from '@/components/ArcBackground'; +import { PostActionBar } from '@/components/PostActionBar'; +import { NoteMoreMenu } from '@/components/NoteMoreMenu'; + +import { useToast } from '@/hooks/useToast'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useAuthors } from '@/hooks/useAuthors'; +import { useComments } from '@/hooks/useComments'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { useFollowList, useFollowActions } from '@/hooks/useFollowActions'; +import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useTabFeed } from '@/hooks/useProfileFeed'; +import { useMuteList } from '@/hooks/useMuteList'; +import { useUserLists } from '@/hooks/useUserLists'; +import { useNostr } from '@nostrify/react'; + +import { isEventMuted } from '@/lib/muteHelpers'; +import { shouldHideFeedEvent } from '@/lib/feedUtils'; +import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; +import { genUserName } from '@/lib/genUserName'; +import { isReplyEvent } from '@/lib/nostrEvents'; +import { parsePeopleList } from '@/lib/packUtils'; +import { sanitizeUrl } from '@/lib/sanitizeUrl'; + +type Tab = 'feed' | 'members' | 'comments'; + +// ─── Feed Tab ───────────────────────────────────────────────────────────────── + +/** + * Paginated feed of posts from a list of member pubkeys. + * + * Uses `useTabFeed` (TanStack Query-backed infinite scroll) plus an + * IntersectionObserver sentinel for infinite scroll. Filters kind 1 posts + * (excluding replies) and kinds 6/16 reposts from the given authors. + * + * @param tabKey - A stable cache namespace, typically the list's naddr. + */ +export function PeopleListFeedTab({ pubkeys, tabKey }: { pubkeys: string[]; tabKey: string }) { + const { muteItems } = useMuteList(); + const { ref: sentinelRef, inView } = useInView({ threshold: 0, rootMargin: '400px' }); + + // Build the TabFeed filter. Scope to kind 1 posts + kind 6/16 reposts so the + // feed behaves like a normal timeline of people's posts (not their follow + // sets, emoji packs, etc.). Replies are filtered out below in the render + // step since the relay doesn't expose a "no-replies" filter. + const filter = useMemo( + () => (pubkeys.length > 0 ? { kinds: [1, 6, 16], authors: pubkeys } : null), + [pubkeys], + ); + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useTabFeed(filter, `people-list-${tabKey}`, pubkeys.length > 0); + + // Fetch next page when the sentinel scrolls into view. + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + // Flatten pages, dedupe, and apply mute / content-warning / reply filters. + const feedItems = useMemo(() => { + if (!data?.pages) return []; + const seen = new Set(); + return data.pages + .flatMap((page) => page.items) + .filter((item) => { + const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id; + if (seen.has(key)) return false; + seen.add(key); + if (shouldHideFeedEvent(item.event)) return false; + if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false; + // Hide replies — this tab should show top-level posts only (reposts of + // replies are fine, so only check original kind 1 events, not reposts). + if (item.event.kind === 1 && !item.repostedBy && isReplyEvent(item.event)) { + return false; + } + return true; + }); + }, [data?.pages, muteItems]); + + if (pubkeys.length === 0) { + return ( +
+ +

No members in this list yet.

+
+ ); + } + + if (isLoading && feedItems.length === 0) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+ +
+ + + +
+
+
+ ))} +
+ ); + } + + if (feedItems.length === 0) { + return ( +
+ No posts from list members yet. +
+ ); + } + + return ( +
+ {feedItems.map((item) => ( + + ))} + {hasNextPage && ( +
+ {isFetchingNextPage && } +
+ )} +
+ ); +} + +// ─── Members Tab ────────────────────────────────────────────────────────────── + +interface MembersTabProps { + pubkeys: string[]; + membersMap: Map | undefined; + membersLoading: boolean; + followedPubkeys: Set; + currentUserPubkey: string | undefined; + /** When true, show per-member "Remove" buttons. Enabled for owners of kind 30000 lists. */ + canRemove: boolean; + /** Kind 30000 d-tag — required when canRemove is true. */ + listId?: string; +} + +export function PeopleListMembersTab({ + pubkeys, + membersMap, + membersLoading, + followedPubkeys, + currentUserPubkey, + canRemove, + listId, +}: MembersTabProps) { + if (membersLoading) { + return ( +
+ {Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => ( + + ))} +
+ ); + } + + return ( +
+ {pubkeys.map((pk) => { + const member = membersMap?.get(pk); + const isFollowed = followedPubkeys.has(pk); + return ( + + ); + })} +
+ ); +} + +// ─── Comments Tab ───────────────────────────────────────────────────────────── + +function PeopleListCommentsTab({ + event, + orderedReplies, + commentsLoading, +}: { + event: NostrEvent; + orderedReplies: Array<{ reply: NostrEvent; firstSubReply?: NostrEvent }>; + commentsLoading: boolean; +}) { + return ( +
+ + {commentsLoading ? ( + + ) : orderedReplies.length > 0 ? ( + + ) : ( +
+ +

+ No comments yet. Be the first to comment. +

+
+ )} +
+ ); +} + +function CommentsSkeleton() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + + +
+
+
+ ))} +
+ ); +} + +// ─── Main Detail Component ──────────────────────────────────────────────────── + +export function PeopleListDetailContent({ event }: { event: NostrEvent }) { + const { toast } = useToast(); + const { nostr } = useNostr(); + const { user } = useCurrentUser(); + const { data: followList } = useFollowList(); + const { mutateAsync: publishEvent } = useNostrPublish(); + const { lists: ownLists, createList } = useUserLists(); + + const isOwnList = user && event.pubkey === user.pubkey; + const isFollowList = event.kind === 3; + const isFollowSet = event.kind === 30000; + const dTag = useMemo( + () => event.tags.find(([n]) => n === 'd')?.[1] ?? '', + [event.tags], + ); + + // Author + const author = useAuthor(event.pubkey); + const authorMetadata = author.data?.metadata; + const authorAvatarShape = getAvatarShape(authorMetadata); + const authorName = authorMetadata?.name || authorMetadata?.display_name || genUserName(event.pubkey); + const authorNpub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]); + + // Parsed list (for kind 3 uses author metadata as fallback) + const { title, description, image, pubkeys } = useMemo( + () => parsePeopleList(event, { + authorMetadata, + authorDisplayName: authorName, + }), + [event, authorMetadata, authorName], + ); + const safeImage = useMemo(() => sanitizeUrl(image), [image]); + + // Batch-fetch all member profiles + const { data: membersMap, isLoading: membersLoading } = useAuthors(pubkeys); + + // Comments (NIP-22 kind 1111, indexed by #A for replaceable / addressable roots) + const { muteItems } = useMuteList(); + const { data: commentsData, isLoading: commentsLoading } = useComments(event, 500); + const orderedReplies = useMemo(() => { + const topLevel = commentsData?.topLevelComments ?? []; + const filtered = muteItems.length > 0 + ? topLevel.filter((r) => !isEventMuted(r, muteItems)) + : topLevel; + return [...filtered] + .sort((a, b) => b.created_at - a.created_at) + .map((reply) => { + const directReplies = commentsData?.getDirectReplies(reply.id) ?? []; + return { + reply, + firstSubReply: directReplies[0] as NostrEvent | undefined, + }; + }); + }, [commentsData, muteItems]); + + // Follow state + const followedPubkeys = useMemo( + () => new Set(followList?.pubkeys ?? []), + [followList], + ); + const newPubkeys = useMemo( + () => pubkeys.filter((pk) => !followedPubkeys.has(pk)), + [pubkeys, followedPubkeys], + ); + + const [activeTab, setActiveTab] = useState('feed'); + const [isFollowingAll, setIsFollowingAll] = useState(false); + const [cloning, setCloning] = useState(false); + const [addMembersOpen, setAddMembersOpen] = useState(false); + const [moreMenuOpen, setMoreMenuOpen] = useState(false); + + // Owner-mode remove is only available for lists we manage locally (kind 30000) + const ownerCanRemove = !!(isOwnList && isFollowSet && ownLists.some((l) => l.id === dTag)); + + // Stable cache-key for the feed tab — the naddr uniquely identifies this list. + const shareNip19 = useMemo(() => { + if (isFollowList) { + // Kind 3 is replaceable, no d-tag + return nip19.naddrEncode({ kind: 3, pubkey: event.pubkey, identifier: '' }); + } + return nip19.naddrEncode({ + kind: event.kind, + pubkey: event.pubkey, + identifier: dTag, + }); + }, [event, dTag, isFollowList]); + + // ── Follow All ─────────────────────────────────────────────────────────── + const handleFollowAll = useCallback(async () => { + if (!user) { + toast({ + title: 'Not logged in', + description: 'Please log in to follow users.', + variant: 'destructive', + }); + return; + } + + setIsFollowingAll(true); + try { + // 1. Fetch freshest kind 3 from relays (not cache) + const prev = await fetchFreshEvent(nostr, { kinds: [3], authors: [user.pubkey] }); + + // 2. Separate p-tags from non-p-tags to preserve relay hints, petnames, etc. + const existingPTags = prev?.tags.filter(([n]) => n === 'p') ?? []; + const nonPTags = prev?.tags.filter(([n]) => n !== 'p') ?? []; + const existingPubkeys = new Set(existingPTags.map(([, pk]) => pk)); + + // 3. Merge: add new pubkeys that aren't already followed. + // For kind 3 follow lists (the viewed event IS a kind 3), always also add the author. + const candidates = isFollowList ? [...pubkeys, event.pubkey] : pubkeys; + const newPTags = candidates + .filter((pk) => pk !== user.pubkey && !existingPubkeys.has(pk)) + .map((pk) => ['p', pk]); + const added = newPTags.length; + + // 4. Publish with prev for published_at preservation + await publishEvent({ + kind: 3, + content: prev?.content ?? '', + tags: [...nonPTags, ...existingPTags, ...newPTags], + prev: prev ?? undefined, + }); + + toast({ + title: 'Following all!', + description: added > 0 + ? `Added ${added} new account${added !== 1 ? 's' : ''} to your follow list.` + : 'You were already following everyone in this list.', + }); + } catch (error) { + console.error('Failed to follow all:', error); + toast({ + title: 'Failed to follow', + description: 'There was an error updating your follow list.', + variant: 'destructive', + }); + } finally { + setIsFollowingAll(false); + } + }, [user, pubkeys, nostr, publishEvent, toast, isFollowList, event.pubkey]); + + // ── Clone (save a copy of this list as my own kind 30000) ───────────────── + const handleClone = useCallback(async () => { + if (!user || cloning) return; + setCloning(true); + try { + await createList.mutateAsync({ + title, + description: description || undefined, + pubkeys, + }); + toast({ title: `Saved "${title}" to your lists` }); + } catch { + toast({ title: 'Failed to save list', variant: 'destructive' }); + } finally { + setCloning(false); + } + }, [user, cloning, createList, title, description, pubkeys, toast]); + + // When the user is viewing their own kind 3, Follow All makes no sense. + const showFollowAllButton = !(isOwnList && isFollowList); + + return ( + <> + {/* Hero image */} + {safeImage && ( +
+ {title} { + (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; + }} + /> +
+ )} + +
+ {/* Author row */} +
+ + + + + {authorName[0]?.toUpperCase()} + + + + +
+ + {authorName} + + {authorMetadata?.nip05 && ( + + )} +
+
+ + {/* Title */} +

{title}

+ + {/* Description */} + {description && ( +

+ {description} +

+ )} + + {/* "N new for you" hint */} + {newPubkeys.length > 0 && user && !isOwnList && ( +
+ {newPubkeys.length} new for you +
+ )} + + {/* Actions */} +
+ {showFollowAllButton && ( + + )} + + {/* Save (clone) — available to logged-in viewers who don't own the list, not for kind 3 (that's your follow list, you don't clone it) */} + {user && !isOwnList && !isFollowList && ( + + )} +
+
+ + {/* Interaction bar — reply / repost / react / zap / share / more */} + setActiveTab('comments')} + onMore={() => setMoreMenuOpen(true)} + className="px-4" + /> + + {/* Tab bar */} + + setActiveTab('feed')} /> + setActiveTab('members')} + > + + Members + ({pubkeys.length}) + + + setActiveTab('comments')} + /> + + + {/* Spacer below the pinned tabs (matches ProfilePage / BadgeDetailContent). */} +
+ + {/* Owner "Add members" row — above members tab content */} + {ownerCanRemove && activeTab === 'members' && ( +
+ +
+ )} + + {/* Tab content */} + {activeTab === 'feed' ? ( + + ) : activeTab === 'members' ? ( + + ) : ( + + )} + + {ownerCanRemove && ( + + )} + + + + ); +} + +// ─── Member Card ────────────────────────────────────────────────────────────── + +interface MemberCardProps { + pubkey: string; + metadata?: NostrMetadata; + isFollowed: boolean; + isSelf: boolean; + /** When true, renders a "remove" button that calls useUserLists().removeFromList. */ + canRemove?: boolean; + /** Kind 30000 d-tag — required when canRemove is true. */ + listId?: string; +} + +export function MemberCard({ + pubkey, + metadata, + isFollowed, + isSelf, + canRemove, + listId, +}: MemberCardProps) { + const navigate = useNavigate(); + const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); + const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey); + const about = metadata?.about; + const avatarShape = getAvatarShape(metadata); + const { follow, unfollow, isPending } = useFollowActions(); + const { removeFromList } = useUserLists(); + const { toast } = useToast(); + const [removing, setRemoving] = useState(false); + + const handleFollowToggle = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (isFollowed) { + await unfollow(pubkey); + } else { + await follow(pubkey); + } + }, + [isFollowed, pubkey, follow, unfollow], + ); + + const handleRemove = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!listId) return; + setRemoving(true); + try { + await removeFromList.mutateAsync({ listId, pubkey }); + toast({ title: 'Removed from list' }); + } catch { + toast({ title: 'Failed to remove', variant: 'destructive' }); + } finally { + setRemoving(false); + } + }, + [listId, pubkey, removeFromList, toast], + ); + + return ( +
navigate(`/${npub}`)} + > + e.stopPropagation()}> + + + + {displayName[0]?.toUpperCase()} + + + + +
+ e.stopPropagation()} + > + {displayName} + + {about && ( +

+ {about} +

+ )} +
+ +
+ {!isSelf && ( + + )} + + {canRemove && listId && ( + + )} +
+
+ ); +} + +export function MemberCardSkeleton() { + return ( +
+ +
+ + +
+ +
+ ); +} diff --git a/src/components/ReplyComposeModal.tsx b/src/components/ReplyComposeModal.tsx index a2b117fa..30e31216 100644 --- a/src/components/ReplyComposeModal.tsx +++ b/src/components/ReplyComposeModal.tsx @@ -8,11 +8,9 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { PortalContainerProvider } from '@/hooks/usePortalContainer'; -import { EmbeddedNote } from '@/components/EmbeddedNote'; -import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; +import { EmbeddedPost } from '@/components/EmbeddedPost'; import { ComposeBox } from '@/components/ComposeBox'; import { LinkEmbed } from '@/components/LinkEmbed'; -import { ProfilePreview } from '@/components/ExternalContentHeader'; import { cn } from '@/lib/utils'; interface ReplyComposeModalProps { @@ -129,7 +127,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
) : ( - + )}
)} @@ -164,36 +162,3 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu ); } - -/** - * Compact embedded preview of the post being replied to. - * Delegates to the shared EmbeddedNote / EmbeddedNaddr components used by - * quote posts and hover cards, so every context renders events consistently. - */ -function EmbeddedPost({ event }: { event: NostrEvent }) { - // Kind 0 (profile) — show a profile card instead of trying to render the raw JSON content - if (event.kind === 0) { - return ( -
- -
- ); - } - - // Addressable events (kind 30000-39999) — use EmbeddedNaddr - if (event.kind >= 30000 && event.kind < 40000) { - const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? ''; - return ( -
- -
- ); - } - - // Everything else — use EmbeddedNote (the event is already in the query cache) - return ( -
- -
- ); -} diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index 8ff75ddb..fb91c11a 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -1,6 +1,6 @@ import type { FeedSettings } from '@/contexts/AppContext'; import type { ComponentType } from 'react'; -import { Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert } from 'lucide-react'; +import { Globe, GitPullRequestArrow, MessageSquareMore, CircleAlert, UserCheck, Users } from 'lucide-react'; import { RepostIcon } from '@/components/icons/RepostIcon'; import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; @@ -343,8 +343,11 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ id: 'packs', showKey: 'showPacks', feedKey: 'feedIncludePacks', + // Also include related people-list kinds under the same feed toggle: + // kind 3 (NIP-02 follow list) and kind 30000 (NIP-51 follow set). + extraFeedKinds: [3, 30000], label: 'Follow Packs', - description: 'Curated follow recommendations', + description: 'Curated follow recommendations and lists', route: 'packs', addressable: true, section: 'social', @@ -544,9 +547,11 @@ export function getPageKinds(def: ExtraKindDef, feedSettings: FeedSettings): num * a label more specific than their parent category. */ const KIND_SPECIFIC_LABELS: Record = { + 3: 'follow list', 6: 'repost', 7: 'reaction', 16: 'repost', + 30000: 'follow set', 1617: 'patch', 1618: 'patch comment', 15128: 'nsite', @@ -563,8 +568,10 @@ const KIND_SPECIFIC_LABELS: Record = { * Specific icons for kinds that need a different icon than their parent category. */ const KIND_SPECIFIC_ICONS: Partial>> = { + 3: UserCheck, 6: RepostIcon, 16: RepostIcon, + 30000: Users, 1617: GitPullRequestArrow, 1618: MessageSquareMore, 15128: Globe, diff --git a/src/lib/packUtils.ts b/src/lib/packUtils.ts index 36f9f24a..86369909 100644 --- a/src/lib/packUtils.ts +++ b/src/lib/packUtils.ts @@ -1,12 +1,82 @@ -import type { NostrEvent } from '@nostrify/nostrify'; +import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; -/** Parse a follow pack / starter pack event into structured data. */ -export function parsePackEvent(event: NostrEvent) { +/** Kinds rendered as "people lists" — kind 3 follow lists, NIP-51 follow sets, and follow packs. */ +export const PEOPLE_LIST_KINDS = new Set([3, 30000, 39089]); + +/** Returns true if an event should be rendered with the shared people-list components. */ +export function isPeopleListKind(kind: number): boolean { + return PEOPLE_LIST_KINDS.has(kind); +} + +/** Classify the variant of a people-list event. */ +export type PeopleListVariant = 'follow-list' | 'follow-set' | 'follow-pack'; + +export function getPeopleListVariant(kind: number): PeopleListVariant | null { + if (kind === 3) return 'follow-list'; + if (kind === 30000) return 'follow-set'; + if (kind === 39089) return 'follow-pack'; + return null; +} + +/** + * Parsed people-list data. For kind 3 (follow lists) the event has no + * title/description/image of its own, so callers pass the author's metadata + * and we fall back to display name + about + banner. + */ +export interface ParsedPeopleList { + /** Human-readable title (never empty — falls back to a sensible default). */ + title: string; + /** Optional description. */ + description: string; + /** Optional cover image URL. */ + image?: string; + /** All pubkeys in the list (public `p` tags only — private items are handled separately). */ + pubkeys: string[]; + /** Variant of the list (for icon/copy choices). */ + variant: PeopleListVariant; +} + +/** + * Parse a people-list event (kind 3, 30000, or 39089) into structured data. + * + * For kind 3 (follow lists), the event carries no title/description/image of its + * own. If `authorMetadata` and `authorDisplayName` are supplied, they're used as + * a best-effort fallback so the card still has something meaningful to show. + */ +export function parsePeopleList( + event: NostrEvent, + opts?: { authorMetadata?: NostrMetadata; authorDisplayName?: string }, +): ParsedPeopleList { const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; - const title = getTag('title') || getTag('name') || 'Untitled Pack'; + const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk); + const variant = getPeopleListVariant(event.kind) ?? 'follow-pack'; + + if (event.kind === 3) { + // Kind 3 has no title/description/image of its own. Synthesize a title + // from the author's display name so the card has a label, but DON'T pull + // in `about` or `banner` — those describe the person, not their follow + // list, and leak profile bio into unrelated views. + const displayName = opts?.authorDisplayName || opts?.authorMetadata?.name || opts?.authorMetadata?.display_name; + const title = displayName ? `${displayName}'s follows` : 'Follow list'; + return { title, description: '', image: undefined, pubkeys, variant }; + } + + const title = getTag('title') || getTag('name') || 'Untitled'; const description = getTag('description') || getTag('summary') || ''; const image = getTag('image') || getTag('thumb') || getTag('banner'); - const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk); - - return { title, description, image, pubkeys }; + return { title, description, image, pubkeys, variant }; +} + +/** + * @deprecated Use {@link parsePeopleList} instead. Kept for backwards compatibility + * with a few callers that parse kind 30000/39089 without author metadata. + */ +export function parsePackEvent(event: NostrEvent) { + const { title, description, image, pubkeys } = parsePeopleList(event); + return { + title: title === 'Untitled' ? 'Untitled Pack' : title, + description, + image, + pubkeys, + }; } diff --git a/src/pages/FollowPage.tsx b/src/pages/FollowPage.tsx index 0d250c1b..e6dce676 100644 --- a/src/pages/FollowPage.tsx +++ b/src/pages/FollowPage.tsx @@ -23,7 +23,7 @@ import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useAddrEvent, type AddrCoords } from '@/hooks/useEvent'; import { fetchFreshEvent } from '@/lib/fetchFreshEvent'; import { parsePackEvent } from '@/lib/packUtils'; -import { PackFeedTab, MemberCard, MemberCardSkeleton } from '@/components/FollowPackDetailContent'; +import { PeopleListFeedTab, MemberCard, MemberCardSkeleton } from '@/components/PeopleListDetailContent'; import { genUserName } from '@/lib/genUserName'; import { ArcBackground, ARC_OVERHANG_PX } from '@/components/ArcBackground'; import { DittoLogo } from '@/components/DittoLogo'; @@ -589,7 +589,10 @@ function FollowPackView({ addr, relays }: { addr: AddrCoords; relays?: string[]
{activeTab === 'feed' ? ( - + ) : membersLoading ? (
{Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => ( diff --git a/src/pages/ListDetailPage.tsx b/src/pages/ListDetailPage.tsx deleted file mode 100644 index ad6f1cba..00000000 --- a/src/pages/ListDetailPage.tsx +++ /dev/null @@ -1,651 +0,0 @@ -/** - * ListDetailPage - * - * Full-page detail view for a NIP-51 List (kind 30000). - * Two tabs: Feed (posts from members) and Members (manage membership). - */ -import { useState, useMemo, useCallback } from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; -import { useNostr } from '@nostrify/react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useSeoMeta } from '@unhead/react'; -import { nip19 } from 'nostr-tools'; -import { - Users, UserPlus, Loader2, X, Rss, Share2, Check, Copy, Quote, PanelLeft, Trash2, -} from 'lucide-react'; -import { RepostIcon } from '@/components/icons/RepostIcon'; -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { getAvatarShape } from '@/lib/avatarShape'; -import { Button } from '@/components/ui/button'; -import { Skeleton } from '@/components/ui/skeleton'; -import { - DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { NoteCard } from '@/components/NoteCard'; -import { PageHeader } from '@/components/PageHeader'; -import { AddMembersDialog } from '@/components/AddMembersDialog'; -import { ReplyComposeModal } from '@/components/ReplyComposeModal'; -import { useAppContext } from '@/hooks/useAppContext'; -import { useCurrentUser } from '@/hooks/useCurrentUser'; -import { useUserLists } from '@/hooks/useUserLists'; -import { useNostrPublish } from '@/hooks/useNostrPublish'; -import { useAuthor } from '@/hooks/useAuthor'; -import { useAuthors } from '@/hooks/useAuthors'; -import { useFollowList, useFollowActions } from '@/hooks/useFollowActions'; -import { useStreamPosts } from '@/hooks/useStreamPosts'; -import { useMuteList } from '@/hooks/useMuteList'; -import { isEventMuted } from '@/lib/muteHelpers'; -import { genUserName } from '@/lib/genUserName'; -import { useProfileUrl } from '@/hooks/useProfileUrl'; -import { shareOrCopy } from '@/lib/share'; -import { getRepostKind } from '@/lib/feedUtils'; -import { DITTO_RELAY } from '@/lib/appRelays'; -import { toast } from '@/hooks/useToast'; -import { useFeedSettings } from '@/hooks/useFeedSettings'; -import { SubHeaderBar } from '@/components/SubHeaderBar'; -import { TabButton } from '@/components/TabButton'; -import { useLayoutOptions } from '@/contexts/LayoutContext'; -import type { NostrEvent } from '@nostrify/nostrify'; -import type { UserList } from '@/hooks/useUserLists'; -import NotFound from './NotFound'; - -/** Parse a kind 30000 event into a UserList shape (for remote lists). */ -function parseRemoteList(event: NostrEvent): UserList { - const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1]; - const id = getTag('d') ?? ''; - const title = getTag('title') || getTag('name') || id; - const description = getTag('description') || getTag('summary') || undefined; - const image = getTag('image') || getTag('thumb') || undefined; - const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk); - return { id, title, description, image, pubkeys, privatePubkeys: [], event }; -} - -type Tab = 'feed' | 'members'; - -// ─── Member Card ────────────────────────────────────────────────────────────── - -function MemberCard({ pubkey, isOwner, listId, onRemoved }: { - pubkey: string; - isOwner: boolean; - listId: string; - onRemoved?: () => void; -}) { - const author = useAuthor(pubkey); - const metadata = author.data?.metadata; - const avatarShape = getAvatarShape(metadata); - const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey); - const profileUrl = useProfileUrl(pubkey, metadata); - const { data: followData } = useFollowList(); - const { follow, unfollow, isPending: followPending } = useFollowActions(); - const { user } = useCurrentUser(); - const { removeFromList } = useUserLists(); - const [removing, setRemoving] = useState(false); - - const isFollowed = useMemo( - () => followData?.pubkeys.includes(pubkey) ?? false, - [followData?.pubkeys, pubkey], - ); - const isSelf = user?.pubkey === pubkey; - - const handleRemove = async () => { - setRemoving(true); - try { - await removeFromList.mutateAsync({ listId, pubkey }); - toast({ title: 'Removed from list' }); - onRemoved?.(); - } catch { - toast({ title: 'Failed to remove', variant: 'destructive' }); - } finally { - setRemoving(false); - } - }; - - return ( -
- - {author.isLoading ? ( - <> - -
- - -
- - ) : ( - <> - - - - {displayName[0]?.toUpperCase()} - - -
-
{displayName}
- {metadata?.nip05 && ( -
{metadata.nip05}
- )} - {metadata?.about && ( -
{metadata.about}
- )} -
- - )} - - -
- {/* Follow/Unfollow button */} - {!isSelf && user && ( - - )} - - {/* Remove button (owner only) */} - {isOwner && ( - - )} -
-
- ); -} - -// ─── Feed Tab ───────────────────────────────────────────────────────────────── - -function ListFeedTab({ list }: { list: UserList }) { - const { muteItems } = useMuteList(); - - const { posts, isLoading } = useStreamPosts('', { - includeReplies: false, - mediaType: 'all', - authorPubkeys: list.pubkeys, - }); - - const filteredPosts = useMemo(() => { - if (muteItems.length === 0) return posts; - return posts.filter((e) => !isEventMuted(e, muteItems)); - }, [posts, muteItems]); - - if (list.pubkeys.length === 0) { - return ( -
- -

No members in this list yet.

-

Add members to see their posts here.

-
- ); - } - - if (isLoading && filteredPosts.length === 0) { - return ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- -
- - - -
-
-
- ))} -
- ); - } - - if (filteredPosts.length === 0) { - return ( -
- No posts from list members yet. -
- ); - } - - return ( -
- {filteredPosts.map((event) => ( - - ))} -
- ); -} - -// ─── Members Tab ────────────────────────────────────────────────────────────── - -function ListMembersTab({ list, isOwner }: { list: UserList; isOwner: boolean }) { - const [addMembersOpen, setAddMembersOpen] = useState(false); - - return ( -
- {isOwner && ( -
- -
- )} - - {list.pubkeys.length === 0 ? ( -
- -

No members yet.

- {isOwner && ( -

Click "Add Members" to search for people.

- )} -
- ) : ( - list.pubkeys.map((pk) => ( - - )) - )} - - {isOwner && ( - - )} -
- ); -} - -// ─── Main Page ──────────────────────────────────────────────────────────────── - -export function ListDetailPage() { - const params = useParams<{ nip19: string }>(); - const naddr = params.nip19; - const navigate = useNavigate(); - const { nostr } = useNostr(); - const { config } = useAppContext(); - const { user } = useCurrentUser(); - const { lists, isLoading: ownListsLoading, createList } = useUserLists(); - const { mutate: publishEvent } = useNostrPublish(); - const queryClient = useQueryClient(); - const [activeTab, setActiveTab] = useState('feed'); - const [copied, setCopied] = useState(false); - const [cloning, setCloning] = useState(false); - const [quoteOpen, setQuoteOpen] = useState(false); - const { addToSidebar, removeFromSidebar, orderedItems } = useFeedSettings(); - - useLayoutOptions({ hasSubHeader: true }); - - // Decode the naddr to get the d-tag identifier and author - const decoded = useMemo(() => { - if (!naddr) return null; - try { - const result = nip19.decode(naddr); - if (result.type === 'naddr' && result.data.kind === 30000) { - return result.data; - } - } catch { - // Invalid naddr - } - return null; - }, [naddr]); - - const isOwnList = !!(decoded && user && decoded.pubkey === user.pubkey); - - // Nostr URI for "Add to sidebar" - const nostrUri = useMemo(() => { - if (!naddr) return null; - return `nostr:${naddr}`; - }, [naddr]); - - const isInSidebar = useMemo( - () => !!nostrUri && orderedItems.includes(nostrUri), - [nostrUri, orderedItems], - ); - - const handleAddToSidebar = useCallback(() => { - if (!nostrUri || isInSidebar) return; - addToSidebar(nostrUri); - toast({ title: 'Added to sidebar' }); - }, [nostrUri, isInSidebar, addToSidebar]); - - const handleRemoveFromSidebar = useCallback(() => { - if (!nostrUri || !isInSidebar) return; - removeFromSidebar(nostrUri); - toast({ title: 'Removed from sidebar' }); - }, [nostrUri, isInSidebar, removeFromSidebar]); - - // For own lists, use the local cache - const ownList = useMemo( - () => (isOwnList && decoded) ? lists.find((l) => l.id === decoded.identifier) ?? null : null, - [lists, decoded, isOwnList], - ); - - // For other people's lists, query the relay - const remoteListQuery = useQuery({ - queryKey: ['remote-list', decoded?.pubkey, decoded?.identifier], - queryFn: async ({ signal }) => { - if (!decoded) return null; - const events = await nostr.query( - [{ kinds: [30000], authors: [decoded.pubkey], '#d': [decoded.identifier], limit: 1 }], - { signal: AbortSignal.any([signal, AbortSignal.timeout(10000)]) }, - ); - if (events.length === 0) return null; - return parseRemoteList(events[0]); - }, - enabled: !!decoded && !isOwnList, - staleTime: 60 * 1000, - }); - - // Unified list object — own list takes priority - const list = isOwnList ? ownList : (remoteListQuery.data ?? null); - const isLoading = isOwnList ? ownListsLoading : remoteListQuery.isLoading; - - // Fetch the list author's profile - const listAuthor = useAuthor(decoded?.pubkey ?? ''); - const listAuthorMetadata = listAuthor.data?.metadata; - const listAuthorName = listAuthorMetadata?.name || listAuthorMetadata?.display_name || (decoded ? genUserName(decoded.pubkey) : ''); - const listAuthorAvatarShape = getAvatarShape(listAuthorMetadata); - const listAuthorProfileUrl = useProfileUrl(decoded?.pubkey ?? '', listAuthorMetadata); - - // Fetch preview avatars for the member stack - const previewPubkeys = useMemo(() => (list?.pubkeys ?? []).slice(0, 8), [list?.pubkeys]); - const { data: previewMembersMap } = useAuthors(previewPubkeys); - - const handleShare = useCallback(async () => { - const url = window.location.href; - const result = await shareOrCopy(url, list?.title); - if (result === 'copied') { - setCopied(true); - toast({ title: 'Link copied to clipboard' }); - setTimeout(() => setCopied(false), 2000); - } - }, [list?.title]); - - const handleRepost = useCallback(() => { - const event = list?.event; - if (!event) return; - if (!user) { - toast({ title: 'Please log in to repost', variant: 'destructive' }); - return; - } - - const repostKind = getRepostKind(event.kind); - const tags: string[][] = [ - ['e', event.id, DITTO_RELAY], - ['p', event.pubkey], - ]; - if (repostKind === 16) { - tags.push(['k', String(event.kind)]); - if (event.kind >= 30000 && event.kind < 40000) { - const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? ''; - tags.push(['a', `${event.kind}:${event.pubkey}:${dTag}`]); - } - } - - publishEvent( - { kind: repostKind, content: '', created_at: Math.floor(Date.now() / 1000), tags }, - { - onSuccess: () => { - toast({ title: 'Reposted!' }); - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['event-stats', event.id] }); - queryClient.invalidateQueries({ queryKey: ['event-interactions', event.id] }); - queryClient.invalidateQueries({ queryKey: ['user-repost', event.id] }); - }, 3000); - }, - onError: () => { - toast({ title: 'Failed to repost', variant: 'destructive' }); - }, - }, - ); - }, [list?.event, user, publishEvent, queryClient]); - - const handleClone = useCallback(async () => { - if (!list || !user || cloning) return; - setCloning(true); - try { - await createList.mutateAsync({ - title: list.title, - description: list.description, - pubkeys: list.pubkeys, - }); - toast({ title: `List "${list.title}" saved to your lists` }); - } catch { - toast({ title: 'Failed to save list', variant: 'destructive' }); - } finally { - setCloning(false); - } - }, [list, user, cloning, createList]); - - useSeoMeta({ - title: list ? `${list.title} | ${config.appName}` : `List | ${config.appName}`, - description: list ? `${list.title} — ${list.pubkeys.length} members` : 'Nostr List', - }); - - // Loading state - if (isLoading) { - return ( -
- navigate(-1)} titleContent={} /> -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
- -
- - -
-
-
- ))} -
-
- ); - } - - // Not found - if (!list) { - return ; - } - - return ( -
- {/* Header */} - window.history.length > 1 ? navigate(-1) : navigate('/lists')} - titleContent={ -
-

{list.title}

- {decoded && ( - - - - - {listAuthorName[0]?.toUpperCase()} - - - - {listAuthorName} - - - )} -
- } - > -
- {user && !isOwnList && ( - - )} - - - - - - - {copied ? : } - Share - - - - Repost - - setQuoteOpen(true)} className="gap-3"> - - Quote post - - {nostrUri && ( - isInSidebar ? ( - - - Remove from sidebar - - ) : ( - - - Add to sidebar - - ) - )} - - -
-
- - {/* Description and image */} - {(list.description || list.image) && ( -
- {list.image && ( -
- {list.title} { - (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; - }} - /> -
- )} - {list.description && ( -

{list.description}

- )} -
- )} - - {/* Member avatar stack */} - {list.pubkeys.length > 0 && ( -
-
- {previewPubkeys.map((pk) => { - const member = previewMembersMap?.get(pk); - const name = member?.metadata?.name || genUserName(pk); - const shape = getAvatarShape(member?.metadata); - return ( - - - - {name[0]?.toUpperCase()} - - - ); - })} -
- {list.pubkeys.length > previewPubkeys.length && ( - - )} -
- )} - - {/* Tab bar */} - - setActiveTab('feed')} - > - - - Feed - - - setActiveTab('members')} - > - - - Members - ({list.pubkeys.length}) - - - - - {/* Tab content */} - {activeTab === 'feed' ? ( - - ) : ( - - )} - - {list.event && ( - - )} -
- ); -} diff --git a/src/pages/NIP19Page.tsx b/src/pages/NIP19Page.tsx index 17680396..da10aa7d 100644 --- a/src/pages/NIP19Page.tsx +++ b/src/pages/NIP19Page.tsx @@ -5,7 +5,6 @@ import { useQuery } from '@tanstack/react-query'; import NotFound from './NotFound'; import { ProfilePage } from './ProfilePage'; import { PostDetailPage, AddrPostDetailPage, PostDetailShell, PostDetailSkeleton } from './PostDetailPage'; -import { ListDetailPage } from './ListDetailPage'; import type { AddressPointer } from 'nostr-tools/nip19'; const HEX_64_RE = /^[0-9a-f]{64}$/; @@ -113,9 +112,6 @@ export function NIP19Page() { case 'naddr': { const addr = decoded.data as AddressPointer; - if (addr.kind === 30000) { - return ; - } return ; } diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index f759919d..4d942b4d 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -39,8 +39,8 @@ import { const BlobbiStateCard = lazy(() => import("@/components/BlobbiStateCard").then(m => ({ default: m.BlobbiStateCard }))); const CustomNipCard = lazy(() => import("@/components/CustomNipCard").then(m => ({ default: m.CustomNipCard }))); import { FileMetadataContent } from "@/components/FileMetadataContent"; -import { FollowPackContent } from "@/components/FollowPackContent"; -import { FollowPackDetailContent } from "@/components/FollowPackDetailContent"; +import { PeopleListContent } from "@/components/PeopleListContent"; +import { PeopleListDetailContent } from "@/components/PeopleListDetailContent"; import { FoundLogContent } from "@/components/FoundLogContent"; import { GeocacheContent } from "@/components/GeocacheContent"; import { GitRepoCard } from "@/components/GitRepoCard"; @@ -92,8 +92,8 @@ import { type AddrCoords, useAddrEvent, useEvent } from "@/hooks/useEvent"; import { usePollVoteLabel } from "@/hooks/usePollVoteLabel"; import { formatNumber } from "@/lib/formatNumber"; -/** Kinds that get the full follow-pack detail view. */ -const FOLLOW_PACK_KINDS = new Set([30000, 39089]); +/** Kinds that get the full people-list detail view (follow list / set / pack). */ +const PEOPLE_LIST_KINDS = new Set([3, 30000, 39089]); /** Kind 30311 = NIP-53 Live Activities. */ const LIVE_STREAM_KIND = 30311; @@ -129,7 +129,9 @@ function shellTitleForKind(kind?: number): string { if (MUSIC_KINDS.has(kind)) return "Track Details"; if (PODCAST_KINDS.has(kind)) return "Episode Details"; if (CALENDAR_EVENT_KINDS.has(kind)) return "Event Details"; - if (FOLLOW_PACK_KINDS.has(kind)) return "Follow Pack"; + if (kind === 3) return "Follow List"; + if (kind === 30000) return "Follow Set"; + if (kind === 39089) return "Follow Pack"; if (kind === LIVE_STREAM_KIND) return "Live Stream"; if (kind === 30617) return "Repository"; if (kind === 1617) return "Patch"; @@ -303,6 +305,17 @@ export function PostDetailPage({ ); } + // People lists (kind 3 / 30000 / 39089) get their own full detail view + if (PEOPLE_LIST_KINDS.has(resolvedEvent.kind)) { + return ( + + + + + + ); + } + return ( @@ -348,12 +361,12 @@ export function AddrPostDetailPage({ addr, relays }: AddrPostDetailPageProps) { ); } - // Follow packs get their own full detail view with member list + Follow All - if (FOLLOW_PACK_KINDS.has(resolvedEvent.kind)) { + // People lists (kind 3 / 30000 / 39089) get their own full detail view with member list + Follow All + if (PEOPLE_LIST_KINDS.has(resolvedEvent.kind)) { return ( - + ); @@ -994,7 +1007,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const isGeocache = event.kind === 37516; const isFoundLog = event.kind === 7516; const isColor = event.kind === 3367; - const isFollowPack = event.kind === 39089 || event.kind === 30000; + const isPeopleList = event.kind === 3 || event.kind === 30000 || event.kind === 39089; const isEmojiPack = event.kind === 30030; const isArticle = event.kind === 30023; const isMagicDeck = event.kind === 37381; @@ -1030,7 +1043,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { !isGeocache && !isFoundLog && !isColor && - !isFollowPack && + !isPeopleList && !isEmojiPack && !isArticle && !isMagicDeck && @@ -2158,7 +2171,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { isGeocache || isFoundLog || isColor || - isFollowPack || + isPeopleList || isEmojiPack ? ( <> {isVine && } @@ -2166,7 +2179,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { {isGeocache && } {isFoundLog && } {isColor && } - {isFollowPack && } + {isPeopleList && } {isEmojiPack && } ) : ( diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 84da59eb..2233fab3 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -453,46 +453,6 @@ function FollowingUserRow({ pubkey, onNavigate }: { pubkey: string; onNavigate?: ); } -// ----- Following List Modal ----- - -interface FollowingListModalProps { - pubkeys: string[]; - open: boolean; - onOpenChange: (open: boolean) => void; - displayName: string; -} - -function FollowingListModal({ pubkeys, open, onOpenChange, displayName }: FollowingListModalProps) { - const handleNavigate = useCallback(() => onOpenChange(false), [onOpenChange]); - - return ( - - -
- {displayName} follows - -
- - {pubkeys.length === 0 ? ( -
- Not following anyone yet. -
- ) : ( - pubkeys.map((pk) => ) - )} -
-
-
- ); -} - type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; function SortableTabChip({ @@ -957,7 +917,6 @@ export function ProfilePage() { const [sidebarMediaUrl, setSidebarMediaUrl] = useState(null); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [followQROpen, setFollowQROpen] = useState(false); - const [followingModalOpen, setFollowingModalOpen] = useState(false); const [followersModalOpen, setFollowersModalOpen] = useState(false); const [lightboxImage, setLightboxImage] = useState(null); @@ -2201,15 +2160,15 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab }; followers )} - {profileFollowing && profileFollowing.count > 0 && ( - + )} {streak > 1 && (
)} - {/* Following List Modal */} - {profileFollowing && profileFollowing.count > 0 && ( - - )} - {/* Followers List Modal */} {pubkey && followersCount > 0 && ( Date: Fri, 17 Apr 2026 19:34:53 -0500 Subject: [PATCH 10/19] release: v2.10.0 --- CHANGELOG.md | 15 +++++++++++++++ android/app/build.gradle | 2 +- ios/App/App.xcodeproj/project.pbxproj | 4 ++-- package.json | 2 +- public/CHANGELOG.md | 15 +++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad3beff0..339411f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2.10.0] - 2026-04-17 + +### Added +- Follow lists, follow sets, and follow packs now share a unified detail view with Feed, Members, and Comments tabs, plus infinite scroll through posts from everyone on the list +- Follow All button lets you follow everyone on someone's list or pack in a single tap, and a Save button copies any list to your own account +- Tapping the "Following" count on a profile now opens a proper shareable page for that follow list instead of a modal + +### Changed +- Quote posts, replies, and hover cards now show rich previews when someone shares a follow list or pack +- Blobbi care actions in your feed now read as "cared for their Blobbi" instead of "updated their Blobbi" + +### Fixed +- Stuck pinch-to-zoom on iOS that could lock the app in a zoomed-out state after a stray gesture +- Profile and follow-list links that point at legacy replaceable events now resolve correctly instead of showing a "not found" state + ## [2.9.0] - 2026-04-17 ### Added diff --git a/android/app/build.gradle b/android/app/build.gradle index fa261eeb..82a28912 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,7 +14,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "2.9.0" + versionName "2.10.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 19905f37..52178650 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -327,7 +327,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.9.0; + MARKETING_VERSION = 2.10.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -351,7 +351,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.9.0; + MARKETING_VERSION = 2.10.0; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/package.json b/package.json index d9118dd0..3b2c5d7b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ditto", "private": true, - "version": "2.9.0", + "version": "2.10.0", "type": "module", "scripts": { "dev": "npm i --silent && vite", diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index ad3beff0..339411f3 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2.10.0] - 2026-04-17 + +### Added +- Follow lists, follow sets, and follow packs now share a unified detail view with Feed, Members, and Comments tabs, plus infinite scroll through posts from everyone on the list +- Follow All button lets you follow everyone on someone's list or pack in a single tap, and a Save button copies any list to your own account +- Tapping the "Following" count on a profile now opens a proper shareable page for that follow list instead of a modal + +### Changed +- Quote posts, replies, and hover cards now show rich previews when someone shares a follow list or pack +- Blobbi care actions in your feed now read as "cared for their Blobbi" instead of "updated their Blobbi" + +### Fixed +- Stuck pinch-to-zoom on iOS that could lock the app in a zoomed-out state after a stray gesture +- Profile and follow-list links that point at legacy replaceable events now resolve correctly instead of showing a "not found" state + ## [2.9.0] - 2026-04-17 ### Added From a2f2d9ff894df707fcf8ce7b4a55ac74732aa909 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 22:39:44 -0500 Subject: [PATCH 11/19] Add configurable shareOrigin to AppConfig window.location.origin resolves to capacitor://localhost on iOS and https://localhost on Android, which produces broken QR codes, broken copy-link actions, and a broken remote-login callback URL on native builds. Add an optional shareOrigin field to AppConfig and a useShareOrigin hook that falls back to window.location.origin when unset. Replace all 13 call sites that build shareable URLs. The origin can be configured three ways, in order of precedence: user localStorage > ditto.json > VITE_SHARE_ORIGIN env var. Native deployments can set VITE_SHARE_ORIGIN=https://ditto.pub at build time so that shared URLs resolve correctly when opened on another device (and get caught by DeepLinkHandler when opened on the same app via Universal/App Links). Regression-of: a12d5db5 --- .env.example | 4 ++++ package-lock.json | 4 ++-- src/App.tsx | 1 + src/components/CalendarEventDetailPage.tsx | 6 ++++-- src/components/CommunityContent.tsx | 6 ++++-- src/components/CreateBadgeDialog.tsx | 6 ++++-- src/components/ExternalContentHeader.tsx | 6 ++++-- src/components/FollowQRDialog.tsx | 4 +++- src/components/NoteCard.tsx | 4 +++- src/components/NoteMoreMenu.tsx | 4 +++- src/components/PostActionBar.tsx | 6 ++++-- src/components/auth/LoginDialog.tsx | 6 ++++-- src/components/letter/LetterCard.tsx | 4 +++- src/contexts/AppContext.ts | 8 ++++++++ src/hooks/useShareOrigin.ts | 21 +++++++++++++++++++++ src/lib/schemas.ts | 1 + src/pages/BlueskyPage.tsx | 6 ++++-- src/pages/PostDetailPage.tsx | 6 ++++-- src/pages/ProfilePage.tsx | 4 +++- src/vite-env.d.ts | 8 ++++++++ 20 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 src/hooks/useShareOrigin.ts diff --git a/.env.example b/.env.example index 34de72d5..c3bdcd48 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,9 @@ VITE_PLAUSIBLE_DOMAIN="example.tld" VITE_PLAUSIBLE_ENDPOINT="https://plausible.example.tld/api/event" # Hex pubkey of the nostr-push server (found in nostr-push startup logs as "worker_pubkey") VITE_NOSTR_PUSH_PUBKEY="" +# Canonical origin used when generating shareable URLs (QR codes, copy-link, remote-login callbacks). +# Primarily useful for native (Capacitor) builds, where window.location.origin is capacitor://localhost. +# Example: VITE_SHARE_ORIGIN="https://ditto.pub" +VITE_SHARE_ORIGIN="" # Set to "*" to allow any host in the Vite dev server (eg. when proxying through a custom domain) # ALLOWED_HOSTS="*" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 6f7116b6..d48db701 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ditto", - "version": "2.9.0", + "version": "2.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.9.0", + "version": "2.10.0", "dependencies": { "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", diff --git a/src/App.tsx b/src/App.tsx index c697cd89..793e7208 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -50,6 +50,7 @@ const queryClient = new QueryClient({ const hardcodedConfig: AppConfig = { appName: "Ditto", appId: "ditto", + shareOrigin: import.meta.env.VITE_SHARE_ORIGIN || undefined, homePage: "feed", client: "naddr1qvzqqqru7cpzq7q6z5ns2hm5c8msyv83qwzxpxe52j8c4d4q5m92wsp9sflelkh9qqzkg6t5w3hswjl4yp", magicMouse: false, diff --git a/src/components/CalendarEventDetailPage.tsx b/src/components/CalendarEventDetailPage.tsx index 506055d8..f8e88bde 100644 --- a/src/components/CalendarEventDetailPage.tsx +++ b/src/components/CalendarEventDetailPage.tsx @@ -32,6 +32,7 @@ import { useEventRSVPs } from '@/hooks/useEventRSVPs'; import { useMyRSVP } from '@/hooks/useMyRSVP'; import { usePublishRSVP } from '@/hooks/usePublishRSVP'; import { useProfileUrl } from '@/hooks/useProfileUrl'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; @@ -153,6 +154,7 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { const navigate = useNavigate(); const { user } = useCurrentUser(); const { toast } = useToast(); + const shareOrigin = useShareOrigin(); const title = getTag(event.tags, 'title') ?? 'Untitled Event'; const image = getTag(event.tags, 'image'); @@ -215,14 +217,14 @@ export function CalendarEventDetailPage({ event }: { event: NostrEvent }) { pubkey: event.pubkey, identifier: d, }); - const url = `${window.location.origin}/${naddr}`; + const url = `${shareOrigin}/${naddr}`; try { await navigator.clipboard.writeText(url); toast({ title: 'Link copied to clipboard' }); } catch { toast({ title: 'Failed to copy link', variant: 'destructive' }); } - }, [event, toast]); + }, [event, toast, shareOrigin]); const isAuthor = user?.pubkey === event.pubkey; const showRSVP = !!user && !isAuthor; diff --git a/src/components/CommunityContent.tsx b/src/components/CommunityContent.tsx index ebb82460..08d9dc8a 100644 --- a/src/components/CommunityContent.tsx +++ b/src/components/CommunityContent.tsx @@ -12,6 +12,7 @@ import { Separator } from '@/components/ui/separator'; import { useAuthor } from '@/hooks/useAuthor'; import { useAuthors } from '@/hooks/useAuthors'; import { useProfileUrl } from '@/hooks/useProfileUrl'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; import { useToast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; import { cn } from '@/lib/utils'; @@ -75,6 +76,7 @@ function ModeratorRow({ pubkey }: { pubkey: string }) { export function CommunityContent({ event }: { event: NostrEvent }) { const { toast } = useToast(); + const shareOrigin = useShareOrigin(); const { name, description, image, moderators, relays } = useMemo( () => parseCommunityEvent(event), [event], @@ -109,14 +111,14 @@ export function CommunityContent({ event }: { event: NostrEvent }) { pubkey: event.pubkey, identifier: d, }); - const url = `${window.location.origin}/${naddr}`; + const url = `${shareOrigin}/${naddr}`; try { await navigator.clipboard.writeText(url); toast({ title: 'Link copied to clipboard' }); } catch { toast({ title: 'Failed to copy link', variant: 'destructive' }); } - }, [event, toast]); + }, [event, toast, shareOrigin]); return (
diff --git a/src/components/CreateBadgeDialog.tsx b/src/components/CreateBadgeDialog.tsx index 1c9e8d29..05fdc730 100644 --- a/src/components/CreateBadgeDialog.tsx +++ b/src/components/CreateBadgeDialog.tsx @@ -23,6 +23,7 @@ import { useAwardBadge } from '@/hooks/useAwardBadge'; import { useAcceptBadge } from '@/hooks/useAcceptBadge'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useToast } from '@/hooks/useToast'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; import { BADGE_DEFINITION_KIND } from '@/lib/badgeUtils'; /** Convert a badge name into a URL-safe slug for the d-tag identifier. */ @@ -43,6 +44,7 @@ interface CreateBadgeDialogProps { export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps) { const { user } = useCurrentUser(); const { toast } = useToast(); + const shareOrigin = useShareOrigin(); // Form state const [name, setName] = useState(''); @@ -175,11 +177,11 @@ export function CreateBadgeDialog({ open, onOpenChange }: CreateBadgeDialogProps pubkey: createdBadge.pubkey, identifier: dTag, }); - navigator.clipboard.writeText(`${window.location.origin}/${naddr}`); + navigator.clipboard.writeText(`${shareOrigin}/${naddr}`); setCopied(true); toast({ title: 'Link copied to clipboard!' }); setTimeout(() => setCopied(false), 2000); - }, [createdBadge, toast]); + }, [createdBadge, toast, shareOrigin]); if (!user) return null; diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 27b587af..9ce381e4 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -22,6 +22,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useWeather } from '@/hooks/useWeather'; import { useToast } from '@/hooks/useToast'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; import { genUserName } from '@/lib/genUserName'; import { getCountryInfo, getWikipediaTitle } from '@/lib/countries'; import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; @@ -76,6 +77,7 @@ function blueskyTimeAgo(dateStr: string): string { function BlueskyPostHeader({ author, rkey, url }: { author: string; rkey: string; url: string }) { const { data: post, isLoading, isError } = useBlueskyPost(author, rkey); const { toast } = useToast(); + const shareOrigin = useShareOrigin(); const profileUrl = `/i/${encodeURIComponent(`https://bsky.app/profile/${post?.handle ?? author}`)}`; const externalContent = useMemo(() => parseExternalUri(url), [url]); @@ -95,12 +97,12 @@ function BlueskyPostHeader({ author, rkey, url }: { author: string; rkey: string const handleShare = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); - const fullUrl = `${window.location.origin}/i/${encodeURIComponent(url)}`; + const fullUrl = `${shareOrigin}/i/${encodeURIComponent(url)}`; const result = await shareOrCopy(fullUrl); if (result === 'copied') { toast({ title: 'Link copied' }); } - }, [url, toast]); + }, [url, toast, shareOrigin]); if (isLoading) { return ( diff --git a/src/components/FollowQRDialog.tsx b/src/components/FollowQRDialog.tsx index d9269278..3a34c0c7 100644 --- a/src/components/FollowQRDialog.tsx +++ b/src/components/FollowQRDialog.tsx @@ -8,6 +8,7 @@ import { getAvatarShape } from '@/lib/avatarShape'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useAuthor } from '@/hooks/useAuthor'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; import { genUserName } from '@/lib/genUserName'; import { getThemedQRColors } from '@/lib/qrColors'; @@ -19,6 +20,7 @@ interface FollowQRDialogProps { export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) { const { user } = useCurrentUser(); const author = useAuthor(user?.pubkey ?? ''); + const shareOrigin = useShareOrigin(); const [qrDataUrl, setQrDataUrl] = useState(''); const [copied, setCopied] = useState(false); @@ -26,7 +28,7 @@ export function FollowQRDialog({ open, onOpenChange }: FollowQRDialogProps) { const displayName = user ? metadata?.name || genUserName(user.pubkey) : ''; const npub = user ? nip19.npubEncode(user.pubkey) : ''; - const followUrl = npub ? `${window.location.origin}/follow/${npub}` : ''; + const followUrl = npub ? `${shareOrigin}/follow/${npub}` : ''; useEffect(() => { if (!followUrl || !open) return; diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx index 2aecfe45..bc238868 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -91,6 +91,7 @@ import { useCurrentUser } from "@/hooks/useCurrentUser"; import { useNip05Verify } from "@/hooks/useNip05Verify"; import { useOpenPost } from "@/hooks/useOpenPost"; import { useProfileUrl } from "@/hooks/useProfileUrl"; +import { useShareOrigin } from "@/hooks/useShareOrigin"; import { toast } from "@/hooks/useToast"; import { useEventStats } from "@/hooks/useTrending"; import { canZap } from "@/lib/canZap"; @@ -341,6 +342,7 @@ export const NoteCard = memo(function NoteCard({ ); const profileUrl = useProfileUrl(event.pubkey, metadata); const encodedId = useMemo(() => encodeEventId(event), [event]); + const shareOrigin = useShareOrigin(); const { data: stats } = useEventStats(event.id, event); const [moreMenuOpen, setMoreMenuOpen] = useState(false); const [replyOpen, setReplyOpen] = useState(false); @@ -807,7 +809,7 @@ export const NoteCard = memo(function NoteCard({ onClick={async (e) => { e.stopPropagation(); impactLight(); - const url = `${window.location.origin}/${encodedId}`; + const url = `${shareOrigin}/${encodedId}`; const result = await shareOrCopy(url); if (result === "copied") toast({ title: "Link copied to clipboard" }); }} diff --git a/src/components/NoteMoreMenu.tsx b/src/components/NoteMoreMenu.tsx index 086e7d1d..4f0ef095 100644 --- a/src/components/NoteMoreMenu.tsx +++ b/src/components/NoteMoreMenu.tsx @@ -49,6 +49,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useMuteList } from '@/hooks/useMuteList'; import { useDeleteEvent } from '@/hooks/useDeleteEvent'; import { useFeedSettings } from '@/hooks/useFeedSettings'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; import { genUserName } from '@/lib/genUserName'; import { toast } from '@/hooks/useToast'; import { impactLight } from '@/lib/haptics'; @@ -308,6 +309,7 @@ interface NoteMoreMenuContentProps extends NoteMoreMenuProps { function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, onAddToList, onViewEventJson, onDelete }: NoteMoreMenuContentProps) { const navigate = useNavigate(); const { user } = useCurrentUser(); + const shareOrigin = useShareOrigin(); const { isBookmarked, toggleBookmark } = useBookmarks(); const bookmarked = isBookmarked(event.id); const { isPinned, togglePin } = usePinnedNotes(user?.pubkey); @@ -331,7 +333,7 @@ function NoteMoreMenuContent({ event, open, onOpenChange, onReport, onMention, o }; const handleCopyLink = () => { - const url = `${window.location.origin}/${nip19Id}`; + const url = `${shareOrigin}/${nip19Id}`; navigator.clipboard.writeText(url); toast({ title: 'Link copied to clipboard' }); close(); diff --git a/src/components/PostActionBar.tsx b/src/components/PostActionBar.tsx index cdf002da..63a4cc0a 100644 --- a/src/components/PostActionBar.tsx +++ b/src/components/PostActionBar.tsx @@ -10,6 +10,7 @@ import { ZapDialog } from '@/components/ZapDialog'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useEventStats } from '@/hooks/useTrending'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; import { useToast } from '@/hooks/useToast'; import { canZap } from '@/lib/canZap'; import { formatNumber } from '@/lib/formatNumber'; @@ -34,6 +35,7 @@ export function PostActionBar({ }: PostActionBarProps) { const { toast } = useToast(); const { user } = useCurrentUser(); + const shareOrigin = useShareOrigin(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const canZapAuthor = user && canZap(metadata); @@ -51,10 +53,10 @@ export function PostActionBar({ } else { encoded = nip19.neventEncode({ id: event.id, author: event.pubkey }); } - const url = `${window.location.origin}/${encoded}`; + const url = `${shareOrigin}/${encoded}`; const result = await shareOrCopy(url); if (result === 'copied') toast({ title: 'Link copied to clipboard' }); - }, [event, toast]); + }, [event, toast, shareOrigin]); return (
diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 3a3f4129..7765527c 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -20,6 +20,7 @@ import { getNsecCredential } from '@/lib/credentialManager'; import { DialogTitle } from '@radix-ui/react-dialog'; import { useAppContext } from '@/hooks/useAppContext'; import { useIsMobile } from '@/hooks/useIsMobile'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; interface LoginDialogProps { isOpen: boolean; @@ -38,6 +39,7 @@ const validateBunkerUri = (uri: string) => { const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onSignupClick }) => { const { config } = useAppContext(); + const shareOrigin = useShareOrigin(); const [isLoading, setIsLoading] = useState(false); const [isFileLoading, setIsFileLoading] = useState(false); const [nsec, setNsec] = useState(''); @@ -70,12 +72,12 @@ const LoginDialog: React.FC = ({ isOpen, onClose, onLogin, onS const isMobileDevice = typeof navigator !== 'undefined' && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); const uri = generateNostrConnectURI(params, { name: config.appName, - callback: isMobileDevice ? `${window.location.origin}/remoteloginsuccess` : undefined, + callback: isMobileDevice ? `${shareOrigin}/remoteloginsuccess` : undefined, }); setNostrConnectParams(params); setNostrConnectUri(uri); setConnectError(null); - }, [login, config.appName]); + }, [login, config.appName, shareOrigin]); // Start listening for connection (async) - runs after params are set. useEffect(() => { diff --git a/src/components/letter/LetterCard.tsx b/src/components/letter/LetterCard.tsx index 2bacc8e1..183f2783 100644 --- a/src/components/letter/LetterCard.tsx +++ b/src/components/letter/LetterCard.tsx @@ -6,6 +6,7 @@ import { useAuthor } from '@/hooks/useAuthor'; import { useDecryptLetter } from '@/hooks/useLetters'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; import { useQueryClient } from '@tanstack/react-query'; import { toast } from '@/hooks/useToast'; import { genUserName } from '@/lib/genUserName'; @@ -51,13 +52,14 @@ export function LetterCard({ letter, mode }: LetterCardProps) { const { user } = useCurrentUser(); const { mutate: publishEvent } = useNostrPublish(); const queryClient = useQueryClient(); + const shareOrigin = useShareOrigin(); const displayName = author.data?.metadata?.name || genUserName(otherPubkey); const avatar = author.data?.metadata?.picture; const npub = nip19.npubEncode(otherPubkey); const noteId = nip19.noteEncode(letter.event.id); - const letterUrl = `${window.location.origin}/${noteId}`; + const letterUrl = `${shareOrigin}/${noteId}`; const isOwnLetter = user?.pubkey === letter.sender; const handleCopyLink = (e: React.MouseEvent) => { diff --git a/src/contexts/AppContext.ts b/src/contexts/AppContext.ts index 78a6f656..e6c9e43a 100644 --- a/src/contexts/AppContext.ts +++ b/src/contexts/AppContext.ts @@ -188,6 +188,14 @@ export interface AppConfig { appName: string; /** Application identifier used as a prefix for application-specific metadata (NIP-78 d-tags, etc). Default: "ditto". */ appId: string; + /** + * Canonical origin used when generating shareable URLs (QR codes, copy-link, + * remote-login callbacks, etc). Falls back to `window.location.origin` when + * unset. Configure this in `ditto.json` for native builds, where + * `window.location.origin` is `capacitor://localhost` or `https://localhost`. + * Must NOT include a trailing slash. + */ + shareOrigin?: string; /** Sidebar item ID to display on the homepage ("/"). Default: "feed". */ homePage: string; /** Display name used in the NIP-89 "client" tag. Falls back to `appName` when not set. */ diff --git a/src/hooks/useShareOrigin.ts b/src/hooks/useShareOrigin.ts new file mode 100644 index 00000000..10440bdd --- /dev/null +++ b/src/hooks/useShareOrigin.ts @@ -0,0 +1,21 @@ +import { useAppContext } from '@/hooks/useAppContext'; + +/** + * Returns the origin to use when building shareable URLs (QR codes, + * copy-link, remote-login callbacks, etc). Prefers `config.shareOrigin` + * when set, otherwise falls back to `window.location.origin`. + * + * The returned value never has a trailing slash. + * + * Why this exists: on Capacitor, `window.location.origin` resolves to + * `capacitor://localhost` (iOS) or `https://localhost` (Android), which + * produces broken shareable URLs. Native builds should configure + * `shareOrigin` in `ditto.json` so that QR codes, copy-link actions, and + * remote-login callbacks resolve to the canonical web origin instead. + */ +export function useShareOrigin(): string { + const { config } = useAppContext(); + const configured = config.shareOrigin?.replace(/\/+$/, ''); + if (configured) return configured; + return typeof window !== 'undefined' ? window.location.origin : ''; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index c1073b6d..71518ced 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -214,6 +214,7 @@ export const SavedFeedSchema = z.object({ export const AppConfigSchema = z.object({ appName: z.string().optional(), appId: z.string().optional(), + shareOrigin: z.string().url().optional(), homePage: z.string().optional(), clientName: z.string().optional(), /** NIP-19 naddr1 string for the kind 31990 handler event. */ diff --git a/src/pages/BlueskyPage.tsx b/src/pages/BlueskyPage.tsx index d6b5972d..e80c9556 100644 --- a/src/pages/BlueskyPage.tsx +++ b/src/pages/BlueskyPage.tsx @@ -26,6 +26,7 @@ import { useBlueskyActorSearch, type BlueskyActorResult } from '@/hooks/useBlues import { BlueskyIcon } from '@/components/icons/BlueskyIcon'; import { shareOrCopy } from '@/lib/share'; import { parseExternalUri } from '@/lib/externalContent'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; import { useToast } from '@/hooks/useToast'; import { cn } from '@/lib/utils'; @@ -89,6 +90,7 @@ function timeAgo(dateStr: string): string { function BlueskyFeedPost({ post }: { post: BlueskyPost }) { const navigate = useNavigate(); const { toast } = useToast(); + const shareOrigin = useShareOrigin(); const webUrl = postWebUrl(post.uri, post.author.handle); const internalUrl = dittoUrl(webUrl); @@ -114,12 +116,12 @@ function BlueskyFeedPost({ post }: { post: BlueskyPost }) { const handleShare = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); - const fullUrl = `${window.location.origin}${internalUrl}`; + const fullUrl = `${shareOrigin}${internalUrl}`; const result = await shareOrCopy(fullUrl); if (result === 'copied') { toast({ title: 'Link copied' }); } - }, [internalUrl, toast]); + }, [internalUrl, toast, shareOrigin]); const handleCardClick = useCallback(() => { navigate(internalUrl); diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 4d942b4d..53f84224 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -177,6 +177,7 @@ import { useEventInteractions, extractZapAmount, extractZapSender, extractZapMes import { useMuteList } from "@/hooks/useMuteList"; import { useProfileUrl } from "@/hooks/useProfileUrl"; import { useReplies } from "@/hooks/useReplies"; +import { useShareOrigin } from "@/hooks/useShareOrigin"; import { toast } from "@/hooks/useToast"; import { useEventStats } from "@/hooks/useTrending"; import type { Nip85EventStats } from "@/hooks/useNip85Stats"; @@ -951,6 +952,7 @@ function BookReviewRating({ event }: { event: NostrEvent }) { function PostDetailContent({ event }: { event: NostrEvent }) { const { muteItems } = useMuteList(); const queryClient = useQueryClient(); + const shareOrigin = useShareOrigin(); const author = useAuthor(event.pubkey); const metadata = author.data?.metadata; const avatarShape = getAvatarShape(metadata); @@ -995,10 +997,10 @@ function PostDetailContent({ event }: { event: NostrEvent }) { }, [event]); const handleShare = useCallback(async () => { - const url = `${window.location.origin}/${encodedEventId}`; + const url = `${shareOrigin}/${encodedEventId}`; const result = await shareOrCopy(url); if (result === "copied") toast({ title: "Link copied to clipboard" }); - }, [encodedEventId]); + }, [encodedEventId, shareOrigin]); // Kind detection — mirrors NoteCard const isVine = event.kind === 34236; diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index 2233fab3..ac323833 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -61,6 +61,7 @@ import { VideoPlayer } from '@/components/VideoPlayer'; import { useActiveProfileTheme } from '@/hooks/useActiveProfileTheme'; import { usePublishTheme } from '@/hooks/usePublishTheme'; +import { useShareOrigin } from '@/hooks/useShareOrigin'; import { useTheme } from '@/hooks/useTheme'; import { useUserStatus } from '@/hooks/useUserStatus'; import { useLocalStorage } from '@/hooks/useLocalStorage'; @@ -171,6 +172,7 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile const { toast } = useToast(); const { user } = useCurrentUser(); const navigate = useNavigate(); + const shareOrigin = useShareOrigin(); const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]); const { addMute, removeMute, isMuted } = useMuteList(); const userMuted = isMuted('pubkey', pubkey); @@ -200,7 +202,7 @@ function ProfileMoreMenu({ pubkey, displayName, open, onOpenChange, isOwnProfile }; const handleCopyLink = () => { - const url = `${window.location.origin}/${npubEncoded}`; + const url = `${shareOrigin}/${npubEncoded}`; navigator.clipboard.writeText(url); toast({ title: 'Profile link copied to clipboard' }); close(); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 3e4ef7cb..bdc91a69 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -7,6 +7,14 @@ declare module '@fontsource/comic-relief/*'; interface ImportMetaEnv { /** Hex pubkey of the nostr-push server for Web Push notifications. */ readonly VITE_NOSTR_PUSH_PUBKEY?: string; + /** + * Canonical origin used when generating shareable URLs (QR codes, copy-link, + * remote-login callbacks, etc). Overridden by `shareOrigin` in `ditto.json` + * and by user config in localStorage. Falls back to `window.location.origin` + * when unset. Primarily useful for native (Capacitor) builds, where + * `window.location.origin` is `capacitor://localhost` or `https://localhost`. + */ + readonly VITE_SHARE_ORIGIN?: string; /** Semver version from package.json (e.g., "2.0.0"). */ readonly VERSION: string; /** ISO 8601 timestamp of when the app was built (e.g., "2026-03-26T19:42:00.000Z"). */ From 54a49f1ece9f8e2b59b4a6a00fdb647741183592 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 17 Apr 2026 22:43:30 -0500 Subject: [PATCH 12/19] release: v2.10.1 --- CHANGELOG.md | 5 +++++ android/app/build.gradle | 2 +- ios/App/App.xcodeproj/project.pbxproj | 4 ++-- package.json | 2 +- public/CHANGELOG.md | 5 +++++ 5 files changed, 14 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 339411f3..c0995e37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [2.10.1] - 2026-04-17 + +### Fixed +- QR codes, copy-link actions, and remote-login callbacks on the iOS and Android apps now produce real shareable URLs instead of broken `capacitor://localhost` ones + ## [2.10.0] - 2026-04-17 ### Added diff --git a/android/app/build.gradle b/android/app/build.gradle index 82a28912..4d2f33e4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,7 +14,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "2.10.0" + versionName "2.10.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 52178650..09cf8407 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -327,7 +327,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.10.0; + MARKETING_VERSION = 2.10.1; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -351,7 +351,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.10.0; + MARKETING_VERSION = 2.10.1; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/package.json b/package.json index 3b2c5d7b..13c7e11d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ditto", "private": true, - "version": "2.10.0", + "version": "2.10.1", "type": "module", "scripts": { "dev": "npm i --silent && vite", diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index 339411f3..c0995e37 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [2.10.1] - 2026-04-17 + +### Fixed +- QR codes, copy-link actions, and remote-login callbacks on the iOS and Android apps now produce real shareable URLs instead of broken `capacitor://localhost` ones + ## [2.10.0] - 2026-04-17 ### Added From 36373400f8c783ff47530c85d5ec00fd6676721f Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 18 Apr 2026 04:39:14 -0500 Subject: [PATCH 13/19] Fix crash on invalid blurhash strings from Nostr events Validate blurhash hashes before passing them to react-blurhash's component, which throws when the encoded length doesn't match the component count header. Malformed hashes from third-party events (e.g. length 92 instead of 94) now gracefully fall back to a skeleton placeholder instead of crashing the page. --- src/components/ImageGallery.tsx | 3 ++- src/components/MediaCollage.tsx | 5 +++-- src/components/ProfileRightSidebar.tsx | 3 ++- src/components/VideoPlayer.tsx | 3 ++- src/lib/blurhash.ts | 7 +++++++ src/pages/VideosFeedPage.tsx | 5 +++-- 6 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 src/lib/blurhash.ts diff --git a/src/components/ImageGallery.tsx b/src/components/ImageGallery.tsx index 68632637..6f10181f 100644 --- a/src/components/ImageGallery.tsx +++ b/src/components/ImageGallery.tsx @@ -3,6 +3,7 @@ import { createPortal } from 'react-dom'; import { ChevronLeft, ChevronRight, X, Download } from 'lucide-react'; import { Blurhash } from 'react-blurhash'; import { cn } from '@/lib/utils'; +import { isValidBlurhash } from '@/lib/blurhash'; import { openUrl } from '@/lib/downloadFile'; import { Skeleton } from '@/components/ui/skeleton'; import { useBlossomFallback } from '@/hooks/useBlossomFallback'; @@ -230,7 +231,7 @@ function GridImage({ > {/* Placeholder shown while the image is loading */} {!loaded && ( - blurhash ? ( + isValidBlurhash(blurhash) ? ( // Blurhash canvas fills the container via CSS — pass small integer decode // resolution; the canvas is stretched to 100%×100% by the style prop. void }) onClick={showBlur ? (e) => { e.stopPropagation(); setCwRevealed(true); } : onClick} aria-label={showBlur ? 'Reveal sensitive content' : 'View media'} > - {item.blurhash && ( + {isValidBlurhash(item.blurhash) && ( void }) style={{ width: '100%', height: '100%' }} /> )} - {!item.blurhash && !loaded && item.type !== 'audio' && ( + {!isValidBlurhash(item.blurhash) && !loaded && item.type !== 'audio' && ( )} diff --git a/src/components/ProfileRightSidebar.tsx b/src/components/ProfileRightSidebar.tsx index 6736bac6..f31d1a58 100644 --- a/src/components/ProfileRightSidebar.tsx +++ b/src/components/ProfileRightSidebar.tsx @@ -4,6 +4,7 @@ import { Check, Copy, QrCode, ExternalLink, Bitcoin, ShieldAlert, Mail } from 'l import { LinkFooter } from '@/components/LinkFooter'; import { Blurhash } from 'react-blurhash'; import { cn } from '@/lib/utils'; +import { isValidBlurhash } from '@/lib/blurhash'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Skeleton } from '@/components/ui/skeleton'; @@ -193,7 +194,7 @@ function MediaTile({ item }: { item: MediaItem }) {
{/* Blurhash or skeleton placeholder while media loads */} {!loaded && ( - item.blurhash ? ( + isValidBlurhash(item.blurhash) ? ( e.stopPropagation()} > {/* Blurhash placeholder — shown until the video has a displayable frame */} - {blurhash && !videoReady && !generatedPoster && ( + {isValidBlurhash(blurhash) && !videoReady && !generatedPoster && ( {/* Thumbnail */}
- {blurhash && ( + {isValidBlurhash(blurhash) && (
- {blurhash && !thumbnail && ( + {isValidBlurhash(blurhash) && !thumbnail && ( Date: Sat, 18 Apr 2026 04:50:34 -0500 Subject: [PATCH 14/19] Harden against malformed untrusted data that crashes pages - ExternalContentPage: new URL() on non-URL NIP-73 identifiers (isbn:, iso3166:) threw TypeError crashing the page; now URL is only created for url-type content and #-prefixed strings are used for other types - ExternalContentPage & RelayPage: decodeURIComponent on malformed percent-encoded URL params threw URIError; now wrapped in try/catch - useMastodonPost: new URL() on invalid URLs inside queryFn now returns null instead of letting the error propagate - colorUtils/themeEvent: malformed hex color values from theme events produced NaN CSS variables; added isValidHex guard in parseColorTags --- src/hooks/useMastodonPost.ts | 3 ++- src/lib/colorUtils.ts | 5 +++++ src/lib/themeEvent.ts | 3 ++- src/pages/ExternalContentPage.tsx | 30 +++++++++++++++++++----------- src/pages/RelayPage.tsx | 4 +++- 5 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/hooks/useMastodonPost.ts b/src/hooks/useMastodonPost.ts index d6d019ef..d39f4a84 100644 --- a/src/hooks/useMastodonPost.ts +++ b/src/hooks/useMastodonPost.ts @@ -55,7 +55,8 @@ export function useMastodonPost(url: string) { return useQuery({ queryKey: ['mastodon-post', url], queryFn: async ({ signal }): Promise => { - const parsed = new URL(url); + let parsed: URL; + try { parsed = new URL(url); } catch { return null; } const origin = parsed.origin; // Extract status ID from the last path segment: /@user/123456 diff --git a/src/lib/colorUtils.ts b/src/lib/colorUtils.ts index 70a657f8..13401038 100644 --- a/src/lib/colorUtils.ts +++ b/src/lib/colorUtils.ts @@ -39,6 +39,11 @@ export function rgbToHsl(r: number, g: number, b: number): { h: number; s: numbe return { h: h * 360, s: s * 100, l: l * 100 }; } +/** Check whether a string looks like a valid hex color (#RGB, #RRGGBB, or without #). */ +export function isValidHex(hex: string): boolean { + return /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(hex); +} + /** Convert hex color (#RRGGBB or #RGB) to RGB. */ export function hexToRgb(hex: string): [number, number, number] { hex = hex.replace('#', ''); diff --git a/src/lib/themeEvent.ts b/src/lib/themeEvent.ts index 6b28304e..0ca112e5 100644 --- a/src/lib/themeEvent.ts +++ b/src/lib/themeEvent.ts @@ -1,6 +1,6 @@ import type { NostrEvent } from '@nostrify/nostrify'; import type { CoreThemeColors, ThemeConfig, ThemeFont, ThemeBackground } from '@/themes'; -import { hslStringToHex, hexToHslString } from '@/lib/colorUtils'; +import { hslStringToHex, hexToHslString, isValidHex } from '@/lib/colorUtils'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; // ─── Kind Constants ─────────────────────────────────────────────────── @@ -39,6 +39,7 @@ function parseColorTags(tags: string[][]): CoreThemeColors | null { const primaryHex = colorMap.get('primary'); if (!bgHex || !textHex || !primaryHex) return null; + if (!isValidHex(bgHex) || !isValidHex(textHex) || !isValidHex(primaryHex)) return null; return { background: hexToHslString(bgHex), diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx index 1f1c53c9..a6b5108e 100644 --- a/src/pages/ExternalContentPage.tsx +++ b/src/pages/ExternalContentPage.tsx @@ -154,7 +154,7 @@ export function ExternalContentPage() { if (!rawUri) return ''; // If the wildcard param looks already encoded (no "://" present), decode it. if (!rawUri.includes('://')) { - return decodeURIComponent(rawUri); + try { return decodeURIComponent(rawUri); } catch { return rawUri; } } // Otherwise it's a bare URL — reattach any query string the browser separated out. return rawUri + location.search; @@ -179,12 +179,20 @@ export function ExternalContentPage() { useSeoMeta({ title: content ? (resolvedTitle ? `${resolvedTitle} | ${config.appName}` : seoTitle(content, config.appName)) : `External Content | ${config.appName}` }); // Build the NIP-73 identifier for comments. - // For URLs, the raw URL is used. For others, the full prefixed identifier. - const commentRoot = useMemo(() => { - if (!content) return undefined; - return new URL(content.value); + // For URLs, a URL object is used. For others (isbn:, iso3166:, etc.) a #-prefixed string + // is passed to useComments for querying but cannot be used with ComposeBox/ReplyComposeModal. + const commentRootUrl = useMemo((): URL | undefined => { + if (!content || content.type !== 'url') return undefined; + try { return new URL(content.value); } catch { return undefined; } }, [content]); + const commentRootId = useMemo((): `#${string}` | undefined => { + if (!content || content.type === 'url') return undefined; + return `#${content.value}` as `#${string}`; + }, [content]); + + const commentRoot: URL | `#${string}` | undefined = commentRootUrl ?? commentRootId; + const { muteItems } = useMuteList(); const { data: commentsData, isLoading: commentsLoading } = useComments(commentRoot, 500); @@ -229,7 +237,7 @@ export function ExternalContentPage() { onFabClick: openCompose, }); - if (!content || !uri || !commentRoot) { + if (!content || !uri) { return ; } @@ -271,20 +279,20 @@ export function ExternalContentPage() { {/* Comment compose dialog (opened via FAB) */} - + {commentRootUrl && } {/* ISBN pages get a tabbed interface with Comments + Reviews */} {content.type === 'isbn' ? ( ) : ( <> {/* Inline compose box */} - + {commentRootUrl && } {/* Threaded comments list */}
@@ -346,7 +354,7 @@ function CommentsEmptyState() { interface BookContentTabsProps { isbn: string; - commentRoot: URL; + commentRoot: URL | undefined; orderedReplies: Array<{ reply: NostrEvent; firstSubReply?: NostrEvent }>; commentsLoading: boolean; } @@ -376,7 +384,7 @@ function BookContentTabs({ isbn, commentRoot, orderedReplies, commentsLoading }: {/* Inline compose box */} - + {commentRoot && } {/* Threaded comments list */}
diff --git a/src/pages/RelayPage.tsx b/src/pages/RelayPage.tsx index 1c607d81..67dcf064 100644 --- a/src/pages/RelayPage.tsx +++ b/src/pages/RelayPage.tsx @@ -53,7 +53,9 @@ export function RelayPage() { const relayUrl = useMemo(() => { if (!rawParam) return undefined; // If the wildcard param has no "://", it's encoded — decode it. - const url = rawParam.includes('://') ? rawParam : decodeURIComponent(rawParam); + let decoded: string; + try { decoded = decodeURIComponent(rawParam); } catch { decoded = rawParam; } + const url = rawParam.includes('://') ? rawParam : decoded; if (url.startsWith('wss://') || url.startsWith('ws://')) { return url; } From bf6788c141ad55ce337c2bece080916c24289ca0 Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 06:46:17 -0300 Subject: [PATCH 15/19] Separate progression state from activity state to prevent evolution reset Sleep/wake toggles overwrote 'evolving' with 'sleeping', permanently destroying evolution progress. The root cause was using a single state tag for two orthogonal concerns: activity (active/sleeping/hibernating) and progression (incubating/evolving). Introduce progression_state and progression_started_at as new tags orthogonal to the activity state tag. Sleep, wake, and hibernation changes now never touch progression. Parser auto-migrates legacy events that stored progression in the state tag on read. --- .../components/BlobbiMissionsModal.tsx | 4 +- .../components/StartEvolutionDialog.tsx | 2 +- .../components/StartIncubationDialog.tsx | 4 +- .../actions/hooks/useActiveTaskProcess.ts | 4 +- .../actions/hooks/useBlobbiDirectAction.ts | 4 +- .../actions/hooks/useBlobbiIncubation.ts | 37 ++++---- .../actions/hooks/useBlobbiStageTransition.ts | 15 ++-- .../hooks/useBlobbiUseInventoryItem.ts | 4 +- src/blobbi/actions/hooks/useEvolveTasks.ts | 2 +- src/blobbi/actions/hooks/useHatchTasks.ts | 2 +- .../companion/interaction/useBlobbiItemUse.ts | 4 +- src/blobbi/core/lib/blobbi-tag-schema.ts | 43 +++++++-- src/blobbi/core/lib/blobbi.ts | 89 +++++++++++++++++-- src/blobbi/core/types/blobbi.ts | 7 +- src/blobbi/dev/useBlobbiDevUpdate.ts | 11 ++- .../components/BlobbiHatchingCeremony.tsx | 10 ++- src/blobbi/onboarding/lib/blobbi-preview.ts | 14 ++- src/pages/BlobbiPage.tsx | 4 +- src/types/blobbi.ts | 7 +- 19 files changed, 201 insertions(+), 66 deletions(-) diff --git a/src/blobbi/actions/components/BlobbiMissionsModal.tsx b/src/blobbi/actions/components/BlobbiMissionsModal.tsx index 1244bc21..8b6aa2ee 100644 --- a/src/blobbi/actions/components/BlobbiMissionsModal.tsx +++ b/src/blobbi/actions/components/BlobbiMissionsModal.tsx @@ -434,8 +434,8 @@ export function BlobbiMissionsModal({ showMissionCard, onToggleMissionCard, }: BlobbiMissionsModalProps) { - const isIncubating = companion.state === 'incubating'; - const isEvolvingState = companion.state === 'evolving'; + const isIncubating = companion.progressionState === 'incubating'; + const isEvolvingState = companion.progressionState === 'evolving'; const isEgg = companion.stage === 'egg'; const isBaby = companion.stage === 'baby'; diff --git a/src/blobbi/actions/components/StartEvolutionDialog.tsx b/src/blobbi/actions/components/StartEvolutionDialog.tsx index 5609d730..e9e41363 100644 --- a/src/blobbi/actions/components/StartEvolutionDialog.tsx +++ b/src/blobbi/actions/components/StartEvolutionDialog.tsx @@ -46,7 +46,7 @@ export function StartEvolutionDialog({ isPending, }: StartEvolutionDialogProps) { // Check if the current Blobbi is already evolving - const isAlreadyEvolving = companion?.state === 'evolving'; + const isAlreadyEvolving = companion?.progressionState === 'evolving'; // Determine title and description based on state const getDialogContent = () => { diff --git a/src/blobbi/actions/components/StartIncubationDialog.tsx b/src/blobbi/actions/components/StartIncubationDialog.tsx index a67a856a..f752c79e 100644 --- a/src/blobbi/actions/components/StartIncubationDialog.tsx +++ b/src/blobbi/actions/components/StartIncubationDialog.tsx @@ -54,14 +54,14 @@ export function StartIncubationDialog({ isPending, }: StartIncubationDialogProps) { // Check if the current Blobbi is already in a task state - const isAlreadyInTaskState = companion?.state === 'incubating' || companion?.state === 'evolving'; + const isAlreadyInTaskState = companion?.progressionState === 'incubating' || companion?.progressionState === 'evolving'; // Check if another Blobbi (not this one) is currently incubating const otherIncubatingBlobbi = useMemo(() => { if (!companion) return null; return companions.find(c => c.d !== companion.d && - c.state === 'incubating' && + c.progressionState === 'incubating' && c.stage === 'egg' ) ?? null; }, [companion, companions]); diff --git a/src/blobbi/actions/hooks/useActiveTaskProcess.ts b/src/blobbi/actions/hooks/useActiveTaskProcess.ts index 4cb50beb..723a080a 100644 --- a/src/blobbi/actions/hooks/useActiveTaskProcess.ts +++ b/src/blobbi/actions/hooks/useActiveTaskProcess.ts @@ -139,8 +139,8 @@ export function useActiveTaskProcess( // Determine which process is active const processType = useMemo((): TaskProcessType => { if (!companion) return null; - if (companion.state === 'incubating') return 'hatch'; - if (companion.state === 'evolving') return 'evolve'; + if (companion.progressionState === 'incubating') return 'hatch'; + if (companion.progressionState === 'evolving') return 'evolve'; return null; }, [companion]); diff --git a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts index 8c83719a..06e2149d 100644 --- a/src/blobbi/actions/hooks/useBlobbiDirectAction.ts +++ b/src/blobbi/actions/hooks/useBlobbiDirectAction.ts @@ -147,9 +147,9 @@ export function useBlobbiDirectAction({ const nowStr = now.toString(); // If incubating or evolving, increment the interaction counter in evolution missions - const companionState = canonical.companion.state; + const progressionState = canonical.companion.progressionState; const updatedTags = canonical.allTags; - if (companionState === 'incubating' || companionState === 'evolving') { + if (progressionState === 'incubating' || progressionState === 'evolving') { trackEvolutionMissionTally('interactions', 1, user.pubkey); } diff --git a/src/blobbi/actions/hooks/useBlobbiIncubation.ts b/src/blobbi/actions/hooks/useBlobbiIncubation.ts index e90e6696..b28fb02b 100644 --- a/src/blobbi/actions/hooks/useBlobbiIncubation.ts +++ b/src/blobbi/actions/hooks/useBlobbiIncubation.ts @@ -181,17 +181,18 @@ export function useStartIncubation({ // Apply decay to the other Blobbi const otherDecayResult = applyBlobbiDecay({ stage: 'egg', - state: 'incubating', + state: 'active', stats: otherStats, lastDecayAt: otherLastDecayAt, now, }); - // Remove task tags and state_started_at from the other Blobbi + // Remove task tags and progression timing from the other Blobbi const otherCleanedTags = otherEvent.tags.filter(tag => tag[0] !== 'task' && tag[0] !== 'task_completed' && - tag[0] !== 'state_started_at' + tag[0] !== 'state_started_at' && + tag[0] !== 'progression_started_at' ); const otherNewTags = updateBlobbiTags(otherCleanedTags, { @@ -200,7 +201,7 @@ export function useStartIncubation({ happiness: otherDecayResult.stats.happiness.toString(), hunger: '100', energy: '100', - state: 'active', + progression_state: 'none', last_interaction: nowStr, last_decay_at: nowStr, }); @@ -254,8 +255,8 @@ export function useStartIncubation({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'incubating', - state_started_at: nowStr, + progression_state: 'incubating', + progression_started_at: nowStr, last_interaction: nowStr, last_decay_at: nowStr, }); @@ -375,7 +376,7 @@ export function useStopIncubation({ throw new Error('No companion selected'); } - if (companion.state !== 'incubating') { + if (companion.progressionState !== 'incubating') { throw new Error('This Blobbi is not incubating'); } @@ -398,11 +399,12 @@ export function useStopIncubation({ }); // ─── Build Updated Tags ─── - // Remove task tags and state_started_at + // Remove task tags and progression timing const cleanedTags = canonical.allTags.filter(tag => tag[0] !== 'task' && tag[0] !== 'task_completed' && - tag[0] !== 'state_started_at' + tag[0] !== 'state_started_at' && + tag[0] !== 'progression_started_at' ); // Build stats update with decayed values @@ -417,7 +419,7 @@ export function useStopIncubation({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'active', + progression_state: 'none', last_interaction: nowStr, last_decay_at: nowStr, }); @@ -510,7 +512,7 @@ export function useStartEvolution({ throw new Error('Only baby Blobbis can evolve'); } - if (companion.state === 'evolving') { + if (companion.progressionState === 'evolving') { throw new Error('This Blobbi is already evolving'); } @@ -549,8 +551,8 @@ export function useStartEvolution({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'evolving', - state_started_at: nowStr, + progression_state: 'evolving', + progression_started_at: nowStr, last_interaction: nowStr, last_decay_at: nowStr, }); @@ -656,7 +658,7 @@ export function useStopEvolution({ throw new Error('No companion selected'); } - if (companion.state !== 'evolving') { + if (companion.progressionState !== 'evolving') { throw new Error('This Blobbi is not evolving'); } @@ -679,11 +681,12 @@ export function useStopEvolution({ }); // ─── Build Updated Tags ─── - // Remove task tags and state_started_at + // Remove task tags and progression timing const cleanedTags = canonical.allTags.filter(tag => tag[0] !== 'task' && tag[0] !== 'task_completed' && - tag[0] !== 'state_started_at' + tag[0] !== 'state_started_at' && + tag[0] !== 'progression_started_at' ); // Build stats update with decayed values @@ -697,7 +700,7 @@ export function useStopEvolution({ const newTags = updateBlobbiTags(cleanedTags, { ...statsUpdate, - state: 'active', + progression_state: 'none', last_interaction: nowStr, last_decay_at: nowStr, }); diff --git a/src/blobbi/actions/hooks/useBlobbiStageTransition.ts b/src/blobbi/actions/hooks/useBlobbiStageTransition.ts index c42d1d96..f9b8107f 100644 --- a/src/blobbi/actions/hooks/useBlobbiStageTransition.ts +++ b/src/blobbi/actions/hooks/useBlobbiStageTransition.ts @@ -201,12 +201,12 @@ export function useBlobbiHatch({ } // ─── Auto-start evolution for newly hatched babies ─── - // Applied AFTER tag validation because cleanupTaskTags repairs - // task-process states to 'active'. We intentionally set 'evolving' - // here so the baby starts its evolution journey immediately. + // Applied AFTER tag validation because cleanupTaskTags clears + // progression tags. We set the new progression_state here so the + // baby starts its evolution journey immediately. const newTags = updateBlobbiTags(repairResult.tags, { - state: 'evolving', - state_started_at: nowStr, + progression_state: 'evolving', + progression_started_at: nowStr, }); // ─── Generate New Content for Baby Stage ─── @@ -355,7 +355,10 @@ export function useBlobbiEvolve({ console.log('[Evolve] Tag repairs applied:', repairResult.repairs); } - const newTags = repairResult.tags; + // Ensure progression is cleared after evolve + const newTags = updateBlobbiTags(repairResult.tags, { + progression_state: 'none', + }); // ─── Generate New Content for Adult Stage ─── // CRITICAL: Content must reflect the new stage diff --git a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts index 6b31f7ca..e7d6dbd7 100644 --- a/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts +++ b/src/blobbi/actions/hooks/useBlobbiUseInventoryItem.ts @@ -241,9 +241,9 @@ export function useBlobbiUseInventoryItem({ const nowStr = now.toString(); // If incubating or evolving, increment the interaction counter in evolution missions - const companionState = canonical.companion.state; + const progressionState = canonical.companion.progressionState; const updatedTags = canonical.allTags; - if (companionState === 'incubating' || companionState === 'evolving') { + if (progressionState === 'incubating' || progressionState === 'evolving') { trackEvolutionMissionTally('interactions', 1, user?.pubkey); } diff --git a/src/blobbi/actions/hooks/useEvolveTasks.ts b/src/blobbi/actions/hooks/useEvolveTasks.ts index 795ae35c..1e61cb04 100644 --- a/src/blobbi/actions/hooks/useEvolveTasks.ts +++ b/src/blobbi/actions/hooks/useEvolveTasks.ts @@ -90,7 +90,7 @@ export function useEvolveTasks( const { nostr } = useNostr(); const pubkey = user?.pubkey; - const isEvolving = companion?.state === 'evolving'; + const isEvolving = companion?.progressionState === 'evolving'; const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]); // ─── Ensure evolution missions exist and match current definitions ─── diff --git a/src/blobbi/actions/hooks/useHatchTasks.ts b/src/blobbi/actions/hooks/useHatchTasks.ts index 1a71c087..400050a4 100644 --- a/src/blobbi/actions/hooks/useHatchTasks.ts +++ b/src/blobbi/actions/hooks/useHatchTasks.ts @@ -109,7 +109,7 @@ export function useHatchTasks( const { nostr } = useNostr(); const pubkey = user?.pubkey; - const isIncubating = companion?.state === 'incubating'; + const isIncubating = companion?.progressionState === 'incubating'; const evolution = useMemo(() => missions?.evolution ?? [], [missions?.evolution]); // ─── Ensure evolution missions exist and match current definitions ─── diff --git a/src/blobbi/companion/interaction/useBlobbiItemUse.ts b/src/blobbi/companion/interaction/useBlobbiItemUse.ts index daa628be..ba2b0f8b 100644 --- a/src/blobbi/companion/interaction/useBlobbiItemUse.ts +++ b/src/blobbi/companion/interaction/useBlobbiItemUse.ts @@ -351,9 +351,9 @@ export function useBlobbiItemUse(options: UseBlobbiItemUseOptions = {}): UseBlob const nowStr = now.toString(); // If incubating or evolving, increment the interaction counter in evolution missions - const companionState = companion.state; + const progressionState = companion.progressionState; const updatedTags = companion.allTags; - if (companionState === 'incubating' || companionState === 'evolving') { + if (progressionState === 'incubating' || progressionState === 'evolving') { trackEvolutionMissionTally('interactions', 1, user?.pubkey); } diff --git a/src/blobbi/core/lib/blobbi-tag-schema.ts b/src/blobbi/core/lib/blobbi-tag-schema.ts index 809591ae..2b2e0f4f 100644 --- a/src/blobbi/core/lib/blobbi-tag-schema.ts +++ b/src/blobbi/core/lib/blobbi-tag-schema.ts @@ -382,8 +382,8 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [ persistent: false, source: 'system', regenerable: false, - format: 'active | sleeping | hibernating | incubating | evolving', - notes: 'incubating is for eggs, evolving is for babies. Reset to active after transition.', + format: 'active | sleeping | hibernating', + notes: 'Activity state only. Progression (incubation/evolution) is tracked in progression_state.', }, { tag: 'last_interaction', @@ -414,8 +414,21 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [ // TASK SYSTEM TAGS // ═══════════════════════════════════════════════════════════════════════════ { - tag: 'state_started_at', - description: 'Unix timestamp when current state (incubating/evolving) started', + tag: 'progression_state', + description: 'Current progression process state (orthogonal to activity state)', + category: 'task', + required: false, + stages: ['egg', 'baby', 'adult'], + persistent: false, + source: 'system', + regenerable: false, + format: 'none | incubating | evolving', + defaultValue: 'none', + notes: 'Set to incubating/evolving when a progression process starts. Cleared to none after hatch/evolve completes. Survives sleep/wake/hibernation changes.', + }, + { + tag: 'progression_started_at', + description: 'Unix timestamp when current progression (incubating/evolving) started', category: 'task', required: false, stages: ['egg', 'baby', 'adult'], @@ -425,6 +438,18 @@ export const BLOBBI_TAG_SCHEMA: readonly BlobbiTagSchema[] = [ format: 'Unix timestamp (seconds)', notes: 'Set when entering incubating/evolving. REMOVED after hatch/evolve completes.', }, + { + tag: 'state_started_at', + description: '@deprecated Use progression_started_at instead. Unix timestamp when current state (incubating/evolving) started', + category: 'task', + required: false, + stages: ['egg', 'baby', 'adult'], + persistent: false, + source: 'system', + regenerable: false, + format: 'Unix timestamp (seconds)', + notes: 'Legacy tag. New events should use progression_started_at instead.', + }, { tag: 'task', description: 'Task progress tracking', @@ -668,7 +693,7 @@ export function getPersistentTagNames(): Set { * These are task-related and state-specific tags. */ export function getTransitionCleanupTagNames(): Set { - return new Set(['task', 'task_completed', 'state_started_at']); + return new Set(['task', 'task_completed', 'state_started_at', 'progression_started_at', 'progression_state']); } /** @@ -776,13 +801,15 @@ const NEVER_INVENT_TAGS = new Set([ * Valid states for each stage. Used to validate/repair state after transitions. */ const VALID_STATES_BY_STAGE: Record> = { - egg: new Set(['active', 'sleeping', 'hibernating', 'incubating']), - baby: new Set(['active', 'sleeping', 'hibernating', 'evolving']), + egg: new Set(['active', 'sleeping', 'hibernating']), + baby: new Set(['active', 'sleeping', 'hibernating']), adult: new Set(['active', 'sleeping', 'hibernating']), }; /** - * Task-process states that should NOT remain after stage transitions. + * Task-process states that should NOT remain in the `state` tag after stage transitions. + * With the new model, state should never be 'incubating'/'evolving' — but we keep this + * for migration: if a legacy event has them in state, the repair will fix it. */ const TASK_PROCESS_STATES = new Set(['incubating', 'evolving']); diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index 276bad25..1163d99a 100644 --- a/src/blobbi/core/lib/blobbi.ts +++ b/src/blobbi/core/lib/blobbi.ts @@ -94,7 +94,16 @@ export function getDaysDifference(dayA: string, dayB: string): number { // ─── Types ──────────────────────────────────────────────────────────────────── export type BlobbiStage = 'egg' | 'baby' | 'adult'; -export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving'; +export type BlobbiState = 'active' | 'sleeping' | 'hibernating'; + +/** + * Progression process state — orthogonal to BlobbiState. + * + * 'none' — no progression process active + * 'incubating' — egg is being incubated (hatch tasks) + * 'evolving' — baby is being evolved (evolve tasks) + */ +export type BlobbiProgressionState = 'none' | 'incubating' | 'evolving'; export interface BlobbiStats { hunger: number; @@ -239,8 +248,10 @@ export interface BlobbiCompanion { name: string; /** Lifecycle stage */ stage: BlobbiStage; - /** Activity state */ + /** Activity state (active, sleeping, hibernating — never progression) */ state: BlobbiState; + /** Progression process state (none, incubating, evolving — orthogonal to state) */ + progressionState: BlobbiProgressionState; /** Deterministic identity seed (64-char hex) */ seed: string | undefined; /** Visual traits (derived from seed or legacy tags) */ @@ -277,8 +288,14 @@ export interface BlobbiCompanion { startIncubation: number | undefined; /** Adult evolution form type (adult only) */ adultType: string | undefined; - /** Timestamp when current state (incubating/evolving) started (unix seconds) */ + /** + * @deprecated Use progressionStartedAt instead. + * Timestamp when current state (incubating/evolving) started (unix seconds). + * Kept for migration compatibility. + */ stateStartedAt: number | undefined; + /** Timestamp when current progression (incubating/evolving) started (unix seconds) */ + progressionStartedAt: number | undefined; /** Task progress cache (source of truth is computed from Nostr events) */ tasks: BlobbiTaskProgress[]; /** Completed task names */ @@ -769,6 +786,8 @@ export function isValidBlobbiEvent(event: NostrEvent): boolean { if (!d) return false; if (b !== BLOBBI_ECOSYSTEM_NAMESPACE) return false; if (!stage || !['egg', 'baby', 'adult'].includes(stage)) return false; + // Accept both new states (active/sleeping/hibernating) and legacy states (incubating/evolving) + // for backwards compatibility during migration if (!state || !['active', 'sleeping', 'hibernating', 'incubating', 'evolving'].includes(state)) return false; if (!lastInteraction) return false; @@ -887,9 +906,33 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined const d = getTagValue(tags, 'd')!; const nameTag = getTagValue(tags, 'name'); const stage = getTagValue(tags, 'stage') as BlobbiStage; - const state = getTagValue(tags, 'state') as BlobbiState; + const rawState = getTagValue(tags, 'state')!; const seed = getTagValue(tags, 'seed'); + // ─── Progression state resolution (migration-aware) ─── + // New model: progression lives in progression_state tag. + // Old model: progression lived in the state tag ('incubating', 'evolving'). + // On read we normalise both into the new model. + const progressionStateTag = getTagValue(tags, 'progression_state') as BlobbiProgressionState | undefined; + + let state: BlobbiState; + let progressionState: BlobbiProgressionState; + + if (progressionStateTag) { + // New-format event: progression_state tag is authoritative + state = rawState as BlobbiState; + progressionState = progressionStateTag; + } else if (rawState === 'incubating' || rawState === 'evolving') { + // Legacy event: progression was stored in state tag. + // Normalise: move it to progressionState, set activity state to 'active'. + state = 'active'; + progressionState = rawState as BlobbiProgressionState; + } else { + // No progression + state = rawState as BlobbiState; + progressionState = 'none'; + } + // Resolve name: tag > legacy d-tag derivation > fallback const name = nameTag ?? deriveNameFromLegacyD(d); @@ -933,6 +976,7 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined name, stage, state, + progressionState, seed, visualTraits, isLegacy, @@ -955,6 +999,7 @@ export function parseBlobbiEvent(event: NostrEvent): BlobbiCompanion | undefined startIncubation: parseNumericTag(tags, 'start_incubation'), adultType: getTagValue(tags, 'adult_type'), stateStartedAt: parseNumericTag(tags, 'state_started_at'), + progressionStartedAt: parseNumericTag(tags, 'progression_started_at') ?? parseNumericTag(tags, 'state_started_at'), tasks, tasksCompleted, allTags: tags, @@ -1045,6 +1090,7 @@ export function buildEggTags( ['name', name], ['stage', 'egg'], ['state', 'active'], + ['progression_state', 'none'], ['seed', seed], ['generation', '1'], ['breeding_ready', 'false'], @@ -1094,6 +1140,8 @@ export const MANAGED_BLOBBI_STATE_TAG_NAMES = new Set([ 'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day', // Social/flag tags 'breeding_ready', + // Progression tags (orthogonal to activity state) + 'progression_state', 'progression_started_at', // Task system tags (removed after stage transitions) 'state_started_at', 'task', 'task_completed', // Evolution tags (adult only) @@ -1497,11 +1545,15 @@ export function buildMigrationTags( // Per blobbi-tag-schema.md: Do NOT invent values for tags that don't exist const persistentTagNames = [ // State/lifecycle tags - 'stage', 'state', + 'stage', // Stat tags 'hunger', 'happiness', 'health', 'hygiene', 'energy', // Progression tags 'experience', 'care_streak', 'care_streak_last_at', 'care_streak_last_day', + // Progression process tags + 'progression_state', 'progression_started_at', + // Legacy progression timing (also preserve for fallback) + 'state_started_at', // Social/flag tags 'generation', 'breeding_ready', // Personality tags (preserve if they exist, do NOT generate) @@ -1519,6 +1571,33 @@ export function buildMigrationTags( } } + // ─── Normalise legacy state → progression_state ─── + // If the legacy event has state='incubating' or state='evolving', migrate + // to the new split model during migration. + const legacyState = getTagValue(legacyTags, 'state'); + if (legacyState === 'incubating' || legacyState === 'evolving') { + // Set activity state to 'active' (the progression process is tracked separately) + newTags.push(['state', 'active']); + // Only set progression_state if not already set from persistentTagNames + if (!getTagValue(legacyTags, 'progression_state')) { + newTags.push(['progression_state', legacyState]); + // Migrate state_started_at → progression_started_at if present + const startedAt = getTagValue(legacyTags, 'state_started_at'); + if (startedAt && !getTagValue(legacyTags, 'progression_started_at')) { + newTags.push(['progression_started_at', startedAt]); + } + } + } else if (legacyState) { + newTags.push(['state', legacyState]); + // Ensure progression_state is set + if (!getTagValue(legacyTags, 'progression_state')) { + newTags.push(['progression_state', 'none']); + } + } else { + newTags.push(['state', 'active']); + newTags.push(['progression_state', 'none']); + } + // ALWAYS include visual trait tags - derived from seed, with legacy tag fallbacks // This ensures every migrated event has complete visual traits for consistent rendering const visualTraits = deriveVisualTraits(legacyTags, seed); diff --git a/src/blobbi/core/types/blobbi.ts b/src/blobbi/core/types/blobbi.ts index 836fa515..beb5e0fb 100644 --- a/src/blobbi/core/types/blobbi.ts +++ b/src/blobbi/core/types/blobbi.ts @@ -16,7 +16,12 @@ * ────────────────────────────────────────────────────────────────────────── */ export type BlobbiLifeStage = 'egg' | 'baby' | 'adult'; -export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving'; +export type BlobbiState = 'active' | 'sleeping' | 'hibernating'; + +/** + * Progression process state — orthogonal to BlobbiState. + */ +export type BlobbiProgressionState = 'none' | 'incubating' | 'evolving'; /* ────────────────────────────────────────────────────────────────────────── * * Visual traits diff --git a/src/blobbi/dev/useBlobbiDevUpdate.ts b/src/blobbi/dev/useBlobbiDevUpdate.ts index 51cb3948..4c57f44d 100644 --- a/src/blobbi/dev/useBlobbiDevUpdate.ts +++ b/src/blobbi/dev/useBlobbiDevUpdate.ts @@ -79,9 +79,14 @@ export function useBlobbiDevUpdate({ tagUpdates.state = updates.state; changedFields.push('state'); - // If changing to evolving/incubating, set state_started_at - if (updates.state === 'evolving' || updates.state === 'incubating') { - tagUpdates.state_started_at = now.toString(); + // If changing to evolving/incubating (legacy dev support), set progression tags + if ((updates.state as string) === 'evolving' || (updates.state as string) === 'incubating') { + // Dev editor: treat these as progression, not activity state + tagUpdates.progression_state = updates.state; + tagUpdates.progression_started_at = now.toString(); + // Override: don't put progression in the state tag + tagUpdates.state = 'active'; + changedFields.push('progression_state'); } } diff --git a/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx b/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx index 787a1c2b..fcfca748 100644 --- a/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx +++ b/src/blobbi/onboarding/components/BlobbiHatchingCeremony.tsx @@ -188,7 +188,7 @@ export function BlobbiHatchingCeremony({ // Baby companion (same visual data but stage=baby) const babyCompanion = useMemo((): BlobbiCompanion | null => { if (!eggCompanion) return null; - return { ...eggCompanion, stage: 'baby', state: 'evolving' }; + return { ...eggCompanion, stage: 'baby', state: 'active' as const, progressionState: 'evolving' as const }; }, [eggCompanion]); const eggColor = preview?.visualTraits.baseColor ?? '#f59e0b'; @@ -211,7 +211,8 @@ export function BlobbiHatchingCeremony({ ownerPubkey: user?.pubkey ?? '', name: existingCompanion.name, stage: 'egg', - state: (existingCompanion.state === 'incubating' ? 'incubating' : 'active') as 'incubating' | 'active', + state: 'active' as const, + progressionState: (existingCompanion.progressionState === 'incubating' ? 'incubating' : 'none') as 'incubating' | 'none', seed: existingCompanion.seed ?? '', stats: { hunger: existingCompanion.stats.hunger ?? STAT_MAX, @@ -387,8 +388,9 @@ export function BlobbiHatchingCeremony({ const babyTags = updateBlobbiTags(tags, { stage: 'baby', - state: 'evolving', - state_started_at: nowStr, + state: 'active', + progression_state: 'evolving', + progression_started_at: nowStr, hunger: STAT_MAX.toString(), happiness: STAT_MAX.toString(), health: STAT_MAX.toString(), diff --git a/src/blobbi/onboarding/lib/blobbi-preview.ts b/src/blobbi/onboarding/lib/blobbi-preview.ts index 1dac1e89..c5d63974 100644 --- a/src/blobbi/onboarding/lib/blobbi-preview.ts +++ b/src/blobbi/onboarding/lib/blobbi-preview.ts @@ -35,8 +35,10 @@ export interface BlobbiEggPreview { name: string; /** Life stage - always 'egg' for previews */ stage: 'egg'; - /** Activity state - new eggs start incubating; older eggs may be 'active' */ - state: 'incubating' | 'active'; + /** Activity state */ + state: 'active'; + /** Progression state - new eggs start incubating; older eggs may be 'none' */ + progressionState: 'incubating' | 'none'; /** Visual traits derived from seed */ visualTraits: BlobbiVisualTraits; /** Default stats for a new egg */ @@ -79,7 +81,8 @@ export function generateEggPreview( seed, name, stage: 'egg', - state: 'incubating', + state: 'active', + progressionState: 'incubating', visualTraits, stats: { ...DEFAULT_EGG_STATS }, createdAt, @@ -134,6 +137,7 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] { ['name', preview.name], ['stage', preview.stage], ['state', preview.state], + ['progression_state', preview.progressionState], ['seed', preview.seed], ['generation', '1'], ['breeding_ready', 'false'], @@ -148,7 +152,7 @@ export function previewToEventTags(preview: BlobbiEggPreview): string[][] { ['energy', preview.stats.energy.toString()], ['last_interaction', now], ['last_decay_at', now], - ['state_started_at', now], + ['progression_started_at', now], // Visual trait tags - ensures deterministic rendering ['base_color', visualTraits.baseColor], ['secondary_color', visualTraits.secondaryColor], @@ -192,7 +196,9 @@ export function previewToBlobbiCompanion(preview: BlobbiEggPreview) { adultType: undefined, // Eggs don't have adult type // Task-related fields + progressionState: preview.progressionState, stateStartedAt: preview.createdAt, + progressionStartedAt: preview.createdAt, tasks: [], tasksCompleted: [], diff --git a/src/pages/BlobbiPage.tsx b/src/pages/BlobbiPage.tsx index fdd0347f..8088b78f 100644 --- a/src/pages/BlobbiPage.tsx +++ b/src/pages/BlobbiPage.tsx @@ -1026,8 +1026,8 @@ function BlobbiDashboard({ // State detection for tasks // Note: isEvolving prop = mutation pending state, isEvolvingState = companion in evolving state - const isIncubating = companion.state === 'incubating'; - const isEvolvingState = companion.state === 'evolving'; + const isIncubating = companion.progressionState === 'incubating'; + const isEvolvingState = companion.progressionState === 'evolving'; const isBaby = companion.stage === 'baby'; const canStartIncubation = isEgg && !isIncubating && !isEvolvingState; const canStartEvolution = isBaby && !isEvolvingState && !isIncubating; diff --git a/src/types/blobbi.ts b/src/types/blobbi.ts index 836fa515..beb5e0fb 100644 --- a/src/types/blobbi.ts +++ b/src/types/blobbi.ts @@ -16,7 +16,12 @@ * ────────────────────────────────────────────────────────────────────────── */ export type BlobbiLifeStage = 'egg' | 'baby' | 'adult'; -export type BlobbiState = 'active' | 'sleeping' | 'hibernating' | 'incubating' | 'evolving'; +export type BlobbiState = 'active' | 'sleeping' | 'hibernating'; + +/** + * Progression process state — orthogonal to BlobbiState. + */ +export type BlobbiProgressionState = 'none' | 'incubating' | 'evolving'; /* ────────────────────────────────────────────────────────────────────────── * * Visual traits From 06b53dbc829fc89b6e29c017b7bfc32aef2a870a Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 07:34:07 -0300 Subject: [PATCH 16/19] Fix stale comments and result types from progression-state migration Update JSDoc comments in useBlobbiIncubation to reference progression_state/progression_started_at instead of the deprecated state/state_started_at. Rename stateStartedAt to progressionStartedAt in StartIncubationResult and StartEvolutionResult interfaces. --- package-lock.json | 4 +-- .../actions/hooks/useBlobbiIncubation.ts | 34 +++++++++---------- src/blobbi/core/lib/blobbi.ts | 6 ++-- 3 files changed, 22 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index d48db701..52226670 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ditto", - "version": "2.10.0", + "version": "2.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ditto", - "version": "2.10.0", + "version": "2.10.1", "dependencies": { "@capacitor/app": "^8.0.0", "@capacitor/core": "^8.1.0", diff --git a/src/blobbi/actions/hooks/useBlobbiIncubation.ts b/src/blobbi/actions/hooks/useBlobbiIncubation.ts index b28fb02b..5775546e 100644 --- a/src/blobbi/actions/hooks/useBlobbiIncubation.ts +++ b/src/blobbi/actions/hooks/useBlobbiIncubation.ts @@ -5,12 +5,12 @@ * * When a user starts incubation: * 1. Apply accumulated decay from last_decay_at to now - * 2. Set state to 'incubating' - * 3. Add state_started_at timestamp + * 2. Set progression_state to 'incubating' + * 3. Add progression_started_at timestamp * 4. Update last_decay_at to the same timestamp * 5. Clear any previous task progress * - * Tasks are computed from Nostr events with created_at >= state_started_at + * Tasks are computed from Nostr events with created_at >= progression_started_at */ import { useMutation } from '@tanstack/react-query'; @@ -80,7 +80,7 @@ export interface StartIncubationResult { /** The Blobbi's name */ name: string; /** Timestamp when incubation started */ - stateStartedAt: number; + progressionStartedAt: number; /** Mode that was used */ mode: StartIncubationMode; /** Name of other Blobbi that was stopped (if mode === 'switch') */ @@ -92,7 +92,7 @@ export interface StartIncubationResult { /** * Hook to start the incubation process for an egg. * - * This sets the Blobbi state to 'incubating' and records the start timestamp. + * This sets progression_state to 'incubating' and records the start timestamp. * Tasks will be computed based on events created after this timestamp. * * IMPORTANT: The mode must be explicitly specified by the caller (UI). @@ -280,7 +280,7 @@ export function useStartIncubation({ return { name: canonical.companion.name, - stateStartedAt: now, + progressionStartedAt: now, mode, stoppedOtherName, }; @@ -344,17 +344,17 @@ export interface StopIncubationResult { /** * Hook to stop/cancel the incubation process for a Blobbi. * - * This resets the Blobbi state to 'active' and clears all task progress tags. + * This clears the progression process and all task progress tags. * The user can restart incubation later, but will need to complete tasks again. * * When stopping incubation: * - Apply accumulated decay first - * - Set state back to 'active' - * - Remove state_started_at tag + * - Set progression_state back to 'none' + * - Remove progression_started_at tag * - Remove all task and task_completed tags * * Requirements: - * - Blobbi must be in incubating state + * - Blobbi must have progressionState === 'incubating' * - User must be logged in */ export function useStopIncubation({ @@ -475,13 +475,13 @@ export interface StartEvolutionResult { /** The Blobbi's name */ name: string; /** Timestamp when evolution started */ - stateStartedAt: number; + progressionStartedAt: number; } /** * Hook to start the evolution process for a baby Blobbi. * - * This sets the Blobbi state to 'evolving' and records the start timestamp. + * This sets progression_state to 'evolving' and records the start timestamp. * Tasks will be computed based on events created after this timestamp. * * Requirements: @@ -576,7 +576,7 @@ export function useStartEvolution({ return { name: canonical.companion.name, - stateStartedAt: now, + progressionStartedAt: now, }; }, onSuccess: ({ name }) => { @@ -626,17 +626,17 @@ export interface StopEvolutionResult { /** * Hook to stop/cancel the evolution process for a Blobbi. * - * This resets the Blobbi state to 'active' and clears all task progress tags. + * This clears the progression process and all task progress tags. * The user can restart evolution later, but will need to complete tasks again. * * When stopping evolution: * - Apply accumulated decay first - * - Set state back to 'active' - * - Remove state_started_at tag + * - Set progression_state back to 'none' + * - Remove progression_started_at tag * - Remove all task and task_completed tags * * Requirements: - * - Blobbi must be in evolving state + * - Blobbi must have progressionState === 'evolving' * - User must be logged in */ export function useStopEvolution({ diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index 1163d99a..cd0d9de3 100644 --- a/src/blobbi/core/lib/blobbi.ts +++ b/src/blobbi/core/lib/blobbi.ts @@ -43,7 +43,7 @@ export const DEFAULT_EGG_STATS = { }; /** - * @deprecated No longer used. Task system uses state_started_at instead. + * @deprecated No longer used. Task system uses progression_started_at instead. * Kept for backwards compatibility with older code that may reference it. */ export const DEFAULT_INCUBATION_TIME = 345600; @@ -278,12 +278,12 @@ export interface BlobbiCompanion { careStreakLastDay: string | undefined; /** * @deprecated Incubation time in seconds - no longer used. - * Task system uses state_started_at instead. + * Task system uses progression_started_at instead. */ incubationTime: number | undefined; /** * @deprecated When incubation began - no longer used. - * Replaced by state_started_at for all process timing. + * Replaced by progression_started_at for all process timing. */ startIncubation: number | undefined; /** Adult evolution form type (adult only) */ From 9b9abaa8552240cf9afde2be5c6597964a631dc0 Mon Sep 17 00:00:00 2001 From: filemon Date: Sat, 18 Apr 2026 08:10:56 -0300 Subject: [PATCH 17/19] Fix stale references to state_started_at in progression migration cleanup - StartIncubationDialog: display progressionState instead of state in restart dialog text (was showing 'active' instead of 'incubating') - blobbi-tag-schema: update deprecated tag replacedBy and category comment to reference progression_started_at instead of state_started_at - blobbi.ts: update deprecation comments for incubation_time and start_incubation to reference progression_started_at --- .../actions/components/StartIncubationDialog.tsx | 2 +- src/blobbi/core/lib/blobbi-tag-schema.ts | 10 +++++----- src/blobbi/core/lib/blobbi.ts | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/blobbi/actions/components/StartIncubationDialog.tsx b/src/blobbi/actions/components/StartIncubationDialog.tsx index f752c79e..4021ac71 100644 --- a/src/blobbi/actions/components/StartIncubationDialog.tsx +++ b/src/blobbi/actions/components/StartIncubationDialog.tsx @@ -90,7 +90,7 @@ export function StartIncubationDialog({ icon: , description: ( <> - Your Blobbi is already {companion?.state}. Starting over will{' '} + Your Blobbi is already {companion?.progressionState}. Starting over will{' '} reset all task progress and begin from the beginning.

Are you sure you want to restart? diff --git a/src/blobbi/core/lib/blobbi-tag-schema.ts b/src/blobbi/core/lib/blobbi-tag-schema.ts index 2b2e0f4f..17f96231 100644 --- a/src/blobbi/core/lib/blobbi-tag-schema.ts +++ b/src/blobbi/core/lib/blobbi-tag-schema.ts @@ -33,7 +33,7 @@ export type TagCategory = | 'stats' // Numeric stats (hunger, health, etc.) | 'state' // Lifecycle state (stage, state, timestamps) | 'progression' // Progress tracking (experience, care_streak) - | 'task' // Task system (task, task_completed, state_started_at) + | 'task' // Task system (task, task_completed, progression_state, progression_started_at) | 'social' // Social flags (breeding_ready) | 'evolution' // Evolution-specific (adult_type) | 'extension'; // Extension tags (theme, crossover_app) @@ -657,14 +657,14 @@ export const DEPRECATED_TAG_SCHEMA: readonly DeprecatedTagSchema[] = [ }, { tag: 'incubation_time', - reason: 'Task system uses state_started_at for timing', - replacedBy: 'state_started_at', + reason: 'Task system uses progression_started_at for timing', + replacedBy: 'progression_started_at', deprecatedSince: 'v1.0', }, { tag: 'start_incubation', - reason: 'Task system uses state_started_at for timing', - replacedBy: 'state_started_at', + reason: 'Task system uses progression_started_at for timing', + replacedBy: 'progression_started_at', deprecatedSince: 'v1.0', }, { diff --git a/src/blobbi/core/lib/blobbi.ts b/src/blobbi/core/lib/blobbi.ts index cd0d9de3..75ce711c 100644 --- a/src/blobbi/core/lib/blobbi.ts +++ b/src/blobbi/core/lib/blobbi.ts @@ -1177,8 +1177,8 @@ export const VISUAL_TRAIT_TAG_NAMES = [ * - incubation_progress: Obsolete task progress field * - egg_status: Obsolete status field * - fees: Obsolete fee tracking field - * - incubation_time: Obsolete; task system uses state_started_at instead - * - start_incubation: Obsolete; replaced by state_started_at + * - incubation_time: Obsolete; task system uses progression_started_at instead + * - start_incubation: Obsolete; replaced by progression_started_at * - interact_6_progress: Legacy interaction tracking; replaced by ["task", "interactions:N"] */ export const DEPRECATED_BLOBBI_TAG_NAMES = new Set([ From 91fe272beac1ccfc7833d25ea3b2eb027628c95a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 Apr 2026 09:19:01 -0500 Subject: [PATCH 18/19] release: v2.10.2 --- CHANGELOG.md | 8 ++++++++ android/app/build.gradle | 2 +- ios/App/App.xcodeproj/project.pbxproj | 4 ++-- package.json | 2 +- public/CHANGELOG.md | 8 ++++++++ 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0995e37..9aab0eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.10.2] - 2026-04-18 + +### Fixed +- Sleep and wake actions on Blobbis no longer wipe out evolution progress, and existing Blobbis that got stuck are recovered automatically +- Pages no longer crash when a post contains a malformed blurhash; a placeholder is shown instead +- External content links (books, locations, and other identifiers) and relay pages no longer crash on unusual or malformed URLs +- Custom profile themes with invalid color values no longer break the page + ## [2.10.1] - 2026-04-17 ### Fixed diff --git a/android/app/build.gradle b/android/app/build.gradle index 4d2f33e4..af6e4761 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,7 +14,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "2.10.1" + versionName "2.10.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 09cf8407..848a3174 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -327,7 +327,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.10.1; + MARKETING_VERSION = 2.10.2; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -351,7 +351,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.10.1; + MARKETING_VERSION = 2.10.2; PRODUCT_BUNDLE_IDENTIFIER = pub.ditto.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/package.json b/package.json index 13c7e11d..6ac7e941 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ditto", "private": true, - "version": "2.10.1", + "version": "2.10.2", "type": "module", "scripts": { "dev": "npm i --silent && vite", diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index c0995e37..9aab0eae 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [2.10.2] - 2026-04-18 + +### Fixed +- Sleep and wake actions on Blobbis no longer wipe out evolution progress, and existing Blobbis that got stuck are recovered automatically +- Pages no longer crash when a post contains a malformed blurhash; a placeholder is shown instead +- External content links (books, locations, and other identifiers) and relay pages no longer crash on unusual or malformed URLs +- Custom profile themes with invalid color values no longer break the page + ## [2.10.1] - 2026-04-17 ### Fixed From f14316f024db011a08ecd95345380bf58519b091 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Sat, 18 Apr 2026 16:42:48 -0500 Subject: [PATCH 19/19] Send reply events to tagged users' inbox relays (NIP-65) When publishing kind 1 or kind 1111 reply events, also deliver them to the read (inbox) relays of p-tagged users. This follows the NIP-65 recommendation that clients send events to the read relays of each tagged user so recipients are more likely to see replies. The inbox delivery is fire-and-forget after the main publish succeeds, so it does not slow down the UI or block the publish flow. --- src/hooks/useNostrPublish.ts | 18 +++++++++ src/lib/inboxRelays.ts | 75 ++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/lib/inboxRelays.ts diff --git a/src/hooks/useNostrPublish.ts b/src/hooks/useNostrPublish.ts index 6417f9b1..a37475ae 100644 --- a/src/hooks/useNostrPublish.ts +++ b/src/hooks/useNostrPublish.ts @@ -4,6 +4,7 @@ import { nip19 } from "nostr-tools"; import { useAppContext } from "./useAppContext"; import { useCurrentUser } from "./useCurrentUser"; +import { sendToInboxRelays } from "@/lib/inboxRelays"; import type { NostrEvent } from "@nostrify/nostrify"; @@ -102,6 +103,23 @@ export function useNostrPublish(): UseMutationResult { } await nostr.event(event, { signal: AbortSignal.timeout(5000) }); + + // NIP-65: For reply events (kind 1 and 1111), also send to the + // inbox (read) relays of tagged users so they receive the reply. + // This is fire-and-forget — it must not block the publish flow. + if (event.kind === 1 || event.kind === 1111) { + const taggedPubkeys = event.tags + .filter(([name]) => name === 'p' || name === 'P') + .map(([, pubkey]) => pubkey) + .filter(Boolean); + + if (taggedPubkeys.length > 0) { + sendToInboxRelays(nostr, event, taggedPubkeys).catch(() => { + // Silently swallow — inbox delivery is best-effort. + }); + } + } + return event; } else { throw new Error("User is not logged in"); diff --git a/src/lib/inboxRelays.ts b/src/lib/inboxRelays.ts new file mode 100644 index 00000000..98b4ed1d --- /dev/null +++ b/src/lib/inboxRelays.ts @@ -0,0 +1,75 @@ +import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; + +/** + * Extract read (inbox) relay URLs from a NIP-65 (kind 10002) relay list event. + * + * Read relays are where a user expects to receive mentions and replies. + * Per NIP-65: tags with no marker are both read+write; tags with "read" + * are read-only; tags with "write" are write-only (excluded here). + */ +export function extractReadRelays(event: NostrEvent): string[] { + const relays = new Set(); + for (const [name, url, marker] of event.tags) { + if (name !== 'r' || marker === 'write' || !url) continue; + try { + const parsed = new URL(url); + if (parsed.protocol === 'wss:') { + relays.add(parsed.href); + } + } catch { + // skip malformed URLs + } + } + return [...relays]; +} + +interface NostrQueryable { + query(filters: NostrFilter[], opts?: { signal?: AbortSignal }): Promise; + group(urls: string[]): { event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise }; +} + +/** + * Fetch the inbox (read) relays for a set of pubkeys and publish the event + * to those relays. This is a best-effort, fire-and-forget operation that + * should not block the caller. + * + * Per NIP-65: "When publishing an event, clients SHOULD send the event to + * all read relays of each tagged user." + */ +export async function sendToInboxRelays( + nostr: NostrQueryable, + event: NostrEvent, + pubkeys: string[], +): Promise { + if (pubkeys.length === 0) return; + + // Deduplicate pubkeys and exclude the event author (they already have the event) + const uniquePubkeys = [...new Set(pubkeys)].filter((pk) => pk !== event.pubkey); + if (uniquePubkeys.length === 0) return; + + try { + // Batch-fetch NIP-65 relay lists for all tagged users + const relayListEvents = await nostr.query( + [{ kinds: [10002], authors: uniquePubkeys, limit: uniquePubkeys.length }], + { signal: AbortSignal.timeout(5000) }, + ); + + // Collect all unique inbox relay URLs + const inboxRelays = new Set(); + for (const relayListEvent of relayListEvents) { + for (const url of extractReadRelays(relayListEvent)) { + inboxRelays.add(url); + } + } + + if (inboxRelays.size === 0) return; + + // Cap to avoid connecting to too many relays at once + const urls = [...inboxRelays].slice(0, 10); + + await nostr.group(urls).event(event, { signal: AbortSignal.timeout(5000) }); + } catch { + // Best-effort: log but don't throw + console.warn('Failed to send event to inbox relays'); + } +}