diff --git a/__tests__/components/drops/create/lexical/plugins/mentions/MentionsPlugin.test.tsx b/__tests__/components/drops/create/lexical/plugins/mentions/MentionsPlugin.test.tsx index 9e02819681..494cb44f53 100644 --- a/__tests__/components/drops/create/lexical/plugins/mentions/MentionsPlugin.test.tsx +++ b/__tests__/components/drops/create/lexical/plugins/mentions/MentionsPlugin.test.tsx @@ -1,13 +1,15 @@ -import React, { createRef } from 'react'; -import { render, act } from '@testing-library/react'; -import NewMentionsPlugin, { MentionTypeaheadOption } from '@/components/drops/create/lexical/plugins/mentions/MentionsPlugin'; +import React, { createRef } from "react"; +import { render, act } from "@testing-library/react"; +import NewMentionsPlugin, { + MentionTypeaheadOption, +} from "@/components/drops/create/lexical/plugins/mentions/MentionsPlugin"; -jest.mock('@lexical/react/LexicalComposerContext', () => ({ +jest.mock("@lexical/react/LexicalComposerContext", () => ({ useLexicalComposerContext: () => [{ update: (fn: any) => fn() }], })); let capturedProps: any; -jest.mock('@lexical/react/LexicalTypeaheadMenuPlugin', () => ({ +jest.mock("@lexical/react/LexicalTypeaheadMenuPlugin", () => ({ LexicalTypeaheadMenuPlugin: (props: any) => { capturedProps = props; return
; @@ -16,23 +18,36 @@ jest.mock('@lexical/react/LexicalTypeaheadMenuPlugin', () => ({ useBasicTypeaheadTriggerMatch: () => jest.fn(), })); -jest.mock('@/hooks/useIdentitiesSearch', () => ({ +jest.mock("@/hooks/useIdentitiesSearch", () => ({ + IDENTITY_SEARCH_MIN_HANDLE_LENGTH: 3, useIdentitiesSearch: jest.fn(), })); -jest.mock('@/components/drops/create/lexical/nodes/MentionNode', () => ({ - $createMentionNode: jest.fn(() => ({ replace: jest.fn(), select: jest.fn() })), +jest.mock("@/components/drops/create/lexical/nodes/MentionNode", () => ({ + $createMentionNode: jest.fn(() => ({ + replace: jest.fn(), + select: jest.fn(), + })), +})); +jest.mock("@/components/drops/create/lexical/nodes/GroupMentionNode", () => ({ + $createGroupMentionNode: jest.fn(() => ({ + replace: jest.fn(), + select: jest.fn(), + })), })); -const { useIdentitiesSearch } = require('@/hooks/useIdentitiesSearch'); -const { $createMentionNode } = require('@/components/drops/create/lexical/nodes/MentionNode'); +const { useIdentitiesSearch } = require("@/hooks/useIdentitiesSearch"); +const { + $createMentionNode, +} = require("@/components/drops/create/lexical/nodes/MentionNode"); +const { + $createGroupMentionNode, +} = require("@/components/drops/create/lexical/nodes/GroupMentionNode"); -describe('MentionsPlugin', () => { - it('builds options from identities and exposes open state', () => { +describe("MentionsPlugin", () => { + it("builds options from identities and exposes open state", () => { (useIdentitiesSearch as jest.Mock).mockReturnValue({ - identities: [ - { id: '1', handle: 'alice', display: 'Alice', pfp: null }, - ], + identities: [{ id: "1", handle: "alice", display: "Alice", pfp: null }], }); const ref = createRef(); render(); @@ -49,12 +64,14 @@ describe('MentionsPlugin', () => { expect(ref.current.isMentionsOpen()).toBe(false); }); - it('calls onSelect with mention info', () => { + it("calls onSelect with mention info", () => { (useIdentitiesSearch as jest.Mock).mockReturnValue({ - identities: [{ id: '1', handle: 'alice', display: 'Alice', pfp: null }], + identities: [{ id: "1", handle: "alice", display: "Alice", pfp: null }], }); const onSelect = jest.fn(); - render(); + render( + + ); const option = capturedProps.options[0]; const close = jest.fn(); act(() => { @@ -67,4 +84,46 @@ describe('MentionsPlugin', () => { }); expect(close).toHaveBeenCalled(); }); + + it("adds @all option for admins and emits group mention", () => { + (useIdentitiesSearch as jest.Mock).mockReturnValue({ + identities: [], + }); + const onSelectGroupMention = jest.fn(); + render( + + ); + expect(capturedProps.options).toHaveLength(0); + + act(() => { + capturedProps.onQueryChange("a"); + }); + expect(capturedProps.options).toHaveLength(0); + + act(() => { + capturedProps.onQueryChange("al"); + }); + expect(capturedProps.options).toHaveLength(0); + + act(() => { + capturedProps.onQueryChange("all"); + }); + const option = capturedProps.options[0]; + const close = jest.fn(); + + act(() => { + capturedProps.onSelectOption(option, null, close); + }); + + expect(option.handle).toBe("@all"); + expect($createGroupMentionNode).toHaveBeenCalledWith("@all"); + expect(onSelectGroupMention).toHaveBeenCalledWith("ALL"); + expect(close).toHaveBeenCalled(); + }); }); diff --git a/__tests__/components/drops/create/utils/storm/CreateDropStormViewPart.test.tsx b/__tests__/components/drops/create/utils/storm/CreateDropStormViewPart.test.tsx index e625c201c6..c4c609fc9a 100644 --- a/__tests__/components/drops/create/utils/storm/CreateDropStormViewPart.test.tsx +++ b/__tests__/components/drops/create/utils/storm/CreateDropStormViewPart.test.tsx @@ -1,27 +1,34 @@ -import { render, screen, fireEvent } from '@testing-library/react'; -import React from 'react'; -import CreateDropStormViewPart from '@/components/drops/create/utils/storm/CreateDropStormViewPart'; +import { render, screen, fireEvent } from "@testing-library/react"; +import React from "react"; +import CreateDropStormViewPart from "@/components/drops/create/utils/storm/CreateDropStormViewPart"; -jest.mock('@/components/drops/view/part/DropPart', () => jest.fn(() =>
)); -jest.mock('@/components/drops/create/utils/storm/CreateDropStormViewPartQuote', () => jest.fn(() =>
)); +jest.mock("@/components/drops/view/part/DropPart", () => + jest.fn(() =>
) +); +jest.mock( + "@/components/drops/create/utils/storm/CreateDropStormViewPartQuote", + () => jest.fn(() =>
) +); -const DropPartMock = require('@/components/drops/view/part/DropPart'); -const QuoteMock = require('@/components/drops/create/utils/storm/CreateDropStormViewPartQuote'); +const DropPartMock = require("@/components/drops/view/part/DropPart"); +const QuoteMock = require("@/components/drops/create/utils/storm/CreateDropStormViewPartQuote"); -describe('CreateDropStormViewPart', () => { +describe("CreateDropStormViewPart", () => { beforeEach(() => { - (global as any).URL.createObjectURL = jest.fn(() => 'blob:url'); + (global as any).URL.createObjectURL = jest.fn(() => "blob:url"); jest.clearAllMocks(); }); const baseProps = { profile: {} as any, part: { - content: 'c', + content: "c", quoted_drop: null, - media: [new File(['1'], 'f.png', { type: 'image/png' })], + media: [new File(["1"], "f.png", { type: "image/png" })], }, mentionedUsers: [], + mentionedGroups: [], + mentionedWaves: [], referencedNfts: [], createdAt: 1, wave: null, @@ -30,21 +37,34 @@ describe('CreateDropStormViewPart', () => { removePart: jest.fn(), }; - it('passes transformed media to DropPart', () => { + it("passes transformed media to DropPart", () => { render(); const call = (DropPartMock as jest.Mock).mock.calls[0][0]; - expect(call.partMedias).toEqual([{ mimeType: 'image/png', mediaSrc: 'blob:url' }]); + expect(call.partMedias).toEqual([ + { mimeType: "image/png", mediaSrc: "blob:url" }, + ]); }); - it('renders quoted drop when provided', () => { - render(); + it("renders quoted drop when provided", () => { + render( + + ); expect(QuoteMock).toHaveBeenCalled(); }); - it('calls removePart on click', () => { + it("calls removePart on click", () => { const removePart = jest.fn(); - render(); - fireEvent.click(screen.getByRole('button', { name: /remove part/i })); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /remove part/i })); expect(removePart).toHaveBeenCalledWith(3); }); }); diff --git a/__tests__/components/drops/view/item/content/DropListItemContentPart.test.tsx b/__tests__/components/drops/view/item/content/DropListItemContentPart.test.tsx index 0f964b9d8f..d6f2bde7b1 100644 --- a/__tests__/components/drops/view/item/content/DropListItemContentPart.test.tsx +++ b/__tests__/components/drops/view/item/content/DropListItemContentPart.test.tsx @@ -12,6 +12,11 @@ jest.mock( () => (props: any) =>
{props.user.handle}
); +jest.mock( + "@/components/drops/view/item/content/DropListItemContentGroupMention", + () => () =>
@all
+); + jest.mock( "@/components/drops/view/item/content/DropListItemContentWaveMention", () => (props: any) => ( @@ -58,4 +63,17 @@ describe("DropListItemContentPart", () => { ); expect(screen.getByTestId("wave-mention")).toHaveTextContent("Wave One"); }); + + it("renders group mention", () => { + render( + + ); + expect(screen.getByTestId("group-mention")).toHaveTextContent("@all"); + }); }); diff --git a/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx b/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx index e1201966fe..f405c9b4c2 100644 --- a/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx +++ b/__tests__/components/drops/view/part/DropPartMarkdown.test.tsx @@ -52,6 +52,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import DropPartMarkdown from "@/components/drops/view/part/DropPartMarkdown"; +import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; const setQueryDataMock = jest.fn(); @@ -218,6 +219,36 @@ describe("DropPartMarkdown", () => { expect(a).toHaveAttribute("rel", "noopener noreferrer nofollow"); }); + it("renders @all as a blue group mention only when ALL is in mentioned groups", () => { + const { rerender } = render( + + ); + + expect(screen.getByText("@all")).toHaveClass("tw-text-primary-400"); + + rerender( + + ); + + expect(screen.getByText("hello @all")).not.toHaveClass( + "tw-text-primary-400" + ); + }); + it("renders Art Blocks token card when feature enabled", async () => { publicEnv.VITE_FEATURE_AB_CARD = "true"; const content = "[token](https://www.artblocks.io/token/662000)"; diff --git a/__tests__/components/waves/CreateDropStormPart.test.tsx b/__tests__/components/waves/CreateDropStormPart.test.tsx index 709b22b440..202755b5c1 100644 --- a/__tests__/components/waves/CreateDropStormPart.test.tsx +++ b/__tests__/components/waves/CreateDropStormPart.test.tsx @@ -1,31 +1,33 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; -jest.mock('@/components/drops/view/part/DropPartMarkdown', () => () => ( +jest.mock("@/components/drops/view/part/DropPartMarkdown", () => () => (
)); -import CreateDropStormPart from '@/components/waves/CreateDropStormPart'; +import CreateDropStormPart from "@/components/waves/CreateDropStormPart"; -const part = { content: 'hello', media: [] } as any; +const part = { content: "hello", media: [] } as any; -describe('CreateDropStormPart', () => { - it('renders part info and handles remove', async () => { +describe("CreateDropStormPart", () => { + it("renders part info and handles remove", async () => { const onRemove = jest.fn(); render( ); - expect(screen.getByText('Part 1')).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button')); + expect(screen.getByText("Part 1")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button")); expect(onRemove).toHaveBeenCalledWith(0); - expect(screen.getByTestId('markdown')).toBeInTheDocument(); + expect(screen.getByTestId("markdown")).toBeInTheDocument(); }); }); diff --git a/__tests__/components/waves/CreateDropStormParts.test.tsx b/__tests__/components/waves/CreateDropStormParts.test.tsx index 7510508104..00a42de018 100644 --- a/__tests__/components/waves/CreateDropStormParts.test.tsx +++ b/__tests__/components/waves/CreateDropStormParts.test.tsx @@ -26,6 +26,8 @@ describe("CreateDropStormParts", () => { diff --git a/__tests__/components/waves/drops/EditDropLexical.test.tsx b/__tests__/components/waves/drops/EditDropLexical.test.tsx index 3e1c74b2d5..a93b3fa549 100644 --- a/__tests__/components/waves/drops/EditDropLexical.test.tsx +++ b/__tests__/components/waves/drops/EditDropLexical.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser"; +import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; type MentionSelectHandler = (user: { mentioned_profile_id: string; @@ -158,6 +159,9 @@ jest.mock("@/components/drops/create/lexical/nodes/MentionNode", () => ({ MentionNode: class MockMentionNode {}, $createMentionNode: jest.fn(() => ({ type: "mention" })), })); +jest.mock("@/components/drops/create/lexical/nodes/GroupMentionNode", () => ({ + GroupMentionNode: class MockGroupMentionNode {}, +})); jest.mock("@/components/drops/create/lexical/nodes/HashtagNode", () => ({ HashtagNode: class MockHashtagNode {}, })); @@ -174,6 +178,12 @@ jest.mock( MENTION_TRANSFORMER: {}, }) ); +jest.mock( + "@/components/drops/create/lexical/transformers/GroupMentionTransformer", + () => ({ + GROUP_MENTION_TRANSFORMER: {}, + }) +); jest.mock( "@/components/drops/create/lexical/transformers/HastagTransformer", () => ({ @@ -211,6 +221,19 @@ const { normalizeDropMarkdown: jest.Mock; exportDropMarkdown: jest.Mock; }; +jest.mock( + "@/components/drops/create/lexical/utils/groupMentionDetection", + () => ({ + getMentionedGroupsFromEditorState: jest.fn(() => []), + }) +); +const { + getMentionedGroupsFromEditorState: getMentionedGroupsFromEditorStateMock, +} = jest.requireMock( + "@/components/drops/create/lexical/utils/groupMentionDetection" +) as { + getMentionedGroupsFromEditorState: jest.Mock; +}; jest.mock("lexical", () => ({ $getRoot: getRootMock, COMMAND_PRIORITY_HIGH: 4, @@ -239,7 +262,9 @@ describe("EditDropLexical", () => { const defaultProps = { initialContent: "Initial content here", initialMentions: [] as ApiDropMentionedUser[], + initialGroupMentions: [], initialWaveMentions: [], + canMentionAll: true, waveId: "wave-123", isSaving: false, onSave: jest.fn(), @@ -250,6 +275,7 @@ describe("EditDropLexical", () => { jest.clearAllMocks(); exportDropMarkdownMock.mockReturnValue("mock markdown"); normalizeDropMarkdownMock.mockImplementation((value: string) => value); + getMentionedGroupsFromEditorStateMock.mockReturnValue([]); convertFromMarkdownStringMock.mockReset(); rootMock.getChildren.mockReturnValue([]); rootMock.getAllTextNodes.mockReturnValue([]); @@ -293,11 +319,93 @@ describe("EditDropLexical", () => { expect(onSave).toHaveBeenCalledWith( "updated markdown", [{ mentioned_profile_id: "profile-1", handle_in_content: "user1" }], + [], + [] + ); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("does not create group mention metadata from raw markdown text", async () => { + const user = userEvent.setup(); + const onSave = jest.fn(); + const onCancel = jest.fn(); + exportDropMarkdownMock.mockReturnValue("Use `@all` here"); + + render( + + ); + + const saveButton = screen.getByRole("button", { name: /save/i }); + await user.click(saveButton); + + expect(onSave).toHaveBeenCalledWith("Use `@all` here", [], [], []); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("saves group mention metadata from editor group mention nodes", async () => { + const user = userEvent.setup(); + const onSave = jest.fn(); + const onCancel = jest.fn(); + exportDropMarkdownMock.mockReturnValue("@all"); + getMentionedGroupsFromEditorStateMock.mockReturnValue([ + ApiDropGroupMention.All, + ]); + + render( + + ); + + const saveButton = screen.getByRole("button", { name: /save/i }); + await user.click(saveButton); + + expect(onSave).toHaveBeenCalledWith( + "@all", + [], + [ApiDropGroupMention.All], [] ); expect(onCancel).not.toHaveBeenCalled(); }); + it("does not strip existing ALL group metadata for non-admin unchanged content", async () => { + const user = userEvent.setup(); + const onSave = jest.fn(); + const onCancel = jest.fn(); + exportDropMarkdownMock.mockReturnValue("@all"); + getMentionedGroupsFromEditorStateMock.mockReturnValue([ + ApiDropGroupMention.All, + ]); + + render( + + ); + + const saveButton = screen.getByRole("button", { name: /save/i }); + await user.click(saveButton); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onSave).not.toHaveBeenCalled(); + }); + it("calls onCancel when markdown has not changed", async () => { const user = userEvent.setup(); const onSave = jest.fn(); diff --git a/__tests__/components/waves/drops/WaveDrop.test.tsx b/__tests__/components/waves/drops/WaveDrop.test.tsx index 025839bc89..b521fd3c68 100644 --- a/__tests__/components/waves/drops/WaveDrop.test.tsx +++ b/__tests__/components/waves/drops/WaveDrop.test.tsx @@ -5,8 +5,11 @@ import { configureStore } from "@reduxjs/toolkit"; import WaveDrop from "@/components/waves/drops/WaveDrop"; import useIsMobileDevice from "@/hooks/isMobileDevice"; import { editSlice } from "@/store/editSlice"; +import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; const mockWaveDropActions = jest.fn(); +const mockMutate = jest.fn(); +let mockEditMentionedGroups: ApiDropGroupMention[] = []; jest.mock("@/components/waves/drops/WaveDropActions", () => (props: any) => { mockWaveDropActions(props); return
; @@ -15,13 +18,20 @@ jest.mock("@/components/waves/drops/WaveDropReply", () => () => (
)); jest.mock("@/components/waves/drops/WaveDropContent", () => (props: any) => ( -
)); jest.mock("@/components/waves/drops/WaveDropHeader", () => () => (
@@ -53,7 +63,7 @@ jest.mock("next/navigation", () => ({ jest.mock("@/hooks/drops/useDropUpdateMutation", () => ({ useDropUpdateMutation: jest.fn(() => ({ - mutate: jest.fn(), + mutate: mockMutate, isPending: false, })), })); @@ -97,6 +107,7 @@ const drop: any = { parts_count: 1, referenced_nfts: [], mentioned_users: [], + mentioned_groups: [], metadata: [], rating: 0, realtime_rating: 0, @@ -114,6 +125,8 @@ const drop: any = { describe("WaveDrop", () => { beforeEach(() => { mockWaveDropActions.mockClear(); + mockMutate.mockClear(); + mockEditMentionedGroups = []; }); it("shows actions on desktop", () => { @@ -184,4 +197,39 @@ describe("WaveDrop", () => { undefined ); }); + + it("omits group mention metadata from edit update requests", () => { + isMobileMock.mockReturnValue(false); + mockEditMentionedGroups = [ApiDropGroupMention.All]; + const stormDrop = { + ...drop, + mentioned_groups: [ApiDropGroupMention.All], + parts: [ + { ...drop.parts[0], content: "edited part" }, + { ...drop.parts[0], part_id: 2, content: "hello @all" }, + ], + }; + + renderWithRedux( + + ); + + fireEvent.click(screen.getByTestId("save-edit")); + + const request = mockMutate.mock.calls[0][0].request; + expect(request).not.toHaveProperty("mentioned_groups"); + }); }); diff --git a/__tests__/components/waves/header/WaveHeader.test.tsx b/__tests__/components/waves/header/WaveHeader.test.tsx index 8eb911e90c..0251eeaca9 100644 --- a/__tests__/components/waves/header/WaveHeader.test.tsx +++ b/__tests__/components/waves/header/WaveHeader.test.tsx @@ -6,7 +6,12 @@ import WaveHeader, { import { ApiWaveType } from "@/generated/models/ApiWaveType"; import { AuthContext } from "@/components/auth/Auth"; -jest.mock("@/components/waves/header/WaveHeaderFollow", () => () =>
); +jest.mock("@/components/waves/header/WaveHeaderFollow", () => (props: any) => ( +
+)); jest.mock("@/components/waves/header/options/WaveHeaderOptions", () => () => (
)); @@ -19,7 +24,7 @@ jest.mock("@/components/waves/header/WaveHeaderDescription", () => () => ( )); jest.mock("@/components/waves/WavePicture", () => () =>
); jest.mock("@/components/waves/specs/WaveNotificationSettings", () => () => ( -
+
)); jest.mock("@/helpers/waves/waves.helpers", () => ({ canEditWave: jest.fn() })); @@ -43,10 +48,12 @@ describe("WaveHeader", () => { (canEditWave as jest.Mock).mockReturnValue(false); }); - const wrapper = (wave: any, props?: any) => + const wrapper = (wave: any, props?: any, auth?: any) => render( @@ -81,4 +88,24 @@ describe("WaveHeader", () => { }); expect(screen.queryByLabelText("Edit wave picture")).toBeNull(); }); + + it("stacks follow and notification controls for connected users", () => { + wrapper(baseWave, undefined, { connectedProfile: { handle: "alice" } }); + + const follow = screen.getByTestId("wave-header-follow"); + const notifications = screen.getByTestId("wave-notification-settings"); + + expect(follow).toHaveAttribute("data-full-width", "true"); + expect(follow.parentElement).toHaveClass( + "tw-flex", + "tw-w-48", + "tw-flex-col", + "tw-items-stretch", + "tw-gap-y-1.5" + ); + expect( + follow.compareDocumentPosition(notifications) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); }); diff --git a/__tests__/components/waves/header/WaveHeaderFollow.test.tsx b/__tests__/components/waves/header/WaveHeaderFollow.test.tsx index e479525155..fa50d6d721 100644 --- a/__tests__/components/waves/header/WaveHeaderFollow.test.tsx +++ b/__tests__/components/waves/header/WaveHeaderFollow.test.tsx @@ -1,13 +1,16 @@ -import { render, screen, act, fireEvent } from '@testing-library/react'; -import React from 'react'; -import WaveHeaderFollow from '@/components/waves/header/WaveHeaderFollow'; -import { AuthContext } from '@/components/auth/Auth'; -import { ReactQueryWrapperContext } from '@/components/react-query-wrapper/ReactQueryWrapper'; -import { useMutation } from '@tanstack/react-query'; -import { commonApiPost, commonApiDeleteWithBody } from '@/services/api/common-api'; +import { render, screen, act, fireEvent } from "@testing-library/react"; +import React from "react"; +import WaveHeaderFollow from "@/components/waves/header/WaveHeaderFollow"; +import { AuthContext } from "@/components/auth/Auth"; +import { ReactQueryWrapperContext } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { useMutation } from "@tanstack/react-query"; +import { + commonApiPost, + commonApiDeleteWithBody, +} from "@/services/api/common-api"; -jest.mock('@tanstack/react-query'); -jest.mock('@/services/api/common-api'); +jest.mock("@tanstack/react-query"); +jest.mock("@/services/api/common-api"); (useMutation as jest.Mock).mockImplementation((opts) => ({ mutateAsync: async () => { @@ -22,7 +25,7 @@ jest.mock('@/services/api/common-api'); }, })); -describe('WaveHeaderFollow', () => { +describe("WaveHeaderFollow", () => { const baseAuth = { requestAuth: jest.fn().mockResolvedValue({ success: true }), setToast: jest.fn(), @@ -30,35 +33,60 @@ describe('WaveHeaderFollow', () => { const rq = { onWaveFollowChange: jest.fn() } as any; beforeEach(() => jest.clearAllMocks()); - it('follows when not subscribed', async () => { + it("follows when not subscribed", async () => { render( - + ); await act(async () => { - fireEvent.click(screen.getByRole('button')); + fireEvent.click(screen.getByRole("button")); await Promise.resolve(); }); expect(commonApiPost).toHaveBeenCalled(); - expect(rq.onWaveFollowChange).toHaveBeenCalledWith({ waveId: 'w', following: true }); + expect(rq.onWaveFollowChange).toHaveBeenCalledWith({ + waveId: "w", + following: true, + }); }); - it('unfollows when already subscribed', async () => { + it("unfollows when already subscribed", async () => { render( - + ); await act(async () => { - fireEvent.click(screen.getByRole('button')); + fireEvent.click(screen.getByRole("button")); await Promise.resolve(); }); expect(commonApiDeleteWithBody).toHaveBeenCalled(); - expect(rq.onWaveFollowChange).toHaveBeenCalledWith({ waveId: 'w', following: false }); + expect(rq.onWaveFollowChange).toHaveBeenCalledWith({ + waveId: "w", + following: false, + }); + }); + + it("supports full-width header layout", () => { + render( + + + + + + ); + + const button = screen.getByRole("button", { name: "Join" }); + expect(button.parentElement).toHaveClass("tw-w-full"); + expect(button).toHaveClass("tw-h-10", "tw-w-full", "tw-justify-center"); }); }); diff --git a/__tests__/components/waves/specs/WaveNotificationSettings.test.tsx b/__tests__/components/waves/specs/WaveNotificationSettings.test.tsx index 090dadda8e..749d7ab331 100644 --- a/__tests__/components/waves/specs/WaveNotificationSettings.test.tsx +++ b/__tests__/components/waves/specs/WaveNotificationSettings.test.tsx @@ -1,26 +1,27 @@ -import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import WaveNotificationSettings from '@/components/waves/specs/WaveNotificationSettings'; -import { AuthContext } from '@/components/auth/Auth'; -import type { ApiWave } from '@/generated/models/ApiWave'; - -jest.mock('@/hooks/useWaveNotificationSubscription', () => ({ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import WaveNotificationSettings from "@/components/waves/specs/WaveNotificationSettings"; +import { AuthContext } from "@/components/auth/Auth"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; + +jest.mock("@/hooks/useWaveNotificationSubscription", () => ({ useWaveNotificationSubscription: jest.fn(), })); -jest.mock('@/services/api/common-api', () => ({ +jest.mock("@/services/api/common-api", () => ({ commonApiPost: jest.fn(), commonApiDelete: jest.fn(), })); -jest.mock('@tanstack/react-query', () => ({ +jest.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: jest.fn(), }), })); -jest.mock('@/contexts/SeizeSettingsContext', () => ({ +jest.mock("@/contexts/SeizeSettingsContext", () => ({ useSeizeSettings: () => ({ seizeSettings: { all_drops_notifications_subscribers_limit: 1000, @@ -28,52 +29,53 @@ jest.mock('@/contexts/SeizeSettingsContext', () => ({ }), })); -jest.mock('react-bootstrap', () => ({ +jest.mock("react-bootstrap", () => ({ OverlayTrigger: ({ children }: any) => children, Tooltip: ({ children }: any) =>
{children}
, })); const mockWave: ApiWave = { - id: 'wave-123', - name: 'Test Wave', + id: "wave-123", + name: "Test Wave", metrics: { subscribers_count: 50, muted: false, }, - subscribed_actions: ['follow'], + subscribed_actions: ["follow"], } as any; const mockWaveHighSubscribers: ApiWave = { - id: 'wave-456', - name: 'Popular Wave', + id: "wave-456", + name: "Popular Wave", metrics: { subscribers_count: 1500, muted: false, }, - subscribed_actions: ['follow'], + subscribed_actions: ["follow"], } as any; const mockWaveMuted: ApiWave = { - id: 'wave-muted', - name: 'Muted Wave', + id: "wave-muted", + name: "Muted Wave", metrics: { subscribers_count: 50, muted: true, }, - subscribed_actions: ['follow'], + subscribed_actions: ["follow"], } as any; const mockAuthContext = { setToast: jest.fn(), }; -const mockUseWaveNotificationSubscription = require('@/hooks/useWaveNotificationSubscription').useWaveNotificationSubscription; +const mockUseWaveNotificationSubscription = + require("@/hooks/useWaveNotificationSubscription").useWaveNotificationSubscription; -describe('WaveNotificationSettings', () => { +describe("WaveNotificationSettings", () => { beforeEach(() => { - jest.clearAllMocks(); + jest.resetAllMocks(); mockUseWaveNotificationSubscription.mockReturnValue({ - data: { subscribed: false }, + data: { subscribed: false, enabled_group_notifications: [] }, refetch: jest.fn(), }); }); @@ -86,256 +88,477 @@ describe('WaveNotificationSettings', () => { ); }; - it('does not render when not following wave', () => { + it("does not render when not following wave", () => { const waveNotFollowing = { ...mockWave, subscribed_actions: [] }; const { container } = renderComponent(waveNotFollowing); - + expect(container.firstChild).toBeNull(); }); - it('renders notification buttons when following wave', () => { + it("renders notification buttons when following wave", () => { + renderComponent(); + + const allMentionsButton = screen.getByLabelText( + "Receive ALL mention notifications" + ); + const allButton = screen.getByLabelText("Receive all drop notifications"); + expect(allMentionsButton).toBeInTheDocument(); + expect(allButton).toBeInTheDocument(); + expect(allMentionsButton.parentElement).toHaveClass( + "tw-grid", + "tw-grid-cols-2", + "tw-gap-x-1.5" + ); + expect(allButton.parentElement).toBe(allMentionsButton.parentElement); + expect(allMentionsButton).toHaveClass("tw-w-full", "tw-border"); + expect(allButton).toHaveClass("tw-w-full", "tw-border"); + expect( + screen.queryByLabelText("Receive mentions-only notifications") + ).not.toBeInTheDocument(); + }); + + it("shows ALL mention button as active when enabled", () => { + mockUseWaveNotificationSubscription.mockReturnValue({ + data: { + subscribed: false, + enabled_group_notifications: [ApiDropGroupMention.All], + }, + refetch: jest.fn(), + }); + renderComponent(); - - expect(screen.getByLabelText('Receive mentions-only notifications')).toBeInTheDocument(); - expect(screen.getByLabelText('Receive all notifications')).toBeInTheDocument(); + + const allMentionsButton = screen.getByLabelText( + "Receive ALL mention notifications" + ); + expect(allMentionsButton).toHaveClass( + "tw-bg-iron-800", + "tw-text-primary-400" + ); }); - it('shows mentions button as active when all notifications disabled', () => { + it("shows all drop button as active when all drop notifications enabled", () => { mockUseWaveNotificationSubscription.mockReturnValue({ - data: { subscribed: false }, + data: { subscribed: true, enabled_group_notifications: [] }, refetch: jest.fn(), }); - + renderComponent(); - - const mentionsButton = screen.getByLabelText('Receive mentions-only notifications'); - expect(mentionsButton).toHaveClass('tw-bg-iron-800', 'tw-text-primary-400'); + + const allButton = screen.getByLabelText("Receive all drop notifications"); + expect(allButton).toHaveClass("tw-bg-iron-800", "tw-text-primary-400"); }); - it('shows all button as active when all notifications enabled', () => { + it("can show ALL mention and all drop buttons active together", () => { mockUseWaveNotificationSubscription.mockReturnValue({ - data: { subscribed: true }, + data: { + subscribed: true, + enabled_group_notifications: [ApiDropGroupMention.All], + }, refetch: jest.fn(), }); - + renderComponent(); - - const allButton = screen.getByLabelText('Receive all notifications'); - expect(allButton).toHaveClass('tw-bg-iron-800', 'tw-text-primary-400'); + + expect( + screen.getByLabelText("Receive ALL mention notifications") + ).toHaveClass("tw-bg-iron-800", "tw-text-primary-400"); + expect(screen.getByLabelText("Receive all drop notifications")).toHaveClass( + "tw-bg-iron-800", + "tw-text-primary-400" + ); }); - it('disables all notifications button when subscriber limit reached', () => { + it("marks only all drop notifications unavailable when subscriber limit reached", () => { renderComponent(mockWaveHighSubscribers); - - const allButton = screen.getByLabelText('Receive all notifications'); - expect(allButton).toBeDisabled(); - expect(allButton).toHaveClass('tw-cursor-not-allowed'); + + const allMentionsButton = screen.getByLabelText( + "Receive ALL mention notifications" + ); + const allButton = screen.getByLabelText("Receive all drop notifications"); + expect(allMentionsButton).not.toBeDisabled(); + expect(allButton).not.toBeDisabled(); + expect(allButton).toHaveAttribute("aria-disabled", "true"); + expect(allButton).toHaveAccessibleDescription( + "'All' notifications unavailable for waves with 1,000+ followers." + ); + expect(allButton).toHaveClass("tw-cursor-not-allowed"); + expect(allButton).not.toHaveAttribute("style"); + expect(allButton.parentElement).toHaveClass("tw-grid"); }); - it('enables all notifications when clicking all button', async () => { - const { commonApiPost } = require('@/services/api/common-api'); + it("allows disabling all drop notifications when subscribed and subscriber limit reached", async () => { + const { commonApiPost } = require("@/services/api/common-api"); const refetch = jest.fn(); - + mockUseWaveNotificationSubscription.mockReturnValue({ - data: { subscribed: false }, + data: { subscribed: true, enabled_group_notifications: [] }, refetch, }); - commonApiPost.mockResolvedValue({}); - - renderComponent(); - - const allButton = screen.getByLabelText('Receive all notifications'); + + renderComponent(mockWaveHighSubscribers); + + const allButton = screen.getByLabelText("Receive all drop notifications"); + expect(allButton).toBeEnabled(); + expect(allButton).toHaveClass("tw-bg-iron-800", "tw-text-primary-400"); + expect(allButton).not.toHaveClass("tw-cursor-not-allowed"); + expect(allButton).not.toHaveAttribute("style"); + expect(allButton.parentElement?.tagName).toBe("DIV"); + await userEvent.click(allButton); - + await waitFor(() => { expect(commonApiPost).toHaveBeenCalledWith({ - endpoint: 'notifications/wave-subscription/wave-123', - body: {}, + endpoint: "notifications/wave-subscription/wave-456", + body: { + subscribed: false, + enabled_group_notifications: [], + }, }); }); - + expect(refetch).toHaveBeenCalled(); }); - it('disables all notifications when clicking mentions button', async () => { - const { commonApiDelete } = require('@/services/api/common-api'); + it("enables ALL mention notifications while preserving all drop preference", async () => { + const { commonApiPost } = require("@/services/api/common-api"); const refetch = jest.fn(); - + mockUseWaveNotificationSubscription.mockReturnValue({ - data: { subscribed: true }, + data: { subscribed: true, enabled_group_notifications: [] }, refetch, }); - - commonApiDelete.mockResolvedValue({}); - + + commonApiPost.mockResolvedValue({}); + renderComponent(); - - const mentionsButton = screen.getByLabelText('Receive mentions-only notifications'); - await userEvent.click(mentionsButton); - + + const allMentionsButton = screen.getByLabelText( + "Receive ALL mention notifications" + ); + await userEvent.click(allMentionsButton); + await waitFor(() => { - expect(commonApiDelete).toHaveBeenCalledWith({ - endpoint: 'notifications/wave-subscription/wave-123', + expect(commonApiPost).toHaveBeenCalledWith({ + endpoint: "notifications/wave-subscription/wave-123", + body: { + subscribed: true, + enabled_group_notifications: [ApiDropGroupMention.All], + }, }); }); - + expect(refetch).toHaveBeenCalled(); }); - it('handles API error when enabling all notifications', async () => { - const { commonApiPost } = require('@/services/api/common-api'); + it("disables ALL mention notifications", async () => { + const { commonApiPost } = require("@/services/api/common-api"); const refetch = jest.fn(); - + mockUseWaveNotificationSubscription.mockReturnValue({ - data: { subscribed: false }, + data: { + subscribed: false, + enabled_group_notifications: [ApiDropGroupMention.All], + }, refetch, }); - - commonApiPost.mockRejectedValue('API Error'); - + + commonApiPost.mockResolvedValue({}); + renderComponent(); - - const allButton = screen.getByLabelText('Receive all notifications'); - await userEvent.click(allButton); - + + const allMentionsButton = screen.getByLabelText( + "Receive ALL mention notifications" + ); + await userEvent.click(allMentionsButton); + await waitFor(() => { - expect(mockAuthContext.setToast).toHaveBeenCalledWith({ - message: 'API Error', - type: 'error', + expect(commonApiPost).toHaveBeenCalledWith({ + endpoint: "notifications/wave-subscription/wave-123", + body: { + subscribed: false, + enabled_group_notifications: [], + }, }); }); + + expect(refetch).toHaveBeenCalled(); }); - it('handles API error when disabling all notifications', async () => { - const { commonApiDelete } = require('@/services/api/common-api'); + it("enables all drop notifications while preserving ALL mention preference", async () => { + const { commonApiPost } = require("@/services/api/common-api"); const refetch = jest.fn(); - + mockUseWaveNotificationSubscription.mockReturnValue({ - data: { subscribed: true }, + data: { + subscribed: false, + enabled_group_notifications: [ApiDropGroupMention.All], + }, refetch, }); - - commonApiDelete.mockRejectedValue('Unable to update subscription'); - + + commonApiPost.mockResolvedValue({}); + renderComponent(); - - const mentionsButton = screen.getByLabelText('Receive mentions-only notifications'); - await userEvent.click(mentionsButton); - + + const allButton = screen.getByLabelText("Receive all drop notifications"); + await userEvent.click(allButton); + await waitFor(() => { - expect(mockAuthContext.setToast).toHaveBeenCalledWith({ - message: 'Unable to update subscription', - type: 'error', + expect(commonApiPost).toHaveBeenCalledWith({ + endpoint: "notifications/wave-subscription/wave-123", + body: { + subscribed: true, + enabled_group_notifications: [ApiDropGroupMention.All], + }, }); }); + + expect(refetch).toHaveBeenCalled(); }); - it('shows loading spinner when toggling notifications', async () => { - const { commonApiPost } = require('@/services/api/common-api'); + it("disables all drop notifications while preserving ALL mention preference", async () => { + const { commonApiPost } = require("@/services/api/common-api"); const refetch = jest.fn(); - + mockUseWaveNotificationSubscription.mockReturnValue({ - data: { subscribed: false }, + data: { + subscribed: true, + enabled_group_notifications: [ApiDropGroupMention.All], + }, refetch, }); - - // Make the API call hang to test loading state - commonApiPost.mockImplementation(() => new Promise(() => {})); - + + commonApiPost.mockResolvedValue({}); + renderComponent(); - - const allButton = screen.getByLabelText('Receive all notifications'); + + const allButton = screen.getByLabelText("Receive all drop notifications"); await userEvent.click(allButton); - - // Check for spinner in the button + await waitFor(() => { - expect(allButton.querySelector('.spinner')).toBeInTheDocument(); + expect(commonApiPost).toHaveBeenCalledWith({ + endpoint: "notifications/wave-subscription/wave-123", + body: { + subscribed: false, + enabled_group_notifications: [ApiDropGroupMention.All], + }, + }); }); + + expect(refetch).toHaveBeenCalled(); }); - it('does not call API when clicking same notification setting', async () => { - const { commonApiPost } = require('@/services/api/common-api'); - + it("does not call API when clicking disabled all drop notifications", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + + renderComponent(mockWaveHighSubscribers); + + const allButton = screen.getByLabelText("Receive all drop notifications"); + await userEvent.click(allButton); + + expect(commonApiPost).not.toHaveBeenCalled(); + }); + + it("does not update notification preferences before preferences load", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + mockUseWaveNotificationSubscription.mockReturnValue({ - data: { subscribed: true }, + data: undefined, + isPending: true, refetch: jest.fn(), }); - + renderComponent(); - - const allButton = screen.getByLabelText('Receive all notifications'); + + const allMentionsButton = screen.getByLabelText( + "Receive ALL mention notifications" + ); + const allButton = screen.getByLabelText("Receive all drop notifications"); + expect(allMentionsButton).toBeDisabled(); + expect(allButton).toBeDisabled(); + + await userEvent.click(allMentionsButton); await userEvent.click(allButton); - + + expect(commonApiPost).not.toHaveBeenCalled(); + }); + + it("lets users retry when notification preferences fail to load", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + const refetch = jest.fn(); + + mockUseWaveNotificationSubscription.mockReturnValue({ + data: undefined, + isError: true, + isFetching: false, + isPending: false, + refetch, + }); + + renderComponent(); + + const retryButton = screen.getByLabelText("Retry notification settings"); + expect(retryButton).toBeEnabled(); + expect( + screen.queryByLabelText("Receive ALL mention notifications") + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Receive all drop notifications") + ).not.toBeInTheDocument(); + + await userEvent.click(retryButton); + + expect(refetch).toHaveBeenCalled(); expect(commonApiPost).not.toHaveBeenCalled(); }); - it('disables all button when wave has high subscriber count', () => { + it("handles API error when enabling all drop notifications", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + + mockUseWaveNotificationSubscription.mockReturnValue({ + data: { subscribed: false, enabled_group_notifications: [] }, + refetch: jest.fn(), + }); + + commonApiPost.mockRejectedValue("API Error"); + + renderComponent(); + + const allButton = screen.getByLabelText("Receive all drop notifications"); + await userEvent.click(allButton); + + await waitFor(() => { + expect(mockAuthContext.setToast).toHaveBeenCalledWith({ + message: "API Error", + type: "error", + }); + }); + }); + + it("handles API error when disabling ALL mention notifications", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + + mockUseWaveNotificationSubscription.mockReturnValue({ + data: { + subscribed: false, + enabled_group_notifications: [ApiDropGroupMention.All], + }, + refetch: jest.fn(), + }); + + commonApiPost.mockRejectedValue("Unable to update subscription"); + + renderComponent(); + + const allMentionsButton = screen.getByLabelText( + "Receive ALL mention notifications" + ); + await userEvent.click(allMentionsButton); + + await waitFor(() => { + expect(mockAuthContext.setToast).toHaveBeenCalledWith({ + message: "Unable to update subscription", + type: "error", + }); + }); + }); + + it("shows loading spinner when toggling notifications", async () => { + const { commonApiPost } = require("@/services/api/common-api"); + const refetch = jest.fn(); + + mockUseWaveNotificationSubscription.mockReturnValue({ + data: { subscribed: false, enabled_group_notifications: [] }, + refetch, + }); + + // Make the API call hang to test loading state + commonApiPost.mockImplementation(() => new Promise(() => {})); + + renderComponent(); + + const allButton = screen.getByLabelText("Receive all drop notifications"); + await userEvent.click(allButton); + + // Check for spinner in the button + await waitFor(() => { + expect(allButton.querySelector(".spinner")).toBeInTheDocument(); + }); + }); + + it("keeps all button focusable when wave has high subscriber count", () => { renderComponent(mockWaveHighSubscribers); - - const allButton = screen.getByLabelText('Receive all notifications'); - expect(allButton).toBeDisabled(); + + const allButton = screen.getByLabelText("Receive all drop notifications"); + expect(allButton).not.toBeDisabled(); + expect(allButton).toHaveAttribute("aria-disabled", "true"); }); - it('renders muted button when wave is muted', () => { + it("renders muted button when wave is muted", () => { renderComponent(mockWaveMuted); - - const mutedButton = screen.getByLabelText('Unmute wave'); + + const mutedButton = screen.getByLabelText("Unmute wave"); expect(mutedButton).toBeInTheDocument(); - expect(screen.getByText('Muted')).toBeInTheDocument(); + expect(screen.getByText("Muted")).toBeInTheDocument(); }); - it('does not render notification settings when wave is muted', () => { + it("does not render notification settings when wave is muted", () => { renderComponent(mockWaveMuted); - - expect(screen.queryByLabelText('Receive mentions-only notifications')).not.toBeInTheDocument(); - expect(screen.queryByLabelText('Receive all notifications')).not.toBeInTheDocument(); + + expect( + screen.queryByLabelText("Receive ALL mention notifications") + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText("Receive all drop notifications") + ).not.toBeInTheDocument(); }); - it('calls unmute API when clicking muted button', async () => { - const { commonApiDelete } = require('@/services/api/common-api'); + it("calls unmute API when clicking muted button", async () => { + const { commonApiDelete } = require("@/services/api/common-api"); commonApiDelete.mockResolvedValue({}); - + renderComponent(mockWaveMuted); - - const mutedButton = screen.getByLabelText('Unmute wave'); + + const mutedButton = screen.getByLabelText("Unmute wave"); await userEvent.click(mutedButton); - + await waitFor(() => { expect(commonApiDelete).toHaveBeenCalledWith({ - endpoint: 'waves/wave-muted/mute', + endpoint: "waves/wave-muted/mute", }); }); }); - it('shows loading spinner when unmuting', async () => { - const { commonApiDelete } = require('@/services/api/common-api'); + it("shows loading spinner when unmuting", async () => { + const { commonApiDelete } = require("@/services/api/common-api"); commonApiDelete.mockImplementation(() => new Promise(() => {})); - + renderComponent(mockWaveMuted); - - const mutedButton = screen.getByLabelText('Unmute wave'); + + const mutedButton = screen.getByLabelText("Unmute wave"); await userEvent.click(mutedButton); - + await waitFor(() => { - expect(mutedButton.querySelector('.spinner')).toBeInTheDocument(); + expect(mutedButton.querySelector(".spinner")).toBeInTheDocument(); }); }); - it('handles error when unmuting fails', async () => { - const { commonApiDelete } = require('@/services/api/common-api'); - commonApiDelete.mockRejectedValue('Unable to unmute wave'); - + it("handles error when unmuting fails", async () => { + const { commonApiDelete } = require("@/services/api/common-api"); + commonApiDelete.mockRejectedValue("Unable to unmute wave"); + renderComponent(mockWaveMuted); - - const mutedButton = screen.getByLabelText('Unmute wave'); + + const mutedButton = screen.getByLabelText("Unmute wave"); await userEvent.click(mutedButton); - + await waitFor(() => { expect(mockAuthContext.setToast).toHaveBeenCalledWith({ - message: 'Unable to unmute wave', - type: 'error', + message: "Unable to unmute wave", + type: "error", }); }); }); -}); \ No newline at end of file +}); diff --git a/__tests__/helpers/waves/drop-group-mentions.test.ts b/__tests__/helpers/waves/drop-group-mentions.test.ts new file mode 100644 index 0000000000..856ada7655 --- /dev/null +++ b/__tests__/helpers/waves/drop-group-mentions.test.ts @@ -0,0 +1,22 @@ +import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; +import { getMentionedGroupsFromParts } from "@/helpers/waves/drop-group-mentions"; + +describe("drop group mentions", () => { + it("does not infer group mentions from raw part content", () => { + const parts: Array<{ + readonly content: string; + readonly mentioned_groups?: ApiDropGroupMention[]; + }> = [{ content: "@all" }]; + + expect(getMentionedGroupsFromParts(parts, true)).toEqual([]); + }); + + it("returns ALL when a part carries explicit group mention metadata", () => { + expect( + getMentionedGroupsFromParts( + [{ mentioned_groups: [ApiDropGroupMention.All] }], + true + ) + ).toEqual([ApiDropGroupMention.All]); + }); +}); diff --git a/__tests__/hooks/useWaveNotificationSubscription.test.ts b/__tests__/hooks/useWaveNotificationSubscription.test.ts index 8aeb4fe913..11159a054a 100644 --- a/__tests__/hooks/useWaveNotificationSubscription.test.ts +++ b/__tests__/hooks/useWaveNotificationSubscription.test.ts @@ -1,37 +1,69 @@ -import { renderHook } from '@testing-library/react'; +import { renderHook } from "@testing-library/react"; const useQueryMock = jest.fn(); -jest.mock('@tanstack/react-query', () => ({ +jest.mock("@tanstack/react-query", () => ({ useQuery: (...args: any) => useQueryMock(...args), })); const commonApiFetch = jest.fn(); -jest.mock('@/services/api/common-api', () => ({ +jest.mock("@/services/api/common-api", () => ({ commonApiFetch: (...args: any) => commonApiFetch(...args), })); -jest.mock('@/contexts/SeizeSettingsContext', () => ({ - useSeizeSettings: () => ({ seizeSettings: { all_drops_notifications_subscribers_limit: 10 } }), -})); +import { useWaveNotificationSubscription } from "@/hooks/useWaveNotificationSubscription"; -import { useWaveNotificationSubscription } from '@/hooks/useWaveNotificationSubscription'; +describe("useWaveNotificationSubscription", () => { + beforeEach(() => { + jest.resetAllMocks(); + }); -describe('useWaveNotificationSubscription', () => { - it('configures useQuery with proper options', async () => { - const wave: any = { id: 'w1', metrics: { subscribers_count: 5 } }; + it("configures useQuery with proper options", async () => { + const wave: any = { + id: "w1", + metrics: { subscribers_count: 5 }, + subscribed_actions: ["DROP_CREATED"], + }; useQueryMock.mockReturnValue({ data: null }); renderHook(() => useWaveNotificationSubscription(wave)); const options = useQueryMock.mock.calls[0][0]; - expect(options.queryKey).toEqual(['wave-notification-subscription', 'w1']); + expect(options.queryKey).toEqual(["wave-notification-subscription", "w1"]); await options.queryFn(); expect(commonApiFetch).toHaveBeenCalledWith({ - endpoint: 'notifications/wave-subscription/w1', + endpoint: "notifications/wave-subscription/w1", }); expect(options.enabled).toBe(true); expect(options.retry(3, new Error())).toBe(false); expect(options.retry(2, new Error())).toBe(true); expect(options.retryDelay(2)).toBe(2000); }); + + it("does not fetch preferences when the wave is not followed", () => { + const wave: any = { + id: "w1", + metrics: { subscribers_count: 5 }, + subscribed_actions: [], + }; + useQueryMock.mockReturnValue({ data: null }); + + renderHook(() => useWaveNotificationSubscription(wave)); + + const options = useQueryMock.mock.calls[0][0]; + expect(options.enabled).toBe(false); + }); + + it("fetches preferences even when all-drop notifications may be disabled by subscriber limits", () => { + const wave: any = { + id: "w1", + metrics: { subscribers_count: 5000 }, + subscribed_actions: ["DROP_CREATED"], + }; + useQueryMock.mockReturnValue({ data: null }); + + renderHook(() => useWaveNotificationSubscription(wave)); + + const options = useQueryMock.mock.calls[0][0]; + expect(options.enabled).toBe(true); + }); }); diff --git a/components/drops/create/lexical/lexical.styles.scss b/components/drops/create/lexical/lexical.styles.scss index 600f6ece81..b699a49692 100644 --- a/components/drops/create/lexical/lexical.styles.scss +++ b/components/drops/create/lexical/lexical.styles.scss @@ -20,6 +20,10 @@ color: rgb(29, 155, 240); } +.editor-group-mention { + color: rgb(29, 155, 240); +} + .editor-hashtag { color: rgb(29, 155, 240); } diff --git a/components/drops/create/lexical/nodes/GroupMentionNode.ts b/components/drops/create/lexical/nodes/GroupMentionNode.ts new file mode 100644 index 0000000000..841707f53c --- /dev/null +++ b/components/drops/create/lexical/nodes/GroupMentionNode.ts @@ -0,0 +1,122 @@ +import { + $applyNodeReplacement, + TextNode, + type DOMConversionMap, + type DOMConversionOutput, + type DOMExportOutput, + type EditorConfig, + type LexicalNode, + type NodeKey, + type SerializedTextNode, + type Spread, +} from "lexical"; + +type SerializedGroupMentionNode = Spread< + { + groupMentionName: string; + }, + SerializedTextNode +>; + +function convertGroupMentionElement(domNode: Node): DOMConversionOutput | null { + const textContent = domNode.textContent; + + if (textContent !== null) { + return { + node: $createGroupMentionNode(textContent), + }; + } + + return null; +} + +export class GroupMentionNode extends TextNode { + __groupMention: string; + + static override getType(): string { + return "group-mention"; + } + + static override clone(node: GroupMentionNode): GroupMentionNode { + return new GroupMentionNode(node.__groupMention, node.__text, node.__key); + } + + static override importJSON( + serializedNode: SerializedGroupMentionNode + ): GroupMentionNode { + const node = $createGroupMentionNode(serializedNode.groupMentionName); + node.setTextContent(serializedNode.text); + node.setFormat(serializedNode.format); + node.setDetail(serializedNode.detail); + node.setMode(serializedNode.mode); + node.setStyle(serializedNode.style); + return node; + } + + constructor(groupMentionName: string, text?: string, key?: NodeKey) { + super(text ?? groupMentionName, key); + this.__groupMention = groupMentionName; + } + + override exportJSON(): SerializedGroupMentionNode { + return { + ...super.exportJSON(), + groupMentionName: this.__groupMention, + type: "group-mention", + version: 1, + }; + } + + override createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + dom.classList.add("editor-group-mention"); + return dom; + } + + override exportDOM(): DOMExportOutput { + const element = document.createElement("span"); + element.setAttribute("data-lexical-group-mention", "true"); + element.textContent = this.__text; + return { element }; + } + + static override importDOM(): DOMConversionMap | null { + return { + span: (domNode: HTMLElement) => { + if (!domNode.hasAttribute("data-lexical-group-mention")) { + return null; + } + return { + conversion: convertGroupMentionElement, + priority: 1, + }; + }, + }; + } + + override isTextEntity(): true { + return true; + } + + override canInsertTextBefore(): boolean { + return false; + } + + override canInsertTextAfter(): boolean { + return false; + } +} + +export function $createGroupMentionNode( + groupMentionName: string +): GroupMentionNode { + const groupMentionNode = new GroupMentionNode(groupMentionName); + groupMentionNode.setMode("segmented").toggleDirectionless(); + return $applyNodeReplacement(groupMentionNode); +} + +export function $isGroupMentionNode( + node: LexicalNode | null | undefined +): node is GroupMentionNode { + return node instanceof GroupMentionNode; +} diff --git a/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx b/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx index 32169263cf..89edf2a978 100644 --- a/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx +++ b/components/drops/create/lexical/plugins/mentions/MentionsPlugin.tsx @@ -1,8 +1,7 @@ "use client"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import type { - MenuTextMatch} from "@lexical/react/LexicalTypeaheadMenuPlugin"; +import type { MenuTextMatch } from "@lexical/react/LexicalTypeaheadMenuPlugin"; import { LexicalTypeaheadMenuPlugin, MenuOption, @@ -19,10 +18,15 @@ import { } from "react"; import * as ReactDOM from "react-dom"; +import { $createGroupMentionNode } from "@/components/drops/create/lexical/nodes/GroupMentionNode"; import { $createMentionNode } from "@/components/drops/create/lexical/nodes/MentionNode"; import MentionsTypeaheadMenu from "./MentionsTypeaheadMenu"; import type { MentionedUser } from "@/entities/IDrop"; -import { useIdentitiesSearch } from "@/hooks/useIdentitiesSearch"; +import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; +import { + IDENTITY_SEARCH_MIN_HANDLE_LENGTH, + useIdentitiesSearch, +} from "@/hooks/useIdentitiesSearch"; import { isInCodeContext } from "@/components/drops/create/lexical/utils/codeContextDetection"; const PUNCTUATION = @@ -102,9 +106,9 @@ function checkForAtSignMentions( // length to add it to the leadOffset const maybeLeadingWhitespace = match[1] ?? ""; - const matchingString = match[3]; + const matchingString = match[3] ?? ""; const replaceableString = match[2] ?? ""; - if (matchingString && matchingString.length >= minMatchLength) { + if (matchingString.length >= minMatchLength) { return { leadOffset: match.index + maybeLeadingWhitespace.length, matchingString, @@ -116,11 +120,12 @@ function checkForAtSignMentions( } function getPossibleQueryMatch(text: string): MenuTextMatch | null { - return checkForAtSignMentions(text, 1); + return checkForAtSignMentions(text, 0); } export class MentionTypeaheadOption extends MenuOption { - id: string; + type: "identity" | "group"; + id: string | null; handle: string; display: string | null; picture: string | null; @@ -130,13 +135,16 @@ export class MentionTypeaheadOption extends MenuOption { handle, display, picture, + type = "identity", }: { - id: string; + id: string | null; handle: string; display: string | null; picture: string | null; + type?: "identity" | "group" | undefined; }) { super(handle); + this.type = type; this.id = id; this.handle = handle; this.display = display; @@ -153,8 +161,12 @@ const NewMentionsPlugin = forwardRef< { readonly waveId: string | null; readonly onSelect: (user: Omit) => void; + readonly canMentionAll?: boolean | undefined; + readonly onSelectGroupMention?: + | ((group: ApiDropGroupMention) => void) + | undefined; } ->(({ waveId, onSelect }, ref) => { +>(({ waveId, onSelect, canMentionAll = false, onSelectGroupMention }, ref) => { const [editor] = useLexicalComposerContext(); const [queryString, setQueryString] = useState(null); const { identities } = useIdentitiesSearch({ @@ -162,30 +174,50 @@ const NewMentionsPlugin = forwardRef< waveId, }); const [isOpen, setIsOpen] = useState(false); - const isMentionsOpen = () => isOpen; const modalRef = useRef(null); - useImperativeHandle(ref, () => ({ - isMentionsOpen, - })); const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch("/", { minLength: 0, }); - const options = useMemo( - () => - identities - .map( - (identity) => + const options = useMemo(() => { + const normalizedQuery = (queryString ?? "").toLowerCase(); + const allOption = + canMentionAll && + normalizedQuery.length >= IDENTITY_SEARCH_MIN_HANDLE_LENGTH && + "all".startsWith(normalizedQuery) + ? [ new MentionTypeaheadOption({ - id: identity.id ?? identity.primary_wallet, - handle: identity.handle ?? identity.primary_wallet, - display: identity.display, - picture: identity.pfp, - }) - ) - .slice(0, SUGGESTION_LIST_LENGTH_LIMIT), - [identities] + id: ApiDropGroupMention.All, + handle: "@all", + display: "Mention everyone", + picture: null, + type: "group", + }), + ] + : []; + const identityLimit = SUGGESTION_LIST_LENGTH_LIMIT - allOption.length; + const identityOptions = identities + .map( + (identity) => + new MentionTypeaheadOption({ + id: identity.id ?? identity.primary_wallet, + handle: identity.handle ?? identity.primary_wallet, + display: identity.display, + picture: identity.pfp, + }) + ) + .slice(0, identityLimit); + + return [...allOption, ...identityOptions]; + }, [canMentionAll, identities, queryString]); + + useImperativeHandle( + ref, + () => ({ + isMentionsOpen: () => isOpen && options.length > 0, + }), + [isOpen, options.length] ); const onSelectOption = useCallback( @@ -195,6 +227,22 @@ const NewMentionsPlugin = forwardRef< closeMenu: () => void ) => { editor.update(() => { + if (selectedOption.type === "group") { + const mentionNode = $createGroupMentionNode("@all"); + if (nodeToReplace) { + nodeToReplace.replace(mentionNode); + } + mentionNode.select(); + onSelectGroupMention?.(ApiDropGroupMention.All); + closeMenu(); + return; + } + + if (!selectedOption.id) { + closeMenu(); + return; + } + const mentionNode = $createMentionNode(`@${selectedOption.handle}`); if (nodeToReplace) { nodeToReplace.replace(mentionNode); @@ -207,7 +255,7 @@ const NewMentionsPlugin = forwardRef< closeMenu(); }); }, - [editor] + [editor, onSelect, onSelectGroupMention] ); const checkForMentionMatch = useCallback( @@ -238,7 +286,7 @@ const NewMentionsPlugin = forwardRef< anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex } ) => { - return anchorElementRef.current && identities.length + return anchorElementRef.current && options.length ? ReactDOM.createPortal(
+ {/* Typeahead avatar URLs can use hosts outside next/image remotePatterns, so this stays unoptimized. */} {`Profile
diff --git a/components/drops/create/lexical/transformers/GroupMentionTransformer.ts b/components/drops/create/lexical/transformers/GroupMentionTransformer.ts new file mode 100644 index 0000000000..d1039e00f2 --- /dev/null +++ b/components/drops/create/lexical/transformers/GroupMentionTransformer.ts @@ -0,0 +1,41 @@ +import type { TextMatchTransformer } from "@lexical/markdown"; + +import { ALL_GROUP_MENTION_TEXT } from "@/helpers/waves/drop-group-mentions"; +import { + $createGroupMentionNode, + $isGroupMentionNode, + GroupMentionNode, +} from "../nodes/GroupMentionNode"; + +const GROUP_MENTION_IMPORT_REGEXP = /(^|[^A-Za-z0-9_@])(@all)(?![A-Za-z0-9_@])/; +const GROUP_MENTION_SHORTCUT_REGEXP = + /(^|[^A-Za-z0-9_@])(@all)(?![A-Za-z0-9_@])$/; + +export const GROUP_MENTION_TRANSFORMER: TextMatchTransformer = { + dependencies: [GroupMentionNode], + export: (node) => { + if (!$isGroupMentionNode(node)) { + return null; + } + + return node.getTextContent(); + }, + regExp: GROUP_MENTION_SHORTCUT_REGEXP, + importRegExp: GROUP_MENTION_IMPORT_REGEXP, + replace: (textNode, match) => { + const [, prefix = ""] = match; + + const nodeToReplace = prefix.length + ? textNode.splitText(prefix.length)[1] + : textNode; + + if (!nodeToReplace) { + return; + } + + const groupMentionNode = $createGroupMentionNode(ALL_GROUP_MENTION_TEXT); + nodeToReplace.replace(groupMentionNode); + }, + trigger: "l", + type: "text-match", +}; diff --git a/components/drops/create/lexical/utils/groupMentionDetection.ts b/components/drops/create/lexical/utils/groupMentionDetection.ts new file mode 100644 index 0000000000..7aa16da206 --- /dev/null +++ b/components/drops/create/lexical/utils/groupMentionDetection.ts @@ -0,0 +1,26 @@ +import { $getRoot, type EditorState } from "lexical"; + +import { $isGroupMentionNode } from "@/components/drops/create/lexical/nodes/GroupMentionNode"; +import { ALL_GROUP_MENTION_TEXT } from "@/helpers/waves/drop-group-mentions"; +import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; + +export const getMentionedGroupsFromEditorState = ( + editorState: EditorState, + canMentionAll: boolean +): ApiDropGroupMention[] => { + if (!canMentionAll) { + return []; + } + + return editorState.read(() => { + const hasAllGroupMentionNode = $getRoot() + .getAllTextNodes() + .some( + (node) => + $isGroupMentionNode(node) && + node.getTextContent() === ALL_GROUP_MENTION_TEXT + ); + + return hasAllGroupMentionNode ? [ApiDropGroupMention.All] : []; + }); +}; diff --git a/components/drops/create/utils/storm/CreateDropStormView.tsx b/components/drops/create/utils/storm/CreateDropStormView.tsx index a283ecb590..2eb13d1d77 100644 --- a/components/drops/create/utils/storm/CreateDropStormView.tsx +++ b/components/drops/create/utils/storm/CreateDropStormView.tsx @@ -31,6 +31,7 @@ const CreateDropStormView = memo( part={part} referencedNfts={drop.referenced_nfts} mentionedUsers={drop.mentioned_users} + mentionedGroups={drop.mentioned_groups ?? []} mentionedWaves={drop.mentioned_waves ?? []} createdAt={now} partIndex={index} diff --git a/components/drops/create/utils/storm/CreateDropStormViewPart.tsx b/components/drops/create/utils/storm/CreateDropStormViewPart.tsx index 6bb7a62e12..9cca1a4230 100644 --- a/components/drops/create/utils/storm/CreateDropStormViewPart.tsx +++ b/components/drops/create/utils/storm/CreateDropStormViewPart.tsx @@ -5,6 +5,7 @@ import type { MentionedWave, ReferencedNft, } from "@/entities/IDrop"; +import type { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; import DropPart from "@/components/drops/view/part/DropPart"; import CreateDropStormViewPartQuote from "./CreateDropStormViewPartQuote"; import type { ProfileMinWithoutSubs } from "@/helpers/ProfileTypes"; @@ -19,6 +20,7 @@ interface CreateDropStormViewPartProps { readonly profile: ProfileMinWithoutSubs; readonly part: CreateDropPart; readonly mentionedUsers: Array>; + readonly mentionedGroups: ApiDropGroupMention[]; readonly mentionedWaves: MentionedWave[]; readonly referencedNfts: Array; readonly createdAt: number; @@ -33,6 +35,7 @@ const CreateDropStormViewPart = memo( profile, part, mentionedUsers, + mentionedGroups, mentionedWaves, referencedNfts, createdAt, @@ -56,6 +59,7 @@ const CreateDropStormViewPart = memo( ; + readonly mentionedGroups: Array; readonly mentionedWaves: Array; readonly referencedNfts: Array; readonly createdAt: number; @@ -62,6 +64,7 @@ export default function CreateDropStormViewPartQuote({ dropId: drop.id, part, mentionedUsers: drop.mentioned_users, + mentionedGroups: drop.mentioned_groups, mentionedWaves: drop.mentioned_waves, referencedNfts: drop.referenced_nfts, createdAt: drop.created_at, @@ -85,6 +88,7 @@ export default function CreateDropStormViewPartQuote({ dropId={partConfig.dropId} profile={profile} mentionedUsers={partConfig.mentionedUsers} + mentionedGroups={partConfig.mentionedGroups} mentionedWaves={partConfig.mentionedWaves} referencedNfts={partConfig.referencedNfts} smallMenuIsShown={false} diff --git a/components/drops/view/item/content/DropListItemContentGroupMention.tsx b/components/drops/view/item/content/DropListItemContentGroupMention.tsx new file mode 100644 index 0000000000..28e16c6c58 --- /dev/null +++ b/components/drops/view/item/content/DropListItemContentGroupMention.tsx @@ -0,0 +1,7 @@ +export default function DropListItemContentGroupMention() { + return ( + + @all + + ); +} diff --git a/components/drops/view/item/content/DropListItemContentPart.tsx b/components/drops/view/item/content/DropListItemContentPart.tsx index 2ba0309bf5..391d72a3fb 100644 --- a/components/drops/view/item/content/DropListItemContentPart.tsx +++ b/components/drops/view/item/content/DropListItemContentPart.tsx @@ -2,6 +2,8 @@ import type { MentionedUser, ReferencedNft } from "@/entities/IDrop"; import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import { assertUnreachable } from "@/helpers/AllowlistToolHelpers"; import DropListItemContentNft from "./nft-tag/DropListItemContentNft"; +import type { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; +import DropListItemContentGroupMention from "./DropListItemContentGroupMention"; import DropListItemContentMention from "./DropListItemContentMention"; import DropListItemContentWaveMention from "./DropListItemContentWaveMention"; import { DropContentPartType } from "@/components/drops/view/part/DropPartMarkdown"; @@ -18,6 +20,12 @@ interface DropListItemContentHashtagProps { readonly match: string; } +interface DropListItemContentGroupMentionProps { + readonly type: DropContentPartType.GROUP_MENTION; + readonly value: ApiDropGroupMention; + readonly match: string; +} + interface DropListItemContentWaveMentionProps { readonly type: DropContentPartType.WAVE_MENTION; readonly value: ApiMentionedWave; @@ -26,6 +34,7 @@ interface DropListItemContentWaveMentionProps { export type DropListItemContentPartProps = | DropListItemContentMentionProps + | DropListItemContentGroupMentionProps | DropListItemContentHashtagProps | DropListItemContentWaveMentionProps; @@ -38,6 +47,8 @@ export default function DropListItemContentPart({ switch (type) { case DropContentPartType.MENTION: return ; + case DropContentPartType.GROUP_MENTION: + return ; case DropContentPartType.HASHTAG: return ; case DropContentPartType.WAVE_MENTION: diff --git a/components/drops/view/part/DropPart.tsx b/components/drops/view/part/DropPart.tsx index e8d41d6d02..64b30159c3 100644 --- a/components/drops/view/part/DropPart.tsx +++ b/components/drops/view/part/DropPart.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import { memo, useRef } from "react"; import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser"; +import type { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import type { ApiDropReferencedNFT } from "@/generated/models/ApiDropReferencedNFT"; import DropPfp from "@/components/drops/create/utils/DropPfp"; @@ -31,6 +32,7 @@ interface DropPartProps { readonly profile: ProfileMinWithoutSubs; readonly dropTitle: string | null; readonly mentionedUsers: Array; + readonly mentionedGroups?: Array | undefined; readonly mentionedWaves: Array; readonly referencedNfts: Array; readonly partContent: string | null; @@ -56,6 +58,7 @@ const DropPart = memo( dropId, profile, mentionedUsers, + mentionedGroups = [], mentionedWaves, referencedNfts, partContent, @@ -221,6 +224,7 @@ const DropPart = memo( )} = ({ mentionedUsers, + mentionedGroups = [], mentionedWaves, referencedNfts, partContent, @@ -34,6 +37,7 @@ const DropPartContent: React.FC = ({
; + readonly mentionedGroups?: Array | undefined; readonly mentionedWaves: Array; readonly referencedNfts: Array; readonly nftLinks?: readonly ApiDropNftLink[] | undefined; @@ -266,6 +268,7 @@ export interface DropPartMarkdownProps { function DropPartMarkdown({ mentionedUsers, + mentionedGroups = [], mentionedWaves, referencedNfts, nftLinks, @@ -350,6 +353,7 @@ function DropPartMarkdown({ createMarkdownContentRenderers({ textSizeClass, mentionedUsers, + mentionedGroups, mentionedWaves, referencedNfts, emojiMap, @@ -359,6 +363,7 @@ function DropPartMarkdown({ [ textSizeClass, mentionedUsers, + mentionedGroups, mentionedWaves, referencedNfts, emojiMap, diff --git a/components/drops/view/part/DropPartMarkdownWithPropLogger.tsx b/components/drops/view/part/DropPartMarkdownWithPropLogger.tsx index 39b5196cd6..3fd5d5569c 100644 --- a/components/drops/view/part/DropPartMarkdownWithPropLogger.tsx +++ b/components/drops/view/part/DropPartMarkdownWithPropLogger.tsx @@ -41,6 +41,7 @@ function areEqual( ) { const propsToCheck: (keyof DropPartMarkdownProps)[] = [ "mentionedUsers", + "mentionedGroups", "mentionedWaves", "referencedNfts", "nftLinks", diff --git a/components/drops/view/part/dropPartMarkdown/content.tsx b/components/drops/view/part/dropPartMarkdown/content.tsx index 0000cf9bd7..466b2735c0 100644 --- a/components/drops/view/part/dropPartMarkdown/content.tsx +++ b/components/drops/view/part/dropPartMarkdown/content.tsx @@ -5,12 +5,20 @@ import type { ExtraProps } from "react-markdown"; import { getRandomObjectId } from "@/helpers/AllowlistToolHelpers"; import type { DropListItemContentPartProps } from "@/components/drops/view/item/content/DropListItemContentPart"; import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser"; +import type { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; +import { ApiDropGroupMention as ApiDropGroupMentionValue } from "@/generated/models/ApiDropGroupMention"; import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import type { ApiDropReferencedNFT } from "@/generated/models/ApiDropReferencedNFT"; import DropListItemContentPart from "@/components/drops/view/item/content/DropListItemContentPart"; +import { + ALL_GROUP_MENTION_TEXT, + hasMentionedGroup, + markAllGroupMentionTokens, +} from "@/helpers/waves/drop-group-mentions"; export enum DropContentPartType { MENTION = "MENTION", + GROUP_MENTION = "GROUP_MENTION", HASHTAG = "HASHTAG", WAVE_MENTION = "WAVE_MENTION", } @@ -28,6 +36,7 @@ type FindNativeEmoji = (emojiId: string) => { skins: NativeEmojiSkin[] } | null; interface MarkdownContentConfig { readonly textSizeClass: string; readonly mentionedUsers: Array; + readonly mentionedGroups: Array; readonly mentionedWaves: Array; readonly referencedNfts: Array; readonly emojiMap: EmojiCategory[]; @@ -50,6 +59,7 @@ const emojiRegex = /(:\w+:)/g; export const createMarkdownContentRenderers = ({ textSizeClass, mentionedUsers, + mentionedGroups, mentionedWaves, referencedNfts, emojiMap, @@ -102,6 +112,15 @@ export const createMarkdownContentRenderers = ({ }), {} ), + ...(hasMentionedGroup(mentionedGroups, ApiDropGroupMentionValue.All) + ? { + [ALL_GROUP_MENTION_TEXT]: { + type: DropContentPartType.GROUP_MENTION, + value: ApiDropGroupMentionValue.All, + match: ALL_GROUP_MENTION_TEXT, + }, + } + : {}), ...mentionedWaves.reduce( (acc, wave) => ({ ...acc, @@ -140,12 +159,22 @@ export const createMarkdownContentRenderers = ({ let currentContent = content; for (const token of Object.values(values)) { + if (token.type === DropContentPartType.GROUP_MENTION) { + continue; + } currentContent = currentContent.replaceAll( token.match, `${splitter}${token.match}${splitter}` ); } + if (hasMentionedGroup(mentionedGroups, ApiDropGroupMentionValue.All)) { + currentContent = markAllGroupMentionTokens({ + content: currentContent, + marker: splitter, + }); + } + const parts = currentContent .split(splitter) .filter((part) => part !== "") diff --git a/components/waves/CreateDrop.tsx b/components/waves/CreateDrop.tsx index 54a9af019c..d6743a7bbe 100644 --- a/components/waves/CreateDrop.tsx +++ b/components/waves/CreateDrop.tsx @@ -26,6 +26,7 @@ import { resolveWaveSubmissionExperience, WaveSubmissionExperience, } from "@/helpers/waves/wave-submission-experience.helpers"; +import { getMentionedGroupsFromParts } from "@/helpers/waves/drop-group-mentions"; interface CreateDropProps { readonly activeDrop: ActiveDropState | null; @@ -113,6 +114,8 @@ export default function CreateDrop({ const isCurationDropMode = submissionExperience === WaveSubmissionExperience.CURATION_LEGACY && isDropMode; + const canMentionAll = + wave.wave.authenticated_user_eligible_for_admin === true; const canSwitchDropMode = useCallback( (newIsDropMode: boolean) => { @@ -173,20 +176,27 @@ export default function CreateDrop({ [canSwitchDropMode, modeScopeToken] ); - const onRemovePart = useCallback((partIndex: number) => { - setDrop((prevDrop) => { - if (!prevDrop) return null; - const newParts = prevDrop.parts.filter((_, i) => i !== partIndex); - return { - ...prevDrop, - parts: newParts, - referenced_nfts: prevDrop.referenced_nfts, - mentioned_users: prevDrop.mentioned_users, - mentioned_waves: prevDrop.mentioned_waves ?? [], - metadata: prevDrop.metadata, - }; - }); - }, []); + const onRemovePart = useCallback( + (partIndex: number) => { + setDrop((prevDrop) => { + if (!prevDrop) return null; + const newParts = prevDrop.parts.filter((_, i) => i !== partIndex); + return { + ...prevDrop, + parts: newParts, + referenced_nfts: prevDrop.referenced_nfts, + mentioned_users: prevDrop.mentioned_users, + mentioned_groups: getMentionedGroupsFromParts( + newParts, + canMentionAll + ), + mentioned_waves: prevDrop.mentioned_waves ?? [], + metadata: prevDrop.metadata, + }; + }); + }, + [canMentionAll] + ); const addDropMutation = useMutation({ mutationFn: async (body: DropMutationBody) => { @@ -344,6 +354,7 @@ export default function CreateDrop({ generateMediaForPart(media, setUploadingFiles)) ); return { - ...part, + content: part.content, + quoted_drop: part.quoted_drop, media, }; }; @@ -545,6 +550,7 @@ const CreateDropContent: React.FC = ({ const hasMetadataValidationErrors = Object.keys(metadataErrorById).length > 0; const hasMetadata = useMemo(() => hasMetadataContent(metadata), [metadata]); + const canMentionAll = wave.wave.authenticated_user_eligible_for_admin; const getMarkdown = useMemo( () => @@ -552,13 +558,21 @@ const CreateDropContent: React.FC = ({ ? exportDropMarkdown(editorState, [ ...SAFE_MARKDOWN_TRANSFORMERS, MENTION_TRANSFORMER, + ...(canMentionAll ? [GROUP_MENTION_TRANSFORMER] : []), HASHTAG_TRANSFORMER, WAVE_MENTION_TRANSFORMER, IMAGE_TRANSFORMER, EMOJI_TRANSFORMER, ]) : null, - [editorState] + [canMentionAll, editorState] + ); + const currentPartMentionedGroups = useMemo( + () => + editorState + ? getMentionedGroupsFromEditorState(editorState, canMentionAll) + : [], + [canMentionAll, editorState] ); const isStormModeActive = isStormMode; @@ -729,6 +743,7 @@ const CreateDropContent: React.FC = ({ ...replyToObj, parts: ensurePartsWithFallback(baseParts, hasMetadata), mentioned_users: drop?.mentioned_users ?? [], + mentioned_groups: getMentionedGroupsFromParts(baseParts, canMentionAll), mentioned_waves: drop?.mentioned_waves ?? [], referenced_nfts: drop?.referenced_nfts ?? [], metadata: getSubmissionMetadata(), @@ -745,25 +760,29 @@ const CreateDropContent: React.FC = ({ const replyToObj = replyTo ? { reply_to: replyTo } : {}; const createGifDrop = (gif: string): CreateDropConfig => { + const parts: CreateDropPart[] = [ + ...(drop?.parts ?? []), + { + content: gif, + quoted_drop: + activeDrop?.action === ActiveDropAction.QUOTE + ? { + drop_id: activeDrop.drop.id, + drop_part_id: activeDrop.partId, + } + : null, + media: files, + mentioned_groups: [], + }, + ]; + return { title: null, drop_type: isDropMode ? ApiDropType.Participatory : ApiDropType.Chat, ...replyToObj, - parts: [ - ...(drop?.parts ?? []), - { - content: gif, - quoted_drop: - activeDrop?.action === ActiveDropAction.QUOTE - ? { - drop_id: activeDrop.drop.id, - drop_part_id: activeDrop.partId, - } - : null, - media: files, - }, - ], + parts, mentioned_users: [], + mentioned_groups: getMentionedGroupsFromParts(parts, canMentionAll), mentioned_waves: [], referenced_nfts: [], metadata: getSubmissionMetadata(), @@ -777,7 +796,8 @@ const CreateDropContent: React.FC = ({ markdown: string | null, allMentions: ApiDropMentionedUser[], allNfts: ReferencedNft[], - allWaves: ApiMentionedWave[] + allWaves: ApiMentionedWave[], + currentMentionedGroups: ApiDropGroupMention[] ): CreateDropConfig => { const availableFiles = files; const hasPartsInDrop = (drop?.parts.length ?? 0) > 0; @@ -802,6 +822,7 @@ const CreateDropContent: React.FC = ({ content: markdown?.length ? markdown : null, quoted_drop: quotedDrop, media: availableFiles, + mentioned_groups: currentMentionedGroups, }, ]; @@ -814,6 +835,7 @@ const CreateDropContent: React.FC = ({ ...replyToObj, parts, mentioned_users: allMentions, + mentioned_groups: getMentionedGroupsFromParts(parts, canMentionAll), mentioned_waves: allWaves, referenced_nfts: allNfts, metadata: getSubmissionMetadata(), @@ -848,7 +870,8 @@ const CreateDropContent: React.FC = ({ updatedMarkdown, updatedMentions, updatedNfts, - updatedWaves + updatedWaves, + currentPartMentionedGroups ); }; @@ -890,6 +913,13 @@ const CreateDropContent: React.FC = ({ ) ); + const filterMentionedGroups = ({ + parts, + }: { + readonly parts: CreateDropPart[]; + }): ApiDropGroupMention[] => + getMentionedGroupsFromParts(parts, canMentionAll); + const [dropEditorRefreshKey, setDropEditorRefreshKey] = useState(0); const refreshState = () => { @@ -1001,6 +1031,9 @@ const CreateDropContent: React.FC = ({ mentionedWaves: dropRequest.mentioned_waves ?? [], parts: dropRequest.parts, }), + mentioned_groups: filterMentionedGroups({ + parts: dropRequest.parts, + }), metadata: dropRequest.metadata, wave_id: wave.id, parts, @@ -1602,6 +1635,7 @@ const CreateDropContent: React.FC = ({ submitting={submitting} isStormMode={isStormModeActive} isDropMode={isDropMode} + canMentionAll={canMentionAll} canSubmit={canSubmit} onEditorState={handleEditorStateChange} onEditorBlur={handleEditorBlur} diff --git a/components/waves/CreateDropInput.tsx b/components/waves/CreateDropInput.tsx index f9c6f541aa..608fa6a677 100644 --- a/components/waves/CreateDropInput.tsx +++ b/components/waves/CreateDropInput.tsx @@ -36,6 +36,7 @@ import type { } from "@/entities/IDrop"; import { ActiveDropAction } from "@/types/dropInteractionTypes"; import { MentionNode } from "../drops/create/lexical/nodes/MentionNode"; +import { GroupMentionNode } from "../drops/create/lexical/nodes/GroupMentionNode"; import { HashtagNode } from "../drops/create/lexical/nodes/HashtagNode"; import { WaveMentionNode } from "../drops/create/lexical/nodes/WaveMentionNode"; import { ImageNode } from "../drops/create/lexical/nodes/ImageNode"; @@ -98,6 +99,7 @@ const CreateDropInput = forwardRef< readonly isStormMode: boolean; readonly submitting: boolean; readonly isDropMode: boolean; + readonly canMentionAll?: boolean | undefined; readonly onDrop?: (() => void) | undefined; readonly onEditorState: (editorState: EditorState) => void; readonly onEditorBlur?: (event: FocusEvent) => void; @@ -116,6 +118,7 @@ const CreateDropInput = forwardRef< canSubmit, isStormMode, isDropMode, + canMentionAll = false, submitting, onEditorState, onEditorBlur, @@ -131,6 +134,7 @@ const CreateDropInput = forwardRef< namespace: "User Drop", nodes: [ MentionNode, + GroupMentionNode, HashtagNode, WaveMentionNode, RootNode, @@ -295,6 +299,7 @@ const CreateDropInput = forwardRef< void; @@ -17,6 +19,7 @@ const CreateDropStormPart: React.FC = ({ partIndex, part, mentionedUsers, + mentionedGroups, mentionedWaves, referencedNfts, onRemovePart, @@ -29,6 +32,7 @@ const CreateDropStormPart: React.FC = ({
void; @@ -24,6 +26,7 @@ interface CreateDropStormPartsProps { const CreateDropStormParts: FC = ({ parts, mentionedUsers, + mentionedGroups, mentionedWaves, referencedNfts, onRemovePart, @@ -115,6 +118,7 @@ const CreateDropStormParts: FC = ({ partIndex={partIndex} part={part} mentionedUsers={mentionedUsers} + mentionedGroups={mentionedGroups} mentionedWaves={mentionedWaves} referencedNfts={referencedNfts} onRemovePart={onRemovePart} diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx index 0e41d30cf3..6af69f5be6 100644 --- a/components/waves/drops/EditDropLexical.tsx +++ b/components/waves/drops/EditDropLexical.tsx @@ -1,6 +1,9 @@ "use client"; -import { $convertFromMarkdownString } from "@lexical/markdown"; +import { + $convertFromMarkdownString, + type Transformer, +} from "@lexical/markdown"; import type { InitialConfigType } from "@lexical/react/LexicalComposer"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; @@ -50,6 +53,7 @@ import { $createMentionNode, MentionNode, } from "@/components/drops/create/lexical/nodes/MentionNode"; +import { GroupMentionNode } from "@/components/drops/create/lexical/nodes/GroupMentionNode"; import EmojiPlugin from "@/components/drops/create/lexical/plugins/emoji/EmojiPlugin"; import type { NewMentionsPluginHandles } from "@/components/drops/create/lexical/plugins/mentions/MentionsPlugin"; import NewMentionsPlugin from "@/components/drops/create/lexical/plugins/mentions/MentionsPlugin"; @@ -59,8 +63,11 @@ import PlainTextPastePlugin from "@/components/drops/create/lexical/plugins/Plai import { HASHTAG_TRANSFORMER } from "@/components/drops/create/lexical/transformers/HastagTransformer"; import { SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE } from "@/components/drops/create/lexical/transformers/markdownTransformers"; import { MENTION_TRANSFORMER } from "@/components/drops/create/lexical/transformers/MentionTransformer"; +import { GROUP_MENTION_TRANSFORMER } from "@/components/drops/create/lexical/transformers/GroupMentionTransformer"; +import { getMentionedGroupsFromEditorState } from "@/components/drops/create/lexical/utils/groupMentionDetection"; import { WAVE_MENTION_TRANSFORMER } from "@/components/drops/create/lexical/transformers/WaveMentionTransformer"; import type { MentionedUser, MentionedWave } from "@/entities/IDrop"; +import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser"; import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import useDeviceInfo from "@/hooks/useDeviceInfo"; @@ -73,16 +80,20 @@ import { exportDropMarkdown, normalizeDropMarkdown, } from "./normalizeDropMarkdown"; +import { areMentionedGroupsEqual } from "@/helpers/waves/drop-group-mentions"; interface EditDropLexicalProps { readonly initialContent: string; readonly initialMentions: ApiDropMentionedUser[]; + readonly initialGroupMentions: ApiDropGroupMention[]; readonly initialWaveMentions: ApiMentionedWave[]; + readonly canMentionAll: boolean; readonly waveId: string | null; readonly isSaving: boolean; readonly onSave: ( content: string, mentions: ApiDropMentionedUser[], + mentionedGroups: ApiDropGroupMention[], mentionedWaves: ApiMentionedWave[] ) => void; readonly onCancel: () => void; @@ -90,7 +101,7 @@ interface EditDropLexicalProps { const MAX_MENTION_RECONSTRUCTION_PASSES = 20; -const EDIT_MARKDOWN_TRANSFORMERS = [ +const BASE_EDIT_MARKDOWN_TRANSFORMERS = [ ...SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE, MENTION_TRANSFORMER, HASHTAG_TRANSFORMER, @@ -266,13 +277,19 @@ function processSplitMentions(textNodes: Array): boolean { return false; } -function InitialContentPlugin({ initialContent }: { initialContent: string }) { +function InitialContentPlugin({ + initialContent, + transformers, +}: { + initialContent: string; + transformers: Transformer[]; +}) { const [editor] = useLexicalComposerContext(); useEffect(() => { editor.update(() => { const normalizedContent = normalizeDropMarkdown(initialContent); - $convertFromMarkdownString(normalizedContent, EDIT_MARKDOWN_TRANSFORMERS); + $convertFromMarkdownString(normalizedContent, transformers); const root = $getRoot(); convertCodeNodesToFences(root); @@ -306,7 +323,7 @@ function InitialContentPlugin({ initialContent }: { initialContent: string }) { root.selectEnd(); }); - }, [editor, initialContent]); + }, [editor, initialContent, transformers]); return null; } @@ -351,6 +368,9 @@ function KeyboardPlugin({ isSaving, isMobileOrApp, initialContent, + initialGroupMentions, + canResolveAllGroupMention, + transformers, mentionsRef, waveMentionsRef, }: { @@ -359,6 +379,9 @@ function KeyboardPlugin({ isSaving: boolean; isMobileOrApp: boolean; initialContent: string; + initialGroupMentions: ApiDropGroupMention[]; + canResolveAllGroupMention: boolean; + transformers: Transformer[]; mentionsRef: React.RefObject; waveMentionsRef: React.RefObject; }) { @@ -396,12 +419,21 @@ function KeyboardPlugin({ if (!isSaving) { const currentMarkdown = exportDropMarkdown( editor.getEditorState(), - EDIT_MARKDOWN_TRANSFORMERS + transformers ); const sanitizedCurrentMarkdown = removeBlankLinePlaceholders(currentMarkdown); + const currentMentionedGroups = getMentionedGroupsFromEditorState( + editor.getEditorState(), + canResolveAllGroupMention + ); if ( - sanitizedCurrentMarkdown.trim() === sanitizedInitialContent.trim() + sanitizedCurrentMarkdown.trim() === + sanitizedInitialContent.trim() && + areMentionedGroupsEqual( + currentMentionedGroups, + initialGroupMentions + ) ) { onCancel(); } else { @@ -424,6 +456,9 @@ function KeyboardPlugin({ isSaving, isMobileOrApp, initialContent, + initialGroupMentions, + canResolveAllGroupMention, + transformers, mentionsRef, waveMentionsRef, sanitizedInitialContent, @@ -435,7 +470,9 @@ function KeyboardPlugin({ const EditDropLexical: React.FC = ({ initialContent, initialMentions, + initialGroupMentions, initialWaveMentions, + canMentionAll, waveId, isSaving, onSave, @@ -459,7 +496,24 @@ const EditDropLexical: React.FC = ({ () => addBlankLinePlaceholders(normalizedInitialContent), [normalizedInitialContent] ); - + const hasInitialAllGroupMention = initialGroupMentions.includes( + ApiDropGroupMention.All + ); + const canResolveAllGroupMention = canMentionAll || hasInitialAllGroupMention; + const importMarkdownTransformers = useMemo( + () => + hasInitialAllGroupMention + ? [...BASE_EDIT_MARKDOWN_TRANSFORMERS, GROUP_MENTION_TRANSFORMER] + : BASE_EDIT_MARKDOWN_TRANSFORMERS, + [hasInitialAllGroupMention] + ); + const exportMarkdownTransformers = useMemo( + () => + canResolveAllGroupMention + ? [...BASE_EDIT_MARKDOWN_TRANSFORMERS, GROUP_MENTION_TRANSFORMER] + : BASE_EDIT_MARKDOWN_TRANSFORMERS, + [canResolveAllGroupMention] + ); const initialConfig: InitialConfigType = { namespace: "EditDropLexical", theme: ExampleTheme, @@ -477,6 +531,7 @@ const EditDropLexical: React.FC = ({ AutoLinkNode, LinkNode, MentionNode, + GroupMentionNode, HashtagNode, WaveMentionNode, EmojiNode, @@ -527,21 +582,36 @@ const EditDropLexical: React.FC = ({ const markdown = exportDropMarkdown( editorState, - EDIT_MARKDOWN_TRANSFORMERS + exportMarkdownTransformers ); const sanitizedMarkdown = removeBlankLinePlaceholders(markdown); + const sanitizedMentionedGroups = getMentionedGroupsFromEditorState( + editorState, + canResolveAllGroupMention + ); - if (sanitizedMarkdown.trim() === normalizedInitialContent.trim()) { + if ( + sanitizedMarkdown.trim() === normalizedInitialContent.trim() && + areMentionedGroupsEqual(sanitizedMentionedGroups, initialGroupMentions) + ) { onCancel(); return; } - onSave(sanitizedMarkdown, mentionedUsers, mentionedWaves); + onSave( + sanitizedMarkdown, + mentionedUsers, + sanitizedMentionedGroups, + mentionedWaves + ); }, [ editorState, + exportMarkdownTransformers, mentionedUsers, mentionedWaves, + canResolveAllGroupMention, + initialGroupMentions, onSave, normalizedInitialContent, onCancel, @@ -575,7 +645,7 @@ const EditDropLexical: React.FC = ({ @@ -583,13 +653,17 @@ const EditDropLexical: React.FC = ({ ref={mentionsRef} waveId={waveId} onSelect={handleMentionSelect} + canMentionAll={canMentionAll} /> - + = ({ isSaving={isSaving} isMobileOrApp={isMobileOrApp} initialContent={normalizedInitialContent} + initialGroupMentions={initialGroupMentions} + canResolveAllGroupMention={canResolveAllGroupMention} + transformers={exportMarkdownTransformers} mentionsRef={mentionsRef} waveMentionsRef={waveMentionsRef} /> diff --git a/components/waves/drops/WaveDrop.tsx b/components/waves/drops/WaveDrop.tsx index bf8ee02f73..e0ea7ce8d8 100644 --- a/components/waves/drops/WaveDrop.tsx +++ b/components/waves/drops/WaveDrop.tsx @@ -575,6 +575,7 @@ const WaveDrop = ({ ( newContent: string, mentions?: ApiDropMentionedUser[], + _mentionedGroups?: unknown, mentionedWaves?: ApiMentionedWave[] ) => { // Clean mentioned users to only include allowed fields for API @@ -591,13 +592,14 @@ const WaveDrop = ({ wave_name_in_content: wave.wave_name_in_content, }) ); + const updatedParts = drop.parts.map((part, index) => ({ + content: index === activePartIndex ? newContent : part.content, + quoted_drop: part.quoted_drop ?? null, + media: part.media, + })); const updateRequest: ApiUpdateDropRequest = { - parts: drop.parts.map((part, index) => ({ - content: index === activePartIndex ? newContent : part.content, - quoted_drop: part.quoted_drop ?? null, - media: part.media, - })), + parts: updatedParts, title: drop.title, metadata: drop.metadata, referenced_nfts: drop.referenced_nfts, diff --git a/components/waves/drops/WaveDropContent.tsx b/components/waves/drops/WaveDropContent.tsx index 38b2b6f7bf..4d7ec47803 100644 --- a/components/waves/drops/WaveDropContent.tsx +++ b/components/waves/drops/WaveDropContent.tsx @@ -1,6 +1,7 @@ import React from "react"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser"; +import type { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import WaveDropPart from "./WaveDropPart"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; @@ -21,6 +22,7 @@ interface WaveDropContentProps { | (( newContent: string, mentions?: ApiDropMentionedUser[], + mentionedGroups?: ApiDropGroupMention[], mentionedWaves?: ApiMentionedWave[] ) => void) | undefined; diff --git a/components/waves/drops/WaveDropPart.tsx b/components/waves/drops/WaveDropPart.tsx index e094ae3aa5..0b2f1cbe1f 100644 --- a/components/waves/drops/WaveDropPart.tsx +++ b/components/waves/drops/WaveDropPart.tsx @@ -3,6 +3,7 @@ import React, { memo, useRef } from "react"; import type { ApiDrop } from "@/generated/models/ApiDrop"; import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser"; +import type { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import WaveDropPartDrop from "./WaveDropPartDrop"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; @@ -22,6 +23,7 @@ interface WaveDropPartProps { | (( newContent: string, mentions?: ApiDropMentionedUser[], + mentionedGroups?: ApiDropGroupMention[], mentionedWaves?: ApiMentionedWave[] ) => void) | undefined; diff --git a/components/waves/drops/WaveDropPartContent.tsx b/components/waves/drops/WaveDropPartContent.tsx index c11e8abfe0..fa48837894 100644 --- a/components/waves/drops/WaveDropPartContent.tsx +++ b/components/waves/drops/WaveDropPartContent.tsx @@ -4,6 +4,7 @@ import React, { useMemo } from "react"; import type { ApiDropPart } from "@/generated/models/ApiDropPart"; import WaveDropPartContentMedias from "./WaveDropPartContentMedias"; import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser"; +import type { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; import type { ApiMentionedWave } from "@/generated/models/ApiMentionedWave"; import type { ReferencedNft } from "@/entities/IDrop"; import type { ApiWaveMin } from "@/generated/models/ApiWaveMin"; @@ -13,6 +14,7 @@ import { ImageScale } from "@/helpers/image.helpers"; interface WaveDropPartContentProps { readonly mentionedUsers: ApiDropMentionedUser[]; + readonly mentionedGroups?: ApiDropGroupMention[] | undefined; readonly mentionedWaves: ApiMentionedWave[]; readonly referencedNfts: ReferencedNft[]; readonly wave: ApiWaveMin; @@ -29,6 +31,7 @@ interface WaveDropPartContentProps { | (( newContent: string, mentions?: ApiDropMentionedUser[], + mentionedGroups?: ApiDropGroupMention[], mentionedWaves?: ApiMentionedWave[] ) => void) | undefined; @@ -44,6 +47,7 @@ interface WaveDropPartContentProps { const WaveDropPartContent: React.FC = ({ mentionedUsers, + mentionedGroups = [], mentionedWaves, referencedNfts, wave, @@ -74,6 +78,10 @@ const WaveDropPartContent: React.FC = ({ () => mentionedWaves, [mentionedWaves] ); + const memoizedMentionedGroups = useMemo( + () => mentionedGroups, + [mentionedGroups] + ); const memoizedReferencedNfts = useMemo( () => referencedNfts, [referencedNfts] @@ -142,6 +150,7 @@ const WaveDropPartContent: React.FC = ({
; + readonly mentionedGroups?: Array | undefined; readonly mentionedWaves: Array; readonly referencedNfts: Array; readonly part: ApiDropPart; @@ -23,6 +25,7 @@ interface WaveDropPartContentMarkdownProps { | (( newContent: string, mentions?: ApiDropMentionedUser[], + mentionedGroups?: ApiDropGroupMention[], mentionedWaves?: ApiMentionedWave[] ) => void) | undefined; @@ -37,6 +40,7 @@ const WaveDropPartContentMarkdown: React.FC< WaveDropPartContentMarkdownProps > = ({ mentionedUsers, + mentionedGroups = [], mentionedWaves, referencedNfts, part, @@ -58,16 +62,19 @@ const WaveDropPartContentMarkdown: React.FC< { if (onSave) { - onSave(content, mentions, waves); + onSave(content, mentions, groups, waves); } }} onCancel={() => { @@ -84,6 +91,7 @@ const WaveDropPartContentMarkdown: React.FC<
void) | undefined; @@ -59,6 +61,7 @@ const WaveDropPartDrop: React.FC = ({ = ({
-
+
{!!connectedProfile?.handle && !activeProfileProxy && ( -
+
+ -
)}
diff --git a/components/waves/header/WaveHeaderFollow.tsx b/components/waves/header/WaveHeaderFollow.tsx index 78f52218f0..b2bda8f836 100644 --- a/components/waves/header/WaveHeaderFollow.tsx +++ b/components/waves/header/WaveHeaderFollow.tsx @@ -35,15 +35,19 @@ const LOADER_SIZES: Record = { [WaveFollowBtnSize.MEDIUM]: CircleLoaderSize.MEDIUM, }; +interface WaveHeaderFollowProps { + readonly wave: ApiWave; + readonly subscribeToAllDrops?: boolean | undefined; + readonly size?: WaveFollowBtnSize | undefined; + readonly fullWidth?: boolean | undefined; +} + export default function WaveHeaderFollow({ wave, subscribeToAllDrops = false, size = WaveFollowBtnSize.MEDIUM, -}: { - readonly wave: ApiWave; - readonly subscribeToAllDrops?: boolean | undefined; - readonly size?: WaveFollowBtnSize | undefined; -}) { + fullWidth = false, +}: WaveHeaderFollowProps) { const { setToast, requestAuth } = useContext(AuthContext); const { onWaveFollowChange } = useContext(ReactQueryWrapperContext); const following = !!wave.subscribed_actions.length; @@ -131,14 +135,16 @@ export default function WaveHeaderFollow({ const printIcon = () => { if (mutating) { return ; - } else if (following) { + } + if (following) { return ( ); - } else { - return ( - - ); } + return ( + + ); }; return ( -
+
diff --git a/components/waves/leaderboard/grid/WaveLeaderboardGridItem.tsx b/components/waves/leaderboard/grid/WaveLeaderboardGridItem.tsx index bccd0d60b5..0e96bb70e2 100644 --- a/components/waves/leaderboard/grid/WaveLeaderboardGridItem.tsx +++ b/components/waves/leaderboard/grid/WaveLeaderboardGridItem.tsx @@ -333,6 +333,7 @@ export const WaveLeaderboardGridItem: React.FC< = - seizeSettings.all_drops_notifications_subscribers_limit; + const settings = useWaveNotificationSettings(wave); - const [following, setFollowing] = useState(false); - const [isAllEnabled, setIsAllEnabled] = useState(); - const [isMuted, setIsMuted] = useState(wave.metrics.muted); - const [muteLoading, setMuteLoading] = useState(false); - - const { data, refetch } = useWaveNotificationSubscription(wave); - - const { setToast } = useAuth(); - - const [loading, setLoading] = useState(false); - const [loadingTarget, setLoadingTarget] = useState<"mentions" | "all" | null>( - null - ); - - useEffect(() => { - setIsMuted(wave.metrics.muted); - }, [wave.metrics.muted]); - - const toggleMute = useCallback(async () => { - setMuteLoading(true); - try { - if (isMuted) { - await commonApiDelete({ endpoint: `waves/${wave.id}/mute` }); - } else { - await commonApiPost({ endpoint: `waves/${wave.id}/mute`, body: {} }); - } - setIsMuted(!isMuted); - queryClient.invalidateQueries({ - queryKey: [QueryKey.WAVE, { wave_id: wave.id }], - }); - queryClient.invalidateQueries({ - queryKey: [QueryKey.WAVES_OVERVIEW], - }); - } catch (error) { - const defaultMessage = isMuted - ? "Unable to unmute wave" - : "Unable to mute wave"; - const errorMessage = typeof error === "string" ? error : defaultMessage; - setToast({ - message: errorMessage, - type: "error", - }); - } finally { - setMuteLoading(false); - } - }, [isMuted, wave.id, queryClient, setToast]); - - useEffect(() => { - setIsAllEnabled(data?.subscribed && !disableSelection); - }, [data, disableSelection]); - - const toggleNotifications = useCallback( - async (enableAll: boolean) => { - if (enableAll === isAllEnabled) return; - - setLoadingTarget(enableAll ? "all" : "mentions"); - setLoading(true); - - if (enableAll) { - try { - await commonApiPost({ - endpoint: `notifications/wave-subscription/${wave.id}`, - body: {}, - }); - await refetch(); - setLoading(false); - setLoadingTarget(null); - } catch (error) { - setLoading(false); - setLoadingTarget(null); - setToast({ - message: - typeof error === "string" - ? error - : "Unable to subscribe to all drops", - type: "error", - }); - } - } else { - try { - await commonApiDelete({ - endpoint: `notifications/wave-subscription/${wave.id}`, - }); - await refetch(); - setLoading(false); - setLoadingTarget(null); - } catch (error) { - setLoading(false); - setLoadingTarget(null); - setToast({ - message: - typeof error === "string" - ? error - : "Unable to subscribe to mentions", - type: "error", - }); - } - } - }, - [isAllEnabled, wave.id, refetch, setToast] - ); - - useEffect(() => { - setFollowing(!!wave.subscribed_actions.length); - refetch(); - }, [wave.subscribed_actions.length, refetch]); - - const getMentionsTooltip = () => { - return isAllEnabled ? "Click to switch to mentions-only notifications" : ""; - }; - - const getAllTooltip = () => { - if (disableSelection) { - return `'All' notifications unavailable for waves with ${seizeSettings.all_drops_notifications_subscribers_limit.toLocaleString()}+ followers.`; - } - return !isAllEnabled ? "Click to enable notifications for all drops" : ""; - }; - - const getActiveButtonStyle = () => { - return "tw-bg-iron-800 tw-text-primary-400 tw-font-medium"; - }; - - const getInactiveButtonStyle = () => { - return "tw-text-iron-400 desktop-hover:hover:tw-text-iron-300 tw-bg-transparent"; - }; - - const getDisabledButtonStyle = () => { - return "tw-text-iron-500 tw-bg-transparent tw-cursor-not-allowed"; - }; - - if (!following) { + if (!settings.following) { return null; } - const getMuteTooltip = () => { - return isMuted ? "Click to unmute this wave" : "Click to mute this wave"; - }; + if (settings.isMuted) { + return ; + } - if (isMuted) { - return ( -
-
- - {getMuteTooltip()} - - }> - - -
-
- ); + if (settings.preferencesUnavailable) { + return ; } return ( -
-
-
- {isAllEnabled ? ( - - {getMentionsTooltip()} - - }> - - - ) : ( - - )} - - {disableSelection ? ( - - {getAllTooltip()} - - }> - - - ) : isAllEnabled ? ( - - ) : ( - - {getAllTooltip()} - - }> - - - )} -
-
-
+ ); } diff --git a/components/waves/specs/wave-notification-settings/WaveMutedNotificationButton.tsx b/components/waves/specs/wave-notification-settings/WaveMutedNotificationButton.tsx new file mode 100644 index 0000000000..eb0c115835 --- /dev/null +++ b/components/waves/specs/wave-notification-settings/WaveMutedNotificationButton.tsx @@ -0,0 +1,47 @@ +import { Spinner } from "@/components/dotLoader/DotLoader"; +import { faBellSlash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { OverlayTrigger, Tooltip } from "react-bootstrap"; +import type { WaveNotificationSettingsState } from "./useWaveNotificationSettings"; + +interface WaveMutedNotificationButtonProps { + readonly waveId: string; + readonly settings: WaveNotificationSettingsState; +} + +export default function WaveMutedNotificationButton({ + waveId, + settings, +}: WaveMutedNotificationButtonProps) { + return ( +
+ + {settings.muteTooltip} + + } + > + + +
+ ); +} diff --git a/components/waves/specs/wave-notification-settings/WaveNotificationPreferenceButtons.tsx b/components/waves/specs/wave-notification-settings/WaveNotificationPreferenceButtons.tsx new file mode 100644 index 0000000000..1760f3fe64 --- /dev/null +++ b/components/waves/specs/wave-notification-settings/WaveNotificationPreferenceButtons.tsx @@ -0,0 +1,113 @@ +import { Spinner } from "@/components/dotLoader/DotLoader"; +import { OverlayTrigger, Tooltip } from "react-bootstrap"; +import type { WaveNotificationSettingsState } from "./useWaveNotificationSettings"; + +interface WaveNotificationPreferenceButtonsProps { + readonly waveId: string; + readonly settings: WaveNotificationSettingsState; +} + +const getButtonStyle = (active: boolean) => { + return active + ? "tw-bg-iron-800 tw-text-primary-400 tw-font-medium" + : "tw-text-iron-400 desktop-hover:hover:tw-text-iron-300 tw-bg-transparent"; +}; + +function getAllDropsButtonStyle(settings: WaveNotificationSettingsState) { + const buttonStyle = getButtonStyle(settings.allDropsEnabled); + return settings.disableAllDropsSelection && !settings.allDropsEnabled + ? `${buttonStyle} tw-cursor-not-allowed` + : buttonStyle; +} + +function AllDropsIcon({ className }: { readonly className: string }) { + return ( + + + + ); +} + +export default function WaveNotificationPreferenceButtons({ + waveId, + settings, +}: WaveNotificationPreferenceButtonsProps) { + const allDropsSelectionDisabled = + settings.disableAllDropsSelection && !settings.allDropsEnabled; + const allDropsTooltipId = `all-drops-tooltip-${waveId}`; + const allDropsDisabledDescriptionId = `${allDropsTooltipId}-disabled-description`; + const allDropsButton = ( + + ); + + return ( +
+ + {settings.allGroupTooltip} + + } + > + + + + + {settings.allDropsTooltip} + + } + > + {allDropsButton} + +
+ ); +} diff --git a/components/waves/specs/wave-notification-settings/WaveNotificationRetryButton.tsx b/components/waves/specs/wave-notification-settings/WaveNotificationRetryButton.tsx new file mode 100644 index 0000000000..84c148cac1 --- /dev/null +++ b/components/waves/specs/wave-notification-settings/WaveNotificationRetryButton.tsx @@ -0,0 +1,28 @@ +import { Spinner } from "@/components/dotLoader/DotLoader"; +import type { WaveNotificationSettingsState } from "./useWaveNotificationSettings"; + +interface WaveNotificationRetryButtonProps { + readonly settings: WaveNotificationSettingsState; +} + +export default function WaveNotificationRetryButton({ + settings, +}: WaveNotificationRetryButtonProps) { + return ( +
+ +
+ ); +} diff --git a/components/waves/specs/wave-notification-settings/useWaveMuteSettings.ts b/components/waves/specs/wave-notification-settings/useWaveMuteSettings.ts new file mode 100644 index 0000000000..5c9c01adea --- /dev/null +++ b/components/waves/specs/wave-notification-settings/useWaveMuteSettings.ts @@ -0,0 +1,58 @@ +import { useAuth } from "@/components/auth/Auth"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import { commonApiDelete, commonApiPost } from "@/services/api/common-api"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; +import { getErrorMessage } from "./waveNotificationSettings.helpers"; + +export function useWaveMuteSettings(wave: ApiWave) { + const queryClient = useQueryClient(); + const { setToast } = useAuth(); + const isMuted = wave.metrics.muted; + const [muteLoading, setMuteLoading] = useState(false); + + const toggleMute = useCallback(async () => { + setMuteLoading(true); + try { + if (isMuted) { + await commonApiDelete({ endpoint: `waves/${wave.id}/mute` }); + } else { + await commonApiPost({ endpoint: `waves/${wave.id}/mute`, body: {} }); + } + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [QueryKey.WAVE, { wave_id: wave.id }], + }), + queryClient.invalidateQueries({ + queryKey: [QueryKey.WAVES_OVERVIEW], + }), + ]); + } catch (error) { + const defaultMessage = isMuted + ? "Unable to unmute wave" + : "Unable to mute wave"; + setToast({ + message: getErrorMessage(error, defaultMessage), + type: "error", + }); + } finally { + setMuteLoading(false); + } + }, [isMuted, wave.id, queryClient, setToast]); + + const onMuteClick = useCallback(() => { + void toggleMute(); + }, [toggleMute]); + + const muteTooltip = isMuted + ? "Click to unmute this wave" + : "Click to mute this wave"; + + return { + isMuted, + muteLoading, + muteTooltip, + onMuteClick, + }; +} diff --git a/components/waves/specs/wave-notification-settings/useWaveNotificationSettings.ts b/components/waves/specs/wave-notification-settings/useWaveNotificationSettings.ts new file mode 100644 index 0000000000..0aad4c5053 --- /dev/null +++ b/components/waves/specs/wave-notification-settings/useWaveNotificationSettings.ts @@ -0,0 +1,19 @@ +import type { ApiWave } from "@/generated/models/ApiWave"; +import { useWaveMuteSettings } from "./useWaveMuteSettings"; +import { useWavePreferenceSettings } from "./useWavePreferenceSettings"; + +export function useWaveNotificationSettings(wave: ApiWave) { + const mute = useWaveMuteSettings(wave); + const preferences = useWavePreferenceSettings(wave); + const following = wave.subscribed_actions.length > 0; + + return { + following, + ...mute, + ...preferences, + }; +} + +export type WaveNotificationSettingsState = ReturnType< + typeof useWaveNotificationSettings +>; diff --git a/components/waves/specs/wave-notification-settings/useWavePreferenceSettings.ts b/components/waves/specs/wave-notification-settings/useWavePreferenceSettings.ts new file mode 100644 index 0000000000..90f2cd4c95 --- /dev/null +++ b/components/waves/specs/wave-notification-settings/useWavePreferenceSettings.ts @@ -0,0 +1,155 @@ +import { useAuth } from "@/components/auth/Auth"; +import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; +import type { ApiUpdateWaveNotificationPreferencesRequest } from "@/generated/models/ApiUpdateWaveNotificationPreferencesRequest"; +import type { ApiWave } from "@/generated/models/ApiWave"; +import type { ApiWaveNotificationPreferences } from "@/generated/models/ApiWaveNotificationPreferences"; +import { useWaveNotificationSubscription } from "@/hooks/useWaveNotificationSubscription"; +import { commonApiPost } from "@/services/api/common-api"; +import { useCallback, useMemo, useState } from "react"; +import { + ALL_GROUP_MENTION, + getAllDropsTooltip, + getErrorMessage, + type NotificationLoadingTarget, +} from "./waveNotificationSettings.helpers"; + +export function useWavePreferenceSettings(wave: ApiWave) { + const { seizeSettings } = useSeizeSettings(); + const { setToast } = useAuth(); + const [loadingTarget, setLoadingTarget] = + useState(null); + + const { + data, + refetch, + isFetching: preferencesFetching = false, + isPending: preferencesPending = false, + } = useWaveNotificationSubscription(wave); + + const allDropsNotificationsSubscribersLimit = + seizeSettings.all_drops_notifications_subscribers_limit; + const disableAllDropsSelection = + wave.metrics.subscribers_count >= allDropsNotificationsSubscribersLimit; + + const enabledGroupNotifications = useMemo( + () => data?.enabled_group_notifications ?? [], + [data?.enabled_group_notifications] + ); + + const subscribedToAllDrops = !!data?.subscribed; + const allDropsEnabled = subscribedToAllDrops; + const allGroupNotificationsEnabled = + enabledGroupNotifications.includes(ALL_GROUP_MENTION); + const loading = + loadingTarget !== null || preferencesPending || preferencesFetching; + const preferencesUnavailable = !data && !preferencesPending; + + const updateNotificationPreferences = useCallback( + async ({ + body, + target, + errorMessage, + }: { + readonly body: ApiUpdateWaveNotificationPreferencesRequest; + readonly target: NotificationLoadingTarget; + readonly errorMessage: string; + }) => { + setLoadingTarget(target); + try { + await commonApiPost< + ApiUpdateWaveNotificationPreferencesRequest, + ApiWaveNotificationPreferences + >({ + endpoint: `notifications/wave-subscription/${wave.id}`, + body, + }); + await refetch(); + } catch (error) { + setToast({ + message: getErrorMessage(error, errorMessage), + type: "error", + }); + } finally { + setLoadingTarget(null); + } + }, + [wave.id, refetch, setToast] + ); + + const toggleAllGroupNotifications = useCallback(async () => { + await updateNotificationPreferences({ + target: "all-group", + body: { + subscribed: subscribedToAllDrops, + enabled_group_notifications: allGroupNotificationsEnabled + ? [] + : [ALL_GROUP_MENTION], + }, + errorMessage: allGroupNotificationsEnabled + ? "Unable to disable @ALL notifications" + : "Unable to enable @ALL notifications", + }); + }, [ + allGroupNotificationsEnabled, + subscribedToAllDrops, + updateNotificationPreferences, + ]); + + const toggleAllDropsNotifications = useCallback(async () => { + if (!subscribedToAllDrops && disableAllDropsSelection) { + return; + } + + await updateNotificationPreferences({ + target: "all-drops", + body: { + subscribed: !subscribedToAllDrops, + enabled_group_notifications: enabledGroupNotifications, + }, + errorMessage: subscribedToAllDrops + ? "Unable to disable all drop notifications" + : "Unable to enable all drop notifications", + }); + }, [ + disableAllDropsSelection, + enabledGroupNotifications, + subscribedToAllDrops, + updateNotificationPreferences, + ]); + + const onAllGroupNotificationsClick = useCallback(() => { + void toggleAllGroupNotifications(); + }, [toggleAllGroupNotifications]); + + const onAllDropsNotificationsClick = useCallback(() => { + void toggleAllDropsNotifications(); + }, [toggleAllDropsNotifications]); + + const onRetryClick = useCallback(() => { + void refetch(); + }, [refetch]); + + const allGroupTooltip = allGroupNotificationsEnabled + ? "Click to disable @ALL notifications" + : "Click to enable @ALL notifications"; + const allDropsTooltip = getAllDropsTooltip({ + disableAllDropsSelection, + subscribedToAllDrops, + subscribersLimit: allDropsNotificationsSubscribersLimit, + }); + + return { + allDropsEnabled, + allGroupNotificationsEnabled, + allDropsTooltip, + allGroupTooltip, + disableAllDropsSelection, + loading, + loadingTarget, + onAllDropsNotificationsClick, + onAllGroupNotificationsClick, + onRetryClick, + preferencesFetching, + preferencesUnavailable, + }; +} diff --git a/components/waves/specs/wave-notification-settings/waveNotificationSettings.helpers.ts b/components/waves/specs/wave-notification-settings/waveNotificationSettings.helpers.ts new file mode 100644 index 0000000000..31a9f4cc8f --- /dev/null +++ b/components/waves/specs/wave-notification-settings/waveNotificationSettings.helpers.ts @@ -0,0 +1,44 @@ +import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; + +export type NotificationLoadingTarget = "all-group" | "all-drops"; + +export const ALL_GROUP_MENTION = ApiDropGroupMention.All; + +export const getErrorMessage = (error: unknown, defaultMessage: string) => { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "object" && error !== null) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string") { + return message; + } + } + + if (typeof error === "string") { + return error; + } + + return defaultMessage; +}; + +export function getAllDropsTooltip({ + disableAllDropsSelection, + subscribedToAllDrops, + subscribersLimit, +}: { + readonly disableAllDropsSelection: boolean; + readonly subscribedToAllDrops: boolean; + readonly subscribersLimit: number; +}) { + if (disableAllDropsSelection && !subscribedToAllDrops) { + return `'All' notifications unavailable for waves with ${subscribersLimit.toLocaleString()}+ followers.`; + } + + if (subscribedToAllDrops) { + return "Click to disable notifications for all drops"; + } + + return "Click to enable notifications for all drops"; +} diff --git a/components/waves/utils/getOptimisticDrop.ts b/components/waves/utils/getOptimisticDrop.ts index 16365ff6c1..49ac96fc59 100644 --- a/components/waves/utils/getOptimisticDrop.ts +++ b/components/waves/utils/getOptimisticDrop.ts @@ -128,5 +128,6 @@ export const getOptimisticDrop = ( reactions: [], boosts: 0, hide_link_preview: false, + mentioned_groups: dropRequest.mentioned_groups ?? [], }; }; diff --git a/entities/IDrop.ts b/entities/IDrop.ts index aca3f73677..0b05ab8b29 100644 --- a/entities/IDrop.ts +++ b/entities/IDrop.ts @@ -1,4 +1,5 @@ import type { ApiCreateDropRequest } from "@/generated/models/ApiCreateDropRequest"; +import type { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; export interface ReferencedNft { readonly contract: string; @@ -45,6 +46,7 @@ export interface CreateDropRequestPart { export interface CreateDropPart extends Omit { readonly media: Array; + readonly mentioned_groups?: Array; } export interface CreateDropConfig extends Omit< diff --git a/generated/models/ApiCreateDropRequest.ts b/generated/models/ApiCreateDropRequest.ts index 7ac4a4a324..deb4a3a756 100644 --- a/generated/models/ApiCreateDropRequest.ts +++ b/generated/models/ApiCreateDropRequest.ts @@ -13,6 +13,7 @@ import { ApiCreateDropPart } from '../models/ApiCreateDropPart'; import { ApiCreateMentionedWave } from '../models/ApiCreateMentionedWave'; +import { ApiDropGroupMention } from '../models/ApiDropGroupMention'; import { ApiDropMentionedUser } from '../models/ApiDropMentionedUser'; import { ApiDropMetadata } from '../models/ApiDropMetadata'; import { ApiDropReferencedNFT } from '../models/ApiDropReferencedNFT'; @@ -24,7 +25,7 @@ export class ApiCreateDropRequest { 'wave_id': string; 'reply_to'?: ApiReplyToDrop; 'drop_type'?: ApiDropType; - 'mentions_all'?: boolean; + 'mentioned_groups'?: Array; 'title'?: string | null; 'parts': Array; 'referenced_nfts': Array; @@ -62,9 +63,9 @@ export class ApiCreateDropRequest { "format": "" }, { - "name": "mentions_all", - "baseName": "mentions_all", - "type": "boolean", + "name": "mentioned_groups", + "baseName": "mentioned_groups", + "type": "Array", "format": "" }, { diff --git a/generated/models/ApiDrop.ts b/generated/models/ApiDrop.ts index f8edf00621..3db2722187 100644 --- a/generated/models/ApiDrop.ts +++ b/generated/models/ApiDrop.ts @@ -12,6 +12,7 @@ */ import { ApiDropContextProfileContext } from '../models/ApiDropContextProfileContext'; +import { ApiDropGroupMention } from '../models/ApiDropGroupMention'; import { ApiDropMentionedUser } from '../models/ApiDropMentionedUser'; import { ApiDropMetadataResponse } from '../models/ApiDropMetadataResponse'; import { ApiDropNftLink } from '../models/ApiDropNftLink'; @@ -56,6 +57,7 @@ export class ApiDrop { 'parts_count': number; 'referenced_nfts': Array; 'mentioned_users': Array; + 'mentioned_groups': Array; 'mentioned_waves': Array; 'metadata': Array; 'rating': number; @@ -166,6 +168,12 @@ export class ApiDrop { "type": "Array", "format": "" }, + { + "name": "mentioned_groups", + "baseName": "mentioned_groups", + "type": "Array", + "format": "" + }, { "name": "mentioned_waves", "baseName": "mentioned_waves", diff --git a/generated/models/ApiDropGroupMention.ts b/generated/models/ApiDropGroupMention.ts new file mode 100644 index 0000000000..d5d7504ab2 --- /dev/null +++ b/generated/models/ApiDropGroupMention.ts @@ -0,0 +1,18 @@ +// @ts-nocheck +/** + * 6529.io API + * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http'; + +export enum ApiDropGroupMention { + All = 'ALL' +} diff --git a/generated/models/ApiDropWithoutWave.ts b/generated/models/ApiDropWithoutWave.ts index 098947cea1..18e4c94e96 100644 --- a/generated/models/ApiDropWithoutWave.ts +++ b/generated/models/ApiDropWithoutWave.ts @@ -12,6 +12,7 @@ */ import { ApiDropContextProfileContext } from '../models/ApiDropContextProfileContext'; +import { ApiDropGroupMention } from '../models/ApiDropGroupMention'; import { ApiDropMentionedUser } from '../models/ApiDropMentionedUser'; import { ApiDropMetadataResponse } from '../models/ApiDropMetadataResponse'; import { ApiDropNftLink } from '../models/ApiDropNftLink'; @@ -54,6 +55,7 @@ export class ApiDropWithoutWave { 'parts_count': number; 'referenced_nfts': Array; 'mentioned_users': Array; + 'mentioned_groups': Array; 'mentioned_waves': Array; 'metadata': Array; 'rating': number; @@ -158,6 +160,12 @@ export class ApiDropWithoutWave { "type": "Array", "format": "" }, + { + "name": "mentioned_groups", + "baseName": "mentioned_groups", + "type": "Array", + "format": "" + }, { "name": "mentioned_waves", "baseName": "mentioned_waves", diff --git a/generated/models/ApiLightDrop.ts b/generated/models/ApiLightDrop.ts index 2a18cb9f9f..fc884d6a1f 100644 --- a/generated/models/ApiLightDrop.ts +++ b/generated/models/ApiLightDrop.ts @@ -17,6 +17,10 @@ import { HttpFile } from '../http/http'; export class ApiLightDrop { 'id': string; + 'wave_id': string; + 'wave_name': string; + 'author': string; + 'created_at': number; /** * Sequence number of the drop in Seize */ @@ -39,6 +43,30 @@ export class ApiLightDrop { "type": "string", "format": "" }, + { + "name": "wave_id", + "baseName": "wave_id", + "type": "string", + "format": "" + }, + { + "name": "wave_name", + "baseName": "wave_name", + "type": "string", + "format": "" + }, + { + "name": "author", + "baseName": "author", + "type": "string", + "format": "" + }, + { + "name": "created_at", + "baseName": "created_at", + "type": "number", + "format": "int64" + }, { "name": "serial_no", "baseName": "serial_no", diff --git a/generated/models/ApiUpdateDropRequest.ts b/generated/models/ApiUpdateDropRequest.ts index 2725eca4bb..f57bd44449 100644 --- a/generated/models/ApiUpdateDropRequest.ts +++ b/generated/models/ApiUpdateDropRequest.ts @@ -19,7 +19,6 @@ import { ApiDropReferencedNFT } from '../models/ApiDropReferencedNFT'; import { HttpFile } from '../http/http'; export class ApiUpdateDropRequest { - 'mentions_all'?: boolean; 'title'?: string | null; 'parts': Array; 'referenced_nfts': Array; @@ -38,12 +37,6 @@ export class ApiUpdateDropRequest { static readonly mapping: {[index: string]: string} | undefined = undefined; static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ - { - "name": "mentions_all", - "baseName": "mentions_all", - "type": "boolean", - "format": "" - }, { "name": "title", "baseName": "title", diff --git a/generated/models/GetWaveSubscription200Response.ts b/generated/models/ApiUpdateWaveNotificationPreferencesRequest.ts similarity index 67% rename from generated/models/GetWaveSubscription200Response.ts rename to generated/models/ApiUpdateWaveNotificationPreferencesRequest.ts index 642f030342..93807b4071 100644 --- a/generated/models/GetWaveSubscription200Response.ts +++ b/generated/models/ApiUpdateWaveNotificationPreferencesRequest.ts @@ -11,10 +11,12 @@ * Do not edit the class manually. */ +import { ApiDropGroupMention } from '../models/ApiDropGroupMention'; import { HttpFile } from '../http/http'; -export class GetWaveSubscription200Response { +export class ApiUpdateWaveNotificationPreferencesRequest { 'subscribed'?: boolean; + 'enabled_group_notifications'?: Array; static readonly discriminator: string | undefined = undefined; @@ -26,10 +28,16 @@ export class GetWaveSubscription200Response { "baseName": "subscribed", "type": "boolean", "format": "" + }, + { + "name": "enabled_group_notifications", + "baseName": "enabled_group_notifications", + "type": "Array", + "format": "" } ]; static getAttributeTypeMap() { - return GetWaveSubscription200Response.attributeTypeMap; + return ApiUpdateWaveNotificationPreferencesRequest.attributeTypeMap; } public constructor() { diff --git a/generated/models/ApiWaveNotificationPreferences.ts b/generated/models/ApiWaveNotificationPreferences.ts new file mode 100644 index 0000000000..c240b86414 --- /dev/null +++ b/generated/models/ApiWaveNotificationPreferences.ts @@ -0,0 +1,45 @@ +// @ts-nocheck +/** + * 6529.io API + * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { ApiDropGroupMention } from '../models/ApiDropGroupMention'; +import { HttpFile } from '../http/http'; + +export class ApiWaveNotificationPreferences { + 'subscribed': boolean; + 'enabled_group_notifications': Array; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "subscribed", + "baseName": "subscribed", + "type": "boolean", + "format": "" + }, + { + "name": "enabled_group_notifications", + "baseName": "enabled_group_notifications", + "type": "Array", + "format": "" + } ]; + + static getAttributeTypeMap() { + return ApiWaveNotificationPreferences.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/generated/models/ObjectSerializer.ts b/generated/models/ObjectSerializer.ts index b14dbf774c..b2c68bb7fe 100644 --- a/generated/models/ObjectSerializer.ts +++ b/generated/models/ObjectSerializer.ts @@ -71,6 +71,7 @@ export * from '../models/ApiDropBoostsPage'; export * from '../models/ApiDropContextProfileContext'; export * from '../models/ApiDropCuration'; export * from '../models/ApiDropCurationRequest'; +export * from '../models/ApiDropGroupMention'; export * from '../models/ApiDropId'; export * from '../models/ApiDropMedia'; export * from '../models/ApiDropMentionedUser'; @@ -193,6 +194,7 @@ export * from '../models/ApiUpcomingMemeSubscriptionStatus'; export * from '../models/ApiUpdateDropRequest'; export * from '../models/ApiUpdateProxyActionRequest'; export * from '../models/ApiUpdateWaveDecisionPause'; +export * from '../models/ApiUpdateWaveNotificationPreferencesRequest'; export * from '../models/ApiUpdateWaveParticipationConfig'; export * from '../models/ApiUpdateWaveRequest'; export * from '../models/ApiUploadItem'; @@ -219,6 +221,7 @@ export * from '../models/ApiWaveLog'; export * from '../models/ApiWaveMetadataType'; export * from '../models/ApiWaveMetrics'; export * from '../models/ApiWaveMin'; +export * from '../models/ApiWaveNotificationPreferences'; export * from '../models/ApiWaveOutcome'; export * from '../models/ApiWaveOutcomeCredit'; export * from '../models/ApiWaveOutcomeDistributionItem'; @@ -272,7 +275,6 @@ export * from '../models/DistributionPhotoCompleteRequest'; export * from '../models/DistributionPhotoCompleteRequestPhoto'; export * from '../models/DistributionPhotoCompleteResponse'; export * from '../models/DistributionPhotosPage'; -export * from '../models/GetWaveSubscription200Response'; export * from '../models/MintingClaim'; export * from '../models/MintingClaimAnimationDetails'; export * from '../models/MintingClaimAnimationDetailsGlb'; @@ -376,13 +378,14 @@ import { ApiCreateWaveOutcome } from '../models/ApiCreateWaveOutcome'; import { ApiCreateWaveOutcomeDistributionItem } from '../models/ApiCreateWaveOutcomeDistributionItem'; import { ApiDistributionAirdropsCsvUploadRequest } from '../models/ApiDistributionAirdropsCsvUploadRequest'; import { ApiDistributionAirdropsUploadResponse } from '../models/ApiDistributionAirdropsUploadResponse'; -import { ApiDrop } from '../models/ApiDrop'; +import { ApiDrop } from '../models/ApiDrop'; import { ApiDropAndDropVote } from '../models/ApiDropAndDropVote'; import { ApiDropBoost } from '../models/ApiDropBoost'; import { ApiDropBoostsPage } from '../models/ApiDropBoostsPage'; import { ApiDropContextProfileContext } from '../models/ApiDropContextProfileContext'; import { ApiDropCuration } from '../models/ApiDropCuration'; import { ApiDropCurationRequest } from '../models/ApiDropCurationRequest'; +import { ApiDropGroupMention } from '../models/ApiDropGroupMention'; import { ApiDropId } from '../models/ApiDropId'; import { ApiDropMedia } from '../models/ApiDropMedia'; import { ApiDropMentionedUser } from '../models/ApiDropMentionedUser'; @@ -402,7 +405,7 @@ import { ApiDropTraceItem } from '../models/ApiDropTraceItem'; import { ApiDropType } from '../models/ApiDropType'; import { ApiDropVote } from '../models/ApiDropVote'; import { ApiDropWinningContext } from '../models/ApiDropWinningContext'; -import { ApiDropWithoutWave } from '../models/ApiDropWithoutWave'; +import { ApiDropWithoutWave } from '../models/ApiDropWithoutWave'; import { ApiDropWithoutWavesPageWithoutCount } from '../models/ApiDropWithoutWavesPageWithoutCount'; import { ApiDropsLeaderboardPage } from '../models/ApiDropsLeaderboardPage'; import { ApiDropsPage } from '../models/ApiDropsPage'; @@ -426,7 +429,7 @@ import { ApiIdentitySubscriptionTargetAction } from '../models/ApiIdentitySubscr import { ApiIdentitySubscriptionTargetType } from '../models/ApiIdentitySubscriptionTargetType'; import { ApiIncomingIdentitySubscriptionsPage } from '../models/ApiIncomingIdentitySubscriptionsPage'; import { ApiIntRange } from '../models/ApiIntRange'; -import { ApiLightDrop } from '../models/ApiLightDrop'; +import { ApiLightDrop } from '../models/ApiLightDrop'; import { ApiLoginRequest } from '../models/ApiLoginRequest'; import { ApiLoginResponse } from '../models/ApiLoginResponse'; import { ApiMarkDropUnreadResponse } from '../models/ApiMarkDropUnreadResponse'; @@ -505,6 +508,7 @@ import { ApiUpcomingMemeSubscriptionStatus , ApiUpcomingMemeSubscriptionStatus import { ApiUpdateDropRequest } from '../models/ApiUpdateDropRequest'; import { ApiUpdateProxyActionRequest } from '../models/ApiUpdateProxyActionRequest'; import { ApiUpdateWaveDecisionPause } from '../models/ApiUpdateWaveDecisionPause'; +import { ApiUpdateWaveNotificationPreferencesRequest } from '../models/ApiUpdateWaveNotificationPreferencesRequest'; import { ApiUpdateWaveParticipationConfig } from '../models/ApiUpdateWaveParticipationConfig'; import { ApiUpdateWaveRequest } from '../models/ApiUpdateWaveRequest'; import { ApiUploadItem } from '../models/ApiUploadItem'; @@ -531,6 +535,7 @@ import { ApiWaveLog } from '../models/ApiWaveLog'; import { ApiWaveMetadataType } from '../models/ApiWaveMetadataType'; import { ApiWaveMetrics } from '../models/ApiWaveMetrics'; import { ApiWaveMin } from '../models/ApiWaveMin'; +import { ApiWaveNotificationPreferences } from '../models/ApiWaveNotificationPreferences'; import { ApiWaveOutcome } from '../models/ApiWaveOutcome'; import { ApiWaveOutcomeCredit } from '../models/ApiWaveOutcomeCredit'; import { ApiWaveOutcomeDistributionItem } from '../models/ApiWaveOutcomeDistributionItem'; @@ -584,7 +589,6 @@ import { DistributionPhotoCompleteRequest } from '../models/DistributionPhotoCom import { DistributionPhotoCompleteRequestPhoto } from '../models/DistributionPhotoCompleteRequestPhoto'; import { DistributionPhotoCompleteResponse } from '../models/DistributionPhotoCompleteResponse'; import { DistributionPhotosPage } from '../models/DistributionPhotosPage'; -import { GetWaveSubscription200Response } from '../models/GetWaveSubscription200Response'; import { MintingClaim } from '../models/MintingClaim'; import { MintingClaimAnimationDetailsClass } from '../models/MintingClaimAnimationDetails'; import { MintingClaimAnimationDetailsGlb , MintingClaimAnimationDetailsGlbFormatEnum } from '../models/MintingClaimAnimationDetailsGlb'; @@ -638,6 +642,7 @@ let primitives = [ let enumsMap: Set = new Set([ "AcceptActionRequestActionEnum", "ApiCommunityMembersSortOption", + "ApiDropGroupMention", "ApiDropSearchStrategy", "ApiDropSubscriptionTargetAction", "ApiDropType", @@ -857,6 +862,7 @@ let typeMap: {[index: string]: any} = { "ApiUpdateDropRequest": ApiUpdateDropRequest, "ApiUpdateProxyActionRequest": ApiUpdateProxyActionRequest, "ApiUpdateWaveDecisionPause": ApiUpdateWaveDecisionPause, + "ApiUpdateWaveNotificationPreferencesRequest": ApiUpdateWaveNotificationPreferencesRequest, "ApiUpdateWaveParticipationConfig": ApiUpdateWaveParticipationConfig, "ApiUpdateWaveRequest": ApiUpdateWaveRequest, "ApiUploadItem": ApiUploadItem, @@ -880,6 +886,7 @@ let typeMap: {[index: string]: any} = { "ApiWaveLog": ApiWaveLog, "ApiWaveMetrics": ApiWaveMetrics, "ApiWaveMin": ApiWaveMin, + "ApiWaveNotificationPreferences": ApiWaveNotificationPreferences, "ApiWaveOutcome": ApiWaveOutcome, "ApiWaveOutcomeDistributionItem": ApiWaveOutcomeDistributionItem, "ApiWaveOutcomeDistributionItemsPage": ApiWaveOutcomeDistributionItemsPage, @@ -920,7 +927,6 @@ let typeMap: {[index: string]: any} = { "DistributionPhotoCompleteRequestPhoto": DistributionPhotoCompleteRequestPhoto, "DistributionPhotoCompleteResponse": DistributionPhotoCompleteResponse, "DistributionPhotosPage": DistributionPhotosPage, - "GetWaveSubscription200Response": GetWaveSubscription200Response, "MintingClaim": MintingClaim, "MintingClaimAnimationDetails": MintingClaimAnimationDetailsClass, "MintingClaimAnimationDetailsGlb": MintingClaimAnimationDetailsGlb, diff --git a/helpers/waves/drop-group-mentions.ts b/helpers/waves/drop-group-mentions.ts new file mode 100644 index 0000000000..1bd5693b31 --- /dev/null +++ b/helpers/waves/drop-group-mentions.ts @@ -0,0 +1,55 @@ +import { ApiDropGroupMention } from "@/generated/models/ApiDropGroupMention"; + +export const ALL_GROUP_MENTION_TEXT = "@all"; + +const createAllGroupMentionPattern = () => + /(^|[^A-Za-z0-9_@])(@all)(?![A-Za-z0-9_@])/g; + +export const getMentionedGroupsFromParts = ( + parts: readonly { + readonly mentioned_groups?: + | readonly ApiDropGroupMention[] + | null + | undefined; + }[], + canMentionAll: boolean +): ApiDropGroupMention[] => { + if (!canMentionAll) { + return []; + } + + return parts.some((part) => + part.mentioned_groups?.includes(ApiDropGroupMention.All) + ) + ? [ApiDropGroupMention.All] + : []; +}; + +export const hasMentionedGroup = ( + mentionedGroups: readonly ApiDropGroupMention[] | null | undefined, + group: ApiDropGroupMention +) => mentionedGroups?.includes(group) ?? false; + +export const areMentionedGroupsEqual = ( + a: readonly ApiDropGroupMention[], + b: readonly ApiDropGroupMention[] +) => { + if (a.length !== b.length) { + return false; + } + + return a.every((group) => b.includes(group)); +}; + +export const markAllGroupMentionTokens = ({ + content, + marker, +}: { + readonly content: string; + readonly marker: string; +}) => + content.replace( + createAllGroupMentionPattern(), + (_match, prefix: string, token: string) => + `${prefix}${marker}${token}${marker}` + ); diff --git a/hooks/drops/useDropUpdateMutation.ts b/hooks/drops/useDropUpdateMutation.ts index 2313513ea6..44df955de1 100644 --- a/hooks/drops/useDropUpdateMutation.ts +++ b/hooks/drops/useDropUpdateMutation.ts @@ -1,4 +1,4 @@ -"use client" +"use client"; import { useMutation } from "@tanstack/react-query"; import { commonApiPost } from "@/services/api/common-api"; @@ -37,7 +37,7 @@ export const useDropUpdateMutation = () => { // Update the drop in wave messages store using existing processIncomingDrop if (myStreamContext?.processIncomingDrop) { myStreamContext.processIncomingDrop( - updatedDrop, + updatedDrop, ProcessIncomingDropType.DROP_INSERT // This will merge/update the drop ); } @@ -47,12 +47,14 @@ export const useDropUpdateMutation = () => { }, onError: (error) => { console.error("Failed to update drop:", error); - + // Check if it's a time limit error - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = + error instanceof Error ? error.message : String(error); if (errorMessage.includes("can't be edited after")) { setToast({ - message: "This drop can no longer be edited. Drops can only be edited within 5 minutes of creation.", + message: + "This drop can no longer be edited. Drops can only be edited within 5 minutes of creation.", type: "error", }); } else { diff --git a/hooks/useIdentitiesSearch.tsx b/hooks/useIdentitiesSearch.tsx index eb95c71498..5a2a8e56d1 100644 --- a/hooks/useIdentitiesSearch.tsx +++ b/hooks/useIdentitiesSearch.tsx @@ -9,6 +9,8 @@ interface UseIdentitiesSearchProps { readonly waveId: string | null; } +export const IDENTITY_SEARCH_MIN_HANDLE_LENGTH = 3; + export function useIdentitiesSearch({ handle, waveId, @@ -34,7 +36,7 @@ export function useIdentitiesSearch({ params, }); }, - enabled: handle.length >= 3, + enabled: handle.length >= IDENTITY_SEARCH_MIN_HANDLE_LENGTH, }); return { identities: identities ?? [] }; diff --git a/hooks/useWaveNotificationSubscription.ts b/hooks/useWaveNotificationSubscription.ts index 1c91b99d9b..27bd3195e5 100644 --- a/hooks/useWaveNotificationSubscription.ts +++ b/hooks/useWaveNotificationSubscription.ts @@ -1,22 +1,17 @@ import { useQuery } from "@tanstack/react-query"; import { commonApiFetch } from "@/services/api/common-api"; import type { ApiWave } from "@/generated/models/ApiWave"; -import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; -import type { GetWaveSubscription200Response } from "@/generated/models/GetWaveSubscription200Response"; +import type { ApiWaveNotificationPreferences } from "@/generated/models/ApiWaveNotificationPreferences"; export function useWaveNotificationSubscription(wave: ApiWave) { - const { seizeSettings } = useSeizeSettings(); return useQuery({ queryKey: ["wave-notification-subscription", wave.id], queryFn: () => { - return commonApiFetch({ + return commonApiFetch({ endpoint: `notifications/wave-subscription/${wave.id}`, }); }, - enabled: - !!wave.id && - wave.metrics.subscribers_count <= - seizeSettings.all_drops_notifications_subscribers_limit, + enabled: !!wave.id && wave.subscribed_actions.length > 0, retry: (failureCount) => { if (failureCount >= 3) { return false; diff --git a/openapi.yaml b/openapi.yaml index f7768af52a..2ecbda1f7d 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -960,10 +960,24 @@ paths: type: integer format: int64 minimum: 1 + - name: min_serial_no + in: query + required: false + description: Oldest message if null + schema: + type: integer + format: int64 + minimum: 1 + - name: older_first + in: query + required: false + description: By default this endpoint orders things newer first, but if you set it to true then it starts from older drops. + schema: + type: boolean - name: wave_id in: query description: Drops in wave with given ID - required: true + required: false schema: type: string responses: @@ -1018,6 +1032,17 @@ paths: schema: type: string format: uuid + - name: curation_name + in: query + description: >- + Only include drops with persisted membership in curations with this + ApiWaveCuration.name across all visible waves. Cannot be combined + with curation_id. + required: false + schema: + type: string + minLength: 1 + maxLength: 50 - name: serial_no_less_than in: query description: Used to find older drops @@ -1887,6 +1912,13 @@ paths: schema: type: number format: int64 + - name: include_profile_groups + description: Include pure profile groups in search results + in: query + required: false + schema: + type: boolean + default: false responses: "200": description: successful operation @@ -2993,10 +3025,7 @@ paths: content: application/json: schema: - type: object - properties: - subscribed: - type: boolean + $ref: "#/components/schemas/ApiWaveNotificationPreferences" post: tags: - Notifications @@ -3008,16 +3037,19 @@ paths: required: true schema: type: string + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/ApiUpdateWaveNotificationPreferencesRequest" responses: "200": description: successful operation content: application/json: schema: - type: object - properties: - subscribed: - type: boolean + $ref: "#/components/schemas/ApiWaveNotificationPreferences" delete: tags: - Notifications @@ -3035,10 +3067,7 @@ paths: content: application/json: schema: - type: object - properties: - subscribed: - type: boolean + $ref: "#/components/schemas/ApiWaveNotificationPreferences" /blocks: get: tags: @@ -4088,7 +4117,7 @@ paths: tags: - Ratings summary: >- - Change REP rating of multiple targets and categories in one go. Targets + Change REP rating of multiple targets and categories in one go. Targets final REP value will be the value you supply here. If you supply multiple addresses for one consolidation group then those amounts will be summed together. @@ -7783,8 +7812,10 @@ components: $ref: "#/components/schemas/ApiReplyToDrop" drop_type: $ref: "#/components/schemas/ApiDropType" - mentions_all: - type: boolean + mentioned_groups: + type: array + items: + $ref: "#/components/schemas/ApiDropGroupMention" ApiCreateGroup: type: object required: @@ -8049,7 +8080,7 @@ components: $ref: "#/components/schemas/ApiWaveParticipationRequirement" required_metadata: description: | - The metadata that must be provided by the participant. + The metadata that must be provided by the participant. Empty array if nothing is required. type: array items: @@ -8305,6 +8336,7 @@ components: - updated_at - referenced_nfts - mentioned_users + - mentioned_groups - mentioned_waves - metadata - parts @@ -8376,6 +8408,10 @@ components: type: array items: $ref: "#/components/schemas/ApiDropMentionedUser" + mentioned_groups: + type: array + items: + $ref: "#/components/schemas/ApiDropGroupMention" mentioned_waves: type: array items: @@ -8507,6 +8543,10 @@ components: properties: curation_id: type: string + ApiDropGroupMention: + type: string + enum: + - ALL ApiDropId: type: object required: @@ -8791,6 +8831,7 @@ components: - updated_at - referenced_nfts - mentioned_users + - mentioned_groups - mentioned_waves - metadata - parts @@ -8860,6 +8901,10 @@ components: type: array items: $ref: "#/components/schemas/ApiDropMentionedUser" + mentioned_groups: + type: array + items: + $ref: "#/components/schemas/ApiDropGroupMention" mentioned_waves: type: array items: @@ -9338,6 +9383,10 @@ components: type: object required: - id + - wave_id + - wave_name + - author + - created_at - serial_no - drop_type - part_1_text @@ -9348,6 +9397,15 @@ components: properties: id: type: string + wave_id: + type: string + wave_name: + type: string + author: + type: string + created_at: + type: number + format: int64 serial_no: description: Sequence number of the drop in Seize type: number @@ -11126,9 +11184,6 @@ components: type: object allOf: - $ref: "#/components/schemas/ApiCreateWaveDropRequest" - properties: - mentions_all: - type: boolean ApiUpdateProxyActionRequest: type: object properties: @@ -11168,6 +11223,15 @@ components: Decisions before this time will not be made. Should not overlap with other pauses. Needs to be after start_time, after now and after wave.next_decision_time + ApiUpdateWaveNotificationPreferencesRequest: + type: object + properties: + subscribed: + type: boolean + enabled_group_notifications: + type: array + items: + $ref: "#/components/schemas/ApiDropGroupMention" ApiUpdateWaveParticipationConfig: type: object required: @@ -11194,7 +11258,7 @@ components: $ref: "#/components/schemas/ApiWaveParticipationRequirement" required_metadata: description: | - The metadata that must be provided by the participant. + The metadata that must be provided by the participant. Empty array if nothing is required. type: array items: @@ -11463,7 +11527,7 @@ components: type: string ApiWaveCreditScope: description: | - The scope of the credit. + The scope of the credit. * WAVE - Credit is spendable across all drops in wave. enum: - WAVE @@ -11799,6 +11863,18 @@ components: type: boolean identity_wave: type: boolean + ApiWaveNotificationPreferences: + type: object + required: + - subscribed + - enabled_group_notifications + properties: + subscribed: + type: boolean + enabled_group_notifications: + type: array + items: + $ref: "#/components/schemas/ApiDropGroupMention" ApiWaveOutcome: type: object required: @@ -11896,7 +11972,7 @@ components: nullable: true required_metadata: description: | - The metadata that must be provided by the participant. + The metadata that must be provided by the participant. Empty array if nothing is required. type: array items: