Skip to content
Draft
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
12 changes: 11 additions & 1 deletion desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export function AppShell() {
const [managedChannelId, setManagedChannelId] = React.useState<string | null>(
null,
);
const [channelManagementRequest, setChannelManagementRequest] =
React.useState({ edit: false, id: 0 });
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
Expand Down Expand Up @@ -637,10 +639,17 @@ export function AppShell() {
markChannelRead,
markChannelUnread,
openCreateChannel: handleOpenCreateChannel,
openChannelManagement: (channelId?: string) => {
openChannelManagement: (
channelId?: string,
options?: { edit?: boolean },
) => {
setManagedChannelId(
typeof channelId === "string" ? channelId : null,
);
setChannelManagementRequest((request) => ({
edit: options?.edit === true,
id: request.id + 1,
}));
setIsChannelManagementOpen(true);
},
getChannelReadAt,
Expand Down Expand Up @@ -906,6 +915,7 @@ export function AppShell() {
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
channelManagementRequest={channelManagementRequest}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={(open) => {
Expand Down
5 changes: 4 additions & 1 deletion desktop/src/app/AppShellContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ type AppShellContextValue = {
) => void;
markChannelUnread: (channelId: string) => void;
openCreateChannel: () => void;
openChannelManagement: (channelId?: string) => void;
openChannelManagement: (
channelId?: string,
options?: { edit?: boolean },
) => void;
// NIP-RS read marker for a channel as a unix-seconds timestamp, or null
// when unknown. Backed by the single AppShell-mounted ReadStateManager so
// every surface (sidebar, home, badges) projects from the same source.
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/app/AppShellOverlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type AppShellOverlaysProps = {
channels: Channel[];
currentPubkey?: string;
isChannelManagementOpen: boolean;
channelManagementRequest: { edit: boolean; id: number };
onBrowseChannelJoin: (channelId: string) => Promise<void>;
onBrowseDialogOpenChange: (open: boolean) => void;
onChannelManagementOpenChange: (open: boolean) => void;
Expand All @@ -34,6 +35,7 @@ export function AppShellOverlays({
channels,
currentPubkey,
isChannelManagementOpen,
channelManagementRequest,
onBrowseChannelJoin,
onBrowseDialogOpenChange,
onChannelManagementOpenChange,
Expand Down Expand Up @@ -80,6 +82,7 @@ export function AppShellOverlays({
<ChannelManagementSheet
channel={activeChannel}
currentPubkey={currentPubkey}
managementRequest={channelManagementRequest}
onDeleted={onDeleteActiveChannel}
onOpenChange={onChannelManagementOpenChange}
open={true}
Expand Down
37 changes: 22 additions & 15 deletions desktop/src/features/channels/ui/ChannelManagementSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ type ChannelManagementSheetProps = {
channel: Channel | null;
animateSplitEnter?: boolean;
currentPubkey?: string;
managementRequest?: { edit: boolean; id: number };
layout?: "overlay" | "split";
onDeleted?: () => void;
onOpenChange: (open: boolean) => void;
Expand All @@ -100,6 +101,7 @@ export function ChannelManagementSheet({
animateSplitEnter = false,
channel,
currentPubkey,
managementRequest = { edit: false, id: 0 },
layout = "overlay",
onDeleted,
onOpenChange,
Expand Down Expand Up @@ -201,10 +203,12 @@ export function ChannelManagementSheet({
// Sync drafts from server only when the sheet opens or the channel changes -
// not on every background refetch, which would clobber in-flight edits.
const syncedForRef = React.useRef<string | null>(null);
const appliedEditRequestRef = React.useRef(0);
React.useEffect(() => {
if (!open) {
// Reset on close so the next open re-syncs from server.
syncedForRef.current = null;
appliedEditRequestRef.current = managementRequest.id;
setIsDeleteDialogOpen(false);
setIsEditDialogOpen(false);
setActiveView("summary");
Expand All @@ -215,22 +219,25 @@ export function ChannelManagementSheet({
}

const key = detail.id;
if (syncedForRef.current === key) {
return;
if (syncedForRef.current !== key) {
syncedForRef.current = key;
setNameDraft(detail.name);
setDescriptionDraft(detail.description);
setTopicDraft(detail.topic ?? "");
setPurposeDraft(detail.purpose ?? "");
setIsPrivateDraft(detail.visibility === "private");
setIsEphemeralDraft(detail.ttlSeconds !== null);
setTtlDraft(
detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "",
);
setActiveView("summary");
}
syncedForRef.current = key;

setNameDraft(detail.name);
setDescriptionDraft(detail.description);
setTopicDraft(detail.topic ?? "");
setPurposeDraft(detail.purpose ?? "");
setIsPrivateDraft(detail.visibility === "private");
setIsEphemeralDraft(detail.ttlSeconds !== null);
setTtlDraft(
detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "",
);
setActiveView("summary");
}, [detail, open]);

if (managementRequest.id !== appliedEditRequestRef.current) {
appliedEditRequestRef.current = managementRequest.id;
setIsEditDialogOpen(managementRequest.edit);
}
}, [detail, managementRequest.edit, managementRequest.id, open]);

if (!channel) {
return null;
Expand Down
120 changes: 120 additions & 0 deletions desktop/src/features/sidebar/ui/ChannelContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import * as React from "react";

import {
Archive,
Bell,
BellOff,
Check,
CheckCircle2,
CircleDot,
Copy,
LogOut,
Pencil,
Plus,
Star,
StarOff,
} from "lucide-react";
import { toast } from "sonner";

import { useAppShell } from "@/app/AppShellContext";
import {
useArchiveChannelMutation,
useChannelMembersQuery,
} from "@/features/channels/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
import {
ContextMenuIconSlot,
Expand All @@ -20,13 +34,35 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import type { Channel } from "@/shared/api/types";
import { copyTextToClipboard } from "@/shared/lib/clipboard";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "@/shared/ui/context-menu";

export function ChannelContextMenu({
children,
contentProps,
modal,
}: {
children: React.ReactNode;
contentProps: React.ComponentProps<typeof ChannelContextMenuItems>;
modal?: boolean;
}) {
return (
<ContextMenu modal={modal}>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ChannelContextMenuItems {...contentProps} />
</ContextMenuContent>
</ContextMenu>
);
}

function MoveToSectionSubmenu({
channelId,
sections,
Expand Down Expand Up @@ -166,6 +202,52 @@ export function ChannelContextMenuItems({
onCreateSectionForChannel?: (channelId: string) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const { openChannelManagement } = useAppShell();
const currentPubkey = useIdentityQuery().data?.pubkey;
const archiveChannelMutation = useArchiveChannelMutation(channel.id);
const membersQuery = useChannelMembersQuery(
channel.id,
channel.channelType !== "dm",
);
const ownerPubkeys =
membersQuery.data
?.filter(
(member) => member.role === "owner" && member.pubkey !== currentPubkey,
)
.map((member) => member.pubkey) ?? [];
const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, {
enabled: ownerPubkeys.length > 0,
});
const selfMember = membersQuery.data?.find(
(member) => member.pubkey === currentPubkey,
);
const canManageOwnedAgentChannel = ownerPubkeys.some((pubkey) =>
ownsAuthorAgent(
ownerProfilesQuery.data?.profiles[normalizePubkey(pubkey)],
currentPubkey,
),
);
const canManageChannel =
selfMember?.role === "owner" ||
selfMember?.role === "admin" ||
canManageOwnedAgentChannel;
const hasResolvedMembers =
membersQuery.data !== undefined || membersQuery.isError;
const hasResolvedOwnerProfiles =
ownerPubkeys.length === 0 ||
ownerProfilesQuery.data !== undefined ||
ownerProfilesQuery.isError;
const isManagementCandidate =
channel.channelType !== "dm" && channel.archivedAt === null;
// Context-menu content mounts per open session. Once these rows are reserved,
// keep them until close so permission resolution cannot move another action
// beneath the pointer; eligible rows transition from disabled to enabled.
const [showManagementActions] = React.useState(
() =>
isManagementCandidate &&
(!hasResolvedMembers || !hasResolvedOwnerProfiles || canManageChannel),
);
const disableManagementActions = !canManageChannel;
const showStar = Boolean(onStarChannel && onUnstarChannel);
const showReadToggle = hasUnread
? Boolean(onMarkChannelRead)
Expand All @@ -192,6 +274,44 @@ export function ChannelContextMenuItems({
onCreateSectionForChannel={onCreateSectionForChannel ?? (() => {})}
/>
) : null}
{showManagementActions ? (
<>
<ContextMenuSeparator />
<ContextMenuItem
disabled={disableManagementActions}
onSelect={() =>
deferMenuAction(() =>
openChannelManagement(channel.id, { edit: true }),
)
}
>
<ContextMenuIconSlot>
<Pencil className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Edit channel</span>
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"
disabled={disableManagementActions}
onSelect={() =>
deferMenuAction(() => {
void archiveChannelMutation.mutateAsync().catch((error) => {
toast.error(
error instanceof Error
? error.message
: "Failed to archive channel.",
);
});
})
}
>
<ContextMenuIconSlot>
<Archive className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Archive channel</span>
</ContextMenuItem>
</>
) : null}
{showReadToggle ? <ContextMenuSeparator /> : null}
{hasUnread && onMarkChannelRead ? (
<ContextMenuItem
Expand Down
Loading
Loading