feat(desktop): add channel archive and delete actions (#2067)

Signed-off-by: Pavlenex <36959754+pavlenex@users.noreply.github.com>
This commit is contained in:
Pavlenex
2026-07-18 10:10:35 -07:00
committed by GitHub
parent 2cb7753fa2
commit 929d3aa12f
7 changed files with 351 additions and 95 deletions
@@ -1,6 +1,11 @@
import { Archive, ArchiveRestore, Trash2 } from "lucide-react";
import { useMemo, type ReactNode } from "react";
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { ChannelMember } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import {
AlertDialog,
AlertDialogAction,
@@ -34,6 +39,117 @@ type ChannelManagementModerationActionsProps = {
unarchiveChannelMutation: ChannelMutation;
};
type ChannelDeleteConfirmationDialogProps = {
channelName: string;
error: unknown;
isPending: boolean;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
trigger?: ReactNode;
};
export function useChannelModerationCapabilities(
members: ChannelMember[] | undefined,
currentPubkey: string | undefined,
enabled: boolean,
) {
const normalizedCurrentPubkey = currentPubkey
? normalizePubkey(currentPubkey)
: undefined;
const selfRole = members?.find(
(member) => normalizePubkey(member.pubkey) === normalizedCurrentPubkey,
)?.role;
const ownerMemberPubkeys = useMemo(
() =>
(members ?? [])
.filter(
(member) =>
member.role === "owner" &&
normalizePubkey(member.pubkey) !== normalizedCurrentPubkey,
)
.map((member) => member.pubkey),
[members, normalizedCurrentPubkey],
);
const shouldResolveAgentOwnership =
enabled && selfRole !== "owner" && ownerMemberPubkeys.length > 0;
const ownerProfilesQuery = useUsersBatchQuery(ownerMemberPubkeys, {
enabled: shouldResolveAgentOwnership,
});
const canManageOwnedAgentChannel = ownerMemberPubkeys.some((pubkey) =>
ownsAuthorAgent(
ownerProfilesQuery.data?.profiles[normalizePubkey(pubkey)],
currentPubkey,
),
);
return {
canDeleteChannel: selfRole === "owner" || canManageOwnedAgentChannel,
canManageChannel:
selfRole === "owner" ||
selfRole === "admin" ||
canManageOwnedAgentChannel,
error: shouldResolveAgentOwnership ? ownerProfilesQuery.error : null,
isLoading: shouldResolveAgentOwnership && ownerProfilesQuery.isLoading,
};
}
export function ChannelDeleteConfirmationDialog({
channelName,
error,
isPending,
onConfirm,
onOpenChange,
open,
trigger,
}: ChannelDeleteConfirmationDialogProps) {
return (
<AlertDialog onOpenChange={onOpenChange} open={open}>
{trigger ? (
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
) : null}
<AlertDialogContent data-testid="channel-delete-confirmation-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Delete channel?</AlertDialogTitle>
<AlertDialogDescription>
Delete {channelName} from the community list. This action cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
{error instanceof Error ? (
<p className="text-sm text-destructive">{error.message}</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button
data-testid="channel-delete-cancel"
disabled={isPending}
type="button"
variant="outline"
>
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-testid="channel-delete-confirm"
disabled={isPending}
onClick={(event) => {
event.preventDefault();
onConfirm();
}}
type="button"
variant="destructive"
>
{isPending ? "Deleting..." : "Delete channel"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function ChannelManagementModerationActions({
archiveChannelMutation,
canManageChannel,
@@ -105,11 +221,16 @@ export function ChannelManagementModerationActions({
</Button>
)}
{canDeleteChannel ? (
<AlertDialog
<ChannelDeleteConfirmationDialog
channelName={resolvedChannelName}
error={deleteChannelMutation.error}
isPending={deleteChannelMutation.isPending}
onConfirm={() => {
void handleDeleteChannel();
}}
onOpenChange={handleDeleteDialogOpenChange}
open={isDeleteDialogOpen}
>
<AlertDialogTrigger asChild>
trigger={
<Button
aria-label="Delete channel"
data-testid="channel-management-delete"
@@ -121,50 +242,8 @@ export function ChannelManagementModerationActions({
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent data-testid="channel-delete-confirmation-dialog">
<AlertDialogHeader>
<AlertDialogTitle>Delete channel?</AlertDialogTitle>
<AlertDialogDescription>
Delete {resolvedChannelName} from the community list. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
{deleteChannelMutation.error instanceof Error ? (
<p className="text-sm text-destructive">
{deleteChannelMutation.error.message}
</p>
) : null}
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button
data-testid="channel-delete-cancel"
disabled={deleteChannelMutation.isPending}
type="button"
variant="outline"
>
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
data-testid="channel-delete-confirm"
disabled={deleteChannelMutation.isPending}
onClick={(event) => {
event.preventDefault();
void handleDeleteChannel();
}}
type="button"
variant="destructive"
>
{deleteChannelMutation.isPending
? "Deleting..."
: "Delete channel"}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
}
/>
) : null}
</div>
);
@@ -38,11 +38,8 @@ import {
formatTtlDuration,
parseTtlDuration,
} from "@/features/channels/lib/ephemeralChannel";
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useTheme } from "@/shared/theme/ThemeProvider";
import { Button } from "@/shared/ui/button";
import {
@@ -83,7 +80,10 @@ import {
NarrativeGroup,
ToggleRow,
} from "./ChannelManagementSheetRows";
import { ChannelManagementModerationActions } from "./ChannelManagementModerationActions";
import {
ChannelManagementModerationActions,
useChannelModerationCapabilities,
} from "./ChannelManagementModerationActions";
import { writeTextToClipboard } from "@/shared/lib/clipboard";
type ChannelManagementSheetProps = {
@@ -137,37 +137,8 @@ export function ChannelManagementSheet({
members.find((member) => member.pubkey === currentPubkey) ?? null;
const hasResolvedMembership = membersQuery.data !== undefined;
// Collect owner-role member pubkeys to look up their NIP-OA ownerPubkey.
// This is what surfaces the "you own the agent that owns this channel" path.
const ownerMemberPubkeys = React.useMemo(
() =>
members
.filter((m) => m.role === "owner" && m.pubkey !== currentPubkey)
.map((m) => m.pubkey),
[members, currentPubkey],
);
const ownerProfilesQuery = useUsersBatchQuery(ownerMemberPubkeys, {
enabled: open && ownerMemberPubkeys.length > 0,
});
// True when an owner-role member of this channel is an agent owned by the
// current user — mirrors the relay's is_agent_owner gate.
const canManageOwnedAgentChannel = React.useMemo(() => {
if (!currentPubkey || !ownerProfilesQuery.data) return false;
return ownerMemberPubkeys.some((pubkey) =>
ownsAuthorAgent(
ownerProfilesQuery.data?.profiles[normalizePubkey(pubkey)],
currentPubkey,
),
);
}, [currentPubkey, ownerMemberPubkeys, ownerProfilesQuery.data]);
const isSelfOwner = selfMember?.role === "owner";
// Capability: may delete this channel (self-owner OR owns the agent-owner).
const canDeleteChannel = isSelfOwner || canManageOwnedAgentChannel;
const canManageChannel =
selfMember?.role === "owner" ||
selfMember?.role === "admin" ||
canManageOwnedAgentChannel;
const { canDeleteChannel, canManageChannel } =
useChannelModerationCapabilities(membersQuery.data, currentPubkey, open);
const canEditNarrative =
canManageChannel && selfMember !== null && detail?.channelType !== "dm";
const isArchived =
@@ -26,6 +26,7 @@ import {
CreateSectionDialog,
DeleteSectionAlertDialog,
RenameSectionDialog,
useDeleteChannelDialog,
useLeaveChannelDialog,
type SectionDialogValue,
} from "@/features/sidebar/ui/ChannelSectionDialogs";
@@ -373,6 +374,10 @@ export function AppSidebar({
React.useState<ChannelSection | null>(null);
const { requestLeaveChannel, dialog: leaveChannelDialog } =
useLeaveChannelDialog();
const { requestDeleteChannel, dialog: deleteChannelDialog } =
useDeleteChannelDialog((channel) => {
if (channel.id === selectedChannelId) onSelectHome();
});
const streamChannels = React.useMemo(
() => channels.filter((channel) => channel.channelType === "stream"),
@@ -636,6 +641,7 @@ export function AppSidebar({
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onDeleteChannel={requestDeleteChannel}
onLeaveChannel={requestLeaveChannel}
/>
) : null}
@@ -705,6 +711,7 @@ export function AppSidebar({
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onDeleteChannel={requestDeleteChannel}
onLeaveChannel={requestLeaveChannel}
/>
))}
@@ -744,6 +751,7 @@ export function AppSidebar({
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onDeleteChannel={requestDeleteChannel}
onLeaveChannel={requestLeaveChannel}
/>
</SidebarDndContext>
@@ -774,6 +782,7 @@ export function AppSidebar({
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
onDeleteChannel={requestDeleteChannel}
/>
</FeatureGate>
<SidebarSection
@@ -961,6 +970,7 @@ export function AppSidebar({
setDeleteSectionTarget(null);
}}
/>
{deleteChannelDialog}
{leaveChannelDialog}
<SidebarRail />
</Sidebar>
@@ -1,4 +1,5 @@
import {
Archive,
Bell,
BellOff,
Check,
@@ -6,11 +7,19 @@ import {
CircleDot,
Copy,
LogOut,
LoaderCircle,
Plus,
Star,
StarOff,
Trash2,
TriangleAlert,
} from "lucide-react";
import {
useArchiveChannelMutation,
useChannelMembersQuery,
} from "@/features/channels/hooks";
import { useChannelModerationCapabilities } from "@/features/channels/ui/ChannelManagementModerationActions";
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
import {
ContextMenuIconSlot,
@@ -18,6 +27,7 @@ import {
} from "@/features/sidebar/ui/sidebarMenuHelpers";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import type { Channel } from "@/shared/api/types";
import { useIdentityQuery } from "@/shared/api/hooks";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import {
ContextMenuItem,
@@ -144,6 +154,7 @@ export function ChannelContextMenuItems({
onAssignChannel,
onUnassignChannel,
onCreateSectionForChannel,
onDeleteChannel,
onLeaveChannel,
}: {
channel: Channel;
@@ -164,8 +175,34 @@ export function ChannelContextMenuItems({
onAssignChannel?: (channelId: string, sectionId: string) => void;
onUnassignChannel?: (channelId: string) => void;
onCreateSectionForChannel?: (channelId: string) => void;
onDeleteChannel?: (channel: Channel) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const canLoadOwnerActions =
channel.channelType !== "dm" && Boolean(onDeleteChannel);
const membersQuery = useChannelMembersQuery(channel.id, canLoadOwnerActions);
const currentPubkey = useIdentityQuery().data?.pubkey;
const archiveChannel = useArchiveChannelMutation(channel.id);
const {
canDeleteChannel,
canManageChannel,
error: capabilityError,
isLoading: isCapabilityLoading,
} = useChannelModerationCapabilities(
membersQuery.data,
currentPubkey,
canLoadOwnerActions,
);
const ownerActionsError = membersQuery.error ?? capabilityError;
const ownerActionsLoading =
canLoadOwnerActions && (membersQuery.isLoading || isCapabilityLoading);
const showChannelActions = Boolean(
onLeaveChannel ||
ownerActionsLoading ||
ownerActionsError ||
canManageChannel ||
canDeleteChannel,
);
const showStar = Boolean(onStarChannel && onUnstarChannel);
const showReadToggle = hasUnread
? Boolean(onMarkChannelRead)
@@ -265,19 +302,56 @@ export function ChannelContextMenuItems({
</ContextMenuItem>
)
) : null}
{showChannelActions ? <ContextMenuSeparator /> : null}
{onLeaveChannel ? (
<>
<ContextMenuSeparator />
<ContextMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => deferMenuAction(() => onLeaveChannel(channel))}
>
<ContextMenuIconSlot>
<LogOut className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Leave channel</span>
</ContextMenuItem>
</>
<ContextMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => deferMenuAction(() => onLeaveChannel(channel))}
>
<ContextMenuIconSlot>
<LogOut className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Leave channel</span>
</ContextMenuItem>
) : null}
{ownerActionsLoading ? (
<ContextMenuItem disabled>
<ContextMenuIconSlot>
<LoaderCircle className="h-4 w-4 animate-spin" />
</ContextMenuIconSlot>
<span>Loading channel actions...</span>
</ContextMenuItem>
) : ownerActionsError ? (
<ContextMenuItem disabled>
<ContextMenuIconSlot>
<TriangleAlert className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Channel actions unavailable</span>
</ContextMenuItem>
) : null}
{canManageChannel ? (
<ContextMenuItem
data-testid={`archive-channel-${channel.name}`}
disabled={archiveChannel.isPending}
onSelect={() => deferMenuAction(() => archiveChannel.mutate())}
>
<ContextMenuIconSlot>
<Archive className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Archive channel</span>
</ContextMenuItem>
) : null}
{canDeleteChannel ? (
<ContextMenuItem
className="text-destructive focus:text-destructive"
data-testid={`delete-channel-${channel.name}`}
onSelect={() => deferMenuAction(() => onDeleteChannel?.(channel))}
>
<ContextMenuIconSlot>
<Trash2 className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Delete channel</span>
</ContextMenuItem>
) : null}
</>
);
@@ -26,7 +26,11 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { Input } from "@/shared/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import type { Channel } from "@/shared/api/types";
import { useLeaveChannelMutation } from "@/features/channels/hooks";
import {
useDeleteChannelMutation,
useLeaveChannelMutation,
} from "@/features/channels/hooks";
import { ChannelDeleteConfirmationDialog } from "@/features/channels/ui/ChannelManagementModerationActions";
export type SectionDialogValue = {
name: string;
@@ -335,3 +339,34 @@ export function useLeaveChannelDialog() {
return { requestLeaveChannel: setTarget, dialog };
}
export function useDeleteChannelDialog(onDeleted: (channel: Channel) => void) {
const [target, setTarget] = React.useState<Channel | null>(null);
const deleteChannel = useDeleteChannelMutation(target?.id ?? null);
const dialog = (
<ChannelDeleteConfirmationDialog
channelName={target?.name ?? ""}
error={deleteChannel.error}
isPending={deleteChannel.isPending}
onConfirm={() => {
const deletedChannel = target;
deleteChannel.mutate(undefined, {
onSuccess: () => {
setTarget(null);
if (deletedChannel) onDeleted(deletedChannel);
},
});
}}
onOpenChange={(open) => {
if (!open) {
deleteChannel.reset();
setTarget(null);
}
}}
open={target !== null}
/>
);
return { requestDeleteChannel: setTarget, dialog };
}
@@ -371,6 +371,7 @@ export function ChannelGroupSection({
starredChannelIds,
onStarChannel,
onUnstarChannel,
onDeleteChannel,
onLeaveChannel,
}: {
browseLabel?: string;
@@ -420,6 +421,7 @@ export function ChannelGroupSection({
starredChannelIds?: ReadonlySet<string>;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
onDeleteChannel?: (channel: Channel) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const contentId = `sidebar-${listTestId}`;
@@ -478,6 +480,7 @@ export function ChannelGroupSection({
onAssignChannel={onAssignChannel}
onUnassignChannel={onUnassignChannel}
onCreateSectionForChannel={onCreateSectionForChannel}
onDeleteChannel={onDeleteChannel}
onLeaveChannel={onLeaveChannel}
/>
</ContextMenuContent>
@@ -572,6 +575,7 @@ export function CustomChannelSection({
starredChannelIds,
onStarChannel,
onUnstarChannel,
onDeleteChannel,
onLeaveChannel,
}: {
section: ChannelSection;
@@ -611,6 +615,7 @@ export function CustomChannelSection({
starredChannelIds?: ReadonlySet<string>;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
onDeleteChannel?: (channel: Channel) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const contentId = `sidebar-section-${section.id}`;
@@ -770,6 +775,7 @@ export function CustomChannelSection({
onCreateSectionForChannel={
onCreateSectionForChannel
}
onDeleteChannel={onDeleteChannel}
onLeaveChannel={onLeaveChannel}
/>
</ContextMenuContent>
+81
View File
@@ -134,6 +134,87 @@ test("leaving a channel from the context menu never freezes the app", async ({
await expectAppClickable(page);
});
test("channel context menu only shows owner actions to the owner", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click({ button: "right" });
await expect(
page.getByRole("menuitem", { name: "Archive channel" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Delete channel" }),
).toBeVisible();
const lifecycleOrder = (
await page.getByRole("menuitem").allTextContents()
).filter((label) =>
["Leave channel", "Archive channel", "Delete channel"].includes(label),
);
expect(lifecycleOrder).toEqual([
"Leave channel",
"Archive channel",
"Delete channel",
]);
await page.keyboard.press("Escape");
await page.getByTestId("channel-random").click({ button: "right" });
await expect(
page.getByRole("menuitem", { name: "Leave channel" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Loading channel actions..." }),
).toHaveCount(0);
await expect(
page.getByRole("menuitem", { name: "Archive channel" }),
).toHaveCount(0);
await expect(
page.getByRole("menuitem", { name: "Delete channel" }),
).toHaveCount(0);
});
test("channel context menu explains when owner actions are loading", async ({
page,
}) => {
await installMockBridge(page, { channelMembersReadDelayMs: 500 });
await page.goto("/");
await page.getByTestId("channel-general").click({ button: "right" });
await expect(
page.getByRole("menuitem", { name: "Loading channel actions..." }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Archive channel" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Loading channel actions..." }),
).toHaveCount(0);
});
test("channel owner can archive from the context menu", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click({ button: "right" });
await page.getByRole("menuitem", { name: "Archive channel" }).click();
await expect(page.getByTestId("stream-list")).not.toContainText("general");
});
test("channel owner can delete from the context menu", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await page.getByTestId("channel-general").click({ button: "right" });
await page.getByRole("menuitem", { name: "Delete channel" }).click();
await expect(
page.getByTestId("channel-delete-confirmation-dialog"),
).toBeVisible();
await page.getByTestId("channel-delete-confirm").click();
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await expect(page.getByTestId("stream-list")).not.toContainText("general");
});
test("fades the pinned sidebar chrome edges outside the Buzz theme", async ({
page,
}) => {