shadimage
lens-blocks
blockpostsource: ok · hit

Lens Post

Displays a post from Lens Social Protocol, including the content, author, attachments, and engagement actions.

npx shadcn add "https://lensblocks.com/r/post.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

avatarbuttondialogdropdown-menuskeletonhttps://lensblocks.com/r/audio-player.jsonhttps://lensblocks.com/r/collect-dialog.jsonhttps://lensblocks.com/r/lens-markdown.jsonhttps://lensblocks.com/r/link-preview.jsonhttps://lensblocks.com/r/quote-dialog.jsonhttps://lensblocks.com/r/tip-dialog.jsonhttps://lensblocks.com/r/use-post-context.jsonhttps://lensblocks.com/r/use-reaction-toggle.jsonhttps://lensblocks.com/r/utils.jsonhttps://lensblocks.com/r/video-player.json

Files9 · source cached 10/14/2026, 1:09:25 PM

registry/new-york/blocks/feed/lens-post.tsx
"use client";

import { MouseEvent, useEffect, useRef, useState } from "react";
import { Copy, Flag, MoreHorizontal, UserCircle2 } from "lucide-react";
import { Account, Post, TxHash, URI } from "@lens-protocol/react";
import moment from "moment/moment";
import { cn } from "@/lib/utils";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";
import { Avatar, AvatarFallback, AvatarImage } from "@/registry/new-york/ui/avatar";
import { Dialog, DialogContent } from "@/registry/new-york/ui/dialog";
import { Button } from "@/registry/new-york/ui/button";
import { RegEx } from "@/registry/new-york/lib/regex";
import { getUsernamePath, parseUri, toApiError, truncateAddress } from "@/registry/new-york/lib/lens-utils";
import { LensMarkdown } from "@/registry/new-york/components/common/lens-markdown";
import { LensAudioPlayer } from "@/registry/new-york/components/common/lens-audio-player";
import { LensVideoPlayer } from "@/registry/new-york/components/common/lens-video-player";
import { LikeButton } from "@/registry/new-york/components/feed/likes/like-button";
import { ReferenceButton } from "@/registry/new-york/components/feed/references/reference-button";
import { CommentButton } from "@/registry/new-york/components/feed/comment/comment-button";
import { CollectButton } from "@/registry/new-york/components/feed/collects/collect-button";
import { TipButton } from "@/registry/new-york/components/feed/tips/tip-button";
import { BookmarkButton } from "@/registry/new-york/components/feed/bookmarks/bookmark-button";
import { CollectDialogRef, LensCollectDialog } from "@/registry/new-york/blocks/feed/lens-collect-dialog";
import { LensQuoteDialog, QuoteDialogRef } from "@/registry/new-york/blocks/feed/lens-quote-dialog";
import { LensTipDialog, TipDialogRef } from "@/registry/new-york/blocks/common/lens-tip-dialog";
import { LensImage } from "@/registry/new-york/components/feed/lens-image";
import { LinkPreview } from "@/registry/new-york/components/feed/link-preview";
import { useLensPostContext } from "@/registry/new-york/hooks/use-lens-post-context";
import { Skeleton } from "@/registry/new-york/ui/skeleton";
import { ResultAsync } from "neverthrow";

