= {}
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
{!loaded && loadingFallback}
- {/* eslint-disable-next-line @next/next/no-img-element */}
}
@@ -180,7 +179,6 @@ export function ProfileImageUpload({
}
// Regular http(s) URL
- // eslint-disable-next-line @next/next/no-img-element
return
}
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