mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add channel management menu actions
Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
f3599f2cd4
commit
4bed899be1
@@ -106,6 +106,8 @@ export function AppShell() {
|
||||
const [managedChannelId, setManagedChannelId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [openChannelManagementInEditMode, setOpenChannelManagementInEditMode] =
|
||||
React.useState(false);
|
||||
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
|
||||
const [browseDialogType, setBrowseDialogType] =
|
||||
React.useState<BrowseDialogType>(null);
|
||||
@@ -637,10 +639,14 @@ export function AppShell() {
|
||||
markChannelRead,
|
||||
markChannelUnread,
|
||||
openCreateChannel: handleOpenCreateChannel,
|
||||
openChannelManagement: (channelId?: string) => {
|
||||
openChannelManagement: (
|
||||
channelId?: string,
|
||||
options?: { edit?: boolean },
|
||||
) => {
|
||||
setManagedChannelId(
|
||||
typeof channelId === "string" ? channelId : null,
|
||||
);
|
||||
setOpenChannelManagementInEditMode(options?.edit === true);
|
||||
setIsChannelManagementOpen(true);
|
||||
},
|
||||
getChannelReadAt,
|
||||
@@ -906,12 +912,16 @@ export function AppShell() {
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isChannelManagementOpen={isChannelManagementOpen}
|
||||
openChannelManagementInEditMode={
|
||||
openChannelManagementInEditMode
|
||||
}
|
||||
onBrowseChannelJoin={handleBrowseChannelJoin}
|
||||
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
|
||||
onChannelManagementOpenChange={(open) => {
|
||||
setIsChannelManagementOpen(open);
|
||||
if (!open) {
|
||||
setManagedChannelId(null);
|
||||
setOpenChannelManagementInEditMode(false);
|
||||
}
|
||||
}}
|
||||
onDeleteActiveChannel={() => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -21,6 +21,7 @@ type AppShellOverlaysProps = {
|
||||
channels: Channel[];
|
||||
currentPubkey?: string;
|
||||
isChannelManagementOpen: boolean;
|
||||
openChannelManagementInEditMode: boolean;
|
||||
onBrowseChannelJoin: (channelId: string) => Promise<void>;
|
||||
onBrowseDialogOpenChange: (open: boolean) => void;
|
||||
onChannelManagementOpenChange: (open: boolean) => void;
|
||||
@@ -34,6 +35,7 @@ export function AppShellOverlays({
|
||||
channels,
|
||||
currentPubkey,
|
||||
isChannelManagementOpen,
|
||||
openChannelManagementInEditMode,
|
||||
onBrowseChannelJoin,
|
||||
onBrowseDialogOpenChange,
|
||||
onChannelManagementOpenChange,
|
||||
@@ -80,6 +82,7 @@ export function AppShellOverlays({
|
||||
<ChannelManagementSheet
|
||||
channel={activeChannel}
|
||||
currentPubkey={currentPubkey}
|
||||
initiallyEditing={openChannelManagementInEditMode}
|
||||
onDeleted={onDeleteActiveChannel}
|
||||
onOpenChange={onChannelManagementOpenChange}
|
||||
open={true}
|
||||
|
||||
@@ -89,6 +89,7 @@ type ChannelManagementSheetProps = {
|
||||
channel: Channel | null;
|
||||
animateSplitEnter?: boolean;
|
||||
currentPubkey?: string;
|
||||
initiallyEditing?: boolean;
|
||||
layout?: "overlay" | "split";
|
||||
onDeleted?: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -100,6 +101,7 @@ export function ChannelManagementSheet({
|
||||
animateSplitEnter = false,
|
||||
channel,
|
||||
currentPubkey,
|
||||
initiallyEditing = false,
|
||||
layout = "overlay",
|
||||
onDeleted,
|
||||
onOpenChange,
|
||||
@@ -229,8 +231,9 @@ export function ChannelManagementSheet({
|
||||
setTtlDraft(
|
||||
detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "",
|
||||
);
|
||||
setIsEditDialogOpen(initiallyEditing);
|
||||
setActiveView("summary");
|
||||
}, [detail, open]);
|
||||
}, [detail, initiallyEditing, open]);
|
||||
|
||||
if (!channel) {
|
||||
return null;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Archive,
|
||||
Bell,
|
||||
BellOff,
|
||||
Check,
|
||||
@@ -6,11 +7,22 @@ import {
|
||||
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,
|
||||
@@ -166,6 +178,37 @@ 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 showManagementActions =
|
||||
channel.channelType !== "dm" &&
|
||||
channel.archivedAt === null &&
|
||||
(selfMember?.role === "owner" ||
|
||||
selfMember?.role === "admin" ||
|
||||
canManageOwnedAgentChannel);
|
||||
const showStar = Boolean(onStarChannel && onUnstarChannel);
|
||||
const showReadToggle = hasUnread
|
||||
? Boolean(onMarkChannelRead)
|
||||
@@ -192,6 +235,42 @@ export function ChannelContextMenuItems({
|
||||
onCreateSectionForChannel={onCreateSectionForChannel ?? (() => {})}
|
||||
/>
|
||||
) : null}
|
||||
{showManagementActions ? (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
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"
|
||||
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
|
||||
|
||||
@@ -2333,6 +2333,75 @@ test("open channel management supports join and leave", async ({ page }) => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("channel context menu edits a stream", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click({ button: "right" });
|
||||
const editItem = page.getByRole("menuitem", { name: "Edit channel" });
|
||||
await expect(editItem).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Archive channel" }),
|
||||
).toBeVisible();
|
||||
|
||||
await editItem.click();
|
||||
const editDialog = page.getByRole("dialog", { name: "Edit channel" });
|
||||
await expect(editDialog).toBeVisible();
|
||||
await expect(editDialog.getByTestId("channel-management-name")).toHaveValue(
|
||||
"general",
|
||||
);
|
||||
|
||||
await editDialog.getByTestId("channel-management-name").fill("general-chat");
|
||||
await editDialog.getByTestId("channel-management-save-changes").click();
|
||||
await expect(editDialog).not.toBeVisible();
|
||||
await expect(page.getByTestId("stream-list")).toContainText("general-chat");
|
||||
|
||||
await page.getByRole("button", { name: "Close" }).click();
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(editDialog).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("channel context menu hides management actions from members and DMs", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-random").click({ button: "right" });
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Edit channel" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Archive channel" }),
|
||||
).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const firstDm = page
|
||||
.getByTestId("dm-list")
|
||||
.locator('[data-testid^="channel-"]')
|
||||
.first();
|
||||
await firstDm.click({ button: "right" });
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Edit channel" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Archive channel" }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("channel context menu archives a stream", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.getByTestId("channel-general").click({ button: "right" });
|
||||
const archiveItem = page.getByRole("menuitem", { name: "Archive channel" });
|
||||
await expect(archiveItem).toBeVisible();
|
||||
await archiveItem.click();
|
||||
|
||||
await expect(page.getByTestId("stream-list")).not.toContainText("general");
|
||||
await openChannelBrowser(page);
|
||||
await expect(page.getByTestId("browse-channel-general")).toContainText(
|
||||
"archived",
|
||||
);
|
||||
});
|
||||
|
||||
test("manage channel can archive and unarchive a stream", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await openChannelManagement(page, "general");
|
||||
|
||||
Reference in New Issue
Block a user