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
3 changes: 2 additions & 1 deletion __tests__/components/navigation/BottomNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ describe("BottomNavigation", () => {
const navItemCalls = (NavItem as jest.Mock).mock.calls;

expect(rendered).toHaveLength(navItemCalls.length);
expect(navItemCalls).toHaveLength(6);
expect(navItemCalls).toHaveLength(7);

const passedItems = navItemCalls.map((call) => call[0].item);
expect(passedItems.map((item: { name: string }) => item.name)).toEqual([
"Profile",
"Waves",
"Messages",
"Home",
Expand Down
201 changes: 157 additions & 44 deletions __tests__/components/navigation/NavItem.test.tsx
Original file line number Diff line number Diff line change
@@ -1,80 +1,193 @@
import { render } from '@testing-library/react';
import NavItem from '@/components/navigation/NavItem';
import { useViewContext } from '@/components/navigation/ViewContext';
import { useAuth } from '@/components/auth/Auth';
import { useTitle } from '@/contexts/TitleContext';
import { useUnreadNotifications } from '@/hooks/useUnreadNotifications';
import { useUnreadIndicator } from '@/hooks/useUnreadIndicator';
import { useNotificationsContext } from '@/components/notifications/NotificationsContext';
import { isNavItemActive } from '@/components/navigation/isNavItemActive';
import { useWaveData } from '@/hooks/useWaveData';
import { useWave } from '@/hooks/useWave';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';

jest.mock('@/components/navigation/ViewContext', () => ({ useViewContext: jest.fn() }));
jest.mock('@/components/auth/Auth', () => ({ useAuth: jest.fn() }));
jest.mock('@/contexts/TitleContext', () => ({ useTitle: jest.fn() }));
jest.mock('@/hooks/useUnreadNotifications', () => ({ useUnreadNotifications: jest.fn() }));
jest.mock('@/hooks/useUnreadIndicator', () => ({ useUnreadIndicator: jest.fn() }));
jest.mock('@/components/notifications/NotificationsContext', () => ({ useNotificationsContext: jest.fn() }));
jest.mock('@/components/navigation/isNavItemActive', () => ({ isNavItemActive: jest.fn() }));
jest.mock('@/hooks/useWaveData', () => ({ useWaveData: jest.fn() }));
jest.mock('@/hooks/useWave', () => ({ useWave: jest.fn() }));
jest.mock('next/navigation', () => ({
import { fireEvent, render } from "@testing-library/react";
import NavItem from "@/components/navigation/NavItem";
import { useViewContext } from "@/components/navigation/ViewContext";
import { useAuth } from "@/components/auth/Auth";
import { useSeizeConnectContext } from "@/components/auth/SeizeConnectContext";
import { useTitle } from "@/contexts/TitleContext";
import { useUnreadNotifications } from "@/hooks/useUnreadNotifications";
import { useUnreadIndicator } from "@/hooks/useUnreadIndicator";
import { useNotificationsContext } from "@/components/notifications/NotificationsContext";
import { isNavItemActive } from "@/components/navigation/isNavItemActive";
import { useWaveData } from "@/hooks/useWaveData";
import { useWave } from "@/hooks/useWave";
import { useRouter, useSearchParams, usePathname } from "next/navigation";

jest.mock("@/components/navigation/ViewContext", () => ({
useViewContext: jest.fn(),
}));
jest.mock("@/components/auth/Auth", () => ({ useAuth: jest.fn() }));
jest.mock("@/components/auth/SeizeConnectContext", () => ({
useSeizeConnectContext: jest.fn(),
}));
jest.mock("@/contexts/TitleContext", () => ({ useTitle: jest.fn() }));
jest.mock("@/hooks/useUnreadNotifications", () => ({
useUnreadNotifications: jest.fn(),
}));
jest.mock("@/hooks/useUnreadIndicator", () => ({
useUnreadIndicator: jest.fn(),
}));
jest.mock("@/components/notifications/NotificationsContext", () => ({
useNotificationsContext: jest.fn(),
}));
jest.mock("@/components/navigation/isNavItemActive", () => ({
isNavItemActive: jest.fn(),
}));
jest.mock("@/hooks/useWaveData", () => ({ useWaveData: jest.fn() }));
jest.mock("@/hooks/useWave", () => ({ useWave: jest.fn() }));
jest.mock("next/navigation", () => ({
useRouter: jest.fn(),
useSearchParams: jest.fn(),
usePathname: jest.fn(),
}));

describe('NavItem notifications', () => {
describe("NavItem notifications", () => {
const handleNavClick = jest.fn();
const seizeConnect = jest.fn();
const removeAllDeliveredNotifications = jest.fn();
const setTitle = jest.fn();

beforeEach(() => {
jest.clearAllMocks();
(useViewContext as jest.Mock).mockReturnValue({
activeView: 'home',
activeView: "home",
handleNavClick,
});
(useRouter as jest.Mock).mockReturnValue({ push: jest.fn() });
(useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams());
(usePathname as jest.Mock).mockReturnValue('/');
(usePathname as jest.Mock).mockReturnValue("/");
(isNavItemActive as jest.Mock).mockReturnValue(false);
(useUnreadIndicator as jest.Mock).mockReturnValue({ hasUnread: false });
(useNotificationsContext as jest.Mock).mockReturnValue({ removeAllDeliveredNotifications });
(useAuth as jest.Mock).mockReturnValue({ connectedProfile: { handle: 'user' } });
(useTitle as jest.Mock).mockReturnValue({ setTitle, title: '6529.io', notificationCount: 0, setNotificationCount: jest.fn(), setWaveData: jest.fn() });
(useNotificationsContext as jest.Mock).mockReturnValue({
removeAllDeliveredNotifications,
});
(useAuth as jest.Mock).mockReturnValue({
connectedProfile: { handle: "user", normalised_handle: "user" },
});
(useSeizeConnectContext as jest.Mock).mockReturnValue({
address: "0xabc",
seizeConnect,
});
(useUnreadNotifications as jest.Mock).mockReturnValue({
notifications: { unread_count: 0 },
haveUnreadNotifications: false,
});
(useTitle as jest.Mock).mockReturnValue({
setTitle,
title: "6529.io",
notificationCount: 0,
setNotificationCount: jest.fn(),
setWaveData: jest.fn(),
});
(useWaveData as jest.Mock).mockReturnValue({ data: null });
(useWave as jest.Mock).mockReturnValue({ isDm: false });
});

it('sets title and shows badge when there are unread notifications', () => {
(useUnreadNotifications as jest.Mock).mockReturnValue({ notifications: { unread_count: 3 }, haveUnreadNotifications: true });
const item = { name: 'Notifications', icon: '/n' } as any;
it("sets title and shows badge when there are unread notifications", () => {
(useUnreadNotifications as jest.Mock).mockReturnValue({
notifications: { unread_count: 3 },
haveUnreadNotifications: true,
});
const item = { name: "Notifications", icon: "/n" } as any;

const { container } = render(<NavItem item={item} />);

// Title is set via TitleContext hooks
expect(container.querySelector('.tw-bg-red')).not.toBeNull();
expect(container.querySelector(".tw-bg-red")).not.toBeNull();
expect(removeAllDeliveredNotifications).not.toHaveBeenCalled();
});

it('clears delivered notifications when none unread', () => {
(useUnreadNotifications as jest.Mock).mockReturnValue({ notifications: { unread_count: 0 }, haveUnreadNotifications: false });
const item = { name: 'Notifications', icon: '/n' } as any;
it("clears delivered notifications when none unread", () => {
(useUnreadNotifications as jest.Mock).mockReturnValue({
notifications: { unread_count: 0 },
haveUnreadNotifications: false,
});
const item = { name: "Notifications", icon: "/n" } as any;

const { container } = render(<NavItem item={item} />);

// Title is set via TitleContext hooks
expect(removeAllDeliveredNotifications).toHaveBeenCalled();
expect(container.querySelector('.tw-bg-red')).toBeNull();
expect(container.querySelector(".tw-bg-red")).toBeNull();
});
});

it('renders disabled item when disabled flag set', () => {
(useUnreadNotifications as jest.Mock).mockReturnValue({});
const item = { name: 'Feed', icon: '/i', disabled: true } as any;
const { getByRole } = render(<NavItem item={item} />);
const button = getByRole('button');
expect(button).toBeDisabled();
it("prompts connect when profile item is clicked without a connected wallet", () => {
(useSeizeConnectContext as jest.Mock).mockReturnValue({
address: undefined,
seizeConnect,
});
const item = {
kind: "route",
name: "Profile",
href: "/profile",
icon: "profile",
} as any;

const { getByRole } = render(<NavItem item={item} />);
fireEvent.click(getByRole("button", { name: "Profile" }));

expect(seizeConnect).toHaveBeenCalledTimes(1);
expect(handleNavClick).not.toHaveBeenCalled();
});

it("navigates to connected user profile when profile item is clicked", () => {
(useAuth as jest.Mock).mockReturnValue({
connectedProfile: { handle: "User", normalised_handle: "my-handle" },
});
const item = {
kind: "route",
name: "Profile",
href: "/profile",
icon: "profile",
} as any;

const { getByRole } = render(<NavItem item={item} />);
fireEvent.click(getByRole("button", { name: "Profile" }));

expect(handleNavClick).toHaveBeenCalledWith(
expect.objectContaining({
kind: "route",
name: "Profile",
href: "/my-handle",
})
);
expect(seizeConnect).not.toHaveBeenCalled();
});

it("marks profile active only on own profile routes", () => {
(useViewContext as jest.Mock).mockReturnValue({
activeView: null,
handleNavClick,
});
(usePathname as jest.Mock).mockReturnValue("/my-handle/proxy");
(useAuth as jest.Mock).mockReturnValue({
connectedProfile: { handle: "User", normalised_handle: "my-handle" },
});
const item = {
kind: "route",
name: "Profile",
href: "/profile",
icon: "profile",
} as any;

const { getByRole, rerender } = render(<NavItem item={item} />);
expect(getByRole("button", { name: "Profile" })).toHaveAttribute(
"aria-current",
"page"
);

(usePathname as jest.Mock).mockReturnValue("/other-user");
rerender(<NavItem item={item} />);
expect(getByRole("button", { name: "Profile" })).not.toHaveAttribute(
"aria-current",
"page"
);
});

it("renders disabled item when disabled flag set", () => {
(useUnreadNotifications as jest.Mock).mockReturnValue({});
const item = { name: "Feed", icon: "/i", disabled: true } as any;
const { getByRole } = render(<NavItem item={item} />);
const button = getByRole("button");
expect(button).toBeDisabled();
});
});
22 changes: 22 additions & 0 deletions components/common/icons/ProfileIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const ProfileIcon = ({
className,
}: {
readonly className?: string | undefined;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.65"
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.964 0a9 9 0 1 0-11.964 0m11.964 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75A3 3 0 1 1 9 9.75a3 3 0 0 1 6 0Z"
/>
</svg>
);

export default ProfileIcon;
8 changes: 8 additions & 0 deletions components/navigation/BottomNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ import BellIcon from "../common/icons/BellIcon";
import ChatBubbleIcon from "../common/icons/ChatBubbleIcon";
import LogoIcon from "../common/icons/LogoIcon";
import CollectionsMenuIcon from "../common/icons/CollectionsMenuIcon";
import ProfileIcon from "../common/icons/ProfileIcon";
import UsersIcon from "../common/icons/UsersIcon";
import WavesIcon from "../common/icons/WavesIcon";
import NavItem from "./NavItem";
import type { NavItem as NavItemData } from "./navTypes";

const items: NavItemData[] = [
{
kind: "route",
name: "Profile",
href: "/profile",
icon: "profile",
iconComponent: ProfileIcon,
},
{
kind: "view",
name: "Waves",
Expand Down
52 changes: 44 additions & 8 deletions components/navigation/NavItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useViewContext } from "./ViewContext";
import type { NavItem as NavItemData } from "./navTypes";
import { motion } from "framer-motion";
import { useAuth } from "../auth/Auth";
import { useSeizeConnectContext } from "../auth/SeizeConnectContext";
import { useTitle } from "@/contexts/TitleContext";
import { useUnreadNotifications } from "@/hooks/useUnreadNotifications";
import { useUnreadIndicator } from "@/hooks/useUnreadIndicator";
Expand All @@ -27,6 +28,7 @@ const NavItem = ({ item }: Props) => {

const { name } = item;
const { icon } = item;
const { address, seizeConnect } = useSeizeConnectContext();

const isLogoItem = name === "Home";

Expand All @@ -41,6 +43,12 @@ const NavItem = ({ item }: Props) => {

// Add unread notifications logic
const { connectedProfile } = useAuth();
const normalizedConnectedHandle = (
connectedProfile?.normalised_handle ?? connectedProfile?.handle
)?.toLowerCase();
const normalizedConnectedAddress = address?.toLowerCase();
const profileSlug = normalizedConnectedHandle ?? normalizedConnectedAddress;
const profileHref = profileSlug ? `/${profileSlug}` : null;
Comment thread
simo6529 marked this conversation as resolved.
const { setTitle } = useTitle();
const { notifications, haveUnreadNotifications } = useUnreadNotifications(
item.name === "Notifications" ? (connectedProfile?.handle ?? null) : null
Expand Down Expand Up @@ -100,20 +108,48 @@ const NavItem = ({ item }: Props) => {

const iconSizeClass = item.iconSizeClass ?? "tw-size-7";

const isActive = isNavItemActive(
item,
pathname ?? "",
searchParams ?? new URLSearchParams(),
activeView,
isCurrentWaveDmValue
);
const isProfileItem = item.kind === "route" && item.name === "Profile";
const normalizedPathname = pathname.toLowerCase();
const isProfileActive =
isProfileItem &&
activeView === null &&
profileHref !== null &&
(normalizedPathname === profileHref ||
normalizedPathname.startsWith(`${profileHref}/`));

const isActive = isProfileItem
? isProfileActive
: isNavItemActive(
item,
pathname,
searchParams,
activeView,
isCurrentWaveDmValue
);

const handleClick = () => {
if (item.kind === "route" && item.name === "Profile") {
if (!address) {
seizeConnect();
return;
}

handleNavClick({
...item,
href: profileHref ?? item.href,
});
return;
}

handleNavClick(item);
};

return (
<button
type="button"
aria-label={name}
aria-current={isActive ? "page" : undefined}
onClick={() => handleNavClick(item)}
onClick={handleClick}
className="tw-relative tw-flex tw-h-full tw-w-full tw-min-w-0 tw-flex-col tw-items-center tw-justify-center tw-border-0 tw-bg-transparent tw-transition-colors focus:tw-outline-none"
>
{isActive && (
Expand Down