Align channel management panel with profile (#1066)

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
thomaspblock
2026-06-24 19:28:35 -07:00
committed by GitHub
co-authored by Cursor npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w Taylor Ho npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent 6284454298
commit 9f35b01880
21 changed files with 2016 additions and 927 deletions
+29 -41
View File
@@ -1,12 +1,8 @@
import * as React from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useQueryClient } from "@tanstack/react-query";
import { Outlet, useLocation } from "@tanstack/react-router";
import {
deriveShellRoute,
isWindowDragHandleEvent,
} from "@/app/AppShell.helpers";
import { deriveShellRoute } from "@/app/AppShell.helpers";
import { AppShellProvider } from "@/app/AppShellContext";
import {
AppShellOverlays,
@@ -20,6 +16,7 @@ import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts";
import { useSettingsShortcuts } from "@/app/useSettingsShortcuts";
import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications";
import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems";
import { useTauriWindowDrag } from "@/app/useTauriWindowDrag";
import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts";
import {
channelsQueryKey,
@@ -88,10 +85,15 @@ const LazySettingsScreen = React.lazy(async () => {
export function AppShell() {
useWebviewZoomShortcuts();
useTauriWindowDrag();
const workspacesHook = useWorkspaces();
const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [managedChannelId, setManagedChannelId] = React.useState<string | null>(
null,
);
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
@@ -118,9 +120,7 @@ export function AppShell() {
() => deriveShellRoute(location.pathname),
[location.pathname],
);
// Settings lives in the history stack: /settings?section=… opens it, back
// (or "Back to app") returns to the previous entry — panels and all — and
// reloads restore the open section from the URL.
// Settings lives in history so back returns to the previous app entry.
const settingsOpen = location.pathname === "/settings";
const locationSearchSection = (location.search as { section?: unknown })
.section;
@@ -187,6 +187,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 {
handleChannelNotification,
@@ -289,6 +295,7 @@ export function AppShell() {
channels,
);
// Badge count consumes the shared NIP-RS read-state from useUnreadChannels.
const { homeBadgeCount, homeBadgeCountExcludingHighPriority } =
useHomeFeedNotificationState(
homeFeedQuery.data,
@@ -554,36 +561,6 @@ export function AppShell() {
selectedView,
});
React.useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (event.button !== 0 || event.detail > 1) {
return;
}
if (!isWindowDragHandleEvent(event)) {
return;
}
void getCurrentWindow().startDragging();
}
function handleDoubleClick(event: MouseEvent) {
if (event.button !== 0 || !isWindowDragHandleEvent(event)) {
return;
}
event.preventDefault();
void getCurrentWindow().toggleMaximize();
}
window.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("dblclick", handleDoubleClick, true);
return () => {
window.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("dblclick", handleDoubleClick, true);
};
}, []);
return (
<PreventSleepProvider>
<ChannelNavigationProvider channels={channels}>
@@ -593,7 +570,12 @@ export function AppShell() {
markChannelRead,
markChannelUnread,
openCreateChannel: handleOpenCreateChannel,
openChannelManagement: () => setIsChannelManagementOpen(true),
openChannelManagement: (channelId?: string) => {
setManagedChannelId(
typeof channelId === "string" ? channelId : null,
);
setIsChannelManagementOpen(true);
},
getChannelReadAt,
getThreadReadAt,
markThreadRead,
@@ -827,16 +809,22 @@ export function AppShell() {
</div>
)}
<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) => {
+1 -1
View File
@@ -15,7 +15,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.
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { isWindowDragHandleEvent } from "@/app/AppShell.helpers";
export function useTauriWindowDrag() {
React.useEffect(() => {
function handlePointerDown(event: PointerEvent) {
if (
event.button !== 0 ||
event.detail > 1 ||
!isWindowDragHandleEvent(event)
) {
return;
}
void getCurrentWindow().startDragging();
}
function handleDoubleClick(event: MouseEvent) {
if (event.button !== 0 || !isWindowDragHandleEvent(event)) {
return;
}
event.preventDefault();
void getCurrentWindow().toggleMaximize();
}
window.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("dblclick", handleDoubleClick, true);
return () => {
window.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("dblclick", handleDoubleClick, true);
};
}, []);
}
@@ -56,7 +56,7 @@ export function ChannelCanvas({
}
if (canvasQuery.isLoading) {
return <p className="text-sm text-muted-foreground">Loading canvas</p>;
return <p className="text-sm text-muted-foreground">Loading canvas...</p>;
}
if (canvasQuery.error instanceof Error) {
@@ -78,7 +78,7 @@ export function ChannelCanvas({
data-testid="channel-canvas-editor"
disabled={setCanvasMutation.isPending}
onChange={(event) => setDraft(event.target.value)}
placeholder="Write your canvas content in Markdown"
placeholder="Write your canvas content in Markdown..."
value={draft}
/>
<div className="flex gap-2">
@@ -94,7 +94,7 @@ export function ChannelCanvas({
type="button"
>
<Save className="h-4 w-4" />
{setCanvasMutation.isPending ? "Saving" : "Save canvas"}
{setCanvasMutation.isPending ? "Saving..." : "Save canvas"}
</Button>
<Button
data-testid="channel-canvas-cancel"
@@ -0,0 +1,64 @@
import type * as React from "react";
import type { Channel } from "@/shared/api/types";
import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
type ChannelManagementAuxiliaryPanelProps = {
activeChannel: Channel;
canResetThreadPanelWidth: boolean;
currentPubkey?: string;
isSinglePanelView: boolean;
onChannelManagementDeleted?: () => void;
onCloseChannelManagement?: () => void;
onResetThreadPanelWidth: () => void;
onThreadPanelResizeStart: (
event: React.PointerEvent<HTMLButtonElement>,
) => void;
threadPanelWidthPx: number;
useSplitAuxiliaryPane: boolean;
};
export function ChannelManagementAuxiliaryPanel({
activeChannel,
canResetThreadPanelWidth,
currentPubkey,
isSinglePanelView,
onChannelManagementDeleted,
onCloseChannelManagement,
onResetThreadPanelWidth,
onThreadPanelResizeStart,
threadPanelWidthPx,
useSplitAuxiliaryPane,
}: ChannelManagementAuxiliaryPanelProps) {
const panel = (
<ChannelManagementSheet
channel={activeChannel}
currentPubkey={currentPubkey}
layout={useSplitAuxiliaryPane || isSinglePanelView ? "split" : "overlay"}
onDeleted={onChannelManagementDeleted}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
onCloseChannelManagement?.();
}
}}
open={true}
/>
);
if (!useSplitAuxiliaryPane) {
return panel;
}
return (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="channel-management-auxiliary-pane"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
);
}
@@ -0,0 +1,171 @@
import { Archive, ArchiveRestore, Trash2 } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
type ChannelMutation<TArgs = void> = {
error: unknown;
isPending: boolean;
mutateAsync: (args: TArgs) => Promise<unknown>;
};
type ChannelManagementModerationActionsProps = {
archiveChannelMutation: ChannelMutation;
canManageChannel: boolean;
deleteChannelMutation: ChannelMutation;
handleDeleteChannel: () => Promise<void>;
handleDeleteDialogOpenChange: (open: boolean) => void;
isArchived: boolean;
isDark: boolean;
isDeleteDialogOpen: boolean;
isOwner: boolean;
resolvedChannelName: string;
unarchiveChannelMutation: ChannelMutation;
};
export function ChannelManagementModerationActions({
archiveChannelMutation,
canManageChannel,
deleteChannelMutation,
handleDeleteChannel,
handleDeleteDialogOpenChange,
isArchived,
isDark,
isDeleteDialogOpen,
isOwner,
resolvedChannelName,
unarchiveChannelMutation,
}: ChannelManagementModerationActionsProps) {
return (
<div
className={cn(
"absolute bottom-3 right-3 z-20 flex items-center gap-2 rounded-full border border-border/60 p-1 shadow-sm",
isDark
? "bg-background/80 backdrop-blur-xl supports-[backdrop-filter]:bg-background/70"
: "bg-background/90 backdrop-blur-md supports-[backdrop-filter]:bg-background/80",
)}
data-testid="channel-management-footer"
>
{isArchived ? (
<Button
aria-label={
unarchiveChannelMutation.isPending
? "Restoring channel"
: "Unarchive channel"
}
data-testid="channel-management-unarchive"
disabled={!canManageChannel || unarchiveChannelMutation.isPending}
onClick={() => {
void unarchiveChannelMutation.mutateAsync();
}}
size="icon"
title={
unarchiveChannelMutation.isPending
? "Restoring channel"
: "Unarchive channel"
}
type="button"
variant="ghost"
>
<ArchiveRestore className="h-4 w-4" />
</Button>
) : (
<Button
aria-label={
archiveChannelMutation.isPending
? "Archiving channel"
: "Archive channel"
}
data-testid="channel-management-archive"
disabled={!canManageChannel || archiveChannelMutation.isPending}
onClick={() => {
void archiveChannelMutation.mutateAsync();
}}
size="icon"
title={
archiveChannelMutation.isPending
? "Archiving channel"
: "Archive channel"
}
type="button"
variant="ghost"
>
<Archive className="h-4 w-4" />
</Button>
)}
{isOwner ? (
<AlertDialog
onOpenChange={handleDeleteDialogOpenChange}
open={isDeleteDialogOpen}
>
<AlertDialogTrigger asChild>
<Button
aria-label="Delete channel"
data-testid="channel-management-delete"
disabled={deleteChannelMutation.isPending}
size="icon"
title="Delete channel"
type="button"
variant="ghost"
>
<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 workspace 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>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,304 @@
import {
ChevronRight,
Copy,
FileText,
Hash,
MessageSquare,
type LucideIcon,
} from "lucide-react";
import type * as React from "react";
import { toast } from "sonner";
import type { Channel } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Switch } from "@/shared/ui/switch";
function getChannelIcon(channelType: Channel["channelType"]): LucideIcon {
if (channelType === "forum") {
return FileText;
}
if (channelType === "dm") {
return MessageSquare;
}
return Hash;
}
export function ChannelHero({ channel }: { channel: Channel }) {
const Icon = getChannelIcon(channel.channelType);
return (
<div className="flex flex-col items-center gap-3 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Icon className="h-8 w-8" />
</div>
<div className="flex max-w-full flex-col items-center">
<h3 className="max-w-full truncate text-xl font-semibold tracking-tight">
{channel.name}
</h3>
</div>
</div>
);
}
export function ChannelQuickAction({
active,
disabled,
icon: Icon,
label,
onClick,
testId,
}: {
active?: boolean;
disabled?: boolean;
icon: LucideIcon;
label: string;
onClick: () => void;
testId?: string;
}) {
return (
<button
className="flex w-16 flex-col items-center gap-2 disabled:cursor-not-allowed disabled:opacity-50"
data-testid={testId}
disabled={disabled}
onClick={onClick}
type="button"
>
<span
className={cn(
"flex h-14 w-14 items-center justify-center rounded-full transition-colors",
active
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted/60 text-foreground hover:bg-muted/80",
)}
>
<Icon className="h-5 w-5" />
</span>
<span
className={cn(
"max-w-full truncate text-xs",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{label}
</span>
</button>
);
}
export function FieldGroup({ children }: { children: React.ReactNode }) {
return (
<div className="overflow-hidden rounded-2xl bg-muted/20">{children}</div>
);
}
export function getMarkdownPreviewText(content: string) {
return content
.split("\n")
.map((line) =>
line
.trim()
.replace(/^#{1,6}\s+/, "")
.replace(/^>\s?/, "")
.replace(/^[-*+]\s+\[[ xX]\]\s+/, "")
.replace(/^[-*+]\s+/, "")
.replace(/^\d+\.\s+/, "")
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/(\*\*|__)(.*?)\1/g, "$2")
.replace(/(\*|_)(.*?)\1/g, "$2")
.replace(/~~(.*?)~~/g, "$1")
.trim(),
)
.filter(Boolean)
.join(" ");
}
export function CopyFieldRow({
icon: Icon,
label,
value,
testId,
}: {
icon: LucideIcon;
label: string;
value: string;
testId?: string;
}) {
async function handleCopy() {
await navigator.clipboard.writeText(value);
toast.success(`Copied ${label.toLowerCase()}`);
}
return (
<button
aria-label={`Copy ${label}`}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40"
data-testid={testId}
onClick={() => {
void handleCopy();
}}
title={`Copy ${label}`}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium text-foreground">
{label}
</span>
<span className="mt-0.5 block truncate font-mono text-sm text-muted-foreground">
{value}
</span>
</span>
<Copy className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
);
}
export function InfoFieldRow({
icon: Icon,
label,
value,
testId,
}: {
icon: LucideIcon;
label: string;
value: string;
testId?: string;
}) {
return (
<div
className="flex w-full items-center gap-3 px-4 py-3"
data-testid={testId}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block text-xs font-medium text-foreground">
{label}
</span>
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
{value}
</span>
</span>
</div>
);
}
export function NarrativeGroup({ children }: { children: React.ReactNode }) {
return (
<div className="overflow-hidden rounded-2xl bg-muted/20">{children}</div>
);
}
export function NarrativeField({
icon: Icon,
label,
value,
testId,
}: {
icon: LucideIcon;
label: string;
value: string;
testId: string;
}) {
return (
<div
className="flex w-full items-start gap-3 px-4 py-3"
data-testid={testId}
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium text-foreground">
{label}
</span>
<span className="mt-1 block whitespace-pre-wrap text-sm leading-6 text-muted-foreground">
{value}
</span>
</span>
</div>
);
}
export function IngressRow({
description,
icon: Icon,
label,
onClick,
testId,
trailing,
}: {
description?: string;
icon: LucideIcon;
label: string;
onClick: () => void;
testId: string;
trailing?: string;
}) {
return (
<button
className="flex w-full items-center gap-3 rounded-2xl bg-muted/20 px-4 py-2 text-left transition-colors hover:bg-muted/40"
data-testid={testId}
onClick={onClick}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{label}
</span>
{description ? (
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
{trailing ? (
<span className="text-sm text-muted-foreground">{trailing}</span>
) : null}
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
);
}
export function ToggleRow({
checked,
description,
disabled,
label,
onCheckedChange,
testId,
}: {
checked: boolean;
description: string;
disabled?: boolean;
label: string;
onCheckedChange: (checked: boolean) => void;
testId: string;
}) {
return (
<div className="flex items-center gap-3 px-4 py-3">
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-foreground">
{label}
</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">
{description}
</span>
</span>
<Switch
checked={checked}
data-testid={testId}
disabled={disabled}
onCheckedChange={onCheckedChange}
/>
</div>
);
}
+188 -183
View File
@@ -1,6 +1,5 @@
import * as React from "react";
import { Bot, Hash, LogIn, Plus, Sparkles, UserPlus } from "lucide-react";
import { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
@@ -27,6 +26,7 @@ import {
} from "@/features/profile/ui/UserProfilePanel";
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import {
BotActivityComposerAction,
@@ -63,7 +63,6 @@ import type { Channel } from "@/shared/api/types";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
import { channelChrome } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
type ChannelPaneProps = {
activeChannel: Channel | null;
activityAgents?: BotActivityAgent[];
@@ -71,6 +70,7 @@ type ChannelPaneProps = {
agentSessionAgents: ChannelAgentSessionAgent[];
botTypingEntries: TypingIndicatorEntry[];
channelFind: ReturnType<typeof useChannelFind>;
channelManagementOpen?: boolean;
currentPubkey?: string;
editTarget?: {
author: string;
@@ -87,14 +87,14 @@ type ChannelPaneProps = {
isSending: boolean;
isTimelineLoading: boolean;
messages: TimelineMessage[];
/** Event id of the oldest unread top-level message at channel open, or null. */
firstUnreadMessageId?: string | null;
/** Count of unread top-level messages at channel open. */
unreadCount?: number;
canResetThreadPanelWidth: boolean;
onCancelEdit?: () => void;
onCancelThreadReply: () => void;
onCloseAgentSession: () => void;
onCloseChannelManagement?: () => void;
onChannelManagementDeleted?: () => void;
onCloseProfilePanel: () => void;
onAddAgent?: () => void;
onCreateChannel?: () => void;
@@ -140,7 +140,6 @@ type ChannelPaneProps = {
onThreadPanelResizeStart: (
event: React.PointerEvent<HTMLButtonElement>,
) => void;
/** Map from lowercase pubkey → persona display name for bot members. */
personaLookup?: Map<string, string>;
profiles?: UserProfileLookup;
openThreadHeadId: string | null;
@@ -158,11 +157,8 @@ type ChannelPaneProps = {
threadTypingPubkeys: string[];
threadReplyTargetMessage: TimelineMessage | null;
threadScrollTargetId: string | null;
/** Per-thread unread counts keyed by thread root id. */
threadUnreadCounts?: ReadonlyMap<string, number>;
/** Subtree unread counts for in-panel summary rows, keyed by reply id. */
threadReplyUnreadCounts?: ReadonlyMap<string, number>;
/** Event id of the first unread reply in the open thread panel. */
threadFirstUnreadReplyId?: string | null;
targetMessageId: string | null;
typingPubkeys: string[];
@@ -174,7 +170,6 @@ type ChannelPaneProps = {
isFollowingThreadById?: (rootId: string) => boolean;
isMessageUnreadById?: (messageId: string) => boolean;
};
export const ChannelPane = React.memo(function ChannelPane({
activeChannel,
agentPubkeys,
@@ -182,6 +177,7 @@ export const ChannelPane = React.memo(function ChannelPane({
activityAgents = agentSessionAgents,
botTypingEntries,
channelFind,
channelManagementOpen = false,
currentPubkey,
editTarget = null,
fetchOlder,
@@ -203,6 +199,8 @@ export const ChannelPane = React.memo(function ChannelPane({
onCancelEdit,
onCancelThreadReply,
onCloseAgentSession,
onCloseChannelManagement,
onChannelManagementDeleted,
onCloseProfilePanel,
onAddAgent,
onCreateChannel,
@@ -274,13 +272,11 @@ export const ChannelPane = React.memo(function ChannelPane({
composerWrapperRef,
`${activeChannelId}:${isSinglePanelView}:${hasMainComposerOverlay}`,
);
const clearWelcomeComposerDismissTimer = React.useCallback(() => {
if (welcomeComposerDismissTimerRef.current !== null) {
window.clearTimeout(welcomeComposerDismissTimerRef.current);
welcomeComposerDismissTimerRef.current = null;
}
if (welcomeComposerHideTimerRef.current !== null) {
window.clearTimeout(welcomeComposerHideTimerRef.current);
welcomeComposerHideTimerRef.current = null;
@@ -812,180 +808,189 @@ export const ChannelPane = React.memo(function ChannelPane({
</section>
) : null}
{threadHeadMessage
? (() => {
const panel = (
<MessageThreadPanel
agentPubkeys={agentPubkeys}
channel={activeChannel}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
isFollowingThread={isFollowingThread}
isMessageUnreadById={isMessageUnreadById}
isSending={isSending}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
onDelete={onDelete}
onEdit={onEdit}
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
onEditSave={onEditSave}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onExpandReplies={onExpandThreadReplies}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={onSendThreadReply}
onScrollTargetResolved={onThreadScrollTargetResolved}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
profiles={profiles}
replyTargetMessage={threadReplyTargetMessage}
scrollTargetId={threadScrollTargetId}
threadHead={threadHeadMessage}
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadUnreadCount={threadUnreadCounts?.get(
threadHeadMessage.id,
)}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadTypingPubkeys={threadTypingPubkeys}
toolbarExtraActions={
hasThreadComposerBotActivity ? (
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={threadComposerBotTypingPubkeys}
variant="inline"
/>
) : null
}
/>
);
return useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="message-thread-panel"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
) : (
panel
);
})()
: shouldShowThreadSkeleton
? (() => {
const panel = (
<MessageThreadPanelSkeleton
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseThread}
widthPx={threadPanelWidthPx}
/>
);
return useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="message-thread-panel"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
) : (
panel
);
})()
: activeChannel && selectedAgent
? (() => {
const panel = (
<AgentSessionThreadPanel
agent={selectedAgent}
canInterruptTurn={selectedAgent.canInterruptTurn}
channel={activeChannel}
isWorking={botTypingEntries.some(
(entry) =>
entry.pubkey.toLowerCase() ===
selectedAgent.pubkey.toLowerCase(),
)}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
{channelManagementOpen && activeChannel ? (
<ChannelManagementAuxiliaryPanel
activeChannel={activeChannel}
canResetThreadPanelWidth={canResetThreadPanelWidth}
currentPubkey={currentPubkey}
isSinglePanelView={isSinglePanelView}
onChannelManagementDeleted={onChannelManagementDeleted}
onCloseChannelManagement={onCloseChannelManagement}
onResetThreadPanelWidth={onResetThreadPanelWidth}
onThreadPanelResizeStart={onThreadPanelResizeStart}
threadPanelWidthPx={threadPanelWidthPx}
useSplitAuxiliaryPane={useSplitAuxiliaryPane}
/>
) : threadHeadMessage ? (
(() => {
const panel = (
<MessageThreadPanel
agentPubkeys={agentPubkeys}
channel={activeChannel}
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
currentPubkey={currentPubkey}
disabled={isComposerDisabled}
editTarget={threadEditTarget}
firstUnreadReplyId={threadFirstUnreadReplyId}
isFollowingThread={isFollowingThread}
isMessageUnreadById={isMessageUnreadById}
isSending={isSending}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelThreadReply}
onClose={onCloseThread}
onDelete={onDelete}
onEdit={onEdit}
onEditLastOwnMessage={handleEditLastOwnThreadMessage}
onEditSave={onEditSave}
onFollowThread={onFollowThread}
onMarkUnread={onMarkUnread}
onMarkRead={onMarkRead}
onExpandReplies={onExpandThreadReplies}
onSelectReplyTarget={onSelectThreadReplyTarget}
onSend={onSendThreadReply}
onScrollTargetResolved={onThreadScrollTargetResolved}
onToggleReaction={onToggleReaction}
onUnfollowThread={onUnfollowThread}
profiles={profiles}
replyTargetMessage={threadReplyTargetMessage}
scrollTargetId={threadScrollTargetId}
threadHead={threadHeadMessage}
threadHeadVideoReviewContext={threadHeadVideoReviewContext}
widthPx={threadPanelWidthPx}
threadReplies={threadMessages}
threadUnreadCount={threadUnreadCounts?.get(threadHeadMessage.id)}
threadReplyUnreadCounts={threadReplyUnreadCounts}
threadTypingPubkeys={threadTypingPubkeys}
toolbarExtraActions={
hasThreadComposerBotActivity ? (
<BotActivityComposerAction
agents={activityAgents}
channelId={activeChannel?.id ?? null}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
onBackToProfile={() =>
onOpenProfilePanel(selectedAgent.pubkey)
}
onClose={onCloseAgentSession}
widthPx={threadPanelWidthPx}
typingBotPubkeys={threadComposerBotTypingPubkeys}
variant="inline"
/>
);
return useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="agent-session-thread-panel"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
) : (
panel
);
})()
: profilePanelPubkey
? (() => {
const panel = (
<UserProfilePanel
currentPubkey={currentPubkey}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
onOpenProfile={onOpenProfilePanel}
onViewChange={onProfilePanelViewChange}
pubkey={profilePanelPubkey}
splitPaneClamp
view={profilePanelView}
widthPx={threadPanelWidthPx}
/>
);
return useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="user-profile-panel"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
) : (
panel
);
})()
: null}
) : null
}
/>
);
return useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="message-thread-panel"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
) : (
panel
);
})()
) : shouldShowThreadSkeleton ? (
(() => {
const panel = (
<MessageThreadPanelSkeleton
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseThread}
widthPx={threadPanelWidthPx}
/>
);
return useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="message-thread-panel"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
) : (
panel
);
})()
) : activeChannel && selectedAgent ? (
(() => {
const panel = (
<AgentSessionThreadPanel
agent={selectedAgent}
canInterruptTurn={selectedAgent.canInterruptTurn}
channel={activeChannel}
isWorking={botTypingEntries.some(
(entry) =>
entry.pubkey.toLowerCase() ===
selectedAgent.pubkey.toLowerCase(),
)}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
profiles={profiles}
onBackToProfile={() => onOpenProfilePanel(selectedAgent.pubkey)}
onClose={onCloseAgentSession}
widthPx={threadPanelWidthPx}
/>
);
return useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="agent-session-thread-panel"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
) : (
panel
);
})()
) : profilePanelPubkey ? (
(() => {
const panel = (
<UserProfilePanel
currentPubkey={currentPubkey}
isSinglePanelView={
useSplitAuxiliaryPane ? false : isSinglePanelView
}
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
onClose={onCloseProfilePanel}
onOpenDm={onOpenDm}
onOpenProfile={onOpenProfilePanel}
onViewChange={onProfilePanelViewChange}
pubkey={profilePanelPubkey}
splitPaneClamp
view={profilePanelView}
widthPx={threadPanelWidthPx}
/>
);
return useSplitAuxiliaryPane ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}
testId="user-profile-panel"
widthPx={threadPanelWidthPx}
>
{panel}
</RightAuxiliaryPane>
) : (
panel
);
})()
) : null}
</div>
);
});
@@ -1,5 +1,6 @@
import * as React from "react";
import { useAppShell } from "@/app/AppShellContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers";
import {
@@ -87,6 +88,7 @@ export function ChannelScreen({
targetMessageEvents,
targetMessageId,
}: ChannelScreenProps) {
const { goHome } = useAppNavigation();
const {
markChannelRead,
markChannelUnread,
@@ -95,7 +97,7 @@ export function ChannelScreen({
markMessageRead,
setContextParentResolver,
openCreateChannel,
openChannelManagement,
openChannelManagement: openGlobalChannelManagement,
followThread,
unfollowThread,
isFollowingThread,
@@ -104,11 +106,13 @@ export function ChannelScreen({
readStateVersion,
} = useAppShell();
const {
channelManagementOpen,
clearMessageRouteTarget,
openAgentSessionPubkey,
openThreadHeadId,
profilePanelPubkey,
profilePanelView,
setChannelManagementOpen,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -172,14 +176,8 @@ export function ChannelScreen({
useChannelSubscription(activeChannel);
const { fetchOlder, hasOlderMessages, isFetchingOlder } =
useFetchOlderMessages(activeChannel);
// Newest TOP-LEVEL message only. The channel read-marker must clear the
// channel timeline without clearing its threads (NIP-RS Option 1): thread
// replies are kind-9 channel events, so taking the last message outright
// would advance the channel frontier past unread replies and the hierarchical
// effective(thread) = max(thread, channel) would silently clear every thread
// badge on channel entry. Scanning from the end for the last message with no
// reply tag keeps the frontier at the last top-level message, leaving thread
// badges intact until the thread itself is read.
// Newest top-level message only: opening a channel should clear the timeline
// without clearing unread thread replies.
const latestActiveMessage = React.useMemo(() => {
const messages = messagesQuery.data;
if (!messages) return null;
@@ -190,12 +188,8 @@ export function ChannelScreen({
}
return null;
}, [messagesQuery.data]);
// No `lastMessageAt` fallback: that timestamp is reply-inclusive (the backend
// takes MAX(created_at) over kind-9 events without a parent filter), so using
// it when the window has no top-level message would advance the channel
// marker past an unread reply and clear its thread unread. null suppresses
// the marker advance (markChannelRead early-returns on markAt === null) until
// a real top-level position is known.
// No `lastMessageAt` fallback: it is reply-inclusive and would clear unread
// thread/sidebar state before a real top-level position is known.
const activeReadAt = latestActiveMessage
? new Date(latestActiveMessage.created_at * 1_000).toISOString()
: null;
@@ -513,6 +507,7 @@ export function ChannelScreen({
handleOpenThread,
managedAgents: activeChannelAgentSessionAgents,
openAgentSessionPubkey,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
@@ -523,6 +518,7 @@ export function ChannelScreen({
const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } =
useChannelProfilePanel({
closeAgentSession: handleCloseAgentSession,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -628,7 +624,10 @@ export function ChannelScreen({
useLoadMissingAncestors(activeChannel, resolvedMessages);
const hasAuxiliaryPanel = Boolean(
effectiveOpenThreadHeadId || openAgentSessionPubkey || profilePanelPubkey,
effectiveOpenThreadHeadId ||
openAgentSessionPubkey ||
profilePanelPubkey ||
channelManagementOpen,
);
const displayedThreadHeadMessage =
openThreadHeadMessage?.id === effectiveOpenThreadHeadId
@@ -679,7 +678,25 @@ export function ChannelScreen({
isJoining={joinChannelMutation.isPending}
onAddBotOpenChange={setIsAddBotOpen}
onJoinChannel={joinChannelMutation.mutateAsync}
onManageChannel={openChannelManagement}
onManageChannel={() => {
if (activeChannel?.channelType === "forum") {
openGlobalChannelManagement();
return;
}
if (channelManagementOpen) {
setChannelManagementOpen(false);
return;
}
setOpenThreadHeadId(null);
setExpandedThreadReplyIds(new Set());
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
handleCloseAgentSession();
setProfilePanelPubkey(null);
setChannelManagementOpen(true);
}}
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
showHeaderContent={!isSinglePanelView}
/>
@@ -717,6 +734,7 @@ export function ChannelScreen({
agentSessionAgents={channelAgentSessionAgents}
botTypingEntries={botTypingEntries}
channelFind={channelFind}
channelManagementOpen={channelManagementOpen}
currentPubkey={currentPubkey}
canResetThreadPanelWidth={canResetThreadPanelWidth}
fetchOlder={fetchOlder}
@@ -749,6 +767,10 @@ export function ChannelScreen({
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelThreadReply={handleCancelThreadReply}
onChannelManagementDeleted={() => {
setChannelManagementOpen(false);
void goHome({ replace: true });
}}
onFollowThread={
effectiveOpenThreadHeadId != null &&
!isNotifiedForEffectiveThread
@@ -762,6 +784,9 @@ export function ChannelScreen({
: undefined
}
onCloseAgentSession={handleCloseAgentSession}
onCloseChannelManagement={() =>
setChannelManagementOpen(false)
}
onCloseThread={handleCloseThread}
onDelete={
activeChannel?.archivedAt ? undefined : handleDelete
@@ -5,6 +5,7 @@ import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
type RightAuxiliaryPaneProps = {
canResetWidth: boolean;
children: React.ReactNode;
constrainToAvailableSpace?: boolean;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
testId?: string;
@@ -14,6 +15,7 @@ type RightAuxiliaryPaneProps = {
export function RightAuxiliaryPane({
canResetWidth,
children,
constrainToAvailableSpace = true,
onResetWidth,
onResizeStart,
testId,
@@ -24,7 +26,9 @@ export function RightAuxiliaryPane({
className="group/right-pane relative flex h-full shrink-0 flex-col overflow-hidden bg-background before:pointer-events-none before:absolute before:bottom-0 before:left-0 before:top-0 before:z-40 before:w-px before:bg-border/80 before:content-['']"
data-testid={testId}
style={{
maxWidth: `calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px)`,
maxWidth: constrainToAvailableSpace
? `calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px)`
: undefined,
width: widthPx,
}}
>
@@ -28,6 +28,7 @@ type UseChannelAgentSessionsOptions = {
handleOpenThread: (message: TimelineMessage) => void;
managedAgents: ChannelAgentSessionAgent[];
openAgentSessionPubkey: string | null;
setChannelManagementOpen: (open: boolean) => void;
setExpandedThreadReplyIds: (value: Set<string>) => void;
setOpenAgentSessionPubkey: PanelValueSetter;
setOpenThreadHeadId: (value: string | null) => void;
@@ -159,6 +160,7 @@ export function useChannelAgentSessions({
handleOpenThread,
managedAgents,
openAgentSessionPubkey,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
@@ -188,9 +190,11 @@ export function useChannelAgentSessions({
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
setProfilePanelPubkey(null);
setChannelManagementOpen(false);
setOpenAgentSessionPubkey(pubkey);
},
[
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
@@ -211,9 +215,15 @@ export function useChannelAgentSessions({
(message: TimelineMessage) => {
setOpenAgentSessionPubkey(null);
setProfilePanelPubkey(null);
setChannelManagementOpen(false);
handleOpenThread(message);
},
[handleOpenThread, setOpenAgentSessionPubkey, setProfilePanelPubkey],
[
handleOpenThread,
setChannelManagementOpen,
setOpenAgentSessionPubkey,
setProfilePanelPubkey,
],
);
React.useEffect(() => {
@@ -13,7 +13,8 @@ import {
*
* Params: `thread` (open thread head id), `profile` (profile panel pubkey),
* `profileView` (profile panel sub-view), `agentSession` (agent session
* panel pubkey).
* panel pubkey), `channelManagement` (presence flag for the channel-management
* panel open/closed only, so it carries a sentinel `"1"` rather than an id).
*/
export type PanelSetterOptions = HistorySearchSetterOptions;
@@ -25,6 +26,7 @@ export type PanelValueSetter = (
const CHANNEL_SEARCH_KEYS = [
"agentSession",
"channelManagement",
"messageId",
"profile",
"profileView",
@@ -32,6 +34,8 @@ const CHANNEL_SEARCH_KEYS = [
"threadRootId",
] as const;
const CHANNEL_MANAGEMENT_OPEN_VALUE = "1";
function asProfilePanelView(value: string | null): ProfilePanelView {
return value === "memories" || value === "channels" ? value : "summary";
}
@@ -63,6 +67,15 @@ export function useChannelPanelHistoryState() {
[applyPatch],
);
const setChannelManagementOpen = React.useCallback(
(open: boolean, options?: PanelSetterOptions) =>
applyPatch(
{ channelManagement: open ? CHANNEL_MANAGEMENT_OPEN_VALUE : null },
options,
),
[applyPatch],
);
const clearMessageRouteTarget = React.useCallback(
(options?: PanelSetterOptions) =>
applyPatch({ messageId: null, threadRootId: null }, options),
@@ -70,11 +83,13 @@ export function useChannelPanelHistoryState() {
);
return {
channelManagementOpen: values.channelManagement != null,
clearMessageRouteTarget,
openAgentSessionPubkey: values.agentSession,
openThreadHeadId: values.thread,
profilePanelPubkey: values.profile,
profilePanelView: asProfilePanelView(values.profileView),
setChannelManagementOpen,
setOpenAgentSessionPubkey,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -5,6 +5,7 @@ import { useOpenDmMutation } from "@/features/channels/hooks";
type UseChannelProfilePanelOptions = {
closeAgentSession: () => void;
setChannelManagementOpen: (open: boolean) => void;
setExpandedThreadReplyIds: (value: Set<string>) => void;
setOpenThreadHeadId: (value: string | null) => void;
setProfilePanelPubkey: (value: string | null) => void;
@@ -14,6 +15,7 @@ type UseChannelProfilePanelOptions = {
export function useChannelProfilePanel({
closeAgentSession,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setProfilePanelPubkey,
@@ -30,10 +32,12 @@ export function useChannelProfilePanel({
setThreadScrollTargetId(null);
setThreadReplyTargetId(null);
closeAgentSession();
setChannelManagementOpen(false);
setProfilePanelPubkey(pubkey);
},
[
closeAgentSession,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenThreadHeadId,
setProfilePanelPubkey,
+72 -11
View File
@@ -3,6 +3,8 @@ import { RefreshCcw } from "lucide-react";
import { useAppShell } from "@/app/AppShellContext";
import { useChannelsQuery } from "@/features/channels/hooks";
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet";
import {
type InboxFilter,
type InboxContextMessage,
@@ -49,6 +51,10 @@ import { topChromeInset } from "@/shared/layout/chromeLayout";
import { cn } from "@/shared/lib/cn";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { useElementWidth } from "@/shared/hooks/use-mobile";
import {
THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
useThreadPanelWidth,
} from "@/shared/hooks/useThreadPanelWidth";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { Button } from "@/shared/ui/button";
@@ -112,10 +118,19 @@ export function HomeView({
);
const [isDeletingMessage, setIsDeletingMessage] = React.useState(false);
const [isSendingReply, setIsSendingReply] = React.useState(false);
const [managedChannelId, setManagedChannelId] = React.useState<string | null>(
null,
);
const { activeReminderEventIds, openReminder } = useRemindLater();
const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState<
Record<string, InboxReply[]>
>({});
const {
canReset: canResetThreadPanelWidth,
onResetWidth: handleThreadPanelWidthReset,
onResizeStart: handleThreadPanelResizeStart,
widthPx: threadPanelWidthPx,
} = useThreadPanelWidth();
const {
canResetInboxListWidth,
handleInboxListResizeStart,
@@ -160,6 +175,15 @@ export function HomeView({
null
);
}, [channels, selectedChannelIdCandidate]);
const managedChannel = React.useMemo(() => {
if (!managedChannelId || !channels) return null;
return channels.find((channel) => channel.id === managedChannelId) ?? null;
}, [channels, managedChannelId]);
const isChannelManagementOpen = managedChannel !== null;
const isSinglePanelChannelManagementView =
isChannelManagementOpen &&
homeInboxWidthPx > 0 &&
homeInboxWidthPx < THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
const channelMessagesQuery = useChannelMessagesQuery(selectedChannel);
const toggleReactionMutation = useToggleReactionMutation();
@@ -376,18 +400,26 @@ export function HomeView({
currentPubkey?.trim().toLowerCase() ===
selectedItem.item.pubkey.trim().toLowerCase();
const isSinglePanelDetailView =
isNarrowHomeViewport && selectedItemId !== null;
// Reminders mode is single-pane: the reminders list renders inline row
// actions and never drives the FeedItem detail pane, so the detail column is
// not rendered at all (no empty pane on wide viewports).
const showListPane = !isSinglePanelDetailView;
isMessagesMode &&
isNarrowHomeViewport &&
selectedItemId !== null &&
!isSinglePanelChannelManagementView;
const showListPane =
!isSinglePanelDetailView && !isSinglePanelChannelManagementView;
const showDetailPane =
isMessagesMode && (!isNarrowHomeViewport || isSinglePanelDetailView);
isMessagesMode &&
!isSinglePanelChannelManagementView &&
(!isNarrowHomeViewport || isSinglePanelDetailView);
const channelManagementWidthPx = isSinglePanelChannelManagementView
? homeInboxWidthPx
: threadPanelWidthPx;
const maxEffectiveInboxListWidthPx =
homeInboxWidthPx > 0
? Math.max(
INBOX_COLUMN_MIN_WIDTH_PX,
homeInboxWidthPx - INBOX_COLUMN_MIN_WIDTH_PX,
homeInboxWidthPx -
INBOX_COLUMN_MIN_WIDTH_PX -
(isChannelManagementOpen ? channelManagementWidthPx : 0),
)
: undefined;
const effectiveInboxListWidthPx =
@@ -403,14 +435,21 @@ export function HomeView({
<div
className={cn(
"relative grid min-h-0 w-full flex-1",
showListPane && showDetailPane
? "grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)]"
: "grid-cols-1",
isSinglePanelChannelManagementView
? "grid-cols-1"
: showListPane && showDetailPane && isChannelManagementOpen
? "grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)_var(--home-channel-management-width)]"
: showListPane && showDetailPane
? "grid-cols-[var(--home-inbox-list-width)_minmax(0,1fr)]"
: isChannelManagementOpen
? "grid-cols-[minmax(0,1fr)_var(--home-channel-management-width)]"
: "grid-cols-1",
)}
data-testid="home-inbox"
ref={homeInboxRef}
style={
{
"--home-channel-management-width": `${channelManagementWidthPx}px`,
"--home-inbox-list-width": `${effectiveInboxListWidthPx}px`,
} as React.CSSProperties
}
@@ -526,7 +565,7 @@ export function HomeView({
setIsDeletingMessage(false);
});
}}
onOpenContext={onOpenContext}
onOpenChannel={setManagedChannelId}
onSendReply={async ({
content,
mediaTags,
@@ -605,6 +644,28 @@ export function HomeView({
replies={selectedItemReplies}
/>
) : null}
{isChannelManagementOpen ? (
<RightAuxiliaryPane
canResetWidth={canResetThreadPanelWidth}
constrainToAvailableSpace={false}
onResetWidth={handleThreadPanelWidthReset}
onResizeStart={handleThreadPanelResizeStart}
testId="home-channel-management-auxiliary-pane"
widthPx={channelManagementWidthPx}
>
<ChannelManagementSheet
channel={managedChannel}
currentPubkey={currentPubkey}
layout="split"
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setManagedChannelId(null);
}
}}
open={true}
/>
</RightAuxiliaryPane>
) : null}
</div>
</div>
);
@@ -12,7 +12,6 @@ import {
type InboxDisplayMessage,
InboxMessageRow,
} from "@/features/home/ui/InboxMessageRow";
import { getThreadReference } from "@/features/messages/lib/threading";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
@@ -33,11 +32,6 @@ import {
TooltipTrigger,
} from "@/shared/ui/tooltip";
const ChannelManagementSheet = React.lazy(async () => {
const module = await import("@/features/channels/ui/ChannelManagementSheet");
return { default: module.ChannelManagementSheet };
});
const MembersSidebar = React.lazy(async () => {
const module = await import("@/features/channels/ui/MembersSidebar");
return { default: module.MembersSidebar };
@@ -60,11 +54,7 @@ type InboxDetailPaneProps = {
currentPubkey?: string;
onBack?: () => void;
onDelete: () => void;
onOpenContext?: (
channelId: string,
messageId: string,
threadRootId?: string | null,
) => void;
onOpenChannel: (channelId: string) => void;
onSendReply: (input: {
content: string;
mediaTags?: string[][];
@@ -95,7 +85,7 @@ export function InboxDetailPane({
currentPubkey,
onBack,
onDelete,
onOpenContext,
onOpenChannel,
onSendReply,
onToggleReaction,
}: InboxDetailPaneProps) {
@@ -104,8 +94,6 @@ export function InboxDetailPane({
const [isFocusHighlightVisible, setIsFocusHighlightVisible] =
React.useState(true);
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const selectedItemId = item?.id ?? null;
const selectedChannelId = item?.item.channelId ?? null;
const selectedMessageScrollKey = React.useMemo(() => {
@@ -137,7 +125,6 @@ export function InboxDetailPane({
React.useEffect(() => {
void selectedChannelId;
setIsMembersSidebarOpen(false);
setIsChannelManagementOpen(false);
}, [selectedChannelId]);
React.useEffect(() => {
@@ -229,7 +216,6 @@ export function InboxDetailPane({
const contextLabel = channelContextName ?? formatInboxTypeLabel(item);
const hasChannelContext = Boolean(channelContextName);
const contextChannelId = item.item.channelId;
const contextThreadRootId = getThreadReference(item.item.tags).rootId;
const handleSelectReplyTarget = (message: InboxDisplayMessage) => {
setReplyTargetId((currentReplyTargetId) =>
@@ -267,16 +253,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,
contextThreadRootId,
)
}
onClick={() => onOpenChannel(contextChannelId)}
title={item.fullTimestampLabel}
type="button"
>
@@ -310,7 +290,11 @@ export function InboxDetailPane({
<ChannelMembersBar
channel={channel}
currentPubkey={currentPubkey}
onManageChannel={() => setIsChannelManagementOpen(true)}
onManageChannel={() => {
if (contextChannelId) {
onOpenChannel(contextChannelId);
}
}}
onToggleMembers={() =>
setIsMembersSidebarOpen((open) => !open)
}
@@ -392,13 +376,6 @@ export function InboxDetailPane({
onOpenChange={setIsMembersSidebarOpen}
open={isMembersSidebarOpen}
/>
<ChannelManagementSheet
channel={channel}
currentPubkey={currentPubkey}
onDeleted={() => setIsChannelManagementOpen(false)}
onOpenChange={setIsChannelManagementOpen}
open={isChannelManagementOpen}
/>
</React.Suspense>
) : null}
</section>
@@ -0,0 +1,43 @@
import * as React from "react";
export function useScrollBoundaryLock(
scrollRef: React.RefObject<HTMLElement | null>,
) {
React.useEffect(() => {
const scrollElement = scrollRef.current;
if (!scrollElement) return;
const handleWheel = (event: WheelEvent) => {
if (event.deltaY === 0) return;
const maxScrollTop =
scrollElement.scrollHeight - scrollElement.clientHeight;
if (maxScrollTop <= 0) {
event.preventDefault();
event.stopPropagation();
return;
}
const atTop = scrollElement.scrollTop <= 0;
const atBottom = scrollElement.scrollTop >= maxScrollTop - 1;
const scrollingPastTop = event.deltaY < 0 && atTop;
const scrollingPastBottom = event.deltaY > 0 && atBottom;
if (scrollingPastTop || scrollingPastBottom) {
event.preventDefault();
event.stopPropagation();
scrollElement.scrollTop = scrollingPastTop ? 0 : maxScrollTop;
}
};
scrollElement.addEventListener("wheel", handleWheel, {
capture: true,
passive: false,
});
return () => {
scrollElement.removeEventListener("wheel", handleWheel, {
capture: true,
});
};
}, [scrollRef]);
}
@@ -14,6 +14,13 @@ async function openManagementSheet(page: import("@playwright/test").Page) {
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
}
async function openEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
).toBeVisible();
}
async function settle(page: import("@playwright/test").Page) {
await page.evaluate(() =>
Promise.all(document.getAnimations().map((a) => a.finished)),
@@ -26,6 +33,7 @@ test.describe("channel controls screenshots", () => {
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
@@ -43,6 +51,7 @@ test.describe("channel controls screenshots", () => {
test("02 — Private toggled on", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
@@ -52,7 +61,7 @@ test.describe("channel controls screenshots", () => {
).toBeChecked();
// Save button enables once the visibility actually changed.
await expect(
page.getByTestId("channel-management-save-lifecycle"),
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await settle(page);
@@ -62,6 +71,7 @@ test.describe("channel controls screenshots", () => {
test("03 — Ephemeral on with friendly timeout field", async ({ page }) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
@@ -71,7 +81,7 @@ test.describe("channel controls screenshots", () => {
await expect(ttl).toBeVisible();
await ttl.fill("1d12h");
await expect(
page.getByTestId("channel-management-save-lifecycle"),
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await settle(page);
@@ -83,6 +93,7 @@ test.describe("channel controls screenshots", () => {
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
@@ -92,7 +103,7 @@ test.describe("channel controls screenshots", () => {
await ttl.fill("soon");
await expect(ttl).toHaveAttribute("aria-invalid", "true");
await expect(
page.getByTestId("channel-management-save-lifecycle"),
page.getByTestId("channel-management-save-changes"),
).toBeDisabled();
await settle(page);
@@ -122,30 +133,28 @@ test.describe("channel controls screenshots", () => {
await sheet.screenshot({ path: `${SHOTS}/06-management-sheet.png` });
});
test("07 — saving lifecycle leaves details save idle", async ({ page }) => {
test("07 — saving lifecycle uses unified save", async ({ page }) => {
await installMockBridge(page, { updateChannelDelayMs: 500 });
await openManagementSheet(page);
await openEditDialog(page);
const sheet = page.getByTestId("channel-management-sheet");
await page.getByTestId("channel-management-ephemeral-toggle").click();
await expect(
page.getByTestId("channel-management-save-lifecycle"),
page.getByTestId("channel-management-save-changes"),
).toBeEnabled();
await page.getByTestId("channel-management-save-lifecycle").click();
await page.getByTestId("channel-management-save-changes").click();
await expect(
page.getByTestId("channel-management-save-lifecycle"),
page.getByTestId("channel-management-save-changes"),
).toHaveText("Saving...");
await expect(
page.getByTestId("channel-management-save-details"),
).toHaveText("Save details");
await sheet.screenshot({
path: `${SHOTS}/07-lifecycle-saving-details-idle.png`,
path: `${SHOTS}/07-lifecycle-saving-unified-save.png`,
});
await expect(
page.getByTestId("channel-management-save-lifecycle"),
).toHaveText("Save visibility");
page.getByRole("dialog", { name: "Edit channel" }),
).toHaveCount(0);
});
test("08 — saved ephemeral lifecycle is reflected after reopen", async ({
@@ -153,19 +162,21 @@ test.describe("channel controls screenshots", () => {
}) => {
await installMockBridge(page);
await openManagementSheet(page);
await openEditDialog(page);
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await page.getByTestId("channel-management-save-lifecycle").click();
await page.getByTestId("channel-management-save-changes").click();
await expect(
page.getByTestId("channel-management-save-lifecycle"),
).toHaveText("Save visibility");
page.getByRole("dialog", { name: "Edit channel" }),
).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("channel-management-close").click();
await expect(
page.getByTestId("channel-management-sheet"),
).not.toBeVisible();
await page.getByTestId("channel-management-trigger").click();
await openEditDialog(page);
const lifecycle = page.getByTestId("channel-management-lifecycle");
await lifecycle.scrollIntoViewIfNeeded();
+180 -46
View File
@@ -7,6 +7,7 @@ import {
openChannelBrowser,
} from "../helpers/bridge";
const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
// Relay-only agent owned by the mock viewer (see e2eBridge.ts
// OWNED_RELAY_AGENT_PUBKEY). Classified as a bot via mockRelayAgents and
@@ -15,6 +16,20 @@ const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8);
const OWNED_RELAY_AGENT_PUBKEY =
"a1b2c3d4e5f60718293a4b5c6d7e8f90112233445566778899aabbccddeeff00";
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,
@@ -26,10 +41,17 @@ async function openChannelManagement(
}
async function closeChannelManagement(page: import("@playwright/test").Page) {
await page.keyboard.press("Escape");
await page.getByTestId("channel-management-close").click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
}
async function openChannelEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
).toBeVisible();
}
async function openMembersSidebar(
page: import("@playwright/test").Page,
channelName: string,
@@ -1166,28 +1188,30 @@ test("manage channel updates details and context", async ({ page }) => {
await page.goto("/");
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
const editDialog = page.getByRole("dialog", { name: "Edit channel" });
await page.getByTestId("channel-management-name").fill(newName);
await page.getByTestId("channel-management-description").fill(newDescription);
await page.getByTestId("channel-management-save-details").click();
await editDialog.getByTestId("channel-management-name").fill(newName);
await editDialog
.getByTestId("channel-management-description")
.fill(newDescription);
await editDialog.getByTestId("channel-management-topic").fill(newTopic);
await editDialog.getByTestId("channel-management-purpose").fill(newPurpose);
await editDialog.getByTestId("channel-management-save-changes").click();
await expect(editDialog).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText(newName);
await expect(page.getByTestId("stream-list")).toContainText(newName);
const saveTopicButton = page.getByTestId("channel-management-save-topic");
const savePurposeButton = page.getByTestId("channel-management-save-purpose");
await page.getByTestId("channel-management-topic").fill(newTopic);
await saveTopicButton.click();
await expect(saveTopicButton).toHaveText("Save topic");
await expect(page.getByTestId("channel-management-topic")).toHaveValue(
await expect(page.getByTestId("channel-management-name-row")).toContainText(
newName,
);
await expect(
page.getByTestId("channel-management-description"),
).toContainText(newDescription);
await expect(page.getByTestId("channel-management-topic")).toContainText(
newTopic,
);
await page.getByTestId("channel-management-purpose").fill(newPurpose);
await savePurposeButton.click();
await expect(savePurposeButton).toHaveText("Save purpose");
await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
await expect(page.getByTestId("channel-management-purpose")).toContainText(
newPurpose,
);
@@ -1200,19 +1224,23 @@ test("manage channel updates details and context", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText(newName);
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await openChannelEditDialog(page);
const reopenedEditDialog = page.getByRole("dialog", {
name: "Edit channel",
});
await expect(page.getByTestId("channel-management-name")).toHaveValue(
newName,
);
await expect(page.getByTestId("channel-management-description")).toHaveValue(
newDescription,
);
await expect(page.getByTestId("channel-management-topic")).toHaveValue(
newTopic,
);
await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
newPurpose,
);
await expect(
reopenedEditDialog.getByTestId("channel-management-name"),
).toHaveValue(newName);
await expect(
reopenedEditDialog.getByTestId("channel-management-description"),
).toHaveValue(newDescription);
await expect(
reopenedEditDialog.getByTestId("channel-management-topic"),
).toHaveValue(newTopic);
await expect(
reopenedEditDialog.getByTestId("channel-management-purpose"),
).toHaveValue(newPurpose);
});
test("manage channel updates visibility and ephemeral lifecycle independently", async ({
@@ -1220,21 +1248,19 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
}) => {
await page.goto("/");
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
const saveDetailsButton = page.getByTestId("channel-management-save-details");
const saveLifecycleButton = page.getByTestId(
"channel-management-save-lifecycle",
);
let saveChangesButton = page.getByTestId("channel-management-save-changes");
await expect(saveLifecycleButton).toBeDisabled();
await expect(saveChangesButton).toBeDisabled();
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await expect(page.getByTestId("channel-management-ttl")).toBeVisible();
await expect(saveLifecycleButton).toBeEnabled();
await expect(saveChangesButton).toBeEnabled();
const commandCountBeforeEnable = (await readCommandPayloadLog(page)).length;
await saveLifecycleButton.click();
await saveChangesButton.click();
await expect
.poll(async () =>
(await readCommandPayloadLog(page)).slice(commandCountBeforeEnable),
@@ -1247,8 +1273,9 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
}),
}),
);
await expect(saveLifecycleButton).toHaveText("Save visibility");
await expect(saveDetailsButton).toHaveText("Save details");
await expect(page.getByRole("dialog", { name: "Edit channel" })).toHaveCount(
0,
);
const channelAfterEnable = await invokeMockCommand<{
ttl_seconds: number | null;
@@ -1263,6 +1290,8 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
await closeChannelManagement(page);
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
saveChangesButton = page.getByTestId("channel-management-save-changes");
await expect(
page.getByTestId("channel-management-private-toggle"),
@@ -1274,10 +1303,10 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
await page.getByTestId("channel-management-private-toggle").click();
await page.getByTestId("channel-management-ephemeral-toggle").click();
await expect(saveLifecycleButton).toBeEnabled();
await expect(saveChangesButton).toBeEnabled();
const commandCountBeforeDisable = (await readCommandPayloadLog(page)).length;
await saveLifecycleButton.click();
await saveChangesButton.click();
await expect
.poll(async () =>
(await readCommandPayloadLog(page)).slice(commandCountBeforeDisable),
@@ -1290,9 +1319,9 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
}),
}),
);
await expect(saveLifecycleButton).toHaveText("Save visibility");
await expect(saveDetailsButton).toHaveText("Save details");
await expect(page.getByTestId("channel-management-ttl")).toHaveCount(0);
await expect(page.getByRole("dialog", { name: "Edit channel" })).toHaveCount(
0,
);
const channelAfterDisable = await invokeMockCommand<{
ttl_seconds: number | null;
@@ -1307,6 +1336,7 @@ test("manage channel updates visibility and ephemeral lifecycle independently",
await closeChannelManagement(page);
await openChannelManagement(page, "general");
await openChannelEditDialog(page);
await expect(
page.getByTestId("channel-management-private-toggle"),
@@ -1324,20 +1354,124 @@ test("manage channel keeps canvas near the top of the sheet", async ({
await openChannelManagement(page, "general");
const sheet = page.getByTestId("channel-management-sheet");
const sheetBox = await sheet.boundingBox();
const timelineBox = await page.getByTestId("message-timeline").boundingBox();
// Canvas section should appear before the name input in the DOM.
// Canvas ingress should appear before the channel metadata rows in the DOM.
const canvasBox = await sheet
.getByTestId("channel-canvas-section")
.getByTestId("channel-canvas-ingress")
.boundingBox();
const nameBox = await sheet
.getByTestId("channel-management-name")
.getByTestId("channel-management-name-row")
.boundingBox();
expect(sheetBox).not.toBeNull();
expect(timelineBox).not.toBeNull();
if (!sheetBox || !timelineBox) {
throw new Error("Expected channel management panel and timeline boxes.");
}
expect(timelineBox.x + timelineBox.width).toBeLessThanOrEqual(sheetBox.x + 1);
await page.mouse.click(timelineBox.x + 24, timelineBox.y + 180);
await expect(sheet).toBeVisible();
await page.keyboard.press("Escape");
await expect(sheet).toBeVisible();
await page.setViewportSize({ height: 720, width: 820 });
await expect(page.getByTestId("message-timeline")).toHaveCount(0);
await expect(page.getByTestId("channel-drop-zone")).toHaveCount(0);
await expect(sheet).toBeVisible();
const narrowSheetBox = await sheet.boundingBox();
if (!narrowSheetBox) {
throw new Error("Expected narrow channel management panel box.");
}
expect(narrowSheetBox.width).toBeGreaterThan(500);
expect(canvasBox).not.toBeNull();
expect(nameBox).not.toBeNull();
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();
const detailBox = await page.getByTestId("home-inbox-detail").boundingBox();
const sheetBox = await page
.getByTestId("channel-management-sheet")
.boundingBox();
if (!detailBox || !sheetBox) {
throw new Error("Expected home detail pane and channel management boxes.");
}
expect(detailBox.x + detailBox.width).toBeLessThanOrEqual(sheetBox.x + 1);
expect(sheetBox.width).toBeGreaterThanOrEqual(300);
await page
.getByTestId("home-inbox-list")
.click({ position: { x: 24, y: 80 } });
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.setViewportSize({ height: 720, width: 820 });
await expect(page.getByTestId("home-inbox-list")).toHaveCount(0);
const narrowHomeBox = await page.getByTestId("home-inbox").boundingBox();
const narrowSheetBox = await page
.getByTestId("channel-management-sheet")
.boundingBox();
if (!narrowHomeBox || !narrowSheetBox) {
throw new Error("Expected narrow home and channel management boxes.");
}
expect(narrowSheetBox.width).toBeGreaterThanOrEqual(narrowHomeBox.width - 1);
await expect(page).not.toHaveURL(/#\/channels\//);
});
test("members sidebar can invite and remove members", async ({ page }) => {
await page.goto("/");
await openMembersSidebar(page, "general");
+36 -33
View File
@@ -32,8 +32,15 @@ async function openChannelManagement(page: import("@playwright/test").Page) {
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
}
async function openChannelEditDialog(page: import("@playwright/test").Page) {
await page.getByTestId("channel-management-edit").click();
await expect(
page.getByRole("dialog", { name: "Edit channel" }),
).toBeVisible();
}
async function closeChannelManagement(page: import("@playwright/test").Page) {
await page.keyboard.press("Escape");
await page.getByTestId("channel-management-close").click();
await expect(page.getByTestId("channel-management-sheet")).not.toBeVisible();
}
@@ -464,32 +471,23 @@ test("manage sheet updates channel details and context through the relay", async
await createStream(page, initialName, initialDescription);
await openChannelManagement(page);
await page.getByTestId("channel-management-name").fill(renamedChannel);
await page
await openChannelEditDialog(page);
const editDialog = page.getByRole("dialog", { name: "Edit channel" });
await editDialog.getByTestId("channel-management-name").fill(renamedChannel);
await editDialog
.getByTestId("channel-management-description")
.fill(updatedDescription);
await page.getByTestId("channel-management-save-details").click();
await editDialog.getByTestId("channel-management-topic").fill(updatedTopic);
await editDialog
.getByTestId("channel-management-purpose")
.fill(updatedPurpose);
await editDialog.getByTestId("channel-management-save-changes").click();
await expect(editDialog).toHaveCount(0);
await expect(page.getByTestId("chat-title")).toHaveText(renamedChannel);
await expect(page.getByTestId("stream-list")).toContainText(renamedChannel);
const saveTopicButton = page.getByTestId("channel-management-save-topic");
const savePurposeButton = page.getByTestId("channel-management-save-purpose");
await page.getByTestId("channel-management-topic").fill(updatedTopic);
await saveTopicButton.click();
await expect(saveTopicButton).toHaveText("Save topic");
await expect(page.getByTestId("channel-management-topic")).toHaveValue(
updatedTopic,
);
await page.getByTestId("channel-management-purpose").fill(updatedPurpose);
await savePurposeButton.click();
await expect(savePurposeButton).toHaveText("Save purpose");
await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
updatedPurpose,
);
await closeChannelManagement(page);
await page.reload();
@@ -502,18 +500,23 @@ test("manage sheet updates channel details and context through the relay", async
);
await openChannelManagement(page);
await expect(page.getByTestId("channel-management-name")).toHaveValue(
renamedChannel,
);
await expect(page.getByTestId("channel-management-description")).toHaveValue(
updatedDescription,
);
await expect(page.getByTestId("channel-management-topic")).toHaveValue(
updatedTopic,
);
await expect(page.getByTestId("channel-management-purpose")).toHaveValue(
updatedPurpose,
);
await openChannelEditDialog(page);
const reopenedEditDialog = page.getByRole("dialog", {
name: "Edit channel",
});
await expect(
reopenedEditDialog.getByTestId("channel-management-name"),
).toHaveValue(renamedChannel);
await expect(
reopenedEditDialog.getByTestId("channel-management-description"),
).toHaveValue(updatedDescription);
await expect(
reopenedEditDialog.getByTestId("channel-management-topic"),
).toHaveValue(updatedTopic);
await expect(
reopenedEditDialog.getByTestId("channel-management-purpose"),
).toHaveValue(updatedPurpose);
});
test("manage sheet archive and unarchive survives a reload through the relay", async ({
@@ -101,8 +101,10 @@ test.describe("relay connectivity screenshots", () => {
await expect(page.getByTestId("chat-title")).toHaveText("general");
await page.getByTestId("channel-management-trigger").click();
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
await page.getByTestId("channel-canvas-ingress").click();
// ChannelCanvas shows the destructive error paragraph when the query fails.
// ChannelCanvas shows the destructive error paragraph in the drill-in view
// when the query fails.
const canvasSection = page.getByTestId("channel-canvas-section");
await canvasSection.scrollIntoViewIfNeeded();
await expect(