type LensPostProps = {
  /**
   * Callback function that is called when the user clicks on a post.
   */
  onPostClick?: (post: Post) => void;

  /**
   * Callback function that is called when the user clicks on an account.
   */
  onAccountClick?: (account: Account) => void;

  /**
   * Callback function that is called when the post URL is copied to clipboard.
   */
  onPostUrlCopied?: () => void;

  /**
   * The URL pattern to use for generating post links if onPostClick is not provided.
   * It should include `{slug}` as a placeholder for the post slug.
   * If not provided, a default pattern (/posts/{slug}) will be used.
   * * Example: `/posts/{slug}` or `https://example.com/posts/{slug}`
   */
  postUrlPattern?: string;

  /**
   * Callback function that is called when a repost is successful.
   * It receives the transaction hash as an argument.
   */
  onRepostSuccess?: (post: Post, txHash: TxHash) => void;

  /**
   * Callback function that is called when a tip is successfully created.
   * It receives the transaction hash as an argument.
   */
  onTipCreated?: (post: Post, txHash: TxHash) => void;

  /**
   * Callback function that is called when there is an error creating a tip.
   * It receives the error object as an argument.
   */
  onTipError?: (post: Post, error: Error) => void;

  /**
   * Callback function that is called when a post bookmark status is successfully updated.
   */
  onBookmarkToggle?: (post: Post, bookmarked: boolean) => void;

  /**
   * Optional additional class names to apply to the post container.
   */
  className?: string;

  /**
   * Optional additional class names to apply to the post content.
   */
  contentClassName?: string;

  /**
   * Whether to show action buttons (like, comment, repost, etc.). Default is true.
   */
  showActions?: boolean;
};

