mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
Collapse channel header actions (#901)
This commit is contained in:
@@ -177,6 +177,7 @@ export function AppShell() {
|
|||||||
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
const [isChannelManagementOpen, setIsChannelManagementOpen] =
|
||||||
React.useState(false);
|
React.useState(false);
|
||||||
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
|
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
|
||||||
|
const [topbarSearchHidden, setTopbarSearchHidden] = React.useState(false);
|
||||||
const [browseDialogType, setBrowseDialogType] =
|
const [browseDialogType, setBrowseDialogType] =
|
||||||
React.useState<BrowseDialogType>(null);
|
React.useState<BrowseDialogType>(null);
|
||||||
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
|
const [isNewDmOpen, setIsNewDmOpen] = React.useState(false);
|
||||||
@@ -675,6 +676,7 @@ export function AppShell() {
|
|||||||
unfollowThread: handleUnfollowThread,
|
unfollowThread: handleUnfollowThread,
|
||||||
isFollowingThread,
|
isFollowingThread,
|
||||||
isNotifiedForThread,
|
isNotifiedForThread,
|
||||||
|
setTopbarSearchHidden,
|
||||||
threadActivityItems,
|
threadActivityItems,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -693,6 +695,7 @@ export function AppShell() {
|
|||||||
void goChannel(channelId);
|
void goChannel(channelId);
|
||||||
}}
|
}}
|
||||||
onOpenResult={handleOpenSearchResult}
|
onOpenResult={handleOpenSearchResult}
|
||||||
|
searchHidden={topbarSearchHidden}
|
||||||
searchFocusRequest={searchFocusRequest}
|
searchFocusRequest={searchFocusRequest}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type AppShellContextValue = {
|
|||||||
unfollowThread: (rootId: string) => void;
|
unfollowThread: (rootId: string) => void;
|
||||||
isFollowingThread: (rootId: string) => boolean;
|
isFollowingThread: (rootId: string) => boolean;
|
||||||
isNotifiedForThread: (rootId: string) => boolean;
|
isNotifiedForThread: (rootId: string) => boolean;
|
||||||
|
setTopbarSearchHidden: (hidden: boolean) => void;
|
||||||
threadActivityItems: ThreadActivityItem[];
|
threadActivityItems: ThreadActivityItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ const AppShellContext = React.createContext<AppShellContextValue>({
|
|||||||
unfollowThread: () => {},
|
unfollowThread: () => {},
|
||||||
isFollowingThread: () => false,
|
isFollowingThread: () => false,
|
||||||
isNotifiedForThread: () => false,
|
isNotifiedForThread: () => false,
|
||||||
|
setTopbarSearchHidden: () => {},
|
||||||
threadActivityItems: [],
|
threadActivityItems: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type AppTopChromeProps = {
|
|||||||
onGoForward: () => void;
|
onGoForward: () => void;
|
||||||
onOpenChannel: (channelId: string) => void;
|
onOpenChannel: (channelId: string) => void;
|
||||||
onOpenResult: (hit: SearchHit) => void;
|
onOpenResult: (hit: SearchHit) => void;
|
||||||
|
searchHidden?: boolean;
|
||||||
searchFocusRequest: number;
|
searchFocusRequest: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export function AppTopChrome({
|
|||||||
onGoForward,
|
onGoForward,
|
||||||
onOpenChannel,
|
onOpenChannel,
|
||||||
onOpenResult,
|
onOpenResult,
|
||||||
|
searchHidden = false,
|
||||||
searchFocusRequest,
|
searchFocusRequest,
|
||||||
}: AppTopChromeProps) {
|
}: AppTopChromeProps) {
|
||||||
return (
|
return (
|
||||||
@@ -76,14 +78,16 @@ export function AppTopChrome({
|
|||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<TopbarSearch
|
{searchHidden ? null : (
|
||||||
channels={channels}
|
<TopbarSearch
|
||||||
className="fixed left-1/2 top-[7px] z-[45] block w-[220px] max-w-[calc(100vw-11rem)] -translate-x-1/2 md:w-[300px] md:max-w-[34vw] lg:w-[360px] lg:max-w-[38vw] xl:w-[420px] xl:max-w-[42vw] 2xl:w-[480px] 2xl:max-w-[44vw]"
|
channels={channels}
|
||||||
currentPubkey={currentPubkey}
|
className="fixed left-1/2 top-[7px] z-[45] block w-[220px] max-w-[calc(100vw-11rem)] -translate-x-1/2 md:w-[300px] md:max-w-[34vw] lg:w-[360px] lg:max-w-[38vw] xl:w-[420px] xl:max-w-[42vw] 2xl:w-[480px] 2xl:max-w-[44vw]"
|
||||||
focusRequest={searchFocusRequest}
|
currentPubkey={currentPubkey}
|
||||||
onOpenChannel={onOpenChannel}
|
focusRequest={searchFocusRequest}
|
||||||
onOpenResult={onOpenResult}
|
onOpenChannel={onOpenChannel}
|
||||||
/>
|
onOpenResult={onOpenResult}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Plus, Settings2, Users } from "lucide-react";
|
import { EllipsisVertical, Plus, Settings2, Users } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useHuddle } from "@/features/huddle";
|
import { useHuddle } from "@/features/huddle";
|
||||||
@@ -13,6 +13,12 @@ import { useChannelMembersQuery } from "@/features/channels/hooks";
|
|||||||
import type { Channel } from "@/shared/api/types";
|
import type { Channel } from "@/shared/api/types";
|
||||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/shared/ui/dropdown-menu";
|
||||||
import { AddChannelBotDialog } from "./AddChannelBotDialog";
|
import { AddChannelBotDialog } from "./AddChannelBotDialog";
|
||||||
|
|
||||||
type ChannelMembersBarProps = {
|
type ChannelMembersBarProps = {
|
||||||
@@ -20,6 +26,7 @@ type ChannelMembersBarProps = {
|
|||||||
currentPubkey?: string;
|
currentPubkey?: string;
|
||||||
onManageChannel: () => void;
|
onManageChannel: () => void;
|
||||||
onToggleMembers: () => void;
|
onToggleMembers: () => void;
|
||||||
|
variant?: "inline" | "compact";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ChannelMembersBar({
|
export function ChannelMembersBar({
|
||||||
@@ -27,6 +34,7 @@ export function ChannelMembersBar({
|
|||||||
currentPubkey,
|
currentPubkey,
|
||||||
onManageChannel,
|
onManageChannel,
|
||||||
onToggleMembers,
|
onToggleMembers,
|
||||||
|
variant = "inline",
|
||||||
}: ChannelMembersBarProps) {
|
}: ChannelMembersBarProps) {
|
||||||
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
|
const [isAddBotOpen, setIsAddBotOpen] = React.useState(false);
|
||||||
const { startHuddle, isStarting: isStartingHuddle } = useHuddle();
|
const { startHuddle, isStarting: isStartingHuddle } = useHuddle();
|
||||||
@@ -82,8 +90,71 @@ export function ChannelMembersBar({
|
|||||||
? relayAgentsQuery.error.message
|
? relayAgentsQuery.error.message
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
const huddleIndicator = (
|
||||||
<React.Fragment>
|
<HuddleIndicator
|
||||||
|
channelId={channel.id}
|
||||||
|
onStart={async () => {
|
||||||
|
try {
|
||||||
|
await startHuddle(channel.id, []);
|
||||||
|
// Refetch channels so the new ephemeral channel appears in the sidebar immediately
|
||||||
|
// (default poll interval is 60s — too slow for huddle UX).
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["channels"] });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to start huddle:", e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
renderMode={variant === "compact" ? "menu-item" : "button"}
|
||||||
|
startDisabled={!canAddAgents || isStartingHuddle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const controls =
|
||||||
|
variant === "compact" ? (
|
||||||
|
<DropdownMenu modal={false}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
aria-label="Channel actions"
|
||||||
|
className="h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
|
||||||
|
data-testid="channel-actions-menu-trigger"
|
||||||
|
size="icon"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<EllipsisVertical className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48" forceMount>
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="channel-add-bot-trigger"
|
||||||
|
disabled={!canAddAgents}
|
||||||
|
onSelect={() => {
|
||||||
|
setIsAddBotOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
<span>Add agent</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="channel-members-trigger"
|
||||||
|
onSelect={onToggleMembers}
|
||||||
|
>
|
||||||
|
<Users />
|
||||||
|
<span>Members</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{memberCount}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{huddleIndicator}
|
||||||
|
<DropdownMenuItem
|
||||||
|
data-testid="channel-management-trigger"
|
||||||
|
onSelect={onManageChannel}
|
||||||
|
>
|
||||||
|
<Settings2 />
|
||||||
|
<span>Manage channel</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
<div className="flex items-center gap-[6px]">
|
<div className="flex items-center gap-[6px]">
|
||||||
<Button
|
<Button
|
||||||
aria-label="Add agent"
|
aria-label="Add agent"
|
||||||
@@ -114,21 +185,7 @@ export function ChannelMembersBar({
|
|||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<HuddleIndicator
|
{huddleIndicator}
|
||||||
className="h-8 w-8"
|
|
||||||
channelId={channel.id}
|
|
||||||
onStart={async () => {
|
|
||||||
try {
|
|
||||||
await startHuddle(channel.id, []);
|
|
||||||
// Refetch channels so the new ephemeral channel appears in the sidebar immediately
|
|
||||||
// (default poll interval is 60s — too slow for huddle UX).
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["channels"] });
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to start huddle:", e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
startDisabled={!canAddAgents || isStartingHuddle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
aria-label="Manage channel"
|
aria-label="Manage channel"
|
||||||
@@ -142,6 +199,11 @@ export function ChannelMembersBar({
|
|||||||
<Settings2 className="size-5" />
|
<Settings2 className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
{controls}
|
||||||
|
|
||||||
<AddChannelBotDialog
|
<AddChannelBotDialog
|
||||||
backendProviders={backendProvidersQuery.data ?? []}
|
backendProviders={backendProvidersQuery.data ?? []}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
|||||||
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
|
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
|
||||||
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
|
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
|
||||||
import {
|
import {
|
||||||
useElementWidthBreakpoint,
|
useElementWidth,
|
||||||
useIsThreadPanelOverlay,
|
useIsThreadPanelOverlay,
|
||||||
} from "@/shared/hooks/use-mobile";
|
} from "@/shared/hooks/use-mobile";
|
||||||
import {
|
import {
|
||||||
@@ -61,6 +61,10 @@ import { useChannelAgentSessions } from "./useChannelAgentSessions";
|
|||||||
import { useChannelProfilePanel } from "./useChannelProfilePanel";
|
import { useChannelProfilePanel } from "./useChannelProfilePanel";
|
||||||
import { useChannelRouteTarget } from "./useChannelRouteTarget";
|
import { useChannelRouteTarget } from "./useChannelRouteTarget";
|
||||||
import type { ChannelScreenProps } from "./ChannelScreen.types";
|
import type { ChannelScreenProps } from "./ChannelScreen.types";
|
||||||
|
|
||||||
|
const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760;
|
||||||
|
const HEADER_ACTIONS_SPLIT_GUTTER_PX = 12;
|
||||||
|
|
||||||
export function ChannelScreen({
|
export function ChannelScreen({
|
||||||
activeChannel,
|
activeChannel,
|
||||||
currentIdentity,
|
currentIdentity,
|
||||||
@@ -80,6 +84,7 @@ export function ChannelScreen({
|
|||||||
unfollowThread,
|
unfollowThread,
|
||||||
isFollowingThread,
|
isFollowingThread,
|
||||||
isNotifiedForThread,
|
isNotifiedForThread,
|
||||||
|
setTopbarSearchHidden,
|
||||||
} = useAppShell();
|
} = useAppShell();
|
||||||
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
|
const [profilePanelPubkey, setProfilePanelPubkey] = React.useState<
|
||||||
string | null
|
string | null
|
||||||
@@ -92,10 +97,8 @@ export function ChannelScreen({
|
|||||||
} = useThreadPanelWidth();
|
} = useThreadPanelWidth();
|
||||||
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
|
const [isMembersSidebarOpen, setIsMembersSidebarOpen] = React.useState(false);
|
||||||
const isThreadPanelOverlay = useIsThreadPanelOverlay();
|
const isThreadPanelOverlay = useIsThreadPanelOverlay();
|
||||||
const [channelContentRef, isNarrowPanelViewport] =
|
const [channelContentRef, channelContentWidthPx] =
|
||||||
useElementWidthBreakpoint<HTMLDivElement>(
|
useElementWidth<HTMLDivElement>();
|
||||||
THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX,
|
|
||||||
);
|
|
||||||
const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>(
|
const [openThreadHeadId, setOpenThreadHeadId] = React.useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -434,21 +437,32 @@ export function ChannelScreen({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useLoadMissingAncestors(activeChannel, resolvedMessages);
|
useLoadMissingAncestors(activeChannel, resolvedMessages);
|
||||||
|
const hasAuxiliaryPanel = Boolean(
|
||||||
|
openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey,
|
||||||
|
);
|
||||||
|
const isNarrowPanelViewport =
|
||||||
|
channelContentWidthPx > 0 &&
|
||||||
|
channelContentWidthPx < THREAD_PANEL_SINGLE_COLUMN_BREAKPOINT_PX;
|
||||||
const isSinglePanelView =
|
const isSinglePanelView =
|
||||||
isNarrowPanelViewport &&
|
isNarrowPanelViewport &&
|
||||||
activeChannel?.channelType !== "forum" &&
|
activeChannel?.channelType !== "forum" &&
|
||||||
Boolean(
|
hasAuxiliaryPanel;
|
||||||
openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey,
|
|
||||||
);
|
|
||||||
const hasSplitRightPanel =
|
const hasSplitRightPanel =
|
||||||
!isSinglePanelView &&
|
!isSinglePanelView && !isThreadPanelOverlay && hasAuxiliaryPanel;
|
||||||
!isThreadPanelOverlay &&
|
const shouldCompactHeaderActions =
|
||||||
Boolean(
|
hasAuxiliaryPanel &&
|
||||||
openThreadHeadMessage || openAgentSessionPubkey || profilePanelPubkey,
|
channelContentWidthPx > 0 &&
|
||||||
);
|
channelContentWidthPx < HEADER_ACTIONS_COMPACT_BREAKPOINT_PX;
|
||||||
const headerActionsRightInset = hasSplitRightPanel
|
const splitRightPanelInset = hasSplitRightPanel
|
||||||
? `min(${threadPanelWidthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`
|
? `min(${threadPanelWidthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const headerActionsRightInset = splitRightPanelInset
|
||||||
|
? `calc(${splitRightPanelInset} + ${HEADER_ACTIONS_SPLIT_GUTTER_PX}px)`
|
||||||
|
: undefined;
|
||||||
|
React.useEffect(() => {
|
||||||
|
setTopbarSearchHidden(isSinglePanelView);
|
||||||
|
return () => setTopbarSearchHidden(false);
|
||||||
|
}, [isSinglePanelView, setTopbarSearchHidden]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}>
|
<AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}>
|
||||||
@@ -458,6 +472,7 @@ export function ChannelScreen({
|
|||||||
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
|
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
|
||||||
activeChannelTitle={activeChannelTitle}
|
activeChannelTitle={activeChannelTitle}
|
||||||
actionsRightInset={headerActionsRightInset}
|
actionsRightInset={headerActionsRightInset}
|
||||||
|
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
|
||||||
activeDmPresenceStatus={activeDmPresenceStatus}
|
activeDmPresenceStatus={activeDmPresenceStatus}
|
||||||
currentPubkey={currentPubkey}
|
currentPubkey={currentPubkey}
|
||||||
isJoining={joinChannelMutation.isPending}
|
isJoining={joinChannelMutation.isPending}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { LogIn } from "lucide-react";
|
import { LogIn } from "lucide-react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
|
|
||||||
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
import { ChatHeader } from "@/features/chat/ui/ChatHeader";
|
||||||
import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
|
import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
|
||||||
import { getChannelDescription } from "@/features/channels/lib/channelDescription";
|
import { getChannelDescription } from "@/features/channels/lib/channelDescription";
|
||||||
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
|
import { ChannelHeaderStatusBadge } from "@/features/channels/ui/ChannelHeaderStatusBadge";
|
||||||
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
|
import { ChannelMembersBar } from "@/features/channels/ui/ChannelMembersBar";
|
||||||
import { UpdateIndicator } from "@/features/settings/UpdateIndicator";
|
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
import type { Channel, PresenceStatus } from "@/shared/api/types";
|
import type { Channel, PresenceStatus } from "@/shared/api/types";
|
||||||
|
|
||||||
@@ -15,6 +13,7 @@ type ChannelScreenHeaderProps = {
|
|||||||
activeChannelEphemeralDisplay: EphemeralChannelDisplay | null;
|
activeChannelEphemeralDisplay: EphemeralChannelDisplay | null;
|
||||||
activeChannelTitle: string;
|
activeChannelTitle: string;
|
||||||
actionsRightInset?: string;
|
actionsRightInset?: string;
|
||||||
|
actionsVariant?: "inline" | "compact";
|
||||||
activeDmPresenceStatus: PresenceStatus | null;
|
activeDmPresenceStatus: PresenceStatus | null;
|
||||||
currentPubkey?: string;
|
currentPubkey?: string;
|
||||||
isJoining?: boolean;
|
isJoining?: boolean;
|
||||||
@@ -29,6 +28,7 @@ export function ChannelScreenHeader({
|
|||||||
activeChannelEphemeralDisplay,
|
activeChannelEphemeralDisplay,
|
||||||
activeChannelTitle,
|
activeChannelTitle,
|
||||||
actionsRightInset,
|
actionsRightInset,
|
||||||
|
actionsVariant = "inline",
|
||||||
activeDmPresenceStatus,
|
activeDmPresenceStatus,
|
||||||
currentPubkey,
|
currentPubkey,
|
||||||
isJoining = false,
|
isJoining = false,
|
||||||
@@ -61,22 +61,13 @@ export function ChannelScreenHeader({
|
|||||||
currentPubkey={currentPubkey}
|
currentPubkey={currentPubkey}
|
||||||
onManageChannel={onManageChannel}
|
onManageChannel={onManageChannel}
|
||||||
onToggleMembers={onToggleMembers}
|
onToggleMembers={onToggleMembers}
|
||||||
|
variant={actionsVariant}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (!showHeaderContent) {
|
if (!showHeaderContent) {
|
||||||
if (typeof document === "undefined") {
|
return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div className="fixed right-3 top-[9px] z-[45] flex shrink-0 items-center gap-1">
|
|
||||||
<UpdateIndicator />
|
|
||||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -136,16 +136,19 @@ export function ChatHeader({
|
|||||||
typeof document === "undefined" ? null : (
|
typeof document === "undefined" ? null : (
|
||||||
createPortal(topRightActions, document.body)
|
createPortal(topRightActions, document.body)
|
||||||
)
|
)
|
||||||
) : (
|
) : actionsRightInset ? (
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center gap-1"
|
className="absolute top-1/2 z-10 flex shrink-0 -translate-y-1/2 items-center gap-1"
|
||||||
style={
|
style={{ right: actionsRightInset }}
|
||||||
actionsRightInset ? { marginRight: actionsRightInset } : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<UpdateIndicator />
|
<UpdateIndicator />
|
||||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
<UpdateIndicator />
|
||||||
|
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { relayClient } from "@/shared/api/relayClient";
|
|||||||
import type { RelayEvent } from "@/shared/api/types";
|
import type { RelayEvent } from "@/shared/api/types";
|
||||||
import { cn } from "@/shared/lib/cn";
|
import { cn } from "@/shared/lib/cn";
|
||||||
import { Button } from "@/shared/ui/button";
|
import { Button } from "@/shared/ui/button";
|
||||||
|
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||||
import { useHuddle } from "../HuddleContext";
|
import { useHuddle } from "../HuddleContext";
|
||||||
import { useHeadphonesGate } from "../lib/useHeadphonesGate";
|
import { useHeadphonesGate } from "../lib/useHeadphonesGate";
|
||||||
@@ -26,6 +27,7 @@ type ActiveHuddle = {
|
|||||||
type HuddleIndicatorProps = {
|
type HuddleIndicatorProps = {
|
||||||
channelId: string;
|
channelId: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
renderMode?: "button" | "menu-item";
|
||||||
/** Called when the user clicks the button and no huddle is active (start). */
|
/** Called when the user clicks the button and no huddle is active (start). */
|
||||||
onStart?: () => void;
|
onStart?: () => void;
|
||||||
/** Whether the start action is disabled (e.g., permissions, already starting). */
|
/** Whether the start action is disabled (e.g., permissions, already starting). */
|
||||||
@@ -40,6 +42,7 @@ type HuddleIndicatorProps = {
|
|||||||
export function HuddleIndicator({
|
export function HuddleIndicator({
|
||||||
channelId,
|
channelId,
|
||||||
className,
|
className,
|
||||||
|
renderMode = "button",
|
||||||
onStart,
|
onStart,
|
||||||
startDisabled,
|
startDisabled,
|
||||||
}: HuddleIndicatorProps) {
|
}: HuddleIndicatorProps) {
|
||||||
@@ -226,6 +229,23 @@ export function HuddleIndicator({
|
|||||||
// No active huddle — render the start button (if onStart provided).
|
// No active huddle — render the start button (if onStart provided).
|
||||||
if (!activeHuddle) {
|
if (!activeHuddle) {
|
||||||
if (!onStart) return null;
|
if (!onStart) return null;
|
||||||
|
if (renderMode === "menu-item") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={className}
|
||||||
|
data-testid="channel-start-huddle-trigger"
|
||||||
|
disabled={startDisabled || isStarting}
|
||||||
|
onSelect={() => headphonesGate.gate(() => onStart())}
|
||||||
|
>
|
||||||
|
<Headphones />
|
||||||
|
<span>Start huddle</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{gateDialog}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -267,6 +287,26 @@ export function HuddleIndicator({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (renderMode === "menu-item") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={className}
|
||||||
|
data-testid="channel-start-huddle-trigger"
|
||||||
|
disabled={isJoining || isStarting}
|
||||||
|
onSelect={() => headphonesGate.gate(() => void doJoin())}
|
||||||
|
>
|
||||||
|
<Headphones />
|
||||||
|
<span>Join huddle</span>
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
{participantCount}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{gateDialog}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|||||||
@@ -11,11 +11,9 @@ const MESSAGE_BODY_OFFSET_PX = MESSAGE_TEXT_OFFSET_PX + 4;
|
|||||||
const NESTED_REPLY_OFFSET_PX = 28;
|
const NESTED_REPLY_OFFSET_PX = 28;
|
||||||
|
|
||||||
function ParticipantAvatar({
|
function ParticipantAvatar({
|
||||||
hasNextParticipant,
|
|
||||||
participant,
|
participant,
|
||||||
index,
|
index,
|
||||||
}: {
|
}: {
|
||||||
hasNextParticipant: boolean;
|
|
||||||
participant: TimelineThreadSummaryParticipant;
|
participant: TimelineThreadSummaryParticipant;
|
||||||
index: number;
|
index: number;
|
||||||
}) {
|
}) {
|
||||||
@@ -27,7 +25,7 @@ function ParticipantAvatar({
|
|||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatarUrl={participant.avatarUrl}
|
avatarUrl={participant.avatarUrl}
|
||||||
className={`h-8 w-8 text-[10px] ${hasNextParticipant ? "ring-2 ring-background" : ""}`}
|
className="h-7 w-7 text-[10px] ring-2 ring-background"
|
||||||
displayName={participant.author}
|
displayName={participant.author}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
@@ -100,7 +98,6 @@ export function MessageThreadSummaryRow({
|
|||||||
<div className="flex shrink-0 items-center">
|
<div className="flex shrink-0 items-center">
|
||||||
{summary.participants.map((participant, index) => (
|
{summary.participants.map((participant, index) => (
|
||||||
<ParticipantAvatar
|
<ParticipantAvatar
|
||||||
hasNextParticipant={index < summary.participants.length - 1}
|
|
||||||
index={index}
|
index={index}
|
||||||
key={participant.id}
|
key={participant.id}
|
||||||
participant={participant}
|
participant={participant}
|
||||||
|
|||||||
@@ -486,7 +486,7 @@ test("opens a single-level thread panel with inline expansion", async ({
|
|||||||
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
return `${Math.round(rect.width)}x${Math.round(rect.height)}`;
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.toBe("32x32");
|
.toBe("28x28");
|
||||||
|
|
||||||
await page.mouse.move(0, 0);
|
await page.mouse.move(0, 0);
|
||||||
const rootSummaryWidthBeforeHover = await rootSummaryRow.evaluate((row) =>
|
const rootSummaryWidthBeforeHover = await rootSummaryRow.evaluate((row) =>
|
||||||
@@ -718,6 +718,76 @@ test("thread panel width uses session storage and reset handle", async ({
|
|||||||
.toBe(defaultWidthPx);
|
.toBe(defaultWidthPx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("narrow thread view collapses channel header actions into a menu", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 980, height: 720 });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("channel-general").click();
|
||||||
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||||
|
await expect(page.getByTestId("channel-add-bot-trigger")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("channel-actions-menu-trigger")).toHaveCount(0);
|
||||||
|
|
||||||
|
const rootMessage = page.locator('[data-message-id="mock-general-alice"]');
|
||||||
|
const threadPanel = page.getByTestId("message-thread-panel");
|
||||||
|
|
||||||
|
await rootMessage.hover();
|
||||||
|
await page.getByTestId("reply-message-mock-general-alice").click();
|
||||||
|
await expect(threadPanel).toBeVisible();
|
||||||
|
await expect(threadPanel.getByTestId("message-thread-back")).toHaveCount(0);
|
||||||
|
|
||||||
|
const menuTrigger = page.getByTestId("channel-actions-menu-trigger");
|
||||||
|
await expect(menuTrigger).toBeVisible();
|
||||||
|
await expect(page.getByTestId("channel-add-bot-trigger")).toBeHidden();
|
||||||
|
await expect(page.getByTestId("channel-members-trigger")).toBeHidden();
|
||||||
|
await expect(page.getByTestId("channel-management-trigger")).toBeHidden();
|
||||||
|
|
||||||
|
const menuBox = await menuTrigger.boundingBox();
|
||||||
|
const threadPanelBox = await threadPanel.boundingBox();
|
||||||
|
if (!menuBox || !threadPanelBox) {
|
||||||
|
throw new Error("Expected header action menu and thread panel bounds");
|
||||||
|
}
|
||||||
|
const menuGapPx = threadPanelBox.x - (menuBox.x + menuBox.width);
|
||||||
|
expect(menuGapPx).toBeGreaterThanOrEqual(10);
|
||||||
|
expect(menuGapPx).toBeLessThanOrEqual(14);
|
||||||
|
|
||||||
|
await menuTrigger.click();
|
||||||
|
|
||||||
|
await expect(page.getByTestId("channel-add-bot-trigger")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("channel-members-trigger")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("channel-start-huddle-trigger")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("channel-management-trigger")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("single-panel thread view hides topbar search and channel actions", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 860, height: 720 });
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByTestId("channel-general").click();
|
||||||
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||||
|
await expect(page.getByTestId("open-search")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("channel-add-bot-trigger")).toBeVisible();
|
||||||
|
|
||||||
|
const rootMessage = page.locator('[data-message-id="mock-general-alice"]');
|
||||||
|
const threadPanel = page.getByTestId("message-thread-panel");
|
||||||
|
|
||||||
|
await rootMessage.hover();
|
||||||
|
await page.getByTestId("reply-message-mock-general-alice").click();
|
||||||
|
await expect(threadPanel).toBeVisible();
|
||||||
|
await expect(threadPanel.getByTestId("message-thread-back")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("open-search")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("channel-actions-menu-trigger")).toHaveCount(0);
|
||||||
|
await expect(page.getByTestId("channel-add-bot-trigger")).toHaveCount(0);
|
||||||
|
|
||||||
|
await threadPanel.getByTestId("message-thread-back").click();
|
||||||
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||||
|
await expect(page.getByTestId("open-search")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("channel-add-bot-trigger")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("composer is focused after selecting a channel", async ({ page }) => {
|
test("composer is focused after selecting a channel", async ({ page }) => {
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByTestId("channel-general").click();
|
await page.getByTestId("channel-general").click();
|
||||||
|
|||||||
Reference in New Issue
Block a user