diff --git a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx index a2921ac14a..27a442e98b 100644 --- a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx +++ b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx @@ -40,6 +40,10 @@ describe('BrainLeftSidebarWave', () => { contributors: [], newDropsCount: { count: 2, latestDropTimestamp: 123 }, isPinned: false, + unreadDropsCount: 0, + latestReadTimestamp: 0, + firstUnreadDropSerialNo: null, + isMuted: false, } as any; beforeEach(() => { @@ -85,7 +89,7 @@ describe('BrainLeftSidebarWave', () => { render(); const link = screen.getByRole('link'); await userEvent.click(link); - expect(setActiveWave).toHaveBeenCalledWith('1', { isDirectMessage: false }); + expect(setActiveWave).toHaveBeenCalledWith('1', { isDirectMessage: false, serialNo: null }); }); it('shows drop indicators for non-chat waves', () => { @@ -93,4 +97,30 @@ describe('BrainLeftSidebarWave', () => { render(); expect(screen.getByTestId('drop-time')).toHaveTextContent('123'); }); + + it('includes firstUnreadDropSerialNo in href when present', () => { + const waveWithUnread = { ...baseWave, id: '3', firstUnreadDropSerialNo: 42 }; + render(); + expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?wave=3&serialNo=42'); + }); + + it('does not include serialNo in href when firstUnreadDropSerialNo is null', () => { + const waveWithoutUnread = { ...baseWave, id: '4', firstUnreadDropSerialNo: null }; + render(); + expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?wave=4'); + }); + + it('shows muted indicator when wave is muted', () => { + const mutedWave = { ...baseWave, id: '5', isMuted: true }; + render(); + const bellSlashIcons = document.querySelectorAll('[data-icon="bell-slash"]'); + expect(bellSlashIcons.length).toBeGreaterThan(0); + }); + + it('does not show muted indicator when wave is not muted', () => { + const unmutedWave = { ...baseWave, id: '6', isMuted: false }; + render(); + const bellSlashIcons = document.querySelectorAll('[data-icon="bell-slash"]'); + expect(bellSlashIcons.length).toBe(0); + }); }); diff --git a/__tests__/components/drops/view/DropsList.test.tsx b/__tests__/components/drops/view/DropsList.test.tsx index 1517326aa3..8dad3b5e70 100644 --- a/__tests__/components/drops/view/DropsList.test.tsx +++ b/__tests__/components/drops/view/DropsList.test.tsx @@ -46,6 +46,11 @@ jest.mock("@/components/drops/view/HighlightDropWrapper", () => ({ }, })); +jest.mock("@/components/drops/view/UnreadDivider", () => ({ + __esModule: true, + default: () =>
, +})); + describe("DropsList", () => { beforeEach(() => { dropProps = []; @@ -84,4 +89,92 @@ describe("DropsList", () => { expect(dropProps).toHaveLength(1); expect(lightProps).toHaveLength(1); }); + + it("renders unread divider when unreadDividerSerialNo matches a drop", () => { + const drops: any = [ + { stableKey: "a", serial_no: 1, type: DropSize.FULL, wave: { id: "w" } }, + { stableKey: "b", serial_no: 2, type: DropSize.FULL, wave: { id: "w" } }, + { stableKey: "c", serial_no: 3, type: DropSize.FULL, wave: { id: "w" } }, + ]; + + render( + + ); + + expect(screen.getByTestId("unread-divider")).toBeInTheDocument(); + }); + + it("does not render unread divider when unreadDividerSerialNo is null", () => { + const drops: any = [ + { stableKey: "a", serial_no: 1, type: DropSize.FULL, wave: { id: "w" } }, + { stableKey: "b", serial_no: 2, type: DropSize.FULL, wave: { id: "w" } }, + ]; + + render( + + ); + + expect(screen.queryByTestId("unread-divider")).not.toBeInTheDocument(); + }); + + it("does not render unread divider when unreadDividerSerialNo does not match any drop", () => { + const drops: any = [ + { stableKey: "a", serial_no: 1, type: DropSize.FULL, wave: { id: "w" } }, + { stableKey: "b", serial_no: 2, type: DropSize.FULL, wave: { id: "w" } }, + ]; + + render( + + ); + + expect(screen.queryByTestId("unread-divider")).not.toBeInTheDocument(); + }); }); diff --git a/__tests__/components/drops/view/UnreadDivider.test.tsx b/__tests__/components/drops/view/UnreadDivider.test.tsx new file mode 100644 index 0000000000..5e94840bee --- /dev/null +++ b/__tests__/components/drops/view/UnreadDivider.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import UnreadDivider from '@/components/drops/view/UnreadDivider'; + +describe('UnreadDivider', () => { + it('renders with default label', () => { + render(); + expect(screen.getByText('New Messages')).toBeInTheDocument(); + }); + + it('renders with custom label', () => { + render(); + expect(screen.getByText('Unread Items')).toBeInTheDocument(); + }); + + it('renders horizontal lines', () => { + const { container } = render(); + const lines = container.querySelectorAll(String.raw`.tw-h-0\.5.tw-bg-rose-500`); + expect(lines.length).toBe(2); + }); +}); + diff --git a/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx b/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx index 50805aaad6..73b86f4120 100644 --- a/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx +++ b/__tests__/components/waves/drop/SingleWaveDropChat.test.tsx @@ -1,8 +1,7 @@ -import { render, fireEvent, act } from '@testing-library/react'; -import React from 'react'; -import { SingleWaveDropChat } from '@/components/waves/drop/SingleWaveDropChat'; +import { SingleWaveDropChat } from "@/components/waves/drop/SingleWaveDropChat"; +import { act, fireEvent, render } from "@testing-library/react"; -jest.mock('@/hooks/useDeviceInfo', () => () => ({ +jest.mock("@/hooks/useDeviceInfo", () => () => ({ isMobileDevice: true, hasTouchScreen: true, isApp: true, @@ -12,7 +11,7 @@ jest.mock('@/hooks/useDeviceInfo', () => () => ({ // Mock useAndroidKeyboard with configurable values let mockKeyboardVisible = false; -jest.mock('@/hooks/useAndroidKeyboard', () => ({ +jest.mock("@/hooks/useAndroidKeyboard", () => ({ useAndroidKeyboard: () => ({ isVisible: mockKeyboardVisible, keyboardHeight: mockKeyboardVisible ? 350 : 0, @@ -21,18 +20,41 @@ jest.mock('@/hooks/useAndroidKeyboard', () => ({ })); let capturedProps: any; -jest.mock('@/components/waves/drops/wave-drops-all', () => ({ __esModule: true, default: (props: any) => { capturedProps = props; return
; } })); +jest.mock("@/components/waves/drops/wave-drops-all", () => ({ + __esModule: true, + default: (props: any) => { + capturedProps = props; + return
; + }, +})); -jest.mock('@/components/waves/CreateDropWaveWrapper', () => ({ CreateDropWaveWrapper: ({ children }: any) =>
{children}
, CreateDropWaveWrapperContext: { SINGLE_DROP: 'SINGLE_DROP' } })); +jest.mock("@/components/waves/CreateDropWaveWrapper", () => ({ + CreateDropWaveWrapper: ({ children }: any) => ( +
{children}
+ ), + CreateDropWaveWrapperContext: { SINGLE_DROP: "SINGLE_DROP" }, +})); -jest.mock('@/components/waves/PrivilegedDropCreator', () => ({ __esModule: true, default: (props: any) => + ); + } + + return ( + <> + + + Mark as unread + + + ); +} diff --git a/components/waves/drops/WaveDropActionsOptions.tsx b/components/waves/drops/WaveDropActionsOptions.tsx index ba43c7f482..510e5a81a5 100644 --- a/components/waves/drops/WaveDropActionsOptions.tsx +++ b/components/waves/drops/WaveDropActionsOptions.tsx @@ -1,12 +1,12 @@ "use client"; -import React, { useState } from "react"; -import { ApiDrop } from "@/generated/models/ApiDrop"; -import CommonAnimationWrapper from "@/components/utils/animation/CommonAnimationWrapper"; -import CommonAnimationOpacity from "@/components/utils/animation/CommonAnimationOpacity"; import DropsListItemDeleteDropModal from "@/components/drops/view/item/options/delete/DropsListItemDeleteDropModal"; -import { Tooltip } from "react-tooltip"; +import CommonAnimationOpacity from "@/components/utils/animation/CommonAnimationOpacity"; +import CommonAnimationWrapper from "@/components/utils/animation/CommonAnimationWrapper"; +import { ApiDrop } from "@/generated/models/ApiDrop"; import { TrashIcon } from "@heroicons/react/24/outline"; +import React, { useState } from "react"; +import { Tooltip } from "react-tooltip"; interface WaveDropActionsOptionsProps { readonly drop: ApiDrop; @@ -32,18 +32,10 @@ const WaveDropActionsOptions: React.FC = ({ Delete diff --git a/components/waves/drops/WaveDropMobileMenu.tsx b/components/waves/drops/WaveDropMobileMenu.tsx index 6167e7d9ea..665277ffbc 100644 --- a/components/waves/drops/WaveDropMobileMenu.tsx +++ b/components/waves/drops/WaveDropMobileMenu.tsx @@ -1,15 +1,16 @@ "use client"; +import { AuthContext } from "@/components/auth/Auth"; +import CommonDropdownItemsMobileWrapper from "@/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper"; import { publicEnv } from "@/config/env"; -import { getWaveRoute } from "@/helpers/navigation.helpers"; -import { FC, useContext, useEffect, useMemo, useState } from "react"; -import { createPortal } from "react-dom"; import { ApiDrop } from "@/generated/models/ApiDrop"; import { ApiDropType } from "@/generated/models/ApiDropType"; +import { getWaveRoute } from "@/helpers/navigation.helpers"; import { DropSize, ExtendedDrop } from "@/helpers/waves/drop.helpers"; -import { AuthContext } from "@/components/auth/Auth"; -import CommonDropdownItemsMobileWrapper from "@/components/utils/select/dropdown/CommonDropdownItemsMobileWrapper"; +import { FC, useContext, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; import WaveDropActionsAddReaction from "./WaveDropActionsAddReaction"; +import WaveDropActionsMarkUnread from "./WaveDropActionsMarkUnread"; import WaveDropActionsRate from "./WaveDropActionsRate"; import WaveDropMobileMenuDelete from "./WaveDropMobileMenuDelete"; import WaveDropMobileMenuEdit from "./WaveDropMobileMenuEdit"; @@ -245,6 +246,11 @@ const WaveDropMobileMenu: FC = ({ )} + {showFollowOption && !isAuthor && ( )} diff --git a/components/waves/drops/wave-drops-all/index.tsx b/components/waves/drops/wave-drops-all/index.tsx index 9ba6fa6531..f4272ee04a 100644 --- a/components/waves/drops/wave-drops-all/index.tsx +++ b/components/waves/drops/wave-drops-all/index.tsx @@ -1,9 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; import { useAuth } from "@/components/auth/Auth"; import { useNotificationsContext } from "@/components/notifications/NotificationsContext"; +import WaveDropsScrollingOverlay from "@/components/waves/drops/WaveDropsScrollingOverlay"; +import { + UnreadDividerProvider, + useUnreadDivider, +} from "@/contexts/wave/UnreadDividerContext"; +import { useWaveChatScrollOptional } from "@/contexts/wave/WaveChatScrollContext"; +import { ApiDrop } from "@/generated/models/ApiDrop"; import { getWaveRoute } from "@/helpers/navigation.helpers"; import { Drop, DropSize, ExtendedDrop } from "@/helpers/waves/drop.helpers"; import { isWaveDirectMessage } from "@/helpers/waves/wave.helpers"; @@ -11,14 +16,13 @@ import useDeviceInfo from "@/hooks/useDeviceInfo"; import { useScrollBehavior } from "@/hooks/useScrollBehavior"; import { useVirtualizedWaveDrops } from "@/hooks/useVirtualizedWaveDrops"; import { useWaveIsTyping } from "@/hooks/useWaveIsTyping"; -import { ApiDrop } from "@/generated/models/ApiDrop"; import { ActiveDropState } from "@/types/dropInteractionTypes"; -import WaveDropsScrollingOverlay from "@/components/waves/drops/WaveDropsScrollingOverlay"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useWaveDropsClipboard } from "./hooks/useWaveDropsClipboard"; import { useWaveDropsNotificationRead } from "./hooks/useWaveDropsNotificationRead"; import { useWaveDropsSerialScroll } from "./hooks/useWaveDropsSerialScroll"; -import { useWaveDropsClipboard } from "./hooks/useWaveDropsClipboard"; import { WaveDropsContent } from "./subcomponents/WaveDropsContent"; -import { useWaveChatScrollOptional } from "@/contexts/wave/WaveChatScrollContext"; const EMPTY_DROPS: Drop[] = []; @@ -43,9 +47,10 @@ interface WaveDropsAllProps { readonly initialDrop: number | null; readonly onDropContentClick?: (drop: ExtendedDrop) => void; readonly bottomPaddingClassName?: string; + readonly isMuted?: boolean; } -const WaveDropsAll: React.FC = ({ +const WaveDropsAllInner: React.FC = ({ waveId, dropId, onReply, @@ -54,8 +59,8 @@ const WaveDropsAll: React.FC = ({ initialDrop, onDropContentClick, bottomPaddingClassName, + isMuted = false, }) => { - const router = useRouter(); const { removeWaveDeliveredNotifications } = useNotificationsContext(); const { connectedProfile } = useAuth(); @@ -65,9 +70,13 @@ const WaveDropsAll: React.FC = ({ const { waveMessages, fetchNextPage, waitAndRevealDrop } = useVirtualizedWaveDrops(waveId, dropId); + const { unreadDividerSerialNo, setUnreadDividerSerialNo } = + useUnreadDivider(); + const typingMessage = useWaveIsTyping( waveId, - connectedProfile?.handle ?? null + connectedProfile?.handle ?? null, + isMuted ); const scrollBehavior = useScrollBehavior(); @@ -98,12 +107,49 @@ const WaveDropsAll: React.FC = ({ null ); + const prevLatestSerialNoRef = useRef(null); + useEffect(() => { setVisibleLatestSerial(null); - }, [waveId]); + prevLatestSerialNoRef.current = null; + if (initialDrop === null) { + setUnreadDividerSerialNo(null); + } else { + setUnreadDividerSerialNo(initialDrop); + } + }, [waveId, initialDrop, setUnreadDividerSerialNo]); const latestSerialNo = waveMessages?.drops?.[0]?.serial_no ?? null; + useEffect(() => { + if (latestSerialNo === null) { + return; + } + + const prevSerial = prevLatestSerialNoRef.current; + prevLatestSerialNoRef.current = latestSerialNo; + + if (prevSerial !== null && latestSerialNo > prevSerial && !isAtBottom) { + setUnreadDividerSerialNo((current) => { + if (current === null) { + return prevSerial + 1; + } + return current; + }); + } + }, [latestSerialNo, isAtBottom, setUnreadDividerSerialNo]); + + const wasNotAtBottomRef = useRef(false); + + useEffect(() => { + if (!isAtBottom) { + wasNotAtBottomRef.current = true; + } else if (wasNotAtBottomRef.current && unreadDividerSerialNo !== null) { + setUnreadDividerSerialNo(null); + wasNotAtBottomRef.current = false; + } + }, [isAtBottom, unreadDividerSerialNo, setUnreadDividerSerialNo]); + useEffect(() => { if (latestSerialNo === null) { return; @@ -169,22 +215,18 @@ const WaveDropsAll: React.FC = ({ }, 0); }, [isAppleMobile, waveMessages?.drops, visibleLatestSerial]); - const { - serialTarget, - queueSerialTarget, - targetDropRef, - isScrolling, - } = useWaveDropsSerialScroll({ - waveId, - dropId, - initialDrop, - waveMessages, - fetchNextPage, - waitAndRevealDrop, - scrollContainerRef, - shouldPinToBottom, - scrollToVisualBottom, - }); + const { serialTarget, queueSerialTarget, targetDropRef, isScrolling } = + useWaveDropsSerialScroll({ + waveId, + dropId, + initialDrop, + waveMessages, + fetchNextPage, + waitAndRevealDrop, + scrollContainerRef, + shouldPinToBottom, + scrollToVisualBottom, + }); const waveChatScroll = useWaveChatScrollOptional(); useEffect(() => { @@ -235,10 +277,7 @@ const WaveDropsAll: React.FC = ({ (drop.wave as unknown as { chat?: { scope?: { group?: { is_direct_message?: boolean } } }; }) ?? undefined; - const isDirectMessage = isWaveDirectMessage( - drop.wave.id, - waveDetails - ); + const isDirectMessage = isWaveDirectMessage(drop.wave.id, waveDetails); const href = getWaveRoute({ waveId: drop.wave.id, serialNo: drop.serial_no, @@ -283,4 +322,34 @@ const WaveDropsAll: React.FC = ({ ); }; +const WaveDropsAll: React.FC = ({ + waveId, + dropId, + onReply, + onQuote, + activeDrop, + initialDrop, + onDropContentClick, + bottomPaddingClassName, + isMuted = false, +}) => { + return ( + + + + ); +}; + export default WaveDropsAll; diff --git a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx index ca0bd6bf8f..7367ce35bc 100644 --- a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx +++ b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsContent.tsx @@ -1,16 +1,19 @@ -import type { MutableRefObject } from "react"; import CircleLoader, { CircleLoaderSize, } from "@/components/distribution-plan-tool/common/CircleLoader"; import WaveDropsEmptyPlaceholder from "@/components/waves/drops/WaveDropsEmptyPlaceholder"; +import { useUnreadDivider } from "@/contexts/wave/UnreadDividerContext"; import type { ApiDrop } from "@/generated/models/ApiDrop"; -import type { ActiveDropState } from "@/types/dropInteractionTypes"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { useVirtualizedWaveDrops } from "@/hooks/useVirtualizedWaveDrops"; +import type { ActiveDropState } from "@/types/dropInteractionTypes"; +import type { MutableRefObject } from "react"; import { WaveDropsMessageListSection } from "./WaveDropsMessageListSection"; import { WaveDropsTypingIndicator } from "./WaveDropsTypingIndicator"; -type WaveMessagesResult = ReturnType["waveMessages"]; +type WaveMessagesResult = ReturnType< + typeof useVirtualizedWaveDrops +>["waveMessages"]; interface WaveDropsContentProps { readonly waveMessages: WaveMessagesResult; @@ -67,6 +70,7 @@ export const WaveDropsContent: React.FC = ({ onRevealPending, bottomPaddingClassName, }) => { + const { unreadDividerSerialNo } = useUnreadDivider(); const dropsCount = waveMessages?.drops?.length ?? 0; const isInitialLoading = !!waveMessages?.isLoading && @@ -108,6 +112,7 @@ export const WaveDropsContent: React.FC = ({ pendingCount={pendingCount} onRevealPending={onRevealPending} bottomPaddingClassName={bottomPaddingClassName} + unreadDividerSerialNo={unreadDividerSerialNo} /> diff --git a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx index e487a945fd..0f4aca0a2b 100644 --- a/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx +++ b/components/waves/drops/wave-drops-all/subcomponents/WaveDropsMessageListSection.tsx @@ -1,13 +1,15 @@ -import type { MutableRefObject } from "react"; import DropsList from "@/components/drops/view/DropsList"; import { WaveDropsReverseContainer } from "@/components/waves/drops/WaveDropsReverseContainer"; import { WaveDropsScrollBottomButton } from "@/components/waves/drops/WaveDropsScrollBottomButton"; import type { ApiDrop } from "@/generated/models/ApiDrop"; -import type { ActiveDropState } from "@/types/dropInteractionTypes"; import type { ExtendedDrop } from "@/helpers/waves/drop.helpers"; import type { useVirtualizedWaveDrops } from "@/hooks/useVirtualizedWaveDrops"; +import type { ActiveDropState } from "@/types/dropInteractionTypes"; +import type { MutableRefObject } from "react"; -type WaveMessagesResult = ReturnType["waveMessages"]; +type WaveMessagesResult = ReturnType< + typeof useVirtualizedWaveDrops +>["waveMessages"]; interface WaveDropsMessageListSectionProps { readonly waveMessages: WaveMessagesResult; @@ -40,6 +42,7 @@ interface WaveDropsMessageListSectionProps { readonly pendingCount: number; readonly onRevealPending: () => void; readonly bottomPaddingClassName?: string; + readonly unreadDividerSerialNo?: number | null; } const MIN_DROPS_FOR_PAGINATION = 25; @@ -65,6 +68,7 @@ export const WaveDropsMessageListSection: React.FC< pendingCount, onRevealPending, bottomPaddingClassName, + unreadDividerSerialNo, }) => { const hasNextPage = !!waveMessages?.hasNextPage && @@ -77,8 +81,7 @@ export const WaveDropsMessageListSection: React.FC< isFetchingNextPage={!!waveMessages?.isLoadingNextPage} hasNextPage={hasNextPage} onTopIntersection={onTopIntersection} - bottomPaddingClassName={bottomPaddingClassName} - > + bottomPaddingClassName={bottomPaddingClassName}>
diff --git a/components/waves/header/options/WaveHeaderOptions.tsx b/components/waves/header/options/WaveHeaderOptions.tsx index 82bb5de8fa..641e0fcbd1 100644 --- a/components/waves/header/options/WaveHeaderOptions.tsx +++ b/components/waves/header/options/WaveHeaderOptions.tsx @@ -1,10 +1,11 @@ "use client"; -import { useRef, useState } from "react"; import { ApiWave } from "@/generated/models/ApiWave"; -import { useClickAway, useKeyPressEvent } from "react-use"; import { AnimatePresence, motion } from "framer-motion"; +import { useRef, useState } from "react"; +import { useClickAway, useKeyPressEvent } from "react-use"; import WaveDelete from "./delete/WaveDelete"; +import WaveMute from "./mute/WaveMute"; export default function WaveHeaderOptions({ wave, @@ -50,6 +51,7 @@ export default function WaveHeaderOptions({ exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.2 }}>
+ setIsOptionsOpen(false)} />
diff --git a/components/waves/header/options/mute/WaveMute.tsx b/components/waves/header/options/mute/WaveMute.tsx new file mode 100644 index 0000000000..7051459d29 --- /dev/null +++ b/components/waves/header/options/mute/WaveMute.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useAuth } from "@/components/auth/Auth"; +import { Spinner } from "@/components/dotLoader/DotLoader"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { ApiWave } from "@/generated/models/ApiWave"; +import { commonApiDelete, commonApiPost } from "@/services/api/common-api"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; + +export default function WaveMute({ + wave, + onSuccess, +}: { + readonly wave: ApiWave; + readonly onSuccess?: () => void; +}) { + const queryClient = useQueryClient(); + const { setToast } = useAuth(); + const [loading, setLoading] = useState(false); + const isMuted = wave.metrics.muted; + + const handleToggleMute = useCallback(async () => { + setLoading(true); + try { + if (isMuted) { + await commonApiDelete({ endpoint: `waves/${wave.id}/mute` }); + } else { + await commonApiPost({ endpoint: `waves/${wave.id}/mute`, body: {} }); + } + queryClient.invalidateQueries({ + queryKey: [QueryKey.WAVE, { wave_id: wave.id }], + }); + queryClient.invalidateQueries({ + queryKey: [QueryKey.WAVES_OVERVIEW], + }); + onSuccess?.(); + } 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 { + setLoading(false); + } + }, [wave.id, isMuted, queryClient, setToast, onSuccess]); + + return ( + + ); +} diff --git a/components/waves/specs/WaveNotificationSettings.tsx b/components/waves/specs/WaveNotificationSettings.tsx index cb483e2718..44f4bc5cec 100644 --- a/components/waves/specs/WaveNotificationSettings.tsx +++ b/components/waves/specs/WaveNotificationSettings.tsx @@ -1,17 +1,16 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; -import { ApiWave } from "@/generated/models/ApiWave"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faAt } from "@fortawesome/free-solid-svg-icons"; -import { useWaveNotificationSubscription } from "@/hooks/useWaveNotificationSubscription"; -import { - commonApiDelete, - commonApiPost, -} from "@/services/api/common-api"; import { useAuth } from "@/components/auth/Auth"; -import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; import { Spinner } from "@/components/dotLoader/DotLoader"; +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import { useSeizeSettings } from "@/contexts/SeizeSettingsContext"; +import { ApiWave } from "@/generated/models/ApiWave"; +import { useWaveNotificationSubscription } from "@/hooks/useWaveNotificationSubscription"; +import { commonApiDelete, commonApiPost } from "@/services/api/common-api"; +import { faAt, faBellSlash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useEffect, useState } from "react"; import { OverlayTrigger, Tooltip } from "react-bootstrap"; interface WaveRatingProps { @@ -19,6 +18,7 @@ interface WaveRatingProps { } export default function WaveNotificationSettings({ wave }: WaveRatingProps) { + const queryClient = useQueryClient(); const { seizeSettings } = useSeizeSettings(); const disableSelection = wave.metrics.subscribers_count >= @@ -26,6 +26,8 @@ export default function WaveNotificationSettings({ wave }: WaveRatingProps) { 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); @@ -36,6 +38,39 @@ export default function WaveNotificationSettings({ wave }: WaveRatingProps) { 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]); @@ -123,6 +158,43 @@ export default function WaveNotificationSettings({ wave }: WaveRatingProps) { return null; } + const getMuteTooltip = () => { + return isMuted ? "Click to unmute this wave" : "Click to mute this wave"; + }; + + if (isMuted) { + return ( +
+
+ + {getMuteTooltip()} + + }> + + +
+
+ ); + } + return (
diff --git a/contexts/wave/MyStreamContext.tsx b/contexts/wave/MyStreamContext.tsx index ad36015e88..7f7417f8bb 100644 --- a/contexts/wave/MyStreamContext.tsx +++ b/contexts/wave/MyStreamContext.tsx @@ -9,6 +9,7 @@ import { useWebsocketStatus } from "@/services/websocket/useWebSocketMessage"; import React, { createContext, ReactNode, + useCallback, useContext, useEffect, useMemo, @@ -40,11 +41,19 @@ interface WavesContextData { readonly fetchNextPage: () => void; readonly addPinnedWave: (id: string) => void; readonly removePinnedWave: (id: string) => void; + readonly restoreWaveUnreadCount: (waveId: string, count?: number) => void; } interface ActiveWaveContextData { readonly id: string | null; - readonly set: (waveId: string | null, options?: { isDirectMessage?: boolean; replace?: boolean }) => void; + readonly set: ( + waveId: string | null, + options?: { + isDirectMessage?: boolean; + replace?: boolean; + serialNo?: number | string | null; + } + ) => void; } // Define the interface for the wave messages store functions @@ -108,6 +117,19 @@ export const MyStreamProvider: React.FC = ({ removeDrop: waveMessagesStore.removeDrop, }); + const wavesRef = useRef(wavesHookData.waves); + const dmWavesRef = useRef(dmWavesHookData.waves); + wavesRef.current = wavesHookData.waves; + dmWavesRef.current = dmWavesHookData.waves; + + const isWaveMuted = useCallback((waveId: string): boolean => { + const wave = wavesRef.current.find((w) => w.id === waveId); + if (wave) return wave.isMuted; + const dmWave = dmWavesRef.current.find((w) => w.id === waveId); + if (dmWave) return dmWave.isMuted; + return false; + }, []); + // Instantiate the real-time updater hook const { processIncomingDrop, processDropRemoved } = useWaveRealtimeUpdater({ activeWaveId, @@ -117,6 +139,7 @@ export const MyStreamProvider: React.FC = ({ syncNewestMessages: waveDataManager.syncNewestMessages, removeDrop: waveMessagesStore.removeDrop, removeWaveDeliveredNotifications, + isWaveMuted, }); useEffect(() => { @@ -168,6 +191,7 @@ export const MyStreamProvider: React.FC = ({ fetchNextPage: wavesHookData.fetchNextPage, addPinnedWave: wavesHookData.addPinnedWave, removePinnedWave: wavesHookData.removePinnedWave, + restoreWaveUnreadCount: wavesHookData.restoreWaveUnreadCount, }; const directMessages: WavesContextData = { @@ -178,6 +202,7 @@ export const MyStreamProvider: React.FC = ({ fetchNextPage: dmWavesHookData.fetchNextPage, addPinnedWave: dmWavesHookData.addPinnedWave, removePinnedWave: dmWavesHookData.removePinnedWave, + restoreWaveUnreadCount: dmWavesHookData.restoreWaveUnreadCount, }; const activeWave: ActiveWaveContextData = { @@ -212,6 +237,7 @@ export const MyStreamProvider: React.FC = ({ wavesHookData.fetchNextPage, wavesHookData.addPinnedWave, wavesHookData.removePinnedWave, + wavesHookData.restoreWaveUnreadCount, dmWavesHookData.waves, dmWavesHookData.isFetching, dmWavesHookData.isFetchingNextPage, @@ -219,6 +245,7 @@ export const MyStreamProvider: React.FC = ({ dmWavesHookData.fetchNextPage, dmWavesHookData.addPinnedWave, dmWavesHookData.removePinnedWave, + dmWavesHookData.restoreWaveUnreadCount, activeWaveId, setActiveWave, waveMessagesStore.getData, diff --git a/contexts/wave/UnreadDividerContext.tsx b/contexts/wave/UnreadDividerContext.tsx new file mode 100644 index 0000000000..0098173f7a --- /dev/null +++ b/contexts/wave/UnreadDividerContext.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { createContext, ReactNode, useContext, useMemo, useState } from "react"; + +type SetUnreadDividerSerialNo = ( + serialNo: number | null | ((current: number | null) => number | null) +) => void; + +interface UnreadDividerContextValue { + unreadDividerSerialNo: number | null; + setUnreadDividerSerialNo: SetUnreadDividerSerialNo; +} + +const UnreadDividerContext = createContext( + null +); + +interface UnreadDividerProviderProps { + readonly initialSerialNo: number | null; + readonly children: ReactNode; +} + +export function UnreadDividerProvider({ + initialSerialNo, + children, +}: UnreadDividerProviderProps) { + const [unreadDividerSerialNo, setUnreadDividerSerialNo] = useState< + number | null + >(initialSerialNo); + + const value = useMemo( + () => ({ + unreadDividerSerialNo, + setUnreadDividerSerialNo, + }), + [unreadDividerSerialNo, setUnreadDividerSerialNo] + ); + + return ( + + {children} + + ); +} + +export function useUnreadDivider() { + const context = useContext(UnreadDividerContext); + if (!context) { + throw new Error( + "useUnreadDivider must be used within an UnreadDividerProvider" + ); + } + return context; +} + +export function useUnreadDividerOptional() { + return useContext(UnreadDividerContext); +} diff --git a/contexts/wave/hooks/useActiveWaveManager.ts b/contexts/wave/hooks/useActiveWaveManager.ts index 5bff7ab965..c9b94e08b4 100644 --- a/contexts/wave/hooks/useActiveWaveManager.ts +++ b/contexts/wave/hooks/useActiveWaveManager.ts @@ -11,6 +11,7 @@ import { interface WaveNavigationOptions { isDirectMessage?: boolean; + serialNo?: number | string | null; } const getWaveFromWindow = (): string | null => { @@ -45,8 +46,9 @@ export function useActiveWaveManager() { const buildUrl = useCallback( (waveId: string | null, options?: WaveNavigationOptions) => { const isDirectMessage = options?.isDirectMessage ?? false; + const serialNo = options?.serialNo ?? undefined; return waveId - ? getWaveRoute({ waveId, isDirectMessage, isApp }) + ? getWaveRoute({ waveId, serialNo, isDirectMessage, isApp }) : getWaveHomeRoute({ isDirectMessage, isApp }); }, [isApp] diff --git a/contexts/wave/hooks/useEnhancedDmWavesList.ts b/contexts/wave/hooks/useEnhancedDmWavesList.ts index f7cd45d724..331a4c5b3b 100644 --- a/contexts/wave/hooks/useEnhancedDmWavesList.ts +++ b/contexts/wave/hooks/useEnhancedDmWavesList.ts @@ -1,70 +1,16 @@ -"use client" +"use client"; -import { useCallback, useMemo } from "react"; import useDmWavesList from "@/hooks/useDmWavesList"; -import useNewDropCounter, { getNewestTimestamp } from "./useNewDropCounter"; -import { ApiWave } from "@/generated/models/ApiWave"; -import type { MinimalWave } from "./useEnhancedWavesList"; +import useEnhancedWavesListCore from "./useEnhancedWavesListCore"; + +export type { MinimalWave } from "./useEnhancedWavesListCore"; function useEnhancedDmWavesList(activeWaveId: string | null) { const wavesData = useDmWavesList(); - const { newDropsCounts, resetAllWavesNewDropsCount } = useNewDropCounter( - activeWaveId, - wavesData.waves, - wavesData.mainWavesRefetch - ); - - const mapWaveToMinimalWave = useCallback( - (wave: ApiWave): MinimalWave => { - const newDropsData = { - count: newDropsCounts[wave.id]?.count ?? 0, - latestDropTimestamp: getNewestTimestamp( - newDropsCounts[wave.id]?.latestDropTimestamp, - wave.metrics.latest_drop_timestamp ?? null - ), - }; - return { - id: wave.id, - name: wave.name, - type: wave.wave.type, - picture: wave.picture, - contributors: wave.contributors_overview.map((c) => ({ - pfp: c.contributor_pfp, - })), - newDropsCount: newDropsData, - isPinned: false, - }; - }, - [newDropsCounts] - ); - - const mappedWaves = useMemo( - () => wavesData.waves.map(mapWaveToMinimalWave), - [wavesData.waves, mapWaveToMinimalWave] - ); - - const sortedWaves = useMemo( - () => - [...mappedWaves].sort( - (a, b) => - (b.newDropsCount.latestDropTimestamp ?? 0) - - (a.newDropsCount.latestDropTimestamp ?? 0) - ), - [mappedWaves] - ); - - return { - waves: sortedWaves, - isFetching: wavesData.isFetching, - isFetchingNextPage: wavesData.isFetchingNextPage, - hasNextPage: wavesData.hasNextPage, - fetchNextPage: wavesData.fetchNextPage, - addPinnedWave: () => {}, - removePinnedWave: () => {}, - refetchAllWaves: wavesData.refetchAllWaves, - resetAllWavesNewDropsCount, - }; + return useEnhancedWavesListCore(activeWaveId, wavesData, { + supportsPinning: false, + }); } -export default useEnhancedDmWavesList; +export default useEnhancedDmWavesList; diff --git a/contexts/wave/hooks/useEnhancedWavesList.ts b/contexts/wave/hooks/useEnhancedWavesList.ts index 16234a1347..5b57e30aa0 100644 --- a/contexts/wave/hooks/useEnhancedWavesList.ts +++ b/contexts/wave/hooks/useEnhancedWavesList.ts @@ -1,79 +1,16 @@ -"use client" +"use client"; -import { useCallback, useMemo } from "react"; import useWavesList from "@/hooks/useWavesList"; -import useNewDropCounter, { - MinimalWaveNewDropsCount, - getNewestTimestamp, -} from "./useNewDropCounter"; -import { ApiWave } from "@/generated/models/ApiWave"; -import { ApiWaveType } from "@/generated/models/ApiWaveType"; +import useEnhancedWavesListCore from "./useEnhancedWavesListCore"; -export interface MinimalWave { - id: string; - name: string; - type: ApiWaveType; - newDropsCount: MinimalWaveNewDropsCount; - picture: string | null; - contributors: { pfp: string }[]; - isPinned: boolean; -} +export type { MinimalWave } from "./useEnhancedWavesListCore"; function useEnhancedWavesList(activeWaveId: string | null) { const wavesData = useWavesList(); - const { newDropsCounts, resetAllWavesNewDropsCount } = useNewDropCounter( - activeWaveId, - wavesData.waves, - wavesData.mainWavesRefetch - ); - - const mapWave = useCallback( - (wave: ApiWave & { isPinned?: boolean }): MinimalWave => { - const newDrops = { - count: newDropsCounts[wave.id]?.count ?? 0, - latestDropTimestamp: getNewestTimestamp( - newDropsCounts[wave.id]?.latestDropTimestamp, - wave.metrics.latest_drop_timestamp ?? null - ), - }; - return { - id: wave.id, - name: wave.name, - type: wave.wave.type, - picture: wave.picture, - contributors: wave.contributors_overview.map((c) => ({ pfp: c.contributor_pfp })), - newDropsCount: newDrops, - // Use server-provided isPinned status instead of local comparison - isPinned: wave.isPinned ?? false, - }; - }, - [newDropsCounts] - ); - - const minimal = useMemo(() => wavesData.waves.map(mapWave), [wavesData.waves, mapWave]); - - const sorted = useMemo( - () => - [...minimal].sort( - (a, b) => - (b.newDropsCount.latestDropTimestamp ?? 0) - - (a.newDropsCount.latestDropTimestamp ?? 0) - ), - [minimal] - ); - - return { - waves: sorted, - isFetching: wavesData.isFetching, - isFetchingNextPage: wavesData.isFetchingNextPage, - hasNextPage: wavesData.hasNextPage, - fetchNextPage: wavesData.fetchNextPage, - addPinnedWave: wavesData.addPinnedWave, - removePinnedWave: wavesData.removePinnedWave, - refetchAllWaves: wavesData.refetchAllWaves, - resetAllWavesNewDropsCount, - }; + return useEnhancedWavesListCore(activeWaveId, wavesData, { + supportsPinning: true, + }); } -export default useEnhancedWavesList; +export default useEnhancedWavesList; diff --git a/contexts/wave/hooks/useEnhancedWavesListCore.ts b/contexts/wave/hooks/useEnhancedWavesListCore.ts new file mode 100644 index 0000000000..9a94ef6ff1 --- /dev/null +++ b/contexts/wave/hooks/useEnhancedWavesListCore.ts @@ -0,0 +1,223 @@ +"use client"; + +import { ApiWave } from "@/generated/models/ApiWave"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import useNewDropCounter, { + MinimalWaveNewDropsCount, + getNewestTimestamp, +} from "./useNewDropCounter"; + +const UNREAD_CLEAR_DELAY_MS = 1000; + +export interface MinimalWave { + id: string; + name: string; + type: ApiWaveType; + newDropsCount: MinimalWaveNewDropsCount; + picture: string | null; + contributors: { pfp: string }[]; + isPinned: boolean; + isMuted: boolean; + unreadDropsCount: number; + latestReadTimestamp: number; + firstUnreadDropSerialNo: number | null; +} + +export interface WavesDataSource { + waves: ApiWave[]; + isFetching: boolean; + isFetchingNextPage: boolean; + hasNextPage: boolean; + fetchNextPage: () => void; + mainWavesRefetch: () => void; + refetchAllWaves: () => void; + addPinnedWave: (waveId: string) => void; + removePinnedWave: (waveId: string) => void; +} + +interface UseEnhancedWavesListCoreOptions { + supportsPinning: boolean; +} + +const DEFAULT_OPTIONS: UseEnhancedWavesListCoreOptions = { + supportsPinning: true, +}; + +function useEnhancedWavesListCore( + activeWaveId: string | null, + wavesData: WavesDataSource, + options: UseEnhancedWavesListCoreOptions = DEFAULT_OPTIONS +) { + const { newDropsCounts, resetAllWavesNewDropsCount } = useNewDropCounter( + activeWaveId, + wavesData.waves, + wavesData.mainWavesRefetch + ); + + const [clearedUnreadWaveIds, setClearedUnreadWaveIds] = useState>( + new Set() + ); + + const [forcedUnreadCounts, setForcedUnreadCounts] = useState< + Record + >({}); + + const resetWaveUnreadCount = useCallback((waveId: string) => { + setClearedUnreadWaveIds((prev) => { + const next = new Set(prev); + next.add(waveId); + return next; + }); + setForcedUnreadCounts((prev) => { + if (!(waveId in prev)) return prev; + const { [waveId]: _, ...rest } = prev; + return rest; + }); + }, []); + + const restoreWaveUnreadCount = useCallback( + (waveId: string, count?: number) => { + setClearedUnreadWaveIds((prev) => { + if (!prev.has(waveId)) return prev; + const next = new Set(prev); + next.delete(waveId); + return next; + }); + if (count !== undefined) { + setForcedUnreadCounts((prev) => ({ + ...prev, + [waveId]: count, + })); + } + }, + [] + ); + + useEffect(() => { + if (!activeWaveId) return; + setForcedUnreadCounts((prev) => { + if (!(activeWaveId in prev)) return prev; + const { [activeWaveId]: _, ...rest } = prev; + return rest; + }); + const timeout = setTimeout(() => { + resetWaveUnreadCount(activeWaveId); + }, UNREAD_CLEAR_DELAY_MS); + return () => clearTimeout(timeout); + }, [activeWaveId, resetWaveUnreadCount]); + + const mapWave = useCallback( + (wave: ApiWave & { pinned?: boolean }): MinimalWave => { + const wsData = newDropsCounts[wave.id]; + const hasNewWsDrops = (wsData?.count ?? 0) > 0; + const newDrops = { + count: wsData?.count ?? 0, + latestDropTimestamp: getNewestTimestamp( + wsData?.latestDropTimestamp, + wave.metrics.latest_drop_timestamp ?? null + ), + firstUnreadSerialNo: wsData?.firstUnreadSerialNo ?? null, + }; + const isCleared = clearedUnreadWaveIds.has(wave.id) && !hasNewWsDrops; + const forcedCount = forcedUnreadCounts[wave.id]; + const apiFirstUnread = wave.metrics.first_unread_drop_serial_no ?? null; + const wsFirstUnread = wsData?.firstUnreadSerialNo ?? null; + let firstUnreadDropSerialNo: number | null = null; + if (!isCleared) { + if (apiFirstUnread !== null && wsFirstUnread !== null) { + firstUnreadDropSerialNo = Math.min(apiFirstUnread, wsFirstUnread); + } else { + firstUnreadDropSerialNo = apiFirstUnread ?? wsFirstUnread; + } + } + + let unreadDropsCount: number; + if (isCleared) { + unreadDropsCount = 0; + } else if (forcedCount !== undefined) { + unreadDropsCount = forcedCount + (wsData?.count ?? 0); + } else if (hasNewWsDrops) { + unreadDropsCount = + wave.metrics.your_unread_drops_count + (wsData?.count ?? 0); + } else { + unreadDropsCount = wave.metrics.your_unread_drops_count; + } + + return { + id: wave.id, + name: wave.name, + type: wave.wave.type, + picture: wave.picture, + contributors: wave.contributors_overview.map((c) => ({ + pfp: c.contributor_pfp, + })), + newDropsCount: newDrops, + isPinned: options.supportsPinning ? wave.pinned ?? false : false, + isMuted: wave.metrics.muted, + unreadDropsCount, + latestReadTimestamp: wave.metrics.your_latest_read_timestamp, + firstUnreadDropSerialNo, + }; + }, + [ + newDropsCounts, + clearedUnreadWaveIds, + forcedUnreadCounts, + options.supportsPinning, + ] + ); + + const minimal = useMemo( + () => wavesData.waves.map(mapWave), + [wavesData.waves, mapWave] + ); + + const sorted = useMemo( + () => + [...minimal].sort((a, b) => { + if (a.isMuted !== b.isMuted) { + return a.isMuted ? 1 : -1; + } + return ( + (b.newDropsCount.latestDropTimestamp ?? 0) - + (a.newDropsCount.latestDropTimestamp ?? 0) + ); + }), + [minimal] + ); + + return useMemo( + () => ({ + waves: sorted, + isFetching: wavesData.isFetching, + isFetchingNextPage: wavesData.isFetchingNextPage, + hasNextPage: wavesData.hasNextPage, + fetchNextPage: wavesData.fetchNextPage, + addPinnedWave: options.supportsPinning + ? wavesData.addPinnedWave + : () => {}, + removePinnedWave: options.supportsPinning + ? wavesData.removePinnedWave + : () => {}, + refetchAllWaves: wavesData.refetchAllWaves, + resetAllWavesNewDropsCount, + restoreWaveUnreadCount, + }), + [ + sorted, + wavesData.isFetching, + wavesData.isFetchingNextPage, + wavesData.hasNextPage, + wavesData.fetchNextPage, + wavesData.addPinnedWave, + wavesData.removePinnedWave, + wavesData.refetchAllWaves, + resetAllWavesNewDropsCount, + restoreWaveUnreadCount, + options.supportsPinning, + ] + ); +} + +export default useEnhancedWavesListCore; diff --git a/contexts/wave/hooks/useNewDropCounter.ts b/contexts/wave/hooks/useNewDropCounter.ts index 7699d51989..ab6dc06b96 100644 --- a/contexts/wave/hooks/useNewDropCounter.ts +++ b/contexts/wave/hooks/useNewDropCounter.ts @@ -1,10 +1,10 @@ "use client"; -import { useState, useCallback, useContext, useEffect } from "react"; -import { WsMessageType, WsDropUpdateMessage } from "@/helpers/Types"; -import { useWebSocketMessage } from "@/services/websocket/useWebSocketMessage"; import { AuthContext } from "@/components/auth/Auth"; import { ApiWave } from "@/generated/models/ApiWave"; +import { WsDropUpdateMessage, WsMessageType } from "@/helpers/Types"; +import { useWebSocketMessage } from "@/services/websocket/useWebSocketMessage"; +import { useCallback, useContext, useEffect, useState } from "react"; /** * Interface for tracking new drops count for a wave @@ -12,6 +12,7 @@ import { ApiWave } from "@/generated/models/ApiWave"; export interface MinimalWaveNewDropsCount { readonly count: number; readonly latestDropTimestamp: number | null; + readonly firstUnreadSerialNo: number | null; } export function getNewestTimestamp( @@ -65,6 +66,7 @@ function useNewDropCounter( waves.find((wave) => wave.id === waveId)?.metrics .latest_drop_timestamp ?? null ), + firstUnreadSerialNo: null, }, })); }, @@ -81,6 +83,7 @@ function useNewDropCounter( prev[wave.id]?.latestDropTimestamp, wave.metrics.latest_drop_timestamp ?? null ), + firstUnreadSerialNo: null, }; }); return newCounts; @@ -115,7 +118,6 @@ function useNewDropCounter( WsMessageType.DROP_UPDATE, useCallback( (message) => { - // Skip if no waveId if (!message?.wave.id) return; const waveId = message.wave.id; @@ -123,8 +125,11 @@ function useNewDropCounter( if (!wave) { refetchWaves(); + return; } + if (wave.metrics.muted) return; + if ( connectedProfile?.handle?.toLowerCase() === message.author.handle?.toLowerCase() @@ -141,6 +146,7 @@ function useNewDropCounter( message.created_at, currentLatestDropTimestamp ?? 0 ), + firstUnreadSerialNo: prev[waveId]?.firstUnreadSerialNo ?? null, }, }; }); @@ -159,26 +165,29 @@ function useNewDropCounter( message.created_at, currentLatestDropTimestamp ?? 0 ), + firstUnreadSerialNo: prev[waveId]?.firstUnreadSerialNo ?? null, }, }; }); } - // Update the count for this wave setNewDropsCounts((prev) => { const currentCount = prev[waveId]?.count ?? 0; const currentLatestDropTimestamp = prev[waveId]?.latestDropTimestamp ?? null; - // Optional: Cap the maximum count at 99 - const MAX_COUNT = 99; + const currentFirstUnread = prev[waveId]?.firstUnreadSerialNo ?? null; return { ...prev, [waveId]: { - count: Math.min(currentCount + 1, MAX_COUNT), + count: currentCount + 1, latestDropTimestamp: Math.max( message.created_at, currentLatestDropTimestamp ?? 0 ), + firstUnreadSerialNo: + currentFirstUnread === null + ? message.serial_no + : Math.min(currentFirstUnread, message.serial_no), }, }; }); diff --git a/contexts/wave/hooks/useWaveRealtimeUpdater.ts b/contexts/wave/hooks/useWaveRealtimeUpdater.ts index 883899b2e8..dea318c094 100644 --- a/contexts/wave/hooks/useWaveRealtimeUpdater.ts +++ b/contexts/wave/hooks/useWaveRealtimeUpdater.ts @@ -21,6 +21,7 @@ interface UseWaveRealtimeUpdaterProps extends WaveDataStoreUpdater { signal: AbortSignal ) => Promise<{ drops: ApiDrop[] | null; highestSerialNo: number | null }>; readonly removeWaveDeliveredNotifications: (waveId: string) => Promise; + readonly isWaveMuted: (waveId: string) => boolean; } export enum ProcessIncomingDropType { @@ -42,6 +43,7 @@ export function useWaveRealtimeUpdater({ syncNewestMessages, removeDrop, removeWaveDeliveredNotifications, + isWaveMuted, }: UseWaveRealtimeUpdaterProps): { processIncomingDrop: ProcessIncomingDropFn; processDropRemoved: (waveId: string, dropId: string) => void; @@ -138,6 +140,10 @@ export function useWaveRealtimeUpdater({ const waveId = drop.wave.id; + if (isWaveMuted(waveId)) { + return; + } + // Check if tab just became visible and refresh eligibility if (tabJustBecameVisibleRef.current) { tabJustBecameVisibleRef.current = false; @@ -247,7 +253,8 @@ export function useWaveRealtimeUpdater({ registerWave, initiateFetchNewestCycle, removeWaveDeliveredNotifications, - refreshEligibility + refreshEligibility, + isWaveMuted, ] ); diff --git a/generated/models/ApiMarkDropUnreadResponse.ts b/generated/models/ApiMarkDropUnreadResponse.ts new file mode 100644 index 0000000000..a79abf7b38 --- /dev/null +++ b/generated/models/ApiMarkDropUnreadResponse.ts @@ -0,0 +1,43 @@ +/** + * 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 class ApiMarkDropUnreadResponse { + 'your_unread_drops_count': number; + 'first_unread_drop_serial_no'?: number | null; + + 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": "your_unread_drops_count", + "baseName": "your_unread_drops_count", + "type": "number", + "format": "" + }, + { + "name": "first_unread_drop_serial_no", + "baseName": "first_unread_drop_serial_no", + "type": "number", + "format": "" + } ]; + + static getAttributeTypeMap() { + return ApiMarkDropUnreadResponse.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/generated/models/ApiWaveMetrics.ts b/generated/models/ApiWaveMetrics.ts index f99c4e6a89..a615929c74 100644 --- a/generated/models/ApiWaveMetrics.ts +++ b/generated/models/ApiWaveMetrics.ts @@ -18,7 +18,11 @@ export class ApiWaveMetrics { 'latest_drop_timestamp': number; 'your_drops_count'?: number; 'your_participation_drops_count': number; - 'your_latest_drop_timestamp'?: number; + 'your_latest_drop_timestamp': number; + 'your_unread_drops_count': number; + 'first_unread_drop_serial_no'?: number; + 'your_latest_read_timestamp': number; + 'muted': boolean; static readonly discriminator: string | undefined = undefined; @@ -60,6 +64,30 @@ export class ApiWaveMetrics { "baseName": "your_latest_drop_timestamp", "type": "number", "format": "int64" + }, + { + "name": "your_unread_drops_count", + "baseName": "your_unread_drops_count", + "type": "number", + "format": "int64" + }, + { + "name": "first_unread_drop_serial_no", + "baseName": "first_unread_drop_serial_no", + "type": "number", + "format": "int64" + }, + { + "name": "your_latest_read_timestamp", + "baseName": "your_latest_read_timestamp", + "type": "number", + "format": "int64" + }, + { + "name": "muted", + "baseName": "muted", + "type": "boolean", + "format": "" } ]; static getAttributeTypeMap() { diff --git a/generated/models/ObjectSerializer.ts b/generated/models/ObjectSerializer.ts index 8e4a6150fc..357580b4c3 100644 --- a/generated/models/ObjectSerializer.ts +++ b/generated/models/ObjectSerializer.ts @@ -95,6 +95,7 @@ export * from '../models/ApiIntRange'; export * from '../models/ApiLightDrop'; export * from '../models/ApiLoginRequest'; export * from '../models/ApiLoginResponse'; +export * from '../models/ApiMarkDropUnreadResponse'; export * from '../models/ApiNft'; export * from '../models/ApiNftMedia'; export * from '../models/ApiNftOwner'; @@ -327,6 +328,7 @@ import { ApiIntRange } from '../models/ApiIntRange'; import { ApiLightDrop } from '../models/ApiLightDrop'; import { ApiLoginRequest } from '../models/ApiLoginRequest'; import { ApiLoginResponse } from '../models/ApiLoginResponse'; +import { ApiMarkDropUnreadResponse } from '../models/ApiMarkDropUnreadResponse'; import { ApiNft , ApiNftTokenTypeEnum } from '../models/ApiNft'; import { ApiNftMedia } from '../models/ApiNftMedia'; import { ApiNftOwner } from '../models/ApiNftOwner'; @@ -596,6 +598,7 @@ let typeMap: {[index: string]: any} = { "ApiLightDrop": ApiLightDrop, "ApiLoginRequest": ApiLoginRequest, "ApiLoginResponse": ApiLoginResponse, + "ApiMarkDropUnreadResponse": ApiMarkDropUnreadResponse, "ApiNft": ApiNft, "ApiNftMedia": ApiNftMedia, "ApiNftOwner": ApiNftOwner, diff --git a/helpers/navigation.helpers.ts b/helpers/navigation.helpers.ts index 8f86609a0f..33f7ed1737 100644 --- a/helpers/navigation.helpers.ts +++ b/helpers/navigation.helpers.ts @@ -69,3 +69,24 @@ export const getWaveHomeRoute = ({ ? getMessagesBaseRoute(isApp) : getWavesBaseRoute(isApp); }; + +export const navigateToDirectMessage = ({ + waveId, + router, + isApp, +}: { + waveId: string; + router: { push: (url: string) => void; replace: (url: string) => void }; + isApp: boolean; +}): void => { + const href = getWaveRoute({ + waveId, + isDirectMessage: true, + isApp, + }); + if (isApp) { + router.replace(href); + } else { + router.push(href); + } +}; diff --git a/hooks/useUnreadIndicator.ts b/hooks/useUnreadIndicator.ts index a94579fb1d..8bc1bb7ac1 100644 --- a/hooks/useUnreadIndicator.ts +++ b/hooks/useUnreadIndicator.ts @@ -39,10 +39,8 @@ export function useUnreadIndicator({ if (type === "notifications") { setHasUnread(haveUnreadNotifications); } else if (type === "messages") { - // For messages, check if any DM has unread drops const hasUnreadMessages = directMessages.list.some((dm) => { - // Use the count property which tracks actual unread drops - return (dm?.newDropsCount?.count ?? 0) > 0; + return (dm?.unreadDropsCount ?? 0) > 0 || (dm?.newDropsCount?.count ?? 0) > 0; }); setHasUnread(hasUnreadMessages); diff --git a/hooks/useWaveIsTyping.ts b/hooks/useWaveIsTyping.ts index d9659576e7..9793d720de 100644 --- a/hooks/useWaveIsTyping.ts +++ b/hooks/useWaveIsTyping.ts @@ -54,28 +54,27 @@ function buildTypingString(entries: TypingEntry[]): string { /* ------------------------------------------------------------------ */ /** - * React hook that returns a live “is‑typing” label for a wave. + * React hook that returns a live "is‑typing" label for a wave. * * @param waveId Wave/channel ID being viewed. * @param myHandle Handle of current user (events from this handle are ignored). + * @param disabled If true, skip websocket subscription (e.g., for muted waves). */ export function useWaveIsTyping( waveId: string, - myHandle: string | null + myHandle: string | null, + disabled: boolean = false ): string { - const { socket } = useWaveWebSocket(waveId); + const { socket } = useWaveWebSocket(disabled ? "" : waveId); - /** Only the final string lives in state; everything else is in a ref. */ const [typingMessage, setTypingMessage] = useState(""); - /** Mutable store of active typers — doesn’t cause re‑renders. */ const typersRef = useRef>(new Map()); - /* ----- 1. Reset when wave changes -------------------------------- */ useEffect(() => { typersRef.current.clear(); setTypingMessage(""); - }, [waveId]); + }, [waveId, disabled]); /* ----- 2. Handle incoming USER_IS_TYPING packets ----------------- */ useEffect(() => { diff --git a/hooks/useWaveWebSocket.ts b/hooks/useWaveWebSocket.ts index 33cb913423..437e690f96 100644 --- a/hooks/useWaveWebSocket.ts +++ b/hooks/useWaveWebSocket.ts @@ -20,20 +20,28 @@ const MAX_RECONNECT_ATTEMPTS = 20; * Custom hook to connect to a WebSocket for a given waveId. * Automatically reconnects on disconnect, up to MAX_RECONNECT_ATTEMPTS, * with a delay of RECONNECT_DELAY ms between attempts. - * Sends a "hello world" message upon successful connection. + * Sends a subscription message upon successful connection. + * + * @param waveId - The wave ID to subscribe to. Pass empty string to disable. */ export function useWaveWebSocket(waveId: string): UseWaveWebSocketResult { const socketRef = useRef(null); const [readyState, setReadyState] = useState(WebSocket.CLOSED); const reconnectAttemptsRef = useRef(0); const reconnectTimeoutRef = useRef(null); - // flag controlling whether reconnection should be attempted const shouldReconnectRef = useRef(true); useEffect(() => { - // allow reconnection again on (re-)mount or waveId change + if (!waveId) { + if (socketRef.current) { + socketRef.current.close(); + socketRef.current = null; + } + setReadyState(WebSocket.CLOSED); + return; + } + shouldReconnectRef.current = true; - // determine base URL from environment const url = publicEnv.WS_ENDPOINT ?? publicEnv.API_ENDPOINT?.replace("https://api", "wss://ws") ?? diff --git a/hooks/useWavesList.ts b/hooks/useWavesList.ts index 6d6e8d202e..398b425943 100644 --- a/hooks/useWavesList.ts +++ b/hooks/useWavesList.ts @@ -27,11 +27,12 @@ function areWavesEqual(arrA: EnhancedWave[], arrB: EnhancedWave[]): boolean { if (arrA === arrB) return true; if (arrA.length !== arrB.length) return false; - // Compare each wave by ID, updatedAt, and isPinned status + // Compare each wave by ID, updatedAt, isPinned, and muted status for (let i = 0; i < arrA.length; i++) { if (arrA[i].id !== arrB[i].id) return false; if (arrA[i].created_at !== arrB[i].created_at) return false; if (arrA[i].isPinned !== arrB[i].isPinned) return false; + if (arrA[i].metrics.muted !== arrB[i].metrics.muted) return false; } return true; diff --git a/openapi.yaml b/openapi.yaml index 02fbf9ddf3..8613b34599 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -696,6 +696,28 @@ paths: $ref: "#/components/schemas/ApiDropSubscriptionActions" "404": description: Wave not found + /drops/{dropId}/mark-unread: + post: + tags: + - Drops + summary: Mark a drop and all subsequent drops in the wave as unread + description: Sets the user's latest_read_timestamp to just before this drop's created_at, making this drop and all newer drops unread. + operationId: markDropUnread + parameters: + - name: dropId + in: path + required: true + schema: + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ApiMarkDropUnreadResponse" + "404": + description: Drop not found /drop-media/prep: post: tags: @@ -3687,6 +3709,45 @@ paths: schema: type: object additionalProperties: false + /waves/{id}/mute: + post: + tags: + - Waves + summary: Mute a wave + operationId: muteWave + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: false + delete: + tags: + - Waves + summary: Unmute a wave + operationId: unmuteWave + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: false /waves/{id}/leaderboard: get: tags: @@ -5873,6 +5934,16 @@ components: enum: - DROP_REPLIED - DROP_VOTED + ApiMarkDropUnreadResponse: + type: object + required: + - your_unread_drops_count + properties: + your_unread_drops_count: + type: integer + first_unread_drop_serial_no: + type: integer + nullable: true ApiDropTraceItem: type: object required: @@ -7969,6 +8040,10 @@ components: - drops_count - latest_drop_timestamp - your_participation_drops_count + - your_latest_drop_timestamp + - your_unread_drops_count + - your_latest_read_timestamp + - muted properties: subscribers_count: type: number @@ -7988,6 +8063,17 @@ components: your_latest_drop_timestamp: type: number format: int64 + your_unread_drops_count: + type: number + format: int64 + first_unread_drop_serial_no: + type: number + format: int64 + your_latest_read_timestamp: + type: number + format: int64 + muted: + type: boolean ApiWaveMin: required: - id