diff --git a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx
index 03c60b8acb..1418f29ac5 100644
--- a/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx
+++ b/__tests__/components/brain/left-sidebar/waves/BrainLeftSidebarWave.test.tsx
@@ -89,7 +89,7 @@ describe('BrainLeftSidebarWave', () => {
render();
const link = screen.getByRole('link');
await userEvent.click(link);
- expect(setActiveWave).toHaveBeenCalledWith('1', { isDirectMessage: false, serialNo: null, divider: null });
+ expect(setActiveWave).toHaveBeenCalledWith('1', { isDirectMessage: false, divider: null });
});
it('shows drop indicators for non-chat waves', () => {
@@ -101,7 +101,7 @@ describe('BrainLeftSidebarWave', () => {
it('includes firstUnreadDropSerialNo in href when present', () => {
const waveWithUnread = { ...baseWave, id: '3', firstUnreadDropSerialNo: 42 };
render();
- expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?divider=42&wave=3&serialNo=42');
+ expect(screen.getByRole('link')).toHaveAttribute('href', '/waves?divider=42&wave=3');
});
it('does not include serialNo in href when firstUnreadDropSerialNo is null', () => {
diff --git a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx
index 9a9026fc74..6171bfc2d4 100644
--- a/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx
+++ b/__tests__/components/brain/my-stream/MyStreamWaveChat.test.tsx
@@ -1,9 +1,9 @@
-import { render, screen, act } from "@testing-library/react";
+import MyStreamWaveChat from "@/components/brain/my-stream/MyStreamWaveChat";
+import { editSlice } from "@/store/editSlice";
+import { configureStore } from "@reduxjs/toolkit";
+import { act, render, screen } from "@testing-library/react";
import React from "react";
import { Provider } from "react-redux";
-import { configureStore } from "@reduxjs/toolkit";
-import { editSlice } from "@/store/editSlice";
-import MyStreamWaveChat from "@/components/brain/my-stream/MyStreamWaveChat";
const replaceMock = jest.fn();
const searchParamsMock = { get: jest.fn() };
@@ -23,11 +23,15 @@ jest.mock("@/components/brain/my-stream/layout/LayoutContext", () => ({
useLayout: () => ({ waveViewStyle: { height: "1px" } }),
}));
-let capturedProps: any = {};
+const capturedPropsHolder = { current: {} as any };
jest.mock("@/components/waves/drops/wave-drops-all", () => ({
__esModule: true,
default: (props: any) => {
- capturedProps = props;
+ capturedPropsHolder.current = props;
+ return
() =>
);
jest.mock('@/hooks/isMobileDevice');
+jest.mock('@/hooks/useIsTouchDevice', () => ({ __esModule: true, default: jest.fn(() => false) }));
jest.mock('next/navigation', () => ({
useRouter: jest.fn(() => ({ push: jest.fn() })),
diff --git a/__tests__/components/waves/drops/WaveDropsAll.test.tsx b/__tests__/components/waves/drops/WaveDropsAll.test.tsx
index eb812650e2..6b6f41361e 100644
--- a/__tests__/components/waves/drops/WaveDropsAll.test.tsx
+++ b/__tests__/components/waves/drops/WaveDropsAll.test.tsx
@@ -85,6 +85,11 @@ jest.mock('@/components/waves/drops/WaveDropsScrollBottomButton', () => ({
}
}));
+jest.mock('@/components/waves/drops/WaveDropsScrollToUnreadButton', () => ({
+ __esModule: true,
+ WaveDropsScrollToUnreadButton: () =>
+}));
+
jest.mock('@/components/waves/drops/WaveDropsEmptyPlaceholder', () => ({
__esModule: true,
default: () =>
diff --git a/__tests__/hooks/useDeviceInfo.test.ts b/__tests__/hooks/useDeviceInfo.test.ts
index 7434651cfd..82d0c58350 100644
--- a/__tests__/hooks/useDeviceInfo.test.ts
+++ b/__tests__/hooks/useDeviceInfo.test.ts
@@ -1,50 +1,100 @@
-import { renderHook } from '@testing-library/react';
import useDeviceInfo from '@/hooks/useDeviceInfo';
+import { act, renderHook } from '@testing-library/react';
jest.mock('@/hooks/useCapacitor', () => ({ __esModule: true, default: jest.fn(() => ({ isCapacitor: false })) }));
const capacitorMock = require('@/hooks/useCapacitor').default as jest.Mock;
-defineMatchMedia();
+let touchStartHandler: EventListener | null = null;
-function defineMatchMedia(pointer = false, width = false) {
- Object.defineProperty(window, 'matchMedia', {
+function defineMatchMedia(pointerFine = true, width = false) {
+ Object.defineProperty(globalThis, 'matchMedia', {
writable: true,
- value: jest.fn((query) => ({
- matches: query.includes('pointer') ? pointer : width,
- addEventListener: jest.fn(),
- removeEventListener: jest.fn(),
- })),
+ value: jest.fn((query: string) => {
+ if (query === '(pointer: fine)') {
+ return { matches: pointerFine, addEventListener: jest.fn(), removeEventListener: jest.fn() };
+ }
+ if (query === '(max-width: 768px)') {
+ return { matches: width, addEventListener: jest.fn(), removeEventListener: jest.fn() };
+ }
+ return { matches: false, addEventListener: jest.fn(), removeEventListener: jest.fn() };
+ }),
});
}
describe('useDeviceInfo', () => {
+ beforeEach(() => {
+ jest.spyOn(globalThis, 'addEventListener').mockImplementation((event, handler) => {
+ if (event === 'touchstart') {
+ touchStartHandler = handler as EventListener;
+ }
+ });
+ jest.spyOn(globalThis, 'removeEventListener');
+ });
+
+ afterEach(() => {
+ touchStartHandler = null;
+ jest.restoreAllMocks();
+ capacitorMock.mockReturnValue({ isCapacitor: false });
+ });
+
it('detects classic mobile user agent', () => {
- Object.defineProperty(window.navigator, 'userAgent', { value: 'iPhone', configurable: true });
- defineMatchMedia(true, false);
+ Object.defineProperty(globalThis.navigator, 'userAgent', { value: 'iPhone', configurable: true });
+ Object.defineProperty(globalThis.navigator, 'maxTouchPoints', { value: 5, configurable: true });
+ defineMatchMedia(false, false);
const { result } = renderHook(() => useDeviceInfo());
expect(result.current.isMobileDevice).toBe(true);
expect(result.current.isAppleMobile).toBe(true);
- expect(result.current.hasTouchScreen).toBe(true);
expect(result.current.isApp).toBe(false);
+ expect(result.current.hasTouchScreen).toBe(true);
});
it('detects capacitor mobile with desktop UA', () => {
capacitorMock.mockReturnValue({ isCapacitor: true });
- Object.defineProperty(window.navigator, 'userAgent', { value: 'Macintosh', configurable: true });
- defineMatchMedia(true, true);
+ Object.defineProperty(globalThis.navigator, 'userAgent', { value: 'Macintosh', configurable: true });
+ Object.defineProperty(globalThis.navigator, 'maxTouchPoints', { value: 5, configurable: true });
+ defineMatchMedia(false, true);
const { result } = renderHook(() => useDeviceInfo());
expect(result.current.isMobileDevice).toBe(true);
expect(result.current.isApp).toBe(true);
expect(result.current.isAppleMobile).toBe(true);
+ expect(result.current.hasTouchScreen).toBe(true);
});
it('returns false for desktop without touch', () => {
capacitorMock.mockReturnValue({ isCapacitor: false });
- Object.defineProperty(window.navigator, 'userAgent', { value: 'Mozilla/5.0', configurable: true });
- defineMatchMedia(false, false);
+ Object.defineProperty(globalThis.navigator, 'userAgent', { value: 'Mozilla/5.0', configurable: true });
+ Object.defineProperty(globalThis.navigator, 'maxTouchPoints', { value: 0, configurable: true });
+ defineMatchMedia(true, false);
const { result } = renderHook(() => useDeviceInfo());
expect(result.current.isMobileDevice).toBe(false);
expect(result.current.hasTouchScreen).toBe(false);
expect(result.current.isAppleMobile).toBe(false);
});
+
+ it('hasTouchScreen becomes true on hybrid device after touch even with fine pointer', () => {
+ capacitorMock.mockReturnValue({ isCapacitor: false });
+ Object.defineProperty(globalThis.navigator, 'userAgent', { value: 'Mozilla/5.0', configurable: true });
+ Object.defineProperty(globalThis.navigator, 'maxTouchPoints', { value: 0, configurable: true });
+ defineMatchMedia(true, false);
+ const { result } = renderHook(() => useDeviceInfo());
+
+ expect(result.current.hasTouchScreen).toBe(false);
+
+ act(() => {
+ if (touchStartHandler) {
+ touchStartHandler(new Event('touchstart'));
+ }
+ });
+
+ expect(result.current.hasTouchScreen).toBe(true);
+ });
+
+ it('hasTouchScreen is true when maxTouchPoints > 0 even without touch event', () => {
+ capacitorMock.mockReturnValue({ isCapacitor: false });
+ Object.defineProperty(globalThis.navigator, 'userAgent', { value: 'Mozilla/5.0', configurable: true });
+ Object.defineProperty(globalThis.navigator, 'maxTouchPoints', { value: 5, configurable: true });
+ defineMatchMedia(true, false);
+ const { result } = renderHook(() => useDeviceInfo());
+ expect(result.current.hasTouchScreen).toBe(true);
+ });
});
diff --git a/__tests__/hooks/useIsTouchDevice.test.ts b/__tests__/hooks/useIsTouchDevice.test.ts
index 0540ca98aa..3dffe50545 100644
--- a/__tests__/hooks/useIsTouchDevice.test.ts
+++ b/__tests__/hooks/useIsTouchDevice.test.ts
@@ -2,114 +2,94 @@ import { renderHook, act } from "@testing-library/react";
import useIsTouchDevice from "@/hooks/useIsTouchDevice";
describe("useIsTouchDevice", () => {
- const originalWindow = globalThis.window;
- const originalNavigator = globalThis.navigator;
+ let addEventListenerSpy: jest.SpyInstance;
+ let removeEventListenerSpy: jest.SpyInstance;
+ let touchStartHandler: EventListener | null = null;
+
+ beforeEach(() => {
+ addEventListenerSpy = jest.spyOn(globalThis, "addEventListener").mockImplementation((event, handler) => {
+ if (event === "touchstart") {
+ touchStartHandler = handler as EventListener;
+ }
+ });
+ removeEventListenerSpy = jest.spyOn(globalThis, "removeEventListener");
+ });
afterEach(() => {
- Object.defineProperty(globalThis, "window", {
- value: originalWindow,
- writable: true,
- });
- Object.defineProperty(globalThis, "navigator", {
- value: originalNavigator,
- writable: true,
- });
+ addEventListenerSpy.mockRestore();
+ removeEventListenerSpy.mockRestore();
+ touchStartHandler = null;
+ jest.restoreAllMocks();
});
- it("returns false when window is undefined", () => {
- Object.defineProperty(globalThis, "window", {
- value: undefined,
+ it("returns false initially when fine pointer is detected", () => {
+ Object.defineProperty(globalThis, "matchMedia", {
writable: true,
+ value: jest.fn((query: string) => ({
+ matches: query === "(pointer: fine)",
+ })),
});
const { result } = renderHook(() => useIsTouchDevice());
expect(result.current).toBe(false);
});
- it("returns true when ontouchstart is in window", () => {
- const mockWindow = {
- ...originalWindow,
- ontouchstart: null,
- matchMedia: jest.fn(() => ({ matches: false })),
- };
- Object.defineProperty(globalThis, "window", {
- value: mockWindow,
+ it("returns false initially and does not listen for touch when fine pointer exists", () => {
+ Object.defineProperty(globalThis, "matchMedia", {
writable: true,
+ value: jest.fn((query: string) => ({
+ matches: query === "(pointer: fine)",
+ })),
});
- const { result } = renderHook(() => useIsTouchDevice());
-
- act(() => {
- jest.runAllTimers?.();
- });
-
- expect(result.current).toBe(true);
+ renderHook(() => useIsTouchDevice());
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith("touchstart", expect.any(Function), expect.any(Object));
});
- it("returns true when navigator.maxTouchPoints > 0", () => {
- const mockWindow = {
- ...originalWindow,
- matchMedia: jest.fn(() => ({ matches: false })),
- };
- Object.defineProperty(globalThis, "window", {
- value: mockWindow,
- writable: true,
- });
- Object.defineProperty(globalThis, "navigator", {
- value: { maxTouchPoints: 5 },
+ it("returns false initially but switches to true after touchstart when no fine pointer", () => {
+ Object.defineProperty(globalThis, "matchMedia", {
writable: true,
+ value: jest.fn(() => ({ matches: false })),
});
const { result } = renderHook(() => useIsTouchDevice());
+ expect(result.current).toBe(false);
act(() => {
- jest.runAllTimers?.();
+ if (touchStartHandler) {
+ touchStartHandler(new Event("touchstart"));
+ }
});
expect(result.current).toBe(true);
});
- it("returns true when matchMedia pointer:coarse matches", () => {
- const mockWindow = {
- ...originalWindow,
- matchMedia: jest.fn((query: string) => ({
- matches: query === "(pointer: coarse)",
- })),
- };
- Object.defineProperty(globalThis, "window", {
- value: mockWindow,
- writable: true,
- });
- Object.defineProperty(globalThis, "navigator", {
- value: { maxTouchPoints: 0 },
+ it("removes touchstart listener after first touch", () => {
+ Object.defineProperty(globalThis, "matchMedia", {
writable: true,
+ value: jest.fn(() => ({ matches: false })),
});
- const { result } = renderHook(() => useIsTouchDevice());
+ renderHook(() => useIsTouchDevice());
act(() => {
- jest.runAllTimers?.();
+ if (touchStartHandler) {
+ touchStartHandler(new Event("touchstart"));
+ }
});
- expect(result.current).toBe(true);
+ expect(removeEventListenerSpy).toHaveBeenCalledWith("touchstart", expect.any(Function));
});
- it("returns false when no touch indicators present", () => {
- const mockWindow = {
- ...originalWindow,
- matchMedia: jest.fn(() => ({ matches: false })),
- };
- Object.defineProperty(globalThis, "window", {
- value: mockWindow,
- writable: true,
- });
- Object.defineProperty(globalThis, "navigator", {
- value: { maxTouchPoints: 0 },
+ it("cleans up event listener on unmount", () => {
+ Object.defineProperty(globalThis, "matchMedia", {
writable: true,
+ value: jest.fn(() => ({ matches: false })),
});
- const { result } = renderHook(() => useIsTouchDevice());
+ const { unmount } = renderHook(() => useIsTouchDevice());
+ unmount();
- expect(result.current).toBe(false);
+ expect(removeEventListenerSpy).toHaveBeenCalledWith("touchstart", expect.any(Function));
});
});
diff --git a/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx b/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx
index 24deb946a6..ead841fa78 100644
--- a/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx
+++ b/components/brain/left-sidebar/waves/BrainLeftSidebarWave.tsx
@@ -68,7 +68,6 @@ const BrainLeftSidebarWave: React.FC
= ({
}
return getWaveRoute({
waveId: wave.id,
- serialNo: wave.firstUnreadDropSerialNo ?? undefined,
extraParams: wave.firstUnreadDropSerialNo
? { divider: String(wave.firstUnreadDropSerialNo) }
: undefined,
@@ -112,7 +111,6 @@ const BrainLeftSidebarWave: React.FC = ({
const nextWaveId = wave.id === activeWaveId ? null : wave.id;
setActiveWave(nextWaveId, {
isDirectMessage,
- serialNo: nextWaveId ? wave.firstUnreadDropSerialNo : null,
divider: nextWaveId ? wave.firstUnreadDropSerialNo : null,
});
},
diff --git a/components/brain/left-sidebar/web/WebBrainLeftSidebarWave/hooks/useWaveNavigation.ts b/components/brain/left-sidebar/web/WebBrainLeftSidebarWave/hooks/useWaveNavigation.ts
index d558683484..c610c48728 100644
--- a/components/brain/left-sidebar/web/WebBrainLeftSidebarWave/hooks/useWaveNavigation.ts
+++ b/components/brain/left-sidebar/web/WebBrainLeftSidebarWave/hooks/useWaveNavigation.ts
@@ -50,7 +50,6 @@ export const useWaveNavigation = ({
const params = new URLSearchParams();
params.set("wave", waveId);
if (firstUnreadDropSerialNo) {
- params.set("serialNo", String(firstUnreadDropSerialNo));
params.set("divider", String(firstUnreadDropSerialNo));
}
return `${basePath}?${params.toString()}`;
@@ -85,7 +84,6 @@ export const useWaveNavigation = ({
const nextWaveId = waveId === currentWaveId ? null : waveId;
setActiveWave(nextWaveId, {
isDirectMessage,
- serialNo: nextWaveId ? firstUnreadDropSerialNo : null,
divider: nextWaveId ? firstUnreadDropSerialNo : null,
});
},
diff --git a/components/brain/my-stream/MyStreamWaveChat.tsx b/components/brain/my-stream/MyStreamWaveChat.tsx
index 6bb4b99add..f391170fa8 100644
--- a/components/brain/my-stream/MyStreamWaveChat.tsx
+++ b/components/brain/my-stream/MyStreamWaveChat.tsx
@@ -1,16 +1,20 @@
"use client";
import { CreateDropWaveWrapper } from "@/components/waves/CreateDropWaveWrapper";
-import WaveDropsAll from "@/components/waves/drops/wave-drops-all";
+import { WaveDropsAllWithoutProvider } from "@/components/waves/drops/wave-drops-all";
+import { WaveGallery } from "@/components/waves/gallery";
import MobileMemesArtSubmissionBtn from "@/components/waves/memes/submission/MobileMemesArtSubmissionBtn";
import PrivilegedDropCreator, {
- DropMode,
+ DropMode,
} from "@/components/waves/PrivilegedDropCreator";
+import { UnreadDividerProvider } from "@/contexts/wave/UnreadDividerContext";
import type { ApiDrop } from "@/generated/models/ApiDrop";
import type { ApiWave } from "@/generated/models/ApiWave";
import { getHomeFeedRoute } from "@/helpers/navigation.helpers";
+import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
import useDeviceInfo from "@/hooks/useDeviceInfo";
import { useWave } from "@/hooks/useWave";
+import type { WaveViewMode } from "@/hooks/useWaveViewMode";
import { selectEditingDropId } from "@/store/editSlice";
import type { ActiveDropState } from "@/types/dropInteractionTypes";
import { ActiveDropAction } from "@/types/dropInteractionTypes";
@@ -18,9 +22,6 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useSelector } from "react-redux";
import { useLayout } from "./layout/LayoutContext";
-import { WaveGallery } from "@/components/waves/gallery";
-import type { WaveViewMode } from "@/hooks/useWaveViewMode";
-import type { ExtendedDrop } from "@/helpers/waves/drop.helpers";
interface InitialDropState {
readonly waveId: string;
@@ -48,6 +49,7 @@ const MyStreamWaveChat: React.FC = ({
const { isMemesWave } = useWave(wave);
const editingDropId = useSelector(selectEditingDropId);
const { isApp } = useDeviceInfo();
+
const [activeDropState, setActiveDropState] = useState<{
readonly waveId: string;
readonly activeDrop: ActiveDropState | null;
@@ -63,6 +65,12 @@ const MyStreamWaveChat: React.FC = ({
setActiveDropState({ waveId: wave.id, activeDrop: nextActiveDrop });
};
+ const capturedDividerRef = useRef<{ waveId: string; serialNo: number | null } | null>(null);
+
+ if (!capturedDividerRef.current || capturedDividerRef.current.waveId !== wave.id) {
+ capturedDividerRef.current = { waveId: wave.id, serialNo: firstUnreadSerialNo };
+ }
+
const initialDropState = useMemo(() => {
const dropParam = searchParams.get("serialNo");
if (!dropParam) {
@@ -90,13 +98,33 @@ const MyStreamWaveChat: React.FC = ({
};
}, [searchParams, wave.id, firstUnreadSerialNo]);
+ useEffect(() => {
+ if (
+ initialDropState?.waveId === wave.id &&
+ initialDropState.dividerSerialNo !== null
+ ) {
+ capturedDividerRef.current = {
+ waveId: wave.id,
+ serialNo: initialDropState.dividerSerialNo,
+ };
+ } else if (
+ !capturedDividerRef.current ||
+ capturedDividerRef.current.waveId !== wave.id
+ ) {
+ capturedDividerRef.current = {
+ waveId: wave.id,
+ serialNo: firstUnreadSerialNo,
+ };
+ }
+ }, [initialDropState, wave.id, firstUnreadSerialNo]);
+
const scrollTarget =
initialDropState?.waveId === wave.id ? initialDropState.serialNo : null;
const dividerTarget =
initialDropState?.waveId === wave.id
? initialDropState.dividerSerialNo
- : firstUnreadSerialNo;
+ : capturedDividerRef.current?.serialNo ?? null;
useEffect(() => {
if (!initialDropState) {
@@ -117,12 +145,10 @@ const MyStreamWaveChat: React.FC = ({
const { waveViewStyle } = useLayout();
- // Create container class based on wave type
const containerClassName = useMemo(() => {
const baseStyles =
"tw-w-full tw-flex tw-flex-col tw-overflow-y-auto tw-overflow-x-hidden lg:tw-scrollbar-thin tw-scrollbar-thumb-iron-500 tw-scrollbar-track-iron-800 desktop-hover:hover:tw-scrollbar-thumb-iron-300 scroll-shadow";
- // Always use flex-grow for consistent height handling
const heightClass = "tw-flex-grow";
return `${baseStyles} ${heightClass}`;
@@ -169,38 +195,43 @@ const MyStreamWaveChat: React.FC = ({
}
return (
-
-
- {!(isApp && editingDropId) && (
-
- )}
- {isMemesWave &&
}
-
+
+
+ {!(isApp && editingDropId) && (
+
+ )}
+ {isMemesWave &&
}
+
+
);
};
diff --git a/components/nextGen/collections/collectionParts/mint/NextGenMintWidget.tsx b/components/nextGen/collections/collectionParts/mint/NextGenMintWidget.tsx
index b595afcf41..688e69f122 100644
--- a/components/nextGen/collections/collectionParts/mint/NextGenMintWidget.tsx
+++ b/components/nextGen/collections/collectionParts/mint/NextGenMintWidget.tsx
@@ -331,11 +331,13 @@ export default function NextGenMintWidget(props: Readonly) {
}, [props.mint_counts]);
useEffect(() => {
- const currentProof = findActiveProof(originalProofs);
- setCurrentProof({
- ...currentProof!,
- proof: currentProof!.proof!,
- });
+ const activeProof = findActiveProof(originalProofs);
+ if (activeProof?.proof) {
+ setCurrentProof({
+ ...activeProof,
+ proof: activeProof.proof,
+ });
+ }
}, [props.fetchingMintCounts]);
function renderAllowlistStatus() {
diff --git a/components/waves/CreateDrop.tsx b/components/waves/CreateDrop.tsx
index ea073e166e..a3a7aa113c 100644
--- a/components/waves/CreateDrop.tsx
+++ b/components/waves/CreateDrop.tsx
@@ -26,6 +26,7 @@ import { DropMode } from "./PrivilegedDropCreator";
import type { DropPrivileges } from "@/hooks/useDropPriviledges";
import { useMyStream } from "@/contexts/wave/MyStreamContext";
import { ProcessIncomingDropType } from "@/contexts/wave/hooks/useWaveRealtimeUpdater";
+import { useUnreadDividerOptional } from "@/contexts/wave/UnreadDividerContext";
interface CreateDropProps {
readonly activeDrop: ActiveDropState | null;
@@ -57,6 +58,7 @@ export default function CreateDrop({
}: CreateDropProps) {
const { setToast } = useContext(AuthContext);
const { waitAndInvalidateDrops } = useContext(ReactQueryWrapperContext);
+ const unreadDividerContext = useUnreadDividerOptional();
useKeyPressEvent("Escape", () => onCancelReplyQuote());
const [isStormMode, setIsStormMode] = useState(false);
const [drop, setDrop] = useState(null);
@@ -225,13 +227,18 @@ export default function CreateDrop({
// Process immediately - avoids state update timing issues
processNextDrop();
+ // Clear unread divider when user sends a message
+ if (unreadDividerContext) {
+ unreadDividerContext.setUnreadDividerSerialNo(null);
+ }
+
// Trigger UI updates
onDropAddedToQueue();
// Explicitly blur any focused input to close keyboard
(document.activeElement as HTMLElement)?.blur();
},
- [onDropAddedToQueue, processNextDrop]
+ [onDropAddedToQueue, processNextDrop, unreadDividerContext]
);
const createDropContentProps = useMemo(
diff --git a/components/waves/drops/WaveDropsScrollToUnreadButton.tsx b/components/waves/drops/WaveDropsScrollToUnreadButton.tsx
new file mode 100644
index 0000000000..27cfca9980
--- /dev/null
+++ b/components/waves/drops/WaveDropsScrollToUnreadButton.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import type { FC, RefObject } from "react";
+import { useEffect, useState, useCallback } from "react";
+
+type UnreadPosition = "hidden" | "above" | "below";
+
+interface WaveDropsScrollToUnreadButtonProps {
+ readonly unreadDividerSerialNo: number | null;
+ readonly scrollContainerRef: RefObject;
+ readonly onScrollToUnread: (serialNo: number) => void;
+}
+
+export const WaveDropsScrollToUnreadButton: FC<
+ WaveDropsScrollToUnreadButtonProps
+> = ({ unreadDividerSerialNo, scrollContainerRef, onScrollToUnread }) => {
+ const [unreadPosition, setUnreadPosition] = useState("hidden");
+
+ const checkUnreadVisibility = useCallback(() => {
+ if (unreadDividerSerialNo === null) {
+ setUnreadPosition("hidden");
+ return;
+ }
+
+ const scrollContainer = scrollContainerRef.current;
+ if (!scrollContainer) {
+ setUnreadPosition("hidden");
+ return;
+ }
+
+ const unreadElement = scrollContainer.querySelector(
+ `#drop-${unreadDividerSerialNo}`
+ );
+
+ if (!unreadElement) {
+ setUnreadPosition("above");
+ return;
+ }
+
+ const containerRect = scrollContainer.getBoundingClientRect();
+ const elementRect = unreadElement.getBoundingClientRect();
+
+ const isAboveViewport = elementRect.bottom < containerRect.top;
+ const isBelowViewport = elementRect.top > containerRect.bottom;
+
+ if (isAboveViewport) {
+ setUnreadPosition("above");
+ } else if (isBelowViewport) {
+ setUnreadPosition("below");
+ } else {
+ setUnreadPosition("hidden");
+ }
+ }, [unreadDividerSerialNo, scrollContainerRef]);
+
+ useEffect(() => {
+ checkUnreadVisibility();
+
+ const scrollContainer = scrollContainerRef.current;
+ if (!scrollContainer) return;
+
+ const handleScroll = () => {
+ checkUnreadVisibility();
+ };
+
+ scrollContainer.addEventListener("scroll", handleScroll, { passive: true });
+
+ const observer = new MutationObserver(() => {
+ checkUnreadVisibility();
+ });
+ observer.observe(scrollContainer, { childList: true, subtree: true });
+
+ return () => {
+ scrollContainer.removeEventListener("scroll", handleScroll);
+ observer.disconnect();
+ };
+ }, [checkUnreadVisibility, scrollContainerRef]);
+
+ useEffect(() => {
+ checkUnreadVisibility();
+ }, [unreadDividerSerialNo, checkUnreadVisibility]);
+
+ if (unreadPosition === "hidden" || unreadDividerSerialNo === null) {
+ return null;
+ }
+
+ const handleClick = () => {
+ onScrollToUnread(unreadDividerSerialNo);
+ };
+
+ const isPointingUp = unreadPosition === "above";
+
+ return (
+
+ );
+};
diff --git a/components/waves/drops/wave-drops-all/index.tsx b/components/waves/drops/wave-drops-all/index.tsx
index ff890b6032..13322708cd 100644
--- a/components/waves/drops/wave-drops-all/index.tsx
+++ b/components/waves/drops/wave-drops-all/index.tsx
@@ -113,8 +113,13 @@ const WaveDropsAllInner: React.FC = ({
);
const prevLatestSerialNoRef = useRef(null);
+ const initializedWaveRef = useRef(null);
useEffect(() => {
+ if (initializedWaveRef.current === waveId) {
+ return;
+ }
+ initializedWaveRef.current = waveId;
setVisibleLatestSerial(null);
prevLatestSerialNoRef.current = null;
setUnreadDividerSerialNo(dividerSerialNo ?? null);
@@ -127,18 +132,8 @@ const WaveDropsAllInner: React.FC = ({
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]);
+ }, [latestSerialNo]);
useEffect(() => {
if (latestSerialNo === null) {
@@ -319,12 +314,15 @@ const WaveDropsAllInner: React.FC = ({
bottomPaddingClassName={bottomPaddingClassName}
boostedDrops={boostedDrops}
onBoostedDropClick={queueSerialTarget}
+ onScrollToUnread={queueSerialTarget}
/>
);
};
+export const WaveDropsAllWithoutProvider: React.FC