export const LensPost = (props: LensPostProps) => {
  const {
    onPostClick,
    onAccountClick,
    postUrlPattern = "/posts/{slug}",
    onRepostSuccess,
    onPostUrlCopied,
    onTipCreated,
    onTipError,
    onBookmarkToggle,
    className,
    contentClassName,
    showActions = true,
  } = props;

  const collectDialog = useRef<CollectDialogRef>(null);
  const quoteDialog = useRef<QuoteDialogRef>(null);
  const tipDialog = useRef<TipDialogRef>(null);

  const [lightboxUri, setLightboxUri] = useState<string | null>(null);
  const [lightboxOpen, setLightboxOpen] = useState(false);
  const [urlInContent, setUrlInContent] = useState<string | null>(null);

  const { post, loading, tip, sessionClient, optimistic } = useLensPostContext();

  useEffect(() => {
    if (!lightboxOpen) {
      setLightboxUri(null);
    }
  }, [lightboxOpen]);

  useEffect(() => {
    if (!post) return;

    const isPost = post.__typename === "Post";
    const basePost = isPost ? post : post.repostOf;
    let postMetadata = basePost.metadata;
    if (postMetadata.__typename === "UnknownPostMetadata") {
      return;
    }

    const checkContentForUrls = async () => {
      const urlRegex = new RegExp(RegEx.URL);

      const urls = postMetadata.content.match(urlRegex);
      if (!urls?.length) return;

      setUrlInContent(urls[0]);
    };

    checkContentForUrls();
  }, [post]);

  if (!post) {
    if (loading) {
      return <PostSkeleton className={className} />;
    }
    return null;
  }

  const isPost = post.__typename === "Post";
  const basePost = isPost ? post : post.repostOf;
  const postMetadata = basePost.metadata;
  if (postMetadata.__typename === "UnknownPostMetadata") {
    return <>Unsupported post type</>;
  }

  const author = basePost.author;
  const authorName = author.metadata?.name ?? author.username?.localName ?? "[anonymous]";
  const image = postMetadata.__typename === "ImageMetadata" ? postMetadata.image : null;
  const audio = postMetadata.__typename === "AudioMetadata" ? postMetadata.audio : null;
  const video = postMetadata.__typename === "VideoMetadata" ? postMetadata.video : null;
  const link: URI | null = postMetadata.__typename === "LinkMetadata" ? postMetadata.sharingLink : null;

  // function isVideoPlatformUrl(url: string): boolean {
  //   const patterns = [
  //     /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+$/, // YouTube
  //     /^(https?:\/\/)?(www\.)?vimeo\.com\/.+$/, // Vimeo
  //     /^(https?:\/\/)?(www\.)?twitch\.tv\/.+$/, // Twitch
  //     /^(https?:\/\/)?(www\.)?tiktok\.com\/.+$/, // TikTok
  //   ];
  //
  //   return patterns.some(pattern => pattern.test(url));
  // }
  //
  // const isVideoEmbed = videoUri ? isVideoPlatformUrl(videoUri) : false;

  const collectAction =
    post && "actions" in post && post.actions?.find(action => action.__typename === "SimpleCollectAction");

  const onReportClick = () => {};

  const onCopyClick = () => {
    const postUrl = postUrlPattern
      ? postUrlPattern.replace("{slug}", basePost.slug)
      : `${window.location.origin}/posts/${basePost.slug}`;
    navigator.clipboard
      .writeText(postUrl)
      .then(onPostUrlCopied)
      .catch(err => {
        console.error("Failed to copy post link:", err);
      });
  };

  const handleAccountClick = (event: MouseEvent<HTMLButtonElement>) => {
    event.stopPropagation();
    if (onAccountClick) {
      onAccountClick(author);
    } else if (author.username) {
      const path = getUsernamePath(author.username.value, author.username.namespace);
      if (event.metaKey || event.ctrlKey || event.button === 1) {
        window.open(path, "_blank");
      } else {
        window.open(path, "_self");
      }
    }
  };

  const handlePostClick = (event: MouseEvent<HTMLButtonElement>) => {
    // Allow text selection
    if (window.getSelection()?.toString()) {
      return;
    }

    event.preventDefault();

    // If it's a repost, we assume the user wants to see the original post
    const postUrl = postUrlPattern ? postUrlPattern.replace("{slug}", basePost.slug) : `/posts/${basePost.slug}`;

    if (onPostClick) {
      onPostClick(basePost);
    } else if (event.metaKey || event.ctrlKey || event.button === 1) {
      window.open(postUrl, "_blank");
    } else {
      window.open(postUrl, "_self");
    }
  };

  return (
    <>
      <article
        onClick={handlePostClick}
        className={cn("w-full px-3 md:px-4 pt-3 md:pt-4 pb-2 flex flex-col gap-3 text-start cursor-pointer", className)}
      >
        <div className="flex-grow flex justify-between flex-none">
          <div className="flex gap-2 w-full min-w-0">
            <button type="button" onClick={handleAccountClick} className="cursor-pointer">
              <Avatar className="flex-none w-10 h-10">
                <AvatarImage src={parseUri(author.metadata?.picture)} alt={`${authorName}'s avatar`} />
                <AvatarFallback>
                  <UserCircle2 className="w-10 h-10 opacity-45" />
                </AvatarFallback>
              </Avatar>
            </button>
            <div className="flex-grow flex flex-col min-w-0">
              <button type="button" onClick={handleAccountClick} className="flex gap-1 w-full min-w-0">
                <span className="text-sm md:text-base font-semibold truncate cursor-pointer hover:underline">
                  {authorName}
                </span>
                {author.username?.localName ? (
                  <span className="text-sm md:text-base text-muted-foreground truncate cursor-pointer hover:underline">
                    @{author.username.localName}
                  </span>
                ) : (
                  <span className="text-sm md:text-base text-muted-foreground truncate cursor-pointer hover:underline">
                    {truncateAddress(author.address)}
                  </span>
                )}
              </button>
              <a
                href={postUrlPattern.replace("{slug}", basePost.slug)}
                onClick={e => e.stopPropagation()}
                className="contents"
              >
                <abbr
                  title={new Date(post.timestamp).toLocaleString()}
                  className="text-xs opacity-65 no-underline hover:underline m-0 p-0"
                >
                  {moment(post.timestamp).fromNow(true)}
                </abbr>
              </a>
            </div>
          </div>
          {showActions && (
            <div className="flex gap-2">
              <BookmarkButton className="md:hidden" />
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button
                    variant="ghost"
                    className="w-8 h-8 active:outline-none focus-visible:outline-none hover:opacity-75 cursor-pointer rounded-full -me-2.5 md:me-0"
                  >
                    <MoreHorizontal className="w-4 h-4 opacity-75" />
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent className="min-w-48" side="bottom">
                  <DropdownMenuItem className="focus:outline-none p-0">
                    <button className="flex gap-2 items-center w-full p-2" onClick={onReportClick} disabled={loading}>
                      <Flag />
                      Report post
                    </button>
                  </DropdownMenuItem>
                  <DropdownMenuItem className="focus:outline-none p-0">
                    <button className="flex gap-2 items-center w-full p-2" onClick={onCopyClick} disabled={loading}>
                      <Copy className="w-4 h-4 inline" />
                      Copy link
                    </button>
                  </DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </div>
          )}
        </div>
        {postMetadata.content && (
          <div className="min-h-12 flex items-center">
            <LensMarkdown
              content={postMetadata.content}
              mentions={basePost.mentions}
              className={cn("text-sm md:text-base", contentClassName)}
            />
          </div>
        )}
        {image && (
          <LensImage
            image={image}
            alt={image?.altTag ?? ""}
            className="w-full mt-2 border rounded-xl object-contain cursor-pointer"
            width={600}
            height={400}
            loading="lazy"
            onClick={event => {
              event.stopPropagation();
              setLightboxUri(image.item);
              setLightboxOpen(true);
            }}
          />
        )}
        {audio && (
          <LensAudioPlayer
            audio={audio}
            postTitle={"title" in postMetadata ? postMetadata.title : undefined}
            onCoverClick={imageUri => {
              setLightboxUri(imageUri);
              setLightboxOpen(true);
            }}
          />
        )}
        {video && <LensVideoPlayer video={video} preload="metadata" />}
        {link && <LinkPreview url={link} />}
        {urlInContent && !image && !audio && !video && !link && <LinkPreview url={urlInContent} />}
        {showActions && (
          <div className="w-full flex gap-4 md:gap-8 items-center justify-between">
            <div className="w-full flex items-center justify-between md:justify-normal gap-4 md:gap-6 -mx-2">
              <CommentButton onClick={() => undefined} />
              <LikeButton />
              <ReferenceButton onQuoteClick={() => quoteDialog.current?.open()} onRepostSuccess={onRepostSuccess} />
              {collectAction && <CollectButton onClick={() => collectDialog.current?.open()} />}
              <TipButton onClick={() => tipDialog.current?.open()} />
            </div>
            <BookmarkButton
              className="hidden md:flex"
              onSuccess={(post: Post, bookmarked: boolean) => onBookmarkToggle?.(post, bookmarked)}
            />
          </div>
        )}
      </article>
      {collectAction && <LensCollectDialog ref={collectDialog} />}
      <LensQuoteDialog ref={quoteDialog} />
      <LensTipDialog
        sessionClient={sessionClient}
        ref={tipDialog}
        createTip={(source, amount, tokenAddress) =>
          ResultAsync.fromPromise(tip(source, amount, tokenAddress), toApiError)
        }
        onTipCreated={txHash => onTipCreated?.(basePost, txHash)}
        onTipError={error => onTipError?.(basePost, error)}
      />
      <Dialog open={lightboxOpen} onOpenChange={setLightboxOpen}>
        <DialogContent className="flex justify-center items-center max-h-full max-w-full bg-transparent border-none shadow-none">
          {lightboxOpen && lightboxUri && (
            <img
              src={lightboxUri}
              alt={"Image attached to post"}
              className="max-w-full max-h-full object-contain shadow-lg"
              loading="lazy"
            />
          )}
        </DialogContent>
      </Dialog>
    </>
  );
};

