import { ActionRow } from "../../../components/ui/action-row"; import { Icon } from "../../../components/ui/icon"; import { useMemo, useSyncExternalStore } from "react"; import { getPaneSidebarWidth, getPaneSidebarWidthRange, PaneSidebar, PaneSidebarAction, PaneSidebarRow, shouldShowPaneSidebar, } from "../../../components"; import { Box, Span, Text, useUiCapabilities } from "../../../ui"; import { TextAttributes } from "../../../ui"; import { blendHex, colors } from "../../../theme/colors"; import { t } from "../../../i18n"; import type { ChatChannel } from "../../../api-client"; import { truncateWithEllipsis } from "../../../utils/text-wrap"; import { teamAccentHex, teamPrefix } from "../cloud/team/model"; import { teamStore } from "../cloud/team/store"; import type { ChatController } from "./controller"; import { chatSidebarStore } from "./sidebar-store"; import { buildChatSidebarRows } from "./sidebar-rows"; import { channelPrefix, formatChannelLabel, } from "./channels"; const DESKTOP_NOTIFICATION_ICON_WIDTH = 3; /** Leading gutter plus the one-column active marker every channel row carries. */ const CHANNEL_ROW_INDENT = 2; /** Width of a header's trailing action, so its label truncates clear of it. */ const SECTION_ACTION_WIDTH = 3; export function shouldShowChannelSidebar(channelCount: number, width: number, height: number): boolean { return shouldShowPaneSidebar(channelCount, width, height); } export function getChannelSidebarWidth( width: number, nativePaneChrome: boolean, preferredWidth?: number | null, ): number { return getPaneSidebarWidth(width, nativePaneChrome, preferredWidth); } function ChannelNotificationIcon({ enabled, onMouseDown, }: { enabled: boolean; onMouseDown?: (event: any) => void; }) { const { nativePaneChrome } = useUiCapabilities(); const iconColor = enabled ? colors.positive : colors.textMuted; if (!nativePaneChrome) { return ( {enabled ? "◖)" : "◖·"} ); } return ( ); } function ProfileIcon({ color, onMouseDown, }: { color: string; onMouseDown?: (event: any) => void; }) { const { nativePaneChrome } = useUiCapabilities(); if (!nativePaneChrome) { return ( @ ); } return ( ); } export function ChannelSidebar({ channels, channelStates, activeChannelId, cursorHeaderKey = null, width, paneWidth, height, focused, keyboardFocused, loading, canManageNotifications, canCreateConversation, needsProfileSetup = false, onSelect, onFocusRequest, onCreateConversation, onOpenProfile, onToggleNotifications, onCreateTeamChannel, }: { channels: ChatChannel[]; channelStates: ReturnType["channelStates"]; activeChannelId: string; /** The section header the keyboard cursor rests on, if it is on one. */ cursorHeaderKey?: string | null; width: number; /** Width of the whole chat pane, which caps how far the sidebar can grow. */ paneWidth: number; height: number; focused: boolean; keyboardFocused: boolean; loading: boolean; canManageNotifications: boolean; canCreateConversation: boolean; needsProfileSetup?: boolean; onSelect?: (channelId: string) => void; onFocusRequest?: () => void; onCreateConversation?: () => void; onOpenProfile?: () => void; onToggleNotifications?: (channelId: string, enabled: boolean) => void; onCreateTeamChannel?: (teamId: string) => void; }) { const { nativePaneChrome } = useUiCapabilities(); const notificationWidth = canManageNotifications ? (nativePaneChrome ? DESKTOP_NOTIFICATION_ICON_WIDTH : 2) : 0; const channelStateById = useMemo(() => new Map(channelStates.map((state) => [state.channelId, state])), [channelStates]); const hasUnread = (section: ChatChannel[]) => section.some((channel) => (channelStateById.get(channel.id)?.unreadCount ?? 0) > 0); const teamSnapshot = useSyncExternalStore( (onChange) => teamStore.subscribe(onChange), () => teamStore.getSnapshot(), ); const sidebarSnapshot = useSyncExternalStore( (onChange) => chatSidebarStore.subscribe(onChange), () => chatSidebarStore.getSnapshot(), ); const sidebarRows = useMemo(() => buildChatSidebarRows({ channels, collapsedSections: sidebarSnapshot.collapsedSections, collapsedTeams: teamSnapshot.collapsedTeams, canCreateConversation, getTeam: (teamId) => teamStore.getTeam(teamId), }), [canCreateConversation, channels, sidebarSnapshot.collapsedSections, teamSnapshot.collapsedTeams, teamSnapshot.teams]); const widthRange = getPaneSidebarWidthRange(paneWidth); return ( chatSidebarStore.setWidth(nextWidth), onResizeEnd: (nextWidth) => chatSidebarStore.commitWidth(nextWidth), }} > {({ backgroundColor: sidebarBg, listWidth }) => { const labelWidth = Math.max(listWidth - CHANNEL_ROW_INDENT - notificationWidth, 1); // The fill a keyboard-focused sidebar gives the row under its cursor. const cursorBackground = blendHex(colors.selected, colors.borderFocused, 0.32); // Every section reads the same: a caret, a label, and an optional // trailing action, so their channels can all share one indent. const sectionHeader = ({ key, label, fg, unread, expanded, onToggle, action }: { key: string; label: string; fg?: string; unread: boolean; expanded: boolean; onToggle: () => void; action?: { ariaLabel: string; onPress: () => void }; }) => { const rowWidth = Math.max(1, listWidth - (action ? SECTION_ACTION_WIDTH : 0)); return ( {action ? ( {({ foregroundColor, onMouseDown }) => ( + )} ) : null} ); }; return ( <> {sidebarRows.map((row) => { if (row.kind === "public-header") { return sectionHeader({ key: row.key, label: "Channels", unread: hasUnread(row.channels), expanded: row.expanded, onToggle: () => chatSidebarStore.toggleSectionCollapsed("public"), }); } if (row.kind === "team-header") { const canAddChannel = !!row.team && !!onCreateTeamChannel; return sectionHeader({ key: row.key, label: row.team ? `${teamPrefix(row.team)} ${row.team.name}` : "Team", fg: row.team ? teamAccentHex(row.team.accentColor) : colors.textDim, unread: hasUnread(row.channels), expanded: row.expanded, onToggle: () => teamStore.toggleTeamCollapsed(row.teamId), action: canAddChannel ? { ariaLabel: `New channel in ${row.team?.name ?? "team"}`, onPress: () => onCreateTeamChannel?.(row.teamId), } : undefined, }); } if (row.kind === "direct-header") { return sectionHeader({ key: row.key, label: "DMs", unread: hasUnread(row.channels), expanded: row.expanded, onToggle: () => chatSidebarStore.toggleSectionCollapsed("direct"), action: canCreateConversation ? { ariaLabel: "New DM", onPress: () => onCreateConversation?.() } : undefined, }); } const channel = row.channel; const active = channel.id === activeChannelId; const channelState = channelStateById.get(channel.id); const notificationsEnabled = channelState?.notificationsEnabled === true; const unread = (channelState?.unreadCount ?? 0) > 0; const label = formatChannelLabel(channel, channel.id); const selectChannel = () => { onFocusRequest?.(); onSelect?.(channel.id); }; const toggleNotifications = () => { onToggleNotifications?.(channel.id, !notificationsEnabled); }; return ( {({ foregroundColor, onMouseDown }) => ( <> {channelPrefix(channel, active)} {truncateWithEllipsis(label, labelWidth)} {canManageNotifications && ( {({ onMouseDown: onActionMouseDown }) => ( )} )} )} ); })} {needsProfileSetup && ( {({ foregroundColor, onMouseDown }) => ( <> {` ${truncateWithEllipsis(t("Profile"), Math.max(listWidth - 3, 1))}`} )} )} {loading && focused && ( {` ${t("syncing")}`} )} ); }} ); }