diff --git a/__tests__/components/waves/WavePicture.test.tsx b/__tests__/components/waves/WavePicture.test.tsx index 66b6614d1f..a2868c3659 100644 --- a/__tests__/components/waves/WavePicture.test.tsx +++ b/__tests__/components/waves/WavePicture.test.tsx @@ -1,27 +1,58 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import WavePicture from '@/components/waves/WavePicture'; +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { AuthContext } from "@/components/auth/Auth"; +import WavePicture from "@/components/waves/WavePicture"; -describe('WavePicture', () => { - it('renders picture image when provided', () => { +describe("WavePicture", () => { + it("renders picture image when provided", () => { render(); - const img = screen.getByRole('img', { name: 'wave' }); - expect(img.getAttribute('src')).toContain('pic.jpg'); - expect(img).toHaveAttribute('alt', 'wave'); + const img = screen.getByRole("img", { name: "wave" }); + expect(img.getAttribute("src")).toContain("pic.jpg"); + expect(img).toHaveAttribute("alt", "wave"); }); - it('renders gradient when no picture and no contributors', () => { - const { container } = render(); + it("renders gradient when no picture and no contributors", () => { + const { container } = render( + + ); expect(container.firstChild).toBeInTheDocument(); }); - it('renders contributor images sliced', () => { + it("renders contributor images sliced", () => { + const contributors = [{ pfp: "a.png" }, { pfp: "b.png" }, { pfp: "c.png" }]; + render( + + ); + expect(screen.getAllByRole("img").length).toBe(3); + }); + + it("excludes authenticated user contributor from collage when identity matches", () => { const contributors = [ - { pfp: 'a.png' }, - { pfp: 'b.png' }, - { pfp: 'c.png' }, + { pfp: "mine.png", identity: "id-0xabc" }, + { pfp: "a.png", identity: "alice" }, + { pfp: "b.png", identity: "bob" }, ]; - render(); - expect(screen.getAllByRole('img').length).toBe(3); + + render( + + + + ); + + expect(screen.getAllByRole("img")).toHaveLength(2); }); }); diff --git a/__tests__/components/waves/header/WaveHeader.test.tsx b/__tests__/components/waves/header/WaveHeader.test.tsx index 5d194ce107..8eb911e90c 100644 --- a/__tests__/components/waves/header/WaveHeader.test.tsx +++ b/__tests__/components/waves/header/WaveHeader.test.tsx @@ -1,44 +1,84 @@ -import { render } from '@testing-library/react'; -import React from 'react'; -import WaveHeader, { WaveHeaderPinnedSide } from '@/components/waves/header/WaveHeader'; -import { ApiWaveType } from '@/generated/models/ApiWaveType'; -import { AuthContext } from '@/components/auth/Auth'; - -jest.mock('@/components/waves/header/WaveHeaderFollow', () => () =>
); -jest.mock('@/components/waves/header/options/WaveHeaderOptions', () => () =>
); -jest.mock('@/components/waves/header/name/WaveHeaderName', () => () =>
); -jest.mock('@/components/waves/header/WaveHeaderFollowers', () => () =>
); -jest.mock('@/components/waves/header/WaveHeaderDescription', () => () =>
); -jest.mock('@/components/waves/WavePicture', () => () =>
); -jest.mock('@/components/waves/specs/WaveNotificationSettings', () => () =>
); +import { render, screen } from "@testing-library/react"; +import React from "react"; +import WaveHeader, { + WaveHeaderPinnedSide, +} from "@/components/waves/header/WaveHeader"; +import { ApiWaveType } from "@/generated/models/ApiWaveType"; +import { AuthContext } from "@/components/auth/Auth"; + +jest.mock("@/components/waves/header/WaveHeaderFollow", () => () =>
); +jest.mock("@/components/waves/header/options/WaveHeaderOptions", () => () => ( +
+)); +jest.mock("@/components/waves/header/name/WaveHeaderName", () => () => ( +
+)); +jest.mock("@/components/waves/header/WaveHeaderFollowers", () => () =>
); +jest.mock("@/components/waves/header/WaveHeaderDescription", () => () => ( +
+)); +jest.mock("@/components/waves/WavePicture", () => () =>
); +jest.mock("@/components/waves/specs/WaveNotificationSettings", () => () => ( +
+)); +jest.mock("@/helpers/waves/waves.helpers", () => ({ canEditWave: jest.fn() })); + +const { canEditWave } = require("@/helpers/waves/waves.helpers"); const baseWave: any = { - id: 'w', - name: 'Wave', + id: "w", + name: "Wave", created_at: 0, - picture: 'p', - author: { handle: 'a', banner1_color: '#000', banner2_color: '#111' }, + picture: "p", + author: { handle: "a", banner1_color: "#000", banner2_color: "#111" }, contributors_overview: [], metrics: { drops_count: 1 }, + chat: { scope: { group: { is_direct_message: false } } }, wave: { type: ApiWaveType.Chat }, }; -describe('WaveHeader', () => { - const wrapper = (wave:any, props?:any) => +describe("WaveHeader", () => { + beforeEach(() => { + (canEditWave as jest.Mock).mockReset(); + (canEditWave as jest.Mock).mockReturnValue(false); + }); + + const wrapper = (wave: any, props?: any) => render( - + ); - it('shows drop icon when wave not chat', () => { + it("shows drop icon when wave not chat", () => { wrapper({ ...baseWave, wave: { type: ApiWaveType.Approve } }); - expect(document.querySelector('svg')).toBeInTheDocument(); + expect(document.querySelector("svg")).toBeInTheDocument(); + }); + + it("omits drop icon for chat waves and applies ring classes", () => { + const { container } = wrapper(baseWave, { + useRing: false, + useRounded: false, + pinnedSide: WaveHeaderPinnedSide.RIGHT, + }); + expect(document.querySelector("svg")).toBeNull(); + expect(container.firstChild?.firstChild).not.toHaveClass("tw-ring-1"); + }); + + it("shows picture edit action for editable non-DM waves", () => { + (canEditWave as jest.Mock).mockReturnValue(true); + wrapper(baseWave); + expect(screen.getByLabelText("Edit wave picture")).toBeInTheDocument(); }); - it('omits drop icon for chat waves and applies ring classes', () => { - const { container } = wrapper(baseWave, { useRing: false, useRounded: false, pinnedSide: WaveHeaderPinnedSide.RIGHT }); - expect(document.querySelector('svg')).toBeNull(); - expect(container.firstChild?.firstChild).not.toHaveClass('tw-ring-1'); + it("hides picture edit action for DM waves even when editable", () => { + (canEditWave as jest.Mock).mockReturnValue(true); + wrapper({ + ...baseWave, + chat: { scope: { group: { is_direct_message: true } } }, + }); + expect(screen.queryByLabelText("Edit wave picture")).toBeNull(); }); }); diff --git a/__tests__/components/waves/header/name/WaveHeaderName.test.tsx b/__tests__/components/waves/header/name/WaveHeaderName.test.tsx index cb55ffc17b..d802ea4826 100644 --- a/__tests__/components/waves/header/name/WaveHeaderName.test.tsx +++ b/__tests__/components/waves/header/name/WaveHeaderName.test.tsx @@ -39,4 +39,17 @@ describe("WaveHeaderName", () => { render(); expect(screen.queryByTestId("edit")).toBeNull(); }); + + it("hides edit button for DM waves even when user can edit", () => { + (canEditWave as jest.Mock).mockReturnValue(true); + render( + + ); + expect(screen.queryByTestId("edit")).toBeNull(); + }); }); diff --git a/components/brain/content/BrainContentPinnedWave.tsx b/components/brain/content/BrainContentPinnedWave.tsx index b3e897bd0d..fb43c765b9 100644 --- a/components/brain/content/BrainContentPinnedWave.tsx +++ b/components/brain/content/BrainContentPinnedWave.tsx @@ -110,6 +110,7 @@ const BrainContentPinnedWave: React.FC = ({ picture={wave.picture} contributors={wave.contributors_overview.map((c) => ({ pfp: c.contributor_pfp, + identity: c.contributor_identity, }))} /> ) : ( diff --git a/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx b/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx index ecdbfb15f9..32332f703f 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveTabsDefault.tsx @@ -129,6 +129,7 @@ const MyStreamWaveTabsDefault: React.FC = ({ picture={wave.picture} contributors={wave.contributors_overview.map((c) => ({ pfp: c.contributor_pfp, + identity: c.contributor_identity, }))} />
diff --git a/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx b/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx index ed53b40e00..96f53f8368 100644 --- a/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx +++ b/components/brain/my-stream/tabs/MyStreamWaveTabsMeme.tsx @@ -185,6 +185,7 @@ const MyStreamWaveTabsMeme: React.FC = ({ picture={wave.picture} contributors={wave.contributors_overview.map((c) => ({ pfp: c.contributor_pfp, + identity: c.contributor_identity, }))} />
diff --git a/components/header/AppHeader.tsx b/components/header/AppHeader.tsx index 0065b10adf..2aa1cc0ad1 100644 --- a/components/header/AppHeader.tsx +++ b/components/header/AppHeader.tsx @@ -115,8 +115,8 @@ export default function AppHeader() { const hasUnreadOnOtherConnectedProfiles = connectedAccounts.some( (account) => !account.isActive && - (connectedAccountUnreadNotifications[account.address.toLowerCase()] ?? 0) > - 0 + (connectedAccountUnreadNotifications[account.address.toLowerCase()] ?? + 0) > 0 ); const pathSegments = pathname.split("/").filter(Boolean); @@ -310,6 +310,7 @@ export default function AppHeader() { picture={activeWave.picture ?? null} contributors={activeWave.contributors_overview.map((c) => ({ pfp: c.contributor_pfp, + identity: c.contributor_identity, }))} />
diff --git a/components/waves/WavePicture.tsx b/components/waves/WavePicture.tsx index 30578c04ae..f3b48383ff 100644 --- a/components/waves/WavePicture.tsx +++ b/components/waves/WavePicture.tsx @@ -1,13 +1,28 @@ +"use client"; + +import { useMemo } from "react"; import { FallbackImage } from "@/components/common/FallbackImage"; +import { useAuth } from "@/components/auth/Auth"; interface WavePictureProps { readonly name: string; readonly picture: string | null; readonly contributors: { readonly pfp: string; + readonly identity?: string | null; }[]; } +interface IdentitySource { + readonly id?: string | null; + readonly handle?: string | null; + readonly normalised_handle?: string | null; + readonly primary_wallet?: string | null; + readonly primary_address?: string | null; + readonly query?: string | null; + readonly wallets?: ReadonlyArray<{ readonly wallet?: string | null }> | null; +} + const polygonsByCount: Record = { // 1 entire area 1: ["polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)"], @@ -59,14 +74,78 @@ const polygonsByCount: Record = { ], }; +const normalizeIdentity = (value: string | null | undefined): string | null => { + if (!value) { + return null; + } + + const trimmed = value.trim().toLowerCase(); + if (!trimmed) { + return null; + } + + return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed; +}; + +const addIdentityCandidate = ( + candidates: Set, + value: string | null | undefined +) => { + const normalized = normalizeIdentity(value); + if (!normalized) { + return; + } + + candidates.add(normalized); + + if (normalized.startsWith("0x")) { + candidates.add(`id-${normalized}`); + } + + if (normalized.startsWith("id-0x")) { + candidates.add(normalized.slice(3)); + } +}; + +const addIdentitySourceCandidates = ( + candidates: Set, + identity: IdentitySource | null | undefined +) => { + if (!identity) { + return; + } + + addIdentityCandidate(candidates, identity.id); + addIdentityCandidate(candidates, identity.handle); + addIdentityCandidate(candidates, identity.normalised_handle); + addIdentityCandidate(candidates, identity.primary_wallet); + addIdentityCandidate(candidates, identity.primary_address); + addIdentityCandidate(candidates, identity.query); + + identity.wallets?.forEach((wallet) => + addIdentityCandidate(candidates, wallet.wallet) + ); +}; + export default function WavePicture({ name, picture, contributors, }: WavePictureProps) { + const { connectedProfile, activeProfileProxy } = useAuth(); + + const authenticatedIdentityCandidates = useMemo(() => { + const candidates = new Set(); + + addIdentitySourceCandidates(candidates, connectedProfile); + addIdentitySourceCandidates(candidates, activeProfileProxy?.created_by); + + return candidates; + }, [activeProfileProxy, connectedProfile]); + if (picture) { return ( -
+
c.pfp).filter(Boolean); + const pfps = contributors + .filter((contributor) => { + if (!contributor.pfp) { + return false; + } + + const normalizedContributorIdentity = normalizeIdentity( + contributor.identity + ); + if (!normalizedContributorIdentity) { + return true; + } + + return !authenticatedIdentityCandidates.has( + normalizedContributorIdentity + ); + }) + .map((contributor) => contributor.pfp); // 3) If no PFPS, show fallback background if (pfps.length === 0) { return ( -
+
); } @@ -96,7 +192,7 @@ export default function WavePicture({ const polygons = polygonsByCount[sliceCount]; return ( -
+
{pfps.slice(0, sliceCount).map((pfp, i) => { const clip = polygons?.[i]; return ( @@ -111,7 +207,7 @@ export default function WavePicture({ alt={`Contributor-${i}`} fill sizes="64px" - className="tw-object-cover tw-block tw-rounded-full" + className="tw-block tw-rounded-full tw-object-cover" />
); diff --git a/components/waves/header/WaveHeader.tsx b/components/waves/header/WaveHeader.tsx index cda9455eb4..12a15e2091 100644 --- a/components/waves/header/WaveHeader.tsx +++ b/components/waves/header/WaveHeader.tsx @@ -41,9 +41,12 @@ export default function WaveHeader({ const created = getTimeAgo(wave.created_at); const firstXContributors = wave.contributors_overview.slice(0, 10); const isDropWave = wave.wave.type !== ApiWaveType.Chat; + const isDirectMessage = wave.chat.scope.group?.is_direct_message ?? false; const canEdit = useMemo( - () => canEditWave({ connectedProfile, activeProfileProxy, wave }), - [activeProfileProxy, connectedProfile, wave] + () => + !isDirectMessage && + canEditWave({ connectedProfile, activeProfileProxy, wave }), + [activeProfileProxy, connectedProfile, isDirectMessage, wave] ); let ringClasses = ""; @@ -87,6 +90,7 @@ export default function WaveHeader({ picture={wave.picture} contributors={wave.contributors_overview.map((c) => ({ pfp: c.contributor_pfp, + identity: c.contributor_identity, }))} />
diff --git a/components/waves/header/name/WaveHeaderName.tsx b/components/waves/header/name/WaveHeaderName.tsx index e5660ef48d..8faceb30be 100644 --- a/components/waves/header/name/WaveHeaderName.tsx +++ b/components/waves/header/name/WaveHeaderName.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import type { ApiWave } from "@/generated/models/ApiWave"; -import { useContext, useEffect, useState } from "react"; +import { useContext } from "react"; import { AuthContext } from "@/components/auth/Auth"; import WaveHeaderNameEdit from "./WaveHeaderNameEdit"; import { canEditWave } from "@/helpers/waves/waves.helpers"; @@ -10,11 +10,11 @@ import { getWavePathRoute } from "@/helpers/navigation.helpers"; export default function WaveHeaderName({ wave }: { readonly wave: ApiWave }) { const { connectedProfile, activeProfileProxy } = useContext(AuthContext); - const getShowEdit = () => + const isDirectMessage = wave.chat.scope.group?.is_direct_message ?? false; + const showEdit = + !isDirectMessage && canEditWave({ connectedProfile, activeProfileProxy, wave }); - const [showEdit, setShowEdit] = useState(getShowEdit()); - useEffect(() => setShowEdit(getShowEdit()), [connectedProfile, wave]); return (
diff --git a/components/waves/utils/profile/WaveProfileTooltip.tsx b/components/waves/utils/profile/WaveProfileTooltip.tsx index f8e32a119f..f7ac09878c 100644 --- a/components/waves/utils/profile/WaveProfileTooltip.tsx +++ b/components/waves/utils/profile/WaveProfileTooltip.tsx @@ -31,6 +31,7 @@ export default function WaveProfileTooltip({ wave?.contributors_overview .map((contributor) => ({ pfp: contributor.contributor_pfp, + identity: contributor.contributor_identity, })) .filter((contributor) => !!contributor.pfp) ?? [], [wave?.contributors_overview] diff --git a/contexts/wave/hooks/useEnhancedWavesListCore.ts b/contexts/wave/hooks/useEnhancedWavesListCore.ts index 20712238eb..1139d7ed02 100644 --- a/contexts/wave/hooks/useEnhancedWavesListCore.ts +++ b/contexts/wave/hooks/useEnhancedWavesListCore.ts @@ -14,7 +14,7 @@ export interface MinimalWave { type: ApiWaveType; newDropsCount: MinimalWaveNewDropsCount; picture: string | null; - contributors: { pfp: string }[]; + contributors: { pfp: string; identity: string }[]; isPinned: boolean; isMuted: boolean; unreadDropsCount: number; @@ -165,6 +165,7 @@ function useEnhancedWavesListCore( picture: wave.picture, contributors: wave.contributors_overview.map((c) => ({ pfp: c.contributor_pfp, + identity: c.contributor_identity, })), newDropsCount: newDrops, // Prefer isPinned (computed optimistic value from useWavesList) over pinned (raw server field)