const PostSkeleton = ({ className }: { className?: string }) => {
  return (
    <div className={cn("w-full px-3 md:px-4 pt-3 md:pt-4 pb-2 flex flex-col gap-3", className)}>
      <div className="flex-grow flex justify-between flex-none">
        <div className="flex gap-2 w-full min-w-0">
          <Skeleton className="w-10 h-10 rounded-full" />
          <div className="flex-grow flex flex-col min-w-0 gap-1 justify-center">
            <div className="flex gap-1 w-full min-w-0 items-center">
              <Skeleton className="h-4 w-32 rounded-full" />
              <Skeleton className="h-4 w-20 rounded-full" />
            </div>
            <Skeleton className="h-3 w-16 rounded-full" />
          </div>
        </div>
      </div>
      <div className="flex flex-col gap-2 pt-2">
        <Skeleton className="w-11/12 h-4 rounded-full" />
        <Skeleton className="w-full h-4 rounded-full" />
        <Skeleton className="w-2/3 h-4 rounded-full" />
      </div>
      <div className="w-full flex gap-4 md:gap-8 items-center justify-between pt-2">
        <div className="w-full flex items-center justify-between md:justify-normal gap-4 md:gap-8">
          <Skeleton className="w-6 h-6 rounded-full" />
          <Skeleton className="w-6 h-6 rounded-full" />
          <Skeleton className="w-6 h-6 rounded-full" />
          <Skeleton className="w-6 h-6 rounded-full" />
          <Skeleton className="w-6 h-6 rounded-full" />
        </div>
        <Skeleton className="w-6 h-6 rounded-full" />
      </div>
    </div>
  );
};
registry/new-york/components/feed/likes/like-button.tsx
"use client";

