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)