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 · lighticonslucide-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,
};