import { MouseEvent } from "react";
import { Heart } from "lucide-react";
import { Button } from "@/registry/new-york/ui/button";
import { useLensPostContext } from "@/registry/new-york/hooks/use-lens-post-context";
import { cn } from "@/lib/utils";

type LikeButtonProps = {
  className?: string;
  onSuccess?: () => void;
  onError?: (error: Error) => void;
  showCount?: boolean;
};

export const LikeButton = ({ className, onSuccess, onError, showCount = true }: LikeButtonProps) => {
  const { post, loading: postLoading, toggleLike, optimistic } = useLensPostContext();

  const operations = post && "operations" in post ? post.operations : null;

  const onClick = async (event: MouseEvent<HTMLButtonElement>) => {
    event.currentTarget.blur();
    event.stopPropagation();

    if (post?.__typename !== "Post") return;

    try {
      await toggleLike();
      onSuccess?.();
    } catch (e) {
      if (onError) {
        onError(e instanceof Error ? e : new Error("An unexpected error occurred while toggling like."));
      } else {
        console.error("An unexpected error occurred while toggling like:", e);
      }
    }
  };

  return (
    <div className="flex items-center">
      <Button
        onClick={onClick}
        disabled={postLoading}
        variant="ghost"
        size="icon"
        className={cn("w-8 h-8 active:outline-none focus-visible:outline-none cursor-pointer rounded-full", className)}
      >
        {optimistic.liked || operations?.hasUpvoted ? (
          <Heart className="text-primary" fill="var(--primary)" />
        ) : (
          <Heart className="opacity-85" />
        )}
      </Button>
      {showCount && <span>{new Intl.NumberFormat().format(optimistic.likeCount)}</span>}
    </div>
  );
};
registry/new-york/components/feed/references/reference-button.tsx
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/registry/new-york/ui/dropdown-menu";
import { Post, TxHash } from "@lens-protocol/react";
import { MouseEvent, useState } from "react";
import { CheckCircle, Loader, MessageCircle, Repeat2 } from "lucide-react";
import { Button } from "@/registry/new-york/ui/button";
import { useLensPostContext } from "@/registry/new-york/hooks/use-lens-post-context";

type ReferenceButtonProps = {
  onQuoteClick: (post: Post) => void;
  onRepostSuccess?: (post: Post, txHash: TxHash) => void;
  onError?: (error: Error) => void;
  showCount?: boolean;
};

