diff --git a/__tests__/components/user/waves/UserPageProfileWaveMasonryOrder.helpers.test.ts b/__tests__/components/user/waves/UserPageProfileWaveMasonryOrder.helpers.test.ts new file mode 100644 index 0000000000..08973536d3 --- /dev/null +++ b/__tests__/components/user/waves/UserPageProfileWaveMasonryOrder.helpers.test.ts @@ -0,0 +1,40 @@ +import { + applyDropOrderIds, + getDropOrderUpdates, + getRollbackOrderIds, +} from "@/components/user/waves/userPageProfileWaveMasonryOrder.helpers"; + +const drop = (id: string) => ({ id }); + +describe("userPageProfileWaveMasonryOrder helpers", () => { + it("builds full absolute priority updates for every drop", () => { + expect(getDropOrderUpdates([drop("b"), drop("a"), drop("c")])).toEqual([ + { dropId: "b", priorityOrder: 1 }, + { dropId: "a", priorityOrder: 2 }, + { dropId: "c", priorityOrder: 3 }, + ]); + }); + + it("uses the persisted order for rollback when it covers the current drops", () => { + expect( + getRollbackOrderIds({ + currentDrops: [drop("a"), drop("b"), drop("c")], + persistedOrderIds: ["c", "a", "b"], + }) + ).toEqual(["c", "a", "b"]); + }); + + it("falls back to the pre-drag order when persisted order is empty", () => { + const currentDrops = [drop("a"), drop("b"), drop("c")]; + + expect( + getRollbackOrderIds({ + currentDrops, + persistedOrderIds: [], + }) + ).toEqual(["a", "b", "c"]); + expect(applyDropOrderIds(currentDrops, ["a", "b", "c"])).toEqual( + currentDrops + ); + }); +}); diff --git a/__tests__/components/user/waves/UserPageProfileWaveReorder.helpers.test.ts b/__tests__/components/user/waves/UserPageProfileWaveReorder.helpers.test.ts new file mode 100644 index 0000000000..b93de66aac --- /dev/null +++ b/__tests__/components/user/waves/UserPageProfileWaveReorder.helpers.test.ts @@ -0,0 +1,34 @@ +import { getProfileWaveReorderGate } from "@/components/user/waves/userPageProfileWaveReorder.helpers"; + +type GateInput = Parameters[0]; + +const baseGate: GateInput = { + canClear: true, + canManageCuration: true, + dropCount: 2, + hasProfileCuration: true, + isDropsFetching: false, + isPermissionLoading: false, +}; + +const closed = { canReorder: false, isLoading: false, shouldShowButton: false }; +const loading = { canReorder: false, isLoading: true, shouldShowButton: true }; + +describe("userPageProfileWaveReorder helpers", () => { + it.each([ + [{}, { canReorder: true, isLoading: false, shouldShowButton: true }], + [{ canManageCuration: false }, closed], + [{ dropCount: 1 }, closed], + [ + { canManageCuration: false, dropCount: 0, isDropsFetching: true }, + loading, + ], + [{ canManageCuration: false, isPermissionLoading: true }, loading], + ] satisfies Array< + [Partial, ReturnType] + >)("returns the expected reorder gate for %o", (overrides, expected) => { + expect(getProfileWaveReorderGate({ ...baseGate, ...overrides })).toEqual( + expected + ); + }); +}); diff --git a/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx b/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx index 249f7fe7c9..723355e8ac 100644 --- a/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx +++ b/components/brain/my-stream/curations/MyStreamWaveCurationContent.tsx @@ -12,7 +12,7 @@ import { useCurationManagementPermission } from "@/hooks/useCurationManagementPe import { useDropCurationMembershipMutation } from "@/hooks/drops/useDropCurationMembershipMutation"; import useDeviceInfo from "@/hooks/useDeviceInfo"; import useIsTouchDevice from "@/hooks/useIsTouchDevice"; -import { useWaveDrops } from "@/hooks/useWaveDrops"; +import { useWaveCurationDrops } from "@/hooks/useWaveCurationDrops"; import type { ApiWave } from "@/generated/models/ApiWave"; import { XMarkIcon } from "@heroicons/react/24/outline"; import { useCallback, useMemo, type ReactNode } from "react"; @@ -136,15 +136,17 @@ export default function MyStreamWaveCurationContent({ }: MyStreamWaveCurationContentProps) { const { leaderboardViewStyle } = useLayout(); const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = - useWaveDrops({ - waveId: wave.id, + useWaveCurationDrops({ + wave, curationId, }); const permissionProbeDropId = drops[0]?.id ?? ""; - const canManageActiveCuration = useCurationManagementPermission({ - curationId, - probeDropId: permissionProbeDropId, - }); + const { canManageCuration: canManageActiveCuration } = + useCurationManagementPermission({ + curationId, + probeDropId: permissionProbeDropId, + enabled: drops.length > 0, + }); const isInitialLoading = isFetching && drops.length === 0; @@ -154,7 +156,7 @@ export default function MyStreamWaveCurationContent({ return; } - void fetchNextPage(); + fetchNextPage().catch(() => undefined); }, [fetchNextPage, hasNextPage, isFetchingNextPage] ); diff --git a/components/user/waves/UserPageProfileWave.tsx b/components/user/waves/UserPageProfileWave.tsx index 0eb04850d2..77ebc42b76 100644 --- a/components/user/waves/UserPageProfileWave.tsx +++ b/components/user/waves/UserPageProfileWave.tsx @@ -4,18 +4,24 @@ 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 MoveIcon from "@/components/utils/icons/MoveIcon"; +import CommonConfirmationModal from "@/components/utils/modal/CommonConfirmationModal"; import UserPageProfileWaveMasonry from "@/components/user/waves/UserPageProfileWaveMasonry"; -import { useProfileWaveMutation } from "@/hooks/useProfileWaveMutation"; -import { useWaveById } from "@/hooks/useWaveById"; -import { useWaveCurations } from "@/hooks/waves/useWaveCurations"; -import { useIdentity } from "@/hooks/useIdentity"; +import { getProfileWaveReorderGate } from "@/components/user/waves/userPageProfileWaveReorder.helpers"; 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 { useIdentity } from "@/hooks/useIdentity"; +import { useProfileWaveMutation } from "@/hooks/useProfileWaveMutation"; +import { useCurationManagementPermission } from "@/hooks/useCurationManagementPermission"; +import { useWaveById } from "@/hooks/useWaveById"; +import { useWaveCurationDrops } from "@/hooks/useWaveCurationDrops"; +import { useWaveCurations } from "@/hooks/waves/useWaveCurations"; import { ArrowTopRightOnSquareIcon, + CheckIcon, XMarkIcon, } from "@heroicons/react/24/outline"; import { @@ -24,7 +30,7 @@ import { useRouter, useSearchParams, } from "next/navigation"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; type ApiErrorLike = { readonly status?: number | undefined; @@ -65,11 +71,11 @@ const isUnavailableWaveError = (error: unknown): boolean => { function UnavailableState({ canClear, - onClear, + onRequestClear, isPending, }: { readonly canClear: boolean; - readonly onClear: () => Promise; + readonly onRequestClear: () => void; readonly isPending: boolean; }) { return ( @@ -87,7 +93,7 @@ function UnavailableState({ {canClear && ( + )} + {!isReorderModeActive && ( + + )} + {!isReorderModeActive && canClear && ( + + )} + + + +
+
+ +
+
+ + + {canClear && ( + { + confirmClearProfileWave().catch(() => undefined); + }} + title="Clear official wave" + message="Are you sure you want to clear the official wave from this profile?" + confirmText="Clear official wave" + isConfirming={isClearPending} + /> + )} + + ); +} + export default function UserPageProfileWave({ profile: initialProfile, }: { @@ -258,6 +492,8 @@ export default function UserPageProfileWave({ handleOrWallet, initialProfile, }); + const [isUnavailableClearConfirmOpen, setIsUnavailableClearConfirmOpen] = + useState(false); const resolvedProfile = profile ?? initialProfile; const profileWaveId = resolvedProfile.profile_wave_id; const { wave, isLoading, isError, error, refetch, isFetching } = @@ -339,15 +575,38 @@ export default function UserPageProfileWave({ 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]); + + function openUnavailableClearConfirm() { + setIsUnavailableClearConfirmOpen(true); + } + + function closeUnavailableClearConfirm() { + if (isPending && pendingAction === "clear") { + return; + } + + setIsUnavailableClearConfirmOpen(false); + } + + async function confirmUnavailableClearProfileWave() { + try { + const updatedProfile = await clearSelectedProfileWave(); + + if (updatedProfile) { + setIsUnavailableClearConfirmOpen(false); + } + } catch { + // Keep the confirmation open; the mutation hook owns the error toast. + } + } if (!profileWaveId) { return null; @@ -369,11 +628,26 @@ export default function UserPageProfileWave({ if (hasUnavailableWaveError) { return ( - + <> + + {canClear && ( + { + confirmUnavailableClearProfileWave().catch(() => undefined); + }} + title="Clear official wave" + message="Are you sure you want to clear the official wave from this profile?" + confirmText="Clear official wave" + isConfirming={isPending && pendingAction === "clear"} + /> + )} + ); } @@ -389,61 +663,22 @@ export default function UserPageProfileWave({ } return ( -
-
-
-
-

- {profileCurationTitle} -

-
- -
- - {canClear && ( - - )} -
-
- -
-
- -
-
-
-
+ ); } diff --git a/components/user/waves/UserPageProfileWaveMasonry.tsx b/components/user/waves/UserPageProfileWaveMasonry.tsx index 8527efafa1..2430df50d1 100644 --- a/components/user/waves/UserPageProfileWaveMasonry.tsx +++ b/components/user/waves/UserPageProfileWaveMasonry.tsx @@ -1,117 +1,96 @@ "use client"; +import { useAuth } from "@/components/auth/Auth"; 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 ProfileIdentitySummary, + SortableUserPageProfileWaveMasonryCard, + UserPageProfileWaveMasonryCard, +} from "@/components/user/waves/UserPageProfileWaveMasonryCard"; +import { + applyDropOrderIds, + areDropOrdersEqual, + getDropOrderIds, + getDropOrderUpdates, + getRollbackOrderIds, +} from "@/components/user/waves/userPageProfileWaveMasonryOrder.helpers"; 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"; +import { useDropCurationOrderMutation } from "@/hooks/drops/useDropCurationOrderMutation"; +import { + fetchAllWaveCurationDrops, + useWaveCurationDrops, +} from "@/hooks/useWaveCurationDrops"; +import { + DndContext, + KeyboardSensor, + PointerSensor, + closestCenter, + type DragEndEvent, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + rectSortingStrategy, + sortableKeyboardCoordinates, +} from "@dnd-kit/sortable"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; interface UserPageProfileWaveMasonryProps { readonly wave: ApiWave; readonly curationId: string; readonly curationName?: string | null | undefined; + readonly canManageProfileWave?: boolean | undefined; + readonly canReorderDrops?: boolean | undefined; readonly showIdentity?: boolean | undefined; readonly profileIdentity?: ProfileIdentitySummary | undefined; + readonly isReorderMode?: boolean | undefined; + readonly onReorderModeChange?: + | ((nextIsReorderMode: boolean) => void) + | undefined; } const MASONRY_COLUMN_WIDTH = 300; const MASONRY_GUTTER = 16; +const REORDER_PAGE_SIZE = 100; + +const buildProfileMasonryColumns = ( + items: readonly T[], + columnCount: number +): T[][] => { + const normalizedColumnCount = Math.max(1, columnCount); + const columns = Array.from( + { length: normalizedColumnCount }, + () => [] as T[] + ); -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; -}; + items.forEach((item, index) => { + columns[index % normalizedColumnCount]!.push(item); + }); -type ProfileMasonryItem = { - readonly drop: ExtendedDrop; - readonly curationId: string; - readonly canManageActiveCuration: boolean; - readonly showIdentity: boolean; - readonly profileIdentity: ProfileIdentitySummary | undefined; + return columns; }; -const getProfileMasonryCardLayout = ({ - activePart, - drop, - profileIdentity, - replyTo, - showIdentity, +const getProfileMasonryColumnKey = ({ + column, + prefix, }: { - 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, - }; -}; + readonly column: readonly ExtendedDrop[]; + readonly prefix: string; +}) => `${prefix}-${column.map((drop) => drop.id).join("|")}`; function useProfileMasonryContainerWidth() { const [container, setContainer] = useState(null); @@ -157,228 +136,78 @@ function useProfileMasonryContainerWidth() { 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, + canManageProfileWave = false, + canReorderDrops, showIdentity = false, profileIdentity, + isReorderMode = false, + onReorderModeChange, }: UserPageProfileWaveMasonryProps) { + const { setToast } = useAuth(); const { drops, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } = - useWaveDrops({ - waveId: wave.id, + useWaveCurationDrops({ + wave, curationId, }); - const permissionProbeDropId = drops[0]?.id ?? ""; - const canManageActiveCuration = useCurationManagementPermission({ - curationId, - probeDropId: permissionProbeDropId, - }); + const { persistOrderAsync, isPending: isSavingOrder } = + useDropCurationOrderMutation({ + curationId, + }); + const canManageActiveCuration = canManageProfileWave; const isInitialLoading = isFetching && drops.length === 0; const curationTitle = curationName?.trim() ?? "Curation"; const { containerRef, containerWidth } = useProfileMasonryContainerWidth(); - const masonryItems = useMemo( + const [isPreparingReorder, setIsPreparingReorder] = useState(false); + const [reorderDrops, setReorderDrops] = useState([]); + const reorderDropsRef = useRef([]); + const persistedOrderIdsRef = useRef([]); + const updateReorderDrops = useCallback((nextDrops: ExtendedDrop[]) => { + reorderDropsRef.current = nextDrops; + setReorderDrops(nextDrops); + }, []); + const canEnterReorder = + canReorderDrops ?? (canManageActiveCuration && drops.length > 1); + const isReorderContentLoading = + isReorderMode && (isPreparingReorder || reorderDrops.length === 0); + const columnCount = Math.max( + 1, + Math.floor( + (containerWidth + MASONRY_GUTTER) / + (MASONRY_COLUMN_WIDTH + MASONRY_GUTTER) + ) + ); + const browseColumns = useMemo( () => - drops.map((drop) => ({ - drop, - curationId, - canManageActiveCuration, - showIdentity, - profileIdentity, - })), - [canManageActiveCuration, curationId, drops, profileIdentity, showIdentity] + buildProfileMasonryColumns(drops, columnCount).filter( + (column) => column.length > 0 + ), + [columnCount, drops] ); - const masonryTopItemsKey = useMemo( + const reorderColumns = useMemo( () => - drops - .slice(0, 8) - .map((drop) => drop.stableKey) - .join("|"), - [drops] + buildProfileMasonryColumns(reorderDrops, columnCount).filter( + (column) => column.length > 0 + ), + [columnCount, reorderDrops] + ); + const reorderIndicesById = useMemo( + () => new Map(reorderDrops.map((drop, index) => [drop.id, index])), + [reorderDrops] + ); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) ); - const masonryKey = `${curationId}-${containerWidth}-${masonryTopItemsKey}`; const handleBottomIntersection = useCallback( (isIntersecting: boolean) => { @@ -391,6 +220,133 @@ export default function UserPageProfileWaveMasonry({ [fetchNextPage, hasNextPage, isFetchingNextPage] ); + const handleDragEnd = useCallback( + async ({ active, over }: DragEndEvent) => { + if ( + !over || + active.id === over.id || + isPreparingReorder || + isSavingOrder + ) { + return; + } + + const currentDrops = reorderDropsRef.current; + const fromIndex = currentDrops.findIndex((drop) => drop.id === active.id); + const toIndex = currentDrops.findIndex((drop) => drop.id === over.id); + + if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) { + return; + } + + const nextDrops = arrayMove(currentDrops, fromIndex, toIndex); + updateReorderDrops(nextDrops); + + const nextOrderIds = getDropOrderIds(nextDrops); + const persistedOrderIds = persistedOrderIdsRef.current; + const rollbackOrderIds = getRollbackOrderIds({ + currentDrops, + persistedOrderIds, + }); + + if (areDropOrdersEqual(nextOrderIds, persistedOrderIds)) { + return; + } + + const updates = getDropOrderUpdates(nextDrops); + + if (updates.length === 0) { + persistedOrderIdsRef.current = nextOrderIds; + return; + } + + try { + await persistOrderAsync(updates); + persistedOrderIdsRef.current = nextOrderIds; + } catch { + try { + const authoritativeDrops = await fetchAllWaveCurationDrops({ + wave, + curationId, + pageSize: REORDER_PAGE_SIZE, + }); + persistedOrderIdsRef.current = getDropOrderIds(authoritativeDrops); + updateReorderDrops(authoritativeDrops); + } catch { + updateReorderDrops(applyDropOrderIds(currentDrops, rollbackOrderIds)); + } + } + }, + [ + curationId, + isPreparingReorder, + isSavingOrder, + persistOrderAsync, + updateReorderDrops, + wave, + ] + ); + + useEffect(() => { + if (!isReorderMode || !canEnterReorder) { + return; + } + + let isCancelled = false; + + const prepareReorder = async () => { + setIsPreparingReorder(true); + + try { + const allDrops = await fetchAllWaveCurationDrops({ + wave, + curationId, + pageSize: REORDER_PAGE_SIZE, + }); + + if (isCancelled) { + return; + } + + persistedOrderIdsRef.current = getDropOrderIds(allDrops); + updateReorderDrops(allDrops); + } catch (error) { + if (isCancelled) { + return; + } + + const message = + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Unable to load curation reorder mode."; + + setToast({ + type: "error", + message, + }); + onReorderModeChange?.(false); + } finally { + if (!isCancelled) { + setIsPreparingReorder(false); + } + } + }; + + prepareReorder().catch(() => undefined); + + return () => { + isCancelled = true; + }; + }, [ + canEnterReorder, + curationId, + isReorderMode, + onReorderModeChange, + setToast, + updateReorderDrops, + wave, + ]); + if (isInitialLoading) { return (
@@ -403,29 +359,99 @@ export default function UserPageProfileWaveMasonry({ return ; } + let masonryContent: ReactNode; + + if (containerWidth <= 0) { + masonryContent = ( +
+ +
+ ); + } else if (isReorderMode) { + masonryContent = isReorderContentLoading ? ( +
+ +
+ ) : ( + { + handleDragEnd(event).catch(() => undefined); + }} + > + drop.id)} + strategy={rectSortingStrategy} + > +
+ {reorderColumns.map((column) => ( +
+ {column.map((drop) => ( + + ))} +
+ ))} +
+
+
+ ); + } else { + masonryContent = ( +
+ {browseColumns.map((column) => ( +
+ {column.map((drop) => ( + + ))} +
+ ))} +
+ ); + } + return (
- {containerWidth > 0 ? ( - item.drop.stableKey} - itemHeightEstimate={420} - columnWidth={MASONRY_COLUMN_WIDTH} - columnGutter={MASONRY_GUTTER} - rowGutter={MASONRY_GUTTER} - overscanBy={2} - ssrWidth={containerWidth} - ssrHeight={900} - /> - ) : ( -
- -
- )} + {masonryContent} - {(hasNextPage || isFetchingNextPage) && ( + {!isReorderMode && (hasNextPage || isFetchingNextPage) && (
{isFetchingNextPage ? ( diff --git a/components/user/waves/UserPageProfileWaveMasonryCard.tsx b/components/user/waves/UserPageProfileWaveMasonryCard.tsx new file mode 100644 index 0000000000..641c615971 --- /dev/null +++ b/components/user/waves/UserPageProfileWaveMasonryCard.tsx @@ -0,0 +1,343 @@ +"use client"; + +import { Spinner } from "@/components/dotLoader/DotLoader"; +import MoveIcon from "@/components/utils/icons/MoveIcon"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import { ApiDropType } from "@/generated/models/ApiDropType"; +import { ImageScale } from "@/helpers/image.helpers"; +import { areSameProfileIdentity } from "@/helpers/ProfileHelpers"; +import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; +import { useDropCurationMembershipMutation } from "@/hooks/drops/useDropCurationMembershipMutation"; +import useDeviceInfo from "@/hooks/useDeviceInfo"; +import useIsTouchDevice from "@/hooks/useIsTouchDevice"; +import Drop, { DropLocation } from "@/components/waves/drops/Drop"; +import DropMinimalIdentityRow from "@/components/waves/drops/DropMinimalIdentityRow"; +import WaveDropAuthorPfp from "@/components/waves/drops/WaveDropAuthorPfp"; +import WaveDropContent from "@/components/waves/drops/WaveDropContent"; +import WaveDropMetadata from "@/components/waves/drops/WaveDropMetadata"; +import WaveDropReply from "@/components/waves/drops/WaveDropReply"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { type CSSProperties, type ReactNode, useState } from "react"; + +export 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; +}; + +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 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 ( +
+ +
+ ); +} + +export function UserPageProfileWaveMasonryCard({ + drop, + curationId, + canManageActiveCuration, + showIdentity, + profileIdentity, + isReorderMode, + isDragging = false, + reorderHandle = null, +}: { + readonly drop: ExtendedDrop; + readonly curationId: string; + readonly canManageActiveCuration: boolean; + readonly showIdentity: boolean; + readonly profileIdentity: ProfileIdentitySummary | undefined; + readonly isReorderMode: boolean; + readonly isDragging?: boolean | undefined; + readonly reorderHandle?: ReactNode; +}) { + 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 && !isReorderMode ? ( + + ) : null; + const dropContent = ( + {}} + onLongPress={() => {}} + setLongPressTriggered={(_triggered: boolean) => {}} + onDropContentClick={undefined} + mediaImageScale={ImageScale.AUTOx1080} + fullWidthMedia={true} + /> + ); + + if (layout.usesDefaultDropRenderer) { + return ( +
+ {removeButton} + {reorderHandle} + +
+ {}} + onReplyClick={() => {}} + onQuoteClick={() => {}} + identityMode={layout.identityMode} + showInteractions={false} + /> +
+
+ ); + } + + return ( +
+ {removeButton} + {reorderHandle} + +
+
+ {layout.shouldUseInlineMinimalLayout ? ( +
+ +
+ +
{dropContent}
+ {drop.metadata.length > 0 && ( + + )} +
+
+ ) : ( + <> + {layout.showMinimalIdentityRow && ( +
+ +
+ +
+
+ )} + +
+ {replyTo && ( +
+ {}} + /> +
+ )} + + {dropContent} +
+ + {drop.metadata.length > 0 && ( +
+ +
+ )} + + )} +
+
+
+ ); +} + +export function SortableUserPageProfileWaveMasonryCard({ + drop, + index, + curationId, + canManageActiveCuration, + showIdentity, + profileIdentity, + isSavingOrder, +}: { + readonly drop: ExtendedDrop; + readonly index: number; + readonly curationId: string; + readonly canManageActiveCuration: boolean; + readonly showIdentity: boolean; + readonly profileIdentity: ProfileIdentitySummary | undefined; + readonly isSavingOrder: boolean; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: drop.id, + disabled: isSavingOrder, + }); + const style: CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; + const reorderHandle = ( +
+ +
+ ); + + return ( +
+ +
+ ); +} diff --git a/components/user/waves/userPageProfileWaveMasonryOrder.helpers.ts b/components/user/waves/userPageProfileWaveMasonryOrder.helpers.ts new file mode 100644 index 0000000000..0c05dd09c1 --- /dev/null +++ b/components/user/waves/userPageProfileWaveMasonryOrder.helpers.ts @@ -0,0 +1,62 @@ +interface DropOrderItem { + readonly id: string; +} + +export interface DropOrderUpdate { + readonly dropId: string; + readonly priorityOrder: number; +} + +export const getDropOrderIds = (drops: readonly DropOrderItem[]): string[] => + drops.map((drop) => drop.id); + +export const areDropOrdersEqual = ( + left: readonly string[], + right: readonly string[] +): boolean => + left.length === right.length && + left.every((id, index) => id === right[index]); + +export const applyDropOrderIds = ( + drops: readonly T[], + orderIds: readonly string[] +): T[] => { + const dropsById = new Map(drops.map((drop) => [drop.id, drop])); + return orderIds + .map((id) => dropsById.get(id) ?? null) + .filter((drop): drop is T => drop !== null); +}; + +export const getDropOrderUpdates = ( + drops: readonly DropOrderItem[] +): DropOrderUpdate[] => + drops.map((drop, index) => ({ + dropId: drop.id, + priorityOrder: index + 1, + })); + +const hasSameDropIds = ( + left: readonly string[], + right: readonly string[] +): boolean => { + if (left.length === 0 || left.length !== right.length) { + return false; + } + + const rightIds = new Set(right); + return left.every((id) => rightIds.has(id)); +}; + +export const getRollbackOrderIds = ({ + currentDrops, + persistedOrderIds, +}: { + readonly currentDrops: readonly DropOrderItem[]; + readonly persistedOrderIds: readonly string[]; +}): string[] => { + const currentOrderIds = getDropOrderIds(currentDrops); + + return hasSameDropIds(currentOrderIds, persistedOrderIds) + ? [...persistedOrderIds] + : currentOrderIds; +}; diff --git a/components/user/waves/userPageProfileWaveReorder.helpers.ts b/components/user/waves/userPageProfileWaveReorder.helpers.ts new file mode 100644 index 0000000000..355d5ee5c3 --- /dev/null +++ b/components/user/waves/userPageProfileWaveReorder.helpers.ts @@ -0,0 +1,29 @@ +export const getProfileWaveReorderGate = ({ + canClear, + canManageCuration, + dropCount, + hasProfileCuration, + isDropsFetching, + isPermissionLoading, +}: { + readonly canClear: boolean; + readonly canManageCuration: boolean; + readonly dropCount: number; + readonly hasProfileCuration: boolean; + readonly isDropsFetching: boolean; + readonly isPermissionLoading: boolean; +}) => { + const canLoadReorderGate = canClear && hasProfileCuration; + const isLoadingDrops = + canLoadReorderGate && dropCount === 0 && isDropsFetching; + const isLoadingPermission = + canLoadReorderGate && dropCount > 1 && isPermissionLoading; + const isLoading = isLoadingDrops || isLoadingPermission; + const canReorder = canClear && canManageCuration && dropCount > 1; + + return { + canReorder, + isLoading, + shouldShowButton: canReorder || isLoading, + }; +}; diff --git a/components/utils/icons/MoveIcon.tsx b/components/utils/icons/MoveIcon.tsx new file mode 100644 index 0000000000..fe91ca4283 --- /dev/null +++ b/components/utils/icons/MoveIcon.tsx @@ -0,0 +1,60 @@ +import type { JSX } from "react"; + +interface MoveIconProps { + readonly className?: string | undefined; +} + +export default function MoveIcon({ className }: MoveIconProps): JSX.Element { + return ( + + ); +} diff --git a/generated/models/ApiCurationDrop.ts b/generated/models/ApiCurationDrop.ts new file mode 100644 index 0000000000..b2449bc6a6 --- /dev/null +++ b/generated/models/ApiCurationDrop.ts @@ -0,0 +1,268 @@ +// @ts-nocheck +/** + * 6529.io API + * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { ApiDropContextProfileContext } from '../models/ApiDropContextProfileContext'; +import { ApiDropGroupMention } from '../models/ApiDropGroupMention'; +import { ApiDropMentionedUser } from '../models/ApiDropMentionedUser'; +import { ApiDropMetadataResponse } from '../models/ApiDropMetadataResponse'; +import { ApiDropNftLink } from '../models/ApiDropNftLink'; +import { ApiDropPart } from '../models/ApiDropPart'; +import { ApiDropRater } from '../models/ApiDropRater'; +import { ApiDropReaction } from '../models/ApiDropReaction'; +import { ApiDropReferencedNFT } from '../models/ApiDropReferencedNFT'; +import { ApiDropSubscriptionTargetAction } from '../models/ApiDropSubscriptionTargetAction'; +import { ApiDropType } from '../models/ApiDropType'; +import { ApiDropWinningContext } from '../models/ApiDropWinningContext'; +import { ApiMentionedWave } from '../models/ApiMentionedWave'; +import { ApiProfileMin } from '../models/ApiProfileMin'; +import { ApiReplyToDropResponse } from '../models/ApiReplyToDropResponse'; + +export class ApiCurationDrop { + 'drop_priority_order': number | null; + 'id': string; + /** + * Sequence number of the drop in Seize + */ + 'serial_no': number; + 'drop_type': ApiDropType; + 'rank': number | null; + 'winning_context'?: ApiDropWinningContext; + 'reply_to'?: ApiReplyToDropResponse; + 'author': ApiProfileMin; + /** + * Time when the drop was created in milliseconds since 1-1-1970 00:00:00.0 UTC + */ + 'created_at': number; + /** + * Time when the drop was updated in milliseconds since 1-1-1970 00:00:00.0 UTC + */ + 'updated_at': number | null; + 'title': string | null; + 'parts': Array; + /** + * Number of drops in the storm + */ + 'parts_count': number; + 'referenced_nfts': Array; + 'mentioned_users': Array; + 'mentioned_groups': Array; + 'mentioned_waves': Array; + 'metadata': Array; + 'rating': number; + 'realtime_rating': number; + 'rating_prediction': number; + 'top_raters': Array; + 'raters_count': number; + 'context_profile_context': ApiDropContextProfileContext; + 'subscribed_actions': Array; + 'is_signed': boolean; + 'reactions': Array; + 'boosts': number; + 'hide_link_preview': boolean; + 'nft_links'?: Array; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "drop_priority_order", + "baseName": "drop_priority_order", + "type": "number", + "format": "int64" + }, + { + "name": "id", + "baseName": "id", + "type": "string", + "format": "" + }, + { + "name": "serial_no", + "baseName": "serial_no", + "type": "number", + "format": "int64" + }, + { + "name": "drop_type", + "baseName": "drop_type", + "type": "ApiDropType", + "format": "" + }, + { + "name": "rank", + "baseName": "rank", + "type": "number", + "format": "int64" + }, + { + "name": "winning_context", + "baseName": "winning_context", + "type": "ApiDropWinningContext", + "format": "" + }, + { + "name": "reply_to", + "baseName": "reply_to", + "type": "ApiReplyToDropResponse", + "format": "" + }, + { + "name": "author", + "baseName": "author", + "type": "ApiProfileMin", + "format": "" + }, + { + "name": "created_at", + "baseName": "created_at", + "type": "number", + "format": "int64" + }, + { + "name": "updated_at", + "baseName": "updated_at", + "type": "number", + "format": "int64" + }, + { + "name": "title", + "baseName": "title", + "type": "string", + "format": "" + }, + { + "name": "parts", + "baseName": "parts", + "type": "Array", + "format": "" + }, + { + "name": "parts_count", + "baseName": "parts_count", + "type": "number", + "format": "int64" + }, + { + "name": "referenced_nfts", + "baseName": "referenced_nfts", + "type": "Array", + "format": "" + }, + { + "name": "mentioned_users", + "baseName": "mentioned_users", + "type": "Array", + "format": "" + }, + { + "name": "mentioned_groups", + "baseName": "mentioned_groups", + "type": "Array", + "format": "" + }, + { + "name": "mentioned_waves", + "baseName": "mentioned_waves", + "type": "Array", + "format": "" + }, + { + "name": "metadata", + "baseName": "metadata", + "type": "Array", + "format": "" + }, + { + "name": "rating", + "baseName": "rating", + "type": "number", + "format": "int64" + }, + { + "name": "realtime_rating", + "baseName": "realtime_rating", + "type": "number", + "format": "int64" + }, + { + "name": "rating_prediction", + "baseName": "rating_prediction", + "type": "number", + "format": "int64" + }, + { + "name": "top_raters", + "baseName": "top_raters", + "type": "Array", + "format": "" + }, + { + "name": "raters_count", + "baseName": "raters_count", + "type": "number", + "format": "int64" + }, + { + "name": "context_profile_context", + "baseName": "context_profile_context", + "type": "ApiDropContextProfileContext", + "format": "" + }, + { + "name": "subscribed_actions", + "baseName": "subscribed_actions", + "type": "Array", + "format": "" + }, + { + "name": "is_signed", + "baseName": "is_signed", + "type": "boolean", + "format": "" + }, + { + "name": "reactions", + "baseName": "reactions", + "type": "Array", + "format": "" + }, + { + "name": "boosts", + "baseName": "boosts", + "type": "number", + "format": "int64" + }, + { + "name": "hide_link_preview", + "baseName": "hide_link_preview", + "type": "boolean", + "format": "" + }, + { + "name": "nft_links", + "baseName": "nft_links", + "type": "Array", + "format": "" + } ]; + + static getAttributeTypeMap() { + return ApiCurationDrop.attributeTypeMap; + } + + public constructor() { + } +} + + diff --git a/generated/models/ApiCurationDropsPage.ts b/generated/models/ApiCurationDropsPage.ts new file mode 100644 index 0000000000..5c378de74c --- /dev/null +++ b/generated/models/ApiCurationDropsPage.ts @@ -0,0 +1,51 @@ +// @ts-nocheck +/** + * 6529.io API + * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { ApiCurationDrop } from '../models/ApiCurationDrop'; + +export class ApiCurationDropsPage { + 'data': Array; + 'page': number; + 'next': boolean; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "data", + "baseName": "data", + "type": "Array", + "format": "" + }, + { + "name": "page", + "baseName": "page", + "type": "number", + "format": "int64" + }, + { + "name": "next", + "baseName": "next", + "type": "boolean", + "format": "" + } ]; + + static getAttributeTypeMap() { + return ApiCurationDropsPage.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/generated/models/ApiDropCuration.ts b/generated/models/ApiDropCuration.ts index 1a8358be36..6bb9ee5d24 100644 --- a/generated/models/ApiDropCuration.ts +++ b/generated/models/ApiDropCuration.ts @@ -16,12 +16,14 @@ import { HttpFile } from '../http/http'; export class ApiDropCuration { 'drop_included': boolean; 'authenticated_user_can_curate': boolean; + 'drop_priority_order': number | null; 'id': string; 'name': string; 'wave_id': string; 'group_id': string; 'created_at': number; 'updated_at': number; + 'priority_order': number; static readonly discriminator: string | undefined = undefined; @@ -40,6 +42,12 @@ export class ApiDropCuration { "type": "boolean", "format": "" }, + { + "name": "drop_priority_order", + "baseName": "drop_priority_order", + "type": "number", + "format": "int64" + }, { "name": "id", "baseName": "id", @@ -75,6 +83,12 @@ export class ApiDropCuration { "baseName": "updated_at", "type": "number", "format": "int64" + }, + { + "name": "priority_order", + "baseName": "priority_order", + "type": "number", + "format": "int32" } ]; static getAttributeTypeMap() { diff --git a/generated/models/ApiDropCurationRequest.ts b/generated/models/ApiDropCurationRequest.ts index 14be16766d..50b69d9448 100644 --- a/generated/models/ApiDropCurationRequest.ts +++ b/generated/models/ApiDropCurationRequest.ts @@ -15,6 +15,7 @@ import { HttpFile } from '../http/http'; export class ApiDropCurationRequest { 'curation_id': string; + 'priority_order'?: number; static readonly discriminator: string | undefined = undefined; @@ -26,6 +27,12 @@ export class ApiDropCurationRequest { "baseName": "curation_id", "type": "string", "format": "" + }, + { + "name": "priority_order", + "baseName": "priority_order", + "type": "number", + "format": "int64" } ]; static getAttributeTypeMap() { diff --git a/generated/models/ApiWaveCuration.ts b/generated/models/ApiWaveCuration.ts index 86dc0b8365..34ff460b36 100644 --- a/generated/models/ApiWaveCuration.ts +++ b/generated/models/ApiWaveCuration.ts @@ -20,6 +20,7 @@ export class ApiWaveCuration { 'group_id': string; 'created_at': number; 'updated_at': number; + 'priority_order': number; static readonly discriminator: string | undefined = undefined; @@ -61,6 +62,12 @@ export class ApiWaveCuration { "baseName": "updated_at", "type": "number", "format": "int64" + }, + { + "name": "priority_order", + "baseName": "priority_order", + "type": "number", + "format": "int32" } ]; static getAttributeTypeMap() { diff --git a/generated/models/ApiWaveCurationRequest.ts b/generated/models/ApiWaveCurationRequest.ts index fe55469ffc..43a1cb0aef 100644 --- a/generated/models/ApiWaveCurationRequest.ts +++ b/generated/models/ApiWaveCurationRequest.ts @@ -16,6 +16,7 @@ import { HttpFile } from '../http/http'; export class ApiWaveCurationRequest { 'name': string; 'group_id': string; + 'priority_order'?: number; static readonly discriminator: string | undefined = undefined; @@ -33,6 +34,12 @@ export class ApiWaveCurationRequest { "baseName": "group_id", "type": "string", "format": "" + }, + { + "name": "priority_order", + "baseName": "priority_order", + "type": "number", + "format": "int32" } ]; static getAttributeTypeMap() { diff --git a/generated/models/ObjectSerializer.ts b/generated/models/ObjectSerializer.ts index b2c68bb7fe..de6dc30781 100644 --- a/generated/models/ObjectSerializer.ts +++ b/generated/models/ObjectSerializer.ts @@ -62,6 +62,8 @@ export * from '../models/ApiCreateWaveConfig'; export * from '../models/ApiCreateWaveDropRequest'; export * from '../models/ApiCreateWaveOutcome'; export * from '../models/ApiCreateWaveOutcomeDistributionItem'; +export * from '../models/ApiCurationDrop'; +export * from '../models/ApiCurationDropsPage'; export * from '../models/ApiDistributionAirdropsCsvUploadRequest'; export * from '../models/ApiDistributionAirdropsUploadResponse'; export * from '../models/ApiDrop'; @@ -376,6 +378,8 @@ import { ApiCreateWaveConfig } from '../models/ApiCreateWaveConfig'; import { ApiCreateWaveDropRequest } from '../models/ApiCreateWaveDropRequest'; import { ApiCreateWaveOutcome } from '../models/ApiCreateWaveOutcome'; import { ApiCreateWaveOutcomeDistributionItem } from '../models/ApiCreateWaveOutcomeDistributionItem'; +import { ApiCurationDrop } from '../models/ApiCurationDrop'; +import { ApiCurationDropsPage } from '../models/ApiCurationDropsPage'; import { ApiDistributionAirdropsCsvUploadRequest } from '../models/ApiDistributionAirdropsCsvUploadRequest'; import { ApiDistributionAirdropsUploadResponse } from '../models/ApiDistributionAirdropsUploadResponse'; import { ApiDrop } from '../models/ApiDrop'; @@ -745,6 +749,8 @@ let typeMap: {[index: string]: any} = { "ApiCreateWaveDropRequest": ApiCreateWaveDropRequest, "ApiCreateWaveOutcome": ApiCreateWaveOutcome, "ApiCreateWaveOutcomeDistributionItem": ApiCreateWaveOutcomeDistributionItem, + "ApiCurationDrop": ApiCurationDrop, + "ApiCurationDropsPage": ApiCurationDropsPage, "ApiDistributionAirdropsCsvUploadRequest": ApiDistributionAirdropsCsvUploadRequest, "ApiDistributionAirdropsUploadResponse": ApiDistributionAirdropsUploadResponse, "ApiDrop": ApiDrop, diff --git a/hooks/drops/useDropCurationMembershipMutation.ts b/hooks/drops/useDropCurationMembershipMutation.ts index e0c00a53e6..3164ea3eb8 100644 --- a/hooks/drops/useDropCurationMembershipMutation.ts +++ b/hooks/drops/useDropCurationMembershipMutation.ts @@ -2,16 +2,18 @@ import { useAuth } from "@/components/auth/Auth"; import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; -import { publicEnv } from "@/config/env"; import type { ApiDropCurationRequest } from "@/generated/models/ApiDropCurationRequest"; -import { commonApiDeleteWithBody } from "@/services/api/common-api"; -import { getAuthJwt, getStagingAuth } from "@/services/auth/auth.utils"; +import { + deleteDropCuration, + postDropCuration, +} from "@/services/api/drop-curations-api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useContext } from "react"; import { getDropCurationsQueryKey, type DropCurationMembership, } from "./useDropCurations"; +import { getAuthJwt } from "@/services/auth/auth.utils"; type DropCurationMembershipAction = "add" | "remove"; @@ -25,67 +27,6 @@ interface DropCurationMembershipMutationVariables { readonly options?: DropCurationMembershipMutationOptions | undefined; } -const getDropCurationMembershipEndpoint = (dropId: string): string => - `${publicEnv.API_ENDPOINT}/api/drops/${dropId}/curations`; - -const getDropCurationMembershipHeaders = (): Record => { - const apiAuth = getStagingAuth(); - const walletAuth = getAuthJwt(); - - return { - "Content-Type": "application/json", - ...(apiAuth ? { "x-6529-auth": apiAuth } : {}), - ...(walletAuth ? { Authorization: `Bearer ${walletAuth}` } : {}), - }; -}; - -const getErrorMessageFromResponse = async ( - response: Response -): Promise => { - const fallbackErrorMessage = response.statusText || "Something went wrong"; - - try { - const rawContent = await response.text(); - if (!rawContent) { - return fallbackErrorMessage; - } - - try { - const parsedBody = JSON.parse(rawContent) as Record; - if (typeof parsedBody["error"] === "string") { - return parsedBody["error"]; - } - if (typeof parsedBody["message"] === "string") { - return parsedBody["message"]; - } - } catch { - return rawContent; - } - - return rawContent; - } catch { - return fallbackErrorMessage; - } -}; - -const addDropToCuration = async ({ - dropId, - body, -}: { - readonly dropId: string; - readonly body: ApiDropCurationRequest; -}): Promise => { - const response = await fetch(getDropCurationMembershipEndpoint(dropId), { - method: "POST", - headers: getDropCurationMembershipHeaders(), - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(await getErrorMessageFromResponse(response)); - } -}; - const getErrorMessage = (error: unknown, fallback: string): string => { if (typeof error === "string" && error.trim().length > 0) { return error; @@ -138,7 +79,13 @@ export function useDropCurationMembershipMutation({ }) { const queryClient = useQueryClient(); const { invalidateDrops } = useContext(ReactQueryWrapperContext); - const { setToast } = useAuth(); + const { setToast, connectedProfile, activeProfileProxy } = useAuth(); + const dropCurationsQueryKey = getDropCurationsQueryKey({ + dropId, + connectedProfileId: connectedProfile?.id ?? null, + activeProfileProxyId: activeProfileProxy?.id ?? null, + hasAuthJwt: Boolean(getAuthJwt()), + }); const mutation = useMutation< void, @@ -152,29 +99,29 @@ export function useDropCurationMembershipMutation({ }; if (action === "add") { - await addDropToCuration({ + await postDropCuration({ dropId, body, }); return; } - await commonApiDeleteWithBody({ - endpoint: `drops/${dropId}/curations`, + await deleteDropCuration({ + dropId, body, }); }, onMutate: async ({ curationId, action }) => { await queryClient.cancelQueries({ - queryKey: getDropCurationsQueryKey(dropId), + queryKey: dropCurationsQueryKey, }); const previousCurations = queryClient.getQueryData< DropCurationMembership[] - >(getDropCurationsQueryKey(dropId)); + >(dropCurationsQueryKey); queryClient.setQueryData( - getDropCurationsQueryKey(dropId), + dropCurationsQueryKey, (current) => current?.map((curation) => curation.id === curationId @@ -203,7 +150,7 @@ export function useDropCurationMembershipMutation({ onError: (error, variables, context) => { if (context?.previousCurations !== undefined) { queryClient.setQueryData( - getDropCurationsQueryKey(dropId), + dropCurationsQueryKey, context.previousCurations ); } @@ -217,7 +164,7 @@ export function useDropCurationMembershipMutation({ }, onSettled: async () => { await queryClient.invalidateQueries({ - queryKey: getDropCurationsQueryKey(dropId), + queryKey: dropCurationsQueryKey, }); }, }); diff --git a/hooks/drops/useDropCurationOrderMutation.ts b/hooks/drops/useDropCurationOrderMutation.ts new file mode 100644 index 0000000000..7eddc20e77 --- /dev/null +++ b/hooks/drops/useDropCurationOrderMutation.ts @@ -0,0 +1,67 @@ +"use client"; + +import { useAuth } from "@/components/auth/Auth"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ApiDropCurationRequest } from "@/generated/models/ApiDropCurationRequest"; +import { postDropCuration } from "@/services/api/drop-curations-api"; +import { useMutation } from "@tanstack/react-query"; +import { useContext } from "react"; + +interface DropCurationOrderUpdate { + readonly dropId: string; + readonly priorityOrder: number; +} + +export function useDropCurationOrderMutation({ + curationId, +}: { + readonly curationId: string; +}) { + const { invalidateDrops } = useContext(ReactQueryWrapperContext); + const { setToast } = useAuth(); + + const mutation = useMutation({ + mutationFn: async (updates: readonly DropCurationOrderUpdate[]) => { + const results = await Promise.allSettled( + updates.map(async ({ dropId, priorityOrder }) => { + const body: ApiDropCurationRequest = { + curation_id: curationId, + priority_order: priorityOrder, + }; + + await postDropCuration({ + dropId, + body, + }); + }) + ); + + const firstRejected = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected" + ); + + if (firstRejected) { + throw firstRejected.reason; + } + }, + onSuccess: () => { + invalidateDrops(); + }, + onError: (error) => { + const message = + error instanceof Error && error.message.trim().length > 0 + ? error.message + : "Failed to update curation order."; + + setToast({ + type: "error", + message, + }); + }, + }); + + return { + persistOrderAsync: mutation.mutateAsync, + isPending: mutation.isPending, + }; +} diff --git a/hooks/drops/useDropCurations.ts b/hooks/drops/useDropCurations.ts index 1503b20815..75c5fd8842 100644 --- a/hooks/drops/useDropCurations.ts +++ b/hooks/drops/useDropCurations.ts @@ -1,7 +1,9 @@ "use client"; +import { useAuth } from "@/components/auth/Auth"; import type { ApiWaveCuration } from "@/generated/models/ApiWaveCuration"; import { commonApiFetch } from "@/services/api/common-api"; +import { getAuthJwt } from "@/services/auth/auth.utils"; import { useQuery } from "@tanstack/react-query"; export interface DropCurationMembership extends ApiWaveCuration { @@ -9,8 +11,41 @@ export interface DropCurationMembership extends ApiWaveCuration { readonly authenticated_user_can_curate: boolean; } -export const getDropCurationsQueryKey = (dropId: string) => - ["drop-curations", { drop_id: dropId }] as const; +const getDropCurationsAuthScope = ({ + connectedProfileId, + activeProfileProxyId, + hasAuthJwt, +}: { + readonly connectedProfileId: string | null; + readonly activeProfileProxyId: string | null; + readonly hasAuthJwt: boolean; +}) => + `${activeProfileProxyId ?? connectedProfileId ?? "anonymous"}:${ + hasAuthJwt ? "authenticated" : "anonymous" + }`; + +export const getDropCurationsQueryKey = ({ + dropId, + connectedProfileId, + activeProfileProxyId, + hasAuthJwt, +}: { + readonly dropId: string; + readonly connectedProfileId: string | null; + readonly activeProfileProxyId: string | null; + readonly hasAuthJwt: boolean; +}) => + [ + "drop-curations", + { + drop_id: dropId, + auth_scope: getDropCurationsAuthScope({ + connectedProfileId, + activeProfileProxyId, + hasAuthJwt, + }), + }, + ] as const; export function useDropCurations({ dropId, @@ -19,13 +54,23 @@ export function useDropCurations({ readonly dropId: string; readonly enabled?: boolean | undefined; }) { + const { connectedProfile, activeProfileProxy, fetchingProfile } = useAuth(); + const connectedProfileId = connectedProfile?.id ?? null; + const activeProfileProxyId = activeProfileProxy?.id ?? null; + const hasAuthJwt = Boolean(getAuthJwt()); + return useQuery({ - queryKey: getDropCurationsQueryKey(dropId), + queryKey: getDropCurationsQueryKey({ + dropId, + connectedProfileId, + activeProfileProxyId, + hasAuthJwt, + }), queryFn: async () => await commonApiFetch({ endpoint: `drops/${dropId}/curations`, }), - enabled: enabled && !!dropId, + enabled: enabled && !!dropId && !fetchingProfile, staleTime: 60_000, }); } diff --git a/hooks/useCurationManagementPermission.ts b/hooks/useCurationManagementPermission.ts index dbbc0123ec..4911986eec 100644 --- a/hooks/useCurationManagementPermission.ts +++ b/hooks/useCurationManagementPermission.ts @@ -5,17 +5,28 @@ import { useDropCurations } from "@/hooks/drops/useDropCurations"; export function useCurationManagementPermission({ curationId, probeDropId, + enabled = true, }: { readonly curationId: string; readonly probeDropId: string; + readonly enabled?: boolean | undefined; }) { - const { data: probeCurations = [] } = useDropCurations({ + const isProbeReady = Boolean(probeDropId); + const isEnabled = enabled && isProbeReady; + const { + data: probeCurations, + isFetching, + isPending, + } = useDropCurations({ dropId: probeDropId, - enabled: Boolean(probeDropId), + enabled: isEnabled, }); + const canManageCuration = + probeCurations?.find((curation) => curation.id === curationId) + ?.authenticated_user_can_curate ?? false; - return ( - probeCurations.find((curation) => curation.id === curationId) - ?.authenticated_user_can_curate ?? false - ); + return { + canManageCuration, + isLoading: enabled && (!isProbeReady || (isPending && isFetching)), + }; } diff --git a/hooks/useWaveCurationDrops.ts b/hooks/useWaveCurationDrops.ts new file mode 100644 index 0000000000..d533d7dc30 --- /dev/null +++ b/hooks/useWaveCurationDrops.ts @@ -0,0 +1,255 @@ +"use client"; + +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ApiCurationDrop } from "@/generated/models/ApiCurationDrop"; +import type { ApiCurationDropsPage } from "@/generated/models/ApiCurationDropsPage"; +import type { ApiDrop } from "@/generated/models/ApiDrop"; +import type { ApiDropWithoutWave } from "@/generated/models/ApiDropWithoutWave"; +import type { ApiReplyToDropResponse } from "@/generated/models/ApiReplyToDropResponse"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; +import { + convertApiDropToExtendedDrop, + type ExtendedDrop, +} from "@/helpers/waves/drop.helpers"; +import type { WsDropUpdateMessage } from "@/helpers/Types"; +import { WsMessageType } from "@/helpers/Types"; +import { commonApiFetch } from "@/services/api/common-api"; +import { useWebSocketMessage } from "@/services/websocket/useWebSocketMessage"; +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { useDebouncedQueryRefetch } from "./useDebouncedQueryRefetch"; + +const DEFAULT_CURATION_DROPS_PAGE_SIZE = 20; +const MAX_CURATION_DROPS_PAGE_SIZE = 100; +const MAX_CURATION_DROPS_PAGES = 100; + +const getWaveMin = (wave: ApiWave): ApiWaveMin => ({ + id: wave.id, + name: wave.name, + picture: wave.picture, + description_drop_id: wave.description_drop.id, + last_drop_time: wave.last_drop_time, + submission_type: wave.participation.submission_strategy?.type ?? null, + authenticated_user_eligible_to_vote: wave.voting.authenticated_user_eligible, + authenticated_user_eligible_to_participate: + wave.participation.authenticated_user_eligible, + authenticated_user_eligible_to_chat: wave.chat.authenticated_user_eligible, + authenticated_user_admin: wave.wave.authenticated_user_eligible_for_admin, + visibility_group_id: wave.visibility.scope.group?.id ?? null, + participation_group_id: wave.participation.scope.group?.id ?? null, + chat_group_id: wave.chat.scope.group?.id ?? null, + voting_group_id: wave.voting.scope.group?.id ?? null, + admin_group_id: wave.wave.admin_group.group?.id ?? null, + voting_period_start: wave.voting.period?.min ?? null, + voting_period_end: wave.voting.period?.max ?? null, + voting_credit_type: wave.voting.credit_type, + admin_drop_deletion_enabled: wave.wave.admin_drop_deletion_enabled, + forbid_negative_votes: wave.voting.forbid_negative_votes, + pinned: wave.pinned, + identity_wave: wave.identity_wave, +}); + +const withWaveOnReplyTo = ( + replyTo: ApiReplyToDropResponse | undefined, + wave: ApiWaveMin +): ApiReplyToDropResponse | undefined => { + if (!replyTo?.drop) { + return replyTo; + } + + return { + ...replyTo, + drop: { + ...replyTo.drop, + wave, + }, + } as ApiReplyToDropResponse; +}; + +const withWave = ( + drop: ApiCurationDrop | ApiDropWithoutWave, + wave: ApiWaveMin +) => + ({ + ...drop, + wave, + reply_to: withWaveOnReplyTo(drop.reply_to, wave), + }) as ApiDrop; + +const processDrops = ( + pages: ApiCurationDropsPage[] | undefined, + wave: ApiWave +): ExtendedDrop[] => { + if (!pages) { + return []; + } + + const waveMin = getWaveMin(wave); + const allDrops = pages.flatMap((page) => page.data); + const orderedDrops = allDrops.some( + (drop) => drop.drop_priority_order !== null + ) + ? allDrops + : [...allDrops].sort( + (left, right) => + right.created_at - left.created_at || right.serial_no - left.serial_no + ); + + return orderedDrops.map((drop) => + convertApiDropToExtendedDrop(withWave(drop, waveMin)) + ); +}; + +const fetchWaveCurationDropsPage = async ({ + waveId, + curationId, + page, + pageSize, +}: { + readonly waveId: string; + readonly curationId: string; + readonly page: number; + readonly pageSize: number; +}): Promise => + await commonApiFetch({ + endpoint: `waves/${waveId}/curations/${curationId}/drops`, + params: { + page: page.toString(), + page_size: pageSize.toString(), + }, + }); + +export const fetchAllWaveCurationDrops = async ({ + wave, + curationId, + pageSize = MAX_CURATION_DROPS_PAGE_SIZE, + maxPages = MAX_CURATION_DROPS_PAGES, +}: { + readonly wave: ApiWave; + readonly curationId: string; + readonly pageSize?: number | undefined; + readonly maxPages?: number | undefined; +}): Promise => { + const normalizedMaxPages = Math.max(1, maxPages); + const pages: ApiCurationDropsPage[] = []; + let nextPage = 1; + let hasNextPage = true; + + while (hasNextPage) { + if (pages.length >= normalizedMaxPages) { + throw new Error( + `Unable to load more than ${ + normalizedMaxPages * pageSize + } curation drops.` + ); + } + + const page = await fetchWaveCurationDropsPage({ + waveId: wave.id, + curationId, + page: nextPage, + pageSize, + }); + + pages.push(page); + hasNextPage = page.next; + nextPage = page.page + 1; + } + + return processDrops(pages, wave); +}; + +interface UseWaveCurationDropsProps { + readonly wave: ApiWave; + readonly curationId: string; + readonly pageSize?: number | undefined; + readonly enabled?: boolean | undefined; +} + +export function useWaveCurationDrops({ + wave, + curationId, + pageSize = DEFAULT_CURATION_DROPS_PAGE_SIZE, + enabled = true, +}: UseWaveCurationDropsProps) { + const queryKey = useMemo( + () => + [ + QueryKey.DROPS, + { + waveId: wave.id, + curationId, + pageSize, + context: "wave-curation-drops", + }, + ] as const, + [curationId, pageSize, wave.id] + ); + + const { + data, + fetchNextPage: onFetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + refetch, + } = useInfiniteQuery({ + queryKey, + queryFn: async ({ pageParam }: { pageParam: number }) => + await fetchWaveCurationDropsPage({ + waveId: wave.id, + curationId, + page: pageParam, + pageSize, + }), + enabled: enabled && !!wave.id && !!curationId, + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.next ? lastPage.page + 1 : undefined, + placeholderData: keepPreviousData, + staleTime: 60000, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchOnReconnect: true, + }); + + const fetchNextPage = useCallback(async () => { + if (hasNextPage && !isFetchingNextPage) { + await onFetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, onFetchNextPage]); + + const drops = useMemo( + () => processDrops(data?.pages, wave), + [data?.pages, wave] + ); + const requestRefetch = useDebouncedQueryRefetch({ + refetch, + isFetching, + isFetchingNextPage, + }); + + useWebSocketMessage( + WsMessageType.DROP_UPDATE, + useCallback( + (message) => { + if (wave.id !== message.wave.id) { + return; + } + + requestRefetch(); + }, + [requestRefetch, wave.id] + ) + ); + + return { + drops, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + refetch, + }; +} diff --git a/openapi.yaml b/openapi.yaml index 2ecbda1f7d..3772f7301d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -6032,6 +6032,49 @@ paths: application/json: schema: $ref: "#/components/schemas/ApiWaveCuration" + /waves/{id}/curations/{curationId}/drops: + get: + tags: + - Waves + summary: List drops in a curation for wave + operationId: listWaveCurationDrops + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: curationId + in: path + required: true + schema: + type: string + - name: page + in: query + required: false + schema: + type: integer + format: int64 + minimum: 1 + default: 1 + - name: page_size + in: query + required: false + schema: + type: integer + format: int64 + minimum: 1 + maximum: 100 + default: 50 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ApiCurationDropsPage" + "404": + description: Wave or curation not found /waves/{id}/curations/{curationId}: post: tags: @@ -8303,6 +8346,29 @@ components: description: type: string nullable: true + ApiCurationDrop: + type: object + required: + - drop_priority_order + allOf: + - $ref: "#/components/schemas/ApiDropWithoutWave" + properties: + drop_priority_order: + type: integer + format: int64 + minimum: 1 + nullable: true + ApiCurationDropsPage: + type: object + required: + - data + allOf: + - $ref: "#/components/schemas/ApiPageWithoutCount" + properties: + data: + type: array + items: + $ref: "#/components/schemas/ApiCurationDrop" ApiDistributionAirdropsCsvUploadRequest: type: object required: @@ -8529,6 +8595,7 @@ components: required: - drop_included - authenticated_user_can_curate + - drop_priority_order allOf: - $ref: "#/components/schemas/ApiWaveCuration" properties: @@ -8536,6 +8603,11 @@ components: type: boolean authenticated_user_can_curate: type: boolean + drop_priority_order: + type: integer + format: int64 + minimum: 1 + nullable: true ApiDropCurationRequest: type: object required: @@ -8543,6 +8615,10 @@ components: properties: curation_id: type: string + priority_order: + type: integer + format: int64 + minimum: 1 ApiDropGroupMention: type: string enum: @@ -11549,6 +11625,7 @@ components: - group_id - created_at - updated_at + - priority_order properties: id: type: string @@ -11564,6 +11641,10 @@ components: updated_at: type: number format: int64 + priority_order: + type: integer + format: int32 + minimum: 1 ApiWaveCurationRequest: type: object required: @@ -11576,6 +11657,10 @@ components: maxLength: 50 group_id: type: string + priority_order: + type: integer + format: int32 + minimum: 1 ApiWaveDecision: type: object required: diff --git a/package.json b/package.json index 28c9cfad1f..22d4c086a3 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,9 @@ "@capacitor/keyboard": "7.0.1", "@capacitor/push-notifications": "7.0.1", "@capacitor/share": "7.0.1", + "@dnd-kit/core": "6.3.1", + "@dnd-kit/sortable": "10.0.0", + "@dnd-kit/utilities": "3.2.2", "@emoji-mart/data": "1.2.1", "@emoji-mart/react": "1.1.1", "@ensdomains/content-hash": "3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c574766a5..0d1a5c2b17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,15 @@ importers: '@capacitor/share': specifier: 7.0.1 version: 7.0.1(@capacitor/core@7.4.1) + '@dnd-kit/core': + specifier: 6.3.1 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/sortable': + specifier: 10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': + specifier: 3.2.2 + version: 3.2.2(react@19.2.4) '@emoji-mart/data': specifier: 1.2.1 version: 1.2.1 @@ -796,6 +805,28 @@ packages: '@corex/deepmerge@4.0.43': resolution: {integrity: sha512-N8uEMrMPL0cu/bdboEWpQYb/0i2K5Qn8eCsxzOmxSggJbbQte7ljMRoXm917AbntqTGOzdTu+vP3KOOzoC70HQ==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@ecies/ciphers@0.2.5': resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -9737,6 +9768,31 @@ snapshots: '@corex/deepmerge@4.0.43': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': dependencies: '@noble/ciphers': 1.3.0 diff --git a/services/api/drop-curations-api.ts b/services/api/drop-curations-api.ts new file mode 100644 index 0000000000..8f28ce00b9 --- /dev/null +++ b/services/api/drop-curations-api.ts @@ -0,0 +1,78 @@ +import type { ApiDropCurationRequest } from "@/generated/models/ApiDropCurationRequest"; +import { commonApiDeleteWithBody } from "@/services/api/common-api"; +import { publicEnv } from "@/config/env"; +import { getAuthJwt, getStagingAuth } from "@/services/auth/auth.utils"; + +const getDropCurationEndpoint = (dropId: string): string => + `${publicEnv.API_ENDPOINT}/api/drops/${encodeURIComponent(dropId)}/curations`; + +const getDropCurationHeaders = (): Record => { + const apiAuth = getStagingAuth(); + const walletAuth = getAuthJwt(); + + return { + "Content-Type": "application/json", + ...(apiAuth ? { "x-6529-auth": apiAuth } : {}), + ...(walletAuth ? { Authorization: `Bearer ${walletAuth}` } : {}), + }; +}; + +const getErrorMessageFromResponse = async ( + response: Response +): Promise => { + const fallbackErrorMessage = response.statusText || "Something went wrong"; + + try { + const rawContent = await response.text(); + if (!rawContent) { + return fallbackErrorMessage; + } + + try { + const parsedBody = JSON.parse(rawContent) as Record; + if (typeof parsedBody["error"] === "string") { + return parsedBody["error"]; + } + if (typeof parsedBody["message"] === "string") { + return parsedBody["message"]; + } + } catch { + return rawContent; + } + + return rawContent; + } catch { + return fallbackErrorMessage; + } +}; + +export const postDropCuration = async ({ + dropId, + body, +}: { + readonly dropId: string; + readonly body: ApiDropCurationRequest; +}): Promise => { + const response = await fetch(getDropCurationEndpoint(dropId), { + method: "POST", + headers: getDropCurationHeaders(), + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error(await getErrorMessageFromResponse(response)); + } +}; + +export const deleteDropCuration = async ({ + dropId, + body, +}: { + readonly dropId: string; + readonly body: ApiDropCurationRequest; +}): Promise => { + await commonApiDeleteWithBody({ + endpoint: `drops/${encodeURIComponent(dropId)}/curations`, + body, + }); +};