diff --git a/.eslintrc.json b/.eslintrc.json index fdf1eb50..b1bfadf6 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,4 +1,5 @@ { + "root": true, "extends": [ "next/core-web-vitals", "plugin:@typescript-eslint/recommended" diff --git a/app/item/page.tsx b/app/item/page.tsx index 8078c5b0..40a606f4 100644 --- a/app/item/page.tsx +++ b/app/item/page.tsx @@ -7,7 +7,8 @@ import { motion } from 'framer-motion' import { ArrowLeftIcon, ShoppingCartIcon, - BuildingStorefrontIcon + BuildingStorefrontIcon, + NoSymbolIcon } from '@heroicons/react/24/outline' import { CheckIcon } from '@heroicons/react/24/solid' import { Sidebar } from '@/components/layout/sidebar' @@ -22,6 +23,7 @@ import { useSettingsStore } from '@/lib/store' import { storeService } from '@/lib/services/store-service' import { storeItemService } from '@/lib/services/store-item-service' import { cartService } from '@/lib/services/cart-service' +import { useBlock } from '@/hooks/use-block' import type { Store, StoreItem } from '@/lib/types' function LoadingFallback() { @@ -63,6 +65,9 @@ function ItemDetailContent() { const [cartItemCount, setCartItemCount] = useState(0) const addedToCartTimeoutRef = useRef | null>(null) + // Check if store owner is blocked + const { isBlocked: isOwnerBlocked, isOwnBlock, isLoading: isBlockLoading, toggleBlock } = useBlock(store?.ownerId ?? '', { resolveProvenance: true }) + // Cleanup timeout on unmount useEffect(() => { return () => { @@ -289,6 +294,45 @@ function ItemDetailContent() { {/* Image Gallery */} + {/* Blocked Store Owner Banner */} + {isOwnerBlocked && ( +
+
+ +
+

+ {isOwnBlock ? 'You have blocked this store owner' : 'This store owner is blocked'} +

+

+ {isOwnBlock + ? "Items from this store won't appear in browse listings." + : "Blocked by a block list you follow. Items from this store won't appear in browse listings."} +

+
+ {isOwnBlock ? ( + + ) : ( + + )} +
+
+ )} + {/* Item Info */}
{/* Store Link */} diff --git a/app/store/page.tsx b/app/store/page.tsx index 8d4fe12c..19d0bf43 100644 --- a/app/store/page.tsx +++ b/app/store/page.tsx @@ -1,7 +1,7 @@ 'use client' import { logger } from '@/lib/logger'; -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' import { useRouter } from 'next/navigation' import { motion } from 'framer-motion' import { @@ -21,6 +21,7 @@ import { useSdk } from '@/contexts/sdk-context' import { useSettingsStore } from '@/lib/store' import { storeService } from '@/lib/services/store-service' import { storeReviewService } from '@/lib/services/store-review-service' +import { checkBlockedForAuthors } from '@/hooks/use-block' import type { Store, StoreRatingSummary } from '@/lib/types' export default function StoreBrowsePage() { @@ -30,6 +31,8 @@ export default function StoreBrowsePage() { const potatoMode = useSettingsStore((s) => s.potatoMode) const [stores, setStores] = useState([]) const [storeRatings, setStoreRatings] = useState>(new Map()) + const [blockedOwners, setBlockedOwners] = useState>(new Map()) + const [blockedResolvedKey, setBlockedResolvedKey] = useState('') const [isLoading, setIsLoading] = useState(true) const [searchQuery, setSearchQuery] = useState('') const [hasStore, setHasStore] = useState(false) @@ -79,13 +82,69 @@ export default function StoreBrowsePage() { loadStores().catch((error) => logger.error(error)) }, [sdkReady]) - // Filter stores by search query - const filteredStores = searchQuery - ? stores.filter(store => + // Identifies which (identity, store set) the blockedOwners state was resolved for + const blockCheckKey = user?.identityId && stores.length > 0 + ? `${user.identityId}:${stores.map(store => store.id).join(',')}` + : '' + + // Check which store owners are blocked + useEffect(() => { + if (!user?.identityId || stores.length === 0) { + setBlockedOwners(new Map()) + return + } + + let cancelled = false + const identityId = user.identityId + const key = blockCheckKey + + const checkBlockedOwners = async () => { + const ownerIds = stores.map(store => store.ownerId) + const blocked = await checkBlockedForAuthors(identityId, ownerIds) + if (!cancelled) { + setBlockedOwners(blocked) + setBlockedResolvedKey(key) + } + } + + checkBlockedOwners().catch((error) => { + logger.error('Failed to check blocked store owners:', error) + // Fail open: show stores rather than blocking the page forever + if (!cancelled) { + setBlockedOwners(new Map()) + setBlockedResolvedKey(key) + } + }) + + return () => { + cancelled = true + } + }, [user?.identityId, stores, blockCheckKey]) + + // Keep the list in its loading state until block status resolves for the + // current identity and store set, so blocked stores never flash as clickable. + // Guests have no blocks, so they skip this entirely. + const isBlockCheckPending = blockCheckKey !== '' && blockedResolvedKey !== blockCheckKey + + // Filter stores by search query and block status + const filteredStores = useMemo(() => { + let filtered = stores + + // Filter out stores owned by blocked users + if (blockedOwners.size > 0) { + filtered = filtered.filter(store => !blockedOwners.get(store.ownerId)) + } + + // Apply search filter + if (searchQuery) { + filtered = filtered.filter(store => store.name.toLowerCase().includes(searchQuery.toLowerCase()) || store.description?.toLowerCase().includes(searchQuery.toLowerCase()) ) - : stores + } + + return filtered + }, [stores, blockedOwners, searchQuery]) const handleStoreClick = (storeId: string) => { router.push(`/store/view?id=${storeId}`) @@ -149,7 +208,7 @@ export default function StoreBrowsePage() { {/* Store List */}
- {isLoading ? ( + {isLoading || isBlockCheckPending ? (

Loading stores...

diff --git a/app/store/view/page.tsx b/app/store/view/page.tsx index 7661fff1..7fe94048 100644 --- a/app/store/view/page.tsx +++ b/app/store/view/page.tsx @@ -12,6 +12,7 @@ import { MagnifyingGlassIcon, MapPinIcon, ShoppingCartIcon, + NoSymbolIcon, XMarkIcon } from '@heroicons/react/24/outline' import { Sidebar } from '@/components/layout/sidebar' @@ -28,6 +29,7 @@ import { storeItemService } from '@/lib/services/store-item-service' import { storeReviewService } from '@/lib/services/store-review-service' import { cartService } from '@/lib/services/cart-service' import { parseStorePolicies } from '@/lib/utils/policies' +import { useBlock } from '@/hooks/use-block' import { saveStoreViewCache, loadStoreViewCache } from '@/lib/caches/store-view-cache' import type { Store, StoreItem, StoreReview, StoreRatingSummary, StorePolicy } from '@/lib/types' @@ -85,6 +87,9 @@ function StoreDetailContent() { const restoredFromCache = useRef(false) const pendingScrollY = useRef(null) + // Check if store owner is blocked + const { isBlocked: isOwnerBlocked, isOwnBlock, isLoading: isBlockLoading, toggleBlock } = useBlock(store?.ownerId ?? '', { resolveProvenance: true }) + // Subscribe to cart changes useEffect(() => { const unsubscribe = cartService.subscribe(() => { @@ -462,6 +467,45 @@ function StoreDetailContent() {
+ {/* Blocked Store Banner */} + {isOwnerBlocked && ( +
+
+ +
+

+ {isOwnBlock ? 'You have blocked this store owner' : 'This store owner is blocked'} +

+

+ {isOwnBlock + ? "This store's products won't appear in browse listings." + : "Blocked by a block list you follow. This store's products won't appear in browse listings."} +

+
+ {isOwnBlock ? ( + + ) : ( + + )} +
+
+ )} + {/* Encryption key warning */} {sellerHasEncryptionKey === false && (
diff --git a/components/store/cart-store-section.tsx b/components/store/cart-store-section.tsx index 56fbf697..689329d0 100644 --- a/components/store/cart-store-section.tsx +++ b/components/store/cart-store-section.tsx @@ -3,11 +3,12 @@ import { forwardRef } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { useRouter } from 'next/navigation' -import { BuildingStorefrontIcon } from '@heroicons/react/24/outline' +import { BuildingStorefrontIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline' import { CartItemRow } from './cart-item-row' import { Button } from '@/components/ui/button' import { formatPrice } from '@/lib/utils/format' import { cartService } from '@/lib/services/cart-service' +import { useBlock } from '@/hooks/use-block' import type { CartItem, Store } from '@/lib/types' interface CartStoreSectionProps { @@ -21,6 +22,9 @@ export const CartStoreSection = forwardRef sum + item.unitPrice * item.quantity, 0) const currency = items[0]?.currency || 'USD' @@ -71,6 +75,20 @@ export const CartStoreSection = forwardRef
+ {/* Blocked Store Owner Warning */} + {isOwnerBlocked && ( +
+
+ +

+ {isOwnBlock + ? 'You have blocked this store owner. Consider removing these items.' + : 'This store owner is blocked by a block list you follow. Consider removing these items.'} +

+
+
+ )} + {/* Items */}
diff --git a/hooks/use-block.ts b/hooks/use-block.ts index 72309629..af46396f 100644 --- a/hooks/use-block.ts +++ b/hooks/use-block.ts @@ -12,9 +12,14 @@ import { clearBlockCache as clearSharedBlockCache, seedBlockStatusCache } from '@/lib/caches/user-status-cache' +import { getConfirmedBlock } from '@/lib/caches/block-cache' export interface UseBlockResult { isBlocked: boolean + /** Whether the block comes from the viewer's own block document */ + isOwnBlock: boolean + /** Identity whose followed block list blocks the target, if any */ + inheritedFrom: string | null isLoading: boolean toggleBlock: (message?: string) => Promise refresh: () => void @@ -23,16 +28,25 @@ export interface UseBlockResult { export interface UseBlockOptions { /** Initial block status from batch prefetch (skips initial query if provided) */ initialValue?: boolean + /** + * Always resolve exact provenance (own vs. inherited) with a platform query + * instead of trusting the confirmed-block cache. Use on pages that render + * provenance-dependent UI (e.g. Unblock vs. Manage block lists) and must + * handle a target blocked both directly and via a followed list. + */ + resolveProvenance?: boolean } /** * Hook to manage block state for a target user */ export function useBlock(targetUserId: string, options: UseBlockOptions = {}): UseBlockResult { - const { initialValue } = options + const { initialValue, resolveProvenance = false } = options const { user } = useAuth() const { open: openLoginPrompt } = useLoginPromptModal() const [isBlocked, setIsBlocked] = useState(initialValue ?? false) + const [isOwnBlock, setIsOwnBlock] = useState(false) + const [inheritedFrom, setInheritedFrom] = useState(null) // Only show loading if no initial value was provided const [isLoading, setIsLoading] = useState(initialValue === undefined) @@ -44,38 +58,62 @@ export function useBlock(targetUserId: string, options: UseBlockOptions = {}): U return } - // Skip initial fetch if initialValue was provided (unless force refresh) - if (initialValue !== undefined && !forceRefresh) { - return - } - - // Check shared cache unless forcing refresh - if (!forceRefresh && cacheKey) { - const cached = getBlockStatus(cacheKey) - if (cached !== null) { - setIsBlocked(cached) + if (!forceRefresh) { + // Known-negative statuses need no provenance lookup + if (initialValue === false) { + setIsLoading(false) + return + } + if (initialValue === undefined && cacheKey && getBlockStatus(cacheKey) === false) { + setIsBlocked(false) + setIsOwnBlock(false) + setInheritedFrom(null) setIsLoading(false) return } + + // Fast positive path: the confirmed-block cache (populated by batch + // checks and prior lookups) records who blocked the target, so trust it + // instead of re-querying - unless exact provenance was requested. Note + // an "own" entry may hide an additional inherited block (own takes + // precedence), which is why provenance-dependent UI opts out of this. + if (!resolveProvenance) { + const confirmed = getConfirmedBlock(user.identityId, targetUserId) + if (confirmed !== undefined) { + const own = confirmed.isBlocked && confirmed.blockedBy === user.identityId + setIsBlocked(confirmed.isBlocked) + setIsOwnBlock(own) + setInheritedFrom(confirmed.isBlocked && !own && confirmed.blockedBy ? confirmed.blockedBy : null) + if (cacheKey) { + setBlockStatus(cacheKey, confirmed.isBlocked) + } + setIsLoading(false) + return + } + } } + // Blocked or unknown status - resolve full provenance so callers know + // whether an unblock (deleting the own block document) can actually help setIsLoading(true) try { const { blockService } = await import('@/lib/services/block-service') - const blocked = await blockService.isBlocked(targetUserId, user.identityId) + const provenance = await blockService.getBlockProvenance(targetUserId, user.identityId) // Cache the result if (cacheKey) { - setBlockStatus(cacheKey, blocked) + setBlockStatus(cacheKey, provenance.isBlocked) } - setIsBlocked(blocked) + setIsBlocked(provenance.isBlocked) + setIsOwnBlock(provenance.isOwnBlock) + setInheritedFrom(provenance.inheritedFrom) } catch (error) { logger.error('useBlock: Error checking block status:', error) } finally { setIsLoading(false) } - }, [user?.identityId, targetUserId, cacheKey, initialValue]) + }, [user?.identityId, targetUserId, cacheKey, initialValue, resolveProvenance]) useEffect(() => { checkBlockStatus() @@ -94,14 +132,28 @@ export function useBlock(targetUserId: string, options: UseBlockOptions = {}): U } const wasBlocked = isBlocked + const wasOwnBlock = isOwnBlock + + if (wasBlocked && !wasOwnBlock && inheritedFrom !== null) { + // Inherited-only block: there is no own block document to delete, so an + // "unblock" here would be a silent no-op. It must be managed via block + // list follows in settings instead. (If provenance is still unresolved, + // fall through and attempt an own unblock as before.) + toast.error('This user is blocked by a block list you follow. Manage block lists in Settings.') + return + } + + // Removing an own block only helps fully if no inherited block remains + const blockedAfterToggle = wasBlocked ? inheritedFrom !== null : true // Optimistic update - setIsBlocked(!wasBlocked) + setIsBlocked(blockedAfterToggle) + setIsOwnBlock(!wasBlocked) setIsLoading(true) // Update cache optimistically if (cacheKey) { - setBlockStatus(cacheKey, !wasBlocked) + setBlockStatus(cacheKey, blockedAfterToggle) } try { @@ -117,7 +169,11 @@ export function useBlock(targetUserId: string, options: UseBlockOptions = {}): U // Show appropriate message based on whether auto-revocation occurred if (wasBlocked) { - toast.success('User unblocked') + if (blockedAfterToggle) { + toast.success('Your block was removed, but this user is still blocked by a block list you follow') + } else { + toast.success('User unblocked') + } } else if ('autoRevoked' in result && result.autoRevoked) { toast.success('User blocked and private feed access revoked') } else { @@ -126,6 +182,7 @@ export function useBlock(targetUserId: string, options: UseBlockOptions = {}): U } catch (error) { // Rollback setIsBlocked(wasBlocked) + setIsOwnBlock(wasOwnBlock) if (cacheKey) { setBlockStatus(cacheKey, wasBlocked) } @@ -134,7 +191,7 @@ export function useBlock(targetUserId: string, options: UseBlockOptions = {}): U } finally { setIsLoading(false) } - }, [user?.identityId, targetUserId, isBlocked, isLoading, cacheKey, openLoginPrompt]) + }, [user?.identityId, targetUserId, isBlocked, isOwnBlock, inheritedFrom, isLoading, cacheKey, openLoginPrompt]) const refresh = useCallback(() => { if (cacheKey) { @@ -143,7 +200,7 @@ export function useBlock(targetUserId: string, options: UseBlockOptions = {}): U checkBlockStatus(true) }, [cacheKey, checkBlockStatus]) - return { isBlocked, isLoading, toggleBlock, refresh } + return { isBlocked, isOwnBlock, inheritedFrom, isLoading, toggleBlock, refresh } } /** diff --git a/lib/services/block-service.ts b/lib/services/block-service.ts index 4a4f80db..57c4fc72 100644 --- a/lib/services/block-service.ts +++ b/lib/services/block-service.ts @@ -26,6 +26,20 @@ import bs58 from 'bs58' // Max users whose blocks can be followed (100 * 32 bytes = 3200 bytes) const MAX_BLOCK_FOLLOWS = 100 +/** + * Why a target user is blocked from the viewer's perspective. + * A target can be blocked by the viewer's own block document, by a block + * inherited from a followed block list, or by both at the same time. + */ +export interface BlockProvenance { + /** Whether the target is blocked at all (own or inherited) */ + isBlocked: boolean + /** Whether the viewer's own block document exists */ + isOwnBlock: boolean + /** Identity of the followed blocker whose list blocks the target, if any */ + inheritedFrom: string | null +} + /** * Block Service - Manages enhanced blocking with bloom filters and block following. * @@ -622,6 +636,48 @@ class BlockService extends BaseDocumentService { return false } + /** + * Resolve full block provenance for a target: whether the viewer's own block + * document exists and/or a block is inherited from a followed block list. + * Unlike isBlocked(), this always checks both sources so callers can offer + * the correct remedy (delete own block vs. manage block list follows). + */ + async getBlockProvenance(targetUserId: string, viewerId: string): Promise { + if (!viewerId || !targetUserId || viewerId === targetUserId) { + return { isBlocked: false, isOwnBlock: false, inheritedFrom: null } + } + + try { + const [ownBlock, followedBlockers] = await Promise.all([ + this.getBlock(targetUserId, viewerId), + this.getBlockFollows(viewerId) + ]) + + const inherited = followedBlockers.length > 0 + ? await this.checkInheritedBlocks(targetUserId, followedBlockers) + : null + + // Keep the confirmed-block cache in sync (own block takes precedence, + // matching isBlocked() behavior) + if (ownBlock) { + addConfirmedBlock(viewerId, targetUserId, viewerId, true, ownBlock.message) + } else if (inherited) { + addConfirmedBlock(viewerId, targetUserId, inherited.blockedBy, true, inherited.message) + } else { + addConfirmedBlock(viewerId, targetUserId, '', false) + } + + return { + isBlocked: Boolean(ownBlock) || inherited !== null, + isOwnBlock: Boolean(ownBlock), + inheritedFrom: inherited?.blockedBy ?? null + } + } catch (error) { + logger.error('Error getting block provenance:', error) + return { isBlocked: false, isOwnBlock: false, inheritedFrom: null } + } + } + /** * Check if target is blocked by any of the followed blockers. * Note: Must query each blocker individually since the index only supports