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
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -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>
);
Expand Down
41 changes: 6 additions & 35 deletions desktop/src/features/channels/ui/ChannelManagementSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 =
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/features/sidebar/ui/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
CreateSectionDialog,
DeleteSectionAlertDialog,
RenameSectionDialog,
useDeleteChannelDialog,
useLeaveChannelDialog,
type SectionDialogValue,
} from "@/features/sidebar/ui/ChannelSectionDialogs";
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -635,6 +640,7 @@ export function AppSidebar({
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onDeleteChannel={requestDeleteChannel}
onLeaveChannel={requestLeaveChannel}
/>
) : null}
Expand Down Expand Up @@ -704,6 +710,7 @@ export function AppSidebar({
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onDeleteChannel={requestDeleteChannel}
onLeaveChannel={requestLeaveChannel}
/>
))}
Expand Down Expand Up @@ -743,6 +750,7 @@ export function AppSidebar({
starredChannelIds={starredChannelIds}
onStarChannel={onStarChannel}
onUnstarChannel={onUnstarChannel}
onDeleteChannel={requestDeleteChannel}
onLeaveChannel={requestLeaveChannel}
/>
</SidebarDndContext>
Expand Down Expand Up @@ -773,6 +781,7 @@ export function AppSidebar({
mutedChannelIds={mutedChannelIds}
onMuteChannel={onMuteChannel}
onUnmuteChannel={onUnmuteChannel}
onDeleteChannel={requestDeleteChannel}
/>
</FeatureGate>
<SidebarSection
Expand Down Expand Up @@ -960,6 +969,7 @@ export function AppSidebar({
setDeleteSectionTarget(null);
}}
/>
{deleteChannelDialog}
{leaveChannelDialog}
<SidebarRail />
</Sidebar>
Expand Down
Loading
Loading