export const ReferenceButton = ({ onQuoteClick, onRepostSuccess, onError, showCount = true }: ReferenceButtonProps) => {
  const { post, repost, loading: postLoading, optimistic } = useLensPostContext();

  const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false);
  const [isPosting, setIsPosting] = useState(false);
  const [showSuccess, setShowSuccess] = useState(false);

  const postToReference: Post | null | undefined = post?.__typename === "Repost" ? post.repostOf : post;

  const onClick = (event: MouseEvent<HTMLButtonElement>) => {
    event.currentTarget.blur();
    event.stopPropagation();
    setIsDropdownMenuOpen(true);
  };

  const onRepostClick = async (event: MouseEvent<HTMLButtonElement>) => {
    event.stopPropagation();
    setIsPosting(true);
    try {
      const txHash = await repost();
      if (postToReference && txHash) {
        onRepostSuccess?.(postToReference, txHash);
        setShowSuccess(true);
        setTimeout(() => {
          setShowSuccess(false);
        }, 3000);
        setShowSuccess(false);
      }
    } catch (error) {
      if (error instanceof Error) {
        onError?.(error);
      }
    } finally {
      setIsPosting(false);
    }
  };

  const handleQuoteClick = (event: MouseEvent<HTMLButtonElement>) => {
    event.currentTarget.blur();
    event.stopPropagation();
    setIsDropdownMenuOpen(false);
    if (!postToReference) return;
    onQuoteClick(postToReference);
  };

  if (!post) return null;

  return (
    <div className="flex items-center">
      <DropdownMenu open={isDropdownMenuOpen} onOpenChange={setIsDropdownMenuOpen}>
        <DropdownMenuTrigger asChild>
          <Button
            variant="ghost"
            size="icon"
            disabled={postLoading || isPosting || showSuccess}
            onClick={onClick}
            className="w-8 h-8 active:outline-none focus-visible:outline-none hover:opacity-75 cursor-pointer rounded-full"
          >
            {isPosting ? (
              <Loader className="animate-spin w-4 h-4 text-muted-foreground" />
            ) : showSuccess ? (
              <CheckCircle className="size-2" />
            ) : postToReference?.operations?.hasReposted ||
              postToReference?.operations?.hasQuoted.optimistic ||
              postToReference?.operations?.hasQuoted.onChain ? (
              <Repeat2 className="size-[1.125rem]" strokeWidth={3} stroke="var(--primary)" />
            ) : (
              <Repeat2 className="size-[1.125rem]" />
            )}
          </Button>
        </DropdownMenuTrigger>
        <DropdownMenuContent className="min-w-40">
          <DropdownMenuItem className="text-md focus:outline-none p-0">
            <button
              className="flex gap-4 items-center w-full p-2 text-sm font-medium"
              onClick={onRepostClick}
              disabled={postLoading || isPosting}
            >
              <Repeat2 className="w-4 h-4 inline" />
              Repost
            </button>
          </DropdownMenuItem>
          <DropdownMenuItem className="text-md focus:outline-none p-0">
            <button
              className="flex gap-4 items-center w-full p-2 text-sm font-medium"
              onClick={handleQuoteClick}
              disabled={postLoading || isPosting}
            >
              <MessageCircle className="w-4 h-4 inline" />
              Quote
            </button>
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
      {showCount && (
        <span className="opacity-85">{new Intl.NumberFormat().format(optimistic.repostAndQuoteCount)}</span>
      )}
    </div>
  );
};
registry/new-york/components/feed/bookmarks/bookmark-button.tsx
import { Button } from "@/registry/new-york/ui/button";
import { Bookmark } from "lucide-react";
import { cn } from "@/lib/utils";
import { useLensPostContext } from "@/registry/new-york/hooks/use-lens-post-context";
import { MouseEvent } from "react";
import { Post } from "@lens-protocol/react";

type BookmarkButtonProps = {
  className?: string;
  onSuccess?: (post: Post, bookmarked: boolean) => void;
  onError?: (error: Error) => void;
};

export const BookmarkButton = ({ className, onSuccess, onError }: BookmarkButtonProps) => {
  const { post, loading: postLoading, toggleBookmark, optimistic } = useLensPostContext();

  const basePost: Post | null | undefined = post?.__typename === "Repost" ? post.repostOf : post;

  const onClick = async (event: MouseEvent<HTMLButtonElement>) => {
    event.currentTarget.blur();
    event.stopPropagation();

    if (!basePost) return;

    try {
      const bookmarked = await toggleBookmark();
      onSuccess?.(basePost, bookmarked);
    } catch (error) {
      if (error instanceof Error && onError) {
        onError(error);
      }
    }
  };

  return (
    <Button
      onClick={onClick}
      disabled={postLoading}
      variant="ghost"
      size="icon"
      className={cn("w-8 h-8 active:outline-none focus-visible:outline-none cursor-pointer rounded-full", className)}
    >
      {optimistic.bookmarked || basePost?.operations?.hasBookmarked ? (
        <Bookmark className="text-primary" fill="var(--primary)" />
      ) : (
        <Bookmark className="opacity-85" />
      )}
    </Button>
  );
};

