diff --git a/__tests__/components/navigation/BottomNavigation.test.tsx b/__tests__/components/navigation/BottomNavigation.test.tsx index 48e0d1564f..b83d80d7f1 100644 --- a/__tests__/components/navigation/BottomNavigation.test.tsx +++ b/__tests__/components/navigation/BottomNavigation.test.tsx @@ -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", diff --git a/__tests__/components/navigation/NavItem.test.tsx b/__tests__/components/navigation/NavItem.test.tsx index 8af5c6d8a0..f8e1156e85 100644 --- a/__tests__/components/navigation/NavItem.test.tsx +++ b/__tests__/components/navigation/NavItem.test.tsx @@ -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(); // 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(); // 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(); - 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(); + 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(); + 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(); + expect(getByRole("button", { name: "Profile" })).toHaveAttribute( + "aria-current", + "page" + ); + + (usePathname as jest.Mock).mockReturnValue("/other-user"); + rerender(); + 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(); + const button = getByRole("button"); + expect(button).toBeDisabled(); + }); }); diff --git a/components/common/icons/ProfileIcon.tsx b/components/common/icons/ProfileIcon.tsx new file mode 100644 index 0000000000..61a7cc512f --- /dev/null +++ b/components/common/icons/ProfileIcon.tsx @@ -0,0 +1,22 @@ +const ProfileIcon = ({ + className, +}: { + readonly className?: string | undefined; +}) => ( + + + +); + +export default ProfileIcon; diff --git a/components/navigation/BottomNavigation.tsx b/components/navigation/BottomNavigation.tsx index 01536debdd..916d3bb17f 100644 --- a/components/navigation/BottomNavigation.tsx +++ b/components/navigation/BottomNavigation.tsx @@ -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", diff --git a/components/navigation/NavItem.tsx b/components/navigation/NavItem.tsx index 399a3156a1..842b431326 100644 --- a/components/navigation/NavItem.tsx +++ b/components/navigation/NavItem.tsx @@ -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"; @@ -27,6 +28,7 @@ const NavItem = ({ item }: Props) => { const { name } = item; const { icon } = item; + const { address, seizeConnect } = useSeizeConnectContext(); const isLogoItem = name === "Home"; @@ -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; const { setTitle } = useTitle(); const { notifications, haveUnreadNotifications } = useUnreadNotifications( item.name === "Notifications" ? (connectedProfile?.handle ?? null) : null @@ -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 (