mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Cursor
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
Taylor Ho
npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent
6284454298
commit
9f35b01880
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user