export default BookmarkButton;
registry/new-york/components/feed/comment/comment-button.tsx
"use client";

import { MouseEvent } from "react";
import { MessageCircle } from "lucide-react";
import { AnyPost } from "@lens-protocol/react";
import { Button } from "@/registry/new-york/ui/button";
import { useLensPostContext } from "@/registry/new-york/hooks/use-lens-post-context";

type CommentButtonProps = {
  onClick: (post: AnyPost) => void;
  showCount?: boolean;
};

export const CommentButton = ({ onClick, showCount = true }: CommentButtonProps) => {
  const { post, loading: postLoading, optimistic } = useLensPostContext();

  const operations = post && "operations" in post ? post.operations : null;

  const onButtonClick = (event: MouseEvent<HTMLButtonElement>) => {
    event.currentTarget.blur();
    event.stopPropagation();
    if (!post) return;
    onClick(post);
  };

  return (
    <div className="flex items-center">
      <Button
        variant="ghost"
        size="icon"
        onClick={onButtonClick}
        disabled={postLoading}
        className="w-8 h-8 active:outline-none focus-visible:outline-none cursor-pointer rounded-full"
      >
        {operations?.hasCommented?.optimistic || operations?.hasCommented?.onChain ? (
          <MessageCircle className="text-primary" fill="var(--primary)" />
        ) : (
          <MessageCircle className="opacity-85" />
        )}
      </Button>
      {showCount && <span className="opacity-85">{new Intl.NumberFormat().format(optimistic.commentCount)}</span>}
    </div>
  );
};
registry/new-york/components/feed/collects/collect-button.tsx
"use client";

import { AnyPost } from "@lens-protocol/react";
import { ShoppingBag } from "lucide-react";
import { Button } from "@/registry/new-york/ui/button";
import { useLensPostContext } from "@/registry/new-york/hooks/use-lens-post-context";
import { MouseEvent } from "react";

type CollectButtonProps = {
  onClick: (post: AnyPost) => void;
  showCount?: boolean;
};

export const CollectButton = ({ onClick, showCount = true }: CollectButtonProps) => {
  const { post, loading: postLoading, optimistic } = useLensPostContext();

  const operations = post && "operations" in post ? post.operations : null;

  const onButtonClick = (event: MouseEvent<HTMLButtonElement>) => {
    event.currentTarget.blur();
    event.stopPropagation();
    if (!post) return;
    onClick(post);
  };

  return (
    <div className="flex items-center">
      <Button
        onClick={onButtonClick}
        disabled={postLoading}
        variant="ghost"
        size="icon"
        className="w-8 h-8 active:outline-none focus-visible:outline-none cursor-pointer rounded-full"
      >
        {optimistic.collected || operations?.hasSimpleCollected ? (
          <svg viewBox="0 0 20 22" className="w-4 h-4">
            <g strokeWidth="1.5" fill="none" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round">
              <path
                d="M3,0 L0,4 L0,18 C0,19.1045695 0.8954305,20 2,20 L16,20 C17.1045695,20 18,19.1045695 18,18 L18,4 L15,0 L3,0 Z"
                stroke="var(--primary)"
                fill="var(--primary)"
                transform="translate(1 1)"
              />
              <path
                stroke="var(--primary)"
                fill="var(--background)"
                d="M3 0 0 4 18 4 15 0z"
                transform="translate(1 1)"
              />
              <path stroke="var(--primary)" d="M0 4 18 4" transform="translate(1 1)" />
              <path
                d="M13,8 C13,10.209139 11.209139,12 9,12 C6.790861,12 5,10.209139 5,8"
                stroke="var(--background)"
                transform="translate(1 1)"
              />
            </g>
          </svg>
        ) : (
          <ShoppingBag className="h-4 h-4" />
        )}
      </Button>
      {showCount && <span className="opacity-85">{new Intl.NumberFormat().format(optimistic.collectCount)}</span>}
    </div>
  );
};
registry/new-york/components/feed/tips/tip-button.tsx
"use client";

