diff --git a/app/[user]/curations/page.tsx b/app/[user]/curations/page.tsx new file mode 100644 index 0000000000..ae3575096d --- /dev/null +++ b/app/[user]/curations/page.tsx @@ -0,0 +1,61 @@ +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"; +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 ; +} + +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, + getTabProps: async ({ profile, query }) => { + if (!profile.profile_wave_id) { + redirect(getProfileTabDestination({ profile, query })); + } + + return {}; + }, +}); + +export default Page; +export { generateMetadata }; diff --git a/app/[user]/waves/page.tsx b/app/[user]/waves/page.tsx index d4e6ccbee9..5848889ae8 100644 --- a/app/[user]/waves/page.tsx +++ b/app/[user]/waves/page.tsx @@ -1,14 +1,77 @@ -import { notFound, redirect } from "next/navigation"; +import { redirect } from "next/navigation"; +import { getAppCommonHeaders } from "@/helpers/server.app.helpers"; +import { getUserProfile } from "@/helpers/server.helpers"; -export default async function WavesPage({ +type UserRouteParams = { user: string }; +type UserSearchParams = Record; + +const normalizeSearchParams = ( + params?: UserSearchParams | URLSearchParams +): URLSearchParams => { + const normalizedParams = new URLSearchParams(); + + if (!params) { + return normalizedParams; + } + + if (params instanceof URLSearchParams) { + for (const [key, value] of params.entries()) { + normalizedParams.append(key, value); + } + return normalizedParams; + } + + for (const [key, value] of Object.entries(params)) { + if (value === undefined) { + continue; + } + + if (Array.isArray(value)) { + for (const entry of value) { + normalizedParams.append(key, entry); + } + continue; + } + + normalizedParams.append(key, value); + } + + return normalizedParams; +}; + +export default async function LegacyWavesPage({ params, + searchParams, }: { - readonly params?: Promise<{ user: string }> | undefined; + readonly params?: Promise | undefined; + readonly searchParams?: Promise | undefined; }) { const resolvedParams = params ? await params : undefined; + const resolvedSearchParams = searchParams ? await searchParams : undefined; const user = resolvedParams?.user; + if (!user) { - notFound(); + redirect("/"); } - redirect(`/${user}`); + + const queryString = normalizeSearchParams(resolvedSearchParams).toString(); + let basePath = `/${encodeURIComponent(user)}`; + + try { + const profile = await getUserProfile({ + user: user.toLowerCase(), + headers: await getAppCommonHeaders(), + }); + const canonicalUser = profile.handle ?? profile.primary_wallet; + + basePath = profile.profile_wave_id + ? `/${encodeURIComponent(canonicalUser)}/curations` + : `/${encodeURIComponent(canonicalUser)}`; + } catch { + basePath = `/${encodeURIComponent(user)}`; + } + + const destination = queryString ? `${basePath}?${queryString}` : basePath; + + redirect(destination); } 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 861ff4a944..249f7fe7c9 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,8 @@ 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; } function MyStreamWaveCurationDropItem({ @@ -37,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(); @@ -103,7 +105,8 @@ function MyStreamWaveCurationDropItem({ }} disabled={isPending} aria-label="Remove drop from this curation" - className="tw-absolute tw-right-7 tw-top-4 tw-z-20 tw-inline-flex tw-h-8 tw-w-8 tw-items-center tw-justify-center tw-rounded-lg tw-border tw-border-solid tw-border-rose-500/25 tw-bg-rose-500/10 tw-p-0 tw-text-rose-400 tw-shadow-[0_10px_30px_rgba(0,0,0,0.32)] tw-backdrop-blur-sm tw-transition-all tw-duration-200 tw-ease-out active:tw-bg-rose-500/15 disabled:tw-cursor-not-allowed disabled:tw-opacity-60 desktop-hover:tw-pointer-events-none desktop-hover:tw-w-auto desktop-hover:tw-translate-y-1 desktop-hover:tw-gap-1.5 desktop-hover:tw-border-iron-700/80 desktop-hover:tw-bg-iron-950/90 desktop-hover:tw-px-2.5 desktop-hover:tw-text-xs desktop-hover:tw-font-medium desktop-hover:tw-text-iron-200 desktop-hover:tw-opacity-0 desktop-hover:group-hover:tw-pointer-events-auto desktop-hover:group-hover:tw-translate-y-0 desktop-hover:group-hover:tw-opacity-100 desktop-hover:hover:tw-border-iron-500 desktop-hover:hover:tw-bg-iron-900 desktop-hover:hover:tw-text-white" + title="Remove from wave" + className="tw-absolute tw-right-7 tw-top-4 tw-z-20 tw-inline-flex tw-h-8 tw-w-8 tw-items-center tw-justify-center tw-rounded-lg tw-border tw-border-solid tw-border-white/10 tw-bg-black/50 tw-p-0 tw-text-iron-400 tw-shadow-[0_10px_30px_rgba(0,0,0,0.32)] tw-backdrop-blur-sm tw-transition-all tw-duration-200 tw-ease-out hover:tw-border-rose-500/30 hover:tw-bg-rose-500/20 hover:tw-text-rose-300 active:tw-bg-rose-500/15 disabled:tw-cursor-not-allowed disabled:tw-opacity-60 desktop-hover:tw-pointer-events-none desktop-hover:tw-w-auto desktop-hover:tw-translate-y-1 desktop-hover:tw-gap-1.5 desktop-hover:tw-px-2.5 desktop-hover:tw-text-xs desktop-hover:tw-font-medium desktop-hover:tw-opacity-0 desktop-hover:group-hover:tw-pointer-events-auto desktop-hover:group-hover:tw-translate-y-0 desktop-hover:group-hover:tw-opacity-100" > {isPending ? ( <> @@ -129,6 +132,7 @@ export default function MyStreamWaveCurationContent({ curationId, curationName, onDropClick, + constrainToViewport = true, }: MyStreamWaveCurationContentProps) { const { leaderboardViewStyle } = useLayout(); const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = @@ -137,9 +141,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; @@ -156,9 +160,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( () => @@ -186,20 +187,18 @@ 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 = ( -
+
{renderedDrops} {(hasNextPage || isFetchingNextPage) && (
@@ -220,8 +219,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 ? ( + ); +}; + +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; + 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 && (
+
-
+
-
- +
+
{children}
diff --git a/components/user/layout/UserPageTabs.tsx b/components/user/layout/UserPageTabs.tsx index 9049c25f1c..d61680c9aa 100644 --- a/components/user/layout/UserPageTabs.tsx +++ b/components/user/layout/UserPageTabs.tsx @@ -5,6 +5,8 @@ import { useCookieConsent } from "@/components/cookies/CookieConsentContext"; import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext"; import { isOwnProfileRoute } from "@/helpers/ProfileHelpers"; import useCapacitor from "@/hooks/useCapacitor"; +import { useIdentity } from "@/hooks/useIdentity"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { faChevronLeft, faChevronRight, @@ -33,9 +35,7 @@ import { type UserPageVisibilityContext, getUserPageTabByRoute, } from "./userTabs.config"; -import { - shouldHideSubscriptions, -} from "./userPageVisibility"; +import { shouldHideSubscriptions } from "./userPageVisibility"; import { shouldDelayUserPageBrainRedirect } from "./userPageBrainAccess"; const DEFAULT_TAB = DEFAULT_USER_PAGE_TAB; @@ -45,17 +45,20 @@ const getServerRenderSnapshot = () => false; const getVisibilityContext = ({ showWaves, + hasProfileWave, capacitorIsIos, country, isOwnProfile, }: { readonly showWaves: boolean; + readonly hasProfileWave: boolean; readonly capacitorIsIos: boolean; readonly country: string | null | undefined; readonly isOwnProfile: boolean; }): UserPageVisibilityContext => { return { showWaves, + hasProfileWave, hideSubscriptions: shouldHideSubscriptions({ capacitorIsIos, country, @@ -71,7 +74,11 @@ const resolveTabFromPath = (pathname: string): UserPageTabKey => { return match?.id ?? DEFAULT_TAB; }; -export default function UserPageTabs() { +export default function UserPageTabs({ + initialProfile, +}: { + readonly initialProfile: ApiIdentity; +}) { const pathname = usePathname(); const router = useRouter(); const params = useParams(); @@ -82,6 +89,11 @@ export default function UserPageTabs() { const { country } = useCookieConsent(); const { showWaves, connectedProfile, fetchingProfile } = useAuth(); const { address, connectionState } = useSeizeConnectContext(); + const { profile: viewedProfile } = useIdentity({ + handleOrWallet, + initialProfile, + }); + const resolvedViewedProfile = viewedProfile ?? initialProfile; const isOwnProfile = useMemo(() => { return isOwnProfileRoute({ @@ -90,15 +102,18 @@ export default function UserPageTabs() { }); }, [connectedProfile, handleOrWallet]); + const hasProfileWave = Boolean(resolvedViewedProfile.profile_wave_id); + const visibilityContext = useMemo( () => getVisibilityContext({ showWaves, + hasProfileWave, capacitorIsIos: capacitor.isIos, country, isOwnProfile, }), - [capacitor.isIos, country, isOwnProfile, showWaves] + [capacitor.isIos, country, hasProfileWave, isOwnProfile, showWaves] ); const scrollContainerRef = useRef(null); diff --git a/components/user/layout/userTabs.config.ts b/components/user/layout/userTabs.config.ts index 80e1aa7f0b..e857240d08 100644 --- a/components/user/layout/userTabs.config.ts +++ b/components/user/layout/userTabs.config.ts @@ -1,5 +1,6 @@ export type UserPageVisibilityContext = { readonly showWaves: boolean; + readonly hasProfileWave: boolean; readonly hideSubscriptions: boolean; readonly isOwnProfile: boolean; }; @@ -27,6 +28,13 @@ const TAB_DEFINITIONS = [ route: "brain", isVisible: ({ showWaves }: UserPageVisibilityContext) => showWaves, }, + { + id: "waves", + title: "Curation", + route: "curations", + isVisible: ({ hasProfileWave }: UserPageVisibilityContext) => + hasProfileWave, + }, { id: "collected", title: "Collected", diff --git a/components/user/waves/UserPageProfileWave.tsx b/components/user/waves/UserPageProfileWave.tsx new file mode 100644 index 0000000000..9caf94e84d --- /dev/null +++ b/components/user/waves/UserPageProfileWave.tsx @@ -0,0 +1,576 @@ +"use client"; + +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"; +import { useWaveCurations } from "@/hooks/waves/useWaveCurations"; +import { useIdentity } from "@/hooks/useIdentity"; +import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import type { ApiWaveCuration } from "@/generated/models/ApiWaveCuration"; +import { isOwnProfileRoute } from "@/helpers/ProfileHelpers"; +import { getWaveRoute } from "@/helpers/navigation.helpers"; +import { isWaveDirectMessage } from "@/helpers/waves/wave.helpers"; +import { + ArrowTopRightOnSquareIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; +import { + useParams, + usePathname, + useRouter, + useSearchParams, +} from "next/navigation"; +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, + isPending, +}: { + readonly canClear: boolean; + readonly onClear: () => Promise; + readonly isPending: boolean; +}) { + return ( +
+
+
+

+ Official wave unavailable +

+

+ The official wave behind this curation tab could not be loaded. It + may have been removed or is no longer accessible. +

+
+ {canClear && ( + + )} +
+
+ ); +} + +function LoadErrorState({ + description, + isRetrying, + onRetry, + title, +}: { + readonly description: string; + readonly isRetrying: boolean; + readonly onRetry: () => void; + readonly title: string; +}) { + return ( +
+
+
+

+ {title} +

+

+ {description} +

+
+ + {isRetrying ? : "Retry"} + +
+
+ ); +} + +const resolveProfileCuration = ( + curations: readonly ApiWaveCuration[] +): ApiWaveCuration | null => { + let firstCreatedCuration: ApiWaveCuration | null = null; + + for (const curation of curations) { + if ( + !firstCreatedCuration || + curation.created_at < firstCreatedCuration.created_at || + (curation.created_at === firstCreatedCuration.created_at && + curation.id.localeCompare(firstCreatedCuration.id) < 0) + ) { + firstCreatedCuration = curation; + } + } + + return firstCreatedCuration; +}; + +const getProfilePageSearchString = (searchString: string): string => { + const params = new URLSearchParams(searchString); + params.delete("curation"); + return params.toString(); +}; + +function ProfileCurationViewToggle({ + viewMode, + onChange, +}: { + readonly viewMode: "masonry" | "list"; + 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} + +
+ ))} +
+ ); +} + +const getProfileCurationTitle = ( + profileCuration: ApiWaveCuration | null +): string => { + const title = profileCuration?.name.trim() ?? ""; + return title || "Curation"; +}; + +function ProfileCurationBody({ + areCurationsError, + areCurationsFetching, + areCurationsLoading, + hasLoadedCurations, + onRetryCurations, + profileCuration, + profileIdentity, + viewMode, + wave, +}: { + readonly areCurationsError: boolean; + readonly areCurationsFetching: boolean; + readonly areCurationsLoading: boolean; + readonly hasLoadedCurations: boolean; + readonly onRetryCurations: () => void; + 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 (areCurationsError && !hasLoadedCurations) { + return ( + + ); + } + + 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, +}: { + readonly profile: ApiIdentity; +}) { + const params = useParams(); + const pathname = usePathname(); + const router = useRouter(); + const searchParams = useSearchParams(); + const searchString = searchParams.toString(); + const handleOrWallet = params["user"]?.toString() ?? ""; + const { connectedProfile, activeProfileProxy } = useAuth(); + const { profile } = useIdentity({ + handleOrWallet, + initialProfile, + }); + const resolvedProfile = profile ?? initialProfile; + const profileWaveId = resolvedProfile.profile_wave_id; + const { wave, isLoading, isError, error, refetch, isFetching } = + useWaveById(profileWaveId); + 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(); + const isOwnProfile = isOwnProfileRoute({ + connectedProfile, + handleOrWallet, + }); + const canClear = isOwnProfile && !activeProfileProxy; + const hasUnavailableWaveError = isUnavailableWaveError(error); + const profileSearchString = useMemo( + () => getProfilePageSearchString(searchString), + [searchString] + ); + const hasLoadedCurations = curations !== undefined; + const profileCuration = useMemo( + () => 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; + } + + const baseWaveHref = getWaveRoute({ + waveId: wave.id, + isDirectMessage: isWaveDirectMessage(wave.id, wave), + isApp: false, + }); + + if (!profileCuration) { + return baseWaveHref; + } + + return `${baseWaveHref}?${new URLSearchParams({ + curation: profileCuration.id, + }).toString()}`; + }, [profileCuration, wave]); + + useEffect(() => { + if (profileSearchString === searchString) { + return; + } + + const nextUrl = profileSearchString + ? `${pathname}?${profileSearchString}` + : pathname; + router.replace(nextUrl, { scroll: false }); + }, [pathname, profileSearchString, router, searchString]); + + const openWave = useCallback(() => { + if (!waveHref) { + return; + } + + router.push(waveHref, { scroll: false }); + }, [router, waveHref]); + const retryLoad = useCallback(async () => { + await refetch(); + }, [refetch]); + const retryCurationsLoad = useCallback(async () => { + await refetchCurations(); + }, [refetchCurations]); + const clearProfileWave = useCallback(async () => { + await clearSelectedProfileWave(); + }, [clearSelectedProfileWave]); + + if (!profileWaveId) { + return null; + } + + if (isLoading) { + return ( +
+
+ + Loading profile wave... +
+
+ ); + } + + if (hasUnavailableWaveError) { + return ( + + ); + } + + if (isError || !wave) { + return ( + + ); + } + + return ( +
+
+
+
+

+ {profileCurationTitle} +

+
+ +
+ {profileCuration && ( + + )} + + {canClear && ( + + )} +
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/components/user/waves/UserPageProfileWaveMasonry.tsx b/components/user/waves/UserPageProfileWaveMasonry.tsx new file mode 100644 index 0000000000..8527efafa1 --- /dev/null +++ b/components/user/waves/UserPageProfileWaveMasonry.tsx @@ -0,0 +1,441 @@ +"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 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; +} + +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 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, + 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; + 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, + identityMode, + shouldUseInlineMinimalLayout, + showMinimalIdentityRow, + 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 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; + const dropContent = ( + {}} + onLongPress={() => {}} + setLongPressTriggered={(_triggered: boolean) => {}} + onDropContentClick={undefined} + mediaImageScale={ImageScale.AUTOx1080} + fullWidthMedia={true} + /> + ); + + if (layout.usesDefaultDropRenderer) { + return ( +
+ {removeButton} + + {}} + onReplyClick={() => {}} + onQuoteClick={() => {}} + identityMode={layout.identityMode} + showInteractions={false} + /> +
+ ); + } + + return ( +
+ {removeButton} + +
+ {layout.shouldUseInlineMinimalLayout ? ( +
+ +
+ +
{dropContent}
+ {drop.metadata.length > 0 && ( + + )} +
+
+ ) : ( + <> + {layout.showMinimalIdentityRow && ( +
+ +
+ +
+
+ )} + +
+ {replyTo && ( +
+ {}} + /> +
+ )} + + {dropContent} +
+ + {drop.metadata.length > 0 && ( +
+ +
+ )} + + )} +
+
+ ); +} + +function UserPageProfileWaveMasonryRenderItem({ + data, +}: { + readonly data: ProfileMasonryItem; +}) { + return ( + + ); +} + +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 masonryItems = useMemo( + () => + drops.map((drop) => ({ + drop, + curationId, + canManageActiveCuration, + showIdentity, + profileIdentity, + })), + [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) => { + if (!isIntersecting || !hasNextPage || isFetchingNextPage) { + return; + } + + fetchNextPage().catch(() => undefined); + }, + [fetchNextPage, hasNextPage, isFetchingNextPage] + ); + + if (isInitialLoading) { + return ( +
+ +
+ ); + } + + if (drops.length === 0) { + return ; + } + + return ( +
+ {containerWidth > 0 ? ( + item.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..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-px-3.5 tw-py-2.5"; + ? "tw-px-3 tw-py-2 tw-text-xs" + : "tw-px-3.5 tw-py-2.5 tw-text-sm"; return (
diff --git a/components/waves/drops/WaveDropPartContentFullWidthImage.tsx b/components/waves/drops/WaveDropPartContentFullWidthImage.tsx new file mode 100644 index 0000000000..6dfe462d3d --- /dev/null +++ b/components/waves/drops/WaveDropPartContentFullWidthImage.tsx @@ -0,0 +1,296 @@ +"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 ( + 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(() => { + 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 = ( +
+ + )} + + + + + + {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 f6b2516e3d..6df87a8148 100644 --- a/components/waves/drops/WaveDropPartContentMedias.tsx +++ b/components/waves/drops/WaveDropPartContentMedias.tsx @@ -1,14 +1,17 @@ import React from "react"; +import clsx from "clsx"; 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; readonly disableMediaInteraction?: boolean | undefined; readonly isCompetitionDrop?: boolean | undefined; readonly imageScale?: ImageScale | undefined; + readonly fullWidthMedia?: boolean | undefined; } const WaveDropPartContentMedias: React.FC = ({ @@ -16,37 +19,77 @@ const WaveDropPartContentMedias: React.FC = ({ disableMediaInteraction = false, isCompetitionDrop = false, imageScale = ImageScale.AUTOx450, + fullWidthMedia = false, }) => { if (!activePart.media.length) { return null; } + const hasContentBeforeMedia = + Boolean(activePart.content?.trim()) || + Boolean(activePart.quoted_drop?.drop_id); + let topSpacingClassName = "tw-mt-1"; + + if (hasContentBeforeMedia) { + topSpacingClassName = "tw-mt-4"; + } else if (fullWidthMedia) { + topSpacingClassName = "tw-mt-0"; + } + + const mediaStackClassName = clsx( + topSpacingClassName, + "tw-space-y-3" + ); + return ( -
- {activePart.media.map((media, i) => ( -
- {disableMediaInteraction ? ( +
+ {activePart.media.map((media, i) => { + const useNaturalHeightImage = + 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" + ); + let mediaContent; + + if (disableMediaInteraction) { + mediaContent = ( - ) : ( + ); + } else if (useNaturalHeightImage) { + mediaContent = ( + + ); + } else { + mediaContent = ( - )} -
- ))} + ); + } + + return ( +
+ {mediaContent} +
+ ); + })}
); }; diff --git a/components/waves/drops/WaveDropPartDrop.tsx b/components/waves/drops/WaveDropPartDrop.tsx index b4049f1f4d..c91af6d9c3 100644 --- a/components/waves/drops/WaveDropPartDrop.tsx +++ b/components/waves/drops/WaveDropPartDrop.tsx @@ -28,6 +28,7 @@ interface WaveDropPartDropProps { readonly onCancel?: (() => void) | undefined; isCompetitionDrop?: boolean | undefined; mediaImageScale?: ImageScale | undefined; + fullWidthMedia?: boolean | undefined; readonly onLinkCardActionsActiveChange?: | ((href: string, active: boolean) => void) | undefined; @@ -48,6 +49,7 @@ const WaveDropPartDrop: React.FC = ({ onCancel, isCompetitionDrop = false, mediaImageScale = ImageScale.AUTOx450, + fullWidthMedia = false, onLinkCardActionsActiveChange, }) => { return ( @@ -74,6 +76,7 @@ const WaveDropPartDrop: React.FC = ({ drop={drop} isCompetitionDrop={isCompetitionDrop} mediaImageScale={mediaImageScale} + fullWidthMedia={fullWidthMedia} onLinkCardActionsActiveChange={onLinkCardActionsActiveChange} />
diff --git a/components/waves/drops/drop.types.ts b/components/waves/drops/drop.types.ts index 1038ed209f..3e1721246c 100644 --- a/components/waves/drops/drop.types.ts +++ b/components/waves/drops/drop.types.ts @@ -5,6 +5,8 @@ export interface DropInteractionParams { partId: number; } +export type DropIdentityMode = "default" | "minimal" | "hidden"; + export enum DropLocation { MY_STREAM = "MY_STREAM", WAVE = "WAVE", diff --git a/components/waves/drops/participation/DefaultParticipationDrop.tsx b/components/waves/drops/participation/DefaultParticipationDrop.tsx index 32501a7101..b276b09058 100644 --- a/components/waves/drops/participation/DefaultParticipationDrop.tsx +++ b/components/waves/drops/participation/DefaultParticipationDrop.tsx @@ -5,7 +5,11 @@ import React from "react"; import { useDropInteractionRules } from "@/hooks/drops/useDropInteractionRules"; import OngoingParticipationDrop from "./OngoingParticipationDrop"; import EndedParticipationDrop from "./EndedParticipationDrop"; -import type { DropInteractionParams, DropLocation } from "../drop.types"; +import type { + DropIdentityMode, + DropInteractionParams, + DropLocation, +} from "../drop.types"; interface DefaultParticipationDropProps { readonly drop: ExtendedDrop; @@ -17,6 +21,8 @@ interface DefaultParticipationDropProps { readonly onQuoteClick: (drop: ApiDrop) => void; readonly onDropContentClick?: ((drop: ExtendedDrop) => 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 24f310ebf7..5c8dd19c5c 100644 --- a/components/waves/drops/participation/EndedParticipationDrop.tsx +++ b/components/waves/drops/participation/EndedParticipationDrop.tsx @@ -15,6 +15,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useCallback, useState } from "react"; import DropCurationButton from "../DropCurationButton"; +import DropMinimalIdentityRow from "../DropMinimalIdentityRow"; import WaveDropActions from "../WaveDropActions"; import WaveDropAuthorPfp from "../WaveDropAuthorPfp"; import WaveDropContent from "../WaveDropContent"; @@ -26,7 +27,7 @@ import { getParticipationIdentityProfile, getParticipationVisibleMetadata, } from "./participationIdentityProfile.helpers"; -import type { DropInteractionParams } from "../drop.types"; +import type { DropIdentityMode, DropInteractionParams } from "../drop.types"; import { DropLocation } from "../drop.types"; interface EndedParticipationDropProps { @@ -38,6 +39,8 @@ interface EndedParticipationDropProps { readonly onReply: (param: DropInteractionParams) => void; readonly onQuoteClick: (drop: ApiDrop) => void; readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; + readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; } export default function EndedParticipationDrop({ @@ -49,6 +52,8 @@ export default function EndedParticipationDrop({ onReply, onQuoteClick, onDropContentClick, + identityMode = "default", + showInteractions = true, }: EndedParticipationDropProps) { const isActiveDrop = activeDrop?.drop.id === drop.id; const router = useRouter(); @@ -72,6 +77,7 @@ export default function EndedParticipationDrop({ const [isSlideUp, setIsSlideUp] = useState(false); const isMobile = useIsMobileDevice(); const hasTouch = useIsTouchDevice() || isMobile; + const showIdentity = identityMode !== "hidden"; const handleNavigation = (e: React.MouseEvent, path: string) => { e.preventDefault(); @@ -80,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); @@ -111,7 +117,7 @@ export default function EndedParticipationDrop({
- {!isMobile && showReplyAndQuote && ( + {!isMobile && showInteractions && showReplyAndQuote && ( - + {showIdentity && }
-
-
- - -

- - handleNavigation( - e, - `/${drop.author.handle ?? drop.author.primary_address}` - ) - } - href={`/${drop.author.handle ?? drop.author.primary_address}`} - className="tw-text-iron-200 tw-no-underline tw-transition tw-duration-300 tw-ease-out hover:tw-text-iron-500" - > - {drop.author.handle ?? drop.author.primary_address} - -

+ {showIdentity && + (identityMode === "minimal" ? ( + + ) : ( +
+
+ + +

+ + handleNavigation( + e, + `/${drop.author.handle ?? drop.author.primary_address}` + ) + } + href={`/${drop.author.handle ?? drop.author.primary_address}`} + className="tw-text-iron-200 tw-no-underline tw-transition tw-duration-300 tw-ease-out hover:tw-text-iron-500" + > + {drop.author.handle ?? drop.author.primary_address} + +

-
+
-

- {getTimeAgoShort(drop.created_at)} -

-
-
- Participant -
-
+

+ {getTimeAgoShort(drop.created_at)} +

+
+
+ Participant +
+
+ ))} - {showWaveInfo && + {identityMode === "default" && + showWaveInfo && (() => { const waveMeta = ( drop.wave as unknown as { @@ -207,7 +219,7 @@ export default function EndedParticipationDrop({
{identityProfile && ( -
+
)} -
- - -
+ {showInteractions && ( +
+ + +
+ )} - + {showInteractions && ( + + )}
); diff --git a/components/waves/drops/participation/OngoingParticipationDrop.tsx b/components/waves/drops/participation/OngoingParticipationDrop.tsx index bd872e3893..b7ed507d10 100644 --- a/components/waves/drops/participation/OngoingParticipationDrop.tsx +++ b/components/waves/drops/participation/OngoingParticipationDrop.tsx @@ -13,6 +13,7 @@ import useIsMobileScreen from "@/hooks/isMobileScreen"; import WaveDropActions from "../WaveDropActions"; import WaveDropMobileMenu from "../WaveDropMobileMenu"; import WaveDropAuthorPfp from "../WaveDropAuthorPfp"; +import DropMinimalIdentityRow from "../DropMinimalIdentityRow"; import ParticipationDropContainer from "./ParticipationDropContainer"; import ParticipationDropHeader from "./ParticipationDropHeader"; import ParticipationDropContent from "./ParticipationDropContent"; @@ -24,7 +25,11 @@ import { getParticipationIdentityProfile, getParticipationVisibleMetadata, } from "./participationIdentityProfile.helpers"; -import type { DropInteractionParams, DropLocation } from "../drop.types"; +import type { + DropIdentityMode, + DropInteractionParams, + DropLocation, +} from "../drop.types"; interface OngoingParticipationDropProps { readonly drop: ExtendedDrop; @@ -35,6 +40,8 @@ interface OngoingParticipationDropProps { readonly onReply: (param: DropInteractionParams) => void; readonly onQuoteClick: (drop: ApiDrop) => void; readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; + readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; } export default function OngoingParticipationDrop({ @@ -46,6 +53,8 @@ export default function OngoingParticipationDrop({ onReply, onQuoteClick, onDropContentClick, + identityMode = "default", + showInteractions = true, }: OngoingParticipationDropProps) { const isActiveDrop = activeDrop?.drop.id === drop.id; const { canShowVote } = useDropInteractionRules(drop); @@ -66,6 +75,7 @@ export default function OngoingParticipationDrop({ right: identityProfile, }) : false; + const showIdentity = identityMode !== "hidden"; const [activePartIndex, setActivePartIndex] = useState(0); const [longPressTriggered, setLongPressTriggered] = useState(false); @@ -73,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); @@ -87,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 && ( )}
- + {showIdentity && }
- + {showIdentity && + (identityMode === "minimal" ? ( + + ) : ( + + ))} {identityProfile && ( -
+
- -
- - {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 49bd24a073..1127ddc5f6 100644 --- a/components/waves/drops/participation/ParticipationDrop.tsx +++ b/components/waves/drops/participation/ParticipationDrop.tsx @@ -5,7 +5,11 @@ import React from "react"; import DefaultParticipationDrop from "./DefaultParticipationDrop"; import MemeParticipationDrop from "@/components/memes/drops/MemeParticipationDrop"; import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; -import type { DropInteractionParams, DropLocation } from "../drop.types"; +import type { + DropIdentityMode, + DropInteractionParams, + DropLocation, +} from "../drop.types"; interface ParticipationDropProps { readonly drop: ExtendedDrop; @@ -17,6 +21,8 @@ interface ParticipationDropProps { readonly onQuoteClick: (drop: ApiDrop) => void; 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 d528722ea8..ec32acb954 100644 --- a/components/waves/drops/winner/DefaultWinnerDrop.tsx +++ b/components/waves/drops/winner/DefaultWinnerDrop.tsx @@ -8,12 +8,13 @@ import useIsTouchDevice from "@/hooks/useIsTouchDevice"; import type { ActiveDropState } from "@/types/dropInteractionTypes"; import Link from "next/link"; import { memo, useCallback, useState } from "react"; -import type { DropInteractionParams } from "../drop.types"; +import type { DropIdentityMode, DropInteractionParams } from "../drop.types"; import { DropLocation } from "../drop.types"; import { getRankHoverBorderClass, getRankStaticBorderClass, } from "../dropRankStyles"; +import DropMinimalIdentityRow from "../DropMinimalIdentityRow"; import WaveDropActions from "../WaveDropActions"; import WaveDropAuthorPfp from "../WaveDropAuthorPfp"; import WaveDropContent from "../WaveDropContent"; @@ -54,6 +55,8 @@ interface DefautWinnerDropProps { readonly onReplyClick: (serialNo: number) => void; readonly onQuoteClick: (drop: ApiDrop) => void; readonly onDropContentClick?: ((drop: ExtendedDrop) => void) | undefined; + readonly identityMode?: DropIdentityMode | undefined; + readonly showInteractions?: boolean | undefined; } const DefaultWinnerDrop = ({ @@ -67,6 +70,8 @@ const DefaultWinnerDrop = ({ onQuoteClick, onDropContentClick, showReplyAndQuote, + identityMode = "default", + showInteractions = true, }: DefautWinnerDropProps) => { const [activePartIndex, setActivePartIndex] = useState(0); const [isSlideUp, setIsSlideUp] = useState(false); @@ -80,6 +85,7 @@ const DefaultWinnerDrop = ({ const effectiveRank = drop.winning_context?.place ?? drop.rank; const decisionTime = drop.winning_context?.decision_time; + const showIdentity = identityMode !== "hidden"; const visibleMetadata = getWinnerVisibleMetadata({ wave: drop.wave, @@ -93,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); @@ -136,23 +142,29 @@ const DefaultWinnerDrop = ({ )}
- + {showIdentity && }
- + ) : ( + + } /> - } - /> - {showWaveInfo && + ))} + {identityMode === "default" && + showWaveInfo && (() => { const waveDetails = drop.wave as unknown as { chat?: @@ -187,7 +199,7 @@ const DefaultWinnerDrop = ({ ); })()}
-
+
- {!isMobile && showReplyAndQuote && ( + {!isMobile && showInteractions && showReplyAndQuote && (
)} -
+
{visibleMetadata.length > 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 5f3ca6d62d..2a9bc54799 100644 --- a/components/waves/drops/winner/WinnerDrop.tsx +++ b/components/waves/drops/winner/WinnerDrop.tsx @@ -5,7 +5,11 @@ import type { ApiDrop } from "@/generated/models/ApiDrop"; import MemeWinnerDrop from "@/components/memes/drops/MemeWinnerDrop"; import DefaultWinnerDrop from "./DefaultWinnerDrop"; import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; -import type { DropInteractionParams, DropLocation } from "../drop.types"; +import type { + DropIdentityMode, + DropInteractionParams, + DropLocation, +} from "../drop.types"; interface WinnerDropProps { readonly drop: ExtendedDrop; @@ -21,6 +25,8 @@ interface WinnerDropProps { readonly onQuoteClick: (drop: ApiDrop) => void; 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/WaveHeaderOptions.tsx b/components/waves/header/options/WaveHeaderOptions.tsx index 5f30b18549..bad4513a42 100644 --- a/components/waves/header/options/WaveHeaderOptions.tsx +++ b/components/waves/header/options/WaveHeaderOptions.tsx @@ -1,11 +1,11 @@ "use client"; +import CommonDropdownItemsDefaultWrapper from "@/components/utils/select/dropdown/CommonDropdownItemsDefaultWrapper"; import type { ApiWave } from "@/generated/models/ApiWave"; -import { AnimatePresence, motion } from "framer-motion"; import { useRef, useState } from "react"; -import { useClickAway, useKeyPressEvent } from "react-use"; import WaveDelete from "./delete/WaveDelete"; import WaveMute from "./mute/WaveMute"; +import WaveProfileWaveAction from "./profile-wave/WaveProfileWaveAction"; export default function WaveHeaderOptions({ wave, @@ -13,50 +13,48 @@ export default function WaveHeaderOptions({ readonly wave: ApiWave; }) { const [isOptionsOpen, setIsOptionsOpen] = useState(false); - const listRef = useRef(null); + const buttonRef = useRef(null); - useClickAway(listRef, () => setIsOptionsOpen(false)); - useKeyPressEvent("Escape", () => setIsOptionsOpen(false)); return ( -
+
- - {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..b858cea3cf --- /dev/null +++ b/components/waves/header/options/profile-wave/WaveProfileWaveAction.tsx @@ -0,0 +1,87 @@ +"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 { areSameProfileIdentity } from "@/helpers/ProfileHelpers"; +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) && + !activeProfileProxy && + areSameProfileIdentity({ + left: connectedProfile + ? { + id: connectedProfile.id, + handle: connectedProfile.handle, + primary_address: connectedProfile.primary_wallet, + } + : null, + right: wave.author, + }) && + 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 wave" : "Saving 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/useCurationManagementPermission.ts b/hooks/useCurationManagementPermission.ts new file mode 100644 index 0000000000..dbbc0123ec --- /dev/null +++ b/hooks/useCurationManagementPermission.ts @@ -0,0 +1,21 @@ +"use client"; + +import { useDropCurations } from "@/hooks/drops/useDropCurations"; + +export function useCurationManagementPermission({ + curationId, + probeDropId, +}: { + readonly curationId: string; + readonly probeDropId: string; +}) { + const { data: probeCurations = [] } = useDropCurations({ + dropId: probeDropId, + enabled: Boolean(probeDropId), + }); + + return ( + probeCurations.find((curation) => curation.id === curationId) + ?.authenticated_user_can_curate ?? false + ); +} diff --git a/hooks/useProfileCurationViewMode.ts b/hooks/useProfileCurationViewMode.ts new file mode 100644 index 0000000000..f8f7543da1 --- /dev/null +++ b/hooks/useProfileCurationViewMode.ts @@ -0,0 +1,15 @@ +"use client"; + +import { useCallback, useState } from "react"; + +type ProfileCurationViewMode = "masonry" | "list"; + +export function useProfileCurationViewMode() { + const [viewMode, setViewMode] = useState("masonry"); + + const toggleViewMode = useCallback(() => { + setViewMode(viewMode === "masonry" ? "list" : "masonry"); + }, [setViewMode, viewMode]); + + return { viewMode, setViewMode, toggleViewMode }; +} 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/package.json b/package.json index 9e8522f91a..28c9cfad1f 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "jwt-decode": "4.0.0", "lexical": "0.14.5", "lodash": "4.17.23", + "masonic": "4.1.0", "mixpanel-browser": "2.76.0", "next": "16.2.1", "next-redux-wrapper": "8.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8018b3b3e5..0c574766a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -212,6 +212,9 @@ importers: lodash: specifier: 4.17.23 version: 4.17.23 + masonic: + specifier: 4.1.0 + version: 4.1.0(react@19.2.4) mixpanel-browser: specifier: 2.76.0 version: 2.76.0 @@ -1170,6 +1173,18 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@essentials/memoize-one@1.1.0': + resolution: {integrity: sha512-HMkuIkKNe0EWSUpZhlaq9+5Yp47YhrMhxLMnXTRnEyE5N4xKLspAvMGjUFdi794VnEF1EcOZFS8rdROeujrgag==} + + '@essentials/one-key-map@1.2.0': + resolution: {integrity: sha512-C2H7zHVcsoipDv4VKY5uUcv5ilsK+uEgEj+WeOdN5oz/Qj1/OZIzCdle90gDzj0xnGQrmZ9qDujwD7AkBb5k9A==} + + '@essentials/raf@1.2.0': + resolution: {integrity: sha512-AWJvpprE2o7ATMb7HBYMVUVmPJBCt2wZp2rY7d+rAcNSMvzLbDepy9KFeqqrPZh+s9aIpbw1LgmuAW7kuRFgrQ==} + + '@essentials/request-timeout@1.3.0': + resolution: {integrity: sha512-lKZPhKScNFnR1MBnk4+sxshk46fpvdN+Uh1LlKWFO5g1ocuz4EcknNIL7tm/rsCAs/+xMWiBTwbDUvm+pDNlXw==} + '@ethereumjs/common@3.2.0': resolution: {integrity: sha512-pksvzI0VyLgmuEF2FA/JR/4/y6hcPq8OUail3/AvycBaW1d5VSauOZzqGvJ3RTmR4MU35lWE8KseKOsEhrFRBA==} @@ -2687,6 +2702,41 @@ packages: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@react-hook/debounce@3.0.0': + resolution: {integrity: sha512-ir/kPrSfAzY12Gre0sOHkZ2rkEmM4fS5M5zFxCi4BnCeXh2nvx9Ujd+U4IGpKCuPA+EQD0pg1eK2NGLvfWejag==} + peerDependencies: + react: '>=16.8' + + '@react-hook/event@1.2.6': + resolution: {integrity: sha512-JUL5IluaOdn5w5Afpe/puPa1rj8X6udMlQ9dt4hvMuKmTrBS1Ya6sb4sVgvfe2eU4yDuOfAhik8xhbcCekbg9Q==} + peerDependencies: + react: '>=16.8' + + '@react-hook/latest@1.0.3': + resolution: {integrity: sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==} + peerDependencies: + react: '>=16.8' + + '@react-hook/passive-layout-effect@1.2.1': + resolution: {integrity: sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==} + peerDependencies: + react: '>=16.8' + + '@react-hook/throttle@2.2.0': + resolution: {integrity: sha512-LJ5eg+yMV8lXtqK3lR+OtOZ2WH/EfWvuiEEu0M3bhR7dZRfTyEJKxH1oK9uyBxiXPtWXiQggWbZirMCXam51tg==} + peerDependencies: + react: '>=16.8' + + '@react-hook/window-scroll@1.3.0': + resolution: {integrity: sha512-LdYnCL22pFI+LTs85Fi2OQHSKWkzIuHFgv8lA+wwuaPxLOEhWR5bzJ21iygUH9X4meeLVRZKEbfpYi3OWWD4GQ==} + peerDependencies: + react: '>=16.8' + + '@react-hook/window-size@3.1.1': + resolution: {integrity: sha512-yWnVS5LKnOUIrEsI44oz3bIIUYqflamPL27n+k/PC//PsX/YeWBky09oPeAoc9As6jSH16Wgo8plI+ECZaHk3g==} + peerDependencies: + react: '>=16.8' + '@react-stately/flags@3.1.2': resolution: {integrity: sha512-2HjFcZx1MyQXoPqcBGALwWWmgFVUk2TuKVIQxCbRq7fPyWXIl6VHcakCLurdtYC2Iks7zizvz0Idv48MQ38DWg==} @@ -6789,6 +6839,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + masonic@4.1.0: + resolution: {integrity: sha512-3RNbAG5qLve7qNtGp1UM/u7vI39jO73ZFHDBAg3xl8AVh7A6Ikx7I7mBeC0NY0h1r1jJn2Wqeol1QMa09MQbyQ==} + peerDependencies: + react: '>=16.8' + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -7737,6 +7792,9 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + react-bootstrap@2.10.10: resolution: {integrity: sha512-gMckKUqn8aK/vCnfwoBpBVFUGT9SVQxwsYrp9yDHt0arXMamxALerliKBxr1TPbntirK/HGrUAHYbAeQTa9GHQ==} peerDependencies: @@ -8561,6 +8619,9 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trie-memoize@1.2.0: + resolution: {integrity: sha512-hEDLVEP1FCgaRtt0oZDJdz2lK9uK7WlB7ASswt9U9cqruSNueVigtRGxI97hevKlViqhAcRgNgzuY/m8FCCMcg==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -9910,6 +9971,16 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@essentials/memoize-one@1.1.0': {} + + '@essentials/one-key-map@1.2.0': {} + + '@essentials/raf@1.2.0': {} + + '@essentials/request-timeout@1.3.0': + dependencies: + '@essentials/raf': 1.2.0 + '@ethereumjs/common@3.2.0': dependencies: '@ethereumjs/util': 8.1.0 @@ -11563,6 +11634,41 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + '@react-hook/debounce@3.0.0(react@19.2.4)': + dependencies: + '@react-hook/latest': 1.0.3(react@19.2.4) + react: 19.2.4 + + '@react-hook/event@1.2.6(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@react-hook/latest@1.0.3(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@react-hook/passive-layout-effect@1.2.1(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@react-hook/throttle@2.2.0(react@19.2.4)': + dependencies: + '@react-hook/latest': 1.0.3(react@19.2.4) + react: 19.2.4 + + '@react-hook/window-scroll@1.3.0(react@19.2.4)': + dependencies: + '@react-hook/event': 1.2.6(react@19.2.4) + '@react-hook/throttle': 2.2.0(react@19.2.4) + react: 19.2.4 + + '@react-hook/window-size@3.1.1(react@19.2.4)': + dependencies: + '@react-hook/debounce': 3.0.0(react@19.2.4) + '@react-hook/event': 1.2.6(react@19.2.4) + '@react-hook/throttle': 2.2.0(react@19.2.4) + react: 19.2.4 + '@react-stately/flags@3.1.2': dependencies: '@swc/helpers': 0.5.19 @@ -17620,6 +17726,21 @@ snapshots: markdown-table@3.0.4: {} + masonic@4.1.0(react@19.2.4): + dependencies: + '@essentials/memoize-one': 1.1.0 + '@essentials/one-key-map': 1.2.0 + '@essentials/request-timeout': 1.3.0 + '@react-hook/event': 1.2.6(react@19.2.4) + '@react-hook/latest': 1.0.3(react@19.2.4) + '@react-hook/passive-layout-effect': 1.2.1(react@19.2.4) + '@react-hook/throttle': 2.2.0(react@19.2.4) + '@react-hook/window-scroll': 1.3.0(react@19.2.4) + '@react-hook/window-size': 3.1.1(react@19.2.4) + raf-schd: 4.0.3 + react: 19.2.4 + trie-memoize: 1.2.0 + math-intrinsics@1.1.0: {} md5@2.3.0: @@ -18875,6 +18996,8 @@ snapshots: radix3@1.1.2: {} + raf-schd@4.0.3: {} + react-bootstrap@2.10.10(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@babel/runtime': 7.29.2 @@ -19873,6 +19996,8 @@ snapshots: tree-kill@1.2.2: {} + trie-memoize@1.2.0: {} + trim-lines@3.0.1: {} trough@2.2.0: {} 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); +};