From ff1e5613b749e44575c72f3042ff6ce7b676ec4a Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 12:21:52 -0400 Subject: [PATCH 1/3] feat(desktop/nip-ia): hide archived identities from discovery surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Dawn's discovery-sweep contract (v2, stamped by Eva): every forward-looking discovery surface in the desktop hides archived identities, while history and audit surfaces leave them visible. The shape: - One predicate, applied uniformly. `useIsArchivedPredicate` wraps the `kind:13535` snapshot in a Set for O(1) lookup, fails open while loading (cold-start can't briefly hide everyone), no `currentPubkey` carve-out (NIP-IA's anti-shadowban property surfaces in the UI; the profile pane's flair and `selfMember` role lookup are independent of bucket — self folds where archive state puts it). - `useClassifiedMembers` peels archived FIRST, then splits remainder into people/bots. Archived wins over bot — a zombie agent under "Bots" defeats NIP-IA's headline use case. - Filter at consumers, never at the query — `extractMentionPubkeys` must still resolve archived authors' historical @-mentions. Surfaces touched (Dawn's table): - MembersSidebar.tsx: foldable "Archived (N)" `
` section below People + Bots, only rendered when archived.length > 0. - useMentions.ts: `.filter` pre-scoring in `suggestions` memo only — `members` source stays unfiltered for history resolution. - NewDirectMessageDialog.tsx, ChannelMemberInviteCard.tsx, RespondToField.tsx: one `!isArchivedDiscovery(user.pubkey)` clause added to each existing search-results `.filter`. Surfaces deliberately left alone (per Dawn's table): channel-members count pill, ChannelManagementSheet internal lookups, ChannelScreen timeline/typing enrichment, extractMentionPubkeys, AddAgentToChannel "already in channel" predicate. All consume `useChannelMembersQuery` but render history / role state / internal-only — Tyler's "never hide messages" rule. Tests (tests/e2e/identity-archive-hide.spec.ts, in smoke project): 1. Archived member folds under "Archived (N)", not active People list. 2. No Archived section when no archived members. 3. Mention autocomplete filters archived; non-archived still suggestable (proves autocomplete is functional, not always-empty). 4. DM picker hides archived from search; non-archived still searchable. 5. History invariant: archived user's existing message still renders with their display name in the timeline. Bonus: `mention-suggestion-${pubkey}` testid added in MentionAutocomplete.tsx so the autocomplete spec can assert presence/absence by pubkey rather than text matching. Verification: - `pnpm exec playwright test --project=smoke`: 110 passed (was 105; +5 new, 0 regressions). - `pnpm typecheck`, `pnpm check`: clean. - Pre-commit (rust-fmt, desktop-tauri-fmt, desktop-check, web-check, mobile-check): all green. Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- desktop/playwright.config.ts | 1 + .../src/features/agents/ui/RespondToField.tsx | 8 +- .../channels/lib/useClassifiedMembers.ts | 22 +++- .../channels/ui/ChannelMemberInviteCard.tsx | 12 +- .../features/channels/ui/MembersSidebar.tsx | 34 ++++- .../src/features/identity-archive/hooks.ts | 25 ++++ .../src/features/messages/lib/useMentions.ts | 4 + .../messages/ui/MentionAutocomplete.tsx | 1 + .../sidebar/ui/NewDirectMessageDialog.tsx | 7 +- .../tests/e2e/identity-archive-hide.spec.ts | 119 ++++++++++++++++++ 10 files changed, 223 insertions(+), 10 deletions(-) create mode 100644 desktop/tests/e2e/identity-archive-hide.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7197eca051..bae910add1 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ "**/relay-reconnect.spec.ts", "**/workflows.spec.ts", "**/identity-archive.spec.ts", + "**/identity-archive-hide.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/features/agents/ui/RespondToField.tsx b/desktop/src/features/agents/ui/RespondToField.tsx index d08b1412f9..45631cd2e3 100644 --- a/desktop/src/features/agents/ui/RespondToField.tsx +++ b/desktop/src/features/agents/ui/RespondToField.tsx @@ -5,6 +5,7 @@ import { parsePubkeyInput, } from "@/features/agents/lib/respondToAllowlist"; import { formatPubkey } from "@/features/channels/lib/memberUtils"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; import type { RespondToMode, UserSearchResult } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -79,12 +80,15 @@ export function CreateAgentRespondToField({ enabled: mode === "allowlist" && deferredQuery.length > 0, limit: 8, }); + const isArchivedDiscovery = useIsArchivedPredicate(); const searchResults = React.useMemo( () => (userSearchQuery.data ?? []).filter( - (user) => !allowlistSet.has(user.pubkey.toLowerCase()), + (user) => + !allowlistSet.has(user.pubkey.toLowerCase()) && + !isArchivedDiscovery(user.pubkey), ), - [allowlistSet, userSearchQuery.data], + [allowlistSet, isArchivedDiscovery, userSearchQuery.data], ); const pasteParsed = React.useMemo( diff --git a/desktop/src/features/channels/lib/useClassifiedMembers.ts b/desktop/src/features/channels/lib/useClassifiedMembers.ts index b7017ceef3..8f76a42e6b 100644 --- a/desktop/src/features/channels/lib/useClassifiedMembers.ts +++ b/desktop/src/features/channels/lib/useClassifiedMembers.ts @@ -4,6 +4,7 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import type { ChannelMember } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -15,6 +16,7 @@ export function useClassifiedMembers( ) { const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); + const isArchived = useIsArchivedPredicate(); const managedAgents = managedAgentsQuery.data ?? []; const relayAgents = relayAgentsQuery.data ?? []; @@ -47,11 +49,19 @@ export function useClassifiedMembers( [managedAgentPubkeys], ); - const { people, bots } = React.useMemo(() => { + // Archived wins over bot: a zombie agent should fold into "Archived", not + // appear as an active "Bot". This is NIP-IA's headline use case. Peel + // archived FIRST, then split the remainder into people/bots. + const { people, bots, archived } = React.useMemo(() => { const peopleList: ChannelMember[] = []; const botList: ChannelMember[] = []; + const archivedList: ChannelMember[] = []; for (const member of members) { + if (isArchived(member.pubkey)) { + archivedList.push(member); + continue; + } if (isBot(member)) { botList.push(member); } else { @@ -64,14 +74,20 @@ export function useClassifiedMembers( compareMembersByRole(left, right, currentPubkey), ); - return { people: sort(peopleList), bots: sort(botList) }; - }, [currentPubkey, isBot, members]); + return { + people: sort(peopleList), + bots: sort(botList), + archived: sort(archivedList), + }; + }, [currentPubkey, isArchived, isBot, members]); return { people, bots, + archived, peopleCount: people.length, botCount: bots.length, + archivedCount: archived.length, isBot, isMyBot, managedAgentsQuery, diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index 5f4271e7df..f67f3ca2ee 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -2,6 +2,7 @@ import { ChevronDown, Search, UserPlus, X } from "lucide-react"; import * as React from "react"; import { formatPubkey } from "@/features/channels/lib/memberUtils"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; import type { AddChannelMembersResult, @@ -78,14 +79,21 @@ export function ChannelMemberInviteCard({ // refined client-side. The Tauri command clamps at 50. limit: 25, }); + const isArchivedDiscovery = useIsArchivedPredicate(); const inviteSearchResults = React.useMemo( () => (userSearchQuery.data ?? []).filter( (user) => !memberPubkeys.has(user.pubkey.toLowerCase()) && - !selectedInviteePubkeys.has(user.pubkey.toLowerCase()), + !selectedInviteePubkeys.has(user.pubkey.toLowerCase()) && + !isArchivedDiscovery(user.pubkey), ), - [memberPubkeys, selectedInviteePubkeys, userSearchQuery.data], + [ + isArchivedDiscovery, + memberPubkeys, + selectedInviteePubkeys, + userSearchQuery.data, + ], ); React.useEffect(() => { diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c7ffcc61fc..6adf6d9cb5 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -78,7 +78,7 @@ export function MembersSidebar({ : null; const rawMembers = membersQuery.data ?? []; - const { people, bots, isBot, isMyBot, managedAgentsQuery } = + const { people, bots, archived, isBot, isMyBot, managedAgentsQuery } = useClassifiedMembers(rawMembers, currentPubkey); const allMemberPubkeys = React.useMemo( @@ -300,6 +300,38 @@ export function MembersSidebar({ + {archived.length > 0 ? ( +
+
+ +

+ Archived +

+ + {archived.length} + + + ▸ + +
+
+ {archived.map((member) => + renderMemberCard(member, isBot(member)), + )} +
+
+
+ ) : null} + {changeRoleError ? (

boolean { + const query = useArchivedIdentitiesQuery(); + return React.useMemo(() => { + const set = new Set( + (query.data?.archived ?? []).map((p) => p.toLowerCase()), + ); + return (pubkey: string) => set.has(pubkey.toLowerCase()); + }, [query.data]); +} + /** * Resolve the NIP-OA owner of a target via its live `kind:0`. Gates the * owner-path archive button. diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index d09eec76c0..5bb8fc7772 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -5,6 +5,7 @@ import { usePersonasQuery, } from "@/features/agents/hooks"; import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; import type { AutocompleteEdit } from "./useRichTextEditor"; import type { ChannelMember } from "@/shared/api/types"; @@ -27,6 +28,7 @@ export function useMentions( const membersQuery = useChannelMembersQuery(channelId); const members = externalMembers ?? membersQuery.data; + const isArchivedDiscovery = useIsArchivedPredicate(); const managedAgentsQuery = useManagedAgentsQuery(); const personasQuery = usePersonasQuery(); const managedAgentNamesByPubkey = React.useMemo( @@ -123,6 +125,7 @@ export function useMentions( }; return (members ?? []) + .filter((member) => !isArchivedDiscovery(member.pubkey)) .map((member) => { const pubkeyLower = member.pubkey.toLowerCase(); @@ -160,6 +163,7 @@ export function useMentions( personaName, })); }, [ + isArchivedDiscovery, managedAgentNamesByPubkey, members, mentionQuery, diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 1cf63ebbab..dae69e0dfb 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -58,6 +58,7 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({ ? "bg-accent text-accent-foreground" : "text-popover-foreground hover:bg-accent/50", )} + data-testid={`mention-suggestion-${suggestion.pubkey}`} key={suggestion.pubkey} onMouseDown={(event) => { event.preventDefault(); diff --git a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx index 3a5a6022ad..ac59310eec 100644 --- a/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx +++ b/desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx @@ -1,6 +1,7 @@ import { Search, X } from "lucide-react"; import * as React from "react"; +import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; import { useUserSearchQuery } from "@/features/profile/hooks"; import { truncatePubkey } from "@/features/profile/lib/identity"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -66,16 +67,18 @@ export function NewDirectMessageDialog({ open && deferredSearchQuery.length > 0 && !hasReachedRecipientLimit, limit: 8, }); + const isArchivedDiscovery = useIsArchivedPredicate(); const searchResults = React.useMemo( () => (userSearchQuery.data ?? []).filter((user) => { const normalizedPubkey = user.pubkey.toLowerCase(); return ( normalizedPubkey !== currentPubkey?.toLowerCase() && - !selectedPubkeys.has(normalizedPubkey) + !selectedPubkeys.has(normalizedPubkey) && + !isArchivedDiscovery(user.pubkey) ); }), - [currentPubkey, selectedPubkeys, userSearchQuery.data], + [currentPubkey, isArchivedDiscovery, selectedPubkeys, userSearchQuery.data], ); React.useEffect(() => { diff --git a/desktop/tests/e2e/identity-archive-hide.spec.ts b/desktop/tests/e2e/identity-archive-hide.spec.ts new file mode 100644 index 0000000000..a69748b30f --- /dev/null +++ b/desktop/tests/e2e/identity-archive-hide.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// Guards the NIP-IA discovery-suppression contract (Dawn's table v2): +// - Members sidebar: archived members fold under "Archived (N)", not in the +// active people/bots lists. +// - Mention autocomplete: archived members are filtered out of suggestions +// (but their `members` entry still resolves historical @-mentions). +// - DM picker: archived users are omitted from search results. +// - History invariant: an archived user's existing channel message renders +// normally (Tyler's "never hide messages"). + +const ALICE_PUBKEY = + "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; +const BOB_PUBKEY = + "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"; + +test.describe("NIP-IA hide archived from discovery", () => { + test("members sidebar: archived member folds under Archived section", async ({ + page, + }) => { + await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByTestId("channel-members-trigger").click(); + await expect(page.getByTestId("members-sidebar")).toBeVisible(); + + // Active People list should NOT include Alice. + const peopleList = page.getByTestId("members-sidebar-people"); + await expect(peopleList).not.toContainText("alice"); + + // Folded Archived section is visible with count = 1. + await expect(page.getByTestId("members-sidebar-archived")).toBeVisible(); + await expect(page.getByTestId("members-sidebar-archived-count")).toHaveText( + "1", + ); + + // Expanding it reveals Alice. + await page.getByTestId("members-sidebar-archived").click(); + await expect( + page.getByTestId("members-sidebar-archived-list"), + ).toContainText("alice"); + }); + + test("members sidebar: no Archived section when no archived members", async ({ + page, + }) => { + await installMockBridge(page, { archivedIdentities: [] }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("channel-members-trigger").click(); + await expect(page.getByTestId("members-sidebar")).toBeVisible(); + await expect(page.getByTestId("members-sidebar-archived")).toHaveCount(0); + }); + + test("mention autocomplete: archived member is filtered out", async ({ + page, + }) => { + await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Type `@a` to trigger autocomplete. Without filtering Alice would match. + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("@a"); + + // Alice's suggestion testid does NOT appear. + await expect( + page.getByTestId(`mention-suggestion-${ALICE_PUBKEY}`), + ).toHaveCount(0); + + // Sanity: Bob (also `@b...` candidate) is available when his query starts; + // proves the autocomplete itself is functional, not just always-empty. + await input.fill("@b"); + await expect( + page.getByTestId(`mention-suggestion-${BOB_PUBKEY}`), + ).toBeVisible(); + }); + + test("DM picker: archived user is omitted from search results", async ({ + page, + }) => { + await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] }); + await page.goto("/"); + await page.getByTestId("new-dm-trigger").click(); + await expect(page.getByTestId("new-dm-dialog")).toBeVisible(); + + await page.getByTestId("new-dm-search").fill("alice"); + // Alice's result row does NOT appear, even though search would normally + // surface her by display name. + await expect(page.getByTestId(`new-dm-result-${ALICE_PUBKEY}`)).toHaveCount( + 0, + ); + + // Sanity: Bob is still searchable, confirming the dialog works. + await page.getByTestId("new-dm-search").fill("bob"); + await expect(page.getByTestId(`new-dm-result-${BOB_PUBKEY}`)).toBeVisible(); + }); + + test("history invariant: archived user's existing message still renders with their name", async ({ + page, + }) => { + // Alice has a seeded message in #general from the prior PR's e2e setup + // (see e2eBridge.ts seed). Archiving her must not remove that message. + await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + // Alice's seed message renders with her display name in the timeline. + const aliceMessage = page.getByTestId("message-row").nth(1); + await expect(aliceMessage).toContainText("alice"); + await expect(aliceMessage).toContainText("Hey team — checking in."); + }); +}); From e9487f74b49bd65d317c0bbadc712173e27b16a0 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 12:29:10 -0400 Subject: [PATCH 2/3] fix(desktop/nip-ia): self-exempt the discovery predicate (Shape A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ff1e5613 shipped Shape B — predicate identity-only, no `currentPubkey` carve-out, self folds in own Archived section. Eva's final stamp in the thread (msg [10], superseding her earlier [8]) is Shape A: self is *never* filtered or folded from their own client, NIP-IA §Self Requests anti-shadowban property. I built from Dawn's intermediate [9] message before the final stamp landed. Two design points worth recording: 1. **Where self-exemption lives.** Dawn's locked v2 helper takes `currentPubkey` as an arg and threads it through every caller. Reading identity inside the predicate via `useIdentityQuery` (global, infinite-staleTime) is materially better: - No prop drilling through useMentions, RespondToField, etc. - Impossible for a future caller to forget the self-exemption. - One source of truth for "what is self". Net diff vs. arg-threading: ~zero callers change, one helper gains 3 lines. 2. **Why this is a real bug, not a polish.** Without self-exemption, a self-archived user loses their own seat in the members sidebar AND drops from their own @-autocomplete — both reproducible with the new tests. That's exactly the shadowban NIP-IA §Self Requests exists to prevent. Tests added: - self-exemption: archived current user still appears in their own People list (not folded). Asserts "You" + "deadbeef" prefix in members-sidebar-people AND members-sidebar-archived has count 0. - self-exemption: archived current user can still self-mention in their own autocomplete. Confirms the predicate's self-exemption fires at the suggestion filter, not just the panel partition. Verification: - `pnpm exec playwright test --project=smoke`: 112 passed (was 110, +2 new self-exemption cases). Pre-existing 5 cases unchanged. - `pnpm typecheck`, `pnpm check`: clean. Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- .../src/features/identity-archive/hooks.ts | 24 ++++++--- .../tests/e2e/identity-archive-hide.spec.ts | 50 +++++++++++++++++++ 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/desktop/src/features/identity-archive/hooks.ts b/desktop/src/features/identity-archive/hooks.ts index 3450ea7518..401803d252 100644 --- a/desktop/src/features/identity-archive/hooks.ts +++ b/desktop/src/features/identity-archive/hooks.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { archiveIdentity, listArchivedIdentities, @@ -43,20 +44,29 @@ export function useIsIdentityArchived(pubkey: string): boolean | undefined { * predicate returns `false` (no-op — show everyone), never `true` — fail-open * so a cold-start can't briefly hide everyone. * - * No `currentPubkey` carve-out: self-mention / self-DM are already no-ops at - * their consumers, and the panel partition intentionally folds the current - * user into their own Archived section when self-archived (NIP-IA's - * anti-shadowban property surfaces in the UI; the profile pane's flair and - * `selfMember` role lookup are independent of bucket). + * Self-exempt by construction: the current user is **never** filtered or + * folded from their own client, even when archived on the relay. NIP-IA §Self + * Requests makes archival deliberately non-silent — the anti-shadowban + * property requires the archived user to see they're archived and be able to + * self-unarchive. The profile pane's "Archived" flair is the honest + * disclosure; removing self from member lists / autocomplete / search would + * build the exact shadowban the NIP is designed to prevent. Self-exemption + * lives here, in the predicate, so no caller can forget it. */ export function useIsArchivedPredicate(): (pubkey: string) => boolean { const query = useArchivedIdentitiesQuery(); + const identityQuery = useIdentityQuery(); + const selfPubkey = identityQuery.data?.pubkey; return React.useMemo(() => { + const self = selfPubkey?.toLowerCase() ?? null; const set = new Set( (query.data?.archived ?? []).map((p) => p.toLowerCase()), ); - return (pubkey: string) => set.has(pubkey.toLowerCase()); - }, [query.data]); + return (pubkey: string) => { + const lower = pubkey.toLowerCase(); + return lower !== self && set.has(lower); + }; + }, [query.data, selfPubkey]); } /** diff --git a/desktop/tests/e2e/identity-archive-hide.spec.ts b/desktop/tests/e2e/identity-archive-hide.spec.ts index a69748b30f..f51f29b245 100644 --- a/desktop/tests/e2e/identity-archive-hide.spec.ts +++ b/desktop/tests/e2e/identity-archive-hide.spec.ts @@ -15,6 +15,9 @@ const ALICE_PUBKEY = "953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"; const BOB_PUBKEY = "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"; +// Active mock identity (matches DEFAULT_MOCK_IDENTITY.pubkey in e2eBridge). +// Used to exercise the self-exemption rule in the predicate. +const SELF_PUBKEY = "deadbeef".repeat(8); test.describe("NIP-IA hide archived from discovery", () => { test("members sidebar: archived member folds under Archived section", async ({ @@ -116,4 +119,51 @@ test.describe("NIP-IA hide archived from discovery", () => { await expect(aliceMessage).toContainText("alice"); await expect(aliceMessage).toContainText("Hey team — checking in."); }); + + test("self-exemption: archived current user still appears in their own People list (not folded)", async ({ + page, + }) => { + // Anti-shadowban property: the current user is never hidden from their + // own client even when archived on the relay. The predicate's + // self-exemption is what enforces this — without it, the user would lose + // their own seat in the members sidebar. + await installMockBridge(page, { archivedIdentities: [SELF_PUBKEY] }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await page.getByTestId("channel-members-trigger").click(); + await expect(page.getByTestId("members-sidebar")).toBeVisible(); + + // Self appears in active People list — NOT folded into Archived. The + // members sidebar renders the current user as "You" + a pubkey-prefix. + await expect(page.getByTestId("members-sidebar-people")).toContainText( + "You", + ); + await expect(page.getByTestId("members-sidebar-people")).toContainText( + "deadbeef", + ); + // No Archived section at all when self is the only archived pubkey in + // the channel (self-exemption makes archived.length === 0). + await expect(page.getByTestId("members-sidebar-archived")).toHaveCount(0); + }); + + test("self-exemption: archived current user can still self-mention in own autocomplete", async ({ + page, + }) => { + // Self-mention is a no-op in practice, but the predicate must not filter + // self from their own autocomplete — that would be the shadowban NIP-IA + // exists to prevent. + await installMockBridge(page, { archivedIdentities: [SELF_PUBKEY] }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially("@npub"); + // Self's suggestion appears in autocomplete despite being in the archived + // set — the predicate's self-exemption fires. + await expect( + page.getByTestId(`mention-suggestion-${SELF_PUBKEY}`), + ).toBeVisible(); + }); }); From c95d1402b8d97147ec677951acb97707a2942ff1 Mon Sep 17 00:00:00 2001 From: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 23 May 2026 13:27:40 -0400 Subject: [PATCH 3/3] test(desktop/nip-ia): cover member-adder discovery filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the channel member-adder (ChannelMemberInviteCard) to the hide e2e matrix — the one hard-filter surface Max's review flagged as code-correct but untested. Tested in #agents (where Alice is a non-member) with a paired control run so the assertion proves the archive filter drops her, not member-exclusion. --- .../tests/e2e/identity-archive-hide.spec.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/desktop/tests/e2e/identity-archive-hide.spec.ts b/desktop/tests/e2e/identity-archive-hide.spec.ts index f51f29b245..4cfd3341cb 100644 --- a/desktop/tests/e2e/identity-archive-hide.spec.ts +++ b/desktop/tests/e2e/identity-archive-hide.spec.ts @@ -166,4 +166,40 @@ test.describe("NIP-IA hide archived from discovery", () => { page.getByTestId(`mention-suggestion-${SELF_PUBKEY}`), ).toBeVisible(); }); + + // Member-adder (ChannelMemberInviteCard) — the invite search excludes + // existing channel members, so we test in #agents where Alice is NOT a + // member (only the mock identity + Charlie are): the ONLY thing that can + // drop her from results is the archive filter. The control run (nothing + // archived) proves she would otherwise appear — guarding a vacuous green. + test("member-adder: archived non-member is omitted from invite search", async ({ + page, + }) => { + await installMockBridge(page, { archivedIdentities: [ALICE_PUBKEY] }); + await page.goto("/"); + await page.getByTestId("channel-agents").click(); + await page.getByTestId("channel-members-trigger").click(); + await expect(page.getByTestId("members-sidebar")).toBeVisible(); + await page.getByTestId("channel-management-search-users").fill("alice"); + await expect( + page.getByTestId(`channel-user-search-result-${ALICE_PUBKEY}`), + ).toHaveCount(0); + }); + + test("member-adder: control — non-archived non-member IS in invite search", async ({ + page, + }) => { + // Same channel/query, nothing archived: Alice now appears. This is the + // companion that makes the test above non-vacuous (proves she was dropped + // by the archive filter, not by member-exclusion or a broken search). + await installMockBridge(page, { archivedIdentities: [] }); + await page.goto("/"); + await page.getByTestId("channel-agents").click(); + await page.getByTestId("channel-members-trigger").click(); + await expect(page.getByTestId("members-sidebar")).toBeVisible(); + await page.getByTestId("channel-management-search-users").fill("alice"); + await expect( + page.getByTestId(`channel-user-search-result-${ALICE_PUBKEY}`), + ).toBeVisible(); + }); });