import { AnyPost } from "@lens-protocol/react";
import { MouseEvent } from "react";
import { CircleDollarSign } from "lucide-react";
import { Button } from "@/registry/new-york/ui/button";
import { useLensPostContext } from "@/registry/new-york/hooks/use-lens-post-context";

interface Props {
  onClick: (post: AnyPost) => void;
  showCount?: boolean;
}

export const TipButton = ({ onClick, showCount = true }: Props) => {
  const { post, loading: postLoading, optimistic } = useLensPostContext();

  const operations = post && "operations" in post ? post.operations : null;

  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
    event.currentTarget.blur();
    event.stopPropagation();
    if (!post) return;
    onClick(post);
  };

  if (!post) return null;

  return (
    <div className="flex items-center">
      <Button
        variant="ghost"
        disabled={postLoading || !operations?.canTip}
        onClick={handleClick}
        className="w-8 h-8 active:outline-none focus-visible:outline-none cursor-pointer rounded-full"
      >
        {optimistic.tipped ? (
          <svg
            xmlns="http://www.w3.org/2000/svg"
            viewBox="0 0 24 24"
            fill="var(--primary)"
            stroke="var(--background)"
            strokeLinecap="round"
            strokeLinejoin="round"
            className="w-4 h-4"
          >
            <circle cx="12" cy="12" r="12" />
            <path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" strokeWidth="2" />
            <path d="M12 18V6" strokeWidth="2" />
          </svg>
        ) : (
          <CircleDollarSign className="w-4 h-4" />
        )}
      </Button>
      {showCount && <span className="opacity-85">{new Intl.NumberFormat().format(optimistic.tipCount)}</span>}
    </div>
  );
};
registry/new-york/components/feed/lens-image.tsx
import { ComponentProps } from "react";
import { MediaImage } from "@lens-protocol/react";
import { parseUri } from "@/registry/new-york/lib/lens-utils";
import { cn } from "@/lib/utils";

type ButtonProps = Omit<ComponentProps<"img">, "src">;

type Props = {
  image: MediaImage;
};

export const LensImage = ({ image, ...props }: Props & ButtonProps) => {
  const imageUri = image ? parseUri(image.item) : null;

  if (!imageUri) {
    return null;
  }

  return (
    <img
      src={imageUri}
      {...props}
      className={cn("w-full mt-2 border rounded-xl object-contain cursor-pointer", props.className)}
    />
  );
};
registry/new-york/lib/regex.ts
const regexLookbehindAvailable: boolean = ((): boolean => {
  try {
    return "ab".replace(/(?<=a)b/g, "c") === "ac";
  } catch {
    return false;
  }
})();

const MATCH_BEHIND = regexLookbehindAvailable ? "(?<=^|\\s)" : "";

const MENTION_NAMESPACE = "\\w+\\/";
const MENTION_BODY = "([\\dA-Za-z]\\w{2,25})";

export const RegEx = {
  CASHTAG: /(\$\w*[A-Za-z]\w*)/g,
  HASHTAG: /(#\w*[A-Za-z]\w*)/g,
  MENTION: new RegExp(`${MATCH_BEHIND}@${MENTION_NAMESPACE}${MENTION_BODY}`, "g"),
  URL: /\b(http|https):\/\/([\p{L}\p{N}_-]+(?:(?:\.[\p{L}\p{N}_-]+)+))([\p{L}\p{N}_.,@?^=%&:\/~+#-]*[\p{L}\p{N}_@?^=%&\/~+#-])/gu,
};

More from lens-blocks

Browse all components in this library →