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) => (
-
+
))}
);
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 (
+ onSelect(option.key)}
+ role="tab"
+ aria-selected={activeKey === option.key}
+ aria-controls={option.panelId}
+ className={`tw-relative tw-whitespace-nowrap tw-border-x-0 tw-border-b-2 tw-border-t-0 tw-border-solid tw-bg-transparent tw-py-3 tw-text-sm tw-font-medium tw-transition-all tw-duration-200 ${
+ fullWidth ? "tw-flex tw-flex-1 tw-justify-center tw-text-center" : ""
+ } ${
+ activeKey === option.key
+ ? "tw-border-primary-300 tw-text-white"
+ : "tw-border-transparent tw-text-iron-500 desktop-hover:hover:tw-text-iron-200"
+ }`}
+ >
+
+ {option.leadingIcon}
+ {option.label}
+
+ {option.hasIndicator && (
+
+ )}
+
+ );
+}
+
+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 && (
-
-
moveActiveCuration("previous")}
- disabled={moveLeftDisabled}
- aria-label="Move curation tab left"
- title="Move tab left"
- className="tw-inline-flex tw-h-9 tw-w-9 tw-items-center tw-justify-center tw-rounded-xl tw-border tw-border-solid tw-border-iron-700 tw-bg-iron-900 tw-text-iron-200 tw-transition hover:tw-border-iron-500 hover:tw-bg-iron-800 hover:tw-text-white disabled:tw-cursor-not-allowed disabled:tw-opacity-40"
- >
-
-
-
moveActiveCuration("next")}
- disabled={moveRightDisabled}
- aria-label="Move curation tab right"
- title="Move tab right"
- className="tw-inline-flex tw-h-9 tw-w-9 tw-items-center tw-justify-center tw-rounded-xl tw-border tw-border-solid tw-border-iron-700 tw-bg-iron-900 tw-text-iron-200 tw-transition hover:tw-border-iron-500 hover:tw-bg-iron-800 hover:tw-text-white disabled:tw-cursor-not-allowed disabled:tw-opacity-40"
- >
-
-
-
- )}
- {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 (
+
+
+ {label}
+
+ {trailingContent}
+
+ );
+ };
+
+ 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 && (
+ <>
+
+
+
+ Switch curation
+
+
+
+ {changeCurationDropdownId && (
+
+ {changeCurationDropdown}
+
+ )}
+
+ >
+ )}
{
- let firstCreatedCuration: ApiWaveCuration | null = null;
-
- for (const curation of curations) {
- if (
- !firstCreatedCuration ||
- curation.created_at < firstCreatedCuration.created_at ||
- (curation.created_at === firstCreatedCuration.created_at &&
- curation.id.localeCompare(firstCreatedCuration.id) < 0)
- ) {
- firstCreatedCuration = curation;
+ if (profileCurationId) {
+ const selectedCuration =
+ curations.find((curation) => curation.id === profileCurationId) ?? null;
+ if (selectedCuration) {
+ return selectedCuration;
}
}
- return firstCreatedCuration;
+ return curations[0] ?? null;
};
-const getProfileCurationTitle = (
+export const getProfileCurationTitle = (
profileCuration: ApiWaveCuration | null
): string => {
const trimmedTitle = profileCuration?.name.trim() ?? "";
return trimmedTitle.length > 0 ? trimmedTitle : "Curation";
};
-export const getOfficialWaveMetadataLabel = ({
- wave,
- areCurationsLoading,
- profileCuration,
-}: {
- readonly wave: ApiWave;
- readonly areCurationsLoading: boolean;
- readonly profileCuration: ApiWaveCuration | null;
-}): string => {
- const metadataParts = [
+export const getOfficialWaveMetadataLabel = (wave: ApiWave): string =>
+ [
`${wave.metrics.drops_count} posts`,
`${wave.metrics.subscribers_count} joined`,
- ];
-
- if (profileCuration) {
- metadataParts.push(`Showing: ${getProfileCurationTitle(profileCuration)}`);
- } else if (areCurationsLoading) {
- metadataParts.push("Loading curation...");
- } else {
- metadataParts.push("No curation yet");
- }
-
- return metadataParts.join(" • ");
-};
+ ].join(" • ");
const getMissingCurationConfig = (
canManageOwnOfficialWave: boolean
diff --git a/components/waves/groups/curation/WaveActiveCurationSection.tsx b/components/waves/groups/curation/WaveActiveCurationSection.tsx
index 91769dad43..040500ccf2 100644
--- a/components/waves/groups/curation/WaveActiveCurationSection.tsx
+++ b/components/waves/groups/curation/WaveActiveCurationSection.tsx
@@ -324,17 +324,6 @@ export default function WaveActiveCurationSection({
{desktopActiveCuration?.name}
-
- {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 (
+
+
= ({
{options.map((option) => (
void;
}) {
const changeWaveDropdownId =
- changeWaveDropdown !== undefined ? "change-wave-dropdown" : undefined;
+ changeWaveDropdown === undefined ? undefined : "change-wave-dropdown";
const changeCurationDropdownId =
- changeCurationDropdown !== undefined
- ? "change-curation-dropdown"
- : undefined;
+ changeCurationDropdown === undefined
+ ? undefined
+ : "change-curation-dropdown";
return (
diff --git a/hooks/useProfileWave.ts b/hooks/useProfileWave.ts
index 99b6a9578a..db4aec31e4 100644
--- a/hooks/useProfileWave.ts
+++ b/hooks/useProfileWave.ts
@@ -33,8 +33,8 @@ export function useProfileWave({
}),
enabled: enabled && normalizedIdentity.length > 0,
staleTime: 60 * 1000,
- ...(initialProfileWave !== undefined
- ? { initialData: initialProfileWave, initialDataUpdatedAt: 0 }
- : {}),
+ ...(initialProfileWave === undefined
+ ? {}
+ : { initialData: initialProfileWave, initialDataUpdatedAt: 0 }),
});
}
diff --git a/hooks/useProfileWaveMutation.ts b/hooks/useProfileWaveMutation.ts
index fa07ffffe8..df4eea73ba 100644
--- a/hooks/useProfileWaveMutation.ts
+++ b/hooks/useProfileWaveMutation.ts
@@ -49,7 +49,7 @@ export function useProfileWaveMutation(profile: ApiIdentity | null) {
return await clearProfileWave({ identity });
},
- onSuccess: (updatedProfile, action) => {
+ onSuccess: async (updatedProfile, action) => {
onProfileEdit({
profile: updatedProfile,
previousProfile: profile,
@@ -65,7 +65,7 @@ export function useProfileWaveMutation(profile: ApiIdentity | null) {
profile_curation_id:
action.type === "set" ? (action.profileCurationId ?? null) : null,
});
- void queryClient.invalidateQueries({ queryKey });
+ await queryClient.invalidateQueries({ queryKey });
}
setToast({
message:
diff --git a/services/api/profile-wave-api.ts b/services/api/profile-wave-api.ts
index 7bc264eba2..74d0f6cf65 100644
--- a/services/api/profile-wave-api.ts
+++ b/services/api/profile-wave-api.ts
@@ -70,12 +70,10 @@ export const setProfileWave = async ({
readonly waveId: string;
readonly profileCurationId?: string | null | undefined;
}): Promise
=> {
- const body: SetProfileWaveRequestBody = {
- wave_id: waveId,
- ...(profileCurationId !== undefined
- ? { profile_curation_id: profileCurationId }
- : {}),
- };
+ const body: SetProfileWaveRequestBody =
+ profileCurationId === undefined
+ ? { wave_id: waveId }
+ : { wave_id: waveId, profile_curation_id: profileCurationId };
return await commonApiPost({
endpoint: `profiles/${encodeURIComponent(identity)}/wave`,
From 0723c5b6cee9c98da8baf9785ea0998002f76ddf Mon Sep 17 00:00:00 2001
From: ragnep
Date: Mon, 20 Apr 2026 16:01:46 +0300
Subject: [PATCH 4/8] wip
Signed-off-by: ragnep
---
components/brain/my-stream/MyStreamWaveDesktopTabs.tsx | 8 ++++----
.../brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx | 6 +++---
2 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx
index cbd9375448..47f4fb28ee 100644
--- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx
+++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx
@@ -180,8 +180,8 @@ function DesktopTabButton({
}`}
>
- {option.leadingIcon}
{option.label}
+ {option.leadingIcon}
{option.hasIndicator && (
@@ -196,11 +196,11 @@ function ProfileCurationIcon({ tooltipId }: { readonly tooltipId: string }) {
aria-label="Profile curation"
data-tooltip-id={tooltipId}
data-tooltip-content="Profile curation"
- className="tw-inline-flex tw-size-4 tw-flex-shrink-0 tw-items-center tw-justify-center tw-leading-none tw-text-primary-300"
+ className="tw-inline-flex tw-size-3.5 tw-flex-shrink-0 tw-items-center tw-justify-center tw-leading-none tw-text-primary-300"
>
);
@@ -290,7 +290,7 @@ function SortableCurationTabOption({
aria-label={`Drag ${option.label} curation tab`}
data-tooltip-id={reorderTooltipId}
data-tooltip-content="Drag to reorder"
- className="tw-inline-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-600 tw-transition hover:tw-bg-iron-900 hover:tw-text-iron-200 disabled:tw-cursor-not-allowed disabled:tw-opacity-40"
+ className="tw-inline-flex tw-h-8 tw-w-4 tw-flex-shrink-0 tw-items-center tw-justify-center tw-border-0 tw-bg-transparent tw-text-iron-600 tw-transition hover:tw-text-iron-300 disabled:tw-cursor-not-allowed disabled:tw-opacity-40"
{...attributes}
{...listeners}
>
diff --git a/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx b/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx
index f002fb0379..432b08b6ef 100644
--- a/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx
+++ b/components/brain/my-stream/tabs/MyStreamWaveCurationTabMenu.tsx
@@ -111,11 +111,11 @@ export default function MyStreamWaveCurationTabMenu({
return (
<>
+
}
- aria-label={`${curation.name} curation options`}
+ aria-label="Curation options"
items={menuItems}
menuWidthClassName="tw-w-52"
disabled={deleteMutation.isPending || isSettingProfileCuration}
From bd0cdec3505d7f161cbc96352f65368674a60883 Mon Sep 17 00:00:00 2001
From: ragnep
Date: Mon, 20 Apr 2026 16:30:02 +0300
Subject: [PATCH 5/8] wip
Signed-off-by: ragnep
---
.../my-stream/MyStreamWaveDesktopTabs.tsx | 66 +------------------
hooks/useProfileWaveMutation.ts | 49 ++++++++++++--
2 files changed, 45 insertions(+), 70 deletions(-)
diff --git a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx
index 47f4fb28ee..7c09cf60e6 100644
--- a/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx
+++ b/components/brain/my-stream/MyStreamWaveDesktopTabs.tsx
@@ -1,10 +1,7 @@
"use client";
import React, { useEffect, useMemo, useRef } from "react";
-import {
- EllipsisVerticalIcon,
- UserCircleIcon,
-} from "@heroicons/react/24/outline";
+import { UserCircleIcon } from "@heroicons/react/24/outline";
import {
closestCenter,
DndContext,
@@ -21,7 +18,6 @@ import {
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";
import type { ApiWave } from "@/generated/models/ApiWave";
@@ -125,7 +121,6 @@ const getEffectiveProfileCurationId = ({
};
const AUTO_EXPAND_LIMIT = 5;
-const MOBILE_INLINE_CURATION_LIMIT = 1;
const TAB_LABELS: Record = {
[MyStreamWaveTab.CHAT]: "Chat",
@@ -557,54 +552,6 @@ const MyStreamWaveDesktopTabs: React.FC = ({
});
};
- const mobileVisibleCurationOptions = useMemo(() => {
- if (curationOptions.length <= MOBILE_INLINE_CURATION_LIMIT) {
- return curationOptions;
- }
-
- const activeCurationOption =
- curationOptions.find((option) => option.key === activeKey) ?? null;
- const visibleOptions = activeCurationOption ? [activeCurationOption] : [];
-
- for (const option of curationOptions) {
- if (visibleOptions.length >= MOBILE_INLINE_CURATION_LIMIT) {
- break;
- }
-
- if (option.key === activeCurationOption?.key) {
- continue;
- }
-
- visibleOptions.push(option);
- }
-
- return visibleOptions;
- }, [activeKey, curationOptions]);
-
- const mobileOverflowCurationOptions = useMemo(() => {
- const visibleKeys = new Set(
- mobileVisibleCurationOptions.map((option) => option.key)
- );
-
- return curationOptions.filter((option) => !visibleKeys.has(option.key));
- }, [curationOptions, mobileVisibleCurationOptions]);
-
- const mobileOptions: TabOption[] = useMemo(
- () => [...standardOptions, ...mobileVisibleCurationOptions],
- [mobileVisibleCurationOptions, standardOptions]
- );
-
- const mobileOverflowItems: CompactMenuItem[] = useMemo(
- () =>
- mobileOverflowCurationOptions.map((option) => ({
- id: option.key,
- label: option.label,
- icon: option.leadingIcon,
- onSelect: () => onSelectCuration(getCurationIdFromTabKey(option.key)),
- })),
- [mobileOverflowCurationOptions, onSelectCuration]
- );
-
useEffect(() => {
const frameId = globalThis.window.requestAnimationFrame(() => {
[desktopTabsScrollerRef.current, mobileTabsScrollerRef.current].forEach(
@@ -642,7 +589,7 @@ const MyStreamWaveDesktopTabs: React.FC = ({
>
{
if (key.startsWith("curation:")) {
@@ -654,15 +601,6 @@ const MyStreamWaveDesktopTabs: React.FC = ({
setActiveTab(key as MyStreamWaveTab);
}}
/>
- {mobileOverflowItems.length > 0 && (
- }
- aria-label="More curations"
- items={mobileOverflowItems}
- menuWidthClassName="tw-w-52"
- />
- )}
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) => (
+ onSelect(option.key)}
+ role="tab"
+ aria-selected={activeKey === option.key}
+ aria-controls={option.panelId}
+ style={style}
+ className={`tw-relative tw-whitespace-nowrap tw-border-x-0 tw-border-b-2 tw-border-t-0 tw-border-solid tw-bg-transparent tw-py-3 tw-text-sm tw-font-medium tw-transition-all tw-duration-200 ${
+ fullWidth ? "tw-flex tw-flex-1 tw-justify-center tw-text-center" : ""
+ } ${
+ activeKey === option.key
+ ? "tw-border-primary-300 tw-text-white"
+ : "tw-border-transparent tw-text-iron-500 desktop-hover:hover:tw-text-iron-200"
+ }`}
+ >
+
+ {option.leadingIcon}
+ {option.label}
+
+ {option.hasIndicator && (
+
+ )}
+
+ );
+
if (!hasActions) {
return (
- {options.map((option) => (
-
onSelect(option.key)}
- role="tab"
- aria-selected={activeKey === option.key}
- aria-controls={option.panelId}
- className={`tw-relative tw-whitespace-nowrap tw-border-x-0 tw-border-b-2 tw-border-t-0 tw-border-solid tw-bg-transparent tw-py-3 tw-text-sm tw-font-medium tw-transition-all tw-duration-200 ${
- fullWidth
- ? "tw-flex tw-flex-1 tw-justify-center tw-text-center"
- : ""
- } ${
- activeKey === option.key
- ? "tw-border-primary-300 tw-text-white"
- : "tw-border-transparent tw-text-iron-500 desktop-hover:hover:tw-text-iron-200"
- }`}
- >
-
- {option.leadingIcon}
- {option.label}
-
- {option.hasIndicator && (
-
- )}
-
- ))}
+ {options.map((option) => renderTabButton(option))}
);
}
@@ -65,43 +66,28 @@ export const TabToggle: React.FC = ({
return (
- {options.map((option) => (
-
-
onSelect(option.key)}
- role="tab"
- aria-selected={activeKey === option.key}
- aria-controls={option.panelId}
- className={`tw-relative tw-whitespace-nowrap tw-border-x-0 tw-border-b-2 tw-border-t-0 tw-border-solid tw-bg-transparent tw-py-3 tw-text-sm tw-font-medium tw-transition-all tw-duration-200 ${
- fullWidth
- ? "tw-flex tw-flex-1 tw-justify-center tw-text-center"
- : ""
- } ${
- activeKey === option.key
- ? "tw-border-primary-300 tw-text-white"
- : "tw-border-transparent tw-text-iron-500 desktop-hover:hover:tw-text-iron-200"
- }`}
+
+ {options.map((option, index) =>
+ renderTabButton(option, { order: index * 2 })
+ )}
+
+ {options.map((option, index) => {
+ if (option.action === undefined || option.action === null) {
+ return null;
+ }
+
+ const actionOrder = index * 2 + 1;
+ return (
+
-
- {option.leadingIcon}
- {option.label}
-
- {option.hasIndicator && (
-
- )}
-
- {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
{showChangeCuration && onOpenChangeCuration !== undefined && (
<>
-
+
-
Switch curation
+
+ Switch curation
+
>
)}
-
+
{isRemoving ? (
@@ -304,7 +306,7 @@ export function OfficialWaveSummary({
) : (
)}
- Unset
+ Unset
diff --git a/hooks/useProfileWave.ts b/hooks/useProfileWave.ts
index 2ca75f1bc9..4d688300de 100644
--- a/hooks/useProfileWave.ts
+++ b/hooks/useProfileWave.ts
@@ -72,6 +72,16 @@ export const setProfileWaveQueryData = (
data
);
}
+
+ if (data.profile_wave_id === null) {
+ return;
+ }
+
+ queryClient.setQueriesData
(
+ { queryKey: ["profile-wave"] },
+ (current) =>
+ current?.profile_wave_id === data.profile_wave_id ? data : current
+ );
};
export const invalidateProfileWaveQueries = async (
From 391c0ce95d0e5b9f669129d3540a390682a1333c Mon Sep 17 00:00:00 2001
From: ragnep
Date: Tue, 21 Apr 2026 09:06:02 +0300
Subject: [PATCH 8/8] wip
Signed-off-by: ragnep
---
hooks/useProfileWave.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/hooks/useProfileWave.ts b/hooks/useProfileWave.ts
index 4d688300de..a615cebdb3 100644
--- a/hooks/useProfileWave.ts
+++ b/hooks/useProfileWave.ts
@@ -28,6 +28,7 @@ export const getProfileWaveIdentity = (
): string =>
normalizeProfileWaveIdentity(
profile?.query ??
+ profile?.normalised_handle ??
profile?.handle ??
profile?.primary_wallet ??
profile?.primary_address ??