From 913aad1ecd0be3c905a9f7ddd29cf6489fe7f933 Mon Sep 17 00:00:00 2001 From: ragnep Date: Mon, 20 Apr 2026 10:18:13 +0300 Subject: [PATCH 1/8] wip Signed-off-by: ragnep --- .../my-stream/MyStreamWaveDesktopTabs.tsx | 123 ++++++++++- .../tabs/MyStreamWaveCurationTabMenu.tsx | 158 ++++++++++++++ components/common/TabToggle.tsx | 84 ++++++-- .../curation/WaveActiveCurationSection.tsx | 65 +++++- hooks/waves/useWaveCurationReorderMutation.ts | 201 ++++++++++++++++++ hooks/waves/useWaveCurations.ts | 27 +++ openapi.yaml | 29 ++- 7 files changed, 658 insertions(+), 29 deletions(-) create mode 100644 components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx create mode 100644 hooks/waves/useWaveCurationReorderMutation.ts diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx index a94fee7b5f..4a24b5bb92 100644 --- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx +++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx @@ -1,13 +1,18 @@ "use client"; import React, { useEffect, useMemo, useRef } from "react"; -import { EllipsisVerticalIcon } from "@heroicons/react/24/outline"; +import { + ArrowLeftIcon, + ArrowRightIcon, + EllipsisVerticalIcon, +} from "@heroicons/react/24/outline"; import { CompactMenu, type CompactMenuItem } from "@/components/compact-menu"; import { TabToggle } from "@/components/common/TabToggle"; import { useSearchParams } from "next/navigation"; import type { ApiWave } from "@/generated/models/ApiWave"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; import { useWaveCurations } from "@/hooks/waves/useWaveCurations"; +import { useWaveCurationReorderMutation } from "@/hooks/waves/useWaveCurationReorderMutation"; import { useWave } from "@/hooks/useWave"; import { useDecisionPoints } from "@/hooks/waves/useDecisionPoints"; import { useWaveTimers } from "@/hooks/useWaveTimers"; @@ -20,6 +25,7 @@ import { type SetActiveContentTab, } from "../ContentTabContext"; import MyStreamWaveCreateCurationAction from "./tabs/MyStreamWaveCreateCurationAction"; +import MyStreamWaveCurationTabMenu from "./tabs/MyStreamWaveCurationTabMenu"; interface MyStreamWaveDesktopTabsProps { readonly activeTab: MyStreamWaveTab; @@ -34,6 +40,7 @@ interface TabOption { readonly key: string; readonly label: string; readonly panelId: string; + readonly action?: React.ReactNode | undefined; } const getContentTabPanelId = (tab: MyStreamWaveTab): string => @@ -107,6 +114,8 @@ const MyStreamWaveDesktopTabs: React.FC = ({ const { data: curations = [] } = useWaveCurations({ waveId: wave.id, }); + const { moveCuration, isPending: isCurationReorderPending } = + useWaveCurationReorderMutation({ waveId: wave.id }); const canManageCurations = wave.wave.authenticated_user_eligible_for_admin === true; @@ -212,12 +221,41 @@ const MyStreamWaveDesktopTabs: React.FC = ({ const curationOptions: TabOption[] = useMemo( () => - curations.map((curation) => ({ + curations.map((curation, index) => ({ key: `curation:${curation.id}`, label: curation.name, panelId: getCurationPanelId(curation.id), + action: canManageCurations ? ( + 0} + canMoveNext={index < curations.length - 1} + isMovePending={isCurationReorderPending} + onMove={(direction) => + moveCuration({ + curation, + direction, + curations, + }) + } + onDeleted={ + activeCurationId === curation.id + ? () => onSelectCuration(null) + : undefined + } + /> + ) : undefined, })), - [curations] + [ + activeCurationId, + canManageCurations, + curations, + isCurationReorderPending, + moveCuration, + onSelectCuration, + wave, + ] ); const options: TabOption[] = useMemo( @@ -228,6 +266,44 @@ const MyStreamWaveDesktopTabs: React.FC = ({ const activeKey = activeCurationId ? `curation:${activeCurationId}` : activeTab; + const activeCuration = useMemo( + () => + activeCurationId + ? (curations.find((curation) => curation.id === activeCurationId) ?? + null) + : null, + [activeCurationId, curations] + ); + const activeCurationIndex = useMemo( + () => + activeCurationId + ? curations.findIndex((curation) => curation.id === activeCurationId) + : -1, + [activeCurationId, curations] + ); + const canMoveActiveCuration = + canManageCurations && activeCuration !== null && curations.length > 1; + const showCurationActions = canMoveActiveCuration || showCreateCurationAction; + const moveLeftDisabled = + activeCuration === null || + activeCurationIndex <= 0 || + isCurationReorderPending; + const moveRightDisabled = + activeCuration === null || + activeCurationIndex < 0 || + activeCurationIndex >= curations.length - 1 || + isCurationReorderPending; + const moveActiveCuration = (direction: "previous" | "next") => { + if (activeCuration === null) { + return; + } + + moveCuration({ + curation: activeCuration, + direction, + curations, + }); + }; const mobileVisibleCurationOptions = useMemo(() => { if (curationOptions.length <= MOBILE_INLINE_CURATION_LIMIT) { @@ -355,12 +431,41 @@ const MyStreamWaveDesktopTabs: React.FC = ({ }} /> - {showCreateCurationAction && ( -
- + {showCurationActions && ( +
+ {canMoveActiveCuration && ( +
+ + +
+ )} + {showCreateCurationAction && ( + + )}
)}
diff --git a/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx b/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx new file mode 100644 index 0000000000..41a927c600 --- /dev/null +++ b/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + ArrowLeftIcon, + ArrowRightIcon, + EllipsisVerticalIcon, +} from "@heroicons/react/24/outline"; +import { CompactMenu, type CompactMenuItem } from "@/components/compact-menu"; +import { useAuth } from "@/components/auth/Auth"; +import CommonConfirmationModal from "@/components/utils/modal/CommonConfirmationModal"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiWaveCuration } from "@/generated/models/ApiWaveCuration"; +import type { DropCurationMembership } from "@/hooks/drops/useDropCurations"; +import { getWaveCurationsQueryKey } from "@/hooks/waves/useWaveCurations"; +import type { WaveCurationMoveDirection } from "@/hooks/waves/useWaveCurationReorderMutation"; +import { commonApiDelete } from "@/services/api/common-api"; +import MyStreamWaveCurationCreateDialog from "./MyStreamWaveCurationCreateDialog"; + +interface MyStreamWaveCurationTabMenuProps { + readonly wave: ApiWave; + readonly curation: ApiWaveCuration; + readonly onDeleted?: (() => void) | undefined; + readonly onMove?: + | ((direction: WaveCurationMoveDirection) => void) + | undefined; + readonly canMovePrevious?: boolean | undefined; + readonly canMoveNext?: boolean | undefined; + readonly isMovePending?: boolean | undefined; +} + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : "Failed to delete curation."; + +export default function MyStreamWaveCurationTabMenu({ + wave, + curation, + onDeleted, + onMove, + canMovePrevious = false, + canMoveNext = false, + isMovePending = false, +}: MyStreamWaveCurationTabMenuProps) { + const queryClient = useQueryClient(); + const { requestAuth, setToast } = useAuth(); + const [isEditOpen, setIsEditOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + + const deleteMutation = useMutation({ + mutationFn: async () => { + const auth = await requestAuth(); + if (!auth.success) { + throw new Error("Authentication was cancelled."); + } + + await commonApiDelete({ + endpoint: `waves/${wave.id}/curations/${curation.id}`, + }); + }, + onSuccess: async () => { + queryClient.setQueryData( + getWaveCurationsQueryKey(wave.id), + (current) => current?.filter((item) => item.id !== curation.id) + ); + queryClient.setQueriesData( + { queryKey: ["drop-curations"] }, + (current) => current?.filter((item) => item.id !== curation.id) + ); + await queryClient.invalidateQueries({ + queryKey: getWaveCurationsQueryKey(wave.id), + }); + await queryClient.invalidateQueries({ + queryKey: ["drop-curations"], + }); + setToast({ + type: "success", + message: "Curation deleted.", + }); + setIsDeleteOpen(false); + onDeleted?.(); + }, + onError: (error) => { + setToast({ + type: "error", + message: getErrorMessage(error), + }); + }, + }); + + const moveItems: CompactMenuItem[] = + onMove === undefined + ? [] + : [ + { + id: "move-left", + label: "Move left", + icon: , + onSelect: () => onMove("previous"), + disabled: isMovePending || !canMovePrevious, + }, + { + id: "move-right", + label: "Move right", + icon: , + onSelect: () => onMove("next"), + disabled: isMovePending || !canMoveNext, + }, + ]; + + const menuItems: CompactMenuItem[] = [ + ...moveItems, + { + id: "edit", + label: "Edit curation", + onSelect: () => setIsEditOpen(true), + }, + { + id: "delete", + label: "Delete curation", + onSelect: () => setIsDeleteOpen(true), + className: "tw-text-red desktop-hover:hover:tw-text-red", + }, + ]; + + return ( + <> + } + aria-label={`${curation.name} curation options`} + items={menuItems} + menuWidthClassName="tw-w-44" + disabled={deleteMutation.isPending} + /> + + {isEditOpen && ( + setIsEditOpen(false)} + onSaved={() => undefined} + curation={curation} + /> + )} + + setIsDeleteOpen(false)} + onConfirm={() => deleteMutation.mutate()} + title="Delete curation" + message={`Delete "${curation.name}" from this wave?`} + confirmText="Delete" + isConfirming={deleteMutation.isPending} + /> + + ); +} diff --git a/components/common/TabToggle.tsx b/components/common/TabToggle.tsx index a1764b94ab..a27e62cc24 100644 --- a/components/common/TabToggle.tsx +++ b/components/common/TabToggle.tsx @@ -5,6 +5,7 @@ interface TabOption { readonly label: string; readonly hasIndicator?: boolean | undefined; readonly panelId: string; + readonly action?: React.ReactNode | undefined; } interface TabToggleProps { @@ -20,33 +21,80 @@ export const TabToggle: React.FC = ({ onSelect, fullWidth = false, // Default to false for backwards compatibility }) => { + const hasActions = options.some( + (option) => option.action !== undefined && option.action !== null + ); + + if (!hasActions) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); + } + return (
{options.map((option) => ( - + {option.action !== undefined && option.action !== null && ( +
+ {option.action} +
)} - +
))} ); diff --git a/components/waves/groups/curation/WaveActiveCurationSection.tsx b/components/waves/groups/curation/WaveActiveCurationSection.tsx index 874514a6aa..91769dad43 100644 --- a/components/waves/groups/curation/WaveActiveCurationSection.tsx +++ b/components/waves/groups/curation/WaveActiveCurationSection.tsx @@ -4,7 +4,11 @@ import clsx from "clsx"; import { useMemo, useState } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { EllipsisHorizontalIcon } from "@heroicons/react/24/outline"; +import { + ArrowDownIcon, + ArrowUpIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/24/outline"; import { CompactMenu, type CompactMenuItem } from "@/components/compact-menu"; import { useAuth } from "@/components/auth/Auth"; import MyStreamWaveCurationCreateDialog from "@/components/brain/my-stream/tabs/MyStreamWaveCurationCreateDialog"; @@ -21,6 +25,7 @@ import { getWaveCurationsQueryKey, useWaveCurations, } from "@/hooks/waves/useWaveCurations"; +import { useWaveCurationReorderMutation } from "@/hooks/waves/useWaveCurationReorderMutation"; import { commonApiDelete, commonApiFetch } from "@/services/api/common-api"; const toScopeGroup = (group: ApiGroupFull): ApiGroup => ({ @@ -54,6 +59,11 @@ export default function WaveActiveCurationSection({ waveId: wave.id, enabled: shouldLoadCurations, }); + const { + moveCuration, + isPending: isCurationReorderPending, + pendingCurationId, + } = useWaveCurationReorderMutation({ waveId: wave.id }); const resolvedActiveCurationId = activeCurationId ?? (isApp ? (curations[0]?.id ?? null) : null); @@ -63,6 +73,15 @@ export default function WaveActiveCurationSection({ null, [curations, resolvedActiveCurationId] ); + const activeCurationIndex = useMemo( + () => + resolvedActiveCurationId + ? curations.findIndex( + (curation) => curation.id === resolvedActiveCurationId + ) + : -1, + [curations, resolvedActiveCurationId] + ); const activeGroupId = activeCuration?.group_id ?? null; const { data: activeGroup, isFetching: isFetchingActiveGroup } = @@ -148,7 +167,49 @@ export default function WaveActiveCurationSection({ }, }); + const canMoveActiveCuration = + canManageCurations && activeCuration !== null && curations.length > 1; + const activeCurationIsReordering = activeCuration + ? pendingCurationId === activeCuration.id + : false; + const moveActiveCuration = (direction: "previous" | "next") => { + if (!activeCuration) { + return; + } + + if (!activeCurationId) { + setSelectedCuration(activeCuration.id); + } + + moveCuration({ + curation: activeCuration, + direction, + curations, + }); + }; + const menuItems: CompactMenuItem[] = [ + ...(canMoveActiveCuration + ? [ + { + id: "move-up", + label: "Move up", + icon: , + disabled: activeCurationIndex <= 0 || isCurationReorderPending, + onSelect: () => moveActiveCuration("previous"), + }, + { + id: "move-down", + label: "Move down", + icon: , + disabled: + activeCurationIndex < 0 || + activeCurationIndex >= curations.length - 1 || + isCurationReorderPending, + onSelect: () => moveActiveCuration("next"), + }, + ] + : []), { id: "edit", label: "Edit curation", @@ -225,6 +286,7 @@ export default function WaveActiveCurationSection({ aria-label="Active curation options" items={menuItems} menuWidthClassName="tw-w-44" + disabled={activeCurationIsReordering} /> )} @@ -270,6 +332,7 @@ export default function WaveActiveCurationSection({ aria-label="Active curation options" items={menuItems} menuWidthClassName="tw-w-44" + disabled={activeCurationIsReordering} /> )} diff --git a/hooks/waves/useWaveCurationReorderMutation.ts b/hooks/waves/useWaveCurationReorderMutation.ts new file mode 100644 index 0000000000..806a366a67 --- /dev/null +++ b/hooks/waves/useWaveCurationReorderMutation.ts @@ -0,0 +1,201 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAuth } from "@/components/auth/Auth"; +import type { ApiWaveCuration } from "@/generated/models/ApiWaveCuration"; +import type { ApiWaveCurationRequest } from "@/generated/models/ApiWaveCurationRequest"; +import { commonApiPost } from "@/services/api/common-api"; +import { + getWaveCurationsQueryKey, + sortWaveCurations, +} from "./useWaveCurations"; + +export type WaveCurationMoveDirection = "previous" | "next"; + +interface MoveWaveCurationVariables { + readonly curation: ApiWaveCuration; + readonly targetPriorityOrder: number; +} + +interface MoveWaveCurationContext { + readonly previousCurations?: ApiWaveCuration[] | undefined; +} + +interface MoveCurationParams { + readonly curation: ApiWaveCuration; + readonly direction: WaveCurationMoveDirection; + readonly curations: readonly ApiWaveCuration[]; +} + +const getMovedCurations = ({ + curations, + curationId, + targetPriorityOrder, +}: { + readonly curations: readonly ApiWaveCuration[]; + readonly curationId: string; + readonly targetPriorityOrder: number; +}): ApiWaveCuration[] => { + const orderedCurations = sortWaveCurations(curations); + const currentIndex = orderedCurations.findIndex( + (curation) => curation.id === curationId + ); + + if (currentIndex < 0) { + return orderedCurations; + } + + const targetIndex = Math.max( + 0, + Math.min(targetPriorityOrder - 1, orderedCurations.length - 1) + ); + + if (targetIndex === currentIndex) { + return orderedCurations; + } + + const nextCurations = [...orderedCurations]; + const [movedCuration] = nextCurations.splice(currentIndex, 1); + + if (!movedCuration) { + return orderedCurations; + } + + nextCurations.splice(targetIndex, 0, movedCuration); + + return nextCurations.map((curation, index) => ({ + ...curation, + priority_order: index + 1, + })); +}; + +const getErrorMessage = (error: unknown): string => { + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return "Failed to update curation order."; +}; + +export function useWaveCurationReorderMutation({ + waveId, +}: { + readonly waveId: string; +}) { + const queryClient = useQueryClient(); + const { requestAuth, setToast } = useAuth(); + const queryKey = useMemo(() => getWaveCurationsQueryKey(waveId), [waveId]); + + const mutation = useMutation< + ApiWaveCuration, + Error, + MoveWaveCurationVariables, + MoveWaveCurationContext + >({ + mutationFn: async ({ curation, targetPriorityOrder }) => { + const auth = await requestAuth(); + if (!auth.success) { + throw new Error("Authentication was cancelled."); + } + + const body: ApiWaveCurationRequest = { + name: curation.name, + group_id: curation.group_id, + priority_order: targetPriorityOrder, + }; + + return await commonApiPost({ + endpoint: `waves/${waveId}/curations/${curation.id}`, + body, + errorMode: "structured", + }); + }, + onMutate: async ({ curation, targetPriorityOrder }) => { + await queryClient.cancelQueries({ queryKey }); + + const previousCurations = + queryClient.getQueryData(queryKey); + + queryClient.setQueryData(queryKey, (current) => + current + ? getMovedCurations({ + curations: current, + curationId: curation.id, + targetPriorityOrder, + }) + : current + ); + + return { previousCurations }; + }, + onSuccess: (savedCuration) => { + queryClient.setQueryData(queryKey, (current) => { + if (!current) { + return [savedCuration]; + } + + return sortWaveCurations( + current.map((curation) => + curation.id === savedCuration.id ? savedCuration : curation + ) + ); + }); + + setToast({ + type: "success", + message: "Curation order updated.", + }); + }, + onError: (error, _variables, context) => { + if (context?.previousCurations !== undefined) { + queryClient.setQueryData(queryKey, context.previousCurations); + } + + setToast({ + type: "error", + message: getErrorMessage(error), + }); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey }); + }, + }); + const { isPending, mutate, variables } = mutation; + + const moveCuration = useCallback( + ({ curation, direction, curations }: MoveCurationParams) => { + const orderedCurations = sortWaveCurations(curations); + const currentIndex = orderedCurations.findIndex( + (item) => item.id === curation.id + ); + + if (currentIndex < 0) { + return; + } + + const targetIndex = + direction === "previous" ? currentIndex - 1 : currentIndex + 1; + + if (targetIndex < 0 || targetIndex >= orderedCurations.length) { + return; + } + + mutate({ + curation, + targetPriorityOrder: targetIndex + 1, + }); + }, + [mutate] + ); + + return { + moveCuration, + isPending, + pendingCurationId: isPending ? variables.curation.id : null, + }; +} diff --git a/hooks/waves/useWaveCurations.ts b/hooks/waves/useWaveCurations.ts index 0e150b86b0..8a0f06890a 100644 --- a/hooks/waves/useWaveCurations.ts +++ b/hooks/waves/useWaveCurations.ts @@ -8,6 +8,32 @@ import { useQuery } from "@tanstack/react-query"; export const getWaveCurationsQueryKey = (waveId: string) => [QueryKey.WAVE_CURATIONS, { wave_id: waveId }] as const; +const getPriorityOrder = ( + curation: ApiWaveCuration, + fallbackIndex: number +): number => + Number.isSafeInteger(curation.priority_order) && curation.priority_order > 0 + ? curation.priority_order + : fallbackIndex + 1; + +export const sortWaveCurations = ( + curations: readonly ApiWaveCuration[] +): ApiWaveCuration[] => + curations + .map((curation, index) => ({ + curation, + index, + priorityOrder: getPriorityOrder(curation, index), + })) + .sort( + (left, right) => + left.priorityOrder - right.priorityOrder || + left.curation.created_at - right.curation.created_at || + left.curation.id.localeCompare(right.curation.id) || + left.index - right.index + ) + .map(({ curation }) => curation); + interface UseWaveCurationsProps { readonly waveId: string; readonly enabled?: boolean | undefined; @@ -23,6 +49,7 @@ export function useWaveCurations({ await commonApiFetch({ endpoint: `waves/${waveId}/curations`, }), + select: sortWaveCurations, enabled: enabled && !!waveId, staleTime: 5 * 60 * 1000, }); diff --git a/openapi.yaml b/openapi.yaml index e265d1f996..57c240b86c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1567,7 +1567,7 @@ paths: responses: "200": description: successful operation - /drops/{dropId}/reactions: + /drops/{dropId}/reaction: post: tags: - Drops @@ -4360,6 +4360,33 @@ paths: application/json: schema: $ref: "#/components/schemas/RedeemedSubscriptionCountsPage" + /subscriptions/redeemed-memes-counts/download: + get: + tags: + - Subscriptions + summary: Download redeemed meme subscription counts as CSV + description: >- + Downloads a CSV with meme_id, artist, drop_date, unique_profiles, + subscriptions_count, and proceeds for MEMES cards with id >= 220. When + `szn` is provided, only cards in that season are included. + operationId: downloadRedeemedMemeSubscriptionCounts + parameters: + - name: szn + in: query + description: Optional MEMES season filter + required: false + schema: + type: integer + format: int64 + minimum: 1 + responses: + "200": + description: successful operation + content: + text/csv: + schema: + type: string + format: binary /subscriptions/consolidation/logs/{consolidation_key}: get: tags: From f8c7a1dce5b2bc81fd4c96bedba92e0e8eda6142 Mon Sep 17 00:00:00 2001 From: ragnep Date: Mon, 20 Apr 2026 14:19:48 +0300 Subject: [PATCH 2/8] wip Signed-off-by: ragnep --- .../my-stream/MyStreamWaveDesktopTabs.tsx | 462 ++++++++++++++---- .../tabs/MyStreamWaveCurationTabMenu.tsx | 71 ++- components/common/TabToggle.tsx | 11 +- components/user/waves/UserPageProfileWave.tsx | 304 +++++++++++- .../user/waves/UserPageProfileWaveShared.tsx | 85 +++- .../user/waves/userPageProfileWave.helpers.ts | 47 +- .../curation/WaveActiveCurationSection.tsx | 11 - hooks/useProfileWave.ts | 40 ++ hooks/useProfileWaveMutation.ts | 31 +- hooks/waves/useWaveCurationReorderMutation.ts | 34 +- package.json | 3 + pnpm-lock.yaml | 56 +++ services/api/profile-wave-api.ts | 42 +- 13 files changed, 982 insertions(+), 215 deletions(-) create mode 100644 hooks/useProfileWave.ts diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx index 4a24b5bb92..9736a7d32e 100644 --- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx +++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx @@ -2,10 +2,25 @@ import React, { useEffect, useMemo, useRef } from "react"; import { - ArrowLeftIcon, - ArrowRightIcon, EllipsisVerticalIcon, + UserCircleIcon, } from "@heroicons/react/24/outline"; +import { + closestCenter, + DndContext, + KeyboardSensor, + PointerSensor, + type DragEndEvent, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { CompactMenu, type CompactMenuItem } from "@/components/compact-menu"; import { TabToggle } from "@/components/common/TabToggle"; import { useSearchParams } from "next/navigation"; @@ -13,6 +28,7 @@ import type { ApiWave } from "@/generated/models/ApiWave"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; import { useWaveCurations } from "@/hooks/waves/useWaveCurations"; import { useWaveCurationReorderMutation } from "@/hooks/waves/useWaveCurationReorderMutation"; +import { useProfileWave } from "@/hooks/useProfileWave"; import { useWave } from "@/hooks/useWave"; import { useDecisionPoints } from "@/hooks/waves/useDecisionPoints"; import { useWaveTimers } from "@/hooks/useWaveTimers"; @@ -24,6 +40,7 @@ import { WaveVotingState, type SetActiveContentTab, } from "../ContentTabContext"; +import MyStreamActionTooltip from "./MyStreamActionTooltip"; import MyStreamWaveCreateCurationAction from "./tabs/MyStreamWaveCreateCurationAction"; import MyStreamWaveCurationTabMenu from "./tabs/MyStreamWaveCurationTabMenu"; @@ -40,15 +57,73 @@ interface TabOption { readonly key: string; readonly label: string; readonly panelId: string; + readonly leadingIcon?: React.ReactNode | undefined; + readonly leadingIconTooltipId?: string | undefined; + readonly hasIndicator?: boolean | undefined; readonly action?: React.ReactNode | undefined; } +interface ProfileLookupSource { + readonly query?: string | null | undefined; + readonly handle?: string | null | undefined; + readonly primary_wallet?: string | null | undefined; + readonly primary_address?: string | null | undefined; + readonly id?: string | null | undefined; +} + const getContentTabPanelId = (tab: MyStreamWaveTab): string => `my-stream-wave-tabpanel-${tab.toLowerCase()}`; const getCurationPanelId = (curationId: string): string => `my-stream-wave-tabpanel-curation-${curationId}`; +const getCurationTabKey = (curationId: string): string => + `curation:${curationId}`; + +const getCurationIdFromTabKey = (key: string): string => + key.replace("curation:", ""); + +const getProfileCurationTooltipId = (curationId: string): string => + `my-stream-profile-curation-${curationId}`; + +const getProfileLookupKey = ( + profile: ProfileLookupSource | null | undefined +): string | null => { + const identity = + profile?.query ?? + profile?.handle ?? + profile?.primary_wallet ?? + profile?.primary_address ?? + profile?.id ?? + null; + + const normalizedIdentity = identity?.trim() ?? ""; + return normalizedIdentity.length > 0 ? normalizedIdentity : null; +}; + +const getEffectiveProfileCurationId = ({ + curations, + isProfileWave, + profileCurationId, +}: { + readonly curations: readonly { id: string }[]; + readonly isProfileWave: boolean; + readonly profileCurationId: string | null | undefined; +}): string | null => { + if (!isProfileWave) { + return null; + } + + if ( + profileCurationId && + curations.some((curation) => curation.id === profileCurationId) + ) { + return profileCurationId; + } + + return curations[0]?.id ?? null; +}; + const AUTO_EXPAND_LIMIT = 5; const MOBILE_INLINE_CURATION_LIMIT = 1; @@ -81,6 +156,173 @@ const getWaveVotingState = ({ return WaveVotingState.ONGOING; }; +interface DesktopTabButtonProps { + readonly option: TabOption; + readonly activeKey: string; + readonly onSelect: (key: string) => void; + readonly fullWidth?: boolean | undefined; +} + +function DesktopTabButton({ + option, + activeKey, + onSelect, + fullWidth = false, +}: DesktopTabButtonProps) { + return ( + + ); +} + +function ProfileCurationIcon({ tooltipId }: { readonly tooltipId: string }) { + return ( + + + ); +} + +function ReorderHandleIcon({ + className, +}: { + readonly className?: string | undefined; +}) { + return ( + + ); +} + +function DesktopTabOption({ + option, + activeKey, + onSelect, +}: DesktopTabButtonProps) { + return ( +
+ + {option.leadingIconTooltipId !== undefined && ( + + )} + {option.action !== undefined && option.action !== null && ( +
+ {option.action} +
+ )} +
+ ); +} + +function SortableCurationTabOption({ + option, + activeKey, + isSortingDisabled, + onSelect, +}: DesktopTabButtonProps & { + readonly isSortingDisabled: boolean; +}) { + const { + attributes, + isDragging, + listeners, + setActivatorNodeRef, + setNodeRef, + transform, + transition, + } = useSortable({ + id: option.key, + disabled: isSortingDisabled, + }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 10 : undefined, + opacity: isDragging ? 0.85 : undefined, + }; + const reorderTooltipId = `${option.key}-reorder-tooltip`; + + return ( +
+ + + + {option.leadingIconTooltipId !== undefined && ( + + )} + {option.action !== undefined && option.action !== null && ( +
+ {option.action} +
+ )} +
+ ); +} + const MyStreamWaveDesktopTabs: React.FC = ({ activeTab, wave, @@ -92,7 +334,7 @@ const MyStreamWaveDesktopTabs: React.FC = ({ const searchParams = useSearchParams(); const { availableTabs, updateAvailableTabs, setActiveContentTab } = useContentTab(); - const { connectedProfile } = useAuth(); + const { activeProfileProxy, connectedProfile } = useAuth(); const hasAuthenticatedProfile = Boolean(connectedProfile?.handle); const { isChatWave, @@ -114,10 +356,28 @@ const MyStreamWaveDesktopTabs: React.FC = ({ const { data: curations = [] } = useWaveCurations({ waveId: wave.id, }); - const { moveCuration, isPending: isCurationReorderPending } = + const isConnectedProfileWaveAuthor = connectedProfile?.id === wave.author.id; + const profileWaveIdentity = getProfileLookupKey( + isConnectedProfileWaveAuthor ? connectedProfile : wave.author + ); + const { data: profileWave } = useProfileWave({ + identity: profileWaveIdentity, + enabled: profileWaveIdentity !== null && curations.length > 0, + }); + const { reorderCuration, isPending: isCurationReorderPending } = useWaveCurationReorderMutation({ waveId: wave.id }); const canManageCurations = wave.wave.authenticated_user_eligible_for_admin === true; + const isProfileWave = profileWave?.profile_wave_id === wave.id; + const profileCurationId = getEffectiveProfileCurationId({ + curations, + isProfileWave, + profileCurationId: profileWave?.profile_curation_id, + }); + const canSetProfileCuration = + isProfileWave && + isConnectedProfileWaveAuthor && + activeProfileProxy === null; const filteredDecisions = useMemo(() => { const decisionsAsApiFormat = allDecisions.map((decision) => ({ @@ -138,6 +398,16 @@ const MyStreamWaveDesktopTabs: React.FC = ({ const autoExpandFutureAttemptsRef = useRef(0); const desktopTabsScrollerRef = useRef(null); const mobileTabsScrollerRef = useRef(null); + const sortableSensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 6, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); useEffect(() => { const hasUpcoming = typeof nextDecisionTime === "number"; @@ -221,23 +491,26 @@ const MyStreamWaveDesktopTabs: React.FC = ({ const curationOptions: TabOption[] = useMemo( () => - curations.map((curation, index) => ({ - key: `curation:${curation.id}`, + curations.map((curation) => ({ + key: getCurationTabKey(curation.id), label: curation.name, panelId: getCurationPanelId(curation.id), + leadingIcon: + curation.id === profileCurationId ? ( + + ) : undefined, + leadingIconTooltipId: + curation.id === profileCurationId + ? getProfileCurationTooltipId(curation.id) + : undefined, action: canManageCurations ? ( 0} - canMoveNext={index < curations.length - 1} - isMovePending={isCurationReorderPending} - onMove={(direction) => - moveCuration({ - curation, - direction, - curations, - }) + canSetAsProfileCuration={ + canSetProfileCuration && curation.id !== profileCurationId } onDeleted={ activeCurationId === curation.id @@ -250,10 +523,10 @@ const MyStreamWaveDesktopTabs: React.FC = ({ [ activeCurationId, canManageCurations, + canSetProfileCuration, curations, - isCurationReorderPending, - moveCuration, onSelectCuration, + profileCurationId, wave, ] ); @@ -264,43 +537,31 @@ const MyStreamWaveDesktopTabs: React.FC = ({ ); const activeKey = activeCurationId - ? `curation:${activeCurationId}` + ? getCurationTabKey(activeCurationId) : activeTab; - const activeCuration = useMemo( - () => - activeCurationId - ? (curations.find((curation) => curation.id === activeCurationId) ?? - null) - : null, - [activeCurationId, curations] + const curationTabKeys = useMemo( + () => curations.map((curation) => getCurationTabKey(curation.id)), + [curations] ); - const activeCurationIndex = useMemo( - () => - activeCurationId - ? curations.findIndex((curation) => curation.id === activeCurationId) - : -1, - [activeCurationId, curations] - ); - const canMoveActiveCuration = - canManageCurations && activeCuration !== null && curations.length > 1; - const showCurationActions = canMoveActiveCuration || showCreateCurationAction; - const moveLeftDisabled = - activeCuration === null || - activeCurationIndex <= 0 || - isCurationReorderPending; - const moveRightDisabled = - activeCuration === null || - activeCurationIndex < 0 || - activeCurationIndex >= curations.length - 1 || - isCurationReorderPending; - const moveActiveCuration = (direction: "previous" | "next") => { - if (activeCuration === null) { + const canDragCurations = canManageCurations && curations.length > 1; + const handleCurationDragEnd = ({ active, over }: DragEndEvent) => { + if (over === null || active.id === over.id) { + return; + } + + const curationId = getCurationIdFromTabKey(String(active.id)); + const targetIndex = curations.findIndex( + (curation) => getCurationTabKey(curation.id) === over.id + ); + const curation = curations.find((item) => item.id === curationId) ?? null; + + if (curation === null || targetIndex < 0) { return; } - moveCuration({ - curation: activeCuration, - direction, + reorderCuration({ + curation, + targetPriorityOrder: targetIndex + 1, curations, }); }; @@ -347,7 +608,8 @@ const MyStreamWaveDesktopTabs: React.FC = ({ mobileOverflowCurationOptions.map((option) => ({ id: option.key, label: option.label, - onSelect: () => onSelectCuration(option.key.replace("curation:", "")), + icon: option.leadingIcon, + onSelect: () => onSelectCuration(getCurationIdFromTabKey(option.key)), })), [mobileOverflowCurationOptions, onSelectCuration] ); @@ -393,7 +655,7 @@ const MyStreamWaveDesktopTabs: React.FC = ({ activeKey={activeKey} onSelect={(key) => { if (key.startsWith("curation:")) { - onSelectCuration(key.replace("curation:", "")); + onSelectCuration(getCurationIdFromTabKey(key)); return; } @@ -417,55 +679,59 @@ const MyStreamWaveDesktopTabs: React.FC = ({ ref={desktopTabsScrollerRef} className="tw-hidden tw-min-w-0 tw-flex-1 tw-overflow-x-auto tw-scrollbar-thin tw-scrollbar-track-iron-800 tw-scrollbar-thumb-iron-500 hover:tw-scrollbar-thumb-iron-300 sm:tw-block" > - { - if (key.startsWith("curation:")) { - onSelectCuration(key.replace("curation:", "")); - return; - } - - onSelectCuration(null); - setActiveTab(key as MyStreamWaveTab); - }} - /> +
+ {standardOptions.map((option) => ( + { + onSelectCuration(null); + setActiveTab(key as MyStreamWaveTab); + }} + /> + ))} + + + {curationOptions.map((option) => ( + + {canDragCurations ? ( + + onSelectCuration(getCurationIdFromTabKey(key)) + } + /> + ) : ( + + onSelectCuration(getCurationIdFromTabKey(key)) + } + /> + )} + + ))} + + +
- {showCurationActions && ( + {showCreateCurationAction && (
- {canMoveActiveCuration && ( -
- - -
- )} - {showCreateCurationAction && ( - - )} +
)} diff --git a/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx b/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx index 41a927c600..f002fb0379 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx @@ -2,11 +2,7 @@ import { useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowLeftIcon, - ArrowRightIcon, - EllipsisVerticalIcon, -} from "@heroicons/react/24/outline"; +import { EllipsisVerticalIcon } from "@heroicons/react/24/outline"; import { CompactMenu, type CompactMenuItem } from "@/components/compact-menu"; import { useAuth } from "@/components/auth/Auth"; import CommonConfirmationModal from "@/components/utils/modal/CommonConfirmationModal"; @@ -14,7 +10,7 @@ import type { ApiWave } from "@/generated/models/ApiWave"; import type { ApiWaveCuration } from "@/generated/models/ApiWaveCuration"; import type { DropCurationMembership } from "@/hooks/drops/useDropCurations"; import { getWaveCurationsQueryKey } from "@/hooks/waves/useWaveCurations"; -import type { WaveCurationMoveDirection } from "@/hooks/waves/useWaveCurationReorderMutation"; +import { useProfileWaveMutation } from "@/hooks/useProfileWaveMutation"; import { commonApiDelete } from "@/services/api/common-api"; import MyStreamWaveCurationCreateDialog from "./MyStreamWaveCurationCreateDialog"; @@ -22,12 +18,8 @@ interface MyStreamWaveCurationTabMenuProps { readonly wave: ApiWave; readonly curation: ApiWaveCuration; readonly onDeleted?: (() => void) | undefined; - readonly onMove?: - | ((direction: WaveCurationMoveDirection) => void) - | undefined; - readonly canMovePrevious?: boolean | undefined; - readonly canMoveNext?: boolean | undefined; - readonly isMovePending?: boolean | undefined; + readonly canSetAsProfileCuration?: boolean | undefined; + readonly isSetAsProfileCurationPending?: boolean | undefined; } const getErrorMessage = (error: unknown): string => @@ -37,15 +29,17 @@ export default function MyStreamWaveCurationTabMenu({ wave, curation, onDeleted, - onMove, - canMovePrevious = false, - canMoveNext = false, - isMovePending = false, + canSetAsProfileCuration = false, + isSetAsProfileCurationPending = false, }: MyStreamWaveCurationTabMenuProps) { const queryClient = useQueryClient(); - const { requestAuth, setToast } = useAuth(); + const { connectedProfile, requestAuth, setToast } = useAuth(); + const { updateProfileWave, isPending: isProfileWavePending } = + useProfileWaveMutation(connectedProfile); const [isEditOpen, setIsEditOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const isSettingProfileCuration = + isSetAsProfileCurationPending || isProfileWavePending; const deleteMutation = useMutation({ mutationFn: async () => { @@ -88,33 +82,24 @@ export default function MyStreamWaveCurationTabMenu({ }, }); - const moveItems: CompactMenuItem[] = - onMove === undefined - ? [] - : [ - { - id: "move-left", - label: "Move left", - icon: , - onSelect: () => onMove("previous"), - disabled: isMovePending || !canMovePrevious, - }, - { - id: "move-right", - label: "Move right", - icon: , - onSelect: () => onMove("next"), - disabled: isMovePending || !canMoveNext, - }, - ]; - const menuItems: CompactMenuItem[] = [ - ...moveItems, { id: "edit", label: "Edit curation", onSelect: () => setIsEditOpen(true), }, + ...(canSetAsProfileCuration + ? [ + { + id: "set-profile-curation", + label: "Set as profile curation", + onSelect: () => { + void updateProfileWave(wave.id, curation.id); + }, + disabled: isSettingProfileCuration, + }, + ] + : []), { id: "delete", label: "Delete curation", @@ -126,12 +111,14 @@ export default function MyStreamWaveCurationTabMenu({ return ( <> } + triggerClassName="tw-mx-0.5 tw-flex tw-h-8 tw-w-6 tw-flex-shrink-0 tw-items-center tw-justify-center tw-rounded-lg tw-border-0 tw-bg-transparent tw-text-iron-500 tw-transition hover:tw-bg-iron-900 hover:tw-text-iron-200" + trigger={ + + } aria-label={`${curation.name} curation options`} items={menuItems} - menuWidthClassName="tw-w-44" - disabled={deleteMutation.isPending} + menuWidthClassName="tw-w-52" + disabled={deleteMutation.isPending || isSettingProfileCuration} /> {isEditOpen && ( diff --git a/components/common/TabToggle.tsx b/components/common/TabToggle.tsx index a27e62cc24..f6405a4e06 100644 --- a/components/common/TabToggle.tsx +++ b/components/common/TabToggle.tsx @@ -3,6 +3,7 @@ import React from "react"; interface TabOption { readonly key: string; readonly label: string; + readonly leadingIcon?: React.ReactNode | undefined; readonly hasIndicator?: boolean | undefined; readonly panelId: string; readonly action?: React.ReactNode | undefined; @@ -48,7 +49,10 @@ export const TabToggle: React.FC = ({ : "tw-border-transparent tw-text-iron-500 desktop-hover:hover:tw-text-iron-200" }`} > - {option.label} + + {option.leadingIcon} + {option.label} + {option.hasIndicator && (
)} @@ -84,7 +88,10 @@ export const TabToggle: React.FC = ({ : "tw-border-transparent tw-text-iron-500 desktop-hover:hover:tw-text-iron-200" }`} > - {option.label} + + {option.leadingIcon} + {option.label} + {option.hasIndicator && (
)} diff --git a/components/user/waves/UserPageProfileWave.tsx b/components/user/waves/UserPageProfileWave.tsx index 6b7019e28a..b44f3e6c4d 100644 --- a/components/user/waves/UserPageProfileWave.tsx +++ b/components/user/waves/UserPageProfileWave.tsx @@ -1,13 +1,15 @@ "use client"; import { useParams, useRouter } from "next/navigation"; -import { useRef, useState } from "react"; +import { type ReactNode, useMemo, useRef, useState } from "react"; import { useAuth } from "@/components/auth/Auth"; import MobileWrapperDialog from "@/components/mobile-wrapper-dialog/MobileWrapperDialog"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; +import type { ApiWaveCuration } from "@/generated/models/ApiWaveCuration"; import { isOwnProfileRoute } from "@/helpers/ProfileHelpers"; import { useIdentity } from "@/hooks/useIdentity"; import { useMediaQuery } from "@/hooks/useMediaQuery"; +import { useProfileWave } from "@/hooks/useProfileWave"; import { useProfileWaveMutation } from "@/hooks/useProfileWaveMutation"; import { useWaveById } from "@/hooks/useWaveById"; import { useWaveCurations } from "@/hooks/waves/useWaveCurations"; @@ -20,6 +22,7 @@ import { } from "./UserPageProfileWaveMasonry"; import { getOfficialWaveMetadataLabel, + getProfileCurationTitle, getProfileIdentityKey, getWaveHref, isUnavailableWaveError, @@ -32,7 +35,190 @@ import { RetryButton, } from "./UserPageProfileWaveShared"; import CircleLoader from "@/components/distribution-plan-tool/common/CircleLoader"; -import { XMarkIcon } from "@heroicons/react/24/outline"; +import type { ApiProfileWaveResponse } from "@/services/api/profile-wave-api"; +import { CheckCircleIcon, XMarkIcon } from "@heroicons/react/24/outline"; + +type CurationPickerVariant = "dropdown" | "mobile-sheet"; + +type ProfileCurationPickerProps = { + readonly curations: readonly ApiWaveCuration[]; + readonly selectedCurationId: string | null; + readonly submittingCurationId: string | null; + readonly isLoading: boolean; + readonly isError: boolean; + readonly isFetching: boolean; + readonly onRetry: () => void; + readonly onSelectCuration: (curationId: string) => void; + readonly variant: CurationPickerVariant; +}; + +function ProfileCurationPicker({ + curations, + selectedCurationId, + submittingCurationId, + isLoading, + isError, + isFetching, + onRetry, + onSelectCuration, + variant, +}: ProfileCurationPickerProps) { + const isMobileSheet = variant === "mobile-sheet"; + const wrapperClassName = isMobileSheet + ? "tw-px-4 sm:tw-px-6" + : "tw-w-full tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-white/10 tw-bg-iron-950 tw-py-2 tw-shadow-2xl"; + const rowPadding = isMobileSheet ? "tw-px-4 tw-py-3" : "tw-px-3 tw-py-2.5"; + const isAnySubmitting = submittingCurationId !== null; + + const renderRow = ({ + id, + label, + isSelected, + isSubmitting, + onClick, + }: { + readonly id: string; + readonly label: string; + readonly isSelected: boolean; + readonly isSubmitting: boolean; + readonly onClick: () => void; + }) => { + let trailingContent: ReactNode = null; + if (isSubmitting) { + trailingContent = ; + } else if (isSelected) { + trailingContent = ( + + ); + } + + return ( + + ); + }; + + let content: ReactNode; + if (isLoading) { + content = ( +
+ + Loading curations... +
+ ); + } else if (isError) { + content = ( +
+ Unable to load curations. + +
+ ); + } else if (curations.length === 0) { + content = ( +

+ This wave has no curations yet. +

+ ); + } else { + content = ( + <> + {curations.map((curation) => + renderRow({ + id: curation.id, + label: curation.name, + isSelected: selectedCurationId === curation.id, + isSubmitting: submittingCurationId === curation.id, + onClick: () => onSelectCuration(curation.id), + }) + )} + + ); + } + + return ( +
+
+ {!isMobileSheet && ( +

+ Profile curation +

+ )} + {content} +
+
+ ); +} + +function ProfileCurationPickerPanel({ + show, + ...pickerProps +}: ProfileCurationPickerProps & { + readonly show: boolean; +}) { + if (!show) { + return null; + } + + return ; +} + +function ProfileCurationMobileDialog({ + show, + isOpen, + onClose, + ...pickerProps +}: Omit & { + readonly show: boolean; + readonly isOpen: boolean; + readonly onClose: () => void; +}) { + if (!show) { + return null; + } + + return ( + + + + ); +} + +const isDesktopCurationPickerOpen = ({ + canSwitchOfficialCuration, + isDesktopChangeWaveMenu, + isChangeCurationOpen, +}: { + readonly canSwitchOfficialCuration: boolean; + readonly isDesktopChangeWaveMenu: boolean; + readonly isChangeCurationOpen: boolean; +}): boolean => + canSwitchOfficialCuration && isDesktopChangeWaveMenu && isChangeCurationOpen; + +const shouldRenderMobileCurationPicker = ({ + canSwitchOfficialCuration, + isDesktopChangeWaveMenu, +}: { + readonly canSwitchOfficialCuration: boolean; + readonly isDesktopChangeWaveMenu: boolean; +}): boolean => canSwitchOfficialCuration && !isDesktopChangeWaveMenu; export default function UserPageProfileWave({ profile: initialProfile, @@ -49,20 +235,38 @@ export default function UserPageProfileWave({ initialProfile, }); const changeWaveDropdownRef = useRef(null); + const changeCurationDropdownRef = useRef(null); const changeWaveButtonRef = useRef(null); + const changeCurationButtonRef = useRef(null); const [isChangeWaveOpen, setIsChangeWaveOpen] = useState(false); + const [isChangeCurationOpen, setIsChangeCurationOpen] = useState(false); const [submittingWaveId, setSubmittingWaveId] = useState(null); + const [submittingCurationId, setSubmittingCurationId] = useState< + string | null + >(null); const isDesktopChangeWaveMenu = useMediaQuery("(min-width: 1024px)"); const resolvedProfile = profile ?? initialProfile; const profileIdentityKey = getProfileIdentityKey(resolvedProfile) ?? handleOrWallet; + const initialProfileWave = useMemo( + () => ({ + profile_wave_id: resolvedProfile.profile_wave_id, + profile_curation_id: null, + }), + [resolvedProfile.profile_wave_id] + ); + const { data: profileWave } = useProfileWave({ + identity: profileIdentityKey, + initialProfileWave, + }); const isOwnProfile = isOwnProfileRoute({ connectedProfile, handleOrWallet, }); const canManageOwnOfficialWave = isOwnProfile && !activeProfileProxy; - const profileWaveId = resolvedProfile.profile_wave_id; + const profileWaveId = profileWave?.profile_wave_id ?? null; + const profileCurationId = profileWave?.profile_curation_id ?? null; const profileIdentityForMasonry: ProfileIdentitySummary = { id: resolvedProfile.id, handle: resolvedProfile.handle, @@ -88,9 +292,23 @@ export default function UserPageProfileWave({ const { wave, isLoading, isError, error, refetch, isFetching } = useWaveById(profileWaveId); - const profileCuration = resolveProfileCuration(curations ?? []); - const waveHref = wave ? getWaveHref(wave) : null; + const profileCuration = resolveProfileCuration( + curations ?? [], + profileCurationId + ); + const waveHref = wave ? getWaveHref(wave, profileCuration?.id ?? null) : null; const hasLoadedCurations = curations !== undefined; + const canSwitchOfficialCuration = + canManageOwnOfficialWave && (curations?.length ?? 0) > 0; + const showDesktopCurationPicker = isDesktopCurationPickerOpen({ + canSwitchOfficialCuration, + isDesktopChangeWaveMenu, + isChangeCurationOpen, + }); + const showMobileCurationPicker = shouldRenderMobileCurationPicker({ + canSwitchOfficialCuration, + isDesktopChangeWaveMenu, + }); useClickAway(changeWaveDropdownRef, () => { if (isDesktopChangeWaveMenu && isChangeWaveOpen) { @@ -98,10 +316,19 @@ export default function UserPageProfileWave({ } }); + useClickAway(changeCurationDropdownRef, () => { + if (isDesktopChangeWaveMenu && isChangeCurationOpen) { + setIsChangeCurationOpen(false); + } + }); + useKeyPressEvent("Escape", () => { if (isChangeWaveOpen) { setIsChangeWaveOpen(false); } + if (isChangeCurationOpen) { + setIsChangeCurationOpen(false); + } }); const openWave = () => { @@ -135,6 +362,7 @@ export default function UserPageProfileWave({ const updatedProfile = await updateProfileWave(waveId); if (updatedProfile) { setIsChangeWaveOpen(false); + setIsChangeCurationOpen(false); } } finally { setSubmittingWaveId(null); @@ -199,16 +427,28 @@ export default function UserPageProfileWave({ ); } + const selectOfficialCuration = async (curationId: string) => { + setSubmittingCurationId(curationId); + + try { + const updatedProfile = await updateProfileWave(profileWaveId, curationId); + if (updatedProfile) { + setIsChangeCurationOpen(false); + } + } finally { + setSubmittingCurationId(null); + } + }; + return (
) : undefined } + changeCurationDropdown={ + + selectOfficialCuration(curationId) + } + variant="dropdown" + /> + } changeWaveDropdownRef={changeWaveDropdownRef} + changeCurationDropdownRef={changeCurationDropdownRef} changeWaveButtonRef={changeWaveButtonRef} + changeCurationButtonRef={changeCurationButtonRef} isChangeWaveOpen={isChangeWaveOpen} + isChangeCurationOpen={isChangeCurationOpen} isRemoving={isPending && pendingAction === "clear"} + isChangingCuration={submittingCurationId !== null} + showChangeCuration={canSwitchOfficialCuration} onOpenWave={openWave} - onOpenChangeWave={() => setIsChangeWaveOpen((open) => !open)} + onOpenChangeWave={() => { + setIsChangeCurationOpen(false); + setIsChangeWaveOpen((open) => !open); + }} + onOpenChangeCuration={() => { + setIsChangeWaveOpen(false); + setIsChangeCurationOpen((open) => !open); + }} onRemoveWave={handleRemoveOfficialWave} />
@@ -253,6 +521,20 @@ export default function UserPageProfileWave({ )} + setIsChangeCurationOpen(false)} + curations={curations ?? []} + selectedCurationId={profileCuration?.id ?? null} + submittingCurationId={submittingCurationId} + isLoading={areCurationsLoading} + isError={areCurationsError} + isFetching={areCurationsFetching} + onRetry={retryCurationsLoad} + onSelectCuration={(curationId) => selectOfficialCuration(curationId)} + /> +
; + readonly changeCurationDropdownRef?: RefObject; readonly changeWaveButtonRef?: RefObject; + readonly changeCurationButtonRef?: RefObject; readonly isChangeWaveOpen: boolean; + readonly isChangeCurationOpen?: boolean | undefined; readonly isRemoving: boolean; + readonly isChangingCuration?: boolean | undefined; + readonly showChangeCuration?: boolean | undefined; readonly onOpenWave: () => void; readonly onOpenChangeWave: () => void; + readonly onOpenChangeCuration?: (() => void) | undefined; readonly onRemoveWave: () => void; }) { - const changeWaveDropdownId = changeWaveDropdown - ? "change-wave-dropdown" - : undefined; + const changeWaveDropdownId = + changeWaveDropdown !== undefined ? "change-wave-dropdown" : undefined; + const changeCurationDropdownId = + changeCurationDropdown !== undefined + ? "change-curation-dropdown" + : undefined; return (
@@ -174,9 +193,22 @@ export function OfficialWaveSummary({
-

- {metadataLabel} -

+
+ {metadataLabel} + {profileCurationLabel && ( + <> + + + + Curation: + + + {profileCurationLabel} + + + + )} +
{canManageOwnOfficialWave && ( @@ -217,6 +249,47 @@ export function OfficialWaveSummary({
)} + {showChangeCuration && onOpenChangeCuration !== undefined && ( + <> +
+
+ + + {changeCurationDropdownId && ( +
+ {changeCurationDropdown} +
+ )} +
+ + )}
- - {canManageCurations && ( - } - aria-label="Active curation options" - items={menuItems} - menuWidthClassName="tw-w-44" - disabled={activeCurationIsReordering} - /> - )}
diff --git a/hooks/useProfileWave.ts b/hooks/useProfileWave.ts new file mode 100644 index 0000000000..99b6a9578a --- /dev/null +++ b/hooks/useProfileWave.ts @@ -0,0 +1,40 @@ +"use client"; + +import { + getProfileWave, + type ApiProfileWaveResponse, +} from "@/services/api/profile-wave-api"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; + +export const getProfileWaveQueryKey = (identity: string) => + ["profile-wave", identity.toLowerCase()] as const; + +export function useProfileWave({ + identity, + initialProfileWave, + enabled = true, +}: { + readonly identity: string | null | undefined; + readonly initialProfileWave?: ApiProfileWaveResponse | undefined; + readonly enabled?: boolean | undefined; +}) { + const normalizedIdentity = useMemo( + () => identity?.trim().toLowerCase() ?? "", + [identity] + ); + + return useQuery({ + queryKey: getProfileWaveQueryKey(normalizedIdentity), + queryFn: async ({ signal }) => + await getProfileWave({ + identity: normalizedIdentity, + signal, + }), + enabled: enabled && normalizedIdentity.length > 0, + staleTime: 60 * 1000, + ...(initialProfileWave !== undefined + ? { initialData: initialProfileWave, initialDataUpdatedAt: 0 } + : {}), + }); +} diff --git a/hooks/useProfileWaveMutation.ts b/hooks/useProfileWaveMutation.ts index 620d5e98d3..fa07ffffe8 100644 --- a/hooks/useProfileWaveMutation.ts +++ b/hooks/useProfileWaveMutation.ts @@ -4,14 +4,20 @@ import { useAuth } from "@/components/auth/Auth"; import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; import { + type ApiProfileWaveResponse, clearProfileWave, setProfileWave, } from "@/services/api/profile-wave-api"; -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useContext } from "react"; +import { getProfileWaveQueryKey } from "./useProfileWave"; type ProfileWaveAction = - | { readonly type: "set"; readonly waveId: string } + | { + readonly type: "set"; + readonly waveId: string; + readonly profileCurationId?: string | null | undefined; + } | { readonly type: "clear" }; const getProfileIdentityKey = (profile: ApiIdentity | null): string | null => @@ -22,6 +28,7 @@ const getProfileIdentityKey = (profile: ApiIdentity | null): string | null => null; export function useProfileWaveMutation(profile: ApiIdentity | null) { + const queryClient = useQueryClient(); const { requestAuth, setToast } = useAuth(); const { onProfileEdit } = useContext(ReactQueryWrapperContext); @@ -36,6 +43,7 @@ export function useProfileWaveMutation(profile: ApiIdentity | null) { return await setProfileWave({ identity, waveId: action.waveId, + profileCurationId: action.profileCurationId, }); } @@ -46,6 +54,19 @@ export function useProfileWaveMutation(profile: ApiIdentity | null) { profile: updatedProfile, previousProfile: profile, }); + const identity = getProfileIdentityKey(profile); + if (identity) { + const queryKey = getProfileWaveQueryKey(identity); + queryClient.setQueryData(queryKey, { + profile_wave_id: + action.type === "set" + ? action.waveId + : updatedProfile.profile_wave_id, + profile_curation_id: + action.type === "set" ? (action.profileCurationId ?? null) : null, + }); + void queryClient.invalidateQueries({ queryKey }); + } setToast({ message: action.type === "set" @@ -85,10 +106,14 @@ export function useProfileWaveMutation(profile: ApiIdentity | null) { } }; - const updateProfileWave = async (waveId: string) => + const updateProfileWave = async ( + waveId: string, + profileCurationId?: string | null + ) => await runProfileWaveMutation({ type: "set", waveId, + profileCurationId, }); const clearSelectedProfileWave = async () => diff --git a/hooks/waves/useWaveCurationReorderMutation.ts b/hooks/waves/useWaveCurationReorderMutation.ts index 806a366a67..940f025e84 100644 --- a/hooks/waves/useWaveCurationReorderMutation.ts +++ b/hooks/waves/useWaveCurationReorderMutation.ts @@ -11,7 +11,7 @@ import { sortWaveCurations, } from "./useWaveCurations"; -export type WaveCurationMoveDirection = "previous" | "next"; +type WaveCurationMoveDirection = "previous" | "next"; interface MoveWaveCurationVariables { readonly curation: ApiWaveCuration; @@ -28,6 +28,12 @@ interface MoveCurationParams { readonly curations: readonly ApiWaveCuration[]; } +interface ReorderCurationParams { + readonly curation: ApiWaveCuration; + readonly targetPriorityOrder: number; + readonly curations: readonly ApiWaveCuration[]; +} + const getMovedCurations = ({ curations, curationId, @@ -193,8 +199,34 @@ export function useWaveCurationReorderMutation({ [mutate] ); + const reorderCuration = useCallback( + ({ curation, targetPriorityOrder, curations }: ReorderCurationParams) => { + const orderedCurations = sortWaveCurations(curations); + const currentIndex = orderedCurations.findIndex( + (item) => item.id === curation.id + ); + const targetIndex = targetPriorityOrder - 1; + + if ( + currentIndex < 0 || + targetIndex < 0 || + targetIndex >= orderedCurations.length || + targetIndex === currentIndex + ) { + return; + } + + mutate({ + curation, + targetPriorityOrder, + }); + }, + [mutate] + ); + return { moveCuration, + reorderCuration, isPending, pendingCurationId: isPending ? variables.curation.id : null, }; diff --git a/package.json b/package.json index 818d24d779..9fd0008754 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 a3253e5fe1..0d2041987f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,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 @@ -839,6 +848,28 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@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'} @@ -9700,6 +9731,31 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@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/profile-wave-api.ts b/services/api/profile-wave-api.ts index d4c2c38616..7bc264eba2 100644 --- a/services/api/profile-wave-api.ts +++ b/services/api/profile-wave-api.ts @@ -1,8 +1,18 @@ import { publicEnv } from "@/config/env"; import type { ApiIdentity } from "@/generated/models/ApiIdentity"; -import { commonApiPost } from "@/services/api/common-api"; +import { commonApiFetch, commonApiPost } from "@/services/api/common-api"; import { getAuthJwt, getStagingAuth } from "@/services/auth/auth.utils"; +export type ApiProfileWaveResponse = { + readonly profile_wave_id: string | null; + readonly profile_curation_id: string | null; +}; + +type SetProfileWaveRequestBody = { + readonly wave_id: string; + readonly profile_curation_id?: string | null; +}; + const buildProfileWaveUrl = (identity: string): string => `${publicEnv.API_ENDPOINT}/api/profiles/${encodeURIComponent(identity)}/wave`; @@ -54,15 +64,35 @@ const parseProfileResponse = async (response: Response): Promise => export const setProfileWave = async ({ identity, waveId, + profileCurationId, }: { readonly identity: string; readonly waveId: string; -}): Promise => - await commonApiPost<{ wave_id: string }, ApiIdentity>({ + readonly profileCurationId?: string | null | undefined; +}): Promise => { + const body: SetProfileWaveRequestBody = { + wave_id: waveId, + ...(profileCurationId !== undefined + ? { profile_curation_id: profileCurationId } + : {}), + }; + + return await commonApiPost({ + endpoint: `profiles/${encodeURIComponent(identity)}/wave`, + body, + }); +}; + +export const getProfileWave = async ({ + identity, + signal, +}: { + readonly identity: string; + readonly signal?: AbortSignal | undefined; +}): Promise => + await commonApiFetch({ endpoint: `profiles/${encodeURIComponent(identity)}/wave`, - body: { - wave_id: waveId, - }, + signal, }); export const clearProfileWave = async ({ From 318a3eaa52498fe3ebec535f283bb99b3b39d345 Mon Sep 17 00:00:00 2001 From: ragnep Date: Mon, 20 Apr 2026 14:28:24 +0300 Subject: [PATCH 3/8] wip Signed-off-by: ragnep --- .../brain/my-stream/MyStreamWaveDesktopTabs.tsx | 13 ++----------- components/common/TabToggle.tsx | 1 - components/user/waves/UserPageProfileWaveShared.tsx | 8 ++++---- hooks/useProfileWave.ts | 6 +++--- hooks/useProfileWaveMutation.ts | 4 ++-- services/api/profile-wave-api.ts | 10 ++++------ 6 files changed, 15 insertions(+), 27 deletions(-) diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx index 9736a7d32e..cbd9375448 100644 --- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx +++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx @@ -160,14 +160,12 @@ interface DesktopTabButtonProps { readonly option: TabOption; readonly activeKey: string; readonly onSelect: (key: string) => void; - readonly fullWidth?: boolean | undefined; } function DesktopTabButton({ option, activeKey, onSelect, - fullWidth = false, }: DesktopTabButtonProps) { return (
diff --git a/hooks/useProfileWaveMutation.ts b/hooks/useProfileWaveMutation.ts index df4eea73ba..7188f40e36 100644 --- a/hooks/useProfileWaveMutation.ts +++ b/hooks/useProfileWaveMutation.ts @@ -20,13 +20,51 @@ type ProfileWaveAction = } | { readonly type: "clear" }; -const getProfileIdentityKey = (profile: ApiIdentity | null): string | null => +interface ProfileWaveIdentitySource { + readonly query?: string | null | undefined; + readonly handle?: string | null | undefined; + readonly normalised_handle?: string | null | undefined; + readonly primary_wallet?: string | null | undefined; + readonly primary_address?: string | null | undefined; + readonly id?: string | null | undefined; +} + +const getProfileIdentityKey = ( + profile: ProfileWaveIdentitySource | null +): string | null => profile?.query ?? profile?.handle ?? profile?.primary_wallet ?? + profile?.primary_address ?? profile?.id ?? null; +const getProfileIdentityAliases = ( + ...profiles: readonly (ProfileWaveIdentitySource | null | undefined)[] +): string[] => { + const aliases = new Set(); + + for (const profile of profiles) { + const candidates = [ + profile?.query, + profile?.handle, + profile?.normalised_handle, + profile?.primary_wallet, + profile?.primary_address, + profile?.id, + ]; + + for (const candidate of candidates) { + const normalizedCandidate = candidate?.trim().toLowerCase() ?? ""; + if (normalizedCandidate.length > 0) { + aliases.add(normalizedCandidate); + } + } + } + + return [...aliases]; +}; + export function useProfileWaveMutation(profile: ApiIdentity | null) { const queryClient = useQueryClient(); const { requestAuth, setToast } = useAuth(); @@ -49,14 +87,14 @@ export function useProfileWaveMutation(profile: ApiIdentity | null) { return await clearProfileWave({ identity }); }, - onSuccess: async (updatedProfile, action) => { + onSuccess: (updatedProfile, action) => { onProfileEdit({ profile: updatedProfile, previousProfile: profile, }); - const identity = getProfileIdentityKey(profile); - if (identity) { - const queryKey = getProfileWaveQueryKey(identity); + const aliases = getProfileIdentityAliases(profile, updatedProfile); + for (const alias of aliases) { + const queryKey = getProfileWaveQueryKey(alias); queryClient.setQueryData(queryKey, { profile_wave_id: action.type === "set" @@ -65,7 +103,6 @@ export function useProfileWaveMutation(profile: ApiIdentity | null) { profile_curation_id: action.type === "set" ? (action.profileCurationId ?? null) : null, }); - await queryClient.invalidateQueries({ queryKey }); } setToast({ message: From 308b023eb6e406a5338f697ce0595b868372e7e1 Mon Sep 17 00:00:00 2001 From: ragnep Date: Mon, 20 Apr 2026 17:05:40 +0300 Subject: [PATCH 6/8] wip Signed-off-by: ragnep --- .../my-stream/MyStreamWaveDesktopTabs.tsx | 29 +---- .../tabs/MyStreamWaveCurationTabMenu.tsx | 5 + components/common/TabToggle.tsx | 108 ++++++++---------- hooks/useProfileWave.ts | 85 +++++++++++++- hooks/useProfileWaveMutation.ts | 79 +++---------- hooks/waves/useWaveCurationReorderMutation.ts | 5 +- 6 files changed, 159 insertions(+), 152 deletions(-) diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx index 7c09cf60e6..8aabff6c4b 100644 --- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx +++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx @@ -24,7 +24,7 @@ import type { ApiWave } from "@/generated/models/ApiWave"; import { ApiWaveType } from "@/generated/models/ApiWaveType"; import { useWaveCurations } from "@/hooks/waves/useWaveCurations"; import { useWaveCurationReorderMutation } from "@/hooks/waves/useWaveCurationReorderMutation"; -import { useProfileWave } from "@/hooks/useProfileWave"; +import { getProfileWaveIdentity, useProfileWave } from "@/hooks/useProfileWave"; import { useWave } from "@/hooks/useWave"; import { useDecisionPoints } from "@/hooks/waves/useDecisionPoints"; import { useWaveTimers } from "@/hooks/useWaveTimers"; @@ -59,14 +59,6 @@ interface TabOption { readonly action?: React.ReactNode | undefined; } -interface ProfileLookupSource { - readonly query?: string | null | undefined; - readonly handle?: string | null | undefined; - readonly primary_wallet?: string | null | undefined; - readonly primary_address?: string | null | undefined; - readonly id?: string | null | undefined; -} - const getContentTabPanelId = (tab: MyStreamWaveTab): string => `my-stream-wave-tabpanel-${tab.toLowerCase()}`; @@ -82,21 +74,6 @@ const getCurationIdFromTabKey = (key: string): string => const getProfileCurationTooltipId = (curationId: string): string => `my-stream-profile-curation-${curationId}`; -const getProfileLookupKey = ( - profile: ProfileLookupSource | null | undefined -): string | null => { - const identity = - profile?.query ?? - profile?.handle ?? - profile?.primary_wallet ?? - profile?.primary_address ?? - profile?.id ?? - null; - - const normalizedIdentity = identity?.trim() ?? ""; - return normalizedIdentity.length > 0 ? normalizedIdentity : null; -}; - const getEffectiveProfileCurationId = ({ curations, isProfileWave, @@ -343,12 +320,12 @@ const MyStreamWaveDesktopTabs: React.FC = ({ waveId: wave.id, }); const isConnectedProfileWaveAuthor = connectedProfile?.id === wave.author.id; - const profileWaveIdentity = getProfileLookupKey( + const profileWaveIdentity = getProfileWaveIdentity( isConnectedProfileWaveAuthor ? connectedProfile : wave.author ); const { data: profileWave } = useProfileWave({ identity: profileWaveIdentity, - enabled: profileWaveIdentity !== null && curations.length > 0, + enabled: profileWaveIdentity.length > 0 && curations.length > 0, }); const { reorderCuration, isPending: isCurationReorderPending } = useWaveCurationReorderMutation({ waveId: wave.id }); diff --git a/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx b/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx index 432b08b6ef..7924c0e4b6 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx @@ -9,6 +9,7 @@ import CommonConfirmationModal from "@/components/utils/modal/CommonConfirmation import type { ApiWave } from "@/generated/models/ApiWave"; import type { ApiWaveCuration } from "@/generated/models/ApiWaveCuration"; import type { DropCurationMembership } from "@/hooks/drops/useDropCurations"; +import { invalidateProfileWaveQueries } from "@/hooks/useProfileWave"; import { getWaveCurationsQueryKey } from "@/hooks/waves/useWaveCurations"; import { useProfileWaveMutation } from "@/hooks/useProfileWaveMutation"; import { commonApiDelete } from "@/services/api/common-api"; @@ -67,6 +68,10 @@ export default function MyStreamWaveCurationTabMenu({ await queryClient.invalidateQueries({ queryKey: ["drop-curations"], }); + await invalidateProfileWaveQueries(queryClient, [ + connectedProfile, + wave.author, + ]); setToast({ type: "success", message: "Curation deleted.", diff --git a/components/common/TabToggle.tsx b/components/common/TabToggle.tsx index cd435072f4..2151a80a0e 100644 --- a/components/common/TabToggle.tsx +++ b/components/common/TabToggle.tsx @@ -26,38 +26,39 @@ export const TabToggle: React.FC = ({ (option) => option.action !== undefined && option.action !== null ); + const renderTabButton = (option: TabOption, style?: React.CSSProperties) => ( + + ); + if (!hasActions) { return (
- {options.map((option) => ( - - ))} + {options.map((option) => renderTabButton(option))}
); } @@ -65,43 +66,28 @@ export const TabToggle: React.FC = ({ return (
- {options.map((option) => ( -
- - {option.action !== undefined && option.action !== null && ( -
- {option.action} -
- )} -
- ))} + {option.action} +
+ ); + })} ); }; diff --git a/hooks/useProfileWave.ts b/hooks/useProfileWave.ts index db4aec31e4..2ca75f1bc9 100644 --- a/hooks/useProfileWave.ts +++ b/hooks/useProfileWave.ts @@ -4,11 +4,88 @@ import { getProfileWave, type ApiProfileWaveResponse, } from "@/services/api/profile-wave-api"; -import { useQuery } from "@tanstack/react-query"; +import { type QueryClient, useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; -export const getProfileWaveQueryKey = (identity: string) => - ["profile-wave", identity.toLowerCase()] as const; +interface ProfileWaveIdentitySource { + readonly query?: string | null | undefined; + readonly handle?: string | null | undefined; + readonly normalised_handle?: string | null | undefined; + readonly primary_wallet?: string | null | undefined; + readonly primary_address?: string | null | undefined; + readonly id?: string | null | undefined; +} + +const normalizeProfileWaveIdentity = ( + identity: string | null | undefined +): string => identity?.trim().toLowerCase() ?? ""; + +const getProfileWaveQueryKey = (identity: string | null | undefined) => + ["profile-wave", normalizeProfileWaveIdentity(identity)] as const; + +export const getProfileWaveIdentity = ( + profile: ProfileWaveIdentitySource | null | undefined +): string => + normalizeProfileWaveIdentity( + profile?.query ?? + profile?.handle ?? + profile?.primary_wallet ?? + profile?.primary_address ?? + profile?.id ?? + null + ); + +const getProfileWaveIdentityAliases = ( + ...profiles: readonly (ProfileWaveIdentitySource | null | undefined)[] +): string[] => { + const aliases = new Set(); + + for (const profile of profiles) { + const candidates = [ + profile?.query, + profile?.handle, + profile?.normalised_handle, + profile?.primary_wallet, + profile?.primary_address, + profile?.id, + ]; + + for (const candidate of candidates) { + const identity = normalizeProfileWaveIdentity(candidate); + if (identity.length > 0) { + aliases.add(identity); + } + } + } + + return [...aliases]; +}; + +export const setProfileWaveQueryData = ( + queryClient: QueryClient, + profiles: readonly (ProfileWaveIdentitySource | null | undefined)[], + data: ApiProfileWaveResponse +): void => { + for (const identity of getProfileWaveIdentityAliases(...profiles)) { + queryClient.setQueryData( + getProfileWaveQueryKey(identity), + data + ); + } +}; + +export const invalidateProfileWaveQueries = async ( + queryClient: QueryClient, + profiles: readonly (ProfileWaveIdentitySource | null | undefined)[] +): Promise => { + await Promise.all( + getProfileWaveIdentityAliases(...profiles).map((identity) => + queryClient.invalidateQueries({ + queryKey: getProfileWaveQueryKey(identity), + }) + ) + ); +}; export function useProfileWave({ identity, @@ -20,7 +97,7 @@ export function useProfileWave({ readonly enabled?: boolean | undefined; }) { const normalizedIdentity = useMemo( - () => identity?.trim().toLowerCase() ?? "", + () => normalizeProfileWaveIdentity(identity), [identity] ); diff --git a/hooks/useProfileWaveMutation.ts b/hooks/useProfileWaveMutation.ts index 7188f40e36..ef39bb769f 100644 --- a/hooks/useProfileWaveMutation.ts +++ b/hooks/useProfileWaveMutation.ts @@ -10,7 +10,10 @@ import { } from "@/services/api/profile-wave-api"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useContext } from "react"; -import { getProfileWaveQueryKey } from "./useProfileWave"; +import { + getProfileWaveIdentity, + setProfileWaveQueryData, +} from "./useProfileWave"; type ProfileWaveAction = | { @@ -20,51 +23,6 @@ type ProfileWaveAction = } | { readonly type: "clear" }; -interface ProfileWaveIdentitySource { - readonly query?: string | null | undefined; - readonly handle?: string | null | undefined; - readonly normalised_handle?: string | null | undefined; - readonly primary_wallet?: string | null | undefined; - readonly primary_address?: string | null | undefined; - readonly id?: string | null | undefined; -} - -const getProfileIdentityKey = ( - profile: ProfileWaveIdentitySource | null -): string | null => - profile?.query ?? - profile?.handle ?? - profile?.primary_wallet ?? - profile?.primary_address ?? - profile?.id ?? - null; - -const getProfileIdentityAliases = ( - ...profiles: readonly (ProfileWaveIdentitySource | null | undefined)[] -): string[] => { - const aliases = new Set(); - - for (const profile of profiles) { - const candidates = [ - profile?.query, - profile?.handle, - profile?.normalised_handle, - profile?.primary_wallet, - profile?.primary_address, - profile?.id, - ]; - - for (const candidate of candidates) { - const normalizedCandidate = candidate?.trim().toLowerCase() ?? ""; - if (normalizedCandidate.length > 0) { - aliases.add(normalizedCandidate); - } - } - } - - return [...aliases]; -}; - export function useProfileWaveMutation(profile: ApiIdentity | null) { const queryClient = useQueryClient(); const { requestAuth, setToast } = useAuth(); @@ -72,8 +30,8 @@ export function useProfileWaveMutation(profile: ApiIdentity | null) { const mutation = useMutation({ mutationFn: async (action: ProfileWaveAction) => { - const identity = getProfileIdentityKey(profile); - if (!identity) { + const identity = getProfileWaveIdentity(profile); + if (identity.length === 0) { throw new Error("Unable to determine the profile identity."); } @@ -92,18 +50,19 @@ export function useProfileWaveMutation(profile: ApiIdentity | null) { profile: updatedProfile, previousProfile: profile, }); - const aliases = getProfileIdentityAliases(profile, updatedProfile); - for (const alias of aliases) { - const queryKey = getProfileWaveQueryKey(alias); - queryClient.setQueryData(queryKey, { - profile_wave_id: - action.type === "set" - ? action.waveId - : updatedProfile.profile_wave_id, - profile_curation_id: - action.type === "set" ? (action.profileCurationId ?? null) : null, - }); - } + const profileWaveData: ApiProfileWaveResponse = { + profile_wave_id: + action.type === "set" + ? action.waveId + : updatedProfile.profile_wave_id, + profile_curation_id: + action.type === "set" ? (action.profileCurationId ?? null) : null, + }; + setProfileWaveQueryData( + queryClient, + [profile, updatedProfile], + profileWaveData + ); setToast({ message: action.type === "set" diff --git a/hooks/waves/useWaveCurationReorderMutation.ts b/hooks/waves/useWaveCurationReorderMutation.ts index 940f025e84..35b96e7e69 100644 --- a/hooks/waves/useWaveCurationReorderMutation.ts +++ b/hooks/waves/useWaveCurationReorderMutation.ts @@ -172,6 +172,9 @@ export function useWaveCurationReorderMutation({ }, }); const { isPending, mutate, variables } = mutation; + const pendingVariables: MoveWaveCurationVariables | undefined = isPending + ? variables + : undefined; const moveCuration = useCallback( ({ curation, direction, curations }: MoveCurationParams) => { @@ -228,6 +231,6 @@ export function useWaveCurationReorderMutation({ moveCuration, reorderCuration, isPending, - pendingCurationId: isPending ? variables.curation.id : null, + pendingCurationId: pendingVariables?.curation.id ?? null, }; } From e18370c796c87eb4bd5ad5720a013ebdf4bacc06 Mon Sep 17 00:00:00 2001 From: ragnep Date: Tue, 21 Apr 2026 08:54:16 +0300 Subject: [PATCH 7/8] wip Signed-off-by: ragnep --- components/user/waves/UserPageProfileWave.tsx | 14 +++++------ .../user/waves/UserPageProfileWaveMasonry.tsx | 2 +- .../UserPageProfileWavePickerNonReady.tsx | 12 +++++----- .../waves/UserPageProfileWavePickerReady.tsx | 5 +++- .../user/waves/UserPageProfileWaveShared.tsx | 24 ++++++++++--------- hooks/useProfileWave.ts | 10 ++++++++ 6 files changed, 41 insertions(+), 26 deletions(-) diff --git a/components/user/waves/UserPageProfileWave.tsx b/components/user/waves/UserPageProfileWave.tsx index b44f3e6c4d..234d318689 100644 --- a/components/user/waves/UserPageProfileWave.tsx +++ b/components/user/waves/UserPageProfileWave.tsx @@ -372,7 +372,7 @@ export default function UserPageProfileWave({ if (!profileWaveId) { return ( {isPending ? ( @@ -412,13 +412,13 @@ export default function UserPageProfileWave({ } if (isLoading) { - return ; + return ; } if (isError || !wave) { return ( diff --git a/components/user/waves/UserPageProfileWaveMasonry.tsx b/components/user/waves/UserPageProfileWaveMasonry.tsx index 968e8bb257..4c8b244069 100644 --- a/components/user/waves/UserPageProfileWaveMasonry.tsx +++ b/components/user/waves/UserPageProfileWaveMasonry.tsx @@ -378,7 +378,7 @@ export default function UserPageProfileWaveMasonry({ .join("|"), [drops] ); - const masonryKey = `${curationId}-${containerWidth}-${masonryTopItemsKey}`; + const masonryKey = `${curationId}-${containerWidth}-${drops.length}-${masonryTopItemsKey}`; const handleBottomIntersection = useCallback( (isIntersecting: boolean) => { diff --git a/components/user/waves/UserPageProfileWavePickerNonReady.tsx b/components/user/waves/UserPageProfileWavePickerNonReady.tsx index f513f76ded..73f1d9e10b 100644 --- a/components/user/waves/UserPageProfileWavePickerNonReady.tsx +++ b/components/user/waves/UserPageProfileWavePickerNonReady.tsx @@ -29,9 +29,9 @@ function CreateWaveLink() { } function renderNotOwnProfileState(variant: WavePickerVariant) { - const title = "No official wave yet"; + const title = "No featured wave yet"; const message = - "This profile hasn't selected an official wave for Curation yet."; + "This profile hasn't selected a featured wave for Curation yet."; if (variant === "panel") { return ; @@ -42,7 +42,7 @@ function renderNotOwnProfileState(variant: WavePickerVariant) { function renderProxyMode(variant: WavePickerVariant) { const message = - "Switch out of proxy mode to change the official wave shown in Curation."; + "Switch out of proxy mode to change the featured wave shown in Curation."; if (variant === DROPDOWN_VARIANT) { return ( @@ -144,10 +144,10 @@ function renderNoPublicWavesState({ readonly hasCreatedWaves: boolean; readonly variant: WavePickerVariant; }) { - const title = hasCreatedWaves ? "No official wave yet" : "No waves yet"; + const title = hasCreatedWaves ? "No featured wave yet" : "No waves yet"; const message = hasCreatedWaves - ? "Only public waves can be used here. Create one to set it as your official wave." - : "Create your first public wave to set it as your official wave."; + ? "Only public waves can be used here. Create one to set it as your featured wave." + : "Create your first public wave to set it as your featured wave."; if (variant === "panel") { return ( diff --git a/components/user/waves/UserPageProfileWavePickerReady.tsx b/components/user/waves/UserPageProfileWavePickerReady.tsx index c2742d44e3..ef4ee0a413 100644 --- a/components/user/waves/UserPageProfileWavePickerReady.tsx +++ b/components/user/waves/UserPageProfileWavePickerReady.tsx @@ -230,6 +230,9 @@ export default function UserPageProfileWavePickerReady({ return (
+

+ Profile wave +

{state.waves.map(renderCandidateWaveRow)}
@@ -256,7 +259,7 @@ export default function UserPageProfileWavePickerReady({ {title}

- Choose the wave you want to use as your official wave. + Choose the wave you want to use as your featured wave.

diff --git a/components/user/waves/UserPageProfileWaveShared.tsx b/components/user/waves/UserPageProfileWaveShared.tsx index e5d368ea25..c8b25f444c 100644 --- a/components/user/waves/UserPageProfileWaveShared.tsx +++ b/components/user/waves/UserPageProfileWaveShared.tsx @@ -198,7 +198,7 @@ export function OfficialWaveSummary({ {profileCurationLabel && ( <> - + Curation: @@ -213,7 +213,7 @@ export function OfficialWaveSummary({ {canManageOwnOfficialWave && (
-
+
- Switch wave + Switch wave