Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,22 @@ jest.mock('@/components/brain/left-sidebar/waves/UnifiedWavesListWaves', () => {
};
});

type DeviceInfo = { isApp: boolean; isMobileDevice: boolean; hasTouchScreen: boolean };
type DeviceInfo = {
isApp: boolean;
isMobileDevice: boolean;
hasTouchScreen: boolean;
isAppleMobile: boolean;
};
const useDeviceInfoMock = useDeviceInfo as jest.MockedFunction<typeof useDeviceInfo>;

beforeEach(() => {
sentinel = null;
useDeviceInfoMock.mockReturnValue({ isApp: false, isMobileDevice: false, hasTouchScreen: false } as DeviceInfo);
useDeviceInfoMock.mockReturnValue({
isApp: false,
isMobileDevice: false,
hasTouchScreen: false,
isAppleMobile: false,
} as DeviceInfo);
});

afterEach(() => {
Expand All @@ -59,7 +69,12 @@ describe('UnifiedWavesList', () => {
});

it('triggers fetchNextPage when sentinel intersects', () => {
useDeviceInfoMock.mockReturnValue({ isApp: true, isMobileDevice: false, hasTouchScreen: false } as DeviceInfo);
useDeviceInfoMock.mockReturnValue({
isApp: true,
isMobileDevice: false,
hasTouchScreen: false,
isAppleMobile: false,
} as DeviceInfo);
const fetchNextPage = jest.fn();
const observerInstances: any[] = [];
(global as any).IntersectionObserver = class {
Expand Down
1 change: 1 addition & 0 deletions __tests__/components/header/HeaderSearchModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ function setup(options: SetupOptions = {}) {
isApp: false,
isMobileDevice: false,
hasTouchScreen: false,
isAppleMobile: false,
});
useAppWalletsMock.mockReturnValue({ appWalletsSupported: true });
useCookieConsentMock.mockReturnValue({ country: "US" });
Expand Down
108 changes: 102 additions & 6 deletions __tests__/components/waves/drops/WaveDropsAll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ jest.mock('@/components/notifications/NotificationsContext');
jest.mock('@/components/auth/Auth');
jest.mock('@/services/api/common-api');
jest.mock('next/navigation');
jest.mock('@/hooks/useDeviceInfo', () => ({
__esModule: true,
default: jest.fn(() => ({
isAppleMobile: false,
isMobileDevice: false,
hasTouchScreen: false,
isApp: false
}))
}));

// Mock components with proper prop capturing
let containerProps: any;
Expand Down Expand Up @@ -65,7 +74,14 @@ jest.mock('@/components/waves/drops/WaveDropsScrollBottomButton', () => ({
__esModule: true,
WaveDropsScrollBottomButton: (props: any) => {
scrollButtonProps = props;
return <button data-testid="scroll-bottom-btn" onClick={props.scrollToBottom} />;
const handleClick = () => {
if (props.newMessagesCount > 0 && props.onRevealNewMessages) {
props.onRevealNewMessages();
} else {
props.scrollToBottom();
}
};
return <button data-testid="scroll-bottom-btn" onClick={handleClick} />;
}
}));

Expand Down Expand Up @@ -154,6 +170,12 @@ interface MockSetupOptions {
connectedProfile?: { handle: string } | null;
};
typingMessage?: string | null;
deviceInfo?: {
isAppleMobile?: boolean;
isMobileDevice?: boolean;
hasTouchScreen?: boolean;
isApp?: boolean;
};
}

function setupMocks(options: MockSetupOptions = {}) {
Expand Down Expand Up @@ -184,11 +206,13 @@ function setupMocks(options: MockSetupOptions = {}) {
...(options.waveMessages ?? {})
};

useVirtualizedWaveDropsMock.mockReturnValue({
waveMessages: waveMessagesMock as any,
let currentWaveMessages = waveMessagesMock as WaveMessagesMock | undefined;

useVirtualizedWaveDropsMock.mockImplementation(() => ({
waveMessages: currentWaveMessages as any,
fetchNextPage: mockFetchNextPage,
waitAndRevealDrop: mockWaitAndRevealDrop
});
}));

// Setup useScrollBehavior mock
require('@/hooks/useScrollBehavior').useScrollBehavior.mockReturnValue({
Expand Down Expand Up @@ -224,6 +248,20 @@ function setupMocks(options: MockSetupOptions = {}) {
require('@/services/api/common-api').commonApiPostWithoutBodyAndResponse.mockImplementation(
mockCommonApiPost.mockResolvedValue(undefined)
);

require('@/hooks/useDeviceInfo').default.mockReturnValue({
isAppleMobile: options.deviceInfo?.isAppleMobile ?? false,
isMobileDevice: options.deviceInfo?.isMobileDevice ?? false,
hasTouchScreen: options.deviceInfo?.hasTouchScreen ?? false,
isApp: options.deviceInfo?.isApp ?? false
});

return {
getWaveMessages: () => currentWaveMessages,
setWaveMessages: (nextWaveMessages: WaveMessagesMock | undefined) => {
currentWaveMessages = nextWaveMessages;
}
};
}

interface RenderOptions {
Expand Down Expand Up @@ -546,11 +584,69 @@ describe('WaveDropsAll', () => {
// Wait for component to render
await waitFor(() => {
expect(screen.getByTestId('drops-list')).toBeInTheDocument();
});

expect(scrollButtonProps.isAtBottom).toBe(false);
});

it('defers new drops on Apple mobile when user is reading and shows reveal badge', async () => {
const initialDrop = createMockDrop({ id: 'drop-1', serial_no: 1 });
const newDrop = createMockDrop({
id: 'drop-2',
serial_no: 2,
created_at: Date.now() + 1000
});

const { getWaveMessages, setWaveMessages } = setupMocks({
waveMessages: { drops: [initialDrop] },
scrollBehavior: {
isAtBottom: false,
shouldPinToBottom: false,
scrollIntent: 'reading'
},
deviceInfo: { isAppleMobile: true }
});

const renderResult = renderComponent();
const { props } = renderResult;

expect(scrollButtonProps.newMessagesCount).toBe(0);

expect(getWaveMessages()).toBeDefined();

act(() => {
const currentWaveMessages = getWaveMessages();
setWaveMessages({
...(currentWaveMessages ?? {}),
drops: [newDrop, initialDrop]
});

expect(scrollButtonProps.isAtBottom).toBe(false);
renderResult.rerender(<WaveDropsAll {...props} />);
});

expect(dropsProps.drops).toHaveLength(1);
expect(scrollButtonProps.newMessagesCount).toBe(1);

const user = userEvent.setup({
// Provide fake timer advancement so userEvent resolves under fake timers
advanceTimers: jest.advanceTimersByTime
});

await act(async () => {
await user.click(screen.getByTestId('scroll-bottom-btn'));
});

act(() => {
jest.runOnlyPendingTimers();
});

await waitFor(() => {
expect(dropsProps.drops).toHaveLength(2);
expect(scrollButtonProps.newMessagesCount).toBe(0);
});

expect(mockScrollToVisualBottom).toHaveBeenCalled();
});
});

describe('Virtualization and Pagination', () => {
it('passes pagination props to reverse container', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,19 @@ describe('WaveDropsScrollBottomButton', () => {
await userEvent.click(screen.getByRole('button'));
expect(scrollToBottom).toHaveBeenCalled();
});

it('renders pending badge and triggers reveal handler', async () => {
const onReveal = jest.fn();
render(
<WaveDropsScrollBottomButton
isAtBottom={false}
scrollToBottom={jest.fn()}
newMessagesCount={3}
onRevealNewMessages={onReveal}
/>
);
expect(screen.getByText('3 new messages')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: /reveal new messages/i }));
expect(onReveal).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ jest.mock("@/hooks/useDeviceInfo", () => ({
isApp: false,
isMobileDevice: false,
hasTouchScreen: false,
isAppleMobile: false,
}),
}));

Expand Down
3 changes: 3 additions & 0 deletions __tests__/hooks/useDeviceInfo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('useDeviceInfo', () => {
defineMatchMedia(true, 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);
});
Expand All @@ -34,6 +35,7 @@ describe('useDeviceInfo', () => {
const { result } = renderHook(() => useDeviceInfo());
expect(result.current.isMobileDevice).toBe(true);
expect(result.current.isApp).toBe(true);
expect(result.current.isAppleMobile).toBe(true);
});

it('returns false for desktop without touch', () => {
Expand All @@ -43,5 +45,6 @@ describe('useDeviceInfo', () => {
const { result } = renderHook(() => useDeviceInfo());
expect(result.current.isMobileDevice).toBe(false);
expect(result.current.hasTouchScreen).toBe(false);
expect(result.current.isAppleMobile).toBe(false);
});
});
5 changes: 3 additions & 2 deletions app/open-mobile/page.client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { publicEnv } from "@/config/env";
import { DeepLinkScope } from "@/hooks/useDeepLinkNavigation";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import useDeviceInfo from "@/hooks/useDeviceInfo";

export default function OpenMobilePage() {
const searchParams = useSearchParams();
const pathParam = searchParams?.get("path") || "";
const [decodedPath, setDecodedPath] = useState<string | null>(null);
const { isAppleMobile } = useDeviceInfo();

useEffect(() => {
if (typeof window === "undefined" || !pathParam) {
Expand Down Expand Up @@ -39,10 +41,9 @@ export default function OpenMobilePage() {

const userAgent =
typeof navigator === "undefined" ? "" : navigator.userAgent;
const isIos = /iPad|iPhone|iPod/.test(userAgent);
const isAndroid = /android/i.test(userAgent);

if (isIos) {
if (isAppleMobile) {
return shareIos;
} else if (isAndroid) {
return shareAndroid;
Expand Down
8 changes: 2 additions & 6 deletions components/cookies/CookiesBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,19 @@ import useDeviceInfo from "@/hooks/useDeviceInfo";
import useIsMobileDevice from "@/hooks/isMobileDevice";

export default function CookiesBanner() {
const { isApp } = useDeviceInfo();
const { isApp, isAppleMobile } = useDeviceInfo();
const isMobile = useIsMobileDevice();
const pathname = usePathname() ?? "";
const { consent, reject } = useCookieConsent();

const isIOS =
typeof navigator !== "undefined" &&
/iPad|iPhone|iPod/.test(navigator.userAgent);

if (["/restricted", "/access"].includes(pathname)) {
return <></>;
}

return (
<div
className={`${styles.banner} ${isApp ? styles.bannerMobile : ""} ${
isApp && isIOS ? styles.bannerIOS : ""
isApp && isAppleMobile ? styles.bannerIOS : ""
} d-flex align-items-center justify-content-between gap-2 ${
isApp ? `flex-column` : ""
}`}
Expand Down
77 changes: 57 additions & 20 deletions components/waves/drops/WaveDropsScrollBottomButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,71 @@ import React from "react";
interface WaveDropsScrollBottomButtonProps {
readonly isAtBottom: boolean;
readonly scrollToBottom: () => void;
readonly newMessagesCount?: number;
readonly onRevealNewMessages?: () => void;
}

export const WaveDropsScrollBottomButton: React.FC<
WaveDropsScrollBottomButtonProps
> = ({ isAtBottom, scrollToBottom }) => {
if (isAtBottom) return null;
> = ({
isAtBottom,
scrollToBottom,
newMessagesCount = 0,
onRevealNewMessages,
}) => {
const hasPending = newMessagesCount > 0;
if (!hasPending && isAtBottom) return null;

const handleClick = () => {
if (hasPending && onRevealNewMessages) {
onRevealNewMessages();
return;
}
scrollToBottom();
};

return (
<button
onClick={scrollToBottom}
className="tw-flex-shrink-0 tw-border tw-border-solid tw-border-iron-700 tw-absolute tw-z-[49] tw-bottom-3 tw-right-2 lg:tw-right-6 tw-bg-iron-700 tw-text-iron-300 tw-rounded-full tw-size-10
lg:tw-size-8 tw-flex tw-items-center tw-justify-center hover:tw-bg-iron-650 hover:tw-border-iron-650 tw-transition-all tw-duration-300"
aria-label="Scroll to bottom"
onClick={handleClick}
className="tw-flex-shrink-0 tw-absolute tw-z-[49] tw-bottom-3 tw-right-2 lg:tw-right-6 tw-rounded-full tw-border tw-border-solid tw-border-iron-700 tw-bg-iron-700 tw-text-iron-300 tw-min-w-[2.75rem] tw-h-10 lg:tw-h-8 tw-px-4 tw-flex tw-items-center tw-justify-center tw-gap-2 hover:tw-bg-iron-650 hover:tw-border-iron-650 tw-transition-all tw-duration-300"
aria-label={hasPending ? "Reveal new messages" : "Scroll to bottom"}
>
<svg
className="tw-flex-shrink-0 tw-size-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
{hasPending ? (
<>
<span className="tw-text-sm tw-font-medium">{`${newMessagesCount} new ${
newMessagesCount === 1 ? "message" : "messages"
}`}</span>
<svg
className="tw-flex-shrink-0 tw-size-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</>
) : (
<svg
className="tw-flex-shrink-0 tw-size-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
)}
Comment thread
simo6529 marked this conversation as resolved.
</button>
);
};
Loading