mirror of
https://github.com/block/buzz.git
synced 2026-11-03 16:01:35 +00:00
Align channel management side panel behavior
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
@@ -17,7 +16,6 @@ export const Route = createFileRoute("/")({
|
||||
|
||||
function HomeRouteComponent() {
|
||||
const { goChannel } = useAppNavigation();
|
||||
const { openChannelManagement } = useAppShell();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const channels = channelsQuery.data ?? [];
|
||||
@@ -67,7 +65,6 @@ function HomeRouteComponent() {
|
||||
<HomeScreen
|
||||
availableChannelIds={availableChannelIds}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
onOpenChannel={openChannelManagement}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
BookOpenText,
|
||||
ChevronLeft,
|
||||
Copy,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
@@ -42,17 +42,6 @@ import {
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -62,15 +51,18 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/shared/ui/sheet";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import {
|
||||
AuxiliaryPanelHeader,
|
||||
AuxiliaryPanelHeaderGroup,
|
||||
AuxiliaryPanelTitle,
|
||||
auxiliaryPanelContentPaddingClass,
|
||||
} from "@/shared/layout/AuxiliaryPanelHeader";
|
||||
import {
|
||||
OverlayPanelBackdrop,
|
||||
PANEL_BASE_CLASS,
|
||||
PANEL_OVERLAY_CLASS,
|
||||
} from "@/shared/ui/OverlayPanelBackdrop";
|
||||
import { ChannelCanvas } from "./ChannelCanvas";
|
||||
import {
|
||||
ChannelHero,
|
||||
@@ -84,10 +76,12 @@ import {
|
||||
NarrativeGroup,
|
||||
ToggleRow,
|
||||
} from "./ChannelManagementSheetRows";
|
||||
import { ChannelManagementModerationActions } from "./ChannelManagementModerationActions";
|
||||
|
||||
type ChannelManagementSheetProps = {
|
||||
channel: Channel | null;
|
||||
currentPubkey?: string;
|
||||
layout?: "overlay" | "split";
|
||||
onDeleted?: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
@@ -98,11 +92,13 @@ const DEFAULT_EPHEMERAL_TTL_SECONDS = 24 * 60 * 60;
|
||||
export function ChannelManagementSheet({
|
||||
channel,
|
||||
currentPubkey,
|
||||
layout = "overlay",
|
||||
onDeleted,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: ChannelManagementSheetProps) {
|
||||
const { isDark } = useTheme();
|
||||
const isSplitLayout = layout === "split";
|
||||
const channelId = channel?.id ?? null;
|
||||
const detailsQuery = useChannelDetailsQuery(channelId, open);
|
||||
const membersQuery = useChannelMembersQuery(channelId, open);
|
||||
@@ -214,7 +210,7 @@ export function ChannelManagementSheet({
|
||||
}
|
||||
}
|
||||
|
||||
function handleSheetOpenChange(next: boolean) {
|
||||
function handlePanelOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
handleDeleteDialogOpenChange(false);
|
||||
}
|
||||
@@ -299,358 +295,111 @@ export function ChannelManagementSheet({
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={handleSheetOpenChange} open={open}>
|
||||
<SheetContent
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 p-0 shadow-none sm:!max-w-[380px] [&>button]:hidden",
|
||||
isDark
|
||||
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
|
||||
: "bg-background",
|
||||
)}
|
||||
data-testid="channel-management-sheet"
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader
|
||||
<DialogPrimitive.Root
|
||||
modal={!isSplitLayout}
|
||||
onOpenChange={handlePanelOpenChange}
|
||||
open={open}
|
||||
>
|
||||
{!isSplitLayout ? (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay asChild>
|
||||
<OverlayPanelBackdrop
|
||||
onClose={() => handlePanelOpenChange(false)}
|
||||
/>
|
||||
</DialogPrimitive.Overlay>
|
||||
</DialogPrimitive.Portal>
|
||||
) : null}
|
||||
{isSplitLayout ? (
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
"relative z-10 min-h-11 flex-row items-center gap-3 space-y-0 border-b border-border/35 px-3 py-1.5 text-left shadow-none",
|
||||
PANEL_BASE_CLASS,
|
||||
"h-full w-full cursor-default overflow-hidden border-l-0 p-0",
|
||||
isDark
|
||||
? "bg-background/70 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
|
||||
: "bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/70",
|
||||
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
|
||||
: "bg-background",
|
||||
)}
|
||||
data-testid="channel-management-sheet"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{activeView === "canvas" ? (
|
||||
<Button
|
||||
aria-label="Back to channel"
|
||||
data-testid="channel-management-back"
|
||||
onClick={() => setActiveView("summary")}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
) : null}
|
||||
<SheetTitle className="min-w-0 flex-1 translate-y-px truncate text-base font-semibold leading-6 tracking-tight">
|
||||
{activeView === "canvas" ? "Canvas" : "Channel"}
|
||||
</SheetTitle>
|
||||
</div>
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
aria-label="Close channel management"
|
||||
data-testid="channel-management-close"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<SheetDescription className="sr-only">
|
||||
Channel settings
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-background px-4 pb-8 pt-4">
|
||||
{activeView === "summary" ? (
|
||||
<div className="space-y-6">
|
||||
<ChannelHero channel={resolvedChannel} />
|
||||
|
||||
{detailsQuery.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{detailsQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{membersQuery.error instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{membersQuery.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-center gap-6">
|
||||
<ChannelQuickAction
|
||||
icon={Copy}
|
||||
label="Copy ID"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
.writeText(resolvedChannel.id)
|
||||
.then(() => toast.success("Copied channel ID"));
|
||||
}}
|
||||
testId="channel-management-copy-id-action"
|
||||
/>
|
||||
{canJoin ? (
|
||||
<ChannelQuickAction
|
||||
active
|
||||
disabled={joinChannelMutation.isPending}
|
||||
icon={DoorOpen}
|
||||
label={
|
||||
joinChannelMutation.isPending ? "Joining..." : "Join"
|
||||
}
|
||||
onClick={() => {
|
||||
void joinChannelMutation.mutateAsync();
|
||||
}}
|
||||
testId="channel-management-join"
|
||||
/>
|
||||
) : null}
|
||||
{canLeave ? (
|
||||
<ChannelQuickAction
|
||||
disabled={leaveChannelMutation.isPending}
|
||||
icon={DoorClosed}
|
||||
label={
|
||||
leaveChannelMutation.isPending ? "Leaving..." : "Leave"
|
||||
}
|
||||
onClick={() => {
|
||||
void leaveChannelMutation.mutateAsync().then(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
}}
|
||||
testId="channel-management-leave"
|
||||
/>
|
||||
) : null}
|
||||
{canManageChannel ? (
|
||||
<ChannelQuickAction
|
||||
icon={Pencil}
|
||||
label="Edit"
|
||||
onClick={() => setIsEditDialogOpen(true)}
|
||||
testId="channel-management-edit"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{joinChannelMutation.error instanceof Error ? (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
{joinChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{leaveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
{leaveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{resolvedChannel.description.trim() ||
|
||||
resolvedChannel.topic?.trim() ||
|
||||
resolvedChannel.purpose?.trim() ? (
|
||||
<NarrativeGroup>
|
||||
{resolvedChannel.description.trim() ? (
|
||||
<NarrativeField
|
||||
icon={FileText}
|
||||
label="Description"
|
||||
testId="channel-management-description"
|
||||
value={resolvedChannel.description.trim()}
|
||||
/>
|
||||
) : null}
|
||||
{resolvedChannel.topic?.trim() ? (
|
||||
<NarrativeField
|
||||
icon={MessageSquare}
|
||||
label="Topic"
|
||||
testId="channel-management-topic"
|
||||
value={resolvedChannel.topic.trim()}
|
||||
/>
|
||||
) : null}
|
||||
{resolvedChannel.purpose?.trim() ? (
|
||||
<NarrativeField
|
||||
icon={Zap}
|
||||
label="Purpose"
|
||||
testId="channel-management-purpose"
|
||||
value={resolvedChannel.purpose.trim()}
|
||||
/>
|
||||
) : null}
|
||||
</NarrativeGroup>
|
||||
) : null}
|
||||
|
||||
{canOpenCanvas ? (
|
||||
<IngressRow
|
||||
description={canvasPreview}
|
||||
icon={BookOpenText}
|
||||
label="Canvas"
|
||||
onClick={() => setActiveView("canvas")}
|
||||
testId="channel-canvas-ingress"
|
||||
trailing={canvasQuery.isLoading ? "Loading..." : undefined}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FieldGroup>
|
||||
<CopyFieldRow
|
||||
icon={Fingerprint}
|
||||
label="Channel ID"
|
||||
testId="channel-management-channel-id"
|
||||
value={resolvedChannel.id}
|
||||
/>
|
||||
<InfoFieldRow
|
||||
icon={Type}
|
||||
label="Name"
|
||||
testId="channel-management-name-row"
|
||||
value={resolvedChannel.name}
|
||||
/>
|
||||
<InfoFieldRow
|
||||
icon={Radio}
|
||||
label="Type"
|
||||
testId="channel-management-type"
|
||||
value={resolvedChannel.channelType}
|
||||
/>
|
||||
<InfoFieldRow
|
||||
icon={resolvedChannel.visibility === "private" ? Lock : Eye}
|
||||
label="Visibility"
|
||||
testId="channel-management-visibility"
|
||||
value={resolvedChannel.visibility}
|
||||
/>
|
||||
<InfoFieldRow
|
||||
icon={Users}
|
||||
label="Members"
|
||||
testId="channel-management-member-count"
|
||||
value={`${memberCount}`}
|
||||
/>
|
||||
{isArchived ? (
|
||||
<InfoFieldRow
|
||||
icon={Archive}
|
||||
label="Status"
|
||||
testId="channel-management-archived"
|
||||
value="Archived"
|
||||
/>
|
||||
) : null}
|
||||
{resolvedChannel.ttlSeconds !== null ? (
|
||||
<InfoFieldRow
|
||||
icon={Archive}
|
||||
label="Ephemeral"
|
||||
testId="channel-management-ephemeral-row"
|
||||
value={formatTtlDuration(resolvedChannel.ttlSeconds)}
|
||||
/>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
{canManageChannel && resolvedChannel.channelType !== "dm" ? (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2"
|
||||
data-testid="channel-management-footer"
|
||||
>
|
||||
{isArchived ? (
|
||||
<Button
|
||||
className="w-full justify-center"
|
||||
data-testid="channel-management-unarchive"
|
||||
disabled={
|
||||
!canManageChannel ||
|
||||
unarchiveChannelMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
void unarchiveChannelMutation.mutateAsync();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<ArchiveRestore className="h-4 w-4" />
|
||||
{unarchiveChannelMutation.isPending
|
||||
? "Restoring..."
|
||||
: "Unarchive"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full justify-center"
|
||||
data-testid="channel-management-archive"
|
||||
disabled={
|
||||
!canManageChannel || archiveChannelMutation.isPending
|
||||
}
|
||||
onClick={() => {
|
||||
void archiveChannelMutation.mutateAsync();
|
||||
}}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
{archiveChannelMutation.isPending
|
||||
? "Archiving..."
|
||||
: "Archive"}
|
||||
</Button>
|
||||
)}
|
||||
{isOwner ? (
|
||||
<AlertDialog
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
open={isDeleteDialogOpen}
|
||||
>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
className="w-full justify-center"
|
||||
data-testid="channel-management-delete"
|
||||
disabled={deleteChannelMutation.isPending}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent data-testid="channel-delete-confirmation-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete channel?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Delete {resolvedChannel.name} 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>
|
||||
{archiveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{archiveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{unarchiveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{unarchiveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="channel-canvas-section">
|
||||
<ChannelCanvas
|
||||
canEdit={canEditNarrative}
|
||||
channelId={channelId}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
<ChannelManagementPanelContent
|
||||
activeView={activeView}
|
||||
archiveChannelMutation={archiveChannelMutation}
|
||||
canEditNarrative={canEditNarrative}
|
||||
canJoin={canJoin}
|
||||
canLeave={canLeave}
|
||||
canManageChannel={canManageChannel}
|
||||
canOpenCanvas={canOpenCanvas}
|
||||
canvasPreview={canvasPreview}
|
||||
canvasQuery={canvasQuery}
|
||||
channelId={channelId}
|
||||
deleteChannelMutation={deleteChannelMutation}
|
||||
detailsError={detailsQuery.error}
|
||||
handleDeleteChannel={handleDeleteChannel}
|
||||
handleDeleteDialogOpenChange={handleDeleteDialogOpenChange}
|
||||
isArchived={isArchived}
|
||||
isDark={isDark}
|
||||
isDeleteDialogOpen={isDeleteDialogOpen}
|
||||
isOwner={isOwner}
|
||||
isSplitLayout={isSplitLayout}
|
||||
joinChannelMutation={joinChannelMutation}
|
||||
leaveChannelMutation={leaveChannelMutation}
|
||||
memberCount={memberCount}
|
||||
membersError={membersQuery.error}
|
||||
onOpenChange={onOpenChange}
|
||||
resolvedChannel={resolvedChannel}
|
||||
setActiveView={setActiveView}
|
||||
setIsEditDialogOpen={setIsEditDialogOpen}
|
||||
unarchiveChannelMutation={unarchiveChannelMutation}
|
||||
/>
|
||||
</DialogPrimitive.Content>
|
||||
) : (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Content
|
||||
className={cn(
|
||||
PANEL_BASE_CLASS,
|
||||
PANEL_OVERLAY_CLASS,
|
||||
"w-[380px] cursor-default overflow-hidden p-0 transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
|
||||
isDark
|
||||
? "bg-background/85 backdrop-blur-xl supports-[backdrop-filter]:bg-background/75"
|
||||
: "bg-background",
|
||||
)}
|
||||
data-testid="channel-management-sheet"
|
||||
>
|
||||
<ChannelManagementPanelContent
|
||||
activeView={activeView}
|
||||
archiveChannelMutation={archiveChannelMutation}
|
||||
canEditNarrative={canEditNarrative}
|
||||
canJoin={canJoin}
|
||||
canLeave={canLeave}
|
||||
canManageChannel={canManageChannel}
|
||||
canOpenCanvas={canOpenCanvas}
|
||||
canvasPreview={canvasPreview}
|
||||
canvasQuery={canvasQuery}
|
||||
channelId={channelId}
|
||||
deleteChannelMutation={deleteChannelMutation}
|
||||
detailsError={detailsQuery.error}
|
||||
handleDeleteChannel={handleDeleteChannel}
|
||||
handleDeleteDialogOpenChange={handleDeleteDialogOpenChange}
|
||||
isArchived={isArchived}
|
||||
isDark={isDark}
|
||||
isDeleteDialogOpen={isDeleteDialogOpen}
|
||||
isOwner={isOwner}
|
||||
isSplitLayout={isSplitLayout}
|
||||
joinChannelMutation={joinChannelMutation}
|
||||
leaveChannelMutation={leaveChannelMutation}
|
||||
memberCount={memberCount}
|
||||
membersError={membersQuery.error}
|
||||
onOpenChange={onOpenChange}
|
||||
resolvedChannel={resolvedChannel}
|
||||
setActiveView={setActiveView}
|
||||
setIsEditDialogOpen={setIsEditDialogOpen}
|
||||
unarchiveChannelMutation={unarchiveChannelMutation}
|
||||
/>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
)}
|
||||
|
||||
{canManageChannel ? (
|
||||
<Dialog onOpenChange={setIsEditDialogOpen} open={isEditDialogOpen}>
|
||||
@@ -843,6 +592,373 @@ export function ChannelManagementSheet({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : null}
|
||||
</Sheet>
|
||||
</DialogPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
type ChannelMutation<TArgs = void> = {
|
||||
error: unknown;
|
||||
isPending: boolean;
|
||||
mutateAsync: (args: TArgs) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type ChannelManagementPanelContentProps = {
|
||||
activeView: "summary" | "canvas";
|
||||
archiveChannelMutation: ChannelMutation;
|
||||
canEditNarrative: boolean;
|
||||
canJoin: boolean;
|
||||
canLeave: boolean;
|
||||
canManageChannel: boolean;
|
||||
canOpenCanvas: boolean;
|
||||
canvasPreview?: string;
|
||||
canvasQuery: { isLoading: boolean };
|
||||
channelId: string | null;
|
||||
deleteChannelMutation: ChannelMutation;
|
||||
detailsError: unknown;
|
||||
handleDeleteChannel: () => Promise<void>;
|
||||
handleDeleteDialogOpenChange: (open: boolean) => void;
|
||||
isArchived: boolean;
|
||||
isDark: boolean;
|
||||
isDeleteDialogOpen: boolean;
|
||||
isOwner: boolean;
|
||||
isSplitLayout: boolean;
|
||||
joinChannelMutation: ChannelMutation;
|
||||
leaveChannelMutation: ChannelMutation;
|
||||
memberCount: number;
|
||||
membersError: unknown;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
resolvedChannel: Channel;
|
||||
setActiveView: React.Dispatch<React.SetStateAction<"summary" | "canvas">>;
|
||||
setIsEditDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
unarchiveChannelMutation: ChannelMutation;
|
||||
};
|
||||
|
||||
function ChannelManagementPanelContent({
|
||||
activeView,
|
||||
archiveChannelMutation,
|
||||
canEditNarrative,
|
||||
canJoin,
|
||||
canLeave,
|
||||
canManageChannel,
|
||||
canOpenCanvas,
|
||||
canvasPreview,
|
||||
canvasQuery,
|
||||
channelId,
|
||||
deleteChannelMutation,
|
||||
detailsError,
|
||||
handleDeleteChannel,
|
||||
handleDeleteDialogOpenChange,
|
||||
isArchived,
|
||||
isDark,
|
||||
isDeleteDialogOpen,
|
||||
isOwner,
|
||||
isSplitLayout,
|
||||
joinChannelMutation,
|
||||
leaveChannelMutation,
|
||||
memberCount,
|
||||
membersError,
|
||||
onOpenChange,
|
||||
resolvedChannel,
|
||||
setActiveView,
|
||||
setIsEditDialogOpen,
|
||||
unarchiveChannelMutation,
|
||||
}: ChannelManagementPanelContentProps) {
|
||||
const showModerationActions =
|
||||
activeView === "summary" &&
|
||||
canManageChannel &&
|
||||
resolvedChannel.channelType !== "dm";
|
||||
|
||||
return (
|
||||
<>
|
||||
{isSplitLayout ? (
|
||||
<AuxiliaryPanelHeader>
|
||||
<AuxiliaryPanelHeaderGroup>
|
||||
{activeView === "canvas" ? (
|
||||
<Button
|
||||
aria-label="Back to channel"
|
||||
data-testid="channel-management-back"
|
||||
onClick={() => setActiveView("summary")}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
) : null}
|
||||
<DialogPrimitive.Title asChild>
|
||||
<AuxiliaryPanelTitle>
|
||||
{activeView === "canvas" ? "Canvas" : "Channel"}
|
||||
</AuxiliaryPanelTitle>
|
||||
</DialogPrimitive.Title>
|
||||
</AuxiliaryPanelHeaderGroup>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button
|
||||
aria-label="Close channel management"
|
||||
data-testid="channel-management-close"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Channel settings
|
||||
</DialogPrimitive.Description>
|
||||
</AuxiliaryPanelHeader>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 flex min-h-11 flex-row items-center gap-3 space-y-0 border-b border-border/35 px-3 py-1.5 text-left shadow-none",
|
||||
isDark
|
||||
? "bg-background/70 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
|
||||
: "bg-background/80 backdrop-blur-md supports-[backdrop-filter]:bg-background/70",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
{activeView === "canvas" ? (
|
||||
<Button
|
||||
aria-label="Back to channel"
|
||||
data-testid="channel-management-back"
|
||||
onClick={() => setActiveView("summary")}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
) : null}
|
||||
<DialogPrimitive.Title className="min-w-0 flex-1 translate-y-px truncate text-base font-semibold leading-6 tracking-tight">
|
||||
{activeView === "canvas" ? "Canvas" : "Channel"}
|
||||
</DialogPrimitive.Title>
|
||||
</div>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button
|
||||
aria-label="Close channel management"
|
||||
data-testid="channel-management-close"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
<DialogPrimitive.Description className="sr-only">
|
||||
Channel settings
|
||||
</DialogPrimitive.Description>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 overflow-y-auto bg-background px-4",
|
||||
showModerationActions ? "pb-20" : "pb-8",
|
||||
isSplitLayout ? auxiliaryPanelContentPaddingClass : "pt-4",
|
||||
)}
|
||||
>
|
||||
{activeView === "summary" ? (
|
||||
<div className="space-y-6">
|
||||
<ChannelHero channel={resolvedChannel} />
|
||||
|
||||
{detailsError instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{detailsError.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{membersError instanceof Error ? (
|
||||
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{membersError.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-center gap-6">
|
||||
<ChannelQuickAction
|
||||
icon={Copy}
|
||||
label="Copy ID"
|
||||
onClick={() => {
|
||||
void navigator.clipboard
|
||||
.writeText(resolvedChannel.id)
|
||||
.then(() => toast.success("Copied channel ID"));
|
||||
}}
|
||||
testId="channel-management-copy-id-action"
|
||||
/>
|
||||
{canJoin ? (
|
||||
<ChannelQuickAction
|
||||
active
|
||||
disabled={joinChannelMutation.isPending}
|
||||
icon={DoorOpen}
|
||||
label={joinChannelMutation.isPending ? "Joining..." : "Join"}
|
||||
onClick={() => {
|
||||
void joinChannelMutation.mutateAsync();
|
||||
}}
|
||||
testId="channel-management-join"
|
||||
/>
|
||||
) : null}
|
||||
{canLeave ? (
|
||||
<ChannelQuickAction
|
||||
disabled={leaveChannelMutation.isPending}
|
||||
icon={DoorClosed}
|
||||
label={
|
||||
leaveChannelMutation.isPending ? "Leaving..." : "Leave"
|
||||
}
|
||||
onClick={() => {
|
||||
void leaveChannelMutation.mutateAsync().then(() => {
|
||||
onOpenChange(false);
|
||||
});
|
||||
}}
|
||||
testId="channel-management-leave"
|
||||
/>
|
||||
) : null}
|
||||
{canManageChannel ? (
|
||||
<ChannelQuickAction
|
||||
icon={Pencil}
|
||||
label="Edit"
|
||||
onClick={() => setIsEditDialogOpen(true)}
|
||||
testId="channel-management-edit"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{joinChannelMutation.error instanceof Error ? (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
{joinChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{leaveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-center text-sm text-destructive">
|
||||
{leaveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{resolvedChannel.description.trim() ||
|
||||
resolvedChannel.topic?.trim() ||
|
||||
resolvedChannel.purpose?.trim() ? (
|
||||
<NarrativeGroup>
|
||||
{resolvedChannel.description.trim() ? (
|
||||
<NarrativeField
|
||||
icon={FileText}
|
||||
label="Description"
|
||||
testId="channel-management-description"
|
||||
value={resolvedChannel.description.trim()}
|
||||
/>
|
||||
) : null}
|
||||
{resolvedChannel.topic?.trim() ? (
|
||||
<NarrativeField
|
||||
icon={MessageSquare}
|
||||
label="Topic"
|
||||
testId="channel-management-topic"
|
||||
value={resolvedChannel.topic.trim()}
|
||||
/>
|
||||
) : null}
|
||||
{resolvedChannel.purpose?.trim() ? (
|
||||
<NarrativeField
|
||||
icon={Zap}
|
||||
label="Purpose"
|
||||
testId="channel-management-purpose"
|
||||
value={resolvedChannel.purpose.trim()}
|
||||
/>
|
||||
) : null}
|
||||
</NarrativeGroup>
|
||||
) : null}
|
||||
|
||||
{canOpenCanvas ? (
|
||||
<IngressRow
|
||||
description={canvasPreview}
|
||||
icon={BookOpenText}
|
||||
label="Canvas"
|
||||
onClick={() => setActiveView("canvas")}
|
||||
testId="channel-canvas-ingress"
|
||||
trailing={canvasQuery.isLoading ? "Loading..." : undefined}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FieldGroup>
|
||||
<CopyFieldRow
|
||||
icon={Fingerprint}
|
||||
label="Channel ID"
|
||||
testId="channel-management-channel-id"
|
||||
value={resolvedChannel.id}
|
||||
/>
|
||||
<InfoFieldRow
|
||||
icon={Type}
|
||||
label="Name"
|
||||
testId="channel-management-name-row"
|
||||
value={resolvedChannel.name}
|
||||
/>
|
||||
<InfoFieldRow
|
||||
icon={Radio}
|
||||
label="Type"
|
||||
testId="channel-management-type"
|
||||
value={resolvedChannel.channelType}
|
||||
/>
|
||||
<InfoFieldRow
|
||||
icon={resolvedChannel.visibility === "private" ? Lock : Eye}
|
||||
label="Visibility"
|
||||
testId="channel-management-visibility"
|
||||
value={resolvedChannel.visibility}
|
||||
/>
|
||||
<InfoFieldRow
|
||||
icon={Users}
|
||||
label="Members"
|
||||
testId="channel-management-member-count"
|
||||
value={`${memberCount}`}
|
||||
/>
|
||||
{isArchived ? (
|
||||
<InfoFieldRow
|
||||
icon={Archive}
|
||||
label="Status"
|
||||
testId="channel-management-archived"
|
||||
value="Archived"
|
||||
/>
|
||||
) : null}
|
||||
{resolvedChannel.ttlSeconds !== null ? (
|
||||
<InfoFieldRow
|
||||
icon={Archive}
|
||||
label="Ephemeral"
|
||||
testId="channel-management-ephemeral-row"
|
||||
value={formatTtlDuration(resolvedChannel.ttlSeconds)}
|
||||
/>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
{archiveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{archiveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
{unarchiveChannelMutation.error instanceof Error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
{unarchiveChannelMutation.error.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="channel-canvas-section">
|
||||
<ChannelCanvas
|
||||
canEdit={canEditNarrative}
|
||||
channelId={channelId}
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModerationActions ? (
|
||||
<ChannelManagementModerationActions
|
||||
archiveChannelMutation={archiveChannelMutation}
|
||||
canManageChannel={canManageChannel}
|
||||
deleteChannelMutation={deleteChannelMutation}
|
||||
handleDeleteChannel={handleDeleteChannel}
|
||||
handleDeleteDialogOpenChange={handleDeleteDialogOpenChange}
|
||||
isArchived={isArchived}
|
||||
isDark={isDark}
|
||||
isDeleteDialogOpen={isDeleteDialogOpen}
|
||||
isOwner={isOwner}
|
||||
resolvedChannelName={resolvedChannel.name}
|
||||
unarchiveChannelMutation={unarchiveChannelMutation}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"
|
||||
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
|
||||
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
|
||||
import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel";
|
||||
import { ChannelManagementSheet } from "@/features/channels/ui/ChannelManagementSheet";
|
||||
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
|
||||
import {
|
||||
BotActivityComposerAction,
|
||||
@@ -61,6 +62,7 @@ type ChannelPaneProps = {
|
||||
agentSessionAgents: ChannelAgentSessionAgent[];
|
||||
botTypingEntries: TypingIndicatorEntry[];
|
||||
channelFind: ReturnType<typeof useChannelFind>;
|
||||
channelManagementOpen?: boolean;
|
||||
currentPubkey?: string;
|
||||
editTarget?: {
|
||||
author: string;
|
||||
@@ -85,6 +87,8 @@ type ChannelPaneProps = {
|
||||
onCancelEdit?: () => void;
|
||||
onCancelThreadReply: () => void;
|
||||
onCloseAgentSession: () => void;
|
||||
onCloseChannelManagement?: () => void;
|
||||
onChannelManagementDeleted?: () => void;
|
||||
onCloseProfilePanel: () => void;
|
||||
onAddAgent?: () => void;
|
||||
onCreateChannel?: () => void;
|
||||
@@ -164,6 +168,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
activityAgents = agentSessionAgents,
|
||||
botTypingEntries,
|
||||
channelFind,
|
||||
channelManagementOpen = false,
|
||||
currentPubkey,
|
||||
editTarget = null,
|
||||
fetchOlder,
|
||||
@@ -184,6 +189,8 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onCancelEdit,
|
||||
onCancelThreadReply,
|
||||
onCloseAgentSession,
|
||||
onCloseChannelManagement,
|
||||
onChannelManagementDeleted,
|
||||
onCloseProfilePanel,
|
||||
onAddAgent,
|
||||
onCreateChannel,
|
||||
@@ -773,61 +780,24 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{threadHeadMessage
|
||||
{channelManagementOpen && activeChannel
|
||||
? (() => {
|
||||
const panel = (
|
||||
<MessageThreadPanel
|
||||
agentPubkeys={agentPubkeys}
|
||||
<ChannelManagementSheet
|
||||
channel={activeChannel}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
currentPubkey={currentPubkey}
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={threadEditTarget}
|
||||
firstUnreadReplyId={threadFirstUnreadReplyId}
|
||||
isFollowingThread={isFollowingThread}
|
||||
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}
|
||||
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}
|
||||
threadReplyUnreadCounts={threadReplyUnreadCounts}
|
||||
threadTypingPubkeys={threadTypingPubkeys}
|
||||
toolbarExtraActions={
|
||||
hasThreadComposerBotActivity ? (
|
||||
<BotActivityComposerAction
|
||||
agents={activityAgents}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
onOpenAgentSession={onOpenAgentSession}
|
||||
openAgentSessionPubkey={openAgentSessionPubkey}
|
||||
profiles={profiles}
|
||||
typingBotPubkeys={threadComposerBotTypingPubkeys}
|
||||
variant="inline"
|
||||
/>
|
||||
) : null
|
||||
layout={
|
||||
useSplitAuxiliaryPane || isSinglePanelView
|
||||
? "split"
|
||||
: "overlay"
|
||||
}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) {
|
||||
onCloseChannelManagement?.();
|
||||
}
|
||||
}}
|
||||
onDeleted={onChannelManagementDeleted}
|
||||
open={true}
|
||||
/>
|
||||
);
|
||||
return useSplitAuxiliaryPane ? (
|
||||
@@ -835,7 +805,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId="message-thread-panel"
|
||||
testId="channel-management-auxiliary-pane"
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
@@ -844,16 +814,61 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
panel
|
||||
);
|
||||
})()
|
||||
: openThreadHeadId && activeChannel
|
||||
: threadHeadMessage
|
||||
? (() => {
|
||||
const panel = (
|
||||
<MessageThreadPanelSkeleton
|
||||
<MessageThreadPanel
|
||||
agentPubkeys={agentPubkeys}
|
||||
channel={activeChannel}
|
||||
channelId={activeChannel?.id ?? null}
|
||||
channelName={activeChannel?.name ?? "channel"}
|
||||
currentPubkey={currentPubkey}
|
||||
disabled={isComposerDisabled}
|
||||
editTarget={threadEditTarget}
|
||||
firstUnreadReplyId={threadFirstUnreadReplyId}
|
||||
isFollowingThread={isFollowingThread}
|
||||
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}
|
||||
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}
|
||||
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 ? (
|
||||
@@ -870,27 +885,15 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
panel
|
||||
);
|
||||
})()
|
||||
: activeChannel && selectedAgent
|
||||
: openThreadHeadId && activeChannel
|
||||
? (() => {
|
||||
const panel = (
|
||||
<AgentSessionThreadPanel
|
||||
agent={selectedAgent}
|
||||
canInterruptTurn={selectedAgent.canInterruptTurn}
|
||||
channel={activeChannel}
|
||||
isWorking={botTypingEntries.some(
|
||||
(entry) =>
|
||||
entry.pubkey.toLowerCase() ===
|
||||
selectedAgent.pubkey.toLowerCase(),
|
||||
)}
|
||||
<MessageThreadPanelSkeleton
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
profiles={profiles}
|
||||
onBackToProfile={() =>
|
||||
onOpenProfilePanel(selectedAgent.pubkey)
|
||||
}
|
||||
onClose={onCloseAgentSession}
|
||||
onClose={onCloseThread}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
@@ -899,7 +902,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId="agent-session-thread-panel"
|
||||
testId="message-thread-panel"
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
@@ -908,19 +911,27 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
panel
|
||||
);
|
||||
})()
|
||||
: profilePanelPubkey
|
||||
: activeChannel && selectedAgent
|
||||
? (() => {
|
||||
const panel = (
|
||||
<UserProfilePanel
|
||||
currentPubkey={currentPubkey}
|
||||
<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"}
|
||||
onClose={onCloseProfilePanel}
|
||||
onOpenDm={onOpenDm}
|
||||
pubkey={profilePanelPubkey}
|
||||
splitPaneClamp
|
||||
profiles={profiles}
|
||||
onBackToProfile={() =>
|
||||
onOpenProfilePanel(selectedAgent.pubkey)
|
||||
}
|
||||
onClose={onCloseAgentSession}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
@@ -929,7 +940,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId="user-profile-panel"
|
||||
testId="agent-session-thread-panel"
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
@@ -938,7 +949,37 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
panel
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
: profilePanelPubkey
|
||||
? (() => {
|
||||
const panel = (
|
||||
<UserProfilePanel
|
||||
currentPubkey={currentPubkey}
|
||||
isSinglePanelView={
|
||||
useSplitAuxiliaryPane ? false : isSinglePanelView
|
||||
}
|
||||
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
|
||||
onClose={onCloseProfilePanel}
|
||||
onOpenDm={onOpenDm}
|
||||
pubkey={profilePanelPubkey}
|
||||
splitPaneClamp
|
||||
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 {
|
||||
@@ -93,6 +94,7 @@ export function ChannelScreen({
|
||||
targetMessageEvents,
|
||||
targetMessageId,
|
||||
}: ChannelScreenProps) {
|
||||
const { goHome } = useAppNavigation();
|
||||
const {
|
||||
markChannelRead,
|
||||
markChannelUnread,
|
||||
@@ -101,7 +103,7 @@ export function ChannelScreen({
|
||||
markThreadRead,
|
||||
setContextParentResolver,
|
||||
openCreateChannel,
|
||||
openChannelManagement,
|
||||
openChannelManagement: openGlobalChannelManagement,
|
||||
followThread,
|
||||
unfollowThread,
|
||||
isFollowingThread,
|
||||
@@ -111,6 +113,8 @@ export function ChannelScreen({
|
||||
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
||||
React.useState(false);
|
||||
const {
|
||||
canReset: canResetThreadPanelWidth,
|
||||
onResetWidth: handleThreadPanelWidthReset,
|
||||
@@ -143,14 +147,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;
|
||||
@@ -161,22 +159,13 @@ 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/sidebar unread — the exact
|
||||
// regression Fix A prevents. 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;
|
||||
// Capture the read frontier as it stood the instant this channel was opened,
|
||||
// BEFORE the mark-read effect below advances it to latest. Written during
|
||||
// render (not in an effect) so the value is read prior to any effect for
|
||||
// this commit — the divider must reflect "what was unread on open", not the
|
||||
// post-open frontier. Keyed per channel and recomputed only when the channel
|
||||
// id changes, never when activeReadAt advances, or the divider would vanish
|
||||
// the moment the open marks the channel read.
|
||||
// Capture the read frontier during render, before the mark-read effect
|
||||
// advances it, so dividers reflect "what was unread on open."
|
||||
const openFrontierRef = React.useRef(new Map<string, number | null>());
|
||||
if (activeChannelId && !openFrontierRef.current.has(activeChannelId)) {
|
||||
openFrontierRef.current.set(
|
||||
@@ -187,12 +176,8 @@ export function ChannelScreen({
|
||||
const openFrontierSeconds = activeChannelId
|
||||
? (openFrontierRef.current.get(activeChannelId) ?? null)
|
||||
: null;
|
||||
// Channels the user manually marked unread this session. A deliberate
|
||||
// mark-unread has no meaningful "new" boundary inside the timeline — the
|
||||
// open-time snapshot already covers every message — so the pill and divider
|
||||
// would otherwise render nothing while the sidebar dot says unread. Suppress
|
||||
// the marker for such channels to avoid that visible contradiction. The flag
|
||||
// is cleared on re-open (a fresh snapshot is recomputed for the channel).
|
||||
// Manual mark-unread has no meaningful in-timeline boundary, so suppress the
|
||||
// marker until the channel is reopened and a fresh snapshot is captured.
|
||||
const forcedUnreadRef = React.useRef(new Set<string>());
|
||||
const [, forceUnreadRender] = React.useReducer((n: number) => n + 1, 0);
|
||||
const isActiveChannelForcedUnread =
|
||||
@@ -209,9 +194,7 @@ export function ChannelScreen({
|
||||
};
|
||||
}, [activeChannelId]);
|
||||
// Clear the open-time frontier on channel leave so re-visiting captures a
|
||||
// fresh read position. Without this, switching away and back would reuse the
|
||||
// stale frontier from the first open, producing a phantom "New" divider over
|
||||
// already-read messages.
|
||||
// fresh read position.
|
||||
React.useEffect(() => {
|
||||
const channelId = activeChannelId;
|
||||
if (!channelId) return;
|
||||
@@ -223,19 +206,12 @@ export function ChannelScreen({
|
||||
if (!activeChannelId || activeChannel?.isMember === false) {
|
||||
return;
|
||||
}
|
||||
// Passive channel-open: advance the marker to the newest top-level message
|
||||
// only (NIP-RS Option 1). `topLevelOnly` stops the read-state layer folding
|
||||
// in observed thread replies, so opening a channel clears the timeline but
|
||||
// leaves thread badges intact until each thread is opened — and leaves the
|
||||
// channel's sidebar dot lit (the reply is still unread for the channel).
|
||||
// Advance only to the newest top-level message; thread replies remain
|
||||
// unread until their thread is opened.
|
||||
markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true });
|
||||
}, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]);
|
||||
// Install the NIP-RS parent resolver: every `thread:<root>` context evaluated
|
||||
// while this channel is active belongs to it (getThreadReadAt is only ever
|
||||
// called on the active channel's timeline messages), so the parent is always
|
||||
// the active channel. Non-thread keys (channels) have no parent → null, which
|
||||
// degrades effective() to the own term. Cleared on channel leave / unmount so
|
||||
// a stale channel id never becomes the parent of another channel's threads.
|
||||
// Install the NIP-RS parent resolver for active-channel thread contexts and
|
||||
// clear it on leave so a stale channel id never parents another channel.
|
||||
React.useEffect(() => {
|
||||
if (!activeChannelId) {
|
||||
setContextParentResolver(null);
|
||||
@@ -438,10 +414,8 @@ export function ChannelScreen({
|
||||
() => buildCreatedAtByMessageId(timelineMessages),
|
||||
[timelineMessages],
|
||||
);
|
||||
// Newest createdAt across an expanded branch (the message itself plus every
|
||||
// descendant). Drilling into a branch advances the thread frontier to this,
|
||||
// consuming everything chronologically up to the deepest reply read. Returns
|
||||
// null when the message is absent so the caller skips the read-state write.
|
||||
// Newest createdAt across an expanded branch, used to advance its thread
|
||||
// frontier when the branch is drilled into.
|
||||
const getSubtreeMaxCreatedAt = React.useCallback(
|
||||
(messageId: string) =>
|
||||
subtreeMaxCreatedAt(
|
||||
@@ -758,6 +732,7 @@ export function ChannelScreen({
|
||||
handleCloseAgentSession();
|
||||
setEditTargetId(null);
|
||||
setProfilePanelPubkey(null);
|
||||
setIsChannelManagementOpen(false);
|
||||
},
|
||||
[handleCloseAgentSession],
|
||||
);
|
||||
@@ -810,7 +785,10 @@ export function ChannelScreen({
|
||||
|
||||
useLoadMissingAncestors(activeChannel, resolvedMessages);
|
||||
const hasAuxiliaryPanel = Boolean(
|
||||
openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey,
|
||||
openThreadHeadMessage ||
|
||||
openAgentSessionPubkey ||
|
||||
profilePanelPubkey ||
|
||||
isChannelManagementOpen,
|
||||
);
|
||||
const isNarrowPanelViewport =
|
||||
channelContentWidthPx > 0 &&
|
||||
@@ -851,7 +829,20 @@ export function ChannelScreen({
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onAddBotOpenChange={setIsAddBotOpen}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
onManageChannel={openChannelManagement}
|
||||
onManageChannel={() => {
|
||||
if (activeChannel?.channelType === "forum") {
|
||||
openGlobalChannelManagement();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenThreadHeadId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
setThreadScrollTargetId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
handleCloseAgentSession();
|
||||
setProfilePanelPubkey(null);
|
||||
setIsChannelManagementOpen(true);
|
||||
}}
|
||||
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
|
||||
showHeaderContent={!isSinglePanelView}
|
||||
/>
|
||||
@@ -891,6 +882,7 @@ export function ChannelScreen({
|
||||
agentSessionAgents={channelAgentSessionAgents}
|
||||
botTypingEntries={botTypingEntries}
|
||||
channelFind={channelFind}
|
||||
channelManagementOpen={isChannelManagementOpen}
|
||||
currentPubkey={currentPubkey}
|
||||
canResetThreadPanelWidth={canResetThreadPanelWidth}
|
||||
fetchOlder={fetchOlder}
|
||||
@@ -922,6 +914,10 @@ export function ChannelScreen({
|
||||
messages={timelineMessages}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelThreadReply={handleCancelThreadReply}
|
||||
onChannelManagementDeleted={() => {
|
||||
setIsChannelManagementOpen(false);
|
||||
void goHome({ replace: true });
|
||||
}}
|
||||
onFollowThread={
|
||||
openThreadHeadId != null && !isNotifiedForCurrentThread
|
||||
? () => followThread(openThreadHeadId)
|
||||
@@ -933,6 +929,9 @@ export function ChannelScreen({
|
||||
: undefined
|
||||
}
|
||||
onCloseAgentSession={handleCloseAgentSession}
|
||||
onCloseChannelManagement={() =>
|
||||
setIsChannelManagementOpen(false)
|
||||
}
|
||||
onCloseThread={handleCloseThread}
|
||||
onDelete={
|
||||
activeChannel?.archivedAt ? undefined : handleDelete
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
type RightAuxiliaryPaneProps = {
|
||||
canResetWidth: boolean;
|
||||
children: React.ReactNode;
|
||||
constrainToAvailableSpace?: boolean;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
testId?: string;
|
||||
@@ -16,6 +17,7 @@ type RightAuxiliaryPaneProps = {
|
||||
export function RightAuxiliaryPane({
|
||||
canResetWidth,
|
||||
children,
|
||||
constrainToAvailableSpace = true,
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
testId,
|
||||
@@ -26,7 +28,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-(--buzz-top-chrome-height,2.5rem) 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,
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -13,13 +13,11 @@ import {
|
||||
type HomeScreenProps = {
|
||||
availableChannelIds: ReadonlySet<string>;
|
||||
currentPubkey?: string;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
};
|
||||
|
||||
export function HomeScreen({
|
||||
availableChannelIds,
|
||||
currentPubkey,
|
||||
onOpenChannel,
|
||||
}: HomeScreenProps) {
|
||||
const homeFeedQuery = useHomeFeedQuery();
|
||||
const { threadActivityItems } = useAppShell();
|
||||
@@ -70,7 +68,6 @@ export function HomeScreen({
|
||||
}
|
||||
feed={augmentedFeed}
|
||||
isLoading={homeFeedQuery.isLoading}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onRefresh={() => {
|
||||
void homeFeedQuery.refetch();
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
@@ -44,6 +46,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 { Button } from "@/shared/ui/button";
|
||||
|
||||
type HomeViewProps = {
|
||||
@@ -52,7 +58,6 @@ type HomeViewProps = {
|
||||
errorMessage?: string;
|
||||
currentPubkey?: string;
|
||||
availableChannelIds: ReadonlySet<string>;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
@@ -62,7 +67,6 @@ export function HomeView({
|
||||
errorMessage,
|
||||
currentPubkey,
|
||||
availableChannelIds,
|
||||
onOpenChannel,
|
||||
onRefresh,
|
||||
}: HomeViewProps) {
|
||||
const [homeInboxRef, homeInboxWidthPx] = useElementWidth<HTMLDivElement>();
|
||||
@@ -75,9 +79,18 @@ export function HomeView({
|
||||
);
|
||||
const [isDeletingMessage, setIsDeletingMessage] = React.useState(false);
|
||||
const [isSendingReply, setIsSendingReply] = React.useState(false);
|
||||
const [managedChannelId, setManagedChannelId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [localRepliesByItemId, setLocalRepliesByItemId] = React.useState<
|
||||
Record<string, InboxReply[]>
|
||||
>({});
|
||||
const {
|
||||
canReset: canResetThreadPanelWidth,
|
||||
onResetWidth: handleThreadPanelWidthReset,
|
||||
onResizeStart: handleThreadPanelResizeStart,
|
||||
widthPx: threadPanelWidthPx,
|
||||
} = useThreadPanelWidth();
|
||||
const {
|
||||
canResetInboxListWidth,
|
||||
handleInboxListResizeStart,
|
||||
@@ -118,6 +131,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();
|
||||
@@ -311,14 +333,24 @@ export function HomeView({
|
||||
currentPubkey?.trim().toLowerCase() ===
|
||||
selectedItem.item.pubkey.trim().toLowerCase();
|
||||
const isSinglePanelDetailView =
|
||||
isNarrowHomeViewport && selectedItemId !== null;
|
||||
const showListPane = !isSinglePanelDetailView;
|
||||
const showDetailPane = !isNarrowHomeViewport || isSinglePanelDetailView;
|
||||
isNarrowHomeViewport &&
|
||||
selectedItemId !== null &&
|
||||
!isSinglePanelChannelManagementView;
|
||||
const showListPane =
|
||||
!isSinglePanelDetailView && !isSinglePanelChannelManagementView;
|
||||
const showDetailPane =
|
||||
!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 =
|
||||
@@ -334,14 +366,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
|
||||
}
|
||||
@@ -430,7 +469,7 @@ export function HomeView({
|
||||
setIsDeletingMessage(false);
|
||||
});
|
||||
}}
|
||||
onOpenChannel={onOpenChannel}
|
||||
onOpenChannel={setManagedChannelId}
|
||||
onSendReply={async ({
|
||||
content,
|
||||
mediaTags,
|
||||
@@ -513,6 +552,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>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,7 @@ 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();
|
||||
}
|
||||
|
||||
@@ -1243,6 +1243,8 @@ 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 ingress should appear before the channel metadata rows in the DOM.
|
||||
const canvasBox = await sheet
|
||||
@@ -1252,6 +1254,25 @@ test("manage channel keeps canvas near the top of the sheet", async ({
|
||||
.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);
|
||||
@@ -1312,6 +1333,31 @@ test("home inbox channel label opens management without leaving home", async ({
|
||||
"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\//);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user