diff --git a/.eslintrc.json b/.eslintrc.json index 5178e79d..c8c6a65a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -7,29 +7,40 @@ "parserOptions": { "project": "./tsconfig.json" }, - "plugins": ["@typescript-eslint"], + "plugins": [ + "@typescript-eslint" + ], "rules": { "no-console": "error", - "@typescript-eslint/no-explicit-any": "warn", - "@typescript-eslint/no-unused-vars": ["warn", { - "argsIgnorePattern": "^_", - "varsIgnorePattern": "^_" - }], + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], "@typescript-eslint/explicit-function-return-type": "off", "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-non-null-assertion": "warn", + "@typescript-eslint/no-non-null-assertion": "error", "@typescript-eslint/prefer-nullish-coalescing": "off", - "@typescript-eslint/prefer-optional-chain": "warn", - "@typescript-eslint/no-floating-promises": "warn", - "@typescript-eslint/await-thenable": "warn", - "@typescript-eslint/no-misused-promises": ["warn", { - "checksVoidReturn": false - }], - "react-hooks/exhaustive-deps": "warn" + "@typescript-eslint/prefer-optional-chain": "error", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/no-misused-promises": [ + "error", + { + "checksVoidReturn": false + } + ], + "react-hooks/exhaustive-deps": "error", + "@next/next/no-img-element": "off" }, "overrides": [ { - "files": ["lib/logger.ts"], + "files": [ + "lib/logger.ts" + ], "rules": { "no-console": "off" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c35f026f..5f3197c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,47 @@ jobs: - name: Run TypeScript compiler run: npx tsc --noEmit + unit: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run Vitest + run: npm run test + + dead-code: + name: Dead Code (knip) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + # Unused files and dependencies fail the job. Unused exports are reported + # but not yet enforced: the backlog is being worked down and a clean + # `knip` run is the target state before this becomes strict. + - name: Run knip (files + dependencies) + run: npx knip --include files,dependencies,unlisted + build: name: Build runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index ad8ab25e..dd483e91 100644 --- a/.gitignore +++ b/.gitignore @@ -84,7 +84,7 @@ lib/wasm-sdk/ # Keep only the necessary WASM files in dash-wasm # (the actual SDK files we need are in lib/dash-wasm/) -.gitignore worktrees +worktrees/ # devnet seeding ledgers (private keys + checkpoints) — never commit .seed-treasury.local.key diff --git a/CLAUDE.md b/CLAUDE.md index 334fb00b..8b631350 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,9 +5,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Development Commands ```bash -npm run dev # Start development server -npm run build # Build for production -npm run lint # Run linting +npm run dev # Start development server +npm run build # Build for production +npm run lint # ESLint over app/ components/ contexts/ hooks/ lib/ types/; warnings fail +npm run test # Vitest unit tests (lib/**/*.test.ts) +npm run lint:dead # knip: unused files, exports, and dependencies ``` ## Workflow @@ -25,7 +27,14 @@ npm run lint - Fix all errors and warnings properly (see Code Quality Guidelines below) - Do not commit code with linter failures -### 2. Run the Build +### 2. Run the Unit Tests +```bash +npm run test +``` +- Pure modules under `lib/` (crypto primitives, codecs, parsers) have Vitest specs next to them as `*.test.ts` +- Add or extend a spec when touching one of these modules; anything that needs the SDK or a browser belongs in `e2e/` + +### 3. Run the Build ```bash npm run build ``` @@ -34,7 +43,7 @@ npm run build - Catches missing imports, type errors, and build-time issues - Do not commit code that fails to build -### 3. Run the End-to-End Tests +### 4. Run the End-to-End Tests ```bash npm run build:testing # build the /testing bundle the tests run against npm run test:e2e @@ -44,7 +53,7 @@ npm run test:e2e - The full suite needs `E2E_SEED_PHRASE` (in gitignored `.env.local`) and performs real state transitions against the dedicated test contracts in `.env.testing` — never production - See `docs/TESTING.md` for the identity pool, provisioning, and known quirks -### 4. Code Review for Complex Changes +### 5. Code Review for Complex Changes For complex or multi-file changes, use a code review sub-agent to identify potential issues: ``` @@ -58,7 +67,7 @@ Use the Task tool with subagent_type=Plan to review the changes for: **Trust but verify**: The review agent may flag potential issues that aren't actually problems, or miss real issues. Treat its output as suggestions to investigate, not definitive judgments. Verify each finding before acting on it. -### 5. Manual Verification (when applicable) +### 6. Manual Verification (when applicable) - For UI changes: Run `npm run dev` and visually verify the changes - For new features: Test the happy path and common error cases - For bug fixes: Confirm the original issue is resolved @@ -67,7 +76,9 @@ Use the Task tool with subagent_type=Plan to review the changes for: | Check | Command | Required | |-------|---------|----------| | Linter | `npm run lint` | Always | +| Unit tests | `npm run test` | Always | | Build | `npm run build` | Always | +| Dead code | `npm run lint:dead` | When adding or removing modules/exports | | End-to-End | `npm run build:testing && npm run test:e2e` | Changes touching feeds, posts, or auth flows | | Code Review | Task sub-agent | Complex changes | | Dev Server | `npm run dev` | UI changes | @@ -167,7 +178,7 @@ Additional contracts back specific features: storefront (7 types), DM, blog, vau 1. **State Management**: Zustand store in `lib/store.ts` 2. **Styling**: Tailwind CSS with custom design system in `tailwind.config.js` 3. **UI Components**: Radix UI primitives in `components/ui/` -4. **Mock Data**: `lib/mock-data.ts` for development when not connected to Dash Platform +4. **Default avatars**: `lib/mock-data.ts` generates the DiceBear placeholder used when a profile has no avatar ### Known Issues diff --git a/README.md b/README.md index 7f033b2e..5d3bc7b5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A decentralized social media platform and marketplace built on Dash Platform. All data—posts, profiles, likes, follows, bookmarks, mentions, tips, direct messages, stores, and orders—is stored on-chain with full user ownership. -Yappr Screenshot +Yappr Screenshot ## Features @@ -93,8 +93,8 @@ npm run dev # Build for production npm run build -# Build for GitHub Pages -npm run build:gh-pages +# Build for a sub-path deployment (e.g. GitHub Pages) +npm run build:subpath # Run linting npm run lint @@ -127,7 +127,7 @@ yappr/ │ ├── orders/seller/ # Seller order management │ ├── post/ # Post detail view and threads │ ├── privacy/ # Privacy policy -│ ├── profile/ # User profile (current user + edit) +│ ├── profile/create/ # Profile creation │ ├── search/ # Search users and hashtags │ ├── settings/ # User settings │ ├── store/ # Store listing and storefront views @@ -171,13 +171,11 @@ yappr/ │ ├── use-link-preview.ts # Link preview fetching │ ├── use-login-prompt-modal.ts # Login prompt modal │ ├── use-mention-validation.ts # Mention validation -│ ├── use-platform-detection.ts # Platform/device detection │ ├── use-post-detail.ts # Post detail with thread loading │ ├── use-post-enrichment.ts # Post stats with deduplication │ ├── use-private-feed-request.ts # Private feed access requests │ ├── use-progressive-enrichment.ts # Progressive data loading │ ├── use-require-auth.ts # Auth requirement wrapper -│ ├── use-require-encryption-key.ts # Require encryption key │ └── use-tip-modal.ts # Tip/payment modal │ ├── lib/ @@ -206,7 +204,6 @@ yappr/ │ │ ├── private-feed-follower-service.ts # Private feed grants │ │ ├── private-feed-crypto-service.ts # Private feed encryption │ │ ├── private-feed-key-store.ts # Private feed key storage -│ │ ├── profile-migration-service.ts # Profile migration │ │ ├── profile-service.ts # Profile management │ │ ├── reply-service.ts # Reply operations │ │ ├── repost-service.ts # Reposts @@ -234,20 +231,18 @@ yappr/ │ ├── bloom-filter.ts # Bloom filter for efficient lookups │ ├── cache-manager.ts # Query caching │ ├── constants.ts # Contract IDs, network config -│ ├── dash-platform-client.ts # Platform client wrapper │ ├── error-utils.ts # Error handling utilities │ ├── message-encryption.ts # DM encryption -│ ├── mock-data.ts # Development mock data +│ ├── mock-data.ts # Default placeholder avatar │ ├── onchain-key-encryption.ts # Key backup encryption │ ├── post-helpers.ts # Post utility functions │ ├── retry-utils.ts # Retry logic with backoff │ ├── secure-storage.ts # Session storage for keys │ ├── store.ts # Main Zustand store -│ ├── types.ts # TypeScript interfaces -│ └── utils.ts # Helper functions +│ ├── types.ts # TypeScript interfaces (re-exports types/) +│ └── utils/ # Helper functions │ -├── types/ -│ └── sdk.ts # Dash SDK type definitions +├── types/ # Domain types: user, post, store, notification │ ├── contracts/ # Dash Platform data contracts │ ├── yappr-social-contract-v2.json # Main social contract (staging/prod) diff --git a/app/contract/page.tsx b/app/contract/page.tsx index 2032fbe1..028b2fbb 100644 --- a/app/contract/page.tsx +++ b/app/contract/page.tsx @@ -43,9 +43,8 @@ export default function ContractPage() { } const documentCount = Object.keys(dataContract.documents).length - const totalIndices = Object.values(dataContract.documents).reduce((acc, doc: any) => - acc + (doc.indices?.length || 0), 0 - ) + const totalIndices = Object.values(dataContract.documents as Record) + .reduce((acc, doc) => acc + (doc.indices?.length || 0), 0) return (
diff --git a/app/explore/page.tsx b/app/explore/page.tsx index b8eaac32..2d46a406 100644 --- a/app/explore/page.tsx +++ b/app/explore/page.tsx @@ -24,13 +24,6 @@ import { TopCreators } from '@/components/explore/top-creators' import type { Post, Blog, BlogPostWithAuthor } from '@/lib/types' import { enrichBlogPostsWithAuthors, getBlogPostUrl } from '@/lib/blog/content-utils' -interface RawPostDocument { - $id: string - $ownerId: string - $createdAt: number - content?: string -} - type ExploreTab = 'hashtags' | 'top' | 'creators' | 'blogs' export default function ExplorePage() { @@ -156,45 +149,27 @@ export default function ExplorePage() { try { setIsSearching(true) - // Search regular posts - const { getDashPlatformClient } = await import('@/lib/dash-platform-client') - const dashClient = getDashPlatformClient() - - const allPosts = await dashClient.queryPosts({ limit: 100 }) + // Search regular posts: a client-side substring match over the most + // recent timeline page. Authors are left as placeholders for PostCard + // to resolve progressively. + const { postService } = await import('@/lib/services/post-service') + const { documents: recentPosts } = await postService.getTimeline({ limit: 100 }) - const typedPosts = allPosts as RawPostDocument[] - const authorIds = Array.from(new Set(typedPosts.map(p => p.$ownerId).filter(Boolean))) + const authorIds = Array.from(new Set(recentPosts.map(p => p.author.id).filter(Boolean))) const blockedMap = user?.identityId ? await checkBlockedForAuthors(user.identityId, authorIds) : new Map() - const filtered = typedPosts + const needle = searchQuery.toLowerCase() + const filtered = recentPosts .filter(post => - post.$ownerId && - post.content?.toLowerCase().includes(searchQuery.toLowerCase()) && - !blockedMap.get(post.$ownerId) + !post.deleted && + post.content.toLowerCase().includes(needle) && + !blockedMap.get(post.author.id) ) .map(post => ({ - id: post.$id, - content: post.content || '', - author: { - id: post.$ownerId, - username: '', - handle: '', - displayName: '', - avatar: '', - followers: 0, - following: 0, - verified: false, - joinedAt: new Date(), - hasDpns: undefined - }, - createdAt: new Date(post.$createdAt || 0), - likes: 0, - replies: 0, - reposts: 0, - quotes: 0, - views: 0 + ...post, + author: { ...post.author, username: '', displayName: '', avatar: '', hasDpns: undefined }, })) setSearchResults(filtered) diff --git a/app/following/page.tsx b/app/following/page.tsx index 90fd0ec7..2881b8f1 100644 --- a/app/following/page.tsx +++ b/app/following/page.tsx @@ -12,6 +12,7 @@ import { useRequireAuth } from '@/hooks/use-require-auth' import { LoadingState, useAsyncState } from '@/components/ui/loading-state' import ErrorBoundary from '@/components/error-boundary' import { followService, dpnsService, unifiedProfileService } from '@/lib/services' +import type { UnifiedProfileDocument } from '@/lib/services/unified-profile-service' import { sortUsernames } from '@/lib/utils/username' import { cacheManager } from '@/lib/cache-manager' import { UserAvatar } from '@/components/ui/avatar-image' @@ -173,12 +174,12 @@ function FollowingPage() { // Create maps for easy lookup (primary username = first of the sorted list) const dpnsMap = new Map(allUsernamesData.map(item => [item.id, item.usernames[0] || null])) const allUsernamesMap = new Map(allUsernamesData.map(item => [item.id, item.usernames])) - const profileMap = new Map(profiles.map(p => [p.$ownerId || (p as any).ownerId, p])) + const profileMap = new Map(profiles.map(p => [p.$ownerId, p])) const followerCountMap = new Map(followerCounts.map(item => [item.id, item.count])) const followingCountMap = new Map(followingCounts.map(item => [item.id, item.count])) // Create enriched user data - const followingUsers = follows.map((follow: any) => { + const followingUsers = follows.map((follow) => { const followingId = follow.followingId if (!followingId) { logger.warn('Follow document missing followingId:', follow) @@ -188,14 +189,12 @@ function FollowingPage() { const username = dpnsMap.get(followingId) const allUsernames = allUsernamesMap.get(followingId) || [] const profile = profileMap.get(followingId) - // Handle both formats: direct properties or nested in data - const profileData = (profile as any)?.data || profile return { id: followingId, username: username || followingId.slice(-8), - displayName: profileData?.displayName || username || `User ${followingId.slice(-8)}`, - bio: profileData?.bio || (profile ? 'Yappr user' : 'Not yet on Yappr'), + displayName: profile?.displayName || username || `User ${followingId.slice(-8)}`, + bio: profile?.bio || (profile ? 'Yappr user' : 'Not yet on Yappr'), hasProfile: !!profile, hasDpnsName: !!username, followersCount: followerCountMap.get(followingId) || 0, @@ -335,7 +334,7 @@ function FollowingPage() { const uniqueIdentityIds = Array.from(new Set(searchResults.map(r => r.ownerId).filter(id => id))) // Query Yappr profiles and follower/following counts for all these identities - let profiles: any[] = [] + let profiles: UnifiedProfileDocument[] = [] let followerCounts: { id: string; count: number }[] = [] let followingCounts: { id: string; count: number }[] = [] if (uniqueIdentityIds.length > 0) { @@ -371,7 +370,7 @@ function FollowingPage() { } // Create maps for easy lookup - const profileMap = new Map(profiles.map(p => [p.$ownerId || (p as any).ownerId, p])) + const profileMap = new Map(profiles.map(p => [p.$ownerId, p])) const followerCountMap = new Map(followerCounts.map(item => [item.id, item.count])) const followingCountMap = new Map(followingCounts.map(item => [item.id, item.count])) @@ -386,8 +385,6 @@ function FollowingPage() { // Create user objects - one per unique owner const searchUsers: FollowingUser[] = Array.from(ownerToNames.entries()).map(([ownerId, names]) => { const profile = profileMap.get(ownerId) - // Handle both formats: direct properties or nested in data - const profileData = (profile as any)?.data || profile // Canonical ordering: the first sorted name is the primary username const sortedNames = sortUsernames(names) const primaryUsername = sortedNames[0] @@ -395,8 +392,8 @@ function FollowingPage() { return { id: ownerId, username: primaryUsername, - displayName: profileData?.displayName || primaryUsername, - bio: profileData?.bio || (profile ? 'Yappr user' : 'Not yet on Yappr'), + displayName: profile?.displayName || primaryUsername, + bio: profile?.bio || (profile ? 'Yappr user' : 'Not yet on Yappr'), hasProfile: !!profile, hasDpnsName: true, // Search results are always from DPNS followersCount: followerCountMap.get(ownerId) || 0, diff --git a/app/item/page.tsx b/app/item/page.tsx index 8078c5b0..b4b68971 100644 --- a/app/item/page.tsx +++ b/app/item/page.tsx @@ -126,12 +126,13 @@ function ItemDetailContent() { const axes = useMemo(() => { if (!item?.variants?.axes) return [] - return item.variants.axes.map((axis, index) => { + const allAxes = item.variants.axes + return allAxes.map((axis, index) => { // For first axis, all options are available // For subsequent axes, filter based on prior selections const priorSelections: Record = {} for (let i = 0; i < index; i++) { - const priorAxis = item.variants!.axes[i] + const priorAxis = allAxes[i] if (variantSelections[priorAxis.name]) { priorSelections[priorAxis.name] = variantSelections[priorAxis.name] } diff --git a/app/post/engagements/page.tsx b/app/post/engagements/page.tsx index 5f58de47..a2721dc2 100644 --- a/app/post/engagements/page.tsx +++ b/app/post/engagements/page.tsx @@ -52,19 +52,18 @@ async function resolveEngagementUsers( : Promise.resolve(new Map()) ]) - const profileMap = new Map(profiles.map((p: any) => [p.$ownerId || p.ownerId, p])) + const profileMap = new Map(profiles.map((p) => [p.$ownerId, p])) return ownerIds.map((id) => { const username = dpnsNamesMap.get(id) || null const profile = profileMap.get(id) - const profileData = (profile as any)?.data || profile - const profileDisplayName = profileData?.displayName + const profileDisplayName = profile?.displayName return { id, username: username || id.slice(-8), displayName: profileDisplayName || username || `User ${id.slice(-8)}`, - bio: profileData?.bio, + bio: profile?.bio, hasDpnsName: !!username, hasProfile: !!profileDisplayName, isFollowing: followStatus.get(id) || false diff --git a/app/profile/create/page.tsx b/app/profile/create/page.tsx index db39217f..65147339 100644 --- a/app/profile/create/page.tsx +++ b/app/profile/create/page.tsx @@ -390,7 +390,6 @@ function CreateProfilePage() { {/* Banner Preview */} {bannerUrl && (
- {/* eslint-disable-next-line @next/next/no-img-element */} Banner preview r.ownerId).filter(Boolean))) // Fetch profiles for display names - let profiles: any[] = [] + let profiles: UnifiedProfileDocument[] = [] if (ownerIds.length > 0) { try { profiles = await unifiedProfileService.getProfilesByIdentityIds(ownerIds) @@ -145,7 +146,7 @@ function SearchPageContent() { } // Create profile map - const profileMap = new Map(profiles.map(p => [p.$ownerId || (p as any).ownerId, p])) + const profileMap = new Map(profiles.map(p => [p.$ownerId, p])) // Group by owner to handle multiple usernames per owner const ownerToNames = new Map() @@ -160,14 +161,13 @@ function SearchPageContent() { // Build results, picking the best matched name via the canonical ordering const results: UserResult[] = Array.from(ownerToNames.entries()).map(([ownerId, names]) => { const profile = profileMap.get(ownerId) - const profileData = (profile as any)?.data || profile const primaryUsername = getPrimaryUsername(names) ?? names[0] return { id: ownerId, username: primaryUsername, - displayName: profileData?.displayName || primaryUsername, - bio: profileData?.bio + displayName: profile?.displayName || primaryUsername, + bio: profile?.bio } }) diff --git a/app/store/manage/page.tsx b/app/store/manage/page.tsx index 6f102714..b8624df4 100644 --- a/app/store/manage/page.tsx +++ b/app/store/manage/page.tsx @@ -645,8 +645,9 @@ function StoreManagePage() { value={store.status} onChange={async (e) => { const newStatus = e.target.value as 'active' | 'paused' | 'closed' + if (!user?.identityId) return try { - const updated = await storeService.patchStore(store.id, user!.identityId, { + const updated = await storeService.patchStore(store.id, user.identityId, { status: newStatus }) setStore(updated) diff --git a/app/user/page.tsx b/app/user/page.tsx index 6eaf7fd7..1bdfcd1c 100644 --- a/app/user/page.tsx +++ b/app/user/page.tsx @@ -47,6 +47,7 @@ import type { Post, ParsedPaymentUri, SocialLink, Store } from '@/lib/types' import { attachQuotedPosts } from '@/lib/feed/resolve-quoted-posts' import { fetchReplyParents } from '@/lib/feed/resolve-reply-parents' import { replyToPost } from '@/lib/services/post-service' +import type { RepostDocument } from '@/lib/services/repost-service' import { PaymentSchemeIcon, getPaymentLabel, truncateAddress } from '@/components/ui/payment-icons' import { PaymentQRCodeDialog } from '@/components/ui/payment-qr-dialog' import { useBlock } from '@/hooks/use-block' @@ -499,7 +500,7 @@ function UserProfileContent() { ...post.author, username: sortedUsernames[0], hasDpns: true - } as any + } }))) } else { // Reset DPNS state when no usernames found @@ -605,7 +606,7 @@ function UserProfileContent() { const newPosts: Post[] = [] let newPostDocs: Post[] = [] - let newRepostDocs: any[] = [] + let newRepostDocs: RepostDocument[] = [] // Fetch more posts using cursor-based pagination if (canLoadMorePosts) { @@ -709,7 +710,7 @@ function UserProfileContent() { // Update pagination state for reposts (only if reposts were fetched) if (canLoadMoreReposts) { if (newRepostDocs.length > 0) { - const lastRepost = newRepostDocs[newRepostDocs.length - 1] as any + const lastRepost = newRepostDocs[newRepostDocs.length - 1] setLastRepostId(lastRepost.$id) } setHasMoreReposts(newRepostDocs.length >= 50) diff --git a/assets/yappr.png b/assets/yappr.png deleted file mode 100644 index 0d1df718..00000000 Binary files a/assets/yappr.png and /dev/null differ diff --git a/components/blog/blocknote-schema.tsx b/components/blog/blocknote-schema.tsx index eae6b346..edcbcbd6 100644 --- a/components/blog/blocknote-schema.tsx +++ b/components/blog/blocknote-schema.tsx @@ -377,7 +377,6 @@ function VideoEmbedPlayer({ url }: { url: string }) { onClick={(e) => { e.preventDefault(); e.stopPropagation(); setIsPlaying(true) }} className="relative w-full aspect-video bg-black cursor-pointer group" > - {/* eslint-disable-next-line @next/next/no-img-element */} YouTube video thumbnail ) : ( - // eslint-disable-next-line @next/next/no-img-element {altText} { loadComments().catch(() => { diff --git a/components/compose/upload-progress.tsx b/components/compose/upload-progress.tsx deleted file mode 100644 index 77a017c9..00000000 --- a/components/compose/upload-progress.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client' - -import { motion } from 'framer-motion' - -interface UploadProgressProps { - /** Progress percentage (0-100) */ - progress: number - /** Optional status message */ - message?: string -} - -/** - * Upload progress overlay component. - * Shows a circular progress indicator with percentage. - */ -export function UploadProgress({ progress, message }: UploadProgressProps) { - const radius = 40 - const circumference = 2 * Math.PI * radius - const offset = circumference * (1 - progress / 100) - - return ( - -
- - {/* Background circle */} - - {/* Progress circle */} - - - {/* Percentage text */} -
- - {Math.round(progress)}% - -
-
- {message && ( -

{message}

- )} -
- ) -} diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx deleted file mode 100644 index 2c8b33a7..00000000 --- a/components/layout/mobile-header.tsx +++ /dev/null @@ -1,89 +0,0 @@ -'use client' - -import { useState, useEffect } from 'react' -import Link from 'next/link' -import Image from 'next/image' -import { useAuth } from '@/contexts/auth-context' -import { UserAvatar } from '@/components/ui/avatar-image' -import * as DropdownMenu from '@radix-ui/react-dropdown-menu' -import { ArrowRightOnRectangleIcon, Cog6ToothIcon } from '@heroicons/react/24/outline' -import { useLoginModal } from '@/hooks/use-login-modal' - -export function MobileHeader() { - const { user, logout } = useAuth() - const openLoginModal = useLoginModal((s) => s.open) - const [isHydrated, setIsHydrated] = useState(false) - - useEffect(() => { - setIsHydrated(true) - }, []) - - return ( -
- - Yappr - Powered by Dash Evolution - Powered by Dash Evolution - - - {user && isHydrated ? ( - - - - - - - - - - - Settings - - - - - - Log out - - - - - ) : isHydrated ? ( - - ) : ( -
- )} -
- ) -} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index 4e283295..14b7632d 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -89,11 +89,13 @@ export function Sidebar() { }, []) // Fetch display name from profile when no DPNS username + const identityId = user?.identityId + const dpnsUsername = user?.dpnsUsername useEffect(() => { // Reset display name at start to avoid stale values setDisplayName(null) - if (!user?.identityId || user.dpnsUsername) { + if (!identityId || dpnsUsername) { return } @@ -102,7 +104,7 @@ export function Sidebar() { async function fetchDisplayName() { try { const { unifiedProfileService } = await import('@/lib/services/unified-profile-service') - const profile = await unifiedProfileService.getProfile(user!.identityId) + const profile = await unifiedProfileService.getProfile(identityId as string) if (mounted) { setDisplayName(profile?.displayName ?? null) } @@ -119,7 +121,7 @@ export function Sidebar() { return () => { mounted = false } - }, [user?.identityId, user?.dpnsUsername]) + }, [identityId, dpnsUsername]) // Initial notification fetch and polling useEffect(() => { diff --git a/components/post/feed-reply-context.tsx b/components/post/feed-reply-context.tsx deleted file mode 100644 index 59f521de..00000000 --- a/components/post/feed-reply-context.tsx +++ /dev/null @@ -1,81 +0,0 @@ -'use client' - -import Link from 'next/link' -import { ChatBubbleLeftIcon } from '@heroicons/react/24/outline' -import { Post } from '@/lib/types' -import { PostCard, ProgressiveEnrichment } from './post-card' - -interface FeedReplyContextProps { - originalPost: Post - reply: Post - replier: { - id: string - username?: string - displayName?: string - } - /** Enrichment data for the reply post */ - replyEnrichment?: ProgressiveEnrichment - /** Enrichment data for the original post */ - originalPostEnrichment?: ProgressiveEnrichment - isOwnPost?: boolean - /** Callback when a post is deleted */ - onDelete?: (postId: string) => void -} - -/** - * Renders a reply context card for the Following feed. - * Shows the original post that was replied to, with a header indicating who replied, - * followed by the actual reply. - */ -export function FeedReplyContext({ - originalPost, - reply, - replier, - replyEnrichment, - originalPostEnrichment, - isOwnPost, - onDelete -}: FeedReplyContextProps) { - // Use enriched username from DPNS if available, fall back to replier data - const replierName = replyEnrichment?.username - ? `@${replyEnrichment.username}` - : replyEnrichment?.displayName || replier.displayName || `User ${replier.id.slice(-6)}` - - return ( -
- {/* Header: Who replied */} - e.stopPropagation()} - className="flex items-center gap-2 text-sm text-gray-500 px-4 pt-3 pb-1 hover:underline" - > - - - {replierName} replied - - - - {/* Original post with muted background */} -
- -
- - {/* Visual connector */} -
-
-
- - {/* The reply */} - -
- ) -} diff --git a/components/post/link-preview.tsx b/components/post/link-preview.tsx index 9a53f28f..8c942cd8 100644 --- a/components/post/link-preview.tsx +++ b/components/post/link-preview.tsx @@ -333,7 +333,6 @@ export function LinkPreview({ data, className = '' }: LinkPreviewProps) { className="relative w-full aspect-video bg-black cursor-pointer group" > {/* Thumbnail */} - {/* eslint-disable-next-line @next/next/no-img-element */} YouTube video thumbnail - {/* eslint-disable-next-line @next/next/no-img-element */} Image preview - {/* eslint-disable-next-line @next/next/no-img-element */} {data.title { if (!user) return + const { encryptedContent, epoch, nonce } = post + if (!encryptedContent || epoch == null || !nonce) { + setState({ status: 'error', message: 'Invalid private post data' }) + return + } setState({ status: 'recovering' }) @@ -261,9 +266,9 @@ export function PrivatePostContent({ if (result.success) { // Recovery successful - now try to decrypt the post const decryptResult = await privateFeedFollowerService.decryptPost({ - encryptedContent: post.encryptedContent!, - epoch: post.epoch!, - nonce: post.nonce!, + encryptedContent, + epoch, + nonce, $ownerId: encryptionSourceOwnerId, }, user.identityId) @@ -306,7 +311,8 @@ export function PrivatePostContent({ const attemptDecryption = useCallback(async () => { // Safety check: ensure this is a private post - if (!post.encryptedContent || post.epoch == null || !post.nonce) { + const { encryptedContent, epoch, nonce } = post + if (!encryptedContent || epoch == null || !nonce) { setState({ status: 'error', message: 'Invalid private post data' }) return } @@ -374,14 +380,14 @@ export function PrivatePostContent({ const cached = privateFeedKeyStore.getCachedCEK(encryptionSourceOwnerId) let cek: Uint8Array - if (cached && cached.epoch === post.epoch) { + if (cached && cached.epoch === epoch) { cek = cached.cek - } else if (cached && cached.epoch > post.epoch!) { - cek = privateFeedCryptoService.deriveCEK(cached.cek, cached.epoch, post.epoch!) + } else if (cached && cached.epoch > epoch) { + cek = privateFeedCryptoService.deriveCEK(cached.cek, cached.epoch, epoch) } else { // Generate from chain const chain = privateFeedCryptoService.generateEpochChain(feedSeed, MAX_EPOCH) - cek = chain[post.epoch!] + cek = chain[epoch] } // Convert encryption source owner ID to bytes for AAD @@ -390,9 +396,9 @@ export function PrivatePostContent({ const decryptedContent = privateFeedCryptoService.decryptPostContent( cek, { - ciphertext: post.encryptedContent, - nonce: post.nonce!, - epoch: post.epoch!, + ciphertext: encryptedContent, + nonce, + epoch, }, ownerIdBytes ) @@ -451,9 +457,9 @@ export function PrivatePostContent({ // Attempt to decrypt using encryption source owner's keys const result = await privateFeedFollowerService.decryptPost({ - encryptedContent: post.encryptedContent, - epoch: post.epoch!, - nonce: post.nonce!, + encryptedContent, + epoch, + nonce, $ownerId: encryptionSourceOwnerId, }, user.identityId) diff --git a/components/settings/banner-customization.tsx b/components/settings/banner-customization.tsx index beb23e50..64a705a8 100644 --- a/components/settings/banner-customization.tsx +++ b/components/settings/banner-customization.tsx @@ -219,7 +219,6 @@ export function BannerCustomization({ onSave, initialBannerUrl }: BannerCustomiz {/* Regular URL image */} {displayUrl && !isIpfsProtocol(bannerUrl || '') && ( - // eslint-disable-next-line @next/next/no-img-element Banner {filteredItems.map((item) => { - const hasVariants = item.variants && item.variants.combinations.length > 0 + const combinations = item.variants?.combinations ?? [] + const hasVariants = combinations.length > 0 const isExpanded = expandedItems.has(item.id) const priceRange = storeItemService.getPriceRange(item) const totalStock = hasVariants - ? item.variants!.combinations.reduce((sum, c) => { + ? combinations.reduce((sum, c) => { const s = c.stock ?? Infinity return s === Infinity ? Infinity : (sum === Infinity ? Infinity : sum + s) }, 0) : storeItemService.getStock(item) const variantRows = hasVariants && isExpanded - ? item.variants!.combinations.map((combo: VariantCombination) => ( + ? combinations.map((combo: VariantCombination) => ( - {item.variants!.combinations.length} variants + {combinations.length} variants
)}
diff --git a/components/store/variant-selector.tsx b/components/store/variant-selector.tsx index f98a20a8..0fe5895b 100644 --- a/components/store/variant-selector.tsx +++ b/components/store/variant-selector.tsx @@ -16,12 +16,13 @@ export function VariantSelector({ item, selections, onChange, className }: Varia const axes = useMemo(() => { if (!item.variants?.axes) return [] - return item.variants.axes.map((axis, index) => { + const allAxes = item.variants.axes + return allAxes.map((axis, index) => { // For first axis, all options are available // For subsequent axes, filter based on prior selections const priorSelections: Record = {} for (let i = 0; i < index; i++) { - const priorAxis = item.variants!.axes[i] + const priorAxis = allAxes[i] if (selections[priorAxis.name]) { priorSelections[priorAxis.name] = selections[priorAxis.name] } diff --git a/components/ui/avatar-image.tsx b/components/ui/avatar-image.tsx index 65bfb4ed..97897257 100644 --- a/components/ui/avatar-image.tsx +++ b/components/ui/avatar-image.tsx @@ -151,7 +151,6 @@ export const UserAvatar = memo(function UserAvatar({ className={`rounded-full object-cover ${sizeClass} ${showPresence ? '' : className}`} /> ) : ( - // eslint-disable-next-line @next/next/no-img-element {alt} {!loaded && loadingFallback} - {/* eslint-disable-next-line @next/next/no-img-element */} {alt} } @@ -180,7 +179,6 @@ export function ProfileImageUpload({ } // Regular http(s) URL - // eslint-disable-next-line @next/next/no-img-element return Preview } diff --git a/contexts/auth-context.tsx b/contexts/auth-context.tsx index 08e8c51e..92344383 100644 --- a/contexts/auth-context.tsx +++ b/contexts/auth-context.tsx @@ -315,7 +315,7 @@ export function withAuth

( if (needsDPNS) { router.push('/dpns/register') } - }, [user, isAuthRestoring, router, needsDPNS, options?.optional]) + }, [user, isAuthRestoring, router, needsDPNS]) if (isAuthRestoring) { return diff --git a/contexts/sdk-context.tsx b/contexts/sdk-context.tsx index 35056cac..08f75d54 100644 --- a/contexts/sdk-context.tsx +++ b/contexts/sdk-context.tsx @@ -20,7 +20,7 @@ export function SdkProvider({ children }: { children: React.ReactNode }) { const initializeSdk = async () => { try { // This provider is the app-wide SDK bootstrap and usually wins the race - // against the on-demand callers (DashPlatformClient, platform-auth), so + // against the on-demand callers (services, platform-auth), so // it has to agree with them on the network. Hardcoding it would leave a // /devnet build reading testnet through every `useSdk()` consumer until // some later caller forced a reinit. @@ -45,7 +45,7 @@ export function SdkProvider({ children }: { children: React.ReactNode }) { // Only initialize in browser if (typeof window !== 'undefined') { logger.info('SdkProvider: Running in browser, starting initialization...') - initializeSdk() + initializeSdk().catch((err) => logger.error('SdkProvider: initialization failed:', err)) } else { logger.info('SdkProvider: Not in browser, skipping initialization') } diff --git a/hooks/use-avatar.ts b/hooks/use-avatar.ts index 10d236ba..c4de22ae 100644 --- a/hooks/use-avatar.ts +++ b/hooks/use-avatar.ts @@ -90,12 +90,12 @@ export function useAvatar(userId: string): UseAvatarResult { }, [userId]) useEffect(() => { - loadAvatar() + loadAvatar().catch((error) => logger.error('useAvatar: load failed:', error)) }, [loadAvatar]) const refresh = useCallback(() => { avatarCache.delete(userId) - loadAvatar(true) + loadAvatar(true).catch((error) => logger.error('useAvatar: refresh failed:', error)) }, [userId, loadAvatar]) return { avatarUrl, loading, refresh } @@ -114,7 +114,7 @@ export function useAvatarSettings(userId: string): UseAvatarSettingsResult { const [saving, setSaving] = useState(false) const [error, setError] = useState(null) - const loadSettings = useCallback(async (forceRefresh = false) => { + const loadSettings = useCallback(async () => { if (!userId) { setLoading(false) return @@ -129,7 +129,7 @@ export function useAvatarSettings(userId: string): UseAvatarSettingsResult { // Get profile to extract avatar settings const profile = await unifiedProfileService.getProfile(userId) - if (profile && profile.avatar) { + if (profile?.avatar) { // Parse the avatar field to extract settings // Could be JSON {"style":"bottts","seed":"xyz"} or a URI (ipfs://, https://, data:) try { @@ -202,7 +202,7 @@ export function useAvatarSettings(userId: string): UseAvatarSettingsResult { }, [userId]) useEffect(() => { - loadSettings() + loadSettings().catch((error) => logger.error('useAvatarSettings: load failed:', error)) }, [loadSettings]) const save = useCallback(async (style: DiceBearStyle, seed: string): Promise => { @@ -225,7 +225,7 @@ export function useAvatarSettings(userId: string): UseAvatarSettingsResult { if (result) { // Clear cache and reload avatarCache.delete(userId) - await loadSettings(true) + await loadSettings() return true } else { setError('Failed to save avatar') @@ -257,7 +257,7 @@ export function useAvatarSettings(userId: string): UseAvatarSettingsResult { if (result) { // Clear cache and reload avatarCache.delete(userId) - await loadSettings(true) + await loadSettings() return true } else { setError('Failed to save avatar') @@ -273,7 +273,7 @@ export function useAvatarSettings(userId: string): UseAvatarSettingsResult { }, [userId, loadSettings]) const refresh = useCallback(() => { - loadSettings(true) + loadSettings().catch((error) => logger.error('useAvatarSettings: refresh failed:', error)) }, [loadSettings]) return { settings, isCustomImage, customImageUrl, loading, saving, error, save, saveCustomUrl, refresh } diff --git a/hooks/use-block.ts b/hooks/use-block.ts index eec0d9bf..e9747407 100644 --- a/hooks/use-block.ts +++ b/hooks/use-block.ts @@ -78,7 +78,7 @@ export function useBlock(targetUserId: string, options: UseBlockOptions = {}): U }, [user?.identityId, targetUserId, cacheKey, initialValue]) useEffect(() => { - checkBlockStatus() + checkBlockStatus().catch((error) => logger.error('useBlock: status check failed:', error)) }, [checkBlockStatus]) const toggleBlock = useCallback(async (message?: string) => { @@ -140,7 +140,7 @@ export function useBlock(targetUserId: string, options: UseBlockOptions = {}): U if (cacheKey) { deleteBlockStatus(cacheKey) } - checkBlockStatus(true) + checkBlockStatus(true).catch((error) => logger.error('useBlock: refresh failed:', error)) }, [cacheKey, checkBlockStatus]) return { isBlocked, isLoading, toggleBlock, refresh } diff --git a/hooks/use-blog-follow.ts b/hooks/use-blog-follow.ts index 3766b5ea..485c8518 100644 --- a/hooks/use-blog-follow.ts +++ b/hooks/use-blog-follow.ts @@ -66,7 +66,7 @@ export function useBlogFollow(blogId: string, initialFollowing?: boolean): UseBl } } - check() + check().catch((error) => logger.error('useBlogFollow: status check failed:', error)) return () => { cancelled = true } }, [user?.identityId, blogId, cacheKey, initialFollowing]) @@ -84,7 +84,7 @@ export function useBlogFollow(blogId: string, initialFollowing?: boolean): UseBl } } - load() + load().catch((error) => logger.error('useBlogFollow: follower count failed:', error)) return () => { cancelled = true } }, [blogId]) @@ -127,7 +127,7 @@ export function useBlogFollow(blogId: string, initialFollowing?: boolean): UseBl } finally { setIsLoading(false) } - }, [user?.identityId, blogId, isFollowing, isLoading, cacheKey, openLoginPrompt]) + }, [user?.identityId, blogId, isFollowing, isLoading, followerCount, cacheKey, openLoginPrompt]) return { isFollowing, isLoading, followerCount, toggleFollow } } diff --git a/hooks/use-dash-transaction-watcher.ts b/hooks/use-dash-transaction-watcher.ts index 58f1b374..93750142 100644 --- a/hooks/use-dash-transaction-watcher.ts +++ b/hooks/use-dash-transaction-watcher.ts @@ -1,5 +1,6 @@ 'use client' +import { logger } from '@/lib/logger' import { useState, useEffect, useCallback, useRef } from 'react' import { waitForUtxo, @@ -91,6 +92,10 @@ export function useDashTransactionWatcher({ } else if (result.error) { setStatus('error') } + }).catch((error) => { + if (watchCountRef.current !== watchId) return + logger.error('useDashTransactionWatcher: watcher failed:', error) + setStatus('error') }) }, [scheme, address, onDetected, onTimeout]) diff --git a/hooks/use-feed-data.ts b/hooks/use-feed-data.ts index 53302972..90038a21 100644 --- a/hooks/use-feed-data.ts +++ b/hooks/use-feed-data.ts @@ -320,7 +320,6 @@ export function useFeedData({ activeTab, feedLanguage }: UseFeedDataOptions): Us } else { const forYouResult = await loadForYouFeed({ startAfter: pagination?.startAfter, - forceRefresh, feedLanguage, setData, setHasMore, diff --git a/hooks/use-file-drop.ts b/hooks/use-file-drop.ts index 5e2ec55c..69970f73 100644 --- a/hooks/use-file-drop.ts +++ b/hooks/use-file-drop.ts @@ -1,6 +1,7 @@ 'use client' import { useCallback, useState } from 'react' +import { logger } from '@/lib/logger' interface UseFileDropOptions { /** When true, drag events are accepted but no file processing occurs */ @@ -44,7 +45,7 @@ export function useFileDrop({ disabled, onDrop, accept }: UseFileDropOptions): U if (!file) return if (accept && !file.type.startsWith(accept)) return - onDrop(file) + Promise.resolve(onDrop(file)).catch((error) => logger.error('useFileDrop: onDrop failed:', error)) }, [disabled, onDrop, accept]) const handleDragOver = useCallback((e: React.DragEvent) => { diff --git a/hooks/use-follow.ts b/hooks/use-follow.ts index 7421e6dd..522a3a10 100644 --- a/hooks/use-follow.ts +++ b/hooks/use-follow.ts @@ -79,7 +79,7 @@ export function useFollow(targetUserId: string, options: UseFollowOptions = {}): }, [user?.identityId, targetUserId, cacheKey, initialValue]) useEffect(() => { - checkFollowStatus() + checkFollowStatus().catch((error) => logger.error('useFollow: status check failed:', error)) }, [checkFollowStatus]) const toggleFollow = useCallback(async () => { @@ -138,7 +138,7 @@ export function useFollow(targetUserId: string, options: UseFollowOptions = {}): if (cacheKey) { deleteFollowStatus(cacheKey) } - checkFollowStatus(true) + checkFollowStatus(true).catch((error) => logger.error('useFollow: refresh failed:', error)) }, [cacheKey, checkFollowStatus]) return { isFollowing, isLoading, toggleFollow, refresh } diff --git a/hooks/use-hashtag-validation.ts b/hooks/use-hashtag-validation.ts index 18b88213..85e00542 100644 --- a/hooks/use-hashtag-validation.ts +++ b/hooks/use-hashtag-validation.ts @@ -75,7 +75,7 @@ export function useHashtagValidation(post: Post | null): HashtagValidationState return () => { cancelled = true } - }, [postId, hashtags.join(','), post?.content]) + }, [postId, hashtags, post?.content]) // Revalidate function to clear cache and re-fetch const revalidate = useCallback(() => { diff --git a/hooks/use-homepage-data.ts b/hooks/use-homepage-data.ts index 0ac93aea..b0e63d93 100644 --- a/hooks/use-homepage-data.ts +++ b/hooks/use-homepage-data.ts @@ -2,9 +2,9 @@ import { logger } from '@/lib/logger'; import { useState, useEffect, useCallback, useRef } from 'react' -import { Post, User } from '@/lib/types' +import { Post } from '@/lib/types' import { postService } from '@/lib/services/post-service' -import { unifiedProfileService } from '@/lib/services/unified-profile-service' +import { unifiedProfileService, type UnifiedProfileDocument } from '@/lib/services/unified-profile-service' import { dpnsService } from '@/lib/services/dpns-service' import { useSdk } from '@/contexts/sdk-context' @@ -72,7 +72,6 @@ export function useHomepageData(): HomepageData { error: null }) - const loadingRef = useRef(false) const hasLoadedRef = useRef(false) const loadPlatformStats = useCallback(async (forceRefresh = false) => { @@ -182,25 +181,22 @@ export function useHomepageData(): HomepageData { dpnsService.resolveUsernamesBatch(authorIds) ]) - // Build profile map - const profileMap = new Map() + const profileMap = new Map() for (const profile of profiles) { - const ownerId = profile.$ownerId || (profile as any).ownerId - if (ownerId) { - profileMap.set(ownerId, profile) + if (profile.$ownerId) { + profileMap.set(profile.$ownerId, profile) } } // Build top users array const users: TopUser[] = sortedAuthors.map(([authorId, postCount]) => { const profile = profileMap.get(authorId) - const profileData = profile?.data || profile const username = usernameMap.get(authorId) || authorId.substring(0, 8) + '...' return { id: authorId, username, - displayName: profileData?.displayName || username, + displayName: profile?.displayName || username, postCount } }) @@ -230,10 +226,9 @@ export function useHomepageData(): HomepageData { cache.featuredPosts = null cache.topUsers = null - // Reload all data - loadPlatformStats(true) - loadFeaturedPosts(true) - loadTopUsers(true) + // Reload all data. Each loader reports its own failure into its slice. + Promise.all([loadPlatformStats(true), loadFeaturedPosts(true), loadTopUsers(true)]) + .catch((error) => logger.error('Homepage refresh failed:', error)) }, [sdkReady, loadPlatformStats, loadFeaturedPosts, loadTopUsers]) // Initial load - wait for SDK to be ready @@ -242,10 +237,9 @@ export function useHomepageData(): HomepageData { if (hasLoadedRef.current) return hasLoadedRef.current = true - // Load all data in parallel - loadPlatformStats() - loadFeaturedPosts() - loadTopUsers() + // Load all data in parallel. Each loader reports its own failure into its slice. + Promise.all([loadPlatformStats(), loadFeaturedPosts(), loadTopUsers()]) + .catch((error) => logger.error('Homepage load failed:', error)) }, [sdkReady, loadPlatformStats, loadFeaturedPosts, loadTopUsers]) return { diff --git a/hooks/use-mention-validation.ts b/hooks/use-mention-validation.ts index 913034ba..1d6ccc7d 100644 --- a/hooks/use-mention-validation.ts +++ b/hooks/use-mention-validation.ts @@ -75,7 +75,7 @@ export function useMentionValidation(post: Post | null): MentionValidationState return () => { cancelled = true } - }, [postId, mentions.join(','), post?.content]) + }, [postId, mentions, post?.content]) // Revalidate function to clear cache and re-fetch const revalidate = useCallback(() => { diff --git a/hooks/use-platform-detection.ts b/hooks/use-platform-detection.ts deleted file mode 100644 index 9e476a98..00000000 --- a/hooks/use-platform-detection.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useState, useEffect } from 'react' - -/** - * Detects whether the user is on a Mac platform for keyboard shortcut hints. - * Returns true for Mac/iOS devices, false for others. - */ -export function usePlatformDetection(): boolean { - const [isMac, setIsMac] = useState(true) // Default to Mac symbol - - useEffect(() => { - setIsMac( - typeof navigator !== 'undefined' && - /Mac|iPod|iPhone|iPad/.test(navigator.platform) - ) - }, []) - - return isMac -} diff --git a/hooks/use-post-detail.ts b/hooks/use-post-detail.ts index d6a31630..39298428 100644 --- a/hooks/use-post-detail.ts +++ b/hooks/use-post-detail.ts @@ -343,7 +343,7 @@ export function usePostDetail({ * context. On v2 the only link is the polymorphic direct parent, so the chain * has to be walked one lookup at a time. */ - const fetchReplyChain = async (mainPost: Post): Promise => { + const fetchReplyChain = useCallback(async (mainPost: Post): Promise => { const chain: Post[] = [] if (hasFlatThreads()) { @@ -390,7 +390,7 @@ export function usePostDetail({ } return chain - } + }, [enrich]) const loadPost = useCallback(async () => { if (!postId || !enabled) return @@ -535,7 +535,7 @@ export function usePostDetail({ setIsLoadingReplies(false) } } - }, [postId, enabled, enrich]) + }, [postId, enabled, enrich, fetchReplyChain]) /** * Fetch the next page of the thread (v3 only — v2's `getReplies` covers one @@ -612,7 +612,7 @@ export function usePostDetail({ setError(null) } - loadPost() + loadPost().catch((err) => logger.error('usePostDetail: load failed:', err)) }, [postId, enabled, loadPost, resetEnrichment]) const refresh = useCallback(async () => { @@ -683,7 +683,7 @@ export function usePostDetail({ if (belongsHere) { // Refresh to get the new reply with proper data - refresh() + refresh().catch((err) => logger.error('usePostDetail: refresh after reply failed:', err)) } } diff --git a/hooks/use-progressive-enrichment.ts b/hooks/use-progressive-enrichment.ts index 973d7bd6..7ecdd343 100644 --- a/hooks/use-progressive-enrichment.ts +++ b/hooks/use-progressive-enrichment.ts @@ -1,6 +1,6 @@ import { logger } from '@/lib/logger'; import { useState, useCallback, useRef, useEffect } from 'react' -import { Post, User } from '@/lib/types' +import { Post } from '@/lib/types' import { postService } from '@/lib/services/post-service' import { dpnsService } from '@/lib/services/dpns-service' import { unifiedProfileService } from '@/lib/services/unified-profile-service' @@ -192,14 +192,10 @@ export function useProgressiveEnrichment( if (!isValid()) return const profileMap = new Map() for (const profile of profiles) { - const ownerId = profile.$ownerId - // Profile data may be nested under 'data' property or at root level - const profileAny = profile as any - const data = profileAny.data || profile - if (ownerId) { - profileMap.set(ownerId, { - displayName: data.displayName, - bio: data.bio + if (profile.$ownerId) { + profileMap.set(profile.$ownerId, { + displayName: profile.displayName, + bio: profile.bio }) } } @@ -286,17 +282,18 @@ export function useProgressiveEnrichment( } } - // Track completion using the SAME promises (no duplicate queries!) - Promise.all([ + // Track completion using the SAME promises (no duplicate queries!). Each + // promise already reports its own failure above; here only settlement matters. + Promise.allSettled([ usernamePromise, profilePromise, avatarPromise, statsPromise, interactionsPromise - ]).finally(() => { + ]).then(() => { if (!isValid()) return setEnrichmentState(prev => ({ ...prev, phase: 'complete' })) - }) + }).catch(err => logger.error('Progressive enrichment: completion tracking failed', err)) }, [currentUserId, skipFollowStatus]) @@ -329,8 +326,9 @@ export function useProgressiveEnrichment( // Cleanup on unmount useEffect(() => { + const idRef = enrichmentIdRef return () => { - enrichmentIdRef.current++ + idRef.current++ } }, []) diff --git a/hooks/use-require-encryption-key.ts b/hooks/use-require-encryption-key.ts deleted file mode 100644 index 171d5d36..00000000 --- a/hooks/use-require-encryption-key.ts +++ /dev/null @@ -1,126 +0,0 @@ -'use client' - -import { useCallback } from 'react' -import { useAuth } from '@/contexts/auth-context' -import { useEncryptionKeyModal, EncryptionKeyAction } from './use-encryption-key-modal' - -/** - * Hook to require encryption key for private feed operations. - * - * If the user has a private feed enabled but no encryption key in session storage, - * this will open the encryption key modal. Otherwise, it will execute the callback. - * - * Example usage: - * ```tsx - * const { requireEncryptionKey, hasEncryptionKey } = useRequireEncryptionKey() - * - * const handleCreatePrivatePost = async () => { - * const canProceed = await requireEncryptionKey('create_private_post', () => { - * // This callback will be called after the key is entered successfully - * doCreatePost() - * }) - * if (!canProceed) return // Modal was opened, user needs to enter key - * } - * ``` - */ -export function useRequireEncryptionKey() { - const { user } = useAuth() - const { open: openModal } = useEncryptionKeyModal() - - /** - * Check if the user has an encryption key stored - */ - const hasEncryptionKey = useCallback((): boolean => { - if (typeof window === 'undefined' || !user) return false - - // Dynamically check secure storage to avoid SSR issues - try { - const { hasEncryptionKey: checkKey } = require('@/lib/secure-storage') - return checkKey(user.identityId) - } catch { - return false - } - }, [user]) - - /** - * Get the stored encryption key as bytes - * Handles both WIF and legacy hex formats automatically - */ - const getEncryptionKeyBytes = useCallback((): Uint8Array | null => { - if (typeof window === 'undefined' || !user) return null - - try { - const { getEncryptionKeyBytes: getKeyBytes } = require('@/lib/secure-storage') - return getKeyBytes(user.identityId) - } catch { - return null - } - }, [user]) - - /** - * Require encryption key for an action. - * Returns true if key is available, false if modal was opened. - */ - const requireEncryptionKey = useCallback(( - action: EncryptionKeyAction = 'generic', - onSuccess?: () => void - ): boolean => { - if (!user) return false - - if (hasEncryptionKey()) { - // Key is available, proceed - if (onSuccess) onSuccess() - return true - } - - // Key not available, open modal - openModal(action, onSuccess) - return false - }, [user, hasEncryptionKey, openModal]) - - /** - * Async version that resolves when key is entered or rejects if cancelled/skipped - */ - const requireEncryptionKeyAsync = useCallback(( - action: EncryptionKeyAction = 'generic' - ): Promise => { - return new Promise((resolve, reject) => { - if (!user) { - reject(new Error('User not logged in')) - return - } - - const existingKey = getEncryptionKeyBytes() - if (existingKey) { - resolve(existingKey) - return - } - - // Open modal and wait for completion - // Pass both onSuccess and onCancel callbacks to ensure promise settles - openModal( - action, - // onSuccess: called when key is successfully entered - () => { - const key = getEncryptionKeyBytes() - if (key) { - resolve(key) - } else { - reject(new Error('Encryption key not entered')) - } - }, - // onCancel: called when modal is dismissed without entering key - () => { - reject(new Error('Encryption key entry cancelled')) - } - ) - }) - }, [user, getEncryptionKeyBytes, openModal]) - - return { - hasEncryptionKey, - getEncryptionKeyBytes, - requireEncryptionKey, - requireEncryptionKeyAsync, - } -} diff --git a/knip.json b/knip.json new file mode 100644 index 00000000..2b187cc9 --- /dev/null +++ b/knip.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": [ + "app/**/*.{ts,tsx}", + "e2e/**/*.ts", + "scripts/**/*.mjs" + ], + "project": [ + "app/**", + "components/**", + "contexts/**", + "hooks/**", + "lib/**", + "types/**", + "e2e/**", + "scripts/**" + ], + "ignoreDependencies": [ + "platform-auth" + ] +} diff --git a/lib/auth/platform-auth-adapters.ts b/lib/auth/platform-auth-adapters.ts index 6349b997..21b884d4 100644 --- a/lib/auth/platform-auth-adapters.ts +++ b/lib/auth/platform-auth-adapters.ts @@ -58,7 +58,6 @@ import { decodeBinaryFromBase64, wrapDekWithPassword, wrapDekWithPrf } from '@/l import { deriveEncryptionKey, validateDerivedKeyMatchesIdentity } from '@/lib/crypto/key-derivation' import { hasEncryptionKeyOnIdentity } from '@/lib/crypto/encryption-key-lookup' import { parsePrivateKey, privateKeyToWif } from '@/lib/crypto/wif' -import { getDashPlatformClient } from '@/lib/dash-platform-client' import { invalidateBlockCache } from '@/lib/caches/block-cache' import { privateFeedKeyStore } from '@/lib/services/private-feed-key-store' import { extractErrorMessage } from '@/lib/error-utils' @@ -341,11 +340,6 @@ export function createYapprPlatformAuthDependencies(): PlatformAuthDependencies return Boolean(legacyProfile) }, }, - clientIdentity: { - setIdentity(identityId) { - getDashPlatformClient().setIdentity(identityId) - }, - }, sideEffects: { runPostLogin, runLogoutCleanup, diff --git a/lib/bloom-filter.test.ts b/lib/bloom-filter.test.ts new file mode 100644 index 00000000..4cabc58e --- /dev/null +++ b/lib/bloom-filter.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import bs58 from 'bs58' +import { BloomFilter, bloomFilterFromBase64, bloomFilterToBase64 } from './bloom-filter' + +function identifier(seed: number): string { + // Distinct 32-byte ids: the seed occupies the first four bytes so no two + // seeds below 2^32 can collide, the rest is deterministic filler. + const bytes = new Uint8Array(32) + new DataView(bytes.buffer).setUint32(0, seed) + for (let i = 4; i < 32; i++) bytes[i] = (seed + i * 17) & 0xff + return bs58.encode(bytes) +} + +describe('BloomFilter', () => { + it('starts empty', () => { + const filter = new BloomFilter() + expect(filter.isEmpty()).toBe(true) + expect(filter.itemCount).toBe(0) + expect(filter.estimateFalsePositiveRate()).toBe(0) + }) + + it('never yields a false negative', () => { + const filter = new BloomFilter() + const ids = Array.from({ length: 500 }, (_, i) => identifier(i)) + for (const id of ids) filter.add(id) + expect(filter.itemCount).toBe(500) + for (const id of ids) expect(filter.mightContain(id)).toBe(true) + }) + + it('keeps the false positive rate low at the design load', () => { + const filter = new BloomFilter() + for (let i = 0; i < 1000; i++) filter.add(identifier(i)) + let hits = 0 + for (let i = 1000; i < 3000; i++) if (filter.mightContain(identifier(i))) hits++ + expect(hits / 2000).toBeLessThan(0.01) + expect(filter.estimateFalsePositiveRate()).toBeLessThan(0.01) + }) + + it('accepts raw bytes and base58 interchangeably', () => { + const filter = new BloomFilter() + const id = identifier(42) + filter.add(bs58.decode(id)) + expect(filter.mightContain(id)).toBe(true) + }) + + it('round-trips through serialize()', () => { + const filter = new BloomFilter() + filter.add(identifier(1)) + const copy = new BloomFilter(filter.serialize(), filter.itemCount) + expect(copy.mightContain(identifier(1))).toBe(true) + expect(copy.itemCount).toBe(1) + expect(copy.serialize()).toEqual(filter.serialize()) + }) + + it('round-trips through base64', () => { + const filter = new BloomFilter() + filter.add(identifier(7)) + const restored = bloomFilterFromBase64(bloomFilterToBase64(filter), 1) + expect(restored.mightContain(identifier(7))).toBe(true) + expect(restored.serialize()).toEqual(filter.serialize()) + }) + + it('pads or truncates foreign data to the fixed size', () => { + const short = new BloomFilter(new Uint8Array([0xff])) + expect(short.serialize().length).toBe(BloomFilter.sizeBytes) + const long = new BloomFilter(new Uint8Array(BloomFilter.sizeBytes + 10).fill(1)) + expect(long.serialize().length).toBe(BloomFilter.sizeBytes) + }) + + it('merges as a union', () => { + const a = new BloomFilter() + const b = new BloomFilter() + a.add(identifier(1)) + b.add(identifier(2)) + const merged = BloomFilter.merge([a, b]) + expect(merged.mightContain(identifier(1))).toBe(true) + expect(merged.mightContain(identifier(2))).toBe(true) + expect(merged.itemCount).toBe(2) + expect(a.mightContain(identifier(2))).toBe(false) + }) +}) diff --git a/lib/crypto/aes-gcm.test.ts b/lib/crypto/aes-gcm.test.ts new file mode 100644 index 00000000..23fdcbb8 --- /dev/null +++ b/lib/crypto/aes-gcm.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { aesGcmDecrypt, aesGcmEncrypt, deriveAesKeyFromPrivateKey, deriveKeyFromPasswordAndSalt } from './aes-gcm' +import { MAX_KDF_ITERATIONS, MIN_KDF_ITERATIONS } from '../onchain-key-encryption' + +const PRIVATE_KEY = Uint8Array.from({ length: 32 }, (_, i) => i) +const PLAINTEXT = new TextEncoder().encode('hello, private feed') + +describe('AES-GCM helpers', () => { + it('round-trips plaintext with a 12-byte IV prefix', async () => { + const key = await deriveAesKeyFromPrivateKey(PRIVATE_KEY) + const sealed = await aesGcmEncrypt(key, PLAINTEXT) + expect(sealed.length).toBe(12 + PLAINTEXT.length + 16) + expect(await aesGcmDecrypt(key, sealed)).toEqual(PLAINTEXT) + }) + + it('produces a fresh IV per call', async () => { + const key = await deriveAesKeyFromPrivateKey(PRIVATE_KEY) + const a = await aesGcmEncrypt(key, PLAINTEXT) + const b = await aesGcmEncrypt(key, PLAINTEXT) + expect(a.slice(0, 12)).not.toEqual(b.slice(0, 12)) + }) + + it('fails authentication on a tampered byte', async () => { + const key = await deriveAesKeyFromPrivateKey(PRIVATE_KEY) + const sealed = await aesGcmEncrypt(key, PLAINTEXT) + sealed[sealed.length - 1] ^= 0x01 + await expect(aesGcmDecrypt(key, sealed)).rejects.toThrow() + }) + + it('fails with a different key', async () => { + const key = await deriveAesKeyFromPrivateKey(PRIVATE_KEY) + const other = await deriveAesKeyFromPrivateKey(PRIVATE_KEY.map((b) => b ^ 0xff)) + const sealed = await aesGcmEncrypt(key, PLAINTEXT) + await expect(aesGcmDecrypt(other, sealed)).rejects.toThrow() + }) + + it('derives the same key from the same private key', async () => { + const [a, b] = await Promise.all([ + deriveAesKeyFromPrivateKey(PRIVATE_KEY), + deriveAesKeyFromPrivateKey(PRIVATE_KEY), + ]) + const sealed = await aesGcmEncrypt(a, PLAINTEXT) + expect(await aesGcmDecrypt(b, sealed)).toEqual(PLAINTEXT) + }) + + it('rejects PBKDF2 iteration counts outside the allowed range', async () => { + const salt = new Uint8Array(16) + await expect(deriveKeyFromPasswordAndSalt('pw', salt, MIN_KDF_ITERATIONS - 1)).rejects.toThrow('Iterations') + await expect(deriveKeyFromPasswordAndSalt('pw', salt, MAX_KDF_ITERATIONS + 1)).rejects.toThrow('Iterations') + }) +}) diff --git a/lib/crypto/hash.test.ts b/lib/crypto/hash.test.ts new file mode 100644 index 00000000..3383a545 --- /dev/null +++ b/lib/crypto/hash.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { hash160 } from './hash' +import { bytesToHex } from './wif' + +describe('hash160', () => { + it('matches the known RIPEMD160(SHA256()) vector for empty input', () => { + expect(bytesToHex(hash160(new Uint8Array(0)))).toBe('b472a266d0bd89c13706a4132ccfb16f7c3b9fcb') + }) + + it('matches the known vector for a compressed secp256k1 generator point', () => { + // 02 || Gx — the pubkey of private key 1. Its hash160 is the well-known + // address payload 751e76e8199196d454941c45d1b3a323f1433bd6. + const pubkey = Uint8Array.from( + Buffer.from('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') + ) + expect(bytesToHex(hash160(pubkey))).toBe('751e76e8199196d454941c45d1b3a323f1433bd6') + }) +}) diff --git a/lib/crypto/wif.test.ts b/lib/crypto/wif.test.ts new file mode 100644 index 00000000..d1f4345a --- /dev/null +++ b/lib/crypto/wif.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import { + MAINNET_WIF_PREFIX, + TESTNET_WIF_PREFIX, + bytesToHex, + hexToBytes, + isLikelyHex, + isLikelyWif, + parsePrivateKey, + privateKeyToWif, + validateWifNetwork, + wifToPrivateKey, +} from './wif' + +const KEY = Uint8Array.from({ length: 32 }, (_, i) => (i * 7 + 3) & 0xff) +const KEY_HEX = bytesToHex(KEY) + +describe('WIF encode/decode', () => { + it('round-trips a compressed testnet key', () => { + const wif = privateKeyToWif(KEY, 'testnet', true) + const decoded = wifToPrivateKey(wif) + expect(decoded.privateKey).toEqual(KEY) + expect(decoded.compressed).toBe(true) + expect(decoded.prefix).toBe(TESTNET_WIF_PREFIX) + expect(wif[0]).toBe('c') + }) + + it('round-trips an uncompressed mainnet key', () => { + const wif = privateKeyToWif(KEY, 'mainnet', false) + const decoded = wifToPrivateKey(wif) + expect(decoded.privateKey).toEqual(KEY) + expect(decoded.compressed).toBe(false) + expect(decoded.prefix).toBe(MAINNET_WIF_PREFIX) + }) + + it('rejects keys that are not 32 bytes', () => { + expect(() => privateKeyToWif(new Uint8Array(31))).toThrow('32 bytes') + }) + + it('rejects a WIF with a corrupted checksum', () => { + const wif = privateKeyToWif(KEY) + const last = wif[wif.length - 1] + const tampered = wif.slice(0, -1) + (last === 'a' ? 'b' : 'a') + expect(() => wifToPrivateKey(tampered)).toThrow() + expect(isLikelyWif(tampered)).toBe(false) + }) + + it('validates the network prefix', () => { + expect(validateWifNetwork(TESTNET_WIF_PREFIX, 'testnet')).toBe(true) + expect(validateWifNetwork(TESTNET_WIF_PREFIX, 'mainnet')).toBe(false) + expect(validateWifNetwork(MAINNET_WIF_PREFIX, 'mainnet')).toBe(true) + }) +}) + +describe('hex helpers', () => { + it('round-trips bytes through hex', () => { + expect(hexToBytes(KEY_HEX)).toEqual(KEY) + expect(bytesToHex(hexToBytes(KEY_HEX))).toBe(KEY_HEX) + }) + + it('accepts a 0x prefix and surrounding whitespace', () => { + expect(hexToBytes(` 0x${KEY_HEX} `)).toEqual(KEY) + expect(isLikelyHex(`0X${KEY_HEX}`)).toBe(true) + }) + + it('rejects malformed hex', () => { + expect(() => hexToBytes('')).toThrow('Empty') + expect(() => hexToBytes('abc')).toThrow('even length') + expect(() => hexToBytes('zz')).toThrow('Invalid hex') + expect(isLikelyHex(KEY_HEX.slice(0, 62))).toBe(false) + }) +}) + +describe('parsePrivateKey', () => { + it('detects WIF and reports the network', () => { + const parsed = parsePrivateKey(privateKeyToWif(KEY, 'mainnet')) + expect(parsed.format).toBe('wif') + expect(parsed.network).toBe('mainnet') + expect(parsed.privateKey).toEqual(KEY) + }) + + it('detects raw hex', () => { + const parsed = parsePrivateKey(KEY_HEX) + expect(parsed.format).toBe('hex') + expect(parsed.network).toBeUndefined() + expect(parsed.privateKey).toEqual(KEY) + }) + + it('rejects anything else', () => { + expect(() => parsePrivateKey('not a key')).toThrow('Invalid private key format') + }) +}) diff --git a/lib/dash-platform-client.ts b/lib/dash-platform-client.ts deleted file mode 100644 index b84ef371..00000000 --- a/lib/dash-platform-client.ts +++ /dev/null @@ -1,431 +0,0 @@ -'use client' - -import { logger } from '@/lib/logger'; -// Import the centralized SDK service -import { evoSdkService } from './services/evo-sdk-service' -import { YAPPR_CONTRACT_ID, getConfiguredNetwork } from './constants' -import { SESSION_STORAGE_KEY } from './storage-scope' -import { documentToPlainObject } from './services/sdk-helpers' - -export class DashPlatformClient { - private sdk: any = null - private identityId: string | null = null - private isInitializing: boolean = false - private postsCache: Map = new Map() - private readonly CACHE_TTL = 120000 // 2 minutes for posts cache (reduced query frequency) - private pendingQueries: Map> = new Map() // Prevent duplicate queries - - constructor() { - // SDK will be initialized on first use - } - - /** - * Initialize the SDK using the centralized WASM service - */ - public async ensureInitialized() { - if (this.sdk || this.isInitializing) { - // Already initialized or initializing - while (this.isInitializing) { - // Wait for initialization to complete - await new Promise(resolve => setTimeout(resolve, 100)) - } - return - } - - this.isInitializing = true - - try { - // Use the centralized WASM service - const network = getConfiguredNetwork() - const contractId = YAPPR_CONTRACT_ID - - logger.info('DashPlatformClient: Initializing via WasmSdkService for network:', network) - - // Initialize the WASM SDK service if not already done - await evoSdkService.initialize({ network, contractId }) - - // Get the SDK instance - this.sdk = await evoSdkService.getSdk() - - logger.info('DashPlatformClient: WASM SDK initialized successfully via service') - } catch (error) { - logger.error('DashPlatformClient: Failed to initialize WASM SDK:', error) - throw error - } finally { - this.isInitializing = false - } - } - - /** - * Set the identity ID for document operations - * This is called by the auth system after identity verification - */ - setIdentity(identityId: string) { - this.identityId = identityId - logger.info('DashPlatformClient: Identity set to:', identityId) - } - - /** - * Create a post document - */ - async createPost(content: string, options?: { - replyToPostId?: string - replyToPostOwnerId?: string - quotedPostId?: string - mediaUrl?: string - }) { - // Get identity ID from instance or auth context - let identityId = this.identityId - - if (!identityId) { - // Try to get from auth context via user session - if (typeof window !== 'undefined') { - const savedSession = localStorage.getItem(SESSION_STORAGE_KEY) - if (savedSession) { - try { - const sessionData = JSON.parse(savedSession) - identityId = sessionData.user?.identityId - if (identityId) { - // Set it for future use - this.identityId = identityId - logger.info('DashPlatformClient: Identity restored from session:', identityId) - } - } catch (e) { - logger.error('Failed to parse session data:', e) - } - } - } - } - - if (!identityId) { - throw new Error('Not logged in - no identity found') - } - - try { - await this.ensureInitialized() - - logger.info('Creating post for identity:', identityId) - - // Use the post service which goes through the new state-transition-service - const { postService } = await import('./services/post-service') - - // Create the post using the post service - // Note: replies are now a separate document type created via replyService - const post = await postService.createPost(identityId, content.trim(), { - quotedPostId: options?.quotedPostId, - mediaUrl: options?.mediaUrl, - language: 'en' - }) - - logger.info('Post created successfully!') - - // Invalidate posts cache since we created a new post - this.postsCache.clear() - - return post - - } catch (error) { - logger.error('Failed to create post:', error) - throw error - } - } - - /** - * Get user profile - */ - async getUserProfile(identityId: string) { - try { - await this.ensureInitialized() - - logger.info('Fetching profile for identity:', identityId) - - // Query profile document for this identity - const query = { - where: [ - ['$ownerId', '==', identityId] - ], - limit: 1 - } - - const contractId = YAPPR_CONTRACT_ID - - // Use EvoSDK documents facade - const profileResponse = await this.sdk.documents.query({ - dataContractId: contractId, - documentTypeName: 'profile', - where: query.where, - limit: query.limit - }) - - logger.info('Profile query response:', profileResponse) - - // Convert Map response (v3 SDK) to array - let profiles: unknown[] = [] - if (profileResponse instanceof Map) { - profiles = Array.from(profileResponse.values()) - .filter(Boolean) - .map(documentToPlainObject) - } else if (Array.isArray(profileResponse)) { - profiles = profileResponse - .filter(Boolean) - .map(documentToPlainObject) - } - - logger.info('Profiles found:', profiles) - - if (profiles.length > 0) { - return profiles[0] - } - - return null - } catch (error) { - logger.error('Failed to fetch profile:', error) - // Return null if profile doesn't exist - return null - } - } - - /** - * Query posts with caching. - * Uses the languageTimeline index: [language, $createdAt]. - * @param options.language - Language code to filter by (defaults to 'en') - */ - async queryPosts(options?: { - limit?: number - startAfter?: any - authorId?: string - forceRefresh?: boolean - language?: string - }) { - try { - // Create cache key based on options - const cacheKey = JSON.stringify({ - limit: options?.limit || 20, - authorId: options?.authorId, - startAfter: options?.startAfter, - language: options?.language || 'en' - }) - - // Check if there's already a pending query for this exact request - const pendingQuery = this.pendingQueries.get(cacheKey) - if (!options?.forceRefresh && pendingQuery) { - logger.info('DashPlatformClient: Returning pending query result') - return await pendingQuery - } - - // Check cache first (unless force refresh) - if (!options?.forceRefresh) { - const cached = this.postsCache.get(cacheKey) - if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) { - logger.info('DashPlatformClient: Returning cached posts') - return cached.posts - } - } - - await this.ensureInitialized() - - const contractId = YAPPR_CONTRACT_ID - - logger.info('DashPlatformClient: Querying posts from contract:', contractId) - - // Create the query promise and store it to prevent duplicates - const queryPromise = this._executePostsQuery(contractId, options, cacheKey) - this.pendingQueries.set(cacheKey, queryPromise) - - try { - const result = await queryPromise - return result - } finally { - // Clean up the pending query - this.pendingQueries.delete(cacheKey) - } - } catch (error: any) { - // Extract error message from WasmSdkError or regular Error - let errorMessage = 'Unknown error' - if (error && typeof error.message === 'string') { - errorMessage = error.message - } else if (error instanceof Error) { - errorMessage = error.message - } - logger.error('DashPlatformClient: Failed to query posts:', errorMessage, { - code: error?.code, - kind: error?.kind - }) - throw error - } - } - - /** - * Execute the actual posts query (separated to allow proper pending query management) - */ - private async _executePostsQuery(contractId: string, options: any, cacheKey: string): Promise { - try { - - // Build where clause - const where: any[] = [] - let orderBy: any[] = [] - - if (options?.authorId) { - // Query by $ownerId (system field) using ownerAndTime index - where.push(['$ownerId', '==', options.authorId]) - where.push(['$createdAt', '>', 0]) - orderBy = [['$ownerId', 'asc'], ['$createdAt', 'desc']] - } else { - // Use languageTimeline index: [language, $createdAt] - // The old timeline index was removed - we now require language filter - const language = options?.language || 'en' - where.push(['language', '==', language]) - where.push(['$createdAt', '>', 0]) - orderBy = [['language', 'asc'], ['$createdAt', 'desc']] - } - - try { - // Use EvoSDK documents facade - const postsResponse = await this.sdk.documents.query({ - dataContractId: contractId, - documentTypeName: 'post', - where: where.length > 0 ? where : undefined, - orderBy, - limit: options?.limit || 20, - startAfter: options?.startAfter || undefined - }) - - logger.info('DashPlatformClient: Posts query response received') - - // Convert Map response (v3 SDK) to array - let posts: unknown[] = [] - if (postsResponse instanceof Map) { - posts = Array.from(postsResponse.values()) - .filter(Boolean) - .map(documentToPlainObject) - } else if (Array.isArray(postsResponse)) { - posts = postsResponse - .filter(Boolean) - .map(documentToPlainObject) - } - - logger.info(`DashPlatformClient: Found ${posts.length} posts`) - - // Cache the results - this.postsCache.set(cacheKey, { - posts, - timestamp: Date.now() - }) - - return posts - } catch (queryError: any) { - // Extract error message from WasmSdkError or regular Error - // WasmSdkError has getters for message, code, kind, retriable, name - let errorMessage = 'Unknown error' - let errorCode: string | undefined - let errorKind: string | undefined - - // Try to access WasmSdkError properties (they're getters) - try { - if (queryError?.message) errorMessage = queryError.message - if (queryError?.code) errorCode = queryError.code - if (queryError?.kind) errorKind = queryError.kind - } catch (e) { - // Getters might throw - } - - // Fallback checks - if (errorMessage === 'Unknown error') { - if (queryError instanceof Error) { - errorMessage = queryError.message - } else if (typeof queryError === 'string') { - errorMessage = queryError - } - } - - logger.info('DashPlatformClient: Document query failed:', { - message: errorMessage, - code: errorCode, - kind: errorKind, - retriable: queryError?.retriable, - errorType: queryError?.constructor?.name, - errorString: String(queryError) - }) - - // For contract-related errors or "not found" errors, return empty array instead of throwing - // These are expected for new contracts or when no documents exist - const isContractError = errorMessage.toLowerCase().includes('contract') - const isNotFoundError = errorMessage.toLowerCase().includes('not found') || - errorMessage.toLowerCase().includes('no documents') - const isKindNotFound = errorKind === 'NotFound' || errorKind === 'not_found' - const isCodeNotFound = errorCode === 'NOT_FOUND' || errorCode === 'not_found' - - if (isContractError || isNotFoundError || isKindNotFound || isCodeNotFound) { - logger.info('DashPlatformClient: Expected error (contract/not found), returning empty posts array') - return [] - } - - // Re-throw other errors - throw queryError - } - } catch (error: any) { - // Extract error message from WasmSdkError or regular Error - let errorMessage = 'Unknown error' - if (error && typeof error.message === 'string') { - errorMessage = error.message - } else if (error instanceof Error) { - errorMessage = error.message - } - logger.error('DashPlatformClient: _executePostsQuery failed:', errorMessage, { - code: error?.code, - kind: error?.kind - }) - throw error - } - } - - /** - * Clear the posts cache and pending queries - */ - clearPostsCache() { - this.postsCache.clear() - this.pendingQueries.clear() - logger.info('DashPlatformClient: Posts cache and pending queries cleared') - } - - /** - * Get key type name - */ - private getKeyTypeName(type: number): string { - const types = ['ECDSA_SECP256K1', 'BLS12_381', 'ECDSA_HASH160', 'BIP13_SCRIPT_HASH', 'EDDSA_25519_HASH160'] - return types[type] || 'UNKNOWN' - } - - /** - * Get key purpose name - */ - private getKeyPurposeName(purpose: number): string { - const purposes = ['AUTHENTICATION', 'ENCRYPTION', 'DECRYPTION', 'TRANSPORT', 'SYSTEM', 'VOTING'] - return purposes[purpose] || 'UNKNOWN' - } - - /** - * Get security level name - */ - private getSecurityLevelName(level: number): string { - const levels = ['MASTER', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] - return levels[level] || 'UNKNOWN' - } -} - -// Singleton instance -let dashClient: DashPlatformClient | null = null - -export function getDashPlatformClient(): DashPlatformClient { - if (!dashClient) { - dashClient = new DashPlatformClient() - } - return dashClient -} - -// Reset the client (useful for handling errors) -export function resetDashPlatformClient(): void { - if (dashClient) { - dashClient = null - } -} diff --git a/lib/feed/load-for-you-feed.ts b/lib/feed/load-for-you-feed.ts index 0cd22115..7bd05809 100644 --- a/lib/feed/load-for-you-feed.ts +++ b/lib/feed/load-for-you-feed.ts @@ -1,12 +1,25 @@ import { logger } from '@/lib/logger'; -import { getDashPlatformClient } from '@/lib/dash-platform-client'; +import { postService } from '@/lib/services/post-service'; import { Post } from '@/lib/types'; import { enrichPostsWithRepostsAndQuotes } from './enrich-posts'; -import { sortFeedByTimestamp, transformRawPost } from './transform-raw-post'; +import { sortFeedByTimestamp } from './transform-raw-post'; + +/** + * Timeline documents arrive with `createDefaultUser` placeholders + * (`hasDpns: false`, "Unknown User"). The feed renders before enrichment, and + * PostCard reads `hasDpns === undefined` as "still resolving" (skeleton) versus + * `false` as "no DPNS name" (identity-id button), so the placeholder is reset + * to the loading shape here to avoid a flash of identity ids on every card. + */ +function withLoadingAuthor(post: Post): Post { + return { + ...post, + author: { ...post.author, username: '', displayName: '', avatar: '', hasDpns: undefined }, + }; +} export async function loadForYouFeed(options: { startAfter?: string; - forceRefresh: boolean; feedLanguage?: string; setData: (updater: (prev: Post[] | null) => Post[] | null) => void; setHasMore: (value: boolean) => void; @@ -15,7 +28,7 @@ export async function loadForYouFeed(options: { }): Promise<{ posts: Post[]; cursor: string | null; hasMore: boolean }> { const MIN_NON_REPLY_POSTS = 20; const MAX_FETCH_ITERATIONS = 5; - const dashClient = getDashPlatformClient(); + const PAGE_SIZE = 20; const currentStartAfter = options.startAfter; @@ -25,12 +38,11 @@ export async function loadForYouFeed(options: { '(iteration 1)' ); - const firstBatchRaw = await dashClient.queryPosts({ - limit: 20, - forceRefresh: options.forceRefresh, + const firstBatchRaw = (await postService.getTimeline({ + limit: PAGE_SIZE, startAfter: currentStartAfter, language: options.feedLanguage, - }); + })).documents; if (firstBatchRaw.length === 0) { logger.info('Feed: No posts available'); @@ -42,16 +54,13 @@ export async function loadForYouFeed(options: { // enrichment merge falls back to the ORIGINAL post for ids missing from the // enriched result, so filtering only inside enrichPostsWithRepostsAndQuotes // would let deleted posts reappear. `deleted` is never set on v2. - const firstBatchPosts = firstBatchRaw - .map((doc) => transformRawPost(doc as Record)) - .filter((post) => !post.deleted); - const firstBatchCursor = (firstBatchRaw[firstBatchRaw.length - 1].$id || - firstBatchRaw[firstBatchRaw.length - 1].id) as string; + const firstBatchPosts = firstBatchRaw.filter((post) => !post.deleted).map(withLoadingAuthor); + const firstBatchCursor = firstBatchRaw[firstBatchRaw.length - 1].id; logger.info(`Feed: First batch has ${firstBatchPosts.length} posts`); const forYouNextCursor: string | null = firstBatchCursor; - const forYouHasMore = firstBatchRaw.length === 20; + const forYouHasMore = firstBatchRaw.length === PAGE_SIZE; enrichPostsWithRepostsAndQuotes(firstBatchPosts) .then((enrichedPosts) => { @@ -79,17 +88,16 @@ export async function loadForYouFeed(options: { while ( allPostCount < MIN_NON_REPLY_POSTS && bgFetchIteration < MAX_FETCH_ITERATIONS && - bgLastBatchSize === 20 + bgLastBatchSize === PAGE_SIZE ) { bgFetchIteration++; logger.info(`Feed: Loading posts starting after ${bgCurrentStartAfter} (iteration ${bgFetchIteration})`); - const bgRawPosts = await dashClient.queryPosts({ - limit: 20, - forceRefresh: false, + const bgRawPosts = (await postService.getTimeline({ + limit: PAGE_SIZE, startAfter: bgCurrentStartAfter, language: options.feedLanguage, - }); + })).documents; bgLastBatchSize = bgRawPosts.length; @@ -99,9 +107,7 @@ export async function loadForYouFeed(options: { break; } - const bgPosts = bgRawPosts - .map((doc) => transformRawPost(doc as Record)) - .filter((post) => !post.deleted); + const bgPosts = bgRawPosts.filter((post) => !post.deleted).map(withLoadingAuthor); enrichPostsWithRepostsAndQuotes(bgPosts) .then((enrichedPosts) => { @@ -117,8 +123,7 @@ export async function loadForYouFeed(options: { allPostCount += bgPosts.length; - const lastPost = bgRawPosts[bgRawPosts.length - 1]; - bgCurrentStartAfter = (lastPost.$id || lastPost.id) as string; + bgCurrentStartAfter = bgRawPosts[bgRawPosts.length - 1].id; options.setData((currentItems) => { if (!currentItems) return bgPosts; @@ -139,7 +144,7 @@ export async function loadForYouFeed(options: { } } - options.setHasMore(bgLastBatchSize === 20); + options.setHasMore(bgLastBatchSize === PAGE_SIZE); logger.info(`Feed: Background fetch complete. Total posts: ${allPostCount}`); }; diff --git a/lib/mock-data.ts b/lib/mock-data.ts index e002585a..36020fb6 100644 --- a/lib/mock-data.ts +++ b/lib/mock-data.ts @@ -1,13 +1,7 @@ /** - * Centralized mock data and default value utilities. - * - * This module provides: - * - Default avatar URL generation (local DiceBear) - * - Mock/default user creation for development - * - Factory functions for creating test data + * Default placeholder avatar generation (local DiceBear). */ -import { User } from './types'; import { generateAvatarDataUri } from './services/avatar-generator'; /** @@ -18,37 +12,3 @@ export function getDefaultAvatarUrl(userId: string): string { if (!userId) return ''; return generateAvatarDataUri('thumbs', userId); } - -/** - * Create a default/placeholder user object. - * Useful for fallback when profile data is unavailable. - */ -export function createDefaultUser(userId: string, overrides?: Partial): User { - return { - id: userId || 'unknown', - username: '', - displayName: 'Unknown User', - avatar: getDefaultAvatarUrl(userId), - followers: 0, - following: 0, - verified: false, - joinedAt: new Date(), - ...overrides, - }; -} - -/** - * Mock user for development/testing. - * Used when not connected to Dash Platform. - */ -export const mockCurrentUser: User = { - id: '1', - username: 'alexchen', - displayName: 'Alex Chen', - avatar: getDefaultAvatarUrl('1'), - bio: 'Building the future of social media', - followers: 1234, - following: 567, - verified: true, - joinedAt: new Date('2024-01-01'), -}; diff --git a/lib/post-helpers.test.ts b/lib/post-helpers.test.ts new file mode 100644 index 00000000..644a9b04 --- /dev/null +++ b/lib/post-helpers.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { + cashtagDisplayToStorage, + cashtagStorageToDisplay, + extractAllTags, + extractCashtags, + extractHashtags, + extractMentions, + firstHashtag, + getTagDisplayText, + isCashtagStorage, + normalizeDpnsUsername, +} from './post-helpers' + +describe('hashtags', () => { + it('extracts lowercase, deduplicated tags without the prefix', () => { + expect(extractHashtags('#Dash and #dash and #Platform!')).toEqual(['dash', 'platform']) + }) + + it('returns the first tag in storage form', () => { + expect(firstHashtag('hello #Second #first')).toBe('second') + expect(firstHashtag('no tags here')).toBe('') + }) + + it('truncates the first tag to the contract ceiling', () => { + const long = 'a'.repeat(70) + expect(firstHashtag(`#${long}`, 61)).toBe('a'.repeat(61)) + expect(firstHashtag(`#${long}`)).toBe('a'.repeat(63)) + }) +}) + +describe('cashtags', () => { + it('stores cashtags with a suffix so they share the hashtag index', () => { + expect(extractCashtags('buy $DASH not $dash or $1bad')).toEqual(['dash_cashtag']) + expect(isCashtagStorage('dash_cashtag')).toBe(true) + expect(isCashtagStorage('dash')).toBe(false) + }) + + it('converts between display and storage forms', () => { + expect(cashtagDisplayToStorage('$DASH')).toBe('dash_cashtag') + expect(cashtagDisplayToStorage('Dash')).toBe('dash_cashtag') + expect(cashtagStorageToDisplay('dash_cashtag')).toBe('DASH') + expect(cashtagStorageToDisplay('dash')).toBe('dash') + expect(getTagDisplayText('dash_cashtag')).toBe('$DASH') + expect(getTagDisplayText('dash')).toBe('#dash') + }) + + it('merges hashtags and cashtags into one tag list', () => { + expect(extractAllTags('#dash $DASH #dash')).toEqual(['dash', 'dash_cashtag']) + }) +}) + +describe('mentions', () => { + it('normalizes DPNS usernames', () => { + expect(normalizeDpnsUsername('Pasta.dash')).toBe('pasta') + expect(normalizeDpnsUsername('PASTA')).toBe('pasta') + }) + + it('extracts deduplicated mentions with or without .dash', () => { + expect(extractMentions('hi @Alice.dash and @alice, cc @bob_2')).toEqual(['alice', 'bob_2']) + expect(extractMentions('email me at foo@bar.com')).toEqual(['bar']) + }) +}) diff --git a/lib/services/avatar-generator.ts b/lib/services/avatar-generator.ts index 718e4124..988bb0bb 100644 --- a/lib/services/avatar-generator.ts +++ b/lib/services/avatar-generator.ts @@ -6,10 +6,9 @@ import * as collection from '@dicebear/collection'; const avatarCache = new Map(); const MAX_CACHE_SIZE = 500; // Limit cache to prevent memory bloat -// Map of style names to their DiceBear collection modules -// Using Style as the generic type since each style has different options -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const styleMap: Record> = { +// Map of style names to their DiceBear collection modules. Every style is a +// Style; only the seed is passed, so the common shape suffices. +const styleMap: Record> = { 'adventurer': collection.adventurer, 'adventurer-neutral': collection.adventurerNeutral, 'avataaars': collection.avataaars, diff --git a/lib/services/cart-service.ts b/lib/services/cart-service.ts index f7c09cdd..cb65ae34 100644 --- a/lib/services/cart-service.ts +++ b/lib/services/cart-service.ts @@ -26,7 +26,7 @@ class CartService { /** * Load cart from localStorage */ - private loadCart(): void { + private loadCart(): Cart { try { const stored = localStorage.getItem(CART_STORAGE_KEY); if (stored) { @@ -42,6 +42,7 @@ class CartService { logger.error('Failed to load cart from localStorage'); this.cart = { items: [], updatedAt: new Date() }; } + return this.cart; } /** @@ -88,10 +89,7 @@ class CartService { * Get current cart */ getCart(): Cart { - if (!this.cart) { - this.loadCart(); - } - return this.cart!; + return this.cart ?? this.loadCart(); } /** diff --git a/lib/services/notification-service.ts b/lib/services/notification-service.ts index 6f3f49e2..60984546 100644 --- a/lib/services/notification-service.ts +++ b/lib/services/notification-service.ts @@ -2,7 +2,7 @@ import { logger } from '@/lib/logger'; import { getEvoSdk } from './evo-sdk-service'; import { dpnsService } from './dpns-service'; import { unifiedProfileService } from './unified-profile-service'; -import { normalizeSDKResponse, identifierToBase58, queryDocuments, QueryDocumentsOptions } from './sdk-helpers'; +import { identifierToBase58, queryDocuments, QueryDocumentsOptions } from './sdk-helpers'; import { YAPPR_CONTRACT_ID } from '../constants'; import { Notification, User, Post } from '../../types'; import { truncateId } from '../utils'; @@ -86,8 +86,7 @@ class NotificationService { try { const sdk = await getEvoSdk(); - // SDK query types are incomplete, cast needed for valid query options - const response = await sdk.documents.query({ + const documents = await queryDocuments(sdk, { dataContractId: YAPPR_CONTRACT_ID, documentTypeName: 'follow', where: [ @@ -96,15 +95,13 @@ class NotificationService { ], orderBy: [['followingId', 'asc'], ['$createdAt', 'asc']], limit: NOTIFICATION_QUERY_LIMIT - } as any); - - const documents = normalizeSDKResponse(response); + }); - return documents.map((doc: any) => ({ - id: doc.$id, + return documents.map((doc) => ({ + id: doc.$id as string, type: 'follow' as const, - fromUserId: doc.$ownerId, // The follower - createdAt: doc.$createdAt + fromUserId: doc.$ownerId as string, // The follower + createdAt: doc.$createdAt as number })); } catch (error) { logger.error('Error fetching new followers:', error); @@ -130,7 +127,7 @@ class NotificationService { // Query followRequest documents where this user is the target (feed owner) // This discovers incoming private feed access requests - const response = await sdk.documents.query({ + const documents = await queryDocuments(sdk, { dataContractId: YAPPR_CONTRACT_ID, documentTypeName: 'followRequest', where: [ @@ -139,15 +136,13 @@ class NotificationService { ], orderBy: [['targetId', 'asc'], ['$createdAt', 'asc']], limit: NOTIFICATION_QUERY_LIMIT - } as any); - - const documents = normalizeSDKResponse(response); + }); - return documents.map((doc: any) => ({ - id: doc.$id, + return documents.map((doc) => ({ + id: doc.$id as string, type: 'privateFeedRequest' as const, - fromUserId: doc.$ownerId, // The requester - createdAt: doc.$createdAt + fromUserId: doc.$ownerId as string, // The requester + createdAt: doc.$createdAt as number })); } catch (error) { logger.error('Error fetching private feed request notifications:', error); @@ -259,8 +254,7 @@ class NotificationService { try { const sdk = await getEvoSdk(); - // SDK query types are incomplete, cast needed for valid query options - const response = await sdk.documents.query({ + const documents = await queryDocuments(sdk, { dataContractId: YAPPR_CONTRACT_ID, documentTypeName: 'postMention', where: [ @@ -269,20 +263,17 @@ class NotificationService { ], orderBy: [['mentionedUserId', 'asc'], ['$createdAt', 'asc']], limit: NOTIFICATION_QUERY_LIMIT - } as any); - - const documents = normalizeSDKResponse(response); + }); - return documents.map((doc: any) => { - const rawPostId = doc.postId || (doc.data?.postId); - const postId = rawPostId ? identifierToBase58(rawPostId) : undefined; + return documents.map((doc) => { + const postId = doc.postId ? identifierToBase58(doc.postId) : undefined; return { - id: doc.$id, + id: doc.$id as string, type: 'mention' as const, - fromUserId: doc.$ownerId, // The post author who mentioned the user + fromUserId: doc.$ownerId as string, // The post author who mentioned the user postId: postId || undefined, - createdAt: doc.$createdAt + createdAt: doc.$createdAt as number }; }); } catch (error) { @@ -353,9 +344,7 @@ class NotificationService { // Collect unique user IDs and post IDs const userIds = Array.from(new Set(rawNotifications.map(n => n.fromUserId))); const postIds = Array.from(new Set( - rawNotifications - .filter(n => n.postId) - .map(n => n.postId!) + rawNotifications.flatMap(n => (n.postId ? [n.postId] : [])) )); // Batch fetch all required data in parallel with fault tolerance diff --git a/lib/services/pagination-utils.ts b/lib/services/pagination-utils.ts index 0e5110fb..a0f20188 100644 --- a/lib/services/pagination-utils.ts +++ b/lib/services/pagination-utils.ts @@ -32,9 +32,17 @@ export interface PaginateFetchResult { reachedLimit: boolean; } -// Use any for SDK type since EvoSDK has complex generic typing -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type SDK = any; +/** + * The slice of the SDK these helpers touch. The grouped-count and raw-where + * shapes built here predate the SDK's query typings, so the surface is kept + * structural: any EvoSDK instance satisfies it, and nothing else is assumed. + */ +interface SDK { + documents: { + count(query: unknown): Promise; + query(query: unknown): Promise; + }; +} /** * Dash Platform caps `in` clauses (and per-query limits) at 100 values — @@ -98,7 +106,9 @@ export async function documentCount( ): Promise { const result = await sdk.documents.count(query); // SDK returns Map; '' is the grand total when no groupBy is set. - const total = result instanceof Map ? result.get('') : result?.['']; // tolerate plain-object shape + const total = result instanceof Map + ? result.get('') + : (result as Record | null | undefined)?.['']; // tolerate plain-object shape if (total === undefined || total === null) { // Zero-count branches aren't materialized in the platform's count trees, so // a genuine 0 comes back as an EMPTY map with no grand-total key. Only warn diff --git a/lib/store.ts b/lib/store.ts index fe1e8e6a..3ccc103a 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -1,7 +1,6 @@ import { create } from 'zustand' import { persist } from 'zustand/middleware' -import { User, Post } from './types' -import { mockCurrentUser } from './mock-data' +import { Post } from './types' import { ProgressiveEnrichment } from '@/components/post/post-card' import type { ReadingMode, FontSizeLevel } from '@/lib/blog/reader-preferences' import { scopedKey } from '@/lib/storage-scope' @@ -24,7 +23,6 @@ export interface PendingPostNavigation { } interface AppState { - currentUser: User | null isComposeOpen: boolean replyingTo: Post | null quotingPost: Post | null @@ -34,7 +32,6 @@ interface AppState { // Pending navigation data (set when clicking post, consumed on detail page mount) pendingPostNavigation: PendingPostNavigation | null - setCurrentUser: (user: User | null) => void setComposeOpen: (open: boolean) => void setReplyingTo: (post: Post | null) => void setQuotingPost: (post: Post | null) => void @@ -60,7 +57,6 @@ const createInitialThreadPost = (): ThreadPost => ({ }) export const useAppStore = create((set, get) => ({ - currentUser: mockCurrentUser, isComposeOpen: false, replyingTo: null, quotingPost: null, @@ -68,25 +64,14 @@ export const useAppStore = create((set, get) => ({ activeThreadPostId: null, pendingPostNavigation: null, - setCurrentUser: (user) => set({ currentUser: user }), setComposeOpen: (open) => { - if (open) { - // Reset thread posts when opening modal - const initialPost = createInitialThreadPost() - set({ - isComposeOpen: open, - threadPosts: [initialPost], - activeThreadPostId: initialPost.id - }) - } else { - // Reset thread posts when closing modal to prevent stale state - const initialPost = createInitialThreadPost() - set({ - isComposeOpen: false, - threadPosts: [initialPost], - activeThreadPostId: initialPost.id - }) - } + // Reset thread drafts on both open and close so no stale draft survives. + const initialPost = createInitialThreadPost() + set({ + isComposeOpen: open, + threadPosts: [initialPost], + activeThreadPostId: initialPost.id + }) }, setReplyingTo: (post) => set({ replyingTo: post }), setQuotingPost: (post) => set({ quotingPost: post }), diff --git a/lib/upload/inventory-parser.ts b/lib/upload/inventory-parser.ts index 0db31ece..41dd1247 100644 --- a/lib/upload/inventory-parser.ts +++ b/lib/upload/inventory-parser.ts @@ -465,10 +465,12 @@ function groupRows(rows: ParsedInventoryRow[]): GroupedInventoryItem[] { for (const row of rows) { const groupId = row.group || `__ungrouped_${ungroupedIndex++}` - if (!groups.has(groupId)) { - groups.set(groupId, []) + let group = groups.get(groupId) + if (!group) { + group = [] + groups.set(groupId, group) } - groups.get(groupId)!.push(row) + group.push(row) } const items: GroupedInventoryItem[] = [] diff --git a/lib/utils/compression.test.ts b/lib/utils/compression.test.ts new file mode 100644 index 00000000..f4caa635 --- /dev/null +++ b/lib/utils/compression.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { compressContent, decompressContent, getCompressedSize, joinChunks, splitIntoChunks } from './compression' + +describe('compression', () => { + it('round-trips JSON content', () => { + const doc = { blocks: [{ type: 'paragraph', text: 'x'.repeat(2000) }], n: 1 } + const packed = compressContent(doc) + expect(packed.byteLength).toBeLessThan(JSON.stringify(doc).length) + expect(decompressContent(packed)).toEqual(doc) + expect(getCompressedSize(doc)).toBe(packed.byteLength) + }) + + it('returns null for corrupt input instead of throwing', () => { + expect(decompressContent(new Uint8Array([1, 2, 3]))).toBeNull() + }) + + it('throws on unserializable input', () => { + const cyclic: Record = {} + cyclic.self = cyclic + expect(() => compressContent(cyclic)).toThrow('Failed to compress') + }) +}) + +describe('chunking', () => { + const data = Uint8Array.from({ length: 10 }, (_, i) => i) + + it('splits into fixed-size chunks with a short tail', () => { + const chunks = splitIntoChunks(data, 4) + expect(chunks.map((c) => Array.from(c))).toEqual([[0, 1, 2, 3], [4, 5, 6, 7], [8, 9]]) + }) + + it('joins chunks back into the original', () => { + expect(joinChunks(splitIntoChunks(data, 3))).toEqual(data) + }) + + it('stops at the first missing chunk', () => { + const [a, , c] = splitIntoChunks(data, 4) + expect(Array.from(joinChunks([a, null, c]))).toEqual([0, 1, 2, 3]) + expect(Array.from(joinChunks([a, new Uint8Array(0), c]))).toEqual([0, 1, 2, 3]) + }) + + it('handles empty and single-chunk input', () => { + expect(joinChunks([]).byteLength).toBe(0) + expect(joinChunks([undefined])).toEqual(new Uint8Array(0)) + expect(joinChunks([data])).toBe(data) + }) +}) diff --git a/lib/utils/contact-methods.ts b/lib/utils/contact-methods.ts deleted file mode 100644 index 8c3c3f76..00000000 --- a/lib/utils/contact-methods.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { SocialLink, LegacyStoreContactMethods } from '@/lib/types' - -/** - * Convert legacy StoreContactMethods object to SocialLink[] format - * Used for backward compatibility when loading old store data - */ -export function legacyContactMethodsToSocialLinks(methods: LegacyStoreContactMethods | undefined): SocialLink[] { - if (!methods) return [] - - const links: SocialLink[] = [] - if (methods.email) links.push({ platform: 'email', handle: methods.email }) - if (methods.signal) links.push({ platform: 'signal', handle: methods.signal }) - if (methods.twitter) links.push({ platform: 'twitter', handle: methods.twitter }) - if (methods.telegram) links.push({ platform: 'telegram', handle: methods.telegram }) - - return links -} diff --git a/lib/utils/slug.test.ts b/lib/utils/slug.test.ts new file mode 100644 index 00000000..55b9cbd8 --- /dev/null +++ b/lib/utils/slug.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { generateSlug, isValidSlug } from './slug' + +describe('generateSlug', () => { + it('lowercases, strips accents and punctuation, and hyphenates spaces', () => { + expect(generateSlug(' Héllo, Wörld! It’s Yappr ')).toBe('hello-world-its-yappr') + }) + + it('collapses runs of hyphens and trims them from the ends', () => { + expect(generateSlug('--a -- b--')).toBe('a-b') + }) + + it('truncates to 63 characters without a trailing hyphen', () => { + const slug = generateSlug(Array.from({ length: 40 }, () => 'ab').join(' ')) + expect(slug.length).toBeLessThanOrEqual(63) + expect(slug.endsWith('-')).toBe(false) + expect(isValidSlug(slug)).toBe(true) + }) + + it('falls back to a timestamp slug when nothing survives', () => { + expect(generateSlug('日本語')).toMatch(/^post-[0-9a-z]+$/) + expect(generateSlug('🎉🎉')).toMatch(/^post-[0-9a-z]+$/) + }) +}) + +describe('isValidSlug', () => { + it('accepts lowercase alphanumerics separated by single hyphens', () => { + expect(isValidSlug('a')).toBe(true) + expect(isValidSlug('hello-world-2')).toBe(true) + }) + + it('rejects everything else', () => { + expect(isValidSlug('')).toBe(false) + expect(isValidSlug('Hello')).toBe(false) + expect(isValidSlug('a--b')).toBe(false) + expect(isValidSlug('-a')).toBe(false) + expect(isValidSlug('a'.repeat(64))).toBe(false) + }) +}) diff --git a/next.config.js b/next.config.js index d113206b..22a2eaab 100644 --- a/next.config.js +++ b/next.config.js @@ -36,7 +36,6 @@ const nextConfig = { output: 'export', images: { unoptimized: true, - domains: ['images.unsplash.com'], }, webpack: (config, { isServer }) => { // Optimize EvoSDK bundle size diff --git a/package-lock.json b/package-lock.json index 883dcc52..7c33e69a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@blocknote/mantine": "^0.47.0", "@blocknote/react": "^0.47.0", "@dashevo/evo-sdk": "4.2.0-dev.8", + "@dashevo/wasm-sdk": "4.2.0-dev.8", "@dicebear/collection": "^9.2.4", "@dicebear/core": "^9.2.4", "@emoji-mart/data": "^1.2.1", @@ -29,11 +30,12 @@ "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-radio-group": "^1.3.7", - "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", + "@storacha/access": "^1.6.9", "@storacha/client": "^2.0.0", + "@ucanto/principal": "^9.0.3", "bs58": "^6.0.0", "bs58check": "^4.0.0", "class-variance-authority": "^0.7.1", @@ -68,9 +70,11 @@ "autoprefixer": "^10.0.1", "eslint": "^8", "eslint-config-next": "14.1.0", + "knip": "^5.88.1", "postcss": "^8", "tailwindcss": "^3.3.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.2.7" } }, "node_modules/@alloc/quick-lru": { @@ -731,21 +735,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", "dev": true, "license": "MIT", "optional": true, @@ -754,9 +758,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -2015,6 +2019,299 @@ "node": ">=12.4.0" } }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz", + "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz", + "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz", + "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz", + "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz", + "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz", + "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz", + "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz", + "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz", + "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz", + "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz", + "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz", + "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz", + "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", + "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", + "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz", + "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz", + "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz", + "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz", + "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@perma/map": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@perma/map/-/map-1.0.3.tgz", @@ -2116,12 +2413,6 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, - "node_modules/@radix-ui/number": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", - "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", - "license": "MIT" - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -3125,77 +3416,6 @@ } } }, - "node_modules/@radix-ui/react-slider": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", - "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", - "license": "MIT", - "dependencies": { - "@radix-ui/number": "1.1.1", - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.3" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", @@ -4555,9 +4775,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, "license": "MIT", "optional": true, @@ -4565,6 +4785,17 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -4574,6 +4805,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz", @@ -5264,6 +5502,121 @@ "win32" ] }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@web3-storage/data-segment": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@web3-storage/data-segment/-/data-segment-5.3.0.tgz", @@ -5635,6 +5988,16 @@ "node": ">=0.10.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -6141,6 +6504,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -6188,6 +6568,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -6532,6 +6922,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -6928,6 +7328,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -7644,6 +8051,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -7654,6 +8071,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -7751,6 +8178,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -7867,6 +8304,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -9601,18 +10054,97 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/knip": { + "version": "5.88.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.88.1.tgz", + "integrity": "sha512-tpy5o7zu1MjawVkLPuahymVJekYY3kYjvzcoInhIchgePxTlo+api90tBv2KfhAIe5uXh+mez1tAfmbv8/TiZg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "@nodelib/fs.walk": "^1.2.3", + "fast-glob": "^3.3.3", + "formatly": "^0.3.0", + "jiti": "^2.6.0", + "minimist": "^1.2.8", + "oxc-resolver": "^11.19.1", + "picocolors": "^1.1.1", + "picomatch": "^4.0.1", + "smol-toml": "^1.5.2", + "strip-json-comments": "5.0.3", + "unbash": "^2.2.0", + "yaml": "^2.8.2", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": ">=18.18.0" + }, + "peerDependencies": { + "@types/node": ">=18", + "typescript": ">=5.0.4 <7" + } + }, + "node_modules/knip/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/knip/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "node_modules/knip/node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/language-subtag-registry": { @@ -9780,6 +10312,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -11275,6 +11814,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/oxc-resolver": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", + "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.24.2", + "@oxc-resolver/binding-android-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-x64": "11.24.2", + "@oxc-resolver/binding-freebsd-x64": "11.24.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-musl": "11.24.2", + "@oxc-resolver/binding-openharmony-arm64": "11.24.2", + "@oxc-resolver/binding-wasm32-wasi": "11.24.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" + } + }, "node_modules/p-defer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-4.0.1.tgz", @@ -11479,6 +12049,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13206,6 +13786,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -13229,6 +13816,19 @@ "node": ">=8" } }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -13301,6 +13901,20 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -13601,6 +14215,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stubborn-fs": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", @@ -13829,6 +14463,13 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -13884,6 +14525,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-buffer": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", @@ -14183,6 +14854,16 @@ "multiformats": "^13.0.0" } }, + "node_modules/unbash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-2.2.0.tgz", + "integrity": "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -14578,12 +15259,237 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -14721,6 +15627,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -14890,6 +15813,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs-parser": { "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", @@ -14930,6 +15869,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz", + "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", diff --git a/package.json b/package.json index 85733bcf..e2fea162 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,10 @@ "build:testing": "set -a && . ./.env.testing && set +a && BASE_PATH=/testing next build", "build:devnet": "set -a && . ./.env.devnet && set +a && BASE_PATH=/devnet next build", "start": "next start", - "lint": "next lint", + "lint": "next lint --dir app --dir components --dir contexts --dir hooks --dir lib --dir types --max-warnings 0", + "lint:dead": "knip", + "test": "vitest run", + "test:watch": "vitest", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui" }, @@ -18,6 +21,7 @@ "@blocknote/mantine": "^0.47.0", "@blocknote/react": "^0.47.0", "@dashevo/evo-sdk": "4.2.0-dev.8", + "@dashevo/wasm-sdk": "4.2.0-dev.8", "@dicebear/collection": "^9.2.4", "@dicebear/core": "^9.2.4", "@emoji-mart/data": "^1.2.1", @@ -35,11 +39,12 @@ "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-radio-group": "^1.3.7", - "@radix-ui/react-slider": "^1.3.5", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tooltip": "^1.0.7", + "@storacha/access": "^1.6.9", "@storacha/client": "^2.0.0", + "@ucanto/principal": "^9.0.3", "bs58": "^6.0.0", "bs58check": "^4.0.0", "class-variance-authority": "^0.7.1", @@ -74,8 +79,10 @@ "autoprefixer": "^10.0.1", "eslint": "^8", "eslint-config-next": "14.1.0", + "knip": "^5.88.1", "postcss": "^8", "tailwindcss": "^3.3.0", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.2.7" } } diff --git a/serve.py b/serve.py deleted file mode 100755 index 3bc80da8..00000000 --- a/serve.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 -""" -Python server for yappr with proper WASM support and CORS headers. -This serves the built Next.js application with the headers needed for WASM to work. -""" - -import http.server -import socketserver -import os -import json -from urllib.parse import urlparse - -PORT = 3000 -DIRECTORY = os.path.dirname(os.path.abspath(__file__)) - -class YapprHTTPRequestHandler(http.server.SimpleHTTPRequestHandler): - def __init__(self, *args, **kwargs): - super().__init__(*args, directory=DIRECTORY, **kwargs) - - def do_GET(self): - """Handle GET requests with Next.js routing support""" - parsed_path = urlparse(self.path) - file_path = parsed_path.path - - # Handle Next.js static files from .next/static - if file_path.startswith('/_next/static/'): - # Serve static files from .next directory - self.path = file_path - return super().do_GET() - - # Handle WASM files from dash-wasm directory - if file_path.startswith('/dash-wasm/'): - # Serve WASM files with correct headers - return super().do_GET() - - # Handle API routes and other static assets - if (file_path.startswith('/api/') or - file_path.endswith('.js') or - file_path.endswith('.css') or - file_path.endswith('.png') or - file_path.endswith('.jpg') or - file_path.endswith('.jpeg') or - file_path.endswith('.gif') or - file_path.endswith('.svg') or - file_path.endswith('.ico') or - file_path.endswith('.wasm')): - return super().do_GET() - - # For all other routes, serve index.html (SPA routing) - if os.path.exists(os.path.join(DIRECTORY, 'out', 'index.html')): - # Production build - serve from out directory - self.path = '/out/index.html' - elif os.path.exists(os.path.join(DIRECTORY, '.next')): - # Development - serve a basic HTML that loads the Next.js dev server - self.send_response(200) - self.send_header('Content-type', 'text/html') - self.end_headers() - html_content = """ - - - - - Yappr - - - -
- - - """ - self.wfile.write(html_content.encode()) - return - else: - # Fallback - self.send_error(404, "File not found") - return - - def end_headers(self): - """Add necessary headers for WASM and CORS support""" - path = self.path.lower() - - # Add CORS headers for WASM - these are critical! - self.send_header('Cross-Origin-Embedder-Policy', 'require-corp') - self.send_header('Cross-Origin-Opener-Policy', 'same-origin') - - # Set correct content type for WASM files - if path.endswith('.wasm'): - self.send_header('Content-Type', 'application/wasm') - # Cache WASM files - self.send_header('Cache-Control', 'public, max-age=604800') - - # Set correct content type for JS files - elif path.endswith('.js'): - self.send_header('Content-Type', 'application/javascript') - - # Allow cross-origin requests - self.send_header('Access-Control-Allow-Origin', '*') - self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') - self.send_header('Access-Control-Allow-Headers', 'Content-Type') - - super().end_headers() - -def main(): - # Check if we have a built application - has_build = os.path.exists(os.path.join(DIRECTORY, 'out')) or os.path.exists(os.path.join(DIRECTORY, '.next')) - - print("=" * 60) - print("🚀 YAPPR PYTHON SERVER") - print("=" * 60) - print(f"📁 Serving from: {DIRECTORY}") - print(f"🌐 Server URL: http://localhost:{PORT}") - print(f"📦 Build detected: {'Yes' if has_build else 'No'}") - print() - print("✅ WASM Headers: Cross-Origin-Embedder-Policy & Cross-Origin-Opener-Policy") - print("✅ CORS: Enabled for all origins") - print("✅ Content-Type: Proper WASM and JS content types") - print() - - if not has_build: - print("⚠️ WARNING: No build detected!") - print(" Run 'npm run build' to create a production build") - print(" Or run 'npm run dev' for development mode") - print() - - print("🔗 Open http://localhost:3000 in your browser") - print("⏹️ Press Ctrl+C to stop the server") - print("=" * 60) - - with socketserver.TCPServer(("", PORT), YapprHTTPRequestHandler) as httpd: - try: - httpd.serve_forever() - except KeyboardInterrupt: - print("\n\n🛑 Server stopped.") - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index 9d4ab25f..2ceb2231 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -2,7 +2,6 @@ module.exports = { darkMode: 'class', content: [ - './pages/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', './app/**/*.{js,ts,jsx,tsx,mdx}', ], diff --git a/tsconfig.json b/tsconfig.json index 2969efc0..70ba039a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es5", + "target": "es2020", "lib": [ "dom", "dom.iterable", diff --git a/types/index.ts b/types/index.ts index 4ce607e5..2f806054 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1,4 +1,3 @@ -export * from './sdk' export * from './user' export * from './post' export * from './store' diff --git a/types/sdk.ts b/types/sdk.ts deleted file mode 100644 index d1241679..00000000 --- a/types/sdk.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Type definitions for Dash Platform SDK responses and document structures. - * These types help eliminate `any` usage throughout the codebase and provide - * compile-time guarantees for SDK interactions. - */ - -/** - * Base document interface representing common fields from Dash Platform documents. - * All documents created on Dash Platform include these system fields. - */ -export interface BaseDocument { - /** Unique document identifier (base58 encoded) */ - $id: string; - /** Owner identity ID (base58 encoded) */ - $ownerId: string; - /** Creation timestamp in milliseconds */ - $createdAt: number; - /** Last update timestamp in milliseconds (optional for immutable documents) */ - $updatedAt?: number; - /** Document revision number for optimistic concurrency */ - $revision?: number; -} - -/** - * Generic document with additional data fields. - * Used when the document structure is known but varies by document type. - */ -export interface DocumentWithData> extends BaseDocument { - /** Nested data object (some SDK responses nest user fields here) */ - data?: T; -} - -/** - * Raw SDK document that may have a toJSON method. - * SDK documents often need to be converted to plain objects. - */ -export interface SDKDocument { - $id?: string; - $ownerId?: string; - $createdAt?: number; - $updatedAt?: number; - $revision?: number; - id?: string; - ownerId?: string; - createdAt?: number; - updatedAt?: number; - revision?: number; - data?: Record; - toJSON?: () => Record; -} - -/** - * SDK query response - can be a Map, Array, or object with documents property. - * The v3 SDK returns different response shapes depending on the query. - */ -export type SDKQueryResponse = - | Map - | SDKDocument[] - | { documents: SDKDocument[] } - | SDKDocument; - -/** - * Result of a state transition (create, update, delete document). - */ -export interface StateTransitionResult { - success: boolean; - transactionHash?: string; - document?: SDKDocument; - error?: string; -} - -/** - * SDK create document operation result. - */ -export interface SDKCreateResult { - document?: SDKDocument; - stateTransition?: { - $id?: string; - }; - transitionId?: string; -} - -/** - * SDK replace/update document operation result. - */ -export interface SDKReplaceResult { - document?: SDKDocument; - stateTransition?: { - $id?: string; - }; - transitionId?: string; -} - -/** - * SDK delete document operation result. - */ -export interface SDKDeleteResult { - stateTransition?: { - $id?: string; - }; - transitionId?: string; -} - -/** - * Identity public key information from SDK. - */ -export interface IdentityPublicKey { - id: number; - type: number; - purpose: number; - securityLevel: number; - data?: Uint8Array; - readOnly?: boolean; - signature?: Uint8Array; -} - -/** - * Identity information from SDK. - */ -export interface IdentityInfo { - id: string; - balance: number; - publicKeys: IdentityPublicKey[]; -} - -/** - * Identity balance response from SDK. - */ -export interface IdentityBalance { - confirmed: number; - pending?: number; -} - -/** - * DPNS domain document structure. - */ -export interface DPNSDomainDocument extends BaseDocument { - label: string; - normalizedLabel: string; - normalizedParentDomainName: string; - records: { - identity?: Uint8Array | string; - }; -} - -/** - * Profile document structure (Yappr contract). - */ -export interface ProfileDocumentData { - displayName?: string; - bio?: string; - location?: string; - website?: string; - avatarUri?: string; - bannerUri?: string; - paymentUris?: string[]; - socialLinks?: string[]; - pronouns?: string; - nsfw?: boolean; -} - -/** - * Post document structure (Yappr contract). - */ -export interface PostDocumentData { - content: string; - mediaUrl?: string; - replyToPostId?: string | Uint8Array; - quotedPostId?: string | Uint8Array; - language?: string; - sensitive?: boolean; - embedContractId?: string | Uint8Array; - embedDocType?: string; - embedId?: string | Uint8Array; -} - -/** - * Like document structure (Yappr contract). - */ -export interface LikeDocumentData { - postId: string | Uint8Array; -} - -/** - * Repost document structure (Yappr contract). - */ -export interface RepostDocumentData { - postId: string | Uint8Array; -} - -/** - * Follow document structure (Yappr contract). - */ -export interface FollowDocumentData { - followingId: string | Uint8Array; -} - -/** - * Bookmark document structure (Yappr contract). - */ -export interface BookmarkDocumentData { - postId: string | Uint8Array; -} - -/** - * Block document structure (Yappr contract). - */ -export interface BlockDocumentData { - blockedId: string | Uint8Array; - message?: string; -} - -/** - * Direct message document structure (Yappr contract). - */ -export interface DirectMessageDocumentData { - conversationId: Uint8Array; - encryptedContent: Uint8Array; -} - -/** - * Conversation invite document structure (Yappr contract). - */ -export interface ConversationInviteDocumentData { - recipientId: Uint8Array; - conversationId: Uint8Array; - senderPubKey?: Uint8Array; -} - -/** - * Type guard to check if a value is an SDK document with toJSON method. - */ -export function hasToJSON(value: unknown): value is { toJSON: () => Record } { - return typeof value === 'object' && value !== null && 'toJSON' in value && typeof (value as { toJSON: unknown }).toJSON === 'function'; -} - -/** - * Type guard to check if a response is a Map. - */ -export function isMapResponse(response: unknown): response is Map { - return response instanceof Map; -} - -/** - * Type guard to check if a response has a documents property. - */ -export function hasDocumentsProperty(response: unknown): response is { documents: SDKDocument[] } { - return typeof response === 'object' && response !== null && 'documents' in response && Array.isArray((response as { documents: unknown }).documents); -} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..42438744 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config' +import path from 'node:path' + +// Unit tests cover the pure modules under lib/ (crypto primitives, codecs, +// parsers). Anything that touches the SDK or the DOM belongs in e2e/. +export default defineConfig({ + resolve: { + alias: { '@': path.resolve(__dirname) }, + }, + test: { + environment: 'node', + include: ['lib/**/*.test.ts'], + }, +})