[] =
+ [
+ { key: "all", label: "All", value: "all" },
+ { key: "image", label: "Images", value: "image" },
+ { key: "video", label: "Video", value: "video" },
+ ];
+
+const COMMUNITY_CURATIONS_SKELETON_CARDS = [
+ { id: "compact", mediaHeight: 210, lines: 2 },
+ { id: "tall", mediaHeight: 320, lines: 4 },
+ { id: "mid", mediaHeight: 250, lines: 3 },
+ { id: "feature", mediaHeight: 390, lines: 2 },
+ { id: "balanced", mediaHeight: 280, lines: 4 },
+ { id: "short", mediaHeight: 220, lines: 3 },
+ { id: "portrait", mediaHeight: 350, lines: 3 },
+ { id: "wide", mediaHeight: 260, lines: 2 },
+] as const;
+
+const COMMUNITY_CURATIONS_SKELETON_LINE_IDS = [
+ "headline",
+ "summary",
+ "detail",
+ "caption",
+] as const;
+
+function CommunityCurationsSkeletonCard({
+ lines,
+ mediaHeight,
+}: {
+ readonly lines: number;
+ readonly mediaHeight: number;
+}) {
+ return (
+
+
+
+
+
+
+ {COMMUNITY_CURATIONS_SKELETON_LINE_IDS.slice(0, lines).map(
+ (lineId, index) => (
+
+ )
+ )}
+
+
+
+ );
+}
+
+function CommunityCurationsSkeletonGrid() {
+ return (
+
+ {COMMUNITY_CURATIONS_SKELETON_CARDS.map((card) => (
+
+ ))}
+
+ );
+}
+
+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 hasMorePages = Boolean(hasNextPage) || isFetchingNextPage;
+ const shouldShowEmptyState =
+ !isInitialLoading && !isError && drops.length === 0 && !hasMorePages;
+ const shouldShowMasonry =
+ !isInitialLoading && !isError && (drops.length > 0 || hasMorePages);
+ const emptyStateTitle =
+ mediaFilter === "all"
+ ? "No curated drops yet"
+ : `No ${mediaFilter} drops found`;
+ const emptyStateDescription =
+ mediaFilter === "all"
+ ? "Community-curated drops will appear here when visible curations have activity."
+ : "Try All to see every community-curated drop.";
+ const handleFetchNextPage = useCallback(async () => {
+ await fetchNextPage();
+ }, [fetchNextPage]);
+
+ return (
+
+
+
+
+
+ Community Curations
+
+
+ Community-curated drops from across 6529 Waves.
+
+
+
+
+
+
+
+ {isInitialLoading && }
+
+ {!isInitialLoading && isError && (
+
+ )}
+
+ {shouldShowEmptyState && (
+
+ )}
+
+ {shouldShowMasonry && (
+
+ )}
+
+
+
+ );
+}
diff --git a/components/community-curations/CommunityCurationsMasonry.tsx b/components/community-curations/CommunityCurationsMasonry.tsx
new file mode 100644
index 0000000000..da8810bbff
--- /dev/null
+++ b/components/community-curations/CommunityCurationsMasonry.tsx
@@ -0,0 +1,355 @@
+"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 Dispatch,
+ type ReactElement,
+ type SetStateAction,
+} from "react";
+
+const MASONRY_COLUMN_WIDTH = 300;
+const MASONRY_GUTTER = 16;
+const INFINITE_SCROLL_ROOT_MARGIN = "1200px 0px";
+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;
+
+const readPanelViewport = (
+ scrollContainer: HTMLElement,
+ gridElement: HTMLElement | null,
+ isScrolling: boolean
+): PanelViewport => ({
+ height: scrollContainer.clientHeight,
+ isScrolling,
+ scrollTop: getGridScrollTop(scrollContainer, gridElement),
+});
+
+const setPanelViewport = (
+ setViewport: Dispatch>,
+ nextViewport: PanelViewport
+) => {
+ setViewport((currentViewport) =>
+ areViewportsEqual(currentViewport, nextViewport)
+ ? currentViewport
+ : nextViewport
+ );
+};
+
+const schedulePanelViewportUpdate = ({
+ frameId,
+ gridElement,
+ isScrolling,
+ scrollContainer,
+ setViewport,
+}: {
+ readonly frameId: number | null;
+ readonly gridElement: HTMLElement | null;
+ readonly isScrolling: boolean;
+ readonly scrollContainer: HTMLElement;
+ readonly setViewport: Dispatch>;
+}): number => {
+ if (frameId !== null) {
+ cancelAnimationFrame(frameId);
+ }
+
+ return requestAnimationFrame(() => {
+ const nextViewport = readPanelViewport(
+ scrollContainer,
+ gridElement,
+ isScrolling
+ );
+ setPanelViewport(setViewport, nextViewport);
+ });
+};
+
+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 scheduleViewportUpdate = (isScrolling: boolean) => {
+ frameId = schedulePanelViewportUpdate({
+ frameId,
+ gridElement,
+ isScrolling,
+ scrollContainer,
+ setViewport,
+ });
+ };
+
+ 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: INFINITE_SCROLL_ROOT_MARGIN,
+ threshold: 0,
+ },
+ handleIntersection,
+ Boolean(scrollContainer)
+ );
+
+ return ;
+}
+
+function CommunityCurationsMasonryItem({
+ data: drop,
+}: RenderComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function CommunityCurationsVirtualMasonry({
+ drops,
+ scrollContainer,
+}: {
+ readonly drops: readonly ExtendedDrop[];
+ readonly scrollContainer: HTMLElement | null;
+}): ReactElement {
+ const [gridElement, setGridElement] = useState(null);
+ const { setWidth, width } = useElementWidth(gridElement);
+ const viewport = usePanelViewport(scrollContainer, gridElement);
+ const items = useMemo(() => [...drops], [drops]);
+ const resetKey = useMemo(
+ () => items.slice(0, 8).map(getDropKey).join("|"),
+ [items]
+ );
+ const positioner = usePositioner(
+ {
+ columnGutter: MASONRY_GUTTER,
+ columnWidth: MASONRY_COLUMN_WIDTH,
+ rowGutter: MASONRY_GUTTER,
+ width: Math.max(width, 1),
+ },
+ [resetKey, items.length]
+ );
+ const resizeObserver = useResizeObserver(positioner);
+ const setContainerRef = useCallback(
+ (element: HTMLElement | null) => {
+ setGridElement(element);
+ setWidth(element?.offsetWidth ?? 0);
+ },
+ [setWidth]
+ );
+
+ return useMasonry({
+ containerRef: setContainerRef,
+ height: Math.max(viewport.height, 1),
+ isScrolling: viewport.isScrolling,
+ itemHeightEstimate: 420,
+ itemKey: getDropKey,
+ items,
+ overscanBy: 2,
+ positioner,
+ render: CommunityCurationsMasonryItem,
+ resizeObserver,
+ scrollTop: viewport.scrollTop,
+ }) as ReactElement;
+}
+
+export default function CommunityCurationsMasonry({
+ drops,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ scrollContainer,
+}: CommunityCurationsMasonryProps) {
+ const shouldShowPaginationFooter = Boolean(hasNextPage) || isFetchingNextPage;
+ const shouldShowLoader = isFetchingNextPage || drops.length === 0;
+ const handleIntersection = useCallback(
+ (isIntersecting: boolean) => {
+ if (!isIntersecting || !hasNextPage || isFetchingNextPage) {
+ return;
+ }
+
+ fetchNextPage().catch(() => undefined);
+ },
+ [fetchNextPage, hasNextPage, isFetchingNextPage]
+ );
+
+ return (
+
+
+
+
+ {shouldShowPaginationFooter && (
+
+ {!isFetchingNextPage && (
+
+ )}
+ {shouldShowLoader && (
+
+ )}
+
+ )}
+
+
+ );
+}
diff --git a/components/community-curations/communityCurations.constants.ts b/components/community-curations/communityCurations.constants.ts
new file mode 100644
index 0000000000..e287e5247f
--- /dev/null
+++ b/components/community-curations/communityCurations.constants.ts
@@ -0,0 +1 @@
+export const COMMUNITY_CURATIONS_LIMIT = 12;
diff --git a/components/community-curations/communityCurations.helpers.ts b/components/community-curations/communityCurations.helpers.ts
new file mode 100644
index 0000000000..4a6ba6f0a1
--- /dev/null
+++ b/components/community-curations/communityCurations.helpers.ts
@@ -0,0 +1,103 @@
+import type { ApiDrop } from "@/generated/models/ApiDrop";
+import { ApiNftLinkMediaPreviewStatusEnum } from "@/generated/models/ApiNftLinkMediaPreview";
+import { getDropPreviewImageUrl } from "@/helpers/waves/drop.helpers";
+
+export type CommunityCurationsMediaType = "image" | "video" | "audio" | "other";
+
+const toNonEmptyString = (value: string | null | undefined): string | null => {
+ const trimmed = value?.trim();
+ if (!trimmed) {
+ return null;
+ }
+
+ return trimmed;
+};
+
+const getMediaTypeFromMimeType = (
+ mimeType: string | null | undefined
+): CommunityCurationsMediaType | 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
+): CommunityCurationsMediaType | 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
+): CommunityCurationsMediaType | 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 getCommunityCurationsMediaType = (
+ drop: ApiDrop
+): CommunityCurationsMediaType => {
+ 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";
+};
diff --git a/components/drops/view/BoostedDropCard.tsx b/components/drops/view/BoostedDropCard.tsx
index 77b663e168..8bdc8acf19 100644
--- a/components/drops/view/BoostedDropCard.tsx
+++ b/components/drops/view/BoostedDropCard.tsx
@@ -44,8 +44,8 @@ const BoostedDropCard = memo(
const rankClasses =
{
1: "tw-text-yellow-400",
- 2: "tw-text-iron-300",
- 3: "tw-text-amber-500",
+ 2: "tw-text-iron-400",
+ 3: "tw-text-iron-400",
}[rank] ?? "tw-text-iron-400";
const isBoosted = drop.context_profile_context?.boosted ?? false;
@@ -64,8 +64,8 @@ const BoostedDropCard = memo(
const isTopRank = rank === 1;
const cardThemeClasses =
highlightTopOnly && !isTopRank
- ? "tw-bg-iron-900/40 tw-ring-iron-800/60 hover:tw-ring-iron-700/70"
- : "tw-bg-gradient-to-r tw-from-amber-950/30 tw-to-iron-900/50 tw-ring-amber-700/30 hover:tw-ring-amber-600/50";
+ ? "tw-bg-iron-900/65 tw-ring-iron-800/80 hover:tw-bg-iron-900/85 hover:tw-ring-iron-700/90"
+ : "tw-bg-amber-950/10 tw-ring-amber-700/30 hover:tw-bg-amber-950/15 hover:tw-ring-amber-600/40";
return (
#{rank}
-
+
{resolvedPfp ? (
-
+
{drop.author.handle}
@@ -128,31 +128,32 @@ const BoostedDropCard = memo(
}
}}
disabled={!canBoost || isPending}
- className={`tw-flex tw-h-6 tw-flex-shrink-0 tw-items-center tw-gap-x-1.5 tw-rounded-full tw-border-0 tw-px-2.5 tw-transition-colors tw-duration-200 ${
+ className={`tw-flex tw-h-5 tw-flex-shrink-0 tw-items-center tw-justify-center tw-gap-x-1 tw-rounded-full tw-border-0 tw-px-2 tw-leading-none tw-transition-colors tw-duration-200 ${
canBoost
? "tw-cursor-pointer tw-ring-1 tw-ring-amber-500/20 hover:tw-bg-amber-600/20"
: "tw-cursor-default"
} ${isBoosted ? "tw-bg-amber-600/20" : "tw-bg-amber-600/10"}`}
aria-label={isBoosted ? "Remove boost" : "Boost"}
>
-
+
{drop.boosts}
-
+
);
diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx
index 314dc763a2..33b09530de 100644
--- a/components/react-query-wrapper/ReactQueryWrapper.tsx
+++ b/components/react-query-wrapper/ReactQueryWrapper.tsx
@@ -10,7 +10,7 @@ import type { ApiDrop } from "@/generated/models/ApiDrop";
import type { ApiProfileProxy } from "@/generated/models/ApiProfileProxy";
import type { ApiWave } from "@/generated/models/ApiWave";
import type { ApiWaveDropsFeed } from "@/generated/models/ApiWaveDropsFeed";
-import type { ApiIdentity } from "@/generated/models/ObjectSerializer";
+import type { ApiIdentity } from "@/generated/models/ApiIdentity";
import { wait } from "@/helpers/Helpers";
import { convertActivityLogParams } from "@/helpers/profile-logs.helpers";
import { Time } from "@/helpers/time";
diff --git a/components/shared/WavesMessagesWrapper.tsx b/components/shared/WavesMessagesWrapper.tsx
index f997138e6e..55c0990053 100644
--- a/components/shared/WavesMessagesWrapper.tsx
+++ b/components/shared/WavesMessagesWrapper.tsx
@@ -110,8 +110,11 @@ const WavesMessagesWrapper: React.FC = ({
);
// Clear logic for when to show each part
- const shouldShowLeftSidebar = showLeftSidebar && (!isMobile || !waveId);
- const shouldShowMainContent = !isMobile || waveId !== undefined;
+ const hasWave = waveId !== undefined;
+ const canShowMainContent = !isMobile || hasWave;
+ const shouldShowLeftSidebar =
+ showLeftSidebar && (!isMobile || (!hasWave && !canShowMainContent));
+ const shouldShowMainContent = canShowMainContent;
const shouldShowDropOverlay =
isDropOpen && drop !== undefined && shouldShowMainContent;
const shouldShowRightSidebar = Boolean(
@@ -130,18 +133,18 @@ const WavesMessagesWrapper: React.FC = ({
return (
-
-
-
+
+
+
{shouldShowLeftSidebar && (
)}
{shouldShowMainContent && (
-
+
{children}
{shouldShowDropOverlay && (
diff --git a/components/user/collected/UserPageCollected.tsx b/components/user/collected/UserPageCollected.tsx
index 1a65997e09..016b27bb5a 100644
--- a/components/user/collected/UserPageCollected.tsx
+++ b/components/user/collected/UserPageCollected.tsx
@@ -17,7 +17,7 @@ import {
} from "@/entities/IProfile";
import type { MemeSeason } from "@/entities/ISeason";
import { SortDirection } from "@/entities/ISort";
-import type { ApiIdentity } from "@/generated/models/ObjectSerializer";
+import type { ApiIdentity } from "@/generated/models/ApiIdentity";
import { areEqualAddresses } from "@/helpers/Helpers";
import type { Page } from "@/helpers/Types";
import useIsMobileScreen from "@/hooks/isMobileScreen";
@@ -290,7 +290,9 @@ const applyQueryUpdateItemsToState = ({
}
case "subcollection":
nextState.subcollection =
- nextState.collection === CollectedCollectionType.NETWORK ? value : null;
+ nextState.collection === CollectedCollectionType.NETWORK
+ ? value
+ : null;
break;
case "seized":
nextState.seized = convertSeized({
@@ -375,9 +377,7 @@ export default function UserPageCollected({
szn: sznParam ?? null,
collection: convertedCollection,
}),
- page: normalizePageNumber(
- pageParam ? Number.parseInt(pageParam, 10) : 1
- ),
+ page: normalizePageNumber(pageParam ? Number.parseInt(pageParam, 10) : 1),
pageSize: PAGE_SIZE,
sortBy: convertSortedBy({
sortBy: sortByParam ?? null,
diff --git a/components/user/collected/UserPageCollectedStats.tsx b/components/user/collected/UserPageCollectedStats.tsx
index 60e82f317b..bf3946435a 100644
--- a/components/user/collected/UserPageCollectedStats.tsx
+++ b/components/user/collected/UserPageCollectedStats.tsx
@@ -3,7 +3,7 @@
import type { UserPageStatsInitialData } from "@/components/user/stats/userPageStats.types";
import { SEARCH_PARAM_ACTIVITY } from "@/components/user/stats/activity/activity.helpers";
import type { CollectedCollectionType } from "@/entities/IProfile";
-import type { ApiIdentity } from "@/generated/models/ObjectSerializer";
+import type { ApiIdentity } from "@/generated/models/ApiIdentity";
import useDeviceInfo from "@/hooks/useDeviceInfo";
import { useSearchParams } from "next/navigation";
import { useId, useMemo, useState } from "react";
diff --git a/components/user/collected/stats/helpers.ts b/components/user/collected/stats/helpers.ts
index a6fcdf8f19..fc9061003b 100644
--- a/components/user/collected/stats/helpers.ts
+++ b/components/user/collected/stats/helpers.ts
@@ -5,7 +5,7 @@ import {
import { CollectedCollectionType } from "@/entities/IProfile";
import type { ApiCollectedStats } from "@/generated/models/ApiCollectedStats";
import type { ApiCollectedStatsSeason } from "@/generated/models/ApiCollectedStatsSeason";
-import type { ApiIdentity } from "@/generated/models/ObjectSerializer";
+import type { ApiIdentity } from "@/generated/models/ApiIdentity";
import { formatNumberWithCommasOrDash } from "@/helpers/Helpers";
import type {
CollectedHeaderMetric,
diff --git a/components/user/collected/stats/subcomponents/CollectedStatsDetailsPanel.tsx b/components/user/collected/stats/subcomponents/CollectedStatsDetailsPanel.tsx
index 159fa2e482..6c97840bc8 100644
--- a/components/user/collected/stats/subcomponents/CollectedStatsDetailsPanel.tsx
+++ b/components/user/collected/stats/subcomponents/CollectedStatsDetailsPanel.tsx
@@ -3,7 +3,7 @@ import CommonAnimationHeight from "@/components/utils/animation/CommonAnimationH
import type { OwnerBalance, OwnerBalanceMemes } from "@/entities/IBalances";
import type { MemeSeason } from "@/entities/ISeason";
import type { ConsolidatedTDH, TDH } from "@/entities/ITDH";
-import type { ApiIdentity } from "@/generated/models/ObjectSerializer";
+import type { ApiIdentity } from "@/generated/models/ApiIdentity";
interface CollectedStatsDetailsPanelProps {
readonly isOpen: boolean;
diff --git a/components/user/collected/stats/types.ts b/components/user/collected/stats/types.ts
index 578615a8aa..20fb56b14a 100644
--- a/components/user/collected/stats/types.ts
+++ b/components/user/collected/stats/types.ts
@@ -4,7 +4,7 @@ import type { CollectedCollectionType } from "@/entities/IProfile";
import type { MemeSeason } from "@/entities/ISeason";
import type { ConsolidatedTDH, TDH } from "@/entities/ITDH";
import type { ApiCollectedStats } from "@/generated/models/ApiCollectedStats";
-import type { ApiIdentity } from "@/generated/models/ObjectSerializer";
+import type { ApiIdentity } from "@/generated/models/ApiIdentity";
export type CollectedHeaderMetric = {
readonly id: string;
diff --git a/components/waves/WaveScreenMessage.tsx b/components/waves/WaveScreenMessage.tsx
deleted file mode 100644
index c6d64dad2d..0000000000
--- a/components/waves/WaveScreenMessage.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import type { ReactNode } from "react";
-
-interface WaveScreenMessageProps {
- readonly title: ReactNode;
- readonly description: ReactNode;
- readonly action?: ReactNode;
-}
-
-export default function WaveScreenMessage({
- title,
- description,
- action,
-}: WaveScreenMessageProps) {
- return (
-
-
- {title}
-
-
- {description}
-
- {action !== undefined && action !== null ? (
-
{action}
- ) : null}
-
- );
-}
diff --git a/components/waves/WavesView.tsx b/components/waves/WavesView.tsx
index 4353a5ae8f..2064e6b304 100644
--- a/components/waves/WavesView.tsx
+++ b/components/waves/WavesView.tsx
@@ -1,21 +1,15 @@
"use client";
import React from "react";
-import { PlusIcon } from "@heroicons/react/24/outline";
+import CommunityCurations from "@/components/community-curations/CommunityCurations";
import MyStreamWave from "../brain/my-stream/MyStreamWave";
import BrainContent from "../brain/content/BrainContent";
-import { useAuth } from "../auth/Auth";
import useDeviceInfo from "../../hooks/useDeviceInfo";
-import PrimaryButton from "../utils/button/PrimaryButton";
-import useCreateModalState from "@/hooks/useCreateModalState";
import { useMyStreamOptional } from "@/contexts/wave/MyStreamContext";
-import WaveScreenMessage from "./WaveScreenMessage";
const WavesView: React.FC = () => {
const myStream = useMyStreamOptional();
- const { connectedProfile } = useAuth();
const { isApp } = useDeviceInfo();
- const { openWave } = useCreateModalState();
const serialisedWaveId = myStream?.activeWave.id ?? null;
@@ -31,31 +25,18 @@ const WavesView: React.FC = () => {
/>
);
} else if (showPlaceholder) {
- content = (
-
-
- Create Wave
-
- ) : null
- }
- />
- );
+ content = ;
}
// Note: Wave views (MyStreamWave) manage their own activeDrop state
// internally via MyStreamWaveChat. We pass null to BrainContent because
// the wave's internal state controls the reply/quote input box.
return (
- {}}>
+ {}}
+ showPinnedWaves={Boolean(serialisedWaveId)}
+ >
{content}
);
diff --git a/components/waves/drop/SingleWaveDropAuthor.tsx b/components/waves/drop/SingleWaveDropAuthor.tsx
index d775de528b..74559d8985 100644
--- a/components/waves/drop/SingleWaveDropAuthor.tsx
+++ b/components/waves/drop/SingleWaveDropAuthor.tsx
@@ -2,7 +2,7 @@
import React from "react";
import ProfileNameWithAiMarker from "@/components/common/profile/ProfileNameWithAiMarker";
-import type { ApiDrop } from "@/generated/models/ObjectSerializer";
+import type { ApiDrop } from "@/generated/models/ApiDrop";
import { resolveIpfsUrlSync } from "@/components/ipfs/IPFSContext";
import Link from "next/link";
import Image from "next/image";
diff --git a/components/waves/drop/SingleWaveDropChat.tsx b/components/waves/drop/SingleWaveDropChat.tsx
index 76cd99e555..b8a1074b99 100644
--- a/components/waves/drop/SingleWaveDropChat.tsx
+++ b/components/waves/drop/SingleWaveDropChat.tsx
@@ -1,7 +1,8 @@
"use client";
import React, { useMemo, useState } from "react";
-import type { ApiDrop, ApiWave } from "@/generated/models/ObjectSerializer";
+import type { ApiDrop } from "@/generated/models/ApiDrop";
+import type { ApiWave } from "@/generated/models/ApiWave";
import useDeviceInfo from "@/hooks/useDeviceInfo";
import WaveDropsAll from "../drops/wave-drops-all";
import {
diff --git a/components/waves/drop/SingleWaveDropVote.tsx b/components/waves/drop/SingleWaveDropVote.tsx
index 89ae1f44a9..8be04f3653 100644
--- a/components/waves/drop/SingleWaveDropVote.tsx
+++ b/components/waves/drop/SingleWaveDropVote.tsx
@@ -1,5 +1,5 @@
import dynamic from "next/dynamic";
-import type { ApiDrop } from "@/generated/models/ObjectSerializer";
+import type { ApiDrop } from "@/generated/models/ApiDrop";
export enum SingleWaveDropVoteSize {
NORMAL = "NORMAL",
diff --git a/components/waves/drop/SingleWaveDropVotes.tsx b/components/waves/drop/SingleWaveDropVotes.tsx
index 6be9198d1a..b14479a570 100644
--- a/components/waves/drop/SingleWaveDropVotes.tsx
+++ b/components/waves/drop/SingleWaveDropVotes.tsx
@@ -1,11 +1,11 @@
"use client";
import DropVoteProgressing from "@/components/drops/view/utils/DropVoteProgressing";
-import type { ApiDrop } from "@/generated/models/ObjectSerializer";
+import type { ApiDrop } from "@/generated/models/ApiDrop";
import { formatNumberWithCommas } from "@/helpers/Helpers";
import {
- WAVE_VOTE_STATS_LABELS,
- WAVE_VOTING_LABELS,
+ WAVE_VOTE_STATS_LABELS,
+ WAVE_VOTING_LABELS,
} from "@/helpers/waves/waves.constants";
import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules";
import React from "react";
@@ -29,7 +29,7 @@ export const SingleWaveDropVotes: React.FC = ({
const shouldShowUserVote = (isVotingEnded || isWinner) && hasUserVoted;
return (
-
+
= ({
current={drop.rating}
projected={drop.rating_prediction}
/>
-
+
{WAVE_VOTING_LABELS[drop.wave.voting_credit_type]}{" "}
{WAVE_VOTE_STATS_LABELS.TOTAL}
@@ -61,7 +61,7 @@ export const SingleWaveDropVotes: React.FC = ({
>
{isUserVoteNegative && "-"}
{formatNumberWithCommas(Math.abs(userVote))}{" "}
-
+
{WAVE_VOTING_LABELS[drop.wave.voting_credit_type]}
diff --git a/components/waves/drops/ContentDisplay.tsx b/components/waves/drops/ContentDisplay.tsx
index 0c345e844c..251064f0e1 100644
--- a/components/waves/drops/ContentDisplay.tsx
+++ b/components/waves/drops/ContentDisplay.tsx
@@ -9,6 +9,7 @@ interface ContentDisplayProps {
readonly textClassName?: string | undefined;
readonly shouldClamp?: boolean;
readonly linkify?: boolean;
+ readonly linkClassName?: string | undefined;
}
/**
@@ -21,6 +22,7 @@ export default function ContentDisplay({
textClassName,
shouldClamp = true,
linkify = true,
+ linkClassName,
}: ContentDisplayProps) {
const clampClass = shouldClamp ? "tw-line-clamp-1" : "";
const containerClasses = [
@@ -46,6 +48,7 @@ export default function ContentDisplay({
segment={segment}
index={i}
linkify={linkify}
+ linkClassName={linkClassName}
/>
))}
diff --git a/components/waves/drops/ContentSegmentComponent.tsx b/components/waves/drops/ContentSegmentComponent.tsx
index 0b40dc1436..fd16fb17fa 100644
--- a/components/waves/drops/ContentSegmentComponent.tsx
+++ b/components/waves/drops/ContentSegmentComponent.tsx
@@ -5,17 +5,29 @@ interface ContentSegmentComponentProps {
readonly segment: ContentSegment;
readonly index: number;
readonly linkify?: boolean;
+ readonly linkClassName?: string | undefined;
}
const URL_REGEX = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]"'])/g;
-function linkifyText(text: string, segmentIndex: number): React.ReactNode[] {
+const DEFAULT_LINK_CLASSES =
+ "tw-text-iron-200/90 tw-underline tw-decoration-white/20 tw-underline-offset-2 tw-transition-colors tw-duration-300 hover:tw-text-iron-50 hover:tw-decoration-white/45";
+
+function linkifyText({
+ linkClassName,
+ segmentIndex,
+ text,
+}: {
+ readonly linkClassName?: string | undefined;
+ readonly segmentIndex: number;
+ readonly text: string;
+}): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
let partIndex = 0;
- const regex = new RegExp(URL_REGEX.source, "g");
- while ((match = regex.exec(text)) !== null) {
+ URL_REGEX.lastIndex = 0;
+ while ((match = URL_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(
@@ -30,12 +42,12 @@ function linkifyText(text: string, segmentIndex: number): React.ReactNode[] {
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
- className="tw-text-iron-200/90 tw-underline tw-decoration-white/20 tw-underline-offset-2 tw-transition-colors tw-duration-300 hover:tw-text-iron-50 hover:tw-decoration-white/45"
+ className={linkClassName ?? DEFAULT_LINK_CLASSES}
>
{match[0]}
);
- lastIndex = regex.lastIndex;
+ lastIndex = URL_REGEX.lastIndex;
}
if (lastIndex < text.length) {
@@ -56,11 +68,18 @@ export default function ContentSegmentComponent({
segment,
index,
linkify = true,
+ linkClassName,
}: ContentSegmentComponentProps) {
if (segment.type === "text") {
return (
- {linkify ? linkifyText(segment.content, index) : segment.content}
+ {linkify
+ ? linkifyText({
+ linkClassName,
+ segmentIndex: index,
+ text: segment.content,
+ })
+ : segment.content}
);
}
diff --git a/components/waves/drops/reaction-utils.ts b/components/waves/drops/reaction-utils.ts
index ddfce438c6..954ef7df29 100644
--- a/components/waves/drops/reaction-utils.ts
+++ b/components/waves/drops/reaction-utils.ts
@@ -1,4 +1,4 @@
-import type { ApiIdentity } from "@/generated/models/ObjectSerializer";
+import type { ApiIdentity } from "@/generated/models/ApiIdentity";
import type { ApiProfileMin } from "@/generated/models/ApiProfileMin";
import type { ApiDropReaction } from "@/generated/models/ApiDropReaction";
import { getBannerColorValue } from "@/helpers/profile-banner.helpers";
diff --git a/components/waves/header/WaveHeader.tsx b/components/waves/header/WaveHeader.tsx
index b811bb509d..a8770434e6 100644
--- a/components/waves/header/WaveHeader.tsx
+++ b/components/waves/header/WaveHeader.tsx
@@ -10,7 +10,7 @@ import WaveHeaderName from "./name/WaveHeaderName";
import WaveHeaderFollowers from "./WaveHeaderFollowers";
import WaveHeaderDescription from "./WaveHeaderDescription";
import WaveHeaderPinButton from "./WaveHeaderPinButton";
-import { ApiWaveType } from "@/generated/models/ObjectSerializer";
+import { ApiWaveType } from "@/generated/models/ApiWaveType";
import WavePicture from "../WavePicture";
import { Time } from "@/helpers/time";
import WaveNotificationSettings from "../specs/WaveNotificationSettings";
diff --git a/components/waves/layout/WavesLayout.tsx b/components/waves/layout/WavesLayout.tsx
index 8aa036d9eb..f83e2543e1 100644
--- a/components/waves/layout/WavesLayout.tsx
+++ b/components/waves/layout/WavesLayout.tsx
@@ -1,16 +1,12 @@
"use client";
import type { ReactNode } from "react";
-import { usePathname, useSearchParams } from "next/navigation";
-import { getActiveWaveIdFromUrl } from "@/helpers/navigation.helpers";
import { useAuthenticatedContent } from "../../../hooks/useAuthenticatedContent";
import useDeviceInfo from "../../../hooks/useDeviceInfo";
import ConnectWallet from "../../common/ConnectWallet";
-import HeaderUserConnect from "../../header/user/HeaderUserConnect";
import UserSetUpProfileCta from "../../user/utils/set-up-profile/UserSetUpProfileCta";
import WavesDesktop from "../WavesDesktop";
import WavesMobile from "../WavesMobile";
-import WaveScreenMessage from "../WaveScreenMessage";
function getConnectPrompt(
contentState: ReturnType["contentState"]
@@ -41,36 +37,22 @@ function getConnectPrompt(
}
function getNotAuthenticatedContent({
- activeWaveId,
children,
containerClassName,
isApp,
- isMobileDevice,
}: {
- readonly activeWaveId: string | null;
readonly children: ReactNode;
readonly containerClassName: string;
readonly isApp: boolean;
- readonly isMobileDevice: boolean;
}): ReactNode {
- if (isApp || (isMobileDevice && activeWaveId === null)) {
+ if (isApp) {
return ;
}
return (
-
- {activeWaveId === null ? (
- }
- />
- ) : (
- children
- )}
-
+ {children}
);
@@ -79,10 +61,7 @@ function getNotAuthenticatedContent({
// Main layout content that uses the Layout context
function WavesLayoutContent({ children }: { readonly children: ReactNode }) {
const { contentState } = useAuthenticatedContent();
- const { isApp, isMobileDevice } = useDeviceInfo();
- const pathname = usePathname();
- const searchParams = useSearchParams();
- const activeWaveId = getActiveWaveIdFromUrl({ pathname, searchParams });
+ const { isApp } = useDeviceInfo();
const containerClassName =
"tw-relative tw-flex tw-flex-col tw-flex-1 tailwind-scope";
@@ -101,11 +80,9 @@ function WavesLayoutContent({ children }: { readonly children: ReactNode }) {
);
} else if (contentState === "not-authenticated") {
content = getNotAuthenticatedContent({
- activeWaveId,
children,
containerClassName,
isApp,
- isMobileDevice,
});
} else {
content =
diff --git a/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarBoostedDrops.tsx b/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarBoostedDrops.tsx
index 877f00da26..4517498ae8 100644
--- a/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarBoostedDrops.tsx
+++ b/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarBoostedDrops.tsx
@@ -64,7 +64,7 @@ export const WaveLeaderboardRightSidebarBoostedDrops =
return (
-
+
Trending
diff --git a/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarTimeWindowSelect.tsx b/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarTimeWindowSelect.tsx
index fab1efc8f8..d97fa163f9 100644
--- a/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarTimeWindowSelect.tsx
+++ b/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarTimeWindowSelect.tsx
@@ -14,21 +14,30 @@ export const WaveLeaderboardRightSidebarTimeWindowSelect =
memo(
({ value, onChange }) => {
return (
-
- {TIME_WINDOW_OPTIONS.map((option) => (
-
- ))}
+
+ {TIME_WINDOW_OPTIONS.map((option) => {
+ const isActive = value === option;
+ return (
+
+ );
+ })}
);
}
diff --git a/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarVoters.tsx b/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarVoters.tsx
index 2a0d3b3961..7888c3c901 100644
--- a/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarVoters.tsx
+++ b/components/waves/leaderboard/sidebar/WaveLeaderboardRightSidebarVoters.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import type { ApiWave } from "@/generated/models/ObjectSerializer";
+import type { ApiWave } from "@/generated/models/ApiWave";
import { useAuth } from "@/components/auth/Auth";
import { useWaveTopVoters } from "@/hooks/useWaveTopVoters";
import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";
@@ -34,21 +34,21 @@ export const WaveLeaderboardRightSidebarVoters: React.FC<
return (
{voters.length === 0 && !isLoading ? (
-
-
-
-
-
+
+
-
+
Be the First to Make a Vote
-
+
Vote on drops to see voter rankings appear here.
@@ -64,8 +64,8 @@ export const WaveLeaderboardRightSidebarVoters: React.FC<
/>
))}
{isFetchingNextPage && (
-
-
+
)}
diff --git a/components/waves/memes/submission/MobileMemesArtSubmissionBtn.tsx b/components/waves/memes/submission/MobileMemesArtSubmissionBtn.tsx
index 02e52032b6..194b5bee6f 100644
--- a/components/waves/memes/submission/MobileMemesArtSubmissionBtn.tsx
+++ b/components/waves/memes/submission/MobileMemesArtSubmissionBtn.tsx
@@ -1,7 +1,7 @@
"use client";
import React, { useMemo, useState } from "react";
-import type { ApiWave } from "@/generated/models/ObjectSerializer";
+import type { ApiWave } from "@/generated/models/ApiWave";
import MemesArtSubmissionModal from "../MemesArtSubmissionModal";
import { SubmissionStatus, useWave } from "@/hooks/useWave";
@@ -71,20 +71,22 @@ const MobileMemesArtSubmissionBtn: React.FC<