Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
8 changes: 6 additions & 2 deletions desktop/src/features/agents/ui/RespondToField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
22 changes: 19 additions & 3 deletions desktop/src/features/channels/lib/useClassifiedMembers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -15,6 +16,7 @@ export function useClassifiedMembers(
) {
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
const isArchived = useIsArchivedPredicate();

const managedAgents = managedAgentsQuery.data ?? [];
const relayAgents = relayAgentsQuery.data ?? [];
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(() => {
Expand Down
34 changes: 33 additions & 1 deletion desktop/src/features/channels/ui/MembersSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"
Expand Down
35 changes: 35 additions & 0 deletions desktop/src/features/identity-archive/hooks.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(
Expand Down Expand Up @@ -123,6 +125,7 @@ export function useMentions(
};

return (members ?? [])
.filter((member) => !isArchivedDiscovery(member.pubkey))
.map((member) => {
const pubkeyLower = member.pubkey.toLowerCase();

Expand Down Expand Up @@ -160,6 +163,7 @@ export function useMentions(
personaName,
}));
}, [
isArchivedDiscovery,
managedAgentNamesByPubkey,
members,
mentionQuery,
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/messages/ui/MentionAutocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 5 additions & 2 deletions desktop/src/features/sidebar/ui/NewDirectMessageDialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down
Loading
Loading