Merge remote-tracking branch 'origin/main' into ditto-music-feed
# Conflicts: # src/components/NoteCard.tsx
This commit is contained in:
@@ -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<prev>..HEAD
|
||||
|
||||
# Or narrowed to an area you're unsure about
|
||||
git diff v<prev>..HEAD -- src/components/ComposeBox.tsx
|
||||
```
|
||||
|
||||
Only the diff reveals intra-release churn (commits that cancel each other out, bugs introduced and then fixed, refactors that land and get reverted). Reading commit messages alone is insufficient.
|
||||
|
||||
##### 5.2. Trace every candidate "Fixed" entry to its origin commit
|
||||
|
||||
For each bug fix you're considering listing, find the commit that introduced the bug.
|
||||
|
||||
**Fast path -- check for `Regression-of:` trailers** (see AGENTS.md "Attributing Regressions"). If the fix commit declares its origin in a trailer, you don't need to hunt:
|
||||
|
||||
```bash
|
||||
# List all commits in the release window with their Regression-of trailers (if any)
|
||||
git log v<prev>..HEAD --no-merges \
|
||||
--format='%h %s%n Regression-of: %(trailers:key=Regression-of,valueonly,separator=%x20)'
|
||||
```
|
||||
|
||||
For each `Regression-of: <sha>` entry, check whether `<sha>` is also in the release window:
|
||||
|
||||
```bash
|
||||
# Returns 0 if <sha> is BEFORE v<prev> (pre-existing bug -> legit "Fixed" entry)
|
||||
# Returns non-zero if <sha> is AFTER v<prev> (intra-release -> omit from "Fixed")
|
||||
git merge-base --is-ancestor <sha> v<prev>
|
||||
```
|
||||
|
||||
**Fallback -- manual tracing** (when no trailer is present):
|
||||
|
||||
```bash
|
||||
# Show the history of a file across all commits
|
||||
git log --oneline v<prev>..HEAD -- path/to/file.tsx
|
||||
|
||||
# Or blame the specific lines the fix touched
|
||||
git blame -L <start>,<end> -- path/to/file.tsx
|
||||
```
|
||||
|
||||
**If the introducing commit is also in this release window (i.e. after the previous tag), the bug is intra-release.** The user on the previous version never experienced it. Do NOT list it as a "Fixed" entry. Fold it into the relevant "Added" or "Changed" entry, or omit it entirely.
|
||||
|
||||
##### 5.3. The "Would a user on the previous version notice this?" test
|
||||
|
||||
The changelog describes the delta between the previous release and this one **from the user's perspective** -- not the development history. Before writing each entry, ask:
|
||||
|
||||
> "Did a user on the previous published version experience this exact thing?"
|
||||
|
||||
- If they experienced a broken state that is now fixed: **"Fixed" entry**
|
||||
- If they experienced the old behavior and now see new behavior: **"Changed" or "Added" entry**
|
||||
- If they never saw either state (introduced AND resolved within this release window): **omit entirely**
|
||||
|
||||
This applies to more than just bugs:
|
||||
- A feature added and then reverted in the same release: omit both
|
||||
- A refactor that was done and then undone: omit both
|
||||
- A performance regression introduced and then fixed: omit both
|
||||
- A typo introduced in a new string and then corrected: mention the new string (if user-facing) as a single "Added"/"Changed" entry, with no "Fixed" entry
|
||||
|
||||
##### 5.4. Worked example -- intra-release bug
|
||||
|
||||
> **Scenario:** Commit A overhauls the compose box and, as a side effect, breaks the background of the expanded emoji picker. Commit B, later in the same release window, restores the background.
|
||||
>
|
||||
> **Correct changelog:** One "Added" entry describing the compose box overhaul. The emoji picker background is part of the finished state the user receives.
|
||||
>
|
||||
> **Incorrect changelog:** An "Added" entry for the overhaul AND a "Fixed" entry for the emoji picker background. The user on the previous version never saw the broken background; listing it invents a problem they didn't have and makes the release notes read like a developer changelog.
|
||||
|
||||
#### Rules
|
||||
|
||||
- Only include categories that have entries (omit empty categories)
|
||||
- Write **user-facing descriptions**, not raw commit messages
|
||||
- Keep descriptions concise -- one line per change
|
||||
@@ -109,9 +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
|
||||
|
||||
|
||||
@@ -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="*"
|
||||
@@ -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 '<removed-or-changed-string>'` -- find commits that touched a specific string
|
||||
- `git log --oneline -- path/to/file` -- list all commits touching a file
|
||||
- `git blame -L <start>,<end> -- path/to/file` -- find who last changed specific lines
|
||||
|
||||
This convention is **strongly recommended but not required.** When the origin is non-obvious, prioritize shipping the fix over hunting indefinitely.
|
||||
|
||||
## Capacitor Compatibility
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <short-sha>` trailer to the bottom of your commit message. See AGENTS.md "Attributing Regressions" for the convention.
|
||||
|
||||
### New features and significant changes
|
||||
|
||||
Every feature MR must link to an existing open issue and clearly align with the [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: <short-sha>` trailer? (See AGENTS.md "Attributing Regressions".)
|
||||
|
||||
Skip anything a linter or type checker would catch. Focus on logic, data flow, and intent.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<title>Ditto — Your content. Your vibe. Your rules.</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="description" content="Ditto — Your content. Your vibe. Your rules." />
|
||||
|
||||
<!-- Open Graph -->
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ditto",
|
||||
"private": true,
|
||||
"version": "2.8.0",
|
||||
"version": "2.10.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm i --silent && vite",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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: <AlertTriangle className="size-5 text-amber-500" />,
|
||||
description: (
|
||||
<>
|
||||
Your Blobbi is already {companion?.state}. Starting over will{' '}
|
||||
Your Blobbi is already {companion?.progressionState}. Starting over will{' '}
|
||||
<strong>reset all task progress</strong> and begin from the beginning.
|
||||
<br /><br />
|
||||
Are you sure you want to restart?
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string> {
|
||||
* These are task-related and state-specific tags.
|
||||
*/
|
||||
export function getTransitionCleanupTagNames(): Set<string> {
|
||||
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<BlobbiStage, Set<string>> = {
|
||||
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']);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<number, string> = {
|
||||
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<number, string> = {
|
||||
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<Record<number, React.ComponentType<{ className?: strin
|
||||
37381: CardsIcon,
|
||||
37516: ChestIcon,
|
||||
7516: ChestIcon,
|
||||
3: UserCheck,
|
||||
30000: Users,
|
||||
39089: PartyPopper,
|
||||
3367: Palette,
|
||||
9735: Zap,
|
||||
@@ -222,6 +226,7 @@ const KIND_SUFFIXES: Partial<Record<number, string>> = {
|
||||
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 <ProfileBadgesCommentContext root={root} className={className} />;
|
||||
}
|
||||
|
||||
// Kind 3 follow lists have no title of their own — synthesize one from the author's name
|
||||
if (root.addr?.kind === 3) {
|
||||
return <FollowListCommentContext pubkey={root.addr.pubkey} className={className} />;
|
||||
}
|
||||
|
||||
return <GenericAddrCommentContext root={root} className={className} />;
|
||||
}
|
||||
|
||||
/** Comment context for kind 3 (follow list) roots — shows "Commenting on @Name's follow list". */
|
||||
function FollowListCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
const metadata = author.data?.metadata;
|
||||
const displayName = metadata?.name ?? genUserName(pubkey);
|
||||
const npubEncoded = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
const listLink = useMemo(
|
||||
() => `/${nip19.naddrEncode({ kind: 3, pubkey, identifier: '' })}`,
|
||||
[pubkey],
|
||||
);
|
||||
|
||||
return (
|
||||
<CommentContextRow prefix="Commenting on" className={className} loading={author.isLoading}>
|
||||
<ProfileHoverCard pubkey={pubkey} asChild>
|
||||
<Link
|
||||
to={`/${npubEncoded}`}
|
||||
className="text-primary hover:underline truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{displayName}'s
|
||||
</Link>
|
||||
</ProfileHoverCard>
|
||||
<Link
|
||||
to={listLink}
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<UserCheck className="size-3.5 shrink-0" />
|
||||
follow list
|
||||
</Link>
|
||||
</CommentContextRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** Comment context for kind 0 (profile) roots — shows "Commenting on @Name". */
|
||||
function ProfileCommentContext({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||
const author = useAuthor(pubkey);
|
||||
|
||||
@@ -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 (
|
||||
<div className="mt-3 space-y-5">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 <EmbeddedBlobbiCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
// 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 <EmbeddedPeopleListCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
return <EmbeddedNaddrCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <EmbeddedBadgeAwardCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
// 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 <EmbeddedPeopleListCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
return <EmbeddedNoteCard event={event} className={className} disableHoverCards={disableHoverCards} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<EmbeddedCardShell
|
||||
pubkey={event.pubkey}
|
||||
createdAt={event.created_at}
|
||||
navigateTo={nip19Id}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
>
|
||||
{/* Title with variant icon */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<TitleIcon className="size-3.5 text-primary shrink-0" />
|
||||
<p className="text-sm font-semibold leading-snug line-clamp-1">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Cover image — only for packs/sets that declare one */}
|
||||
{safeImage && (
|
||||
<div className="rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={safeImage}
|
||||
alt={title}
|
||||
className="w-full max-h-[140px] object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Avatar stack + member count */}
|
||||
{pubkeys.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex -space-x-1.5">
|
||||
{previewPubkeys.map((pk) => {
|
||||
const member = membersMap?.get(pk);
|
||||
const name = member?.metadata?.name || genUserName(pk);
|
||||
const shape = getAvatarShape(member?.metadata);
|
||||
return (
|
||||
<Avatar
|
||||
key={pk}
|
||||
shape={shape}
|
||||
className="size-5 ring-1 ring-background"
|
||||
>
|
||||
<AvatarImage src={member?.metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[9px]">
|
||||
{name[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{memberLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</EmbeddedCardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { ProfilePreview } from '@/components/ExternalContentHeader';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface EmbeddedPostProps {
|
||||
/** The event to render. */
|
||||
event: NostrEvent;
|
||||
/** Extra classes applied to the outermost wrapper. */
|
||||
className?: string;
|
||||
/** When true, ProfileHoverCards inside the card are disabled to prevent nested hover cards. */
|
||||
disableHoverCards?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact embedded preview of a Nostr event.
|
||||
*
|
||||
* Delegates to the shared `EmbeddedNote` / `EmbeddedNaddr` components used by
|
||||
* quote posts, reply indicators, comment context, and hover cards so every
|
||||
* surface that previews an event renders it consistently — regardless of
|
||||
* whether it's a text note, an addressable event (article, people list,
|
||||
* badge…), or a profile (kind 0).
|
||||
*/
|
||||
export function EmbeddedPost({ event, className, disableHoverCards }: EmbeddedPostProps) {
|
||||
// Kind 0 (profile) — show a profile card instead of trying to render the raw JSON content
|
||||
if (event.kind === 0) {
|
||||
return (
|
||||
<div className={cn('rounded-xl border border-border bg-secondary/30 overflow-hidden', className)}>
|
||||
<ProfilePreview pubkey={event.pubkey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Addressable events (kind 30000-39999) — use EmbeddedNaddr
|
||||
if (event.kind >= 30000 && event.kind < 40000) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
||||
return (
|
||||
<EmbeddedNaddr
|
||||
addr={{ kind: event.kind, pubkey: event.pubkey, identifier: dTag }}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Everything else — use EmbeddedNote (the event is already in the query cache)
|
||||
return (
|
||||
<EmbeddedNote
|
||||
eventId={event.id}
|
||||
authorHint={event.pubkey}
|
||||
className={className}
|
||||
disableHoverCards={disableHoverCards}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<number, string> = {
|
||||
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]);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<Users className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No members in this pack yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && filteredPosts.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-11 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredPosts.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground text-sm">
|
||||
No posts from pack members yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filteredPosts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Members Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function PackMembersTab({
|
||||
pubkeys,
|
||||
membersMap,
|
||||
membersLoading,
|
||||
followedPubkeys,
|
||||
currentUserPubkey,
|
||||
}: {
|
||||
pubkeys: string[];
|
||||
membersMap: Map<string, { metadata?: NostrMetadata }> | undefined;
|
||||
membersLoading: boolean;
|
||||
followedPubkeys: Set<string>;
|
||||
currentUserPubkey: string | undefined;
|
||||
}) {
|
||||
if (membersLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => (
|
||||
<MemberCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{pubkeys.map((pk) => {
|
||||
const member = membersMap?.get(pk);
|
||||
const isFollowed = followedPubkeys.has(pk);
|
||||
return (
|
||||
<MemberCard
|
||||
key={pk}
|
||||
pubkey={pk}
|
||||
metadata={member?.metadata}
|
||||
isFollowed={isFollowed}
|
||||
isSelf={pk === currentUserPubkey}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Tab>('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 && (
|
||||
<div className="w-full overflow-hidden bg-muted border-b border-border">
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-auto max-h-[300px] object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to={`/${npub}`}>
|
||||
<Avatar shape={avatarShape} className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link to={`/${npub}`} className="font-bold text-[15px] hover:underline block truncate">
|
||||
{displayName}
|
||||
</Link>
|
||||
{metadata?.nip05 && (
|
||||
<VerifiedNip05Text nip05={metadata.nip05} pubkey={event.pubkey} className="text-sm text-muted-foreground truncate block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant="secondary" className="shrink-0 gap-1">
|
||||
<Users className="size-3" />
|
||||
{isStarterPack ? 'Starter Pack' : 'List'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-bold mt-4 leading-snug">{title}</h2>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-[15px] text-muted-foreground leading-relaxed mt-2 whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats + Actions */}
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-1.5">
|
||||
<Users className="size-4" />
|
||||
{pubkeys.length} member{pubkeys.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{newPubkeys.length > 0 && user && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
{newPubkeys.length} new for you
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
className="gap-2 flex-1"
|
||||
onClick={handleFollowAll}
|
||||
disabled={isFollowingAll || !user}
|
||||
>
|
||||
{isFollowingAll ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Following…
|
||||
</>
|
||||
) : newPubkeys.length === 0 && user ? (
|
||||
<>
|
||||
<Check className="size-4" />
|
||||
Already following all
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="size-4" />
|
||||
Follow All ({pubkeys.length})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="icon" onClick={handleCopyLink}>
|
||||
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<SubHeaderBar>
|
||||
<TabButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
|
||||
<TabButton label="Members" active={activeTab === 'members'} onClick={() => setActiveTab('members')} />
|
||||
</SubHeaderBar>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'feed' ? (
|
||||
<PackFeedTab pubkeys={pubkeys} />
|
||||
) : (
|
||||
<PackMembersTab
|
||||
pubkeys={pubkeys}
|
||||
membersMap={membersMap}
|
||||
membersLoading={membersLoading}
|
||||
followedPubkeys={followedPubkeys}
|
||||
currentUserPubkey={user?.pubkey}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors cursor-pointer"
|
||||
onClick={() => navigate(`/${npub}`)}
|
||||
>
|
||||
<Link to={`/${npub}`} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
to={`/${npub}`}
|
||||
className="font-bold text-[15px] hover:underline block truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayName}
|
||||
</Link>
|
||||
{about && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{about}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isSelf && (
|
||||
<Button
|
||||
variant={isFollowed ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={handleFollowToggle}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : isFollowed ? (
|
||||
'Following'
|
||||
) : (
|
||||
'Follow'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberCardSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string>('');
|
||||
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;
|
||||
|
||||
@@ -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.
|
||||
<Blurhash
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
|
||||
import { Images, Play, ShieldAlert } from 'lucide-react';
|
||||
import { Blurhash } from 'react-blurhash';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { isValidBlurhash } from '@/lib/blurhash';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { getAvatarShape } from '@/lib/avatarShape';
|
||||
@@ -139,7 +140,7 @@ function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void })
|
||||
onClick={showBlur ? (e) => { e.stopPropagation(); setCwRevealed(true); } : onClick}
|
||||
aria-label={showBlur ? 'Reveal sensitive content' : 'View media'}
|
||||
>
|
||||
{item.blurhash && (
|
||||
{isValidBlurhash(item.blurhash) && (
|
||||
<Blurhash
|
||||
hash={item.blurhash}
|
||||
width="100%"
|
||||
@@ -151,7 +152,7 @@ function MediaThumb({ item, onClick }: { item: MediaItem; onClick: () => void })
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
{!item.blurhash && !loaded && item.type !== 'audio' && (
|
||||
{!isValidBlurhash(item.blurhash) && !loaded && item.type !== 'audio' && (
|
||||
<Skeleton className="absolute inset-0 w-full h-full rounded-none" />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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({
|
||||
<FoundLogContent event={event} />
|
||||
) : isColor ? (
|
||||
<ColorMomentContent event={event} />
|
||||
) : isFollowPack ? (
|
||||
<FollowPackContent event={event} />
|
||||
) : isPeopleList ? (
|
||||
<PeopleListContent event={event} />
|
||||
) : isArticle ? (
|
||||
<Suspense fallback={<Skeleton className="h-24 w-full rounded-lg" />}>
|
||||
<ArticleContent event={event} preview className="mt-2" />
|
||||
@@ -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<number, KindHeaderConfig> = {
|
||||
},
|
||||
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<number, KindHeaderConfig> = {
|
||||
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<number, KindHeaderConfig> = {
|
||||
noun: "playlist",
|
||||
nounRoute: "/music",
|
||||
},
|
||||
3: {
|
||||
icon: UserCheck,
|
||||
action: "updated their",
|
||||
noun: "follow list",
|
||||
},
|
||||
};
|
||||
|
||||
/** Generic action header: icon · [author name] [action] [linked noun] */
|
||||
|
||||
@@ -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
|
||||
<DialogContent className="max-w-md max-h-[85dvh] p-0 gap-0 rounded-2xl overflow-y-auto [&>button]:hidden">
|
||||
<DialogTitle className="sr-only">Post options</DialogTitle>
|
||||
|
||||
{/* Post preview */}
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<div className="flex gap-3">
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<span className="font-bold truncate">
|
||||
{author.data?.event ? (
|
||||
<EmojifiedText tags={author.data.event.tags}>{displayName}</EmojifiedText>
|
||||
) : displayName}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0">·</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{timeAgo(event.created_at)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-muted-foreground line-clamp-3 max-h-[4.5em] overflow-hidden">
|
||||
{/^[A-Za-z0-9+/=_-]{20,}$/.test(event.content.trim()) ? (
|
||||
<span className="italic">Encrypted content</span>
|
||||
) : (
|
||||
<NoteContent event={event} className="text-sm leading-relaxed" disableEmbeds />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Post preview — delegates to the shared EmbeddedPost used by quote
|
||||
posts and reply indicators so every surface renders events the same way. */}
|
||||
<div className="p-4">
|
||||
<EmbeddedPost event={event} disableHoverCards />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -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 (
|
||||
<div className="mt-2">
|
||||
{/* Title */}
|
||||
{title && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{!isFollowSet && <PartyPopper className="size-4 text-primary shrink-0" />}
|
||||
<TitleIcon className="size-4 text-primary shrink-0" />
|
||||
<span className="text-[15px] font-semibold leading-snug">{title}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -43,11 +56,11 @@ export function FollowPackContent({ event }: { event: NostrEvent }) {
|
||||
)}
|
||||
|
||||
{/* Cover image */}
|
||||
{image && (
|
||||
{safeImage && (
|
||||
<div className="rounded-2xl overflow-hidden mb-3">
|
||||
<img
|
||||
src={image}
|
||||
alt={title ?? 'Follow pack'}
|
||||
src={safeImage}
|
||||
alt={title}
|
||||
className="w-full max-h-[200px] object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
@@ -57,13 +70,9 @@ export function FollowPackContent({ event }: { event: NostrEvent }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member count pill + avatar stack */}
|
||||
{/* Avatar stack */}
|
||||
{pubkeys.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-[11px] gap-1 font-medium shrink-0">
|
||||
<Users className="size-3" />
|
||||
{pubkeys.length}
|
||||
</Badge>
|
||||
<div className="flex -space-x-2">
|
||||
{previewPubkeys.map((pk) => {
|
||||
const member = membersMap?.get(pk);
|
||||
@@ -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<NostrFilter | null>(
|
||||
() => (pubkeys.length > 0 ? { kinds: [1, 6, 16], authors: pubkeys } : null),
|
||||
[pubkeys],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
} = useTabFeed(filter, `people-list-${tabKey}`, pubkeys.length > 0);
|
||||
|
||||
// Fetch next page when the sentinel scrolls into view.
|
||||
useEffect(() => {
|
||||
if (inView && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Flatten pages, dedupe, and apply mute / content-warning / reply filters.
|
||||
const feedItems = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
const seen = new Set<string>();
|
||||
return data.pages
|
||||
.flatMap((page) => page.items)
|
||||
.filter((item) => {
|
||||
const key = item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
if (shouldHideFeedEvent(item.event)) return false;
|
||||
if (muteItems.length > 0 && isEventMuted(item.event, muteItems)) return false;
|
||||
// Hide replies — this tab should show top-level posts only (reposts of
|
||||
// replies are fine, so only check original kind 1 events, not reposts).
|
||||
if (item.event.kind === 1 && !item.repostedBy && isReplyEvent(item.event)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [data?.pages, muteItems]);
|
||||
|
||||
if (pubkeys.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<Users className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No members in this list yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && feedItems.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-11 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (feedItems.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground text-sm">
|
||||
No posts from list members yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{feedItems.map((item) => (
|
||||
<NoteCard
|
||||
key={item.repostedBy ? `repost-${item.repostedBy}-${item.event.id}` : item.event.id}
|
||||
event={item.event}
|
||||
repostedBy={item.repostedBy}
|
||||
/>
|
||||
))}
|
||||
{hasNextPage && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-6">
|
||||
{isFetchingNextPage && <Loader2 className="size-5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Members Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface MembersTabProps {
|
||||
pubkeys: string[];
|
||||
membersMap: Map<string, { metadata?: NostrMetadata }> | undefined;
|
||||
membersLoading: boolean;
|
||||
followedPubkeys: Set<string>;
|
||||
currentUserPubkey: string | undefined;
|
||||
/** When true, show per-member "Remove" buttons. Enabled for owners of kind 30000 lists. */
|
||||
canRemove: boolean;
|
||||
/** Kind 30000 d-tag — required when canRemove is true. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
export function PeopleListMembersTab({
|
||||
pubkeys,
|
||||
membersMap,
|
||||
membersLoading,
|
||||
followedPubkeys,
|
||||
currentUserPubkey,
|
||||
canRemove,
|
||||
listId,
|
||||
}: MembersTabProps) {
|
||||
if (membersLoading) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => (
|
||||
<MemberCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{pubkeys.map((pk) => {
|
||||
const member = membersMap?.get(pk);
|
||||
const isFollowed = followedPubkeys.has(pk);
|
||||
return (
|
||||
<MemberCard
|
||||
key={pk}
|
||||
pubkey={pk}
|
||||
metadata={member?.metadata}
|
||||
isFollowed={isFollowed}
|
||||
isSelf={pk === currentUserPubkey}
|
||||
canRemove={canRemove}
|
||||
listId={listId}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Comments Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function PeopleListCommentsTab({
|
||||
event,
|
||||
orderedReplies,
|
||||
commentsLoading,
|
||||
}: {
|
||||
event: NostrEvent;
|
||||
orderedReplies: Array<{ reply: NostrEvent; firstSubReply?: NostrEvent }>;
|
||||
commentsLoading: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<ComposeBox compact replyTo={event} />
|
||||
{commentsLoading ? (
|
||||
<CommentsSkeleton />
|
||||
) : orderedReplies.length > 0 ? (
|
||||
<FlatThreadedReplyList replies={orderedReplies} />
|
||||
) : (
|
||||
<div className="py-16 flex flex-col items-center gap-3 text-center px-8">
|
||||
<MessageCircle className="size-8 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No comments yet. Be the first to comment.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsSkeleton() {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Detail Component ────────────────────────────────────────────────────
|
||||
|
||||
export function PeopleListDetailContent({ event }: { event: NostrEvent }) {
|
||||
const { toast } = useToast();
|
||||
const { 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<Tab>('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 && (
|
||||
<div className="w-full overflow-hidden bg-muted border-b border-border">
|
||||
<img
|
||||
src={safeImage}
|
||||
alt={title}
|
||||
className="w-full h-auto max-h-[300px] object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
{/* Author row */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to={`/${authorNpub}`}>
|
||||
<Avatar shape={authorAvatarShape} className="size-11">
|
||||
<AvatarImage src={authorMetadata?.picture} alt={authorName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{authorName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
to={`/${authorNpub}`}
|
||||
className="font-bold text-[15px] hover:underline block truncate"
|
||||
>
|
||||
{authorName}
|
||||
</Link>
|
||||
{authorMetadata?.nip05 && (
|
||||
<VerifiedNip05Text
|
||||
nip05={authorMetadata.nip05}
|
||||
pubkey={event.pubkey}
|
||||
className="text-sm text-muted-foreground truncate block"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-xl font-bold mt-4 leading-snug">{title}</h2>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="text-[15px] text-muted-foreground leading-relaxed mt-2 whitespace-pre-wrap">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* "N new for you" hint */}
|
||||
{newPubkeys.length > 0 && user && !isOwnList && (
|
||||
<div className="mt-4 text-sm text-green-600 dark:text-green-400">
|
||||
{newPubkeys.length} new for you
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 mt-3">
|
||||
{showFollowAllButton && (
|
||||
<Button
|
||||
className="gap-2 flex-1"
|
||||
onClick={handleFollowAll}
|
||||
disabled={isFollowingAll || !user}
|
||||
>
|
||||
{isFollowingAll ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Following…
|
||||
</>
|
||||
) : newPubkeys.length === 0 && user ? (
|
||||
<>
|
||||
<Check className="size-4" />
|
||||
Already following all
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserPlus className="size-4" />
|
||||
Follow All ({pubkeys.length})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Save (clone) — available to logged-in viewers who don't own the list, not for kind 3 (that's your follow list, you don't clone it) */}
|
||||
{user && !isOwnList && !isFollowList && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={showFollowAllButton ? undefined : 'flex-1'}
|
||||
onClick={handleClone}
|
||||
disabled={cloning}
|
||||
title="Save a copy to your lists"
|
||||
>
|
||||
{cloning ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interaction bar — reply / repost / react / zap / share / more */}
|
||||
<PostActionBar
|
||||
event={event}
|
||||
replyLabel="Comments"
|
||||
onReply={() => setActiveTab('comments')}
|
||||
onMore={() => setMoreMenuOpen(true)}
|
||||
className="px-4"
|
||||
/>
|
||||
|
||||
{/* Tab bar */}
|
||||
<SubHeaderBar pinned>
|
||||
<TabButton label="Feed" active={activeTab === 'feed'} onClick={() => setActiveTab('feed')} />
|
||||
<TabButton
|
||||
label="Members"
|
||||
active={activeTab === 'members'}
|
||||
onClick={() => setActiveTab('members')}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
Members
|
||||
<span className="text-xs text-muted-foreground">({pubkeys.length})</span>
|
||||
</span>
|
||||
</TabButton>
|
||||
<TabButton
|
||||
label="Comments"
|
||||
active={activeTab === 'comments'}
|
||||
onClick={() => setActiveTab('comments')}
|
||||
/>
|
||||
</SubHeaderBar>
|
||||
|
||||
{/* Spacer below the pinned tabs (matches ProfilePage / BadgeDetailContent). */}
|
||||
<div style={{ height: ARC_OVERHANG_PX }} />
|
||||
|
||||
{/* Owner "Add members" row — above members tab content */}
|
||||
{ownerCanRemove && activeTab === 'members' && (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setAddMembersOpen(true)}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
Add Members
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'feed' ? (
|
||||
<PeopleListFeedTab pubkeys={pubkeys} tabKey={shareNip19} />
|
||||
) : activeTab === 'members' ? (
|
||||
<PeopleListMembersTab
|
||||
pubkeys={pubkeys}
|
||||
membersMap={membersMap}
|
||||
membersLoading={membersLoading}
|
||||
followedPubkeys={followedPubkeys}
|
||||
currentUserPubkey={user?.pubkey}
|
||||
canRemove={ownerCanRemove}
|
||||
listId={dTag}
|
||||
/>
|
||||
) : (
|
||||
<PeopleListCommentsTab
|
||||
event={event}
|
||||
orderedReplies={orderedReplies}
|
||||
commentsLoading={commentsLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{ownerCanRemove && (
|
||||
<AddMembersDialog
|
||||
open={addMembersOpen}
|
||||
onOpenChange={setAddMembersOpen}
|
||||
listId={dTag}
|
||||
listPubkeys={pubkeys}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NoteMoreMenu
|
||||
event={event}
|
||||
open={moreMenuOpen}
|
||||
onOpenChange={setMoreMenuOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Member Card ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface MemberCardProps {
|
||||
pubkey: string;
|
||||
metadata?: NostrMetadata;
|
||||
isFollowed: boolean;
|
||||
isSelf: boolean;
|
||||
/** When true, renders a "remove" button that calls useUserLists().removeFromList. */
|
||||
canRemove?: boolean;
|
||||
/** Kind 30000 d-tag — required when canRemove is true. */
|
||||
listId?: string;
|
||||
}
|
||||
|
||||
export function MemberCard({
|
||||
pubkey,
|
||||
metadata,
|
||||
isFollowed,
|
||||
isSelf,
|
||||
canRemove,
|
||||
listId,
|
||||
}: MemberCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const npub = useMemo(() => nip19.npubEncode(pubkey), [pubkey]);
|
||||
const displayName = metadata?.name || metadata?.display_name || genUserName(pubkey);
|
||||
const about = metadata?.about;
|
||||
const avatarShape = getAvatarShape(metadata);
|
||||
const { follow, unfollow, isPending } = useFollowActions();
|
||||
const { removeFromList } = useUserLists();
|
||||
const { toast } = useToast();
|
||||
const [removing, setRemoving] = useState(false);
|
||||
|
||||
const handleFollowToggle = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (isFollowed) {
|
||||
await unfollow(pubkey);
|
||||
} else {
|
||||
await follow(pubkey);
|
||||
}
|
||||
},
|
||||
[isFollowed, pubkey, follow, unfollow],
|
||||
);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!listId) return;
|
||||
setRemoving(true);
|
||||
try {
|
||||
await removeFromList.mutateAsync({ listId, pubkey });
|
||||
toast({ title: 'Removed from list' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to remove', variant: 'destructive' });
|
||||
} finally {
|
||||
setRemoving(false);
|
||||
}
|
||||
},
|
||||
[listId, pubkey, removeFromList, toast],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors cursor-pointer group"
|
||||
onClick={() => navigate(`/${npub}`)}
|
||||
>
|
||||
<Link to={`/${npub}`} className="shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<Avatar shape={avatarShape} className="size-11">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
to={`/${npub}`}
|
||||
className="font-bold text-[15px] hover:underline block truncate"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{displayName}
|
||||
</Link>
|
||||
{about && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-1">
|
||||
{about}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{!isSelf && (
|
||||
<Button
|
||||
variant={isFollowed ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={handleFollowToggle}
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : isFollowed ? (
|
||||
'Following'
|
||||
) : (
|
||||
'Follow'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canRemove && listId && (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={removing}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-all"
|
||||
aria-label="Remove from list"
|
||||
>
|
||||
{removing
|
||||
? <Loader2 className="size-4 animate-spin" />
|
||||
: <X className="size-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemberCardSkeleton() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<Skeleton className="size-11 rounded-full shrink-0" />
|
||||
<div className="flex-1 min-w-0 space-y-1.5">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={`flex items-center justify-between py-1 border-t border-b border-border${className ? ` ${className}` : ''}`}>
|
||||
|
||||
@@ -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 }) {
|
||||
<div className="relative w-full h-full">
|
||||
{/* Blurhash or skeleton placeholder while media loads */}
|
||||
{!loaded && (
|
||||
item.blurhash ? (
|
||||
isValidBlurhash(item.blurhash) ? (
|
||||
<Blurhash
|
||||
hash={item.blurhash}
|
||||
width={32}
|
||||
|
||||
@@ -8,11 +8,9 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { PortalContainerProvider } from '@/hooks/usePortalContainer';
|
||||
import { EmbeddedNote } from '@/components/EmbeddedNote';
|
||||
import { EmbeddedNaddr } from '@/components/EmbeddedNaddr';
|
||||
import { EmbeddedPost } from '@/components/EmbeddedPost';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { LinkEmbed } from '@/components/LinkEmbed';
|
||||
import { ProfilePreview } from '@/components/ExternalContentHeader';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ReplyComposeModalProps {
|
||||
@@ -129,7 +127,7 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
<LinkEmbed url={event.href} showActions={false} hideImage />
|
||||
</div>
|
||||
) : (
|
||||
<EmbeddedPost event={event} />
|
||||
<EmbeddedPost event={event} className="mx-4 mb-2" disableHoverCards />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -164,36 +162,3 @@ export function ReplyComposeModal({ event, quotedEvent, open, onOpenChange, onSu
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="mx-4 mb-2 rounded-xl border border-border bg-secondary/30 overflow-hidden">
|
||||
<ProfilePreview pubkey={event.pubkey} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Addressable events (kind 30000-39999) — use EmbeddedNaddr
|
||||
if (event.kind >= 30000 && event.kind < 40000) {
|
||||
const dTag = event.tags.find(([name]) => name === 'd')?.[1] ?? '';
|
||||
return (
|
||||
<div className="mx-4 mb-2">
|
||||
<EmbeddedNaddr addr={{ kind: event.kind, pubkey: event.pubkey, identifier: dTag }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Everything else — use EmbeddedNote (the event is already in the query cache)
|
||||
return (
|
||||
<div className="mx-4 mb-2">
|
||||
<EmbeddedNote eventId={event.id} authorHint={event.pubkey} disableHoverCards />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
width="100%"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { getNsecCredential } from '@/lib/credentialManager';
|
||||
import { DialogTitle } from '@radix-ui/react-dialog';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||
import { useShareOrigin } from '@/hooks/useShareOrigin';
|
||||
|
||||
interface LoginDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -38,6 +39,7 @@ const validateBunkerUri = (uri: string) => {
|
||||
|
||||
const LoginDialog: React.FC<LoginDialogProps> = ({ 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<LoginDialogProps> = ({ 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(() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -55,7 +55,8 @@ export function useMastodonPost(url: string) {
|
||||
return useQuery({
|
||||
queryKey: ['mastodon-post', url],
|
||||
queryFn: async ({ signal }): Promise<ExternalPostData | null> => {
|
||||
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
|
||||
|
||||
@@ -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<NostrEvent> {
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -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 : '';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('#', '');
|
||||
|
||||
@@ -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<number, string> = {
|
||||
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<number, string> = {
|
||||
* Specific icons for kinds that need a different icon than their parent category.
|
||||
*/
|
||||
const KIND_SPECIFIC_ICONS: Partial<Record<number, ComponentType<{ className?: string }>>> = {
|
||||
3: UserCheck,
|
||||
6: RepostIcon,
|
||||
16: RepostIcon,
|
||||
30000: Users,
|
||||
1617: GitPullRequestArrow,
|
||||
1618: MessageSquareMore,
|
||||
15128: Globe,
|
||||
|
||||
@@ -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<string>();
|
||||
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<NostrEvent[]>;
|
||||
group(urls: string[]): { event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
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<string>();
|
||||
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');
|
||||
}
|
||||
}
|
||||
+77
-7
@@ -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<number>([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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <NotFound />;
|
||||
}
|
||||
|
||||
@@ -271,20 +279,20 @@ export function ExternalContentPage() {
|
||||
<ExternalActionBar content={content} />
|
||||
|
||||
{/* Comment compose dialog (opened via FAB) */}
|
||||
<ReplyComposeModal event={commentRoot} open={composeOpen} onOpenChange={setComposeOpen} />
|
||||
{commentRootUrl && <ReplyComposeModal event={commentRootUrl} open={composeOpen} onOpenChange={setComposeOpen} />}
|
||||
|
||||
{/* ISBN pages get a tabbed interface with Comments + Reviews */}
|
||||
{content.type === 'isbn' ? (
|
||||
<BookContentTabs
|
||||
isbn={content.value.replace('isbn:', '')}
|
||||
commentRoot={commentRoot}
|
||||
commentRoot={commentRootUrl}
|
||||
orderedReplies={orderedReplies}
|
||||
commentsLoading={commentsLoading}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Inline compose box */}
|
||||
<ComposeBox compact replyTo={commentRoot} />
|
||||
{commentRootUrl && <ComposeBox compact replyTo={commentRootUrl} />}
|
||||
|
||||
{/* Threaded comments list */}
|
||||
<div>
|
||||
@@ -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 }:
|
||||
|
||||
<TabsContent value="comments" className="mt-0">
|
||||
{/* Inline compose box */}
|
||||
<ComposeBox compact replyTo={commentRoot} />
|
||||
{commentRoot && <ComposeBox compact replyTo={commentRoot} />}
|
||||
|
||||
{/* Threaded comments list */}
|
||||
<div>
|
||||
|
||||
@@ -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[]
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto w-full bg-background/85" style={{ paddingTop: ARC_OVERHANG_PX }}>
|
||||
{activeTab === 'feed' ? (
|
||||
<PackFeedTab pubkeys={pubkeys} />
|
||||
<PeopleListFeedTab
|
||||
pubkeys={pubkeys}
|
||||
tabKey={`follow-${addr.kind}-${addr.pubkey}-${addr.identifier}`}
|
||||
/>
|
||||
) : membersLoading ? (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: Math.min(pubkeys.length, 8) }).map((_, i) => (
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-3 px-4 py-3 hover:bg-secondary/30 transition-colors group">
|
||||
<Link to={profileUrl} className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{author.isLoading ? (
|
||||
<>
|
||||
<Skeleton className="size-10 rounded-full shrink-0" />
|
||||
<div className="space-y-1 flex-1">
|
||||
<Skeleton className="h-4 w-28" />
|
||||
<Skeleton className="h-3 w-36" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar shape={avatarShape} className="size-10 shrink-0">
|
||||
<AvatarImage src={metadata?.picture} alt={displayName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-sm">
|
||||
{displayName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold truncate">{displayName}</div>
|
||||
{metadata?.nip05 && (
|
||||
<div className="text-xs text-muted-foreground truncate">{metadata.nip05}</div>
|
||||
)}
|
||||
{metadata?.about && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{metadata.about}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* Follow/Unfollow button */}
|
||||
{!isSelf && user && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isFollowed ? 'outline' : 'default'}
|
||||
className="h-7 px-2.5 text-xs"
|
||||
disabled={followPending}
|
||||
onClick={() => isFollowed ? unfollow(pubkey) : follow(pubkey)}
|
||||
>
|
||||
{followPending ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : isFollowed ? 'Following' : 'Follow'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Remove button (owner only) */}
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
disabled={removing}
|
||||
className="opacity-0 group-hover:opacity-100 p-1.5 rounded-full text-muted-foreground hover:text-destructive hover:bg-destructive/10 disabled:opacity-40 transition-all"
|
||||
aria-label="Remove from list"
|
||||
>
|
||||
{removing
|
||||
? <Loader2 className="size-4 animate-spin" />
|
||||
: <X className="size-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<Users className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No members in this list yet.</p>
|
||||
<p className="text-xs mt-1">Add members to see their posts here.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading && filteredPosts.length === 0) {
|
||||
return (
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-11 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredPosts.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center text-muted-foreground text-sm">
|
||||
No posts from list members yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{filteredPosts.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Members Tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ListMembersTab({ list, isOwner }: { list: UserList; isOwner: boolean }) {
|
||||
const [addMembersOpen, setAddMembersOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isOwner && (
|
||||
<div className="px-4 py-3 border-b border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setAddMembersOpen(true)}
|
||||
>
|
||||
<UserPlus className="size-4" />
|
||||
Add Members
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{list.pubkeys.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<Users className="size-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No members yet.</p>
|
||||
{isOwner && (
|
||||
<p className="text-xs mt-1">Click "Add Members" to search for people.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
list.pubkeys.map((pk) => (
|
||||
<MemberCard
|
||||
key={pk}
|
||||
pubkey={pk}
|
||||
isOwner={isOwner}
|
||||
listId={list.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
{isOwner && (
|
||||
<AddMembersDialog
|
||||
open={addMembersOpen}
|
||||
onOpenChange={setAddMembersOpen}
|
||||
listId={list.id}
|
||||
listPubkeys={list.pubkeys}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<Tab>('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 (
|
||||
<main>
|
||||
<PageHeader onBack={() => navigate(-1)} titleContent={<Skeleton className="h-6 w-32" />} />
|
||||
<div className="divide-y divide-border">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="size-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Not found
|
||||
if (!list) {
|
||||
return <NotFound />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
{/* Header */}
|
||||
<PageHeader
|
||||
onBack={() => window.history.length > 1 ? navigate(-1) : navigate('/lists')}
|
||||
titleContent={
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-lg font-bold truncate">{list.title}</h1>
|
||||
{decoded && (
|
||||
<Link to={listAuthorProfileUrl} className="flex items-center gap-1.5 mt-0.5 group">
|
||||
<Avatar shape={listAuthorAvatarShape} className="size-4">
|
||||
<AvatarImage src={listAuthorMetadata?.picture} alt={listAuthorName} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[8px]">
|
||||
{listAuthorName[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-xs text-muted-foreground group-hover:underline truncate">
|
||||
{listAuthorName}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{user && !isOwnList && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full gap-1.5 h-8"
|
||||
onClick={handleClone}
|
||||
disabled={cloning}
|
||||
>
|
||||
{cloning
|
||||
? <Loader2 className="size-3.5 animate-spin" />
|
||||
: <Copy className="size-3.5" />
|
||||
}
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full size-9"
|
||||
title="Share"
|
||||
>
|
||||
<Share2 className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={handleShare} className="gap-3">
|
||||
{copied ? <Check className="size-4 text-green-500" /> : <Share2 className="size-4" />}
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleRepost} className="gap-3">
|
||||
<RepostIcon className="size-4" />
|
||||
Repost
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setQuoteOpen(true)} className="gap-3">
|
||||
<Quote className="size-4" />
|
||||
Quote post
|
||||
</DropdownMenuItem>
|
||||
{nostrUri && (
|
||||
isInSidebar ? (
|
||||
<DropdownMenuItem onClick={handleRemoveFromSidebar} className="gap-3">
|
||||
<Trash2 className="size-4" />
|
||||
Remove from sidebar
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem onClick={handleAddToSidebar} className="gap-3">
|
||||
<PanelLeft className="size-4" />
|
||||
Add to sidebar
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* Description and image */}
|
||||
{(list.description || list.image) && (
|
||||
<div className="px-4 pb-3">
|
||||
{list.image && (
|
||||
<div className="rounded-xl overflow-hidden mb-2">
|
||||
<img
|
||||
src={list.image}
|
||||
alt={list.title}
|
||||
className="w-full max-h-[160px] object-cover"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{list.description && (
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">{list.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Member avatar stack */}
|
||||
{list.pubkeys.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-4 pb-3">
|
||||
<div className="flex -space-x-2">
|
||||
{previewPubkeys.map((pk) => {
|
||||
const member = previewMembersMap?.get(pk);
|
||||
const name = member?.metadata?.name || genUserName(pk);
|
||||
const shape = getAvatarShape(member?.metadata);
|
||||
return (
|
||||
<Avatar key={pk} shape={shape} className="size-7 ring-2 ring-background">
|
||||
<AvatarImage src={member?.metadata?.picture} alt={name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
|
||||
{name[0]?.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{list.pubkeys.length > previewPubkeys.length && (
|
||||
<button
|
||||
onClick={() => setActiveTab('members')}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
+{list.pubkeys.length - previewPubkeys.length} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab bar */}
|
||||
<SubHeaderBar>
|
||||
<TabButton
|
||||
label="Feed"
|
||||
active={activeTab === 'feed'}
|
||||
onClick={() => setActiveTab('feed')}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Rss className="size-4" />
|
||||
Feed
|
||||
</span>
|
||||
</TabButton>
|
||||
<TabButton
|
||||
label="Members"
|
||||
active={activeTab === 'members'}
|
||||
onClick={() => setActiveTab('members')}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Users className="size-4" />
|
||||
Members
|
||||
<span className="text-xs text-muted-foreground">({list.pubkeys.length})</span>
|
||||
</span>
|
||||
</TabButton>
|
||||
</SubHeaderBar>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'feed' ? (
|
||||
<ListFeedTab list={list} />
|
||||
) : (
|
||||
<ListMembersTab list={list} isOwner={isOwnList} />
|
||||
)}
|
||||
|
||||
{list.event && (
|
||||
<ReplyComposeModal
|
||||
quotedEvent={list.event}
|
||||
open={quoteOpen}
|
||||
onOpenChange={setQuoteOpen}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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 <ListDetailPage />;
|
||||
}
|
||||
return <AddrPostDetailPage addr={{ kind: addr.kind, pubkey: addr.pubkey, identifier: addr.identifier }} relays={addr.relays} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<PostDetailShell title={detailTitle}>
|
||||
<MutedContentGuard event={resolvedEvent}>
|
||||
<PeopleListDetailContent event={resolvedEvent} />
|
||||
</MutedContentGuard>
|
||||
</PostDetailShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PostDetailShell title={detailTitle}>
|
||||
<MutedContentGuard event={resolvedEvent}>
|
||||
@@ -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 (
|
||||
<PostDetailShell>
|
||||
<MutedContentGuard event={resolvedEvent}>
|
||||
<FollowPackDetailContent event={resolvedEvent} />
|
||||
<PeopleListDetailContent event={resolvedEvent} />
|
||||
</MutedContentGuard>
|
||||
</PostDetailShell>
|
||||
);
|
||||
@@ -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 && <VineDetailContent event={event} />}
|
||||
@@ -2167,7 +2182,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
|
||||
{isGeocache && <GeocacheContent event={event} />}
|
||||
{isFoundLog && <FoundLogContent event={event} />}
|
||||
{isColor && <ColorMomentContent event={event} />}
|
||||
{isFollowPack && <FollowPackContent event={event} />}
|
||||
{isPeopleList && <PeopleListContent event={event} />}
|
||||
{isEmojiPack && <EmojiPackContent event={event} />}
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md p-0 gap-0 rounded-2xl overflow-hidden [&>button]:hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<DialogTitle className="text-base font-bold">{displayName} follows</DialogTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full size-8"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="max-h-[60vh]">
|
||||
{pubkeys.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground text-sm">
|
||||
Not following anyone yet.
|
||||
</div>
|
||||
) : (
|
||||
pubkeys.map((pk) => <FollowingUserRow key={pk} pubkey={pk} onNavigate={handleNavigate} />)
|
||||
)}
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
|
||||
function SortableTabChip({
|
||||
@@ -957,7 +919,6 @@ export function ProfilePage() {
|
||||
const [sidebarMediaUrl, setSidebarMediaUrl] = useState<string | null>(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<string | null>(null);
|
||||
|
||||
@@ -2201,15 +2162,15 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
<span className="text-sm text-muted-foreground">followers</span>
|
||||
</button>
|
||||
)}
|
||||
{profileFollowing && profileFollowing.count > 0 && (
|
||||
<button
|
||||
{profileFollowing && profileFollowing.count > 0 && pubkey && (
|
||||
<Link
|
||||
to={`/${nip19.naddrEncode({ kind: 3, pubkey, identifier: '' })}`}
|
||||
className="flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||
onClick={() => setFollowingModalOpen(true)}
|
||||
title={`${profileFollowing.count} following`}
|
||||
>
|
||||
<span className="text-sm font-bold tabular-nums text-primary">{formatNumber(profileFollowing.count)}</span>
|
||||
<span className="text-sm text-muted-foreground">following</span>
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
{streak > 1 && (
|
||||
<div
|
||||
@@ -2640,16 +2601,6 @@ type EditableTab = { label: string; isCore: boolean; tab?: ProfileTab };
|
||||
<FollowQRDialog open={followQROpen} onOpenChange={setFollowQROpen} />
|
||||
)}
|
||||
|
||||
{/* Following List Modal */}
|
||||
{profileFollowing && profileFollowing.count > 0 && (
|
||||
<FollowingListModal
|
||||
pubkeys={profileFollowing.pubkeys}
|
||||
open={followingModalOpen}
|
||||
onOpenChange={setFollowingModalOpen}
|
||||
displayName={displayName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Followers List Modal */}
|
||||
{pubkey && followersCount > 0 && (
|
||||
<FollowersListModal
|
||||
|
||||
@@ -53,7 +53,9 @@ export function RelayPage() {
|
||||
const relayUrl = useMemo(() => {
|
||||
if (!rawParam) return undefined;
|
||||
// If the wildcard param has no "://", it's encoded — decode it.
|
||||
const url = rawParam.includes('://') ? rawParam : decodeURIComponent(rawParam);
|
||||
let decoded: string;
|
||||
try { decoded = decodeURIComponent(rawParam); } catch { decoded = rawParam; }
|
||||
const url = rawParam.includes('://') ? rawParam : decoded;
|
||||
if (url.startsWith('wss://') || url.startsWith('ws://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
<div className="relative aspect-video overflow-hidden rounded-xl bg-muted">
|
||||
{blurhash && (
|
||||
{isValidBlurhash(blurhash) && (
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
width="100%"
|
||||
@@ -644,7 +645,7 @@ function ShortThumb({
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="relative w-full aspect-[9/16] overflow-hidden rounded-xl bg-muted">
|
||||
{blurhash && !thumbnail && (
|
||||
{isValidBlurhash(blurhash) && !thumbnail && (
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
width="100%"
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
Vendored
+8
@@ -7,6 +7,14 @@ declare module '@fontsource/comic-relief/*';
|
||||
interface ImportMetaEnv {
|
||||
/** Hex pubkey of the nostr-push server for Web Push notifications. */
|
||||
readonly VITE_NOSTR_PUSH_PUBKEY?: string;
|
||||
/**
|
||||
* Canonical origin used when generating shareable URLs (QR codes, copy-link,
|
||||
* remote-login callbacks, etc). Overridden by `shareOrigin` in `ditto.json`
|
||||
* and by user config in localStorage. Falls back to `window.location.origin`
|
||||
* when unset. Primarily useful for native (Capacitor) builds, where
|
||||
* `window.location.origin` is `capacitor://localhost` or `https://localhost`.
|
||||
*/
|
||||
readonly VITE_SHARE_ORIGIN?: string;
|
||||
/** Semver version from package.json (e.g., "2.0.0"). */
|
||||
readonly VERSION: string;
|
||||
/** ISO 8601 timestamp of when the app was built (e.g., "2026-03-26T19:42:00.000Z"). */
|
||||
|
||||
Reference in New Issue
Block a user