diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index d8bd9d42..27a46dfe 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -100,7 +100,82 @@ 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. + +**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 +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 +184,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/.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/AGENTS.md b/AGENTS.md index 98f55479..3bbe815c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1413,7 +1413,38 @@ 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 + +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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a55491ad..9aab0eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # 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 +- 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 +- 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 +- 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 + ## [2.8.0] - 2026-04-16 ### Added 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. diff --git a/android/app/build.gradle b/android/app/build.gradle index a63c0332..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.8.0" + 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/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. - + diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index bd2940bf..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.8.0; + 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.8.0; + 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 e4b77a8e..6ac7e941 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ditto", "private": true, - "version": "2.8.0", + "version": "2.10.2", "type": "module", "scripts": { "dev": "npm i --silent && vite", diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index a55491ad..9aab0eae 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -1,5 +1,43 @@ # 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 +- 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 +- 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 +- 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 + ## [2.8.0] - 2026-04-16 ### Added 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/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..4021ac71 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]); @@ -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/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..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). @@ -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, }); @@ -279,7 +280,7 @@ export function useStartIncubation({ return { name: canonical.companion.name, - stateStartedAt: now, + progressionStartedAt: now, mode, stoppedOtherName, }; @@ -343,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({ @@ -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, }); @@ -473,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: @@ -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, }); @@ -574,7 +576,7 @@ export function useStartEvolution({ return { name: canonical.companion.name, - stateStartedAt: now, + progressionStartedAt: now, }; }, onSuccess: ({ name }) => { @@ -624,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({ @@ -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..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) @@ -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', @@ -632,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', }, { @@ -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..75ce711c 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; @@ -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) */ @@ -267,18 +278,24 @@ 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) */ 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) @@ -1129,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([ @@ -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/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/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/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/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..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 ( @@ -1082,6 +1084,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 +1117,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/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/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/NoteCard.tsx b/src/components/NoteCard.tsx index 34f64735..b5ffdfd0 100644 --- a/src/components/NoteCard.tsx +++ b/src/components/NoteCard.tsx @@ -20,6 +20,7 @@ import { SmilePlus, PartyPopper, Sparkles, + UserCheck, Users, Zap, } from "lucide-react"; @@ -48,7 +49,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"; @@ -92,6 +93,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"; @@ -342,6 +344,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); @@ -393,7 +396,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; @@ -443,7 +446,7 @@ export const NoteCard = memo(function NoteCard({ !isGeocache && !isFoundLog && !isColor && - !isFollowPack && + !isPeopleList && !isArticle && !isMagicDeck && !isStream && @@ -589,8 +592,8 @@ export const NoteCard = memo(function NoteCard({ ) : isColor ? ( - ) : isFollowPack ? ( - + ) : isPeopleList ? ( + ) : isArticle ? ( }> @@ -808,7 +811,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" }); }} @@ -1800,7 +1803,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", }, @@ -1811,7 +1814,7 @@ 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", @@ -1828,6 +1831,11 @@ const KIND_HEADER_MAP: Record = { noun: "playlist", nounRoute: "/music", }, + 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..4f0ef095 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'; @@ -52,8 +49,8 @@ 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 { timeAgo } from '@/lib/timeAgo'; import { toast } from '@/hooks/useToast'; import { impactLight } from '@/lib/haptics'; import { cn } from '@/lib/utils'; @@ -312,15 +309,14 @@ 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); 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(); @@ -337,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(); @@ -410,34 +406,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/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/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) ? (
) : ( - + )}
)} @@ -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/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx index 083d840d..6f0a713d 100644 --- a/src/components/VideoPlayer.tsx +++ b/src/components/VideoPlayer.tsx @@ -3,6 +3,7 @@ import type Hls from 'hls.js'; import { Play, Pause, Volume1, Volume2, VolumeX, Expand } from 'lucide-react'; import { Blurhash } from 'react-blurhash'; import { cn } from '@/lib/utils'; +import { isValidBlurhash } from '@/lib/blurhash'; import { useBlossomFallback } from '@/hooks/useBlossomFallback'; import { usePlayerControls } from '@/hooks/usePlayerControls'; import { useVideoThumbnail } from '@/hooks/useVideoThumbnail'; @@ -203,7 +204,7 @@ export function VideoPlayer({ src: originalSrc, poster, className, dim, blurhash onClick={(e) => e.stopPropagation()} > {/* Blurhash placeholder — shown until the video has a displayable frame */} - {blurhash && !videoReady && !generatedPoster && ( + {isValidBlurhash(blurhash) && !videoReady && !generatedPoster && ( { 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/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]; 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/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/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/blurhash.ts b/src/lib/blurhash.ts new file mode 100644 index 00000000..ba26ed1b --- /dev/null +++ b/src/lib/blurhash.ts @@ -0,0 +1,7 @@ +import { isBlurhashValid } from 'blurhash'; + +/** Returns `true` when `hash` is a structurally valid blurhash string. */ +export function isValidBlurhash(hash: string | undefined | null): hash is string { + if (!hash) return false; + return isBlurhashValid(hash).result; +} 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/extraKinds.ts b/src/lib/extraKinds.ts index cab39385..a3e2e7b2 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/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'); + } +} 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/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/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/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/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/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/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 8a001981..40b38268 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; @@ -130,7 +130,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"; @@ -176,6 +178,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"; @@ -304,6 +307,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 ( @@ -349,12 +363,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 ( - + ); @@ -939,6 +953,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); @@ -983,10 +998,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; @@ -995,7 +1010,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; @@ -1031,7 +1046,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { !isGeocache && !isFoundLog && !isColor && - !isFollowPack && + !isPeopleList && !isEmojiPack && !isArticle && !isMagicDeck && @@ -2159,7 +2174,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) { isGeocache || isFoundLog || isColor || - isFollowPack || + isPeopleList || isEmojiPack ? ( <> {isVine && } @@ -2167,7 +2182,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..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(); @@ -453,46 +455,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 +919,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 +2162,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 && ( { 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; } diff --git a/src/pages/VideosFeedPage.tsx b/src/pages/VideosFeedPage.tsx index 297e6347..004c9ae8 100644 --- a/src/pages/VideosFeedPage.tsx +++ b/src/pages/VideosFeedPage.tsx @@ -22,6 +22,7 @@ import { nip19 } from "nostr-tools"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Blurhash } from "react-blurhash"; import { Link } from "react-router-dom"; +import { isValidBlurhash } from "@/lib/blurhash"; import { ARC_OVERHANG_PX } from "@/components/ArcBackground"; import { FeedEmptyState } from "@/components/FeedEmptyState"; import { KindInfoButton } from "@/components/KindInfoButton"; @@ -247,7 +248,7 @@ function VideoGridCard({ event }: { event: NostrEvent }) { > {/* Thumbnail */}
- {blurhash && ( + {isValidBlurhash(blurhash) && (
- {blurhash && !thumbnail && ( + {isValidBlurhash(blurhash) && !thumbnail && (