feat(desktop): hide archived identities from discovery surfaces (NIP-IA) (#734)

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-05-23 13:58:01 -04:00
committed by GitHub
parent 608e3af634
commit 59f51a8ac7
10 changed files with 319 additions and 10 deletions

View File

@@ -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"],

View File

@@ -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(

View File

@@ -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,

View File

@@ -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(() => {

View File

@@ -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({
</div>
</section>
{archived.length > 0 ? (
<section className="space-y-2.5">
<details
className="group/archived"
data-testid="members-sidebar-archived"
>
<summary className="flex cursor-pointer items-center gap-2 list-none [&::-webkit-details-marker]:hidden">
<h2 className="text-sm font-semibold tracking-tight text-muted-foreground">
Archived
</h2>
<span
className="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground"
data-testid="members-sidebar-archived-count"
>
{archived.length}
</span>
<span className="ml-auto text-xs text-muted-foreground transition-transform group-open/archived:rotate-90">
▸
</span>
</summary>
<div
className="mt-2 space-y-2"
data-testid="members-sidebar-archived-list"
>
{archived.map((member) =>
renderMemberCard(member, isBot(member)),
)}
</div>
</details>
</section>
) : null}
{changeRoleError ? (
<p
className="text-sm text-destructive"

View File

@@ -1,5 +1,7 @@
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useIdentityQuery } from "@/shared/api/hooks";
import {
archiveIdentity,
listArchivedIdentities,
@@ -34,6 +36,39 @@ export function useIsIdentityArchived(pubkey: string): boolean | undefined {
return query.data.archived.includes(lower);
}
/**
* Predicate for hiding archived identities from forward-looking discovery
* surfaces (mention autocomplete, DM picker, member-adder, search,
* panel-fold). Distinct from `useIsIdentityArchived` because callers here
* need a synchronous boolean: while the `kind:13535` snapshot is loading the
* predicate returns `false` (no-op — show everyone), never `true` — fail-open
* so a cold-start can't briefly hide everyone.
*
* 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) => {
const lower = pubkey.toLowerCase();
return lower !== self && set.has(lower);
};
}, [query.data, selfPubkey]);
}
/**
* Resolve the NIP-OA owner of a target via its live `kind:0`. Gates the
* owner-path archive button.

View File

@@ -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,

View File

@@ -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();

View File

@@ -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(() => {

View File

@@ -0,0 +1,205 @@
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";
// 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 ({
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.");
});
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();
});
// 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();
});
});