mirror of
https://github.com/block/buzz.git
synced 2026-11-12 04:12:21 +00:00
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:
@@ -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"],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
205
desktop/tests/e2e/identity-archive-hide.spec.ts
Normal file
205
desktop/tests/e2e/identity-archive-hide.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user