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}
+
+
+ ▸
+
+
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. 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..4cfd3341cb --- /dev/null +++ b/desktop/tests/e2e/identity-archive-hide.spec.ts @@ -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(); + }); +});