From a8e7d80019a736a992dfe22bbe10eaa07dc97b85 Mon Sep 17 00:00:00 2001 From: ragnep Date: Mon, 13 Apr 2026 14:44:49 +0300 Subject: [PATCH 1/8] wip Signed-off-by: ragnep --- app/[user]/waves/page.tsx | 34 ++- .../curations/MyStreamWaveCurationContent.tsx | 10 +- .../tabs/MyStreamWaveCreateCurationAction.tsx | 2 +- components/user/layout/UserPageLayout.tsx | 11 +- components/user/layout/UserPageTabs.tsx | 25 +- components/user/layout/userTabs.config.ts | 8 + components/user/waves/UserPageProfileWave.tsx | 286 ++++++++++++++++++ .../header/options/WaveHeaderOptions.tsx | 58 ++-- .../profile-wave/WaveProfileWaveAction.tsx | 79 +++++ helpers/waves/wave.helpers.ts | 24 ++ hooks/useProfileWaveMutation.ts | 105 +++++++ services/api/profile-wave-api.ts | 83 +++++ 12 files changed, 668 insertions(+), 57 deletions(-) create mode 100644 components/user/waves/UserPageProfileWave.tsx create mode 100644 components/waves/header/options/profile-wave/WaveProfileWaveAction.tsx create mode 100644 hooks/useProfileWaveMutation.ts create mode 100644 services/api/profile-wave-api.ts diff --git a/app/[user]/waves/page.tsx b/app/[user]/waves/page.tsx index d4e6ccbee9..ca88d7daf2 100644 --- a/app/[user]/waves/page.tsx +++ b/app/[user]/waves/page.tsx @@ -1,14 +1,22 @@ -import { notFound, redirect } from "next/navigation"; - -export default async function WavesPage({ - params, -}: { - readonly params?: Promise<{ user: string }> | undefined; -}) { - const resolvedParams = params ? await params : undefined; - const user = resolvedParams?.user; - if (!user) { - notFound(); - } - redirect(`/${user}`); +import { createUserTabPage } from "@/app/[user]/_lib/userTabPageFactory"; +import UserPageProfileWave from "@/components/user/waves/UserPageProfileWave"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import { + USER_PAGE_TAB_IDS, + USER_PAGE_TAB_MAP, +} from "@/components/user/layout/userTabs.config"; + +function WaveTab({ profile }: { readonly profile: ApiIdentity }) { + return ; } + +const TAB_CONFIG = USER_PAGE_TAB_MAP[USER_PAGE_TAB_IDS.WAVES]; + +const { Page, generateMetadata } = createUserTabPage({ + subroute: TAB_CONFIG.route, + metaLabel: TAB_CONFIG.metaLabel, + Tab: WaveTab, +}); + +export default Page; +export { generateMetadata }; diff --git a/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx b/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx index 861ff4a944..8a7c152f18 100644 --- a/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx +++ b/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx @@ -22,6 +22,7 @@ interface MyStreamWaveCurationContentProps { readonly curationId: string; readonly curationName?: string | null | undefined; readonly onDropClick: (drop: ExtendedDrop) => void; + readonly constrainToViewport?: boolean | undefined; } function MyStreamWaveCurationDropItem({ @@ -129,6 +130,7 @@ export default function MyStreamWaveCurationContent({ curationId, curationName, onDropClick, + constrainToViewport = true, }: MyStreamWaveCurationContentProps) { const { leaderboardViewStyle } = useLayout(); const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = @@ -220,8 +222,12 @@ export default function MyStreamWaveCurationContent({ return (
{content}
diff --git a/components/brain/my-stream/tabs/MyStreamWaveCreateCurationAction.tsx b/components/brain/my-stream/tabs/MyStreamWaveCreateCurationAction.tsx index e88867e2a2..181dbf9106 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveCreateCurationAction.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveCreateCurationAction.tsx @@ -35,7 +35,7 @@ export default function MyStreamWaveCreateCurationAction({ return ( <> -
+
{showCreateFirstCurationCallout ? ( - - {isOptionsOpen && ( - -
- setIsOptionsOpen(false)} /> - -
-
- )} -
+ +
  • +
    + setIsOptionsOpen(false)} + /> + setIsOptionsOpen(false)} /> + +
    +
  • +
    ); } diff --git a/components/waves/header/options/profile-wave/WaveProfileWaveAction.tsx b/components/waves/header/options/profile-wave/WaveProfileWaveAction.tsx new file mode 100644 index 0000000000..d58594eb0e --- /dev/null +++ b/components/waves/header/options/profile-wave/WaveProfileWaveAction.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Spinner } from "@/components/dotLoader/DotLoader"; +import { useAuth } from "@/components/auth/Auth"; +import { useProfileWaveMutation } from "@/hooks/useProfileWaveMutation"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { isPublicNonDirectMessageWave } from "@/helpers/waves/wave.helpers"; +import { useCallback } from "react"; + +export default function WaveProfileWaveAction({ + wave, + onSuccess, +}: { + readonly wave: ApiWave; + readonly onSuccess?: (() => void) | undefined; +}) { + const { connectedProfile, activeProfileProxy } = useAuth(); + const { updateProfileWave, clearSelectedProfileWave, isPending } = + useProfileWaveMutation(connectedProfile); + + const isSelectedProfileWave = connectedProfile?.profile_wave_id === wave.id; + const canManageProfileWave = + Boolean(connectedProfile?.handle) && + !activeProfileProxy && + connectedProfile?.handle === wave.author.handle && + isPublicNonDirectMessageWave(wave); + + const handleClick = useCallback(async () => { + if (isPending) { + return; + } + + const updatedProfile = isSelectedProfileWave + ? await clearSelectedProfileWave() + : await updateProfileWave(wave.id); + + if (updatedProfile) { + onSuccess?.(); + } + }, [ + clearSelectedProfileWave, + isPending, + isSelectedProfileWave, + onSuccess, + updateProfileWave, + wave.id, + ]); + + const buttonLabel = (() => { + if (isPending) { + return isSelectedProfileWave + ? "Clearing profile wave" + : "Saving profile wave"; + } + + return isSelectedProfileWave ? "Clear profile wave" : "Set as profile wave"; + })(); + + if (!canManageProfileWave) { + return null; + } + + return ( + + ); +} diff --git a/helpers/waves/wave.helpers.ts b/helpers/waves/wave.helpers.ts index c0da5ea9f3..5cd7b73aad 100644 --- a/helpers/waves/wave.helpers.ts +++ b/helpers/waves/wave.helpers.ts @@ -7,8 +7,23 @@ interface WaveDetailsLike { | { readonly is_direct_message?: boolean | undefined; } + | null | undefined; } + | null + | undefined; + } + | undefined; + readonly visibility?: + | { + readonly scope?: + | { + readonly group?: + | { readonly id?: string | undefined } + | null + | undefined; + } + | null | undefined; } | undefined; @@ -36,3 +51,12 @@ export const isWaveDirectMessage = ( return waveDetails?.chat?.scope?.group?.is_direct_message ?? false; }; + +export const isPublicNonDirectMessageWave = ( + wave?: WaveDetailsLike | null +): boolean => + Boolean( + wave && + !wave.chat?.scope?.group?.is_direct_message && + !wave.visibility?.scope?.group?.id + ); diff --git a/hooks/useProfileWaveMutation.ts b/hooks/useProfileWaveMutation.ts new file mode 100644 index 0000000000..620d5e98d3 --- /dev/null +++ b/hooks/useProfileWaveMutation.ts @@ -0,0 +1,105 @@ +"use client"; + +import { useAuth } from "@/components/auth/Auth"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import { + clearProfileWave, + setProfileWave, +} from "@/services/api/profile-wave-api"; +import { useMutation } from "@tanstack/react-query"; +import { useContext } from "react"; + +type ProfileWaveAction = + | { readonly type: "set"; readonly waveId: string } + | { readonly type: "clear" }; + +const getProfileIdentityKey = (profile: ApiIdentity | null): string | null => + profile?.query ?? + profile?.handle ?? + profile?.primary_wallet ?? + profile?.id ?? + null; + +export function useProfileWaveMutation(profile: ApiIdentity | null) { + const { requestAuth, setToast } = useAuth(); + const { onProfileEdit } = useContext(ReactQueryWrapperContext); + + const mutation = useMutation({ + mutationFn: async (action: ProfileWaveAction) => { + const identity = getProfileIdentityKey(profile); + if (!identity) { + throw new Error("Unable to determine the profile identity."); + } + + if (action.type === "set") { + return await setProfileWave({ + identity, + waveId: action.waveId, + }); + } + + return await clearProfileWave({ identity }); + }, + onSuccess: (updatedProfile, action) => { + onProfileEdit({ + profile: updatedProfile, + previousProfile: profile, + }); + setToast({ + message: + action.type === "set" + ? "Profile wave updated." + : "Profile wave cleared.", + type: "success", + }); + }, + onError: (error: unknown, action) => { + const fallbackMessage = + action.type === "set" + ? "Unable to update profile wave." + : "Unable to clear profile wave."; + setToast({ + message: error instanceof Error ? error.message : fallbackMessage, + type: "error", + }); + }, + }); + + const ensureAuthenticated = async (): Promise => { + const { success } = await requestAuth(); + return success; + }; + + const runProfileWaveMutation = async ( + action: ProfileWaveAction + ): Promise => { + if (!(await ensureAuthenticated())) { + return null; + } + + try { + return await mutation.mutateAsync(action); + } catch { + return null; + } + }; + + const updateProfileWave = async (waveId: string) => + await runProfileWaveMutation({ + type: "set", + waveId, + }); + + const clearSelectedProfileWave = async () => + await runProfileWaveMutation({ + type: "clear", + }); + + return { + updateProfileWave, + clearSelectedProfileWave, + isPending: mutation.isPending, + pendingAction: mutation.variables?.type ?? null, + }; +} diff --git a/services/api/profile-wave-api.ts b/services/api/profile-wave-api.ts new file mode 100644 index 0000000000..d4c2c38616 --- /dev/null +++ b/services/api/profile-wave-api.ts @@ -0,0 +1,83 @@ +import { publicEnv } from "@/config/env"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import { commonApiPost } from "@/services/api/common-api"; +import { getAuthJwt, getStagingAuth } from "@/services/auth/auth.utils"; + +const buildProfileWaveUrl = (identity: string): string => + `${publicEnv.API_ENDPOINT}/api/profiles/${encodeURIComponent(identity)}/wave`; + +const buildProfileWaveHeaders = (): HeadersInit => { + const apiAuth = getStagingAuth(); + const walletAuth = getAuthJwt(); + + return { + ...(apiAuth ? { "x-6529-auth": apiAuth } : {}), + ...(walletAuth ? { Authorization: `Bearer ${walletAuth}` } : {}), + }; +}; + +const parseApiErrorMessage = async (response: Response): Promise => { + const fallbackMessage = response.statusText || "Something went wrong"; + + try { + const rawContent = await response.text(); + if (!rawContent) { + return fallbackMessage; + } + + try { + const parsedBody = JSON.parse(rawContent) as { + error?: unknown; + message?: unknown; + } | null; + + if (typeof parsedBody?.error === "string") { + return parsedBody.error; + } + + if (typeof parsedBody?.message === "string") { + return parsedBody.message; + } + } catch { + return rawContent; + } + + return rawContent; + } catch { + return fallbackMessage; + } +}; + +const parseProfileResponse = async (response: Response): Promise => + (await response.json()) as ApiIdentity; + +export const setProfileWave = async ({ + identity, + waveId, +}: { + readonly identity: string; + readonly waveId: string; +}): Promise => + await commonApiPost<{ wave_id: string }, ApiIdentity>({ + endpoint: `profiles/${encodeURIComponent(identity)}/wave`, + body: { + wave_id: waveId, + }, + }); + +export const clearProfileWave = async ({ + identity, +}: { + readonly identity: string; +}): Promise => { + const response = await fetch(buildProfileWaveUrl(identity), { + method: "DELETE", + headers: buildProfileWaveHeaders(), + }); + + if (!response.ok) { + throw new Error(await parseApiErrorMessage(response)); + } + + return await parseProfileResponse(response); +}; From 47dd8dc2bdf5627aeb5ed6932016e257c58ad26b Mon Sep 17 00:00:00 2001 From: ragnep Date: Tue, 14 Apr 2026 13:33:32 +0300 Subject: [PATCH 2/8] wip Signed-off-by: ragnep --- .../curations/CurationEmptyState.tsx | 30 ++ .../curations/MyStreamWaveCurationContent.tsx | 34 +- .../media/DropListItemContentMedia.tsx | 3 + .../media/DropListItemContentMediaImage.tsx | 210 ++++++-- components/header/AppHeader.tsx | 433 +++++++++++------ components/user/waves/UserPageProfileWave.tsx | 231 ++++++--- .../user/waves/UserPageProfileWaveMasonry.tsx | 453 ++++++++++++++++++ components/utils/button/SecondaryButton.tsx | 6 +- components/waves/drops/Drop.tsx | 11 +- .../waves/drops/DropMinimalIdentityRow.tsx | 50 ++ components/waves/drops/WaveDrop.tsx | 440 +++++++++++++---- components/waves/drops/WaveDropContent.tsx | 3 + components/waves/drops/WaveDropPart.tsx | 3 + .../waves/drops/WaveDropPartContent.tsx | 3 + .../waves/drops/WaveDropPartContentMedias.tsx | 86 +++- components/waves/drops/WaveDropPartDrop.tsx | 3 + components/waves/drops/drop.types.ts | 2 + .../DefaultParticipationDrop.tsx | 7 +- .../participation/EndedParticipationDrop.tsx | 78 +-- .../OngoingParticipationDrop.tsx | 24 +- .../drops/participation/ParticipationDrop.tsx | 7 +- .../waves/drops/winner/DefaultWinnerDrop.tsx | 46 +- components/waves/drops/winner/WinnerDrop.tsx | 7 +- hooks/useCurationManagementPermission.ts | 21 + hooks/useProfileCurationViewMode.ts | 24 + package.json | 1 + pnpm-lock.yaml | 125 +++++ 27 files changed, 1867 insertions(+), 474 deletions(-) create mode 100644 components/brain/my-stream/curations/CurationEmptyState.tsx create mode 100644 components/user/waves/UserPageProfileWaveMasonry.tsx create mode 100644 components/waves/drops/DropMinimalIdentityRow.tsx create mode 100644 hooks/useCurationManagementPermission.ts create mode 100644 hooks/useProfileCurationViewMode.ts diff --git a/components/brain/my-stream/curations/CurationEmptyState.tsx b/components/brain/my-stream/curations/CurationEmptyState.tsx new file mode 100644 index 0000000000..98d87d269e --- /dev/null +++ b/components/brain/my-stream/curations/CurationEmptyState.tsx @@ -0,0 +1,30 @@ +"use client"; + +interface CurationEmptyStateProps { + readonly curationTitle: string; + readonly containerClassName?: string | undefined; + readonly description?: string | undefined; +} + +const DEFAULT_CONTAINER_CLASS_NAME = + "tw-flex tw-min-h-[20rem] tw-items-center tw-justify-center tw-px-6"; + +const DEFAULT_DESCRIPTION = + "This tab will show the drops added to this curation."; + +export default function CurationEmptyState({ + curationTitle, + containerClassName = DEFAULT_CONTAINER_CLASS_NAME, + description = DEFAULT_DESCRIPTION, +}: CurationEmptyStateProps) { + return ( +
    +
    +

    + {curationTitle} is empty +

    +

    {description}

    +
    +
    + ); +} diff --git a/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx b/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx index 8a7c152f18..cf50cd8fe8 100644 --- a/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx +++ b/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx @@ -3,12 +3,13 @@ import CircleLoader, { CircleLoaderSize, } from "@/components/distribution-plan-tool/common/CircleLoader"; +import CurationEmptyState from "@/components/brain/my-stream/curations/CurationEmptyState"; import { Spinner } from "@/components/dotLoader/DotLoader"; import CommonIntersectionElement from "@/components/utils/CommonIntersectionElement"; import Drop, { DropLocation } from "@/components/waves/drops/Drop"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { useCurationManagementPermission } from "@/hooks/useCurationManagementPermission"; import { useDropCurationMembershipMutation } from "@/hooks/drops/useDropCurationMembershipMutation"; -import { useDropCurations } from "@/hooks/drops/useDropCurations"; import useDeviceInfo from "@/hooks/useDeviceInfo"; import useIsTouchDevice from "@/hooks/useIsTouchDevice"; import { useWaveDrops } from "@/hooks/useWaveDrops"; @@ -21,7 +22,7 @@ interface MyStreamWaveCurationContentProps { readonly wave: ApiWave; readonly curationId: string; readonly curationName?: string | null | undefined; - readonly onDropClick: (drop: ExtendedDrop) => void; + readonly onDropClick?: ((drop: ExtendedDrop) => void) | undefined; readonly constrainToViewport?: boolean | undefined; } @@ -38,7 +39,7 @@ function MyStreamWaveCurationDropItem({ readonly nextDrop: ExtendedDrop | null; readonly curationId: string; readonly canManageActiveCuration: boolean; - readonly onDropClick: (drop: ExtendedDrop) => void; + readonly onDropClick?: ((drop: ExtendedDrop) => void) | undefined; }) { const { hasTouchScreen, isApp } = useDeviceInfo(); const isTouchDevice = useIsTouchDevice(); @@ -139,9 +140,9 @@ export default function MyStreamWaveCurationContent({ curationId, }); const permissionProbeDropId = drops[0]?.id ?? ""; - const { data: permissionProbeCurations = [] } = useDropCurations({ - dropId: permissionProbeDropId, - enabled: Boolean(permissionProbeDropId), + const canManageActiveCuration = useCurationManagementPermission({ + curationId, + probeDropId: permissionProbeDropId, }); const isInitialLoading = isFetching && drops.length === 0; @@ -158,9 +159,6 @@ export default function MyStreamWaveCurationContent({ ); const curationTitle = curationName?.trim() ?? "Curation"; - const canManageActiveCuration = - permissionProbeCurations.find((curation) => curation.id === curationId) - ?.authenticated_user_can_curate ?? false; const renderedDrops = useMemo( () => @@ -188,16 +186,14 @@ export default function MyStreamWaveCurationContent({ ); } else if (drops.length === 0) { content = ( -
    -
    -

    - {curationTitle} is empty -

    -

    - This tab will show the drops added to this curation. -

    -
    -
    + ); } else { content = ( diff --git a/components/drops/view/item/content/media/DropListItemContentMedia.tsx b/components/drops/view/item/content/media/DropListItemContentMedia.tsx index 4144e7531e..155011f8e3 100644 --- a/components/drops/view/item/content/media/DropListItemContentMedia.tsx +++ b/components/drops/view/item/content/media/DropListItemContentMedia.tsx @@ -35,6 +35,7 @@ export default function DropListItemContentMedia({ disableAutoPlay = false, imageObjectPosition, imageScale = ImageScale.AUTOx800, + naturalHeight = false, htmlIframeContainerClassName, htmlPreviewImageUrl, loadStrategy = "in-view", @@ -47,6 +48,7 @@ export default function DropListItemContentMedia({ readonly disableAutoPlay?: boolean | undefined; readonly imageObjectPosition?: string | undefined; readonly imageScale?: ImageScale | undefined; + readonly naturalHeight?: boolean | undefined; readonly htmlIframeContainerClassName?: string | undefined; readonly htmlPreviewImageUrl?: string | undefined; readonly loadStrategy?: MediaLoadStrategy | undefined; @@ -87,6 +89,7 @@ export default function DropListItemContentMedia({ disableModal={disableModal} imageObjectPosition={imageObjectPosition} imageScale={imageScale} + naturalHeight={naturalHeight} loadStrategy={loadStrategy} /> ); diff --git a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx index 681699d2d8..cc69faa1d3 100644 --- a/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx +++ b/components/drops/view/item/content/media/DropListItemContentMediaImage.tsx @@ -29,6 +29,137 @@ const tooltipProps = { const modalButtonClasses = "tw-flex tw-items-center tw-justify-center tw-border-0 tw-text-iron-50 tw-bg-iron-800 desktop-hover:hover:tw-bg-iron-700 tw-rounded-full tw-size-10 tw-flex-shrink-0 tw-backdrop-blur-sm tw-transition-all tw-duration-300 tw-ease-out"; +function getImageContainerClassName({ + naturalHeight, + isCompetitionDrop, +}: { + readonly naturalHeight: boolean; + readonly isCompetitionDrop: boolean; +}): string { + if (naturalHeight) { + return "tw-relative tw-w-full"; + } + + return [ + "tw-relative tw-flex tw-h-full tw-w-full tw-items-center", + isCompetitionDrop ? "tw-justify-center" : "", + ] + .filter(Boolean) + .join(" "); +} + +function renderInlineImageContent({ + naturalHeight, + shouldLoadImage, + errorCount, + maxRetries, + imgRef, + retryTick, + src, + imageScale, + loadStrategy, + resolvedObjectPosition, + handleImageLoad, + handleImageClick, + handleError, + loaded, + disableModal, + hasTouchScreen, + loadingPlaceholderStyle, + manualRetry, +}: { + readonly naturalHeight: boolean; + readonly shouldLoadImage: boolean; + readonly errorCount: number; + readonly maxRetries: number; + readonly imgRef: React.RefObject; + readonly retryTick: number; + readonly src: string; + readonly imageScale: ImageScale; + readonly loadStrategy: MediaLoadStrategy; + readonly resolvedObjectPosition: string; + readonly handleImageLoad: () => void; + readonly handleImageClick: ( + event: React.MouseEvent + ) => void; + readonly handleError: () => void; + readonly loaded: boolean; + readonly disableModal: boolean; + readonly hasTouchScreen: boolean; + readonly loadingPlaceholderStyle: React.CSSProperties; + readonly manualRetry: () => void; +}) { + if (naturalHeight && shouldLoadImage && errorCount <= maxRetries) { + return ( + // eslint-disable-next-line @next/next/no-img-element + Drop media + ); + } + + return ( + <> + {!loaded && errorCount <= maxRetries && ( +
    + )} + + {shouldLoadImage && errorCount <= maxRetries && ( + + )} + + {errorCount > maxRetries && ( +
    + + Couldn’t load image. + + +
    + )} + + ); +} + function DropListItemContentMediaImage({ src, maxRetries = 0, @@ -37,6 +168,7 @@ function DropListItemContentMediaImage({ disableModal = false, imageObjectPosition, imageScale = ImageScale.AUTOx450, + naturalHeight = false, loadStrategy = "in-view", }: { readonly src: string; @@ -46,6 +178,7 @@ function DropListItemContentMediaImage({ readonly disableModal?: boolean | undefined; readonly imageObjectPosition?: string | undefined; readonly imageScale?: ImageScale | undefined; + readonly naturalHeight?: boolean | undefined; readonly loadStrategy?: MediaLoadStrategy | undefined; }) { const [ref, inView] = useInView(); @@ -124,7 +257,7 @@ function DropListItemContentMediaImage({ left: "50%", transform: "translate(-50%, -50%)", }; - const shouldLoadImage = loadStrategy === "eager" || inView; + const shouldLoadImage = naturalHeight || loadStrategy === "eager" || inView; useKeyPressEvent("Escape", () => { if (!disableModal) { @@ -287,59 +420,34 @@ function DropListItemContentMediaImage({ const resolvedObjectPosition = imageObjectPosition ?? (isCompetitionDrop ? "center" : "left top"); + const containerClassName = getImageContainerClassName({ + naturalHeight, + isCompetitionDrop, + }); return ( <> -
    - {!loaded && errorCount <= maxRetries && ( -
    - )} - - {shouldLoadImage && errorCount <= maxRetries && ( - - )} - {errorCount > maxRetries && ( -
    - - Couldn’t load image. - - -
    - )} +
    + {renderInlineImageContent({ + naturalHeight, + shouldLoadImage, + errorCount, + maxRetries, + imgRef, + retryTick, + src, + imageScale, + loadStrategy, + resolvedObjectPosition, + handleImageLoad, + handleImageClick, + handleError, + loaded, + disableModal, + hasTouchScreen, + loadingPlaceholderStyle, + manualRetry, + })}
    {!disableModal && isModalOpen && diff --git a/components/header/AppHeader.tsx b/components/header/AppHeader.tsx index e4aafbe31d..ec9b1565e3 100644 --- a/components/header/AppHeader.tsx +++ b/components/header/AppHeader.tsx @@ -10,7 +10,7 @@ import { } from "@heroicons/react/24/outline"; import Image from "next/image"; import { useParams, usePathname } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; import { resolveIpfsUrlSync } from "@/components/ipfs/IPFSContext"; import { DEFAULT_CONNECTED_PROFILE_FALLBACK_PFP } from "@/constants/constants"; import { useNavigationHistoryContext } from "@/contexts/NavigationHistoryContext"; @@ -33,6 +33,7 @@ import { useWaveShareCopyAction } from "@/hooks/waves/useWaveShareCopyAction"; import WaveDescriptionPopover from "@/components/waves/header/WaveDescriptionPopover"; import WavePicture from "@/components/waves/WavePicture"; import { getWaveDescriptionPreviewText } from "@/helpers/waves/waveDescriptionPreview"; +import type { ApiWave } from "@/generated/models/ApiWave"; const COLLECTION_TITLES: Record = { "the-memes": "The Memes", @@ -42,6 +43,15 @@ const COLLECTION_TITLES: Record = { }; const PROFILE_DOUBLE_ACTIVATE_DELAY_MS = 280; +interface HeaderConnectedAccount { + readonly address: string; + readonly isActive: boolean; +} + +interface HeaderTimeoutRef { + current: ReturnType | null; +} + const sliceString = (str: string, length: number): string => { if (str.length <= length) return str; const half = Math.floor(length / 2); @@ -88,6 +98,229 @@ const getDropForgeTitle = (pathSegments: string[]): string | null => { return null; }; +const getHeaderTitle = ({ + pathname, + waveId, + wave, + isWaveResolving, + isWavesRoute, + isMessagesRoute, + basePath, + pageTitle, + pathSegments, +}: { + readonly pathname: string; + readonly waveId: string | null; + readonly wave: { readonly name?: string | null } | null | undefined; + readonly isWaveResolving: boolean; + readonly isWavesRoute: boolean; + readonly isMessagesRoute: boolean; + readonly basePath: string; + readonly pageTitle: string; + readonly pathSegments: string[]; +}): ReactNode => { + if (pathname === "/waves/create") return "Waves"; + if (pathname === "/messages/create") return "Messages"; + if (isWavesRoute && !waveId) return "Waves"; + if (isMessagesRoute && !waveId) return "Messages"; + if (waveId) { + if (isWaveResolving) return ; + return wave?.name; + } + + const collectionTitle = getCollectionTitle(basePath, pageTitle); + if (collectionTitle) return collectionTitle; + + const rememesTitle = getRememesTitle(pathSegments); + if (rememesTitle) return rememesTitle; + + const dropForgeTitle = getDropForgeTitle(pathSegments); + if (dropForgeTitle) return dropForgeTitle; + + return sliceString(capitalizeEveryWord(pageTitle), 20); +}; + +const HeaderTitleContent = ({ + activeWave, + isWaveResolving, + isDm, + previewText, + finalTitle, +}: { + readonly activeWave: ApiWave | null; + readonly isWaveResolving: boolean; + readonly isDm: boolean; + readonly previewText: string | null; + readonly finalTitle: ReactNode; +}) => { + if (activeWave === null || isWaveResolving) { + return {finalTitle}; + } + + return ( +
    +
    + ({ + pfp: c.contributor_pfp, + identity: c.contributor_identity, + }))} + /> +
    + {!isDm && previewText !== null ? ( + + + {activeWave.name} + + + {previewText} + + + ) : ( + + {activeWave.name} + + )} +
    + ); +}; + +const HeaderGalleryToggle = ({ + showGalleryToggle, + viewMode, + toggleViewMode, +}: { + readonly showGalleryToggle: boolean; + readonly viewMode: "chat" | "gallery"; + readonly toggleViewMode: () => void; +}) => { + if (!showGalleryToggle) { + return null; + } + + return ( + + ); +}; + +const HeaderWaveLinkAction = ({ + showWaveLinkAction, + handleWaveLinkActionClick, + waveLinkActionLabel, + waveLinkActionMode, + waveLinkActionIconColor, + renderWaveLinkActionIcon, +}: { + readonly showWaveLinkAction: boolean; + readonly handleWaveLinkActionClick: () => void; + readonly waveLinkActionLabel: string; + readonly waveLinkActionMode: string; + readonly waveLinkActionIconColor: string; + readonly renderWaveLinkActionIcon: () => ReactNode; +}) => { + if (!showWaveLinkAction) { + return null; + } + + return ( + + ); +}; + +const switchToNextConnectedAccount = ({ + connectedAccounts, + seizeSwitchConnectedAccount, + onFailure, +}: { + readonly connectedAccounts: readonly HeaderConnectedAccount[]; + readonly seizeSwitchConnectedAccount: (address: string) => void; + readonly onFailure: (error: unknown) => void; +}): boolean => { + if (connectedAccounts.length < 2) { + return false; + } + + const activeIndex = connectedAccounts.findIndex( + (account) => account.isActive + ); + const currentIndex = Math.max(activeIndex, 0); + const nextAccount = + connectedAccounts[(currentIndex + 1) % connectedAccounts.length]; + + if (!nextAccount) { + return false; + } + + try { + seizeSwitchConnectedAccount(nextAccount.address); + return true; + } catch (error) { + onFailure(error); + return false; + } +}; + +const handleProfileActivate = ({ + address, + profileClickTimeoutRef, + openMenu, + switchConnectedAccount, +}: { + readonly address: string | null | undefined; + readonly profileClickTimeoutRef: HeaderTimeoutRef; + readonly openMenu: () => void; + readonly switchConnectedAccount: () => boolean; +}) => { + if (!address) { + openMenu(); + return; + } + + if (profileClickTimeoutRef.current) { + clearTimeout(profileClickTimeoutRef.current); + profileClickTimeoutRef.current = null; + + if (!switchConnectedAccount()) { + openMenu(); + } + return; + } + + profileClickTimeoutRef.current = setTimeout(() => { + profileClickTimeoutRef.current = null; + openMenu(); + }, PROFILE_DOUBLE_ACTIVATE_DELAY_MS); +}; + export default function AppHeader() { const [menuOpen, setMenuOpen] = useState(false); const myStream = useMyStreamOptional(); @@ -139,13 +372,12 @@ export default function AppHeader() { ); const pathSegments = pathname.split("/").filter(Boolean); - const basePath = pathSegments.length ? pathSegments[0] : ""; - const pageTitle = pathSegments.length - ? pathSegments - .at(-1) - ?.replaceAll(/[-_]/g, " ") - .replace(/^./, (c) => c.toUpperCase()) - : "Home"; + const basePath = pathSegments[0] ?? ""; + const pageTitle = + pathSegments + .at(-1) + ?.replaceAll(/[-_]/g, " ") + .replace(/^./, (c) => c.toUpperCase()) ?? "Home"; const waveId = myStream?.activeWave.id ?? null; const { wave, isLoading, isFetching } = useWaveById(waveId); @@ -234,76 +466,36 @@ export default function AppHeader() { ); const hasMultipleConnectedAccounts = connectedAccounts.length > 1; - - const switchToNextConnectedAccount = (): boolean => { - if (connectedAccounts.length < 2) { - return false; - } - - const activeIndex = connectedAccounts.findIndex( - (account) => account.isActive - ); - const currentIndex = Math.max(activeIndex, 0); - const nextAccount = - connectedAccounts[(currentIndex + 1) % connectedAccounts.length]; - if (!nextAccount) { - return false; - } - - try { - seizeSwitchConnectedAccount(nextAccount.address); - return true; - } catch (error) { - console.error("Failed to switch connected account from header", error); - setMenuOpen(true); - return false; - } - }; - - const onProfileActivate = () => { - if (!address) { - setMenuOpen(true); - return; - } - - if (profileClickTimeoutRef.current) { - clearTimeout(profileClickTimeoutRef.current); - profileClickTimeoutRef.current = null; - - const didSwitchAccount = switchToNextConnectedAccount(); - if (!didSwitchAccount) { - setMenuOpen(true); - } - return; - } - - profileClickTimeoutRef.current = setTimeout(() => { - profileClickTimeoutRef.current = null; - setMenuOpen(true); - }, PROFILE_DOUBLE_ACTIVATE_DELAY_MS); - }; - - const finalTitle: React.ReactNode = (() => { - if (pathname === "/waves/create") return "Waves"; - if (pathname === "/messages/create") return "Messages"; - if (isWavesRoute && !waveId) return "Waves"; - if (isMessagesRoute && !waveId) return "Messages"; - if (waveId) { - if (isWaveResolving) return ; - return wave?.name; - } - - const collectionTitle = getCollectionTitle(basePath!, pageTitle!); - if (collectionTitle) return collectionTitle; - - const rememesTitle = getRememesTitle(pathSegments); - if (rememesTitle) return rememesTitle; - - const dropForgeTitle = getDropForgeTitle(pathSegments); - if (dropForgeTitle) return dropForgeTitle; - - return sliceString(capitalizeEveryWord(pageTitle!), 20); - })(); + const openMenu = () => setMenuOpen(true); + const onProfileActivate = () => + handleProfileActivate({ + address, + profileClickTimeoutRef, + openMenu, + switchConnectedAccount: () => + switchToNextConnectedAccount({ + connectedAccounts, + seizeSwitchConnectedAccount, + onFailure: (error) => { + console.error( + "Failed to switch connected account from header", + error + ); + }, + }), + }); + + const finalTitle = getHeaderTitle({ + pathname, + waveId, + wave, + isWaveResolving, + isWavesRoute, + isMessagesRoute, + basePath, + pageTitle, + pathSegments, + }); return (
    @@ -324,77 +516,32 @@ export default function AppHeader() { )}
    - {activeWave !== null && !isWaveResolving ? ( -
    -
    - ({ - pfp: c.contributor_pfp, - identity: c.contributor_identity, - }))} - /> -
    - {!isDm && previewText !== null ? ( - - - {activeWave.name} - - - {previewText} - - - ) : ( - - {activeWave.name} - - )} -
    - ) : ( - {finalTitle} - )} +
    - {showGalleryToggle && ( - - )} +
    {isHomeRoute && } - {showWaveLinkAction && ( - - )} +
    void onClear()} disabled={isPending} loading={isPending} + size="sm" className="tw-whitespace-nowrap" > @@ -92,6 +92,106 @@ const getProfilePageSearchString = (searchString: string): string => { return params.toString(); }; +function ProfileCurationViewToggle({ + viewMode, + onToggle, +}: { + readonly viewMode: "masonry" | "list"; + readonly onToggle: () => void; +}) { + return ( + + ); +} + +const getProfileCurationTitle = ( + profileCuration: ApiWaveCuration | null +): string => { + const title = profileCuration?.name.trim() ?? ""; + return title || "Curation"; +}; + +function ProfileCurationBody({ + areCurationsLoading, + profileCuration, + profileIdentity, + viewMode, + wave, +}: { + readonly areCurationsLoading: boolean; + readonly profileCuration: ApiWaveCuration | null; + readonly profileIdentity: { + readonly id?: string | null | undefined; + readonly handle?: string | null | undefined; + readonly primary_address?: string | null | undefined; + }; + readonly viewMode: "masonry" | "list"; + readonly wave: NonNullable["wave"]>; +}) { + if (areCurationsLoading) { + return ( +
    + + Loading curation... +
    + ); + } + + if (!profileCuration) { + return ( +
    +

    + No curations yet +

    +

    + This official wave does not have any curations to show yet. +

    +
    + ); + } + + if (viewMode === "masonry") { + return ( + + ); + } + + return ( + + ); +} + export default function UserPageProfileWave({ profile: initialProfile, }: { @@ -101,7 +201,6 @@ export default function UserPageProfileWave({ const pathname = usePathname(); const router = useRouter(); const searchString = useSearchParams().toString(); - const queryClient = useQueryClient(); const handleOrWallet = params["user"]?.toString() ?? ""; const { connectedProfile, activeProfileProxy } = useAuth(); const { profile } = useIdentity({ @@ -118,6 +217,9 @@ export default function UserPageProfileWave({ }); const { clearSelectedProfileWave, isPending, pendingAction } = useProfileWaveMutation(resolvedProfile); + const { viewMode, toggleViewMode } = useProfileCurationViewMode( + profileWaveId ?? handleOrWallet + ); const isOwnProfile = isOwnProfileRoute({ connectedProfile, handleOrWallet, @@ -131,6 +233,18 @@ export default function UserPageProfileWave({ () => resolveProfileCuration(curations), [curations] ); + const profileCurationTitle = useMemo( + () => getProfileCurationTitle(profileCuration), + [profileCuration] + ); + const profileIdentityForMasonry = useMemo( + () => ({ + id: resolvedProfile.id, + handle: resolvedProfile.handle, + primary_address: resolvedProfile.primary_wallet, + }), + [resolvedProfile.handle, resolvedProfile.id, resolvedProfile.primary_wallet] + ); const waveHref = useMemo(() => { if (!wave) { return null; @@ -162,19 +276,6 @@ export default function UserPageProfileWave({ router.replace(nextUrl, { scroll: false }); }, [pathname, profileSearchString, router, searchString]); - const onDropClick = useCallback( - (drop: ExtendedDrop) => { - queryClient.setQueryData( - [QueryKey.DROP, { drop_id: drop.id }], - drop as ApiDrop - ); - - const nextParams = new URLSearchParams(profileSearchString); - nextParams.set("drop", drop.id); - router.push(`${pathname}?${nextParams.toString()}`, { scroll: false }); - }, - [pathname, profileSearchString, queryClient, router] - ); const openWave = useCallback(() => { if (!waveHref) { return; @@ -211,73 +312,63 @@ export default function UserPageProfileWave({ ); } - let curationContent: ReactNode; - - if (areCurationsLoading) { - curationContent = ( -
    - - Loading curation... -
    - ); - } else if (profileCuration) { - curationContent = ( - - ); - } else { - curationContent = ( -
    -

    - No curations yet -

    -

    - This official wave does not have any curations to show yet. -

    -
    - ); - } - return ( -
    +
    -
    -
    - Official curation -
    +
    +

    + {profileCurationTitle} +

    -
    - + {profileCuration && ( + + )} + Open wave - + {canClear && ( - void clearSelectedProfileWave()} + )}
    - {curationContent} +
    diff --git a/components/user/waves/UserPageProfileWaveMasonry.tsx b/components/user/waves/UserPageProfileWaveMasonry.tsx new file mode 100644 index 0000000000..811a7662d4 --- /dev/null +++ b/components/user/waves/UserPageProfileWaveMasonry.tsx @@ -0,0 +1,453 @@ +"use client"; + +import CurationEmptyState from "@/components/brain/my-stream/curations/CurationEmptyState"; +import CircleLoader, { + CircleLoaderSize, +} from "@/components/distribution-plan-tool/common/CircleLoader"; +import { Spinner } from "@/components/dotLoader/DotLoader"; +import CommonIntersectionElement from "@/components/utils/CommonIntersectionElement"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import Drop, { DropLocation } from "@/components/waves/drops/Drop"; +import DropMinimalIdentityRow from "@/components/waves/drops/DropMinimalIdentityRow"; +import WaveDropContent from "@/components/waves/drops/WaveDropContent"; +import WaveDropAuthorPfp from "@/components/waves/drops/WaveDropAuthorPfp"; +import WaveDropMetadata from "@/components/waves/drops/WaveDropMetadata"; +import WaveDropRatings from "@/components/waves/drops/WaveDropRatings"; +import WaveDropReactions from "@/components/waves/drops/WaveDropReactions"; +import WaveDropReply from "@/components/waves/drops/WaveDropReply"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { ImageScale } from "@/helpers/image.helpers"; +import { areSameProfileIdentity } from "@/helpers/ProfileHelpers"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { useCurationManagementPermission } from "@/hooks/useCurationManagementPermission"; +import { useDropCurationMembershipMutation } from "@/hooks/drops/useDropCurationMembershipMutation"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; +import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import { useWaveDrops } from "@/hooks/useWaveDrops"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { Masonry } from "masonic"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +interface UserPageProfileWaveMasonryProps { + readonly wave: ApiWave; + readonly curationId: string; + readonly curationName?: string | null | undefined; + readonly showIdentity?: boolean | undefined; + readonly profileIdentity?: ProfileIdentitySummary | undefined; +} + +interface ProfileMasonryState { + readonly curationId: string; + readonly lastDropCount: number; + readonly resetVersion: number; +} + +const MASONRY_COLUMN_WIDTH = 300; +const MASONRY_GUTTER = 16; + +type ProfileIdentitySummary = { + readonly id?: string | null | undefined; + readonly handle?: string | null | undefined; + readonly primary_address?: string | null | undefined; +}; + +type ProfileMasonryIdentityMode = "default" | "minimal" | "hidden"; + +type ProfileMasonryCardLayout = { + readonly contentWrapperClassName: string; + readonly identityMode: ProfileMasonryIdentityMode; + readonly showMinimalIdentityRow: boolean; + readonly usesDefaultDropRenderer: boolean; +}; + +const getProfileMasonryCardLayout = ({ + activePart, + drop, + profileIdentity, + replyTo, + showIdentity, +}: { + readonly activePart: ExtendedDrop["parts"][number] | undefined; + readonly drop: ExtendedDrop; + readonly profileIdentity: ProfileIdentitySummary | undefined; + readonly replyTo: ExtendedDrop["reply_to"]; + readonly showIdentity: boolean; +}): ProfileMasonryCardLayout => { + const isOwnProfileDrop = areSameProfileIdentity({ + left: drop.author, + right: profileIdentity, + }); + let identityMode: ProfileMasonryIdentityMode = "default"; + if (!showIdentity) { + identityMode = isOwnProfileDrop ? "hidden" : "minimal"; + } + const hasContentPadding = + Boolean(replyTo) || + Boolean(activePart?.content?.trim()) || + Boolean(activePart?.quoted_drop?.drop_id) || + (activePart?.media.length ?? 0) === 0; + + return { + contentWrapperClassName: [ + hasContentPadding ? "tw-px-4 tw-pt-4" : "", + !hasContentPadding ? "tw-pt-2" : "", + hasContentPadding ? "tw-pb-4" : "tw-pb-2", + ] + .filter(Boolean) + .join(" "), + identityMode, + showMinimalIdentityRow: identityMode === "minimal", + usesDefaultDropRenderer: + showIdentity || drop.drop_type !== ApiDropType.Chat, + }; +}; + +function useProfileMasonryContainerWidth() { + const [container, setContainer] = useState(null); + const [containerWidth, setContainerWidth] = useState(0); + + const containerRef = useCallback((node: HTMLDivElement | null) => { + setContainer(node); + setContainerWidth((currentWidth) => { + const nextWidth = node?.offsetWidth ?? 0; + return currentWidth === nextWidth ? currentWidth : nextWidth; + }); + }, []); + + useEffect(() => { + if (!container) { + return; + } + + const updateWidth = () => { + const nextWidth = container.offsetWidth; + setContainerWidth((currentWidth) => + currentWidth === nextWidth ? currentWidth : nextWidth + ); + }; + + if (typeof ResizeObserver === "undefined") { + window.addEventListener("resize", updateWidth); + return () => { + window.removeEventListener("resize", updateWidth); + }; + } + + const observer = new ResizeObserver(() => { + updateWidth(); + }); + + observer.observe(container); + + return () => { + observer.disconnect(); + }; + }, [container]); + + return { containerRef, containerWidth }; +} + +function useProfileMasonryRenderState({ + containerWidth, + curationId, + dropCount, +}: { + readonly containerWidth: number; + readonly curationId: string; + readonly dropCount: number; +}) { + const [state, setState] = useState(() => ({ + curationId, + lastDropCount: dropCount, + resetVersion: 0, + })); + + let resetVersion = state.resetVersion; + let shouldResetMasonry = false; + + if (state.curationId !== curationId) { + resetVersion = 0; + setState({ + curationId, + lastDropCount: dropCount, + resetVersion, + }); + } else if (dropCount < state.lastDropCount) { + resetVersion = state.resetVersion + 1; + shouldResetMasonry = true; + setState({ + curationId, + lastDropCount: dropCount, + resetVersion, + }); + } else if (dropCount > state.lastDropCount) { + setState({ + ...state, + lastDropCount: dropCount, + }); + } + + return { + masonryKey: `${curationId}-${resetVersion}-${containerWidth}`, + shouldResetMasonry, + }; +} + +function CurationMasonryRemoveButton({ + drop, + curationId, +}: { + readonly drop: ExtendedDrop; + readonly curationId: string; +}) { + const { hasTouchScreen, isApp } = useDeviceInfo(); + const isTouchDevice = useIsTouchDevice(); + const { updateMembership, isPending } = useDropCurationMembershipMutation({ + dropId: drop.id, + }); + const shouldAlwaysShow = isTouchDevice || (isApp && hasTouchScreen); + + return ( +
    + +
    + ); +} + +function UserPageProfileWaveMasonryCard({ + drop, + curationId, + canManageActiveCuration, + showIdentity, + profileIdentity, +}: { + readonly drop: ExtendedDrop; + readonly curationId: string; + readonly canManageActiveCuration: boolean; + readonly showIdentity: boolean; + readonly profileIdentity: ProfileIdentitySummary | undefined; +}) { + const [activePartIndex, setActivePartIndex] = useState(0); + const replyTo = drop.reply_to; + const activePart = drop.parts[activePartIndex] ?? drop.parts[0]; + const layout = getProfileMasonryCardLayout({ + activePart, + drop, + profileIdentity, + replyTo, + showIdentity, + }); + + const removeButton = canManageActiveCuration ? ( + + ) : null; + + if (layout.usesDefaultDropRenderer) { + return ( +
    + {removeButton} + + {}} + onReplyClick={() => {}} + onQuoteClick={() => {}} + identityMode={layout.identityMode} + /> +
    + ); + } + + return ( +
    + {removeButton} + +
    + {layout.showMinimalIdentityRow && ( +
    + +
    + +
    +
    + )} + +
    + {replyTo && ( +
    + {}} + /> +
    + )} + + {}} + onLongPress={() => {}} + setLongPressTriggered={(_triggered: boolean) => {}} + onDropContentClick={undefined} + mediaImageScale={ImageScale.AUTOx1080} + fullWidthMedia={true} + /> +
    + +
    + {drop.metadata.length > 0 && ( + + )} + {!!drop.raters_count && } + +
    +
    +
    + ); +} + +export default function UserPageProfileWaveMasonry({ + wave, + curationId, + curationName, + showIdentity = false, + profileIdentity, +}: UserPageProfileWaveMasonryProps) { + const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = + useWaveDrops({ + waveId: wave.id, + curationId, + }); + const permissionProbeDropId = drops[0]?.id ?? ""; + const canManageActiveCuration = useCurationManagementPermission({ + curationId, + probeDropId: permissionProbeDropId, + }); + const isInitialLoading = isFetching && drops.length === 0; + const curationTitle = curationName?.trim() ?? "Curation"; + const { containerRef, containerWidth } = useProfileMasonryContainerWidth(); + const { masonryKey, shouldResetMasonry } = useProfileMasonryRenderState({ + containerWidth, + curationId, + dropCount: drops.length, + }); + + const handleBottomIntersection = useCallback( + (isIntersecting: boolean) => { + if (!isIntersecting || !hasNextPage || isFetchingNextPage) { + return; + } + + void fetchNextPage(); + }, + [fetchNextPage, hasNextPage, isFetchingNextPage] + ); + + const masonryCard = useMemo( + () => + function MasonryCard({ data }: { readonly data: ExtendedDrop }) { + return ( + + ); + }, + [canManageActiveCuration, curationId, profileIdentity, showIdentity] + ); + + if (isInitialLoading) { + return ( +
    + +
    + ); + } + + if (drops.length === 0) { + return ; + } + + if (shouldResetMasonry) { + return ( +
    + +
    + ); + } + + return ( +
    + {containerWidth > 0 ? ( + drop.stableKey} + itemHeightEstimate={420} + columnWidth={MASONRY_COLUMN_WIDTH} + columnGutter={MASONRY_GUTTER} + rowGutter={MASONRY_GUTTER} + overscanBy={2} + ssrWidth={containerWidth} + ssrHeight={900} + /> + ) : ( +
    + +
    + )} + + {(hasNextPage || isFetchingNextPage) && ( +
    + {isFetchingNextPage ? ( + + ) : ( + + )} +
    + )} +
    + ); +} diff --git a/components/utils/button/SecondaryButton.tsx b/components/utils/button/SecondaryButton.tsx index fdbe635fe5..409756117e 100644 --- a/components/utils/button/SecondaryButton.tsx +++ b/components/utils/button/SecondaryButton.tsx @@ -19,8 +19,8 @@ export default function SecondaryButton({ }: SecondaryButtonProps) { const sizeClasses = size === "sm" - ? "tw-px-2.5 tw-py-1.5" - : "tw-px-3.5 tw-py-2.5"; + ? "tw-px-2.5 tw-py-1.5 tw-text-xs" + : "tw-px-3.5 tw-py-2.5 tw-text-sm"; return ( + ); } return ( @@ -223,6 +239,17 @@ function DropListItemContentMediaImage({ [disableModal] ); + const handleNaturalHeightImageClick = useCallback( + (event: React.MouseEvent) => { + if (disableModal) { + return; + } + event.stopPropagation(); + setIsModalOpen(true); + }, + [disableModal] + ); + const handleCloseModal = useCallback( ( event?: @@ -440,6 +467,7 @@ function DropListItemContentMediaImage({ loadStrategy, resolvedObjectPosition, handleImageLoad, + handleNaturalHeightImageClick, handleImageClick, handleError, loaded, diff --git a/components/memes/drops/MemeParticipationDrop.tsx b/components/memes/drops/MemeParticipationDrop.tsx index 884c48534e..34a7ed6770 100644 --- a/components/memes/drops/MemeParticipationDrop.tsx +++ b/components/memes/drops/MemeParticipationDrop.tsx @@ -27,6 +27,7 @@ interface MemeParticipationDropProps { readonly showReplyAndQuote: boolean; readonly location: DropLocation; readonly onReply: (param: DropInteractionParams) => void; + readonly showInteractions?: boolean | undefined; } // Border styling based on rank @@ -62,6 +63,7 @@ export default function MemeParticipationDrop({ showReplyAndQuote, location, onReply, + showInteractions = true, }: MemeParticipationDropProps) { const [isVotingModalOpen, setIsVotingModalOpen] = useState(false); const { canShowVote } = useDropInteractionRules(drop); @@ -86,6 +88,37 @@ export default function MemeParticipationDrop({ onReply({ drop, partId: drop.parts[0]?.part_id! }); }, [onReply, drop]); + const content = ( + <> +
    + +
    + + +
    +
    + {artworkMedia && ( +
    + +
    + )} + +
    + +
    + + ); + return (
    - - <> -
    - -
    - - -
    -
    - {artworkMedia && ( -
    - -
    - )} - -
    - -
    - -
    + {showInteractions ? ( + + {content} + + ) : ( + content + )}
    - {canShowVote && ( + {canShowVote && showInteractions && (
    e.stopPropagation()}> -
    - -
    + {showInteractions && ( +
    + +
    + )} + + {showInteractions && ( +
    + +
    + )} +
    -
    - setIsVotingModalOpen(false)} /> -
    -
    - - {isMobileScreen ? ( - setIsVotingModalOpen(false)} - /> - ) : ( - setIsVotingModalOpen(false)} - /> - )} + ) : ( + setIsVotingModalOpen(false)} + /> + ))}
    ); diff --git a/components/memes/drops/MemeWinnerDrop.tsx b/components/memes/drops/MemeWinnerDrop.tsx index e56ab0f3b2..e7d4cf7d2b 100644 --- a/components/memes/drops/MemeWinnerDrop.tsx +++ b/components/memes/drops/MemeWinnerDrop.tsx @@ -23,6 +23,7 @@ interface MemeWinnerDropProps { readonly drop: ExtendedDrop; readonly showReplyAndQuote: boolean; readonly onReply: (param: DropInteractionParams) => void; + readonly showInteractions?: boolean | undefined; } const getRankHoverClass = (rank: number | null): string => { @@ -33,6 +34,7 @@ export default function MemeWinnerDrop({ drop, showReplyAndQuote, onReply, + showInteractions = true, }: MemeWinnerDropProps) { const isMobile = useIsMobileDevice(); const { location } = useDropContext(); @@ -53,6 +55,41 @@ export default function MemeWinnerDrop({ }, [onReply, drop]); const effectiveRank = drop.winning_context?.place ?? drop.rank; + const content = ( + <> +
    + +
    + +
    +
    + + +
    +
    + + + + {artworkMedia && ( +
    + +
    + )} + +
    + +
    + + ); + return (
    - - <> -
    - -
    - -
    -
    - - -
    -
    - - - - {artworkMedia && ( -
    - -
    - )} - -
    - -
    - -
    - {!isMobile && showReplyAndQuote && ( + {showInteractions ? ( + + {content} + + ) : ( + content + )} + {!isMobile && showInteractions && showReplyAndQuote && (
    showWaves && hasProfileWave, }, diff --git a/components/user/waves/UserPageProfileWave.tsx b/components/user/waves/UserPageProfileWave.tsx index 987524115c..3c34ba4a6a 100644 --- a/components/user/waves/UserPageProfileWave.tsx +++ b/components/user/waves/UserPageProfileWave.tsx @@ -2,9 +2,11 @@ import { useAuth } from "@/components/auth/Auth"; import { Spinner } from "@/components/dotLoader/DotLoader"; +import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; import SecondaryButton from "@/components/utils/button/SecondaryButton"; import MyStreamWaveCurationContent from "@/components/brain/my-stream/curations/MyStreamWaveCurationContent"; import UserPageProfileWaveMasonry from "@/components/user/waves/UserPageProfileWaveMasonry"; +import { TOOLTIP_STYLES } from "@/helpers/tooltip.helpers"; import { useProfileCurationViewMode } from "@/hooks/useProfileCurationViewMode"; import { useProfileWaveMutation } from "@/hooks/useProfileWaveMutation"; import { useWaveById } from "@/hooks/useWaveById"; @@ -17,8 +19,6 @@ import { getWaveRoute } from "@/helpers/navigation.helpers"; import { isWaveDirectMessage } from "@/helpers/waves/wave.helpers"; import { ArrowTopRightOnSquareIcon, - Bars3Icon, - Squares2X2Icon, XMarkIcon, } from "@heroicons/react/24/outline"; import { @@ -28,6 +28,7 @@ import { useSearchParams, } from "next/navigation"; import { useCallback, useEffect, useMemo } from "react"; +import { Tooltip } from "react-tooltip"; function UnavailableState({ canClear, @@ -51,16 +52,23 @@ function UnavailableState({

    {canClear && ( - void onClear()} + )}
    @@ -94,33 +102,105 @@ const getProfilePageSearchString = (searchString: string): string => { function ProfileCurationViewToggle({ viewMode, - onToggle, + onChange, }: { readonly viewMode: "masonry" | "list"; - readonly onToggle: () => void; + readonly onChange: (mode: "masonry" | "list") => void; }) { + const viewModes = [ + { + mode: "list" as const, + label: "List view", + tooltipId: "profile-curation-view-toggle-list", + icon: ( + + ), + }, + { + mode: "masonry" as const, + label: "Grid view", + tooltipId: "profile-curation-view-toggle-grid", + icon: ( + + ), + }, + ]; + + const getViewModeTabClass = (mode: "masonry" | "list"): string => { + const baseClassName = + "tw-flex tw-h-7 tw-w-7 tw-items-center tw-justify-center tw-rounded-md tw-border tw-border-solid tw-border-transparent tw-transition-colors"; + + if (viewMode === mode) { + return `${baseClassName} tw-border-primary-500/50 tw-bg-primary-600/20 tw-text-primary-400`; + } + + return `${baseClassName} tw-bg-transparent tw-text-iron-500 desktop-hover:hover:tw-bg-white/5 desktop-hover:hover:tw-text-iron-300`; + }; + return ( - + <> +
    + {viewModes.map((mode) => ( +
    + + + {mode.label} + +
    + ))} +
    + ); } @@ -200,7 +280,10 @@ export default function UserPageProfileWave({ const params = useParams(); const pathname = usePathname(); const router = useRouter(); - const searchString = useSearchParams().toString(); + const searchParams = useSearchParams(); + const searchString = searchParams.toString(); + const shouldForceUnavailableState = + searchParams.get("mockProfileWaveUnavailable") === "1"; const handleOrWallet = params["user"]?.toString() ?? ""; const { connectedProfile, activeProfileProxy } = useAuth(); const { profile } = useIdentity({ @@ -217,9 +300,7 @@ export default function UserPageProfileWave({ }); const { clearSelectedProfileWave, isPending, pendingAction } = useProfileWaveMutation(resolvedProfile); - const { viewMode, toggleViewMode } = useProfileCurationViewMode( - profileWaveId ?? handleOrWallet - ); + const { viewMode, setViewMode } = useProfileCurationViewMode(); const isOwnProfile = isOwnProfileRoute({ connectedProfile, handleOrWallet, @@ -284,11 +365,11 @@ export default function UserPageProfileWave({ router.push(waveHref, { scroll: false }); }, [router, waveHref]); - if (!profileWaveId) { + if (!profileWaveId && !shouldForceUnavailableState) { return null; } - if (isLoading) { + if (!shouldForceUnavailableState && isLoading) { return (
    )} void clearSelectedProfileWave()} disabled={isPending} - className={[ - "tw-group tw-inline-flex tw-shrink-0 tw-items-center tw-justify-center tw-gap-1 tw-whitespace-nowrap tw-rounded-lg tw-border tw-border-solid tw-px-4 tw-py-2 tw-text-xs tw-font-semibold tw-transition-all tw-duration-300 tw-ease-out focus-visible:tw-outline-none focus-visible:tw-ring-2 focus-visible:tw-ring-iron-500 disabled:tw-cursor-not-allowed disabled:tw-opacity-60", - isPending && pendingAction === "clear" - ? "tw-border-iron-700 tw-bg-iron-900 tw-text-iron-300" - : "tw-border-iron-800 tw-bg-iron-950/60 tw-text-iron-200 hover:tw-border-iron-700 hover:tw-bg-iron-900", - ].join(" ")} + className={`tw-flex tw-items-center tw-justify-center tw-gap-x-1.5 tw-whitespace-nowrap tw-rounded-lg tw-border tw-border-solid tw-bg-iron-950 tw-px-3 tw-py-2 tw-text-xs tw-font-semibold tw-shadow-sm tw-ring-1 tw-transition tw-duration-300 tw-ease-out focus-visible:tw-outline focus-visible:tw-outline-2 focus-visible:tw-outline-offset-2 focus-visible:tw-outline-iron-700 ${ + isPending + ? "tw-cursor-not-allowed tw-border-iron-950 tw-text-iron-600 tw-ring-iron-900" + : "tw-border-iron-950 tw-text-iron-300 tw-ring-iron-800 hover:tw-border-iron-800 hover:tw-bg-iron-800 hover:tw-ring-iron-700" + }`} > {isPending && pendingAction === "clear" ? ( - + ) : ( )} diff --git a/components/user/waves/UserPageProfileWaveMasonry.tsx b/components/user/waves/UserPageProfileWaveMasonry.tsx index 811a7662d4..07156e5243 100644 --- a/components/user/waves/UserPageProfileWaveMasonry.tsx +++ b/components/user/waves/UserPageProfileWaveMasonry.tsx @@ -13,8 +13,6 @@ import DropMinimalIdentityRow from "@/components/waves/drops/DropMinimalIdentity import WaveDropContent from "@/components/waves/drops/WaveDropContent"; import WaveDropAuthorPfp from "@/components/waves/drops/WaveDropAuthorPfp"; import WaveDropMetadata from "@/components/waves/drops/WaveDropMetadata"; -import WaveDropRatings from "@/components/waves/drops/WaveDropRatings"; -import WaveDropReactions from "@/components/waves/drops/WaveDropReactions"; import WaveDropReply from "@/components/waves/drops/WaveDropReply"; import type { ApiWave } from "@/generated/models/ApiWave"; import { ImageScale } from "@/helpers/image.helpers"; @@ -57,10 +55,19 @@ type ProfileMasonryIdentityMode = "default" | "minimal" | "hidden"; type ProfileMasonryCardLayout = { readonly contentWrapperClassName: string; readonly identityMode: ProfileMasonryIdentityMode; + readonly shouldUseInlineMinimalLayout: boolean; readonly showMinimalIdentityRow: boolean; readonly usesDefaultDropRenderer: boolean; }; +type ProfileMasonryItem = { + readonly drop: ExtendedDrop; + readonly curationId: string; + readonly canManageActiveCuration: boolean; + readonly showIdentity: boolean; + readonly profileIdentity: ProfileIdentitySummary | undefined; +}; + const getProfileMasonryCardLayout = ({ activePart, drop, @@ -87,17 +94,26 @@ const getProfileMasonryCardLayout = ({ Boolean(activePart?.content?.trim()) || Boolean(activePart?.quoted_drop?.drop_id) || (activePart?.media.length ?? 0) === 0; + const showMinimalIdentityRow = identityMode === "minimal"; + const shouldUseInlineMinimalLayout = + showMinimalIdentityRow && + Boolean(activePart?.content?.trim()) && + !replyTo && + !activePart?.quoted_drop?.drop_id && + (activePart?.media.length ?? 0) === 0; + let contentWrapperClassName = "tw-pt-2 tw-pb-2"; + if (hasContentPadding) { + const topPaddingClass = showMinimalIdentityRow ? "tw-pt-2" : "tw-pt-4"; + contentWrapperClassName = `tw-px-4 ${topPaddingClass} tw-pb-4`; + } else if (showMinimalIdentityRow) { + contentWrapperClassName = "tw-pt-1 tw-pb-2"; + } return { - contentWrapperClassName: [ - hasContentPadding ? "tw-px-4 tw-pt-4" : "", - !hasContentPadding ? "tw-pt-2" : "", - hasContentPadding ? "tw-pb-4" : "tw-pb-2", - ] - .filter(Boolean) - .join(" "), + contentWrapperClassName, identityMode, - showMinimalIdentityRow: identityMode === "minimal", + shouldUseInlineMinimalLayout, + showMinimalIdentityRow, usesDefaultDropRenderer: showIdentity || drop.drop_type !== ApiDropType.Chat, }; @@ -265,6 +281,19 @@ function UserPageProfileWaveMasonryCard({ const removeButton = canManageActiveCuration ? ( ) : null; + const dropContent = ( + {}} + onLongPress={() => {}} + setLongPressTriggered={(_triggered: boolean) => {}} + onDropContentClick={undefined} + mediaImageScale={ImageScale.AUTOx1080} + fullWidthMedia={true} + /> + ); if (layout.usesDefaultDropRenderer) { return ( @@ -284,6 +313,7 @@ function UserPageProfileWaveMasonryCard({ onReplyClick={() => {}} onQuoteClick={() => {}} identityMode={layout.identityMode} + showInteractions={false} /> ); @@ -294,54 +324,73 @@ function UserPageProfileWaveMasonryCard({ {removeButton}
    - {layout.showMinimalIdentityRow && ( -
    + {layout.shouldUseInlineMinimalLayout ? ( +
    +
    {dropContent}
    + {drop.metadata.length > 0 && ( + + )}
    - )} - -
    - {replyTo && ( -
    - {}} - /> + ) : ( + <> + {layout.showMinimalIdentityRow && ( +
    + +
    + +
    +
    + )} + +
    + {replyTo && ( +
    + {}} + /> +
    + )} + + {dropContent}
    - )} - - {}} - onLongPress={() => {}} - setLongPressTriggered={(_triggered: boolean) => {}} - onDropContentClick={undefined} - mediaImageScale={ImageScale.AUTOx1080} - fullWidthMedia={true} - /> -
    -
    - {drop.metadata.length > 0 && ( - - )} - {!!drop.raters_count && } - -
    + {drop.metadata.length > 0 && ( +
    + +
    + )} + + )}
    ); } +function UserPageProfileWaveMasonryRenderItem({ + data, +}: { + readonly data: ProfileMasonryItem; +}) { + return ( + + ); +} + export default function UserPageProfileWaveMasonry({ wave, curationId, @@ -367,6 +416,17 @@ export default function UserPageProfileWaveMasonry({ curationId, dropCount: drops.length, }); + const masonryItems = useMemo( + () => + drops.map((drop) => ({ + drop, + curationId, + canManageActiveCuration, + showIdentity, + profileIdentity, + })), + [canManageActiveCuration, curationId, drops, profileIdentity, showIdentity] + ); const handleBottomIntersection = useCallback( (isIntersecting: boolean) => { @@ -379,22 +439,6 @@ export default function UserPageProfileWaveMasonry({ [fetchNextPage, hasNextPage, isFetchingNextPage] ); - const masonryCard = useMemo( - () => - function MasonryCard({ data }: { readonly data: ExtendedDrop }) { - return ( - - ); - }, - [canManageActiveCuration, curationId, profileIdentity, showIdentity] - ); - if (isInitialLoading) { return (
    @@ -420,9 +464,9 @@ export default function UserPageProfileWaveMasonry({ {containerWidth > 0 ? ( drop.stableKey} + items={masonryItems} + render={UserPageProfileWaveMasonryRenderItem} + itemKey={(item) => item.drop.stableKey} itemHeightEstimate={420} columnWidth={MASONRY_COLUMN_WIDTH} columnGutter={MASONRY_GUTTER} diff --git a/components/utils/button/SecondaryButton.tsx b/components/utils/button/SecondaryButton.tsx index 409756117e..6d0323d852 100644 --- a/components/utils/button/SecondaryButton.tsx +++ b/components/utils/button/SecondaryButton.tsx @@ -19,18 +19,20 @@ export default function SecondaryButton({ }: SecondaryButtonProps) { const sizeClasses = size === "sm" - ? "tw-px-2.5 tw-py-1.5 tw-text-xs" + ? "tw-px-3 tw-py-2 tw-text-xs" : "tw-px-3.5 tw-py-2.5 tw-text-sm"; return (
    - {!isMobile && showReplyAndQuote && !isEditing && ( + {!isMobile && showInteractions && showReplyAndQuote && !isEditing && ( React.ReactNode) | undefined; readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; } const WaveDrop = ({ @@ -420,6 +423,7 @@ const WaveDrop = ({ showReplyAndQuote, wrapContentOnly, identityMode = "default", + showInteractions = true, }: WaveDropProps) => { const [activePartIndex, setActivePartIndex] = useState(0); const [isSlideUp, setIsSlideUp] = useState(false); @@ -450,7 +454,7 @@ const WaveDrop = ({ const hasTouch = useHasTouchInput() || isMobile; const breakpoint = useBreakpoint(); const isMdUp = breakpoint === "MD"; - const allowLongPress = hasTouch && !isMdUp; + const allowLongPress = showInteractions && hasTouch && !isMdUp; const compact = useCompactMode(); const hasActiveLinkCardActions = activeLinkCardActionIds.length > 0; @@ -461,7 +465,11 @@ const WaveDrop = ({ isProfileView, }); const showActionsButton = - hasTouch && showReplyAndQuote && !isEditing && identityMode === "default"; + showInteractions && + hasTouch && + showReplyAndQuote && + !isEditing && + identityMode === "default"; const groupingClass = getGroupingClass({ isProfileView, shouldGroupWithPreviousDrop, @@ -692,13 +700,14 @@ const WaveDrop = ({ allowLongPress, handleLinkCardActionsActiveChange, isMobile, + showInteractions, showReplyAndQuote, handleOnReply, handleOnEdit, hasActiveLinkCardActions, }); - const reactionsRow = ( + const reactionsRow = (drop.metadata.length > 0 || showInteractions) && (
    0 && ( )} - {!!drop.raters_count && } - + {showInteractions && !!drop.raters_count && ( + + )} + {showInteractions && }
    ); @@ -731,17 +742,19 @@ const WaveDrop = ({ > {wrapContentOnly ? wrapContentOnly(contentBlock) : contentBlock} {reactionsRow} - + {showInteractions && ( + + )} = ({ topSpacingClassName = "tw-mt-0"; } - const mediaStackClassName = [ + const mediaStackClassName = clsx( topSpacingClassName, "tw-space-y-3", - fullWidthMedia ? "-tw-mx-4" : "", - ] - .filter(Boolean) - .join(" "); + fullWidthMedia && "-tw-mx-4" + ); return (
    @@ -49,12 +48,10 @@ const WaveDropPartContentMedias: React.FC = ({ fullWidthMedia && media.mime_type.includes("image"); const mediaContainerClassName = useNaturalHeightImage ? "tw-w-full" - : [ + : clsx( "tw-flex tw-h-64 tw-items-center tw-justify-center", - fullWidthMedia ? "tw-w-full" : "", - ] - .filter(Boolean) - .join(" "); + fullWidthMedia && "tw-w-full" + ); return (
    void) | undefined; readonly parentContainerRef?: React.RefObject | undefined; readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; } export default function ParticipationDrop( diff --git a/components/waves/drops/participation/EndedParticipationDrop.tsx b/components/waves/drops/participation/EndedParticipationDrop.tsx index c832c0bd1d..5c8dd19c5c 100644 --- a/components/waves/drops/participation/EndedParticipationDrop.tsx +++ b/components/waves/drops/participation/EndedParticipationDrop.tsx @@ -40,6 +40,7 @@ interface EndedParticipationDropProps { readonly onQuoteClick: (drop: ApiDrop) => void; readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; } export default function EndedParticipationDrop({ @@ -52,6 +53,7 @@ export default function EndedParticipationDrop({ onQuoteClick, onDropContentClick, identityMode = "default", + showInteractions = true, }: EndedParticipationDropProps) { const isActiveDrop = activeDrop?.drop.id === drop.id; const router = useRouter(); @@ -84,10 +86,10 @@ export default function EndedParticipationDrop({ }; const handleLongPress = useCallback(() => { - if (!hasTouch) return; + if (!showInteractions || !hasTouch) return; setLongPressTriggered(true); setIsSlideUp(true); - }, [hasTouch]); + }, [hasTouch, showInteractions]); const handleOnReply = useCallback(() => { setIsSlideUp(false); @@ -115,7 +117,7 @@ export default function EndedParticipationDrop({
    - {!isMobile && showReplyAndQuote && ( + {!isMobile && showInteractions && showReplyAndQuote && (
    )} -
    - - -
    + {showInteractions && ( +
    + + +
    + )} - + {showInteractions && ( + + )}
    ); diff --git a/components/waves/drops/participation/OngoingParticipationDrop.tsx b/components/waves/drops/participation/OngoingParticipationDrop.tsx index 9391943212..b7ed507d10 100644 --- a/components/waves/drops/participation/OngoingParticipationDrop.tsx +++ b/components/waves/drops/participation/OngoingParticipationDrop.tsx @@ -41,6 +41,7 @@ interface OngoingParticipationDropProps { readonly onQuoteClick: (drop: ApiDrop) => void; readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; } export default function OngoingParticipationDrop({ @@ -53,6 +54,7 @@ export default function OngoingParticipationDrop({ onQuoteClick, onDropContentClick, identityMode = "default", + showInteractions = true, }: OngoingParticipationDropProps) { const isActiveDrop = activeDrop?.drop.id === drop.id; const { canShowVote } = useDropInteractionRules(drop); @@ -81,10 +83,10 @@ export default function OngoingParticipationDrop({ const [isVotingModalOpen, setIsVotingModalOpen] = useState(false); const handleLongPress = useCallback(() => { - if (!hasTouch) return; + if (!showInteractions || !hasTouch) return; setLongPressTriggered(true); setIsSlideUp(true); - }, [hasTouch]); + }, [hasTouch, showInteractions]); const handleOnReply = useCallback(() => { setIsSlideUp(false); @@ -95,9 +97,13 @@ export default function OngoingParticipationDrop({ setIsSlideUp(false); }, []); - const voteAction = canShowVote ? ( - setIsVotingModalOpen(true)} /> - ) : null; + const voteAction = + canShowVote && showInteractions ? ( + setIsVotingModalOpen(true)} + /> + ) : null; return ( - {!isMobile && showReplyAndQuote && ( + {!isMobile && showInteractions && showReplyAndQuote && ( - -
    - - {isMobileScreen ? ( - setIsVotingModalOpen(false)} + voteAction={voteAction} + showInteractions={showInteractions} /> - ) : ( - + + {showInteractions && + (isMobileScreen ? ( + setIsVotingModalOpen(false)} + /> + ) : ( + setIsVotingModalOpen(false)} + /> + ))} + + {showInteractions && ( + setIsVotingModalOpen(false)} + isOpen={isSlideUp} + longPressTriggered={longPressTriggered} + showReplyAndQuote={showReplyAndQuote} + setOpen={setIsSlideUp} + onReply={handleOnReply} + onAddReaction={handleOnAddReaction} /> )} - - {/* Mobile menu */} - ); } diff --git a/components/waves/drops/participation/ParticipationDrop.tsx b/components/waves/drops/participation/ParticipationDrop.tsx index 91d8592350..1127ddc5f6 100644 --- a/components/waves/drops/participation/ParticipationDrop.tsx +++ b/components/waves/drops/participation/ParticipationDrop.tsx @@ -22,6 +22,7 @@ interface ParticipationDropProps { readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; readonly parentContainerRef?: React.RefObject | undefined; readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; } export default function ParticipationDrop(props: ParticipationDropProps) { diff --git a/components/waves/drops/participation/ParticipationDropFooter.tsx b/components/waves/drops/participation/ParticipationDropFooter.tsx index b080cc71b3..4cfb403807 100644 --- a/components/waves/drops/participation/ParticipationDropFooter.tsx +++ b/components/waves/drops/participation/ParticipationDropFooter.tsx @@ -10,11 +10,13 @@ import { ParticipationDropRatings } from "./ParticipationDropRatings"; interface ParticipationDropFooterProps { readonly drop: ExtendedDrop; readonly voteAction?: ReactNode; + readonly showInteractions?: boolean | undefined; } export default function ParticipationDropFooter({ drop, voteAction, + showInteractions = true, }: ParticipationDropFooterProps) { const { canShowVote } = useDropInteractionRules(drop); const canShowCuration = drop.context_profile_context?.curatable ?? false; @@ -28,6 +30,10 @@ export default function ParticipationDropFooter({ const shouldShowReactionsFooter = hasReactions || (!canShowVote && canShowCuration); + if (!showInteractions) { + return
    ; + } + return ( <> {shouldShowVoteFooter && ( diff --git a/components/waves/drops/winner/DefaultWinnerDrop.tsx b/components/waves/drops/winner/DefaultWinnerDrop.tsx index 4f3031043f..ec32acb954 100644 --- a/components/waves/drops/winner/DefaultWinnerDrop.tsx +++ b/components/waves/drops/winner/DefaultWinnerDrop.tsx @@ -56,6 +56,7 @@ interface DefautWinnerDropProps { readonly onQuoteClick: (drop: ApiDrop) => void; readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; } const DefaultWinnerDrop = ({ @@ -70,6 +71,7 @@ const DefaultWinnerDrop = ({ onDropContentClick, showReplyAndQuote, identityMode = "default", + showInteractions = true, }: DefautWinnerDropProps) => { const [activePartIndex, setActivePartIndex] = useState(0); const [isSlideUp, setIsSlideUp] = useState(false); @@ -97,10 +99,10 @@ const DefaultWinnerDrop = ({ : getBackgroundColorClass(location); const handleLongPress = useCallback(() => { - if (!hasTouch) return; + if (!showInteractions || !hasTouch) return; setLongPressTriggered(true); setIsSlideUp(true); - }, [hasTouch]); + }, [hasTouch, showInteractions]); const handleOnReply = useCallback(() => { setIsSlideUp(false); @@ -212,7 +214,7 @@ const DefaultWinnerDrop = ({
    - {!isMobile && showReplyAndQuote && ( + {!isMobile && showInteractions && showReplyAndQuote && (
    0 && ( )} -
    - {!!drop.raters_count && } - -
    + {showInteractions && ( +
    + {!!drop.raters_count && } + +
    + )}
    - + {showInteractions && ( + + )}
    ); }; diff --git a/components/waves/drops/winner/WinnerDrop.tsx b/components/waves/drops/winner/WinnerDrop.tsx index 2b788996b0..2a9bc54799 100644 --- a/components/waves/drops/winner/WinnerDrop.tsx +++ b/components/waves/drops/winner/WinnerDrop.tsx @@ -26,6 +26,7 @@ interface WinnerDropProps { readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; readonly parentContainerRef?: React.RefObject | undefined; readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; } const WinnerDrop = (props: WinnerDropProps) => { diff --git a/components/waves/header/options/profile-wave/WaveProfileWaveAction.tsx b/components/waves/header/options/profile-wave/WaveProfileWaveAction.tsx index d58594eb0e..6a6302ab41 100644 --- a/components/waves/header/options/profile-wave/WaveProfileWaveAction.tsx +++ b/components/waves/header/options/profile-wave/WaveProfileWaveAction.tsx @@ -48,9 +48,7 @@ export default function WaveProfileWaveAction({ const buttonLabel = (() => { if (isPending) { - return isSelectedProfileWave - ? "Clearing profile wave" - : "Saving profile wave"; + return isSelectedProfileWave ? "Clearing wave" : "Saving wave"; } return isSelectedProfileWave ? "Clear profile wave" : "Set as profile wave"; @@ -64,9 +62,9 @@ export default function WaveProfileWaveAction({ - ); - } - - return ( - <> - {!loaded && errorCount <= maxRetries && ( -
    - )} - - {shouldLoadImage && errorCount <= maxRetries && ( - - )} - - {errorCount > maxRetries && ( -
    - - Couldn’t load image. - - -
    - )} - - ); -} - function DropListItemContentMediaImage({ src, maxRetries = 0, @@ -184,7 +37,6 @@ function DropListItemContentMediaImage({ disableModal = false, imageObjectPosition, imageScale = ImageScale.AUTOx450, - naturalHeight = false, loadStrategy = "in-view", }: { readonly src: string; @@ -194,7 +46,6 @@ function DropListItemContentMediaImage({ readonly disableModal?: boolean | undefined; readonly imageObjectPosition?: string | undefined; readonly imageScale?: ImageScale | undefined; - readonly naturalHeight?: boolean | undefined; readonly loadStrategy?: MediaLoadStrategy | undefined; }) { const [ref, inView] = useInView(); @@ -239,17 +90,6 @@ function DropListItemContentMediaImage({ [disableModal] ); - const handleNaturalHeightImageClick = useCallback( - (event: React.MouseEvent) => { - if (disableModal) { - return; - } - event.stopPropagation(); - setIsModalOpen(true); - }, - [disableModal] - ); - const handleCloseModal = useCallback( ( event?: @@ -284,7 +124,7 @@ function DropListItemContentMediaImage({ left: "50%", transform: "translate(-50%, -50%)", }; - const shouldLoadImage = naturalHeight || loadStrategy === "eager" || inView; + const shouldLoadImage = loadStrategy === "eager" || inView; useKeyPressEvent("Escape", () => { if (!disableModal) { @@ -447,35 +287,59 @@ function DropListItemContentMediaImage({ const resolvedObjectPosition = imageObjectPosition ?? (isCompetitionDrop ? "center" : "left top"); - const containerClassName = getImageContainerClassName({ - naturalHeight, - isCompetitionDrop, - }); return ( <> -
    - {renderInlineImageContent({ - naturalHeight, - shouldLoadImage, - errorCount, - maxRetries, - imgRef, - retryTick, - src, - imageScale, - loadStrategy, - resolvedObjectPosition, - handleImageLoad, - handleNaturalHeightImageClick, - handleImageClick, - handleError, - loaded, - disableModal, - hasTouchScreen, - loadingPlaceholderStyle, - manualRetry, - })} +
    + {!loaded && errorCount <= maxRetries && ( +
    + )} + + {shouldLoadImage && errorCount <= maxRetries && ( + + )} + {errorCount > maxRetries && ( +
    + + Couldn’t load image. + + +
    + )}
    {!disableModal && isModalOpen && diff --git a/components/user/waves/UserPageProfileWave.tsx b/components/user/waves/UserPageProfileWave.tsx index 3c34ba4a6a..45ea02db76 100644 --- a/components/user/waves/UserPageProfileWave.tsx +++ b/components/user/waves/UserPageProfileWave.tsx @@ -30,6 +30,43 @@ import { import { useCallback, useEffect, useMemo } from "react"; import { Tooltip } from "react-tooltip"; +type ApiErrorLike = { + readonly status?: number | undefined; + readonly response?: { + readonly status?: number | undefined; + }; +}; + +const getErrorStatus = (error: unknown): number | null => { + if (error === null || error === undefined || typeof error !== "object") { + return null; + } + + const apiError = error as ApiErrorLike; + return apiError.status ?? apiError.response?.status ?? null; +}; + +const getErrorMessage = (error: unknown): string => { + if (typeof error === "string") { + return error; + } + + if (error instanceof Error) { + return error.message; + } + + return ""; +}; + +const isUnavailableWaveError = (error: unknown): boolean => { + const status = getErrorStatus(error); + if (status === 403 || status === 404) { + return true; + } + + return /not found|forbidden/i.test(getErrorMessage(error)); +}; + function UnavailableState({ canClear, onClear, @@ -75,6 +112,38 @@ function UnavailableState({ ); } +function LoadErrorState({ + isRetrying, + onRetry, +}: { + readonly isRetrying: boolean; + readonly onRetry: () => void; +}) { + return ( +
    +
    +
    +

    + Unable to load official wave +

    +

    + There was a temporary problem loading this profile curation. Try + again. +

    +
    + + {isRetrying ? : "Retry"} + +
    +
    + ); +} + const resolveProfileCuration = ( curations: readonly ApiWaveCuration[] ): ApiWaveCuration | null => { @@ -292,7 +361,8 @@ export default function UserPageProfileWave({ }); const resolvedProfile = profile ?? initialProfile; const profileWaveId = resolvedProfile.profile_wave_id; - const { wave, isLoading, isError } = useWaveById(profileWaveId); + const { wave, isLoading, isError, error, refetch, isFetching } = + useWaveById(profileWaveId); const { data: curations = [], isLoading: areCurationsLoading } = useWaveCurations({ waveId: wave?.id ?? "", @@ -306,6 +376,7 @@ export default function UserPageProfileWave({ handleOrWallet, }); const canClear = isOwnProfile && !activeProfileProxy; + const hasUnavailableWaveError = isUnavailableWaveError(error); const profileSearchString = useMemo( () => getProfilePageSearchString(searchString), [searchString] @@ -383,7 +454,7 @@ export default function UserPageProfileWave({ ); } - if (shouldForceUnavailableState || isError || !wave) { + if (shouldForceUnavailableState || hasUnavailableWaveError) { return ( { + void refetch(); + }} + /> + ); + } + return (
    diff --git a/components/user/waves/UserPageProfileWaveMasonry.tsx b/components/user/waves/UserPageProfileWaveMasonry.tsx index 07156e5243..d0ba751994 100644 --- a/components/user/waves/UserPageProfileWaveMasonry.tsx +++ b/components/user/waves/UserPageProfileWaveMasonry.tsx @@ -35,12 +35,6 @@ interface UserPageProfileWaveMasonryProps { readonly profileIdentity?: ProfileIdentitySummary | undefined; } -interface ProfileMasonryState { - readonly curationId: string; - readonly lastDropCount: number; - readonly resetVersion: number; -} - const MASONRY_COLUMN_WIDTH = 300; const MASONRY_GUTTER = 16; @@ -164,52 +158,6 @@ function useProfileMasonryContainerWidth() { return { containerRef, containerWidth }; } -function useProfileMasonryRenderState({ - containerWidth, - curationId, - dropCount, -}: { - readonly containerWidth: number; - readonly curationId: string; - readonly dropCount: number; -}) { - const [state, setState] = useState(() => ({ - curationId, - lastDropCount: dropCount, - resetVersion: 0, - })); - - let resetVersion = state.resetVersion; - let shouldResetMasonry = false; - - if (state.curationId !== curationId) { - resetVersion = 0; - setState({ - curationId, - lastDropCount: dropCount, - resetVersion, - }); - } else if (dropCount < state.lastDropCount) { - resetVersion = state.resetVersion + 1; - shouldResetMasonry = true; - setState({ - curationId, - lastDropCount: dropCount, - resetVersion, - }); - } else if (dropCount > state.lastDropCount) { - setState({ - ...state, - lastDropCount: dropCount, - }); - } - - return { - masonryKey: `${curationId}-${resetVersion}-${containerWidth}`, - shouldResetMasonry, - }; -} - function CurationMasonryRemoveButton({ drop, curationId, @@ -411,11 +359,6 @@ export default function UserPageProfileWaveMasonry({ const isInitialLoading = isFetching && drops.length === 0; const curationTitle = curationName?.trim() ?? "Curation"; const { containerRef, containerWidth } = useProfileMasonryContainerWidth(); - const { masonryKey, shouldResetMasonry } = useProfileMasonryRenderState({ - containerWidth, - curationId, - dropCount: drops.length, - }); const masonryItems = useMemo( () => drops.map((drop) => ({ @@ -427,6 +370,15 @@ export default function UserPageProfileWaveMasonry({ })), [canManageActiveCuration, curationId, drops, profileIdentity, showIdentity] ); + const masonryTopItemsKey = useMemo( + () => + drops + .slice(0, 8) + .map((drop) => drop.stableKey) + .join("|"), + [drops] + ); + const masonryKey = `${curationId}-${containerWidth}-${masonryTopItemsKey}`; const handleBottomIntersection = useCallback( (isIntersecting: boolean) => { @@ -451,14 +403,6 @@ export default function UserPageProfileWaveMasonry({ return ; } - if (shouldResetMasonry) { - return ( -
    - -
    - ); - } - return (
    {containerWidth > 0 ? ( diff --git a/components/waves/drops/WaveDropPartContentFullWidthImage.tsx b/components/waves/drops/WaveDropPartContentFullWidthImage.tsx new file mode 100644 index 0000000000..98e41e7419 --- /dev/null +++ b/components/waves/drops/WaveDropPartContentFullWidthImage.tsx @@ -0,0 +1,320 @@ +"use client"; + +import { fullScreenSupported } from "@/helpers/Helpers"; +import { getScaledImageUri, ImageScale } from "@/helpers/image.helpers"; +import useCapacitor from "@/hooks/useCapacitor"; +import { faExpand, faRotateLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; +import React, { useCallback, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Tooltip } from "react-tooltip"; +import useKeyPressEvent from "react-use/lib/useKeyPressEvent"; +import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch"; + +const tooltipProps = { + delayShow: 250, + place: "top" as const, + style: { backgroundColor: "#37373E", color: "white", zIndex: 1002 }, +}; + +const modalButtonClasses = + "tw-flex tw-items-center tw-justify-center tw-border-0 tw-text-iron-50 tw-bg-iron-800 desktop-hover:hover:tw-bg-iron-700 tw-rounded-full tw-size-10 tw-flex-shrink-0 tw-backdrop-blur-sm tw-transition-all tw-duration-300 tw-ease-out"; + +function NaturalHeightImage({ + imgRef, + primarySrc, + fallbackSrc, + retryTick, + onLoad, + onFinalError, +}: { + readonly imgRef: React.RefObject; + readonly primarySrc: string; + readonly fallbackSrc: string; + readonly retryTick: number; + readonly onLoad: () => void; + readonly onFinalError: () => void; +}) { + const [currentSrc, setCurrentSrc] = useState(primarySrc); + const [usedFallback, setUsedFallback] = useState(false); + + const handleError = useCallback(() => { + if (!usedFallback) { + setCurrentSrc(fallbackSrc); + setUsedFallback(true); + return; + } + + onFinalError(); + }, [fallbackSrc, onFinalError, usedFallback]); + + return ( + // eslint-disable-next-line @next/next/no-img-element + Drop media + ); +} + +export default function WaveDropPartContentFullWidthImage({ + src, + imageScale = ImageScale.AUTOx450, +}: { + readonly src: string; + readonly imageScale?: ImageScale | undefined; +}) { + const [loaded, setLoaded] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [errorCount, setErrorCount] = useState(0); + const [retryTick, setRetryTick] = useState(0); + const [isZoomed, setIsZoomed] = useState(false); + const imgRef = useRef(null); + const modalImageRef = useRef(null); + const { isCapacitor } = useCapacitor(); + + const handleImageLoad = useCallback(() => { + setLoaded(true); + }, []); + + const handleError = useCallback(() => { + setErrorCount(1); + }, []); + + const manualRetry = useCallback(() => { + setLoaded(false); + setErrorCount(0); + setRetryTick((currentTick) => currentTick + 1); + }, []); + + const handleOpenModal = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + setIsModalOpen(true); + }, + [] + ); + + const handleCloseModal = useCallback( + ( + event?: + | React.MouseEvent + | React.KeyboardEvent + | React.MouseEvent + ) => { + event?.stopPropagation(); + setIsModalOpen(false); + }, + [] + ); + + const handleFullScreen = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + const fullscreenTarget = modalImageRef.current ?? imgRef.current; + if (fullscreenTarget) { + fullscreenTarget.requestFullscreen().catch(() => undefined); + } + }, + [] + ); + + useKeyPressEvent("Escape", () => { + handleCloseModal(); + }); + + const modalContent = ( +
    event.stopPropagation()} + onTouchEnd={(event) => event.stopPropagation()} + onTouchMove={(event) => event.stopPropagation()} + > +
    + setIsZoomed(event.state.scale > 1)} + > + {({ resetTransform }) => ( +
    +
    +
    event.stopPropagation()} + tabIndex={0} + aria-label="Full size drop media" + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.stopPropagation(); + } + }} + > + + {/* eslint-disable-next-line @next/next/no-img-element */} + Full size drop media + +
    + +
    + {isZoomed && ( + + )} + + + + + + {fullScreenSupported() && !isCapacitor && ( + + )} + + +
    +
    +
    + )} +
    + + {!isCapacitor && ( + <> + + Open in Browser + + + + Full screen + + + + Reset zoom + + + + Close + + + )} +
    + ); + + if (errorCount > 0) { + return ( +
    + + Couldn’t load image. + + +
    + ); + } + + return ( + <> + + + {!loaded && ( +
    + Loading image +
    + )} + + {isModalOpen && createPortal(modalContent, document.body)} + + ); +} diff --git a/components/waves/drops/WaveDropPartContentMedias.tsx b/components/waves/drops/WaveDropPartContentMedias.tsx index 5e6e57405a..8b1b0ca57b 100644 --- a/components/waves/drops/WaveDropPartContentMedias.tsx +++ b/components/waves/drops/WaveDropPartContentMedias.tsx @@ -4,6 +4,7 @@ import type { ApiDropPart } from "@/generated/models/ApiDropPart"; import MediaDisplay from "@/components/drops/view/item/content/media/MediaDisplay"; import DropListItemContentMedia from "@/components/drops/view/item/content/media/DropListItemContentMedia"; import { ImageScale } from "@/helpers/image.helpers"; +import WaveDropPartContentFullWidthImage from "./WaveDropPartContentFullWidthImage"; interface WaveDropPartContentMediasProps { readonly activePart: ApiDropPart; @@ -52,31 +53,41 @@ const WaveDropPartContentMedias: React.FC = ({ "tw-flex tw-h-64 tw-items-center tw-justify-center", fullWidthMedia && "tw-w-full" ); + let mediaContent; + + if (disableMediaInteraction) { + mediaContent = ( + + ); + } else if (useNaturalHeightImage) { + mediaContent = ( + + ); + } else { + mediaContent = ( + + ); + } return (
    - {disableMediaInteraction ? ( - - ) : ( - - )} + {mediaContent}
    ); })} diff --git a/components/waves/drops/participation/OngoingParticipationDrop.tsx b/components/waves/drops/participation/OngoingParticipationDrop.tsx index b7ed507d10..061781744f 100644 --- a/components/waves/drops/participation/OngoingParticipationDrop.tsx +++ b/components/waves/drops/participation/OngoingParticipationDrop.tsx @@ -162,6 +162,7 @@ export default function OngoingParticipationDrop({
    diff --git a/components/waves/drops/participation/ParticipationDropFooter.tsx b/components/waves/drops/participation/ParticipationDropFooter.tsx index 4cfb403807..10cf309c2b 100644 --- a/components/waves/drops/participation/ParticipationDropFooter.tsx +++ b/components/waves/drops/participation/ParticipationDropFooter.tsx @@ -10,12 +10,14 @@ import { ParticipationDropRatings } from "./ParticipationDropRatings"; interface ParticipationDropFooterProps { readonly drop: ExtendedDrop; readonly voteAction?: ReactNode; + readonly showIdentity?: boolean | undefined; readonly showInteractions?: boolean | undefined; } export default function ParticipationDropFooter({ drop, voteAction, + showIdentity = true, showInteractions = true, }: ParticipationDropFooterProps) { const { canShowVote } = useDropInteractionRules(drop); @@ -38,7 +40,9 @@ export default function ParticipationDropFooter({ <> {shouldShowVoteFooter && (
    e.stopPropagation()} >
    @@ -65,13 +69,21 @@ export default function ParticipationDropFooter({ {/* Show ratings if no vote button */} {shouldShowRatingsOnlyFooter && ( -
    +
    )} {shouldShowReactionsFooter && ( -
    +
    {!canShowVote && ( { From a1301478d7d361f620829f7abe5f9bd49f0dccee Mon Sep 17 00:00:00 2001 From: ragnep Date: Tue, 14 Apr 2026 15:35:13 +0300 Subject: [PATCH 5/8] wip Signed-off-by: ragnep --- components/user/layout/UserPageLayout.tsx | 3 +- components/user/waves/UserPageProfileWave.tsx | 84 ++++++++++--------- .../user/waves/UserPageProfileWaveMasonry.tsx | 2 +- .../WaveDropPartContentFullWidthImage.tsx | 68 +++++---------- .../OngoingParticipationDrop.tsx | 1 - .../participation/ParticipationDropFooter.tsx | 18 +--- 6 files changed, 71 insertions(+), 105 deletions(-) diff --git a/components/user/layout/UserPageLayout.tsx b/components/user/layout/UserPageLayout.tsx index 2039a0f0a5..0bff329cfc 100644 --- a/components/user/layout/UserPageLayout.tsx +++ b/components/user/layout/UserPageLayout.tsx @@ -15,7 +15,8 @@ export default function UserPageLayout({ readonly children: ReactNode; }) { const normalizedHandleOrWallet = handleOrWallet.toLowerCase(); - const mainAddress = initialProfile.primary_wallet; + const mainAddress = + initialProfile?.primary_wallet ?? normalizedHandleOrWallet; return (
    diff --git a/components/user/waves/UserPageProfileWave.tsx b/components/user/waves/UserPageProfileWave.tsx index 45ea02db76..beda10bba7 100644 --- a/components/user/waves/UserPageProfileWave.tsx +++ b/components/user/waves/UserPageProfileWave.tsx @@ -91,7 +91,7 @@ function UnavailableState({ {canClear && ( - - {mode.label} - -
    - ))} -
    - +
    + {viewModes.map((mode) => ( +
    + + + {mode.label} + +
    + ))} +
    ); } @@ -435,6 +433,12 @@ export default function UserPageProfileWave({ router.push(waveHref, { scroll: false }); }, [router, waveHref]); + const retryLoad = useCallback(async () => { + await refetch(); + }, [refetch]); + const clearProfileWave = useCallback(async () => { + await clearSelectedProfileWave(); + }, [clearSelectedProfileWave]); if (!profileWaveId && !shouldForceUnavailableState) { return null; @@ -459,7 +463,7 @@ export default function UserPageProfileWave({ ); } @@ -468,9 +472,7 @@ export default function UserPageProfileWave({ return ( { - void refetch(); - }} + onRetry={retryLoad} /> ); } @@ -503,7 +505,7 @@ export default function UserPageProfileWave({ {canClear && ( + + {fullScreenSupported() && !isCapacitor && ( diff --git a/components/waves/drops/participation/OngoingParticipationDrop.tsx b/components/waves/drops/participation/OngoingParticipationDrop.tsx index 061781744f..b7ed507d10 100644 --- a/components/waves/drops/participation/OngoingParticipationDrop.tsx +++ b/components/waves/drops/participation/OngoingParticipationDrop.tsx @@ -162,7 +162,6 @@ export default function OngoingParticipationDrop({
    diff --git a/components/waves/drops/participation/ParticipationDropFooter.tsx b/components/waves/drops/participation/ParticipationDropFooter.tsx index 10cf309c2b..4cfb403807 100644 --- a/components/waves/drops/participation/ParticipationDropFooter.tsx +++ b/components/waves/drops/participation/ParticipationDropFooter.tsx @@ -10,14 +10,12 @@ import { ParticipationDropRatings } from "./ParticipationDropRatings"; interface ParticipationDropFooterProps { readonly drop: ExtendedDrop; readonly voteAction?: ReactNode; - readonly showIdentity?: boolean | undefined; readonly showInteractions?: boolean | undefined; } export default function ParticipationDropFooter({ drop, voteAction, - showIdentity = true, showInteractions = true, }: ParticipationDropFooterProps) { const { canShowVote } = useDropInteractionRules(drop); @@ -40,9 +38,7 @@ export default function ParticipationDropFooter({ <> {shouldShowVoteFooter && (
    e.stopPropagation()} >
    @@ -69,21 +65,13 @@ export default function ParticipationDropFooter({ {/* Show ratings if no vote button */} {shouldShowRatingsOnlyFooter && ( -
    +
    )} {shouldShowReactionsFooter && ( -
    +
    {!canShowVote && ( Date: Tue, 14 Apr 2026 16:23:02 +0300 Subject: [PATCH 6/8] wip Signed-off-by: ragnep --- app/[user]/curations/page.tsx | 39 +++++++++++++ components/user/waves/UserPageProfileWave.tsx | 55 ++++++++++++++++--- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/app/[user]/curations/page.tsx b/app/[user]/curations/page.tsx index ca88d7daf2..ae3575096d 100644 --- a/app/[user]/curations/page.tsx +++ b/app/[user]/curations/page.tsx @@ -5,6 +5,38 @@ import { USER_PAGE_TAB_IDS, USER_PAGE_TAB_MAP, } from "@/components/user/layout/userTabs.config"; +import { redirect } from "next/navigation"; + +const getProfileTabDestination = ({ + profile, + query, +}: { + readonly profile: ApiIdentity; + readonly query: Record; +}): string => { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(query)) { + if (key === "curation" || value === undefined) { + continue; + } + + if (Array.isArray(value)) { + for (const entry of value) { + searchParams.append(key, entry); + } + continue; + } + + searchParams.append(key, value); + } + + const canonicalUser = profile.handle ?? profile.primary_wallet ?? ""; + const basePath = canonicalUser ? `/${encodeURIComponent(canonicalUser)}` : "/"; + const queryString = searchParams.toString(); + + return queryString ? `${basePath}?${queryString}` : basePath; +}; function WaveTab({ profile }: { readonly profile: ApiIdentity }) { return ; @@ -16,6 +48,13 @@ const { Page, generateMetadata } = createUserTabPage({ subroute: TAB_CONFIG.route, metaLabel: TAB_CONFIG.metaLabel, Tab: WaveTab, + getTabProps: async ({ profile, query }) => { + if (!profile.profile_wave_id) { + redirect(getProfileTabDestination({ profile, query })); + } + + return {}; + }, }); export default Page; diff --git a/components/user/waves/UserPageProfileWave.tsx b/components/user/waves/UserPageProfileWave.tsx index beda10bba7..c55e931376 100644 --- a/components/user/waves/UserPageProfileWave.tsx +++ b/components/user/waves/UserPageProfileWave.tsx @@ -113,22 +113,25 @@ function UnavailableState({ } function LoadErrorState({ + description, isRetrying, onRetry, + title, }: { + readonly description: string; readonly isRetrying: boolean; readonly onRetry: () => void; + readonly title: string; }) { return (

    - Unable to load official wave + {title}

    - There was a temporary problem loading this profile curation. Try - again. + {description}

    void; readonly profileCuration: ApiWaveCuration | null; readonly profileIdentity: { readonly id?: string | null | undefined; @@ -304,6 +315,17 @@ function ProfileCurationBody({ ); } + if (areCurationsError && !hasLoadedCurations) { + return ( + + ); + } + if (!profileCuration) { return (
    @@ -361,11 +383,16 @@ export default function UserPageProfileWave({ const profileWaveId = resolvedProfile.profile_wave_id; const { wave, isLoading, isError, error, refetch, isFetching } = useWaveById(profileWaveId); - const { data: curations = [], isLoading: areCurationsLoading } = - useWaveCurations({ - waveId: wave?.id ?? "", - enabled: !!wave?.id, - }); + const { + data: curations, + isLoading: areCurationsLoading, + isError: areCurationsError, + isFetching: areCurationsFetching, + refetch: refetchCurations, + } = useWaveCurations({ + waveId: wave?.id ?? "", + enabled: !!wave?.id, + }); const { clearSelectedProfileWave, isPending, pendingAction } = useProfileWaveMutation(resolvedProfile); const { viewMode, setViewMode } = useProfileCurationViewMode(); @@ -379,8 +406,9 @@ export default function UserPageProfileWave({ () => getProfilePageSearchString(searchString), [searchString] ); + const hasLoadedCurations = curations !== undefined; const profileCuration = useMemo( - () => resolveProfileCuration(curations), + () => resolveProfileCuration(curations ?? []), [curations] ); const profileCurationTitle = useMemo( @@ -436,6 +464,9 @@ export default function UserPageProfileWave({ const retryLoad = useCallback(async () => { await refetch(); }, [refetch]); + const retryCurationsLoad = useCallback(async () => { + await refetchCurations(); + }, [refetchCurations]); const clearProfileWave = useCallback(async () => { await clearSelectedProfileWave(); }, [clearSelectedProfileWave]); @@ -471,6 +502,8 @@ export default function UserPageProfileWave({ if (isError || !wave) { return ( @@ -527,7 +560,11 @@ export default function UserPageProfileWave({
    Date: Tue, 14 Apr 2026 16:55:40 +0300 Subject: [PATCH 7/8] wip Signed-off-by: ragnep --- components/user/layout/userTabs.config.ts | 4 ++-- components/user/waves/UserPageProfileWave.tsx | 10 +++++----- components/waves/drops/WaveDropPartContentMedias.tsx | 3 +-- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/components/user/layout/userTabs.config.ts b/components/user/layout/userTabs.config.ts index 2b4c0099cc..e857240d08 100644 --- a/components/user/layout/userTabs.config.ts +++ b/components/user/layout/userTabs.config.ts @@ -32,8 +32,8 @@ const TAB_DEFINITIONS = [ id: "waves", title: "Curation", route: "curations", - isVisible: ({ showWaves, hasProfileWave }: UserPageVisibilityContext) => - showWaves && hasProfileWave, + isVisible: ({ hasProfileWave }: UserPageVisibilityContext) => + hasProfileWave, }, { id: "collected", diff --git a/components/user/waves/UserPageProfileWave.tsx b/components/user/waves/UserPageProfileWave.tsx index c55e931376..0cfa380270 100644 --- a/components/user/waves/UserPageProfileWave.tsx +++ b/components/user/waves/UserPageProfileWave.tsx @@ -527,14 +527,14 @@ export default function UserPageProfileWave({ onChange={setViewMode} /> )} - Open wave - + {canClear && (