{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "social-feed",
  "title": "Social Feed",
  "author": "Satchmo",
  "description": "Chronological feed of on-chain BSocial posts with avatar, author name, timestamp, content, like/reply actions, infinite scroll pagination, and channel filtering via the 1sat-stack API",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "avatar",
    "badge",
    "separator",
    "skeleton"
  ],
  "files": [
    {
      "path": "registry/new-york/blocks/social-feed/index.tsx",
      "content": "\"use client\"\n\nimport { SocialFeedUI, type SocialFeedUIProps } from \"./social-feed-ui\"\nimport {\n  useSocialFeed,\n  type UseSocialFeedOptions,\n  type UseSocialFeedReturn,\n  type SocialPost,\n  type PostSigner,\n  type AuthorProfile,\n} from \"./use-social-feed\"\n\n// ---------------------------------------------------------------------------\n// Re-exports\n// ---------------------------------------------------------------------------\n\nexport { PostCardUI, type PostCardUIProps } from \"./post-card-ui\"\nexport { SocialFeedUI, type SocialFeedUIProps } from \"./social-feed-ui\"\nexport {\n  useSocialFeed,\n  type UseSocialFeedOptions,\n  type UseSocialFeedReturn,\n  type SocialPost,\n  type PostSigner,\n  type AuthorProfile,\n} from \"./use-social-feed\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SocialFeedProps\n  extends Omit<\n    SocialFeedUIProps,\n    | \"posts\"\n    | \"isLoading\"\n    | \"isLoadingMore\"\n    | \"error\"\n    | \"hasMore\"\n    | \"onLoadMore\"\n    | \"onRefresh\"\n  > {\n  /** Channel to filter posts by */\n  channel?: string\n  /** Search query string */\n  query?: string\n  /** Number of posts per page (default: 20) */\n  limit?: number\n  /** Base URL for the 1sat-stack API (default: https://api.1sat.app) */\n  apiUrl?: string\n  /** Whether to auto-fetch on mount (default: true) */\n  autoFetch?: boolean\n}\n\n// ---------------------------------------------------------------------------\n// Composed component\n// ---------------------------------------------------------------------------\n\n/**\n * A chronological feed of on-chain BSocial posts.\n *\n * Fetches posts from the 1sat-stack API and renders them in a vertical feed\n * with avatar, author name, timestamp, content, and action buttons.\n * Supports infinite scroll pagination, channel filtering, and custom\n * like button rendering via the `renderLikeButton` prop.\n *\n * @example\n * ```tsx\n * import { SocialFeed } from \"@/components/blocks/social-feed\"\n *\n * <SocialFeed\n *   channel=\"general\"\n *   onPostClick={(post) => console.log(\"Clicked:\", post.txid)}\n *   onAuthorClick={(post) => console.log(\"Author:\", post.signers?.[0])}\n * />\n * ```\n *\n * @example\n * ```tsx\n * // With custom like button\n * import { SocialFeed } from \"@/components/blocks/social-feed\"\n * import { LikeButton } from \"@/components/blocks/like-button\"\n *\n * <SocialFeed\n *   renderLikeButton={(post) => (\n *     <LikeButton\n *       txid={post.txid}\n *       count={post.likes ?? 0}\n *       variant=\"text\"\n *       onLike={async (txid) => {\n *         // broadcast BSocial like tx\n *         return { txid: \"...\" }\n *       }}\n *     />\n *   )}\n * />\n * ```\n */\nexport function SocialFeed({\n  channel,\n  query,\n  limit,\n  apiUrl,\n  autoFetch,\n  ...uiProps\n}: SocialFeedProps) {\n  const feed = useSocialFeed({\n    channel,\n    query,\n    limit,\n    apiUrl,\n    autoFetch,\n  })\n\n  return (\n    <SocialFeedUI\n      posts={feed.posts}\n      isLoading={feed.isLoading}\n      isLoadingMore={feed.isLoadingMore}\n      error={feed.error}\n      hasMore={feed.hasMore}\n      onLoadMore={feed.loadMore}\n      onRefresh={feed.refresh}\n      {...uiProps}\n    />\n  )\n}\n",
      "type": "registry:block",
      "target": "~/components/blocks/social-feed/index.tsx"
    },
    {
      "path": "registry/new-york/blocks/social-feed/post-card-ui.tsx",
      "content": "\"use client\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Avatar, AvatarFallback, AvatarImage } from \"@/components/ui/avatar\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Heart, MessageCircle, ExternalLink } from \"lucide-react\"\nimport type { SocialPost } from \"./use-social-feed\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface PostCardUIProps {\n  /** The post data to render */\n  post: SocialPost\n  /** Optional CSS classes */\n  className?: string\n  /** Called when the post card is clicked */\n  onPostClick?: (post: SocialPost) => void\n  /** Called when the author row is clicked */\n  onAuthorClick?: (post: SocialPost) => void\n  /** Slot for a custom like button component (e.g. the LikeButton block) */\n  likeButtonSlot?: React.ReactNode\n  /** Whether to show the reply button */\n  showReplyButton?: boolean\n  /** Called when the reply button is clicked */\n  onReplyClick?: (post: SocialPost) => void\n  /** Callback to handle external links (e.g. open in system browser from a WebView) */\n  onExternalLink?: (url: string) => void\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** Format a unix timestamp (seconds) to a relative time string */\nfunction formatRelativeTime(timestampSeconds: number): string {\n  const now = Date.now() / 1000\n  const diff = now - timestampSeconds\n\n  if (diff < 60) return \"just now\"\n  if (diff < 3600) {\n    const mins = Math.floor(diff / 60)\n    return `${mins}m ago`\n  }\n  if (diff < 86400) {\n    const hours = Math.floor(diff / 3600)\n    return `${hours}h ago`\n  }\n  if (diff < 604800) {\n    const days = Math.floor(diff / 86400)\n    return `${days}d ago`\n  }\n\n  const date = new Date(timestampSeconds * 1000)\n  return date.toLocaleDateString(undefined, {\n    month: \"short\",\n    day: \"numeric\",\n    year:\n      date.getFullYear() !== new Date().getFullYear() ? \"numeric\" : undefined,\n  })\n}\n\n/** Derive a display name from a post's signer or author data */\nfunction getDisplayName(post: SocialPost): string {\n  if (post.author?.name) return post.author.name\n  if (post.signers?.[0]?.bapId) {\n    const bapId = post.signers[0].bapId\n    return `${bapId.slice(0, 6)}...${bapId.slice(-4)}`\n  }\n  if (post.signers?.[0]?.address) {\n    const addr = post.signers[0].address\n    return `${addr.slice(0, 6)}...${addr.slice(-4)}`\n  }\n  return \"Anonymous\"\n}\n\n/** Derive initials for the avatar fallback */\nfunction getInitials(name: string): string {\n  if (name === \"Anonymous\") return \"?\"\n  // If it looks like a truncated hash/address, use the first two chars\n  if (name.includes(\"...\")) return name.slice(0, 2).toUpperCase()\n  const parts = name.trim().split(/\\s+/)\n  if (parts.length >= 2) {\n    return `${parts[0][0]}${parts[1][0]}`.toUpperCase()\n  }\n  return name.slice(0, 2).toUpperCase()\n}\n\n/** Build an ORDFS URL for embedded media */\nfunction getMediaUrl(outpoint: string): string {\n  return `https://ordfs.network/content/${outpoint}`\n}\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\n\nexport function PostCardUI({\n  post,\n  className,\n  onPostClick,\n  onAuthorClick,\n  likeButtonSlot,\n  showReplyButton = true,\n  onReplyClick,\n  onExternalLink,\n}: PostCardUIProps) {\n  const displayName = getDisplayName(post)\n  const initials = getInitials(displayName)\n  const avatarUrl = post.author?.avatar\n  const isClickable = Boolean(onPostClick)\n\n  return (\n    <article\n      className={cn(\n        \"group relative flex gap-3 px-4 py-3\",\n        isClickable &&\n          \"cursor-pointer hover:bg-accent/50 transition-colors duration-150\",\n        className\n      )}\n      onClick={onPostClick ? () => onPostClick(post) : undefined}\n      role={isClickable ? \"button\" : undefined}\n      tabIndex={isClickable ? 0 : undefined}\n      onKeyDown={\n        isClickable\n          ? (e) => {\n              if (e.key === \"Enter\" || e.key === \" \") {\n                e.preventDefault()\n                onPostClick?.(post)\n              }\n            }\n          : undefined\n      }\n      data-testid=\"post-card\"\n    >\n      {/* Avatar column */}\n      <div className=\"shrink-0 pt-0.5\">\n        <button\n          type=\"button\"\n          className=\"rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n          onClick={(e) => {\n            e.stopPropagation()\n            onAuthorClick?.(post)\n          }}\n          disabled={!onAuthorClick}\n          aria-label={`View profile of ${displayName}`}\n        >\n          <Avatar className=\"size-8\">\n            {avatarUrl && <AvatarImage src={avatarUrl} alt={displayName} />}\n            <AvatarFallback>{initials}</AvatarFallback>\n          </Avatar>\n        </button>\n      </div>\n\n      {/* Content column */}\n      <div className=\"flex-1 min-w-0 flex flex-col gap-1.5\">\n        {/* Author row */}\n        <div className=\"flex items-center gap-2 min-w-0\">\n          <button\n            type=\"button\"\n            className={cn(\n              \"truncate text-sm font-semibold text-foreground\",\n              onAuthorClick &&\n                \"hover:underline focus-visible:outline-none focus-visible:underline\"\n            )}\n            onClick={(e) => {\n              e.stopPropagation()\n              onAuthorClick?.(post)\n            }}\n            disabled={!onAuthorClick}\n          >\n            {displayName}\n          </button>\n\n          {post.channel && (\n            <Badge\n              variant=\"secondary\"\n              className=\"shrink-0 text-[10px] leading-tight px-1.5 py-0\"\n            >\n              #{post.channel}\n            </Badge>\n          )}\n\n          <time\n            dateTime={new Date(post.timestamp * 1000).toISOString()}\n            className=\"shrink-0 text-xs text-muted-foreground ml-auto\"\n            title={new Date(post.timestamp * 1000).toLocaleString()}\n          >\n            {formatRelativeTime(post.timestamp)}\n          </time>\n        </div>\n\n        {/* Post content */}\n        <div className=\"text-sm text-foreground whitespace-pre-wrap break-words leading-relaxed\">\n          {post.content}\n        </div>\n\n        {/* Embedded media */}\n        {post.mediaOutpoint && (\n          <div className=\"mt-2 overflow-hidden rounded-lg border border-border\">\n            <img\n              src={getMediaUrl(post.mediaOutpoint)}\n              alt=\"Embedded media\"\n              className=\"max-h-80 w-auto object-contain\"\n              loading=\"lazy\"\n            />\n          </div>\n        )}\n\n        {/* Action bar */}\n        <div className=\"flex items-center gap-1 pt-1 -ml-1.5\">\n          {/* Like button slot or default */}\n          {likeButtonSlot ?? (\n            <span\n              className={cn(\n                \"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted-foreground\",\n                \"transition-colors duration-150\"\n              )}\n              aria-label={`${post.likes ?? 0} likes`}\n            >\n              <Heart className=\"size-3.5\" aria-hidden=\"true\" />\n              {(post.likes ?? 0) > 0 && (\n                <span className=\"tabular-nums\">{post.likes}</span>\n              )}\n            </span>\n          )}\n\n          {/* Reply button */}\n          {showReplyButton && (\n            <button\n              type=\"button\"\n              className={cn(\n                \"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted-foreground\",\n                \"hover:bg-accent hover:text-accent-foreground transition-colors duration-150\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n              )}\n              onClick={(e) => {\n                e.stopPropagation()\n                onReplyClick?.(post)\n              }}\n              disabled={!onReplyClick}\n              aria-label={`${post.replies ?? 0} replies`}\n            >\n              <MessageCircle className=\"size-3.5\" aria-hidden=\"true\" />\n              {(post.replies ?? 0) > 0 && (\n                <span className=\"tabular-nums\">{post.replies}</span>\n              )}\n            </button>\n          )}\n\n          {/* View on-chain link */}\n          {onExternalLink ? (\n            <button\n              type=\"button\"\n              className={cn(\n                \"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted-foreground\",\n                \"hover:bg-accent hover:text-accent-foreground transition-colors duration-150\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                \"ml-auto\"\n              )}\n              onClick={(e) => {\n                e.stopPropagation()\n                onExternalLink(`https://whatsonchain.com/tx/${post.txid}`)\n              }}\n              aria-label=\"View transaction on-chain\"\n            >\n              <ExternalLink className=\"size-3\" aria-hidden=\"true\" />\n            </button>\n          ) : (\n            <a\n              href={`https://whatsonchain.com/tx/${post.txid}`}\n              target=\"_blank\"\n              rel=\"noopener noreferrer\"\n              className={cn(\n                \"inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs text-muted-foreground\",\n                \"hover:bg-accent hover:text-accent-foreground transition-colors duration-150\",\n                \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n                \"ml-auto\"\n              )}\n              onClick={(e) => e.stopPropagation()}\n              aria-label=\"View transaction on-chain\"\n            >\n              <ExternalLink className=\"size-3\" aria-hidden=\"true\" />\n            </a>\n          )}\n        </div>\n      </div>\n    </article>\n  )\n}\n",
      "type": "registry:component",
      "target": "~/components/blocks/social-feed/post-card-ui.tsx"
    },
    {
      "path": "registry/new-york/blocks/social-feed/social-feed-ui.tsx",
      "content": "\"use client\"\n\nimport { useCallback, useEffect, useRef } from \"react\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { Separator } from \"@/components/ui/separator\"\nimport { Skeleton } from \"@/components/ui/skeleton\"\nimport { AlertCircle, Loader2, MessageSquare, RefreshCw } from \"lucide-react\"\nimport { PostCardUI, type PostCardUIProps } from \"./post-card-ui\"\nimport type { SocialPost } from \"./use-social-feed\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface SocialFeedUIProps {\n  /** Array of posts to display */\n  posts: SocialPost[]\n  /** Whether the initial fetch is in progress */\n  isLoading: boolean\n  /** Whether a \"load more\" request is in progress */\n  isLoadingMore: boolean\n  /** Error from the most recent fetch */\n  error: Error | null\n  /** Whether more posts are available to load */\n  hasMore: boolean\n  /** Fetch the next page of posts */\n  onLoadMore: () => void\n  /** Re-fetch the feed from the beginning */\n  onRefresh: () => void\n  /** Optional CSS classes for the outer container */\n  className?: string\n  /** Called when a post card is clicked */\n  onPostClick?: (post: SocialPost) => void\n  /** Called when an author is clicked */\n  onAuthorClick?: (post: SocialPost) => void\n  /** Called when the reply button is clicked */\n  onReplyClick?: (post: SocialPost) => void\n  /** Whether to use IntersectionObserver for infinite scroll (default: true) */\n  infiniteScroll?: boolean\n  /** Render function for a custom like button per post */\n  renderLikeButton?: (post: SocialPost) => React.ReactNode\n  /** Render function for custom post card content */\n  renderPostCard?: (\n    post: SocialPost,\n    defaultProps: PostCardUIProps\n  ) => React.ReactNode\n  /** Callback to handle external links (e.g. open in system browser from a WebView) */\n  onExternalLink?: (url: string) => void\n}\n\n// ---------------------------------------------------------------------------\n// Skeleton loader\n// ---------------------------------------------------------------------------\n\nfunction PostCardSkeleton() {\n  return (\n    <div className=\"flex gap-3 px-4 py-3\" data-testid=\"post-card-skeleton\">\n      <Skeleton className=\"size-8 rounded-full shrink-0\" />\n      <div className=\"flex flex-1 flex-col gap-2\">\n        <div className=\"flex items-center gap-2\">\n          <Skeleton className=\"h-4 w-24\" />\n          <Skeleton className=\"h-3 w-12 ml-auto\" />\n        </div>\n        <Skeleton className=\"h-4 w-full\" />\n        <Skeleton className=\"h-4 w-3/4\" />\n        <div className=\"flex gap-3 pt-1\">\n          <Skeleton className=\"size-5 rounded-full\" />\n          <Skeleton className=\"size-5 rounded-full\" />\n        </div>\n      </div>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Empty state\n// ---------------------------------------------------------------------------\n\nfunction EmptyState({ onRefresh }: { onRefresh: () => void }) {\n  return (\n    <div\n      className=\"flex flex-col items-center justify-center py-16 px-4 text-center\"\n      data-testid=\"social-feed-empty\"\n    >\n      <div className=\"mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-muted\">\n        <MessageSquare className=\"size-6 text-muted-foreground\" />\n      </div>\n      <p className=\"text-sm font-medium text-foreground\">No posts yet</p>\n      <p className=\"mt-1 text-sm text-muted-foreground\">\n        Be the first to post something on-chain\n      </p>\n      <Button variant=\"outline\" size=\"sm\" className=\"mt-4\" onClick={onRefresh}>\n        <RefreshCw data-icon=\"inline-start\" />\n        Refresh\n      </Button>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Error state\n// ---------------------------------------------------------------------------\n\nfunction ErrorState({\n  error,\n  onRetry,\n}: {\n  error: Error\n  onRetry: () => void\n}) {\n  return (\n    <div\n      className=\"flex flex-col items-center justify-center py-12 px-4 text-center\"\n      role=\"alert\"\n      data-testid=\"social-feed-error\"\n    >\n      <div className=\"mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-destructive/10\">\n        <AlertCircle className=\"size-6 text-destructive\" />\n      </div>\n      <p className=\"text-sm font-medium text-foreground\">\n        Failed to load posts\n      </p>\n      <p className=\"mt-1 text-sm text-muted-foreground max-w-sm\">\n        {error.message}\n      </p>\n      <Button variant=\"outline\" size=\"sm\" className=\"mt-4\" onClick={onRetry}>\n        Try again\n      </Button>\n    </div>\n  )\n}\n\n// ---------------------------------------------------------------------------\n// Component\n// ---------------------------------------------------------------------------\n\nexport function SocialFeedUI({\n  posts,\n  isLoading,\n  isLoadingMore,\n  error,\n  hasMore,\n  onLoadMore,\n  onRefresh,\n  className,\n  onPostClick,\n  onAuthorClick,\n  onReplyClick,\n  infiniteScroll = true,\n  renderLikeButton,\n  renderPostCard,\n  onExternalLink,\n}: SocialFeedUIProps) {\n  // Infinite scroll via IntersectionObserver\n  const sentinelRef = useRef<HTMLDivElement>(null)\n\n  const handleIntersect = useCallback(\n    (entries: IntersectionObserverEntry[]) => {\n      const entry = entries[0]\n      if (entry?.isIntersecting && hasMore && !isLoading && !isLoadingMore) {\n        onLoadMore()\n      }\n    },\n    [hasMore, isLoading, isLoadingMore, onLoadMore]\n  )\n\n  useEffect(() => {\n    if (!infiniteScroll) return\n    const sentinel = sentinelRef.current\n    if (!sentinel) return\n\n    const observer = new IntersectionObserver(handleIntersect, {\n      rootMargin: \"200px\",\n    })\n    observer.observe(sentinel)\n    return () => observer.disconnect()\n  }, [infiniteScroll, handleIntersect])\n\n  // Initial loading state\n  if (isLoading && posts.length === 0) {\n    return (\n      <div className={cn(\"divide-y divide-border\", className)} data-testid=\"social-feed\">\n        <PostCardSkeleton />\n        <PostCardSkeleton />\n        <PostCardSkeleton />\n      </div>\n    )\n  }\n\n  // Error state (no posts loaded yet)\n  if (error && posts.length === 0) {\n    return (\n      <div className={className} data-testid=\"social-feed\">\n        <ErrorState error={error} onRetry={onRefresh} />\n      </div>\n    )\n  }\n\n  // Empty state\n  if (!isLoading && posts.length === 0) {\n    return (\n      <div className={className} data-testid=\"social-feed\">\n        <EmptyState onRefresh={onRefresh} />\n      </div>\n    )\n  }\n\n  return (\n    <div className={cn(\"relative\", className)} data-testid=\"social-feed\">\n      {/* Post list */}\n      <div className=\"divide-y divide-border\">\n        {posts.map((post) => {\n          const defaultProps: PostCardUIProps = {\n            post,\n            onPostClick,\n            onAuthorClick,\n            onReplyClick,\n            likeButtonSlot: renderLikeButton?.(post),\n            onExternalLink,\n          }\n\n          return renderPostCard ? (\n            <div key={post.txid}>{renderPostCard(post, defaultProps)}</div>\n          ) : (\n            <PostCardUI key={post.txid} {...defaultProps} />\n          )\n        })}\n      </div>\n\n      {/* Load more area */}\n      <div className=\"py-4\">\n        {isLoadingMore && (\n          <div className=\"flex items-center justify-center gap-2 py-3 text-sm text-muted-foreground\">\n            <Loader2 className=\"size-4 animate-spin\" />\n            Loading more posts...\n          </div>\n        )}\n\n        {/* Error loading more (posts already visible) */}\n        {error && posts.length > 0 && (\n          <div className=\"flex items-center justify-center gap-2 py-3\">\n            <p className=\"text-sm text-destructive\">{error.message}</p>\n            <Button variant=\"ghost\" size=\"sm\" onClick={onLoadMore}>\n              Retry\n            </Button>\n          </div>\n        )}\n\n        {/* Manual load more button (when infinite scroll is disabled) */}\n        {!infiniteScroll && hasMore && !isLoadingMore && !error && (\n          <div className=\"flex justify-center\">\n            <Button variant=\"outline\" size=\"sm\" onClick={onLoadMore}>\n              Load more\n            </Button>\n          </div>\n        )}\n\n        {/* End of feed */}\n        {!hasMore && posts.length > 0 && (\n          <div className=\"flex items-center justify-center gap-3 py-2\">\n            <Separator className=\"flex-1 max-w-16\" />\n            <p className=\"text-xs text-muted-foreground\">End of feed</p>\n            <Separator className=\"flex-1 max-w-16\" />\n          </div>\n        )}\n\n        {/* Infinite scroll sentinel */}\n        {infiniteScroll && hasMore && (\n          <div ref={sentinelRef} className=\"h-1\" aria-hidden=\"true\" />\n        )}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "~/components/blocks/social-feed/social-feed-ui.tsx"
    },
    {
      "path": "registry/new-york/blocks/social-feed/use-social-feed.ts",
      "content": "import { useCallback, useEffect, useRef, useState } from \"react\"\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\n/** A signer entry attached to a post (AIP identity) */\nexport interface PostSigner {\n  /** Algorithm identifier (e.g. \"BITCOIN_ECDSA\") */\n  algorithm: string\n  /** Bitcoin address of the signer */\n  address: string\n  /** BAP identity ID, if present */\n  bapId?: string\n}\n\n/** Author profile resolved from the BAP API */\nexport interface AuthorProfile {\n  /** BAP identity ID */\n  bapId: string\n  /** Display name */\n  name?: string\n  /** Profile image URL or on-chain reference */\n  avatar?: string\n  /** Bitcoin address */\n  address?: string\n}\n\n/** A single BSocial post as returned by the 1sat-stack API */\nexport interface SocialPost {\n  /** Transaction ID of the post */\n  txid: string\n  /** Post content text */\n  content: string\n  /** Timestamp in seconds since epoch */\n  timestamp: number\n  /** App name from MAP protocol */\n  app: string\n  /** Action type (post, reply, etc.) */\n  type: string\n  /** Channel the post belongs to, if any */\n  channel?: string\n  /** Signer/author information from AIP */\n  signers?: PostSigner[]\n  /** Resolved author profile (populated client-side) */\n  author?: AuthorProfile\n  /** Number of likes on this post */\n  likes?: number\n  /** Number of replies to this post */\n  replies?: number\n  /** Media outpoint if post has embedded media */\n  mediaOutpoint?: string\n}\n\n/** Raw post shape from 1sat-stack search endpoint */\ninterface RawSearchPost {\n  txid: string\n  B?: { content?: string; \"content-type\"?: string; encoding?: string }\n  MAP?: {\n    app?: string\n    type?: string\n    context?: string\n    contextValue?: string\n    channel?: string\n  }\n  timestamp?: number\n  blk?: { t?: number }\n  likes?: number\n  replies?: number\n}\n\n/** Raw post shape from 1sat-stack address endpoint (includes signers) */\ninterface RawAddressPost extends RawSearchPost {\n  AIP?: Array<{\n    algorithm?: string\n    address?: string\n    bapId?: string\n  }>\n}\n\nexport interface UseSocialFeedOptions {\n  /** Channel to filter posts by */\n  channel?: string\n  /** Search query string */\n  query?: string\n  /** Number of posts per page (default: 20) */\n  limit?: number\n  /** Base URL for the 1sat-stack API (default: https://api.1sat.app) */\n  apiUrl?: string\n  /** Whether to auto-fetch on mount (default: true) */\n  autoFetch?: boolean\n}\n\nexport interface UseSocialFeedReturn {\n  /** Array of fetched posts */\n  posts: SocialPost[]\n  /** Whether the initial fetch is in progress */\n  isLoading: boolean\n  /** Whether a \"load more\" request is in progress */\n  isLoadingMore: boolean\n  /** Error from the most recent fetch, if any */\n  error: Error | null\n  /** Whether more posts are available beyond the current page */\n  hasMore: boolean\n  /** Fetch the next page of posts */\n  loadMore: () => Promise<void>\n  /** Re-fetch from the beginning */\n  refresh: () => Promise<void>\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\nconst DEFAULT_API_URL = \"https://api.1sat.app\"\nconst DEFAULT_LIMIT = 20\n\nfunction normalizePost(raw: RawSearchPost | RawAddressPost): SocialPost {\n  const signers: PostSigner[] = []\n  if (\"AIP\" in raw && Array.isArray(raw.AIP)) {\n    for (const signer of raw.AIP) {\n      signers.push({\n        algorithm: signer.algorithm ?? \"BITCOIN_ECDSA\",\n        address: signer.address ?? \"\",\n        bapId: signer.bapId,\n      })\n    }\n  }\n\n  return {\n    txid: raw.txid,\n    content: raw.B?.content ?? \"\",\n    timestamp: raw.blk?.t ?? raw.timestamp ?? 0,\n    app: raw.MAP?.app ?? \"bsocial\",\n    type: raw.MAP?.type ?? \"post\",\n    channel: raw.MAP?.channel ?? raw.MAP?.contextValue,\n    signers: signers.length > 0 ? signers : undefined,\n    likes: raw.likes ?? 0,\n    replies: raw.replies ?? 0,\n  }\n}\n\n// ---------------------------------------------------------------------------\n// Hook\n// ---------------------------------------------------------------------------\n\nexport function useSocialFeed({\n  channel,\n  query,\n  limit = DEFAULT_LIMIT,\n  apiUrl = DEFAULT_API_URL,\n  autoFetch = true,\n}: UseSocialFeedOptions = {}): UseSocialFeedReturn {\n  const [posts, setPosts] = useState<SocialPost[]>([])\n  const [isLoading, setIsLoading] = useState(false)\n  const [isLoadingMore, setIsLoadingMore] = useState(false)\n  const [error, setError] = useState<Error | null>(null)\n  const [hasMore, setHasMore] = useState(true)\n  const offsetRef = useRef(0)\n  const abortRef = useRef<AbortController | null>(null)\n\n  const fetchPosts = useCallback(\n    async (offset: number, isRefresh: boolean) => {\n      abortRef.current?.abort()\n      const controller = new AbortController()\n      abortRef.current = controller\n\n      if (isRefresh) {\n        setIsLoading(true)\n      } else {\n        setIsLoadingMore(true)\n      }\n      setError(null)\n\n      try {\n        const params = new URLSearchParams()\n        if (query) params.set(\"q\", query)\n        if (channel) params.set(\"channel\", channel)\n        params.set(\"limit\", String(limit))\n        params.set(\"offset\", String(offset))\n\n        const url = `${apiUrl}/1sat/bsocial/post/search?${params.toString()}`\n        const response = await fetch(url, { signal: controller.signal })\n\n        if (!response.ok) {\n          throw new Error(\n            `Failed to fetch posts: ${response.status} ${response.statusText}`\n          )\n        }\n\n        const data: RawSearchPost[] = await response.json()\n        const normalized = data.map(normalizePost)\n\n        if (isRefresh) {\n          setPosts(normalized)\n        } else {\n          setPosts((prev) => [...prev, ...normalized])\n        }\n\n        offsetRef.current = offset + normalized.length\n        setHasMore(normalized.length >= limit)\n      } catch (err) {\n        if (err instanceof DOMException && err.name === \"AbortError\") return\n        const fetchError =\n          err instanceof Error ? err : new Error(\"Failed to fetch social feed\")\n        setError(fetchError)\n      } finally {\n        setIsLoading(false)\n        setIsLoadingMore(false)\n      }\n    },\n    [apiUrl, channel, limit, query]\n  )\n\n  const loadMore = useCallback(async () => {\n    if (isLoadingMore || isLoading || !hasMore) return\n    await fetchPosts(offsetRef.current, false)\n  }, [fetchPosts, hasMore, isLoading, isLoadingMore])\n\n  const refresh = useCallback(async () => {\n    offsetRef.current = 0\n    setHasMore(true)\n    await fetchPosts(0, true)\n  }, [fetchPosts])\n\n  // Auto-fetch on mount and when dependencies change\n  useEffect(() => {\n    if (!autoFetch) return\n    offsetRef.current = 0\n    setHasMore(true)\n    fetchPosts(0, true)\n\n    return () => {\n      abortRef.current?.abort()\n    }\n  }, [autoFetch, fetchPosts])\n\n  return {\n    posts,\n    isLoading,\n    isLoadingMore,\n    error,\n    hasMore,\n    loadMore,\n    refresh,\n  }\n}\n",
      "type": "registry:component",
      "target": "~/components/blocks/social-feed/use-social-feed.ts"
    }
  ],
  "type": "registry:block"
}