Keep channel management in home inbox

This commit is contained in:
Thomas Petersen
2026-06-16 15:50:41 -04:00
parent 6a2f5783e0
commit 3473efb0df
7 changed files with 100 additions and 20 deletions

View File

@@ -102,6 +102,9 @@ export function AppShell() {
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [managedChannelId, setManagedChannelId] = React.useState<string | null>(
null,
);
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [topbarSearchHidden, setTopbarSearchHidden] = React.useState(false);
const [topbarSearchLoading, setTopbarSearchLoading] = React.useState(false);
@@ -223,6 +226,12 @@ export function AppShell() {
: null,
[channels, selectedChannelId],
);
const managedChannel = React.useMemo(() => {
const targetChannelId = managedChannelId ?? selectedChannelId;
return targetChannelId
? (channels.find((channel) => channel.id === targetChannelId) ?? null)
: null;
}, [channels, managedChannelId, selectedChannelId]);
const handleThreadReplyDesktopNotification = React.useEffectEvent(
(channelId: string, event: RelayEvent) => {
@@ -665,7 +674,10 @@ export function AppShell() {
markChannelRead,
markChannelUnread,
openCreateChannel: handleOpenCreateChannel,
openChannelManagement: () => {
openChannelManagement: (channelId?: string) => {
setManagedChannelId(
typeof channelId === "string" ? channelId : null,
);
setIsChannelManagementOpen(true);
},
getChannelReadAt,
@@ -887,16 +899,22 @@ export function AppShell() {
)}
<AppShellOverlays
activeChannel={activeChannel}
activeChannel={managedChannel}
browseDialogType={browseDialogType}
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={setIsChannelManagementOpen}
onChannelManagementOpenChange={(open) => {
setIsChannelManagementOpen(open);
if (!open) {
setManagedChannelId(null);
}
}}
onDeleteActiveChannel={() => {
setIsChannelManagementOpen(false);
setManagedChannelId(null);
void goHome({ replace: true });
}}
onSelectChannel={(channelId) => {

View File

@@ -11,7 +11,7 @@ type AppShellContextValue = {
) => void;
markChannelUnread: (channelId: string) => void;
openCreateChannel: () => void;
openChannelManagement: () => void;
openChannelManagement: (channelId?: string) => 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.

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";
import { useAppShell } from "@/app/AppShellContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { HomeScreen } from "@/features/home/ui/HomeScreen";
@@ -16,6 +17,7 @@ export const Route = createFileRoute("/")({
function HomeRouteComponent() {
const { goChannel } = useAppNavigation();
const { openChannelManagement } = useAppShell();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
const channels = channelsQuery.data ?? [];
@@ -65,12 +67,7 @@ function HomeRouteComponent() {
<HomeScreen
availableChannelIds={availableChannelIds}
currentPubkey={identityQuery.data?.pubkey}
onOpenChannel={(channelId) => {
void goChannel(channelId);
}}
onOpenContext={(channelId, messageId) => {
void goChannel(channelId, { messageId });
}}
onOpenChannel={openChannelManagement}
/>
);
}

View File

@@ -14,14 +14,12 @@ type HomeScreenProps = {
availableChannelIds: ReadonlySet<string>;
currentPubkey?: string;
onOpenChannel: (channelId: string) => void;
onOpenContext: (channelId: string, messageId: string) => void;
};
export function HomeScreen({
availableChannelIds,
currentPubkey,
onOpenChannel,
onOpenContext,
}: HomeScreenProps) {
const homeFeedQuery = useHomeFeedQuery();
const { threadActivityItems } = useAppShell();
@@ -73,7 +71,6 @@ export function HomeScreen({
feed={augmentedFeed}
isLoading={homeFeedQuery.isLoading}
onOpenChannel={onOpenChannel}
onOpenContext={onOpenContext}
onRefresh={() => {
void homeFeedQuery.refetch();
}}

View File

@@ -53,7 +53,6 @@ type HomeViewProps = {
currentPubkey?: string;
availableChannelIds: ReadonlySet<string>;
onOpenChannel: (channelId: string) => void;
onOpenContext: (channelId: string, messageId: string) => void;
onRefresh: () => void;
};
@@ -64,7 +63,6 @@ export function HomeView({
currentPubkey,
availableChannelIds,
onOpenChannel,
onOpenContext,
onRefresh,
}: HomeViewProps) {
const [homeInboxRef, homeInboxWidthPx] = useElementWidth<HTMLDivElement>();
@@ -433,7 +431,6 @@ export function HomeView({
});
}}
onOpenChannel={onOpenChannel}
onOpenContext={onOpenContext}
onSendReply={async ({
content,
mediaTags,

View File

@@ -60,7 +60,6 @@ type InboxDetailPaneProps = {
onBack?: () => void;
onDelete: () => void;
onOpenChannel: (channelId: string) => void;
onOpenContext?: (channelId: string, messageId: string) => void;
onSendReply: (input: {
content: string;
mediaTags?: string[][];
@@ -94,7 +93,6 @@ export function InboxDetailPane({
onBack,
onDelete,
onOpenChannel,
onOpenContext,
onSendReply,
onToggleReaction,
onToggleDone,
@@ -256,10 +254,10 @@ export function InboxDetailPane({
</Button>
) : null}
<div className="min-w-0">
{canOpenChannel && contextChannelId && onOpenContext ? (
{canOpenChannel && contextChannelId ? (
<button
className="flex min-w-0 items-center gap-[4px] text-left text-sm font-semibold leading-5 tracking-tight text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
onClick={() => onOpenContext(contextChannelId, item.id)}
onClick={() => onOpenChannel(contextChannelId)}
title={item.fullTimestampLabel}
type="button"
>

View File

@@ -3,8 +3,23 @@ import { expect, test } from "@playwright/test";
import { KIND_TYPING_INDICATOR } from "../../src/shared/constants/kinds";
import { TEST_IDENTITIES, installMockBridge } from "../helpers/bridge";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
type MockFeedWindow = Window & {
__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: {
category: "mention" | "needs_action" | "activity" | "agent_activity";
channel_id: string | null;
channel_name: string;
content: string;
created_at: number;
id: string;
kind: number;
pubkey: string;
tags: string[][];
}) => unknown;
};
async function openChannelManagement(
page: import("@playwright/test").Page,
channelName: string,
@@ -1242,6 +1257,64 @@ test("manage channel keeps canvas near the top of the sheet", async ({
expect(canvasBox?.y).toBeLessThan(nameBox?.y);
});
test("home inbox channel label opens management without leaving home", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await page.waitForFunction(
() =>
typeof (window as MockFeedWindow).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ ===
"function",
);
await page.evaluate(
({ channelId, createdAt, currentPubkey, senderPubkey }) => {
const pushFeedItem = (window as MockFeedWindow)
.__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__;
if (!pushFeedItem) {
throw new Error("Mock feed injection helper is not installed.");
}
pushFeedItem({
id: "mock-feed-home-channel-panel",
kind: 9,
pubkey: senderPubkey,
content: "Please review the home panel routing.",
created_at: createdAt,
channel_id: channelId,
channel_name: "general",
tags: [
["e", channelId],
["p", currentPubkey],
],
category: "mention",
});
},
{
channelId: GENERAL_CHANNEL_ID,
createdAt: Math.floor(Date.now() / 1000),
currentPubkey: TEST_IDENTITIES.tyler.pubkey,
senderPubkey: TEST_IDENTITIES.alice.pubkey,
},
);
await page
.getByTestId("home-inbox-item-mock-feed-home-channel-panel")
.click();
await page
.getByTestId("home-inbox-detail")
.getByRole("button", { exact: true, name: "general" })
.click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await expect(page.getByTestId("channel-management-name-row")).toContainText(
"general",
);
await expect(page.getByTestId("home-inbox-list")).toBeVisible();
await expect(page).not.toHaveURL(/#\/channels\//);
});
test("members sidebar can invite and remove members", async ({ page }) => {
await page.goto("/");
await openMembersSidebar(page, "general");