From faa241a769f7b6237ca3fc7dc021fa901429e229 Mon Sep 17 00:00:00 2001 From: ragnep Date: Wed, 22 Apr 2026 15:44:06 +0300 Subject: [PATCH 1/7] art feed view Signed-off-by: ragnep --- app/art-feed/page.client.tsx | 7 + app/art-feed/page.tsx | 18 ++ components/art-feed/ArtFeed.tsx | 195 +++++++++++++++++ components/art-feed/ArtFeedTrigger.tsx | 31 +++ components/art-feed/artFeed.constants.ts | 5 + components/art-feed/artFeed.helpers.ts | 202 ++++++++++++++++++ .../left-sidebar/waves/MemesWaveFooter.tsx | 97 +++++---- hooks/useArtFeedDrops.ts | 113 ++++++++++ 8 files changed, 624 insertions(+), 44 deletions(-) create mode 100644 app/art-feed/page.client.tsx create mode 100644 app/art-feed/page.tsx create mode 100644 components/art-feed/ArtFeed.tsx create mode 100644 components/art-feed/ArtFeedTrigger.tsx create mode 100644 components/art-feed/artFeed.constants.ts create mode 100644 components/art-feed/artFeed.helpers.ts create mode 100644 hooks/useArtFeedDrops.ts diff --git a/app/art-feed/page.client.tsx b/app/art-feed/page.client.tsx new file mode 100644 index 0000000000..538ce5a879 --- /dev/null +++ b/app/art-feed/page.client.tsx @@ -0,0 +1,7 @@ +"use client"; + +import ArtFeed from "@/components/art-feed/ArtFeed"; + +export default function ArtFeedPageClient() { + return ; +} diff --git a/app/art-feed/page.tsx b/app/art-feed/page.tsx new file mode 100644 index 0000000000..8e95dae657 --- /dev/null +++ b/app/art-feed/page.tsx @@ -0,0 +1,18 @@ +import ArtFeedPageClient from "./page.client"; +import { getAppMetadata } from "@/components/providers/metadata"; +import type { Metadata } from "next"; + +export default function ArtFeedPage() { + return ( +
+ +
+ ); +} + +export function generateMetadata(): Metadata { + return getAppMetadata({ + title: "ART Feed", + description: "Curated ART drops from across 6529 Waves", + }); +} diff --git a/components/art-feed/ArtFeed.tsx b/components/art-feed/ArtFeed.tsx new file mode 100644 index 0000000000..51ee682778 --- /dev/null +++ b/components/art-feed/ArtFeed.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { ART_FEED_LIMIT } from "@/components/art-feed/artFeed.constants"; +import UserPageProfileWaveMasonry, { + useProfileMasonryContainerWidth, +} from "@/components/user/waves/UserPageProfileWaveMasonry"; +import { + useArtFeedDrops, + type ArtFeedAudience, + type ArtFeedMediaFilter, +} from "@/hooks/useArtFeedDrops"; +import { useCallback, useState } from "react"; + +const MEDIA_FILTER_OPTIONS: Array<{ + key: ArtFeedMediaFilter; + label: string; +}> = [ + { key: "all", label: "All" }, + { key: "image", label: "Images" }, + { key: "video", label: "Video" }, + { key: "audio", label: "Audio" }, +]; + +const AUDIENCE_OPTIONS: Array<{ key: ArtFeedAudience; label: string }> = [ + { key: "following", label: "Following" }, + { key: "everyone", label: "Everyone" }, +]; + +const getTabButtonClassName = (isActive: boolean): string => + `tw-rounded-md tw-border-0 tw-px-3 tw-py-2 tw-text-sm tw-font-semibold tw-transition-colors ${ + isActive + ? "tw-bg-iron-800 tw-text-white" + : "tw-bg-transparent tw-text-iron-400 desktop-hover:hover:tw-text-iron-100" + }`; + +function ArtFeedSkeletonCard() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function ArtFeedEmptyState({ + title, + description, +}: { + readonly title: string; + readonly description: string; +}) { + return ( +
+

+ {title} +

+

{description}

+
+ ); +} + +export default function ArtFeed() { + const { containerRef, containerWidth } = useProfileMasonryContainerWidth(); + const [mediaFilter, setMediaFilter] = useState("all"); + const [audience, setAudience] = useState("everyone"); + const { + allDrops, + drops, + fetchNextPage, + hasNextPage, + isError, + isFetchingNextPage, + isLoading, + } = useArtFeedDrops({ + audience, + mediaFilter, + limit: ART_FEED_LIMIT, + }); + + const isInitialLoading = isLoading && allDrops.length === 0; + const emptyTitle = + audience === "following" ? "No followed ART yet" : "No ART drops yet"; + const emptyDescription = + audience === "following" + ? "Follow curators to build a more personal ART view." + : "User-curated ART drops will appear here when visible curations have activity."; + const handleFetchNextPage = useCallback(async () => { + await fetchNextPage(); + }, [fetchNextPage]); + + return ( +
+
+
+
+

+ ART +

+

+ ART Feed +

+

+ User-curated ART drops from across 6529 Waves. +

+
+ +
+
+ {MEDIA_FILTER_OPTIONS.map((option) => ( + + ))} +
+
+ {AUDIENCE_OPTIONS.map((option) => ( + + ))} +
+
+
+ +
+ {isInitialLoading && ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ )} + + {!isInitialLoading && isError && ( + + )} + + {!isInitialLoading && !isError && drops.length === 0 && ( + + )} + + {!isInitialLoading && !isError && drops.length > 0 && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/components/art-feed/ArtFeedTrigger.tsx b/components/art-feed/ArtFeedTrigger.tsx new file mode 100644 index 0000000000..efdb75ca7c --- /dev/null +++ b/components/art-feed/ArtFeedTrigger.tsx @@ -0,0 +1,31 @@ +"use client"; + +import DiscoverIcon from "@/components/common/icons/DiscoverIcon"; +import Link from "next/link"; +import { ART_FEED_HREF } from "./artFeed.constants"; + +interface ArtFeedTriggerProps { + readonly variant?: "compact" | "panel" | undefined; +} + +export default function ArtFeedTrigger({ + variant = "compact", +}: ArtFeedTriggerProps) { + const className = + variant === "panel" + ? "tw-inline-flex tw-w-12 tw-flex-shrink-0 tw-items-center tw-justify-center tw-rounded-lg tw-border tw-border-solid tw-border-iron-700 tw-bg-iron-950 tw-text-iron-300 tw-no-underline tw-transition-colors desktop-hover:hover:tw-border-primary-400/35 desktop-hover:hover:tw-bg-primary-500/10 desktop-hover:hover:tw-text-primary-300" + : "tw-inline-flex tw-items-center tw-justify-center tw-rounded-full tw-border tw-border-solid tw-border-iron-700 tw-bg-iron-950 tw-px-2.5 tw-py-2 tw-text-iron-300 tw-no-underline tw-transition-colors desktop-hover:hover:tw-border-primary-400/35 desktop-hover:hover:tw-bg-primary-500/10 desktop-hover:hover:tw-text-primary-300"; + + return ( + + + ART feed + + ); +} diff --git a/components/art-feed/artFeed.constants.ts b/components/art-feed/artFeed.constants.ts new file mode 100644 index 0000000000..dbeda7b953 --- /dev/null +++ b/components/art-feed/artFeed.constants.ts @@ -0,0 +1,5 @@ +export const ART_FEED_HREF = "/art-feed"; + +export const ART_FEED_CURATION_NAME = "ART"; + +export const ART_FEED_LIMIT = 12; diff --git a/components/art-feed/artFeed.helpers.ts b/components/art-feed/artFeed.helpers.ts new file mode 100644 index 0000000000..29d005e974 --- /dev/null +++ b/components/art-feed/artFeed.helpers.ts @@ -0,0 +1,202 @@ +import { resolveIpfsUrlSync } from "@/components/ipfs/IPFSContext"; +import { buildProcessedContent } from "@/components/waves/drops/media-utils"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { ApiNftLinkMediaPreviewStatusEnum } from "@/generated/models/ApiNftLinkMediaPreview"; +import { formatAddress } from "@/helpers/Helpers"; +import { getWaveRoute } from "@/helpers/navigation.helpers"; +import { getDropPreviewImageUrl } from "@/helpers/waves/drop.helpers"; + +export interface ArtFeedMediaPreview { + readonly url: string; + readonly kind: "image" | "video"; +} + +export type ArtFeedMediaType = "image" | "video" | "audio" | "other"; + +const getCombinedDropContent = (drop: ApiDrop): string => + drop.parts + .map((part) => part.content?.trim()) + .filter((content): content is string => Boolean(content)) + .join("\n\n"); + +const toNonEmptyString = (value: string | null | undefined): string | null => { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + + return trimmed; +}; + +const getMediaTypeFromMimeType = ( + mimeType: string | null | undefined +): ArtFeedMediaType | null => { + const normalized = mimeType?.toLowerCase(); + if (!normalized) { + return null; + } + + if (normalized.startsWith("image/")) { + return "image"; + } + + if (normalized.startsWith("video/")) { + return "video"; + } + + if (normalized.startsWith("audio/")) { + return "audio"; + } + + return null; +}; + +const getMediaTypeFromUrl = ( + url: string | null | undefined +): ArtFeedMediaType | null => { + const normalized = url?.split(/[?#]/)[0]?.toLowerCase(); + if (!normalized) { + return null; + } + + if (/\.(avif|gif|jpe?g|png|webp|svg)$/.test(normalized)) { + return "image"; + } + + if (/\.(m3u8|mov|mp4|webm)$/.test(normalized)) { + return "video"; + } + + if (/\.(aac|flac|m4a|mp3|ogg|wav)$/.test(normalized)) { + return "audio"; + } + + return null; +}; + +const getNftLinkMediaType = (drop: ApiDrop): ArtFeedMediaType | null => { + for (const nftLink of drop.nft_links ?? []) { + const preview = nftLink.data?.media_preview; + const previewUrl = + preview?.status === ApiNftLinkMediaPreviewStatusEnum.Ready + ? (toNonEmptyString(preview.card_url) ?? + toNonEmptyString(preview.small_url) ?? + toNonEmptyString(preview.thumb_url)) + : null; + const previewMediaType = + getMediaTypeFromMimeType(preview?.mime_type) ?? + getMediaTypeFromUrl(previewUrl); + if (previewMediaType) { + return previewMediaType; + } + + const mediaUri = toNonEmptyString(nftLink.data?.media_uri); + const mediaUriType = getMediaTypeFromUrl(mediaUri); + if (mediaUriType) { + return mediaUriType; + } + } + + return null; +}; + +export const getArtFeedText = (drop: ApiDrop): string | null => { + const combinedContent = getCombinedDropContent(drop); + + if (!combinedContent) { + return null; + } + + const processed = buildProcessedContent(combinedContent, []); + const text = processed.segments + .filter((segment) => segment.type === "text") + .map((segment) => segment.content.trim()) + .filter(Boolean) + .join(" "); + + return text.length > 0 ? text : null; +}; + +export const getArtFeedTitle = (drop: ApiDrop): string => { + const title = drop.title?.trim(); + if (title) { + return title; + } + + const text = getArtFeedText(drop); + if (text) { + return text.length > 90 ? `${text.slice(0, 87)}...` : text; + } + + return "Untitled ART drop"; +}; + +export const getArtFeedMediaPreview = ( + drop: ApiDrop +): ArtFeedMediaPreview | null => { + const previewImageUrl = getDropPreviewImageUrl(drop.metadata); + if (previewImageUrl) { + return { + kind: "image", + url: resolveIpfsUrlSync(previewImageUrl), + }; + } + + const media = drop.parts.flatMap((part) => part.media).at(0); + if (!media) { + return null; + } + + const mimeType = media.mime_type.toLowerCase(); + if (mimeType.startsWith("image/")) { + return { + kind: "image", + url: resolveIpfsUrlSync(media.url), + }; + } + + if (mimeType.startsWith("video/")) { + return { + kind: "video", + url: resolveIpfsUrlSync(media.url), + }; + } + + return null; +}; + +export const getArtFeedMediaType = (drop: ApiDrop): ArtFeedMediaType => { + if (getDropPreviewImageUrl(drop.metadata)) { + return "image"; + } + + const media = drop.parts.flatMap((part) => part.media).at(0); + if (!media) { + return getNftLinkMediaType(drop) ?? "other"; + } + + return getMediaTypeFromMimeType(media.mime_type) ?? "other"; +}; + +export const getArtFeedAuthorLabel = (drop: ApiDrop): string => + drop.author.handle + ? `@${drop.author.handle}` + : formatAddress(drop.author.primary_address); + +export const getArtFeedAuthorHref = (drop: ApiDrop): string => + `/${encodeURIComponent(drop.author.handle ?? drop.author.primary_address)}`; + +export const getArtFeedDropHref = (drop: ApiDrop): string => + getWaveRoute({ + waveId: drop.wave.id, + extraParams: { drop: drop.id }, + isDirectMessage: false, + isApp: false, + }); + +export const getArtFeedWaveHref = (drop: ApiDrop): string => + getWaveRoute({ + waveId: drop.wave.id, + isDirectMessage: false, + isApp: false, + }); diff --git a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx index cdb0f079d3..48021dd5b5 100644 --- a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx +++ b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx @@ -1,5 +1,6 @@ "use client"; +import ArtFeedTrigger from "@/components/art-feed/ArtFeedTrigger"; import MemesWaveQuickVoteTrigger from "@/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger"; import MemesWaveZapIcon from "@/components/brain/left-sidebar/waves/MemesWaveZapIcon"; import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; @@ -76,59 +77,67 @@ const MemesWaveFooter: React.FC = ({ transition={revealTransition} className={ collapsed - ? "tw-z-10 tw-flex tw-flex-shrink-0 tw-justify-center tw-px-2 tw-pb-2 tw-pt-1" + ? "tw-z-10 tw-flex tw-flex-shrink-0 tw-justify-center tw-gap-2 tw-px-2 tw-pb-2 tw-pt-1" : "tw-relative tw-z-20 tw-mt-auto tw-flex-shrink-0" } > {collapsed ? ( - + <> + + + ) : ( - +
- +
)} )} diff --git a/hooks/useArtFeedDrops.ts b/hooks/useArtFeedDrops.ts new file mode 100644 index 0000000000..6909e5829e --- /dev/null +++ b/hooks/useArtFeedDrops.ts @@ -0,0 +1,113 @@ +"use client"; + +import { ART_FEED_CURATION_NAME } from "@/components/art-feed/artFeed.constants"; +import { + getArtFeedMediaType, + type ArtFeedMediaType, +} from "@/components/art-feed/artFeed.helpers"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { DropSize, type ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { commonApiFetch } from "@/services/api/common-api"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +export type ArtFeedAudience = "everyone" | "following"; +export type ArtFeedMediaFilter = "all" | Exclude; + +const ART_FEED_DROPS_QUERY_KEY = "ART_FEED_DROPS"; + +interface UseArtFeedDropsProps { + readonly audience?: ArtFeedAudience | undefined; + readonly mediaFilter?: ArtFeedMediaFilter | undefined; + readonly limit: number; + readonly enabled?: boolean | undefined; +} + +const getUniqueDrops = (pages: ApiDrop[][] | undefined): ExtendedDrop[] => { + if (!pages) { + return []; + } + + const seen = new Set(); + const drops: ExtendedDrop[] = []; + + for (const drop of pages.flat()) { + if (seen.has(drop.id)) { + continue; + } + seen.add(drop.id); + drops.push({ + ...drop, + type: DropSize.FULL, + stableKey: drop.id, + stableHash: drop.id, + }); + } + + return drops; +}; + +const isDropFromFollowedAuthor = (drop: ExtendedDrop): boolean => + drop.author.subscribed_actions.length > 0; + +const matchesMediaFilter = ( + drop: ExtendedDrop, + mediaFilter: ArtFeedMediaFilter +): boolean => + mediaFilter === "all" || getArtFeedMediaType(drop) === mediaFilter; + +export function useArtFeedDrops({ + audience = "everyone", + mediaFilter = "all", + limit, + enabled = true, +}: UseArtFeedDropsProps) { + const query = useInfiniteQuery({ + queryKey: [ + ART_FEED_DROPS_QUERY_KEY, + { curation_name: ART_FEED_CURATION_NAME, limit }, + ], + queryFn: async ({ pageParam }: { pageParam: number | null }) => { + const params: Record = { + curation_name: ART_FEED_CURATION_NAME, + limit: `${limit}`, + }; + + if (typeof pageParam === "number") { + params["serial_no_less_than"] = `${pageParam}`; + } + + return await commonApiFetch({ + endpoint: "drops", + params, + }); + }, + enabled, + initialPageParam: null, + getNextPageParam: (lastPage) => + lastPage.length === limit ? lastPage.at(-1)?.serial_no : undefined, + staleTime: 60_000, + }); + + const allDrops = useMemo( + () => getUniqueDrops(query.data?.pages), + [query.data?.pages] + ); + + const drops = useMemo( + () => + allDrops.filter( + (drop) => + matchesMediaFilter(drop, mediaFilter) && + (audience === "everyone" || isDropFromFollowedAuthor(drop)) + ), + [allDrops, audience, mediaFilter] + ); + + return { + ...query, + allDrops, + curationName: ART_FEED_CURATION_NAME, + drops, + }; +} From 091fc46ca4ec6e2167b4d9a9b2c90f8009ef5c4e Mon Sep 17 00:00:00 2001 From: ragnep Date: Thu, 23 Apr 2026 15:26:53 +0300 Subject: [PATCH 2/7] curations feed Signed-off-by: ragnep --- app/art-feed/page.client.tsx | 7 - app/art-feed/page.tsx | 18 - components/art-feed/ArtFeed.tsx | 195 ----------- components/art-feed/ArtFeedTrigger.tsx | 31 -- components/art-feed/artFeed.constants.ts | 5 - components/art-feed/artFeed.helpers.ts | 202 ------------ components/brain/content/BrainContent.tsx | 11 +- .../left-sidebar/waves/MemesWaveFooter.tsx | 23 +- .../brain/my-stream/MyStreamWaveContent.tsx | 4 +- .../CommunityCurations.tsx | 155 +++++++++ .../CommunityCurationsMasonry.tsx | 309 ++++++++++++++++++ .../communityCurations.constants.ts | 1 + .../communityCurations.helpers.ts | 103 ++++++ components/shared/WavesMessagesWrapper.tsx | 10 +- components/waves/WavesView.tsx | 33 +- hooks/useArtFeedDrops.ts | 113 ------- hooks/useCommunityCurationsDrops.ts | 112 +++++++ 17 files changed, 708 insertions(+), 624 deletions(-) delete mode 100644 app/art-feed/page.client.tsx delete mode 100644 app/art-feed/page.tsx delete mode 100644 components/art-feed/ArtFeed.tsx delete mode 100644 components/art-feed/ArtFeedTrigger.tsx delete mode 100644 components/art-feed/artFeed.constants.ts delete mode 100644 components/art-feed/artFeed.helpers.ts create mode 100644 components/community-curations/CommunityCurations.tsx create mode 100644 components/community-curations/CommunityCurationsMasonry.tsx create mode 100644 components/community-curations/communityCurations.constants.ts create mode 100644 components/community-curations/communityCurations.helpers.ts delete mode 100644 hooks/useArtFeedDrops.ts create mode 100644 hooks/useCommunityCurationsDrops.ts diff --git a/app/art-feed/page.client.tsx b/app/art-feed/page.client.tsx deleted file mode 100644 index 538ce5a879..0000000000 --- a/app/art-feed/page.client.tsx +++ /dev/null @@ -1,7 +0,0 @@ -"use client"; - -import ArtFeed from "@/components/art-feed/ArtFeed"; - -export default function ArtFeedPageClient() { - return ; -} diff --git a/app/art-feed/page.tsx b/app/art-feed/page.tsx deleted file mode 100644 index 8e95dae657..0000000000 --- a/app/art-feed/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import ArtFeedPageClient from "./page.client"; -import { getAppMetadata } from "@/components/providers/metadata"; -import type { Metadata } from "next"; - -export default function ArtFeedPage() { - return ( -
- -
- ); -} - -export function generateMetadata(): Metadata { - return getAppMetadata({ - title: "ART Feed", - description: "Curated ART drops from across 6529 Waves", - }); -} diff --git a/components/art-feed/ArtFeed.tsx b/components/art-feed/ArtFeed.tsx deleted file mode 100644 index 51ee682778..0000000000 --- a/components/art-feed/ArtFeed.tsx +++ /dev/null @@ -1,195 +0,0 @@ -"use client"; - -import { ART_FEED_LIMIT } from "@/components/art-feed/artFeed.constants"; -import UserPageProfileWaveMasonry, { - useProfileMasonryContainerWidth, -} from "@/components/user/waves/UserPageProfileWaveMasonry"; -import { - useArtFeedDrops, - type ArtFeedAudience, - type ArtFeedMediaFilter, -} from "@/hooks/useArtFeedDrops"; -import { useCallback, useState } from "react"; - -const MEDIA_FILTER_OPTIONS: Array<{ - key: ArtFeedMediaFilter; - label: string; -}> = [ - { key: "all", label: "All" }, - { key: "image", label: "Images" }, - { key: "video", label: "Video" }, - { key: "audio", label: "Audio" }, -]; - -const AUDIENCE_OPTIONS: Array<{ key: ArtFeedAudience; label: string }> = [ - { key: "following", label: "Following" }, - { key: "everyone", label: "Everyone" }, -]; - -const getTabButtonClassName = (isActive: boolean): string => - `tw-rounded-md tw-border-0 tw-px-3 tw-py-2 tw-text-sm tw-font-semibold tw-transition-colors ${ - isActive - ? "tw-bg-iron-800 tw-text-white" - : "tw-bg-transparent tw-text-iron-400 desktop-hover:hover:tw-text-iron-100" - }`; - -function ArtFeedSkeletonCard() { - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ); -} - -function ArtFeedEmptyState({ - title, - description, -}: { - readonly title: string; - readonly description: string; -}) { - return ( -
-

- {title} -

-

{description}

-
- ); -} - -export default function ArtFeed() { - const { containerRef, containerWidth } = useProfileMasonryContainerWidth(); - const [mediaFilter, setMediaFilter] = useState("all"); - const [audience, setAudience] = useState("everyone"); - const { - allDrops, - drops, - fetchNextPage, - hasNextPage, - isError, - isFetchingNextPage, - isLoading, - } = useArtFeedDrops({ - audience, - mediaFilter, - limit: ART_FEED_LIMIT, - }); - - const isInitialLoading = isLoading && allDrops.length === 0; - const emptyTitle = - audience === "following" ? "No followed ART yet" : "No ART drops yet"; - const emptyDescription = - audience === "following" - ? "Follow curators to build a more personal ART view." - : "User-curated ART drops will appear here when visible curations have activity."; - const handleFetchNextPage = useCallback(async () => { - await fetchNextPage(); - }, [fetchNextPage]); - - return ( -
-
-
-
-

- ART -

-

- ART Feed -

-

- User-curated ART drops from across 6529 Waves. -

-
- -
-
- {MEDIA_FILTER_OPTIONS.map((option) => ( - - ))} -
-
- {AUDIENCE_OPTIONS.map((option) => ( - - ))} -
-
-
- -
- {isInitialLoading && ( -
- {Array.from({ length: 4 }).map((_, index) => ( - - ))} -
- )} - - {!isInitialLoading && isError && ( - - )} - - {!isInitialLoading && !isError && drops.length === 0 && ( - - )} - - {!isInitialLoading && !isError && drops.length > 0 && ( -
- -
- )} -
-
-
- ); -} diff --git a/components/art-feed/ArtFeedTrigger.tsx b/components/art-feed/ArtFeedTrigger.tsx deleted file mode 100644 index efdb75ca7c..0000000000 --- a/components/art-feed/ArtFeedTrigger.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import DiscoverIcon from "@/components/common/icons/DiscoverIcon"; -import Link from "next/link"; -import { ART_FEED_HREF } from "./artFeed.constants"; - -interface ArtFeedTriggerProps { - readonly variant?: "compact" | "panel" | undefined; -} - -export default function ArtFeedTrigger({ - variant = "compact", -}: ArtFeedTriggerProps) { - const className = - variant === "panel" - ? "tw-inline-flex tw-w-12 tw-flex-shrink-0 tw-items-center tw-justify-center tw-rounded-lg tw-border tw-border-solid tw-border-iron-700 tw-bg-iron-950 tw-text-iron-300 tw-no-underline tw-transition-colors desktop-hover:hover:tw-border-primary-400/35 desktop-hover:hover:tw-bg-primary-500/10 desktop-hover:hover:tw-text-primary-300" - : "tw-inline-flex tw-items-center tw-justify-center tw-rounded-full tw-border tw-border-solid tw-border-iron-700 tw-bg-iron-950 tw-px-2.5 tw-py-2 tw-text-iron-300 tw-no-underline tw-transition-colors desktop-hover:hover:tw-border-primary-400/35 desktop-hover:hover:tw-bg-primary-500/10 desktop-hover:hover:tw-text-primary-300"; - - return ( - - - ART feed - - ); -} diff --git a/components/art-feed/artFeed.constants.ts b/components/art-feed/artFeed.constants.ts deleted file mode 100644 index dbeda7b953..0000000000 --- a/components/art-feed/artFeed.constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const ART_FEED_HREF = "/art-feed"; - -export const ART_FEED_CURATION_NAME = "ART"; - -export const ART_FEED_LIMIT = 12; diff --git a/components/art-feed/artFeed.helpers.ts b/components/art-feed/artFeed.helpers.ts deleted file mode 100644 index 29d005e974..0000000000 --- a/components/art-feed/artFeed.helpers.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { resolveIpfsUrlSync } from "@/components/ipfs/IPFSContext"; -import { buildProcessedContent } from "@/components/waves/drops/media-utils"; -import type { ApiDrop } from "@/generated/models/ApiDrop"; -import { ApiNftLinkMediaPreviewStatusEnum } from "@/generated/models/ApiNftLinkMediaPreview"; -import { formatAddress } from "@/helpers/Helpers"; -import { getWaveRoute } from "@/helpers/navigation.helpers"; -import { getDropPreviewImageUrl } from "@/helpers/waves/drop.helpers"; - -export interface ArtFeedMediaPreview { - readonly url: string; - readonly kind: "image" | "video"; -} - -export type ArtFeedMediaType = "image" | "video" | "audio" | "other"; - -const getCombinedDropContent = (drop: ApiDrop): string => - drop.parts - .map((part) => part.content?.trim()) - .filter((content): content is string => Boolean(content)) - .join("\n\n"); - -const toNonEmptyString = (value: string | null | undefined): string | null => { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - - return trimmed; -}; - -const getMediaTypeFromMimeType = ( - mimeType: string | null | undefined -): ArtFeedMediaType | null => { - const normalized = mimeType?.toLowerCase(); - if (!normalized) { - return null; - } - - if (normalized.startsWith("image/")) { - return "image"; - } - - if (normalized.startsWith("video/")) { - return "video"; - } - - if (normalized.startsWith("audio/")) { - return "audio"; - } - - return null; -}; - -const getMediaTypeFromUrl = ( - url: string | null | undefined -): ArtFeedMediaType | null => { - const normalized = url?.split(/[?#]/)[0]?.toLowerCase(); - if (!normalized) { - return null; - } - - if (/\.(avif|gif|jpe?g|png|webp|svg)$/.test(normalized)) { - return "image"; - } - - if (/\.(m3u8|mov|mp4|webm)$/.test(normalized)) { - return "video"; - } - - if (/\.(aac|flac|m4a|mp3|ogg|wav)$/.test(normalized)) { - return "audio"; - } - - return null; -}; - -const getNftLinkMediaType = (drop: ApiDrop): ArtFeedMediaType | null => { - for (const nftLink of drop.nft_links ?? []) { - const preview = nftLink.data?.media_preview; - const previewUrl = - preview?.status === ApiNftLinkMediaPreviewStatusEnum.Ready - ? (toNonEmptyString(preview.card_url) ?? - toNonEmptyString(preview.small_url) ?? - toNonEmptyString(preview.thumb_url)) - : null; - const previewMediaType = - getMediaTypeFromMimeType(preview?.mime_type) ?? - getMediaTypeFromUrl(previewUrl); - if (previewMediaType) { - return previewMediaType; - } - - const mediaUri = toNonEmptyString(nftLink.data?.media_uri); - const mediaUriType = getMediaTypeFromUrl(mediaUri); - if (mediaUriType) { - return mediaUriType; - } - } - - return null; -}; - -export const getArtFeedText = (drop: ApiDrop): string | null => { - const combinedContent = getCombinedDropContent(drop); - - if (!combinedContent) { - return null; - } - - const processed = buildProcessedContent(combinedContent, []); - const text = processed.segments - .filter((segment) => segment.type === "text") - .map((segment) => segment.content.trim()) - .filter(Boolean) - .join(" "); - - return text.length > 0 ? text : null; -}; - -export const getArtFeedTitle = (drop: ApiDrop): string => { - const title = drop.title?.trim(); - if (title) { - return title; - } - - const text = getArtFeedText(drop); - if (text) { - return text.length > 90 ? `${text.slice(0, 87)}...` : text; - } - - return "Untitled ART drop"; -}; - -export const getArtFeedMediaPreview = ( - drop: ApiDrop -): ArtFeedMediaPreview | null => { - const previewImageUrl = getDropPreviewImageUrl(drop.metadata); - if (previewImageUrl) { - return { - kind: "image", - url: resolveIpfsUrlSync(previewImageUrl), - }; - } - - const media = drop.parts.flatMap((part) => part.media).at(0); - if (!media) { - return null; - } - - const mimeType = media.mime_type.toLowerCase(); - if (mimeType.startsWith("image/")) { - return { - kind: "image", - url: resolveIpfsUrlSync(media.url), - }; - } - - if (mimeType.startsWith("video/")) { - return { - kind: "video", - url: resolveIpfsUrlSync(media.url), - }; - } - - return null; -}; - -export const getArtFeedMediaType = (drop: ApiDrop): ArtFeedMediaType => { - if (getDropPreviewImageUrl(drop.metadata)) { - return "image"; - } - - const media = drop.parts.flatMap((part) => part.media).at(0); - if (!media) { - return getNftLinkMediaType(drop) ?? "other"; - } - - return getMediaTypeFromMimeType(media.mime_type) ?? "other"; -}; - -export const getArtFeedAuthorLabel = (drop: ApiDrop): string => - drop.author.handle - ? `@${drop.author.handle}` - : formatAddress(drop.author.primary_address); - -export const getArtFeedAuthorHref = (drop: ApiDrop): string => - `/${encodeURIComponent(drop.author.handle ?? drop.author.primary_address)}`; - -export const getArtFeedDropHref = (drop: ApiDrop): string => - getWaveRoute({ - waveId: drop.wave.id, - extraParams: { drop: drop.id }, - isDirectMessage: false, - isApp: false, - }); - -export const getArtFeedWaveHref = (drop: ApiDrop): string => - getWaveRoute({ - waveId: drop.wave.id, - isDirectMessage: false, - isApp: false, - }); diff --git a/components/brain/content/BrainContent.tsx b/components/brain/content/BrainContent.tsx index 6cc10c944c..48f5fe0072 100644 --- a/components/brain/content/BrainContent.tsx +++ b/components/brain/content/BrainContent.tsx @@ -59,18 +59,17 @@ const BrainContent: React.FC = ({ const shouldShowPinnedWaves = showPinnedWaves && breakpoint === "S" && isApp; return ( -
+
{showPinnedWaves && (
+ className="tw-sticky tw-top-0 tw-z-10 tw-bg-iron-950 tw-px-2 sm:tw-px-4 md:tw-px-6 lg:tw-hidden lg:tw-px-0" + > {shouldShowPinnedWaves && }
)} -
-
- {children} -
+
+
{children}
{activeDrop && (
diff --git a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx index 48021dd5b5..14ced1e782 100644 --- a/components/brain/left-sidebar/waves/MemesWaveFooter.tsx +++ b/components/brain/left-sidebar/waves/MemesWaveFooter.tsx @@ -1,6 +1,5 @@ "use client"; -import ArtFeedTrigger from "@/components/art-feed/ArtFeedTrigger"; import MemesWaveQuickVoteTrigger from "@/components/brain/left-sidebar/waves/MemesWaveQuickVoteTrigger"; import MemesWaveZapIcon from "@/components/brain/left-sidebar/waves/MemesWaveZapIcon"; import { useMemesWaveFooterStats } from "@/hooks/useMemesWaveFooterStats"; @@ -44,7 +43,7 @@ const MemesWaveFooter: React.FC = ({ leftThisRoundCount )}, ${formatMemesQuickVoteUnratedText(unratedCount)}` : "Quick vote"; - const buttonTitle = isReady ? "Uncast votes" : "Quick vote"; + const buttonTitle = isReady ? "Uncast Power" : "Quick vote"; const votingPowerLabel = votingLabel ? ` ${votingLabel}` : " votes"; const buttonValue = isReady && typeof uncastPower === "number" @@ -82,16 +81,13 @@ const MemesWaveFooter: React.FC = ({ } > {collapsed ? ( - <> - - - + ) : (
@@ -109,7 +105,7 @@ const MemesWaveFooter: React.FC = ({ className="tw-pointer-events-none tw-absolute tw-inset-0 -tw-translate-x-full tw-bg-gradient-to-r tw-from-white/0 tw-via-white/[0.08] tw-to-white/0 tw-opacity-50 tw-transition-transform tw-duration-1000 tw-ease-out desktop-hover:group-hover:tw-translate-x-full" />
- + {buttonTitle} @@ -135,7 +131,6 @@ const MemesWaveFooter: React.FC = ({ )}
-
)} diff --git a/components/brain/my-stream/MyStreamWaveContent.tsx b/components/brain/my-stream/MyStreamWaveContent.tsx index c69a436714..6240a61939 100644 --- a/components/brain/my-stream/MyStreamWaveContent.tsx +++ b/components/brain/my-stream/MyStreamWaveContent.tsx @@ -181,7 +181,7 @@ const MyStreamWaveContent: React.FC = ({ waveId }) => { return (
{/* Always render tab container (hidden on app inside MyStreamWaveTabs) */} @@ -195,7 +195,7 @@ const MyStreamWaveContent: React.FC = ({ waveId }) => { />
[] = + [ + { key: "all", label: "All", value: "all" }, + { key: "image", label: "Images", value: "image" }, + { key: "video", label: "Video", value: "video" }, + { key: "audio", label: "Audio", value: "audio" }, + ]; + +function CommunityCurationsSkeletonCard() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function CommunityCurationsEmptyState({ + title, + description, +}: { + readonly title: string; + readonly description: string; +}) { + return ( +
+

+ {title} +

+

{description}

+
+ ); +} + +export default function CommunityCurations() { + const { waveViewStyle } = useLayout(); + const [scrollContainer, setScrollContainer] = useState( + null + ); + const [mediaFilter, setMediaFilter] = + useState("all"); + const { + allDrops, + drops, + fetchNextPage, + hasNextPage, + isError, + isFetchingNextPage, + isLoading, + } = useCommunityCurationsDrops({ + mediaFilter, + limit: COMMUNITY_CURATIONS_LIMIT, + }); + + const isInitialLoading = isLoading && allDrops.length === 0; + const handleFetchNextPage = useCallback(async () => { + await fetchNextPage(); + }, [fetchNextPage]); + + return ( +
+
+
+
+

+ Community Curations +

+

+ Community-curated drops from across 6529 Waves. +

+
+ +
+
+ +
+
+
+ +
+ {isInitialLoading && ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ )} + + {!isInitialLoading && isError && ( + + )} + + {!isInitialLoading && !isError && drops.length === 0 && ( + + )} + + {!isInitialLoading && !isError && drops.length > 0 && ( + + )} +
+
+
+ ); +} diff --git a/components/community-curations/CommunityCurationsMasonry.tsx b/components/community-curations/CommunityCurationsMasonry.tsx new file mode 100644 index 0000000000..41ec53fd20 --- /dev/null +++ b/components/community-curations/CommunityCurationsMasonry.tsx @@ -0,0 +1,309 @@ +"use client"; + +import CircleLoader, { + CircleLoaderSize, +} from "@/components/distribution-plan-tool/common/CircleLoader"; +import { TweetPreviewModeProvider } from "@/components/tweets/TweetPreviewModeContext"; +import Drop, { DropLocation } from "@/components/waves/drops/Drop"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { useIntersectionObserver } from "@/hooks/scroll/useIntersectionObserver"; +import { + type RenderComponentProps, + useMasonry, + usePositioner, + useResizeObserver, +} from "masonic"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, +} from "react"; + +const MASONRY_COLUMN_WIDTH = 300; +const MASONRY_GUTTER = 16; +const SCROLL_IDLE_DELAY_MS = 120; + +type PanelViewport = { + readonly height: number; + readonly isScrolling: boolean; + readonly scrollTop: number; +}; + +interface CommunityCurationsMasonryProps { + readonly drops: readonly ExtendedDrop[]; + readonly fetchNextPage: () => Promise; + readonly hasNextPage: boolean | undefined; + readonly isFetchingNextPage: boolean; + readonly scrollContainer: HTMLElement | null; +} + +const EMPTY_VIEWPORT: PanelViewport = { + height: 0, + isScrolling: false, + scrollTop: 0, +}; + +const noop = () => {}; + +const getDropKey = (drop: ExtendedDrop) => drop.stableKey; + +const getGridScrollTop = ( + scrollContainer: HTMLElement, + gridElement: HTMLElement | null +) => { + if (!gridElement) { + return 0; + } + + const scrollRect = scrollContainer.getBoundingClientRect(); + const gridRect = gridElement.getBoundingClientRect(); + const gridOffsetTop = + gridRect.top - scrollRect.top + scrollContainer.scrollTop; + + return Math.max(0, scrollContainer.scrollTop - gridOffsetTop); +}; + +const areViewportsEqual = (left: PanelViewport, right: PanelViewport) => + left.height === right.height && + left.isScrolling === right.isScrolling && + left.scrollTop === right.scrollTop; + +function useElementWidth(element: HTMLElement | null) { + const [width, setWidth] = useState(0); + + useEffect(() => { + if (!element || typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(([entry]) => { + const nextWidth = Math.floor(entry?.contentRect.width ?? 0); + setWidth((currentWidth) => + currentWidth === nextWidth ? currentWidth : nextWidth + ); + }); + + observer.observe(element); + return () => observer.disconnect(); + }, [element]); + + return { setWidth, width }; +} + +function usePanelViewport( + scrollContainer: HTMLElement | null, + gridElement: HTMLElement | null +) { + const [viewport, setViewport] = useState(EMPTY_VIEWPORT); + + useEffect(() => { + if (!scrollContainer) { + return; + } + + let idleTimeout: ReturnType | null = null; + let frameId: number | null = null; + + const readViewport = (isScrolling: boolean): PanelViewport => ({ + height: scrollContainer.clientHeight, + isScrolling, + scrollTop: getGridScrollTop(scrollContainer, gridElement), + }); + + const scheduleViewportUpdate = (isScrolling: boolean) => { + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + + frameId = requestAnimationFrame(() => { + frameId = null; + const nextViewport = readViewport(isScrolling); + setViewport((currentViewport) => + areViewportsEqual(currentViewport, nextViewport) + ? currentViewport + : nextViewport + ); + }); + }; + + const onScroll = () => { + scheduleViewportUpdate(true); + + if (idleTimeout) { + clearTimeout(idleTimeout); + } + + idleTimeout = setTimeout( + () => scheduleViewportUpdate(false), + SCROLL_IDLE_DELAY_MS + ); + }; + + scheduleViewportUpdate(false); + scrollContainer.addEventListener("scroll", onScroll, { passive: true }); + + const observer = + typeof ResizeObserver === "undefined" + ? null + : new ResizeObserver(() => scheduleViewportUpdate(false)); + + observer?.observe(scrollContainer); + if (gridElement) { + observer?.observe(gridElement); + } + + return () => { + scrollContainer.removeEventListener("scroll", onScroll); + observer?.disconnect(); + if (idleTimeout) { + clearTimeout(idleTimeout); + } + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, [gridElement, scrollContainer]); + + return scrollContainer ? viewport : EMPTY_VIEWPORT; +} + +function CommunityCurationsInfiniteScrollTrigger({ + onIntersection, + scrollContainer, +}: { + readonly onIntersection: (isIntersecting: boolean) => void; + readonly scrollContainer: HTMLElement | null; +}) { + const triggerRef = useRef(null); + const handleIntersection = useCallback( + (entry: IntersectionObserverEntry) => onIntersection(entry.isIntersecting), + [onIntersection] + ); + + useIntersectionObserver( + triggerRef, + { root: scrollContainer, rootMargin: "160px 0px", threshold: 0 }, + handleIntersection, + Boolean(scrollContainer) + ); + + return