/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import {useLingui} from '@lingui/react/macro'; import { ArrowLeftIcon, CaretRightIcon, EyeSlashIcon, ListIcon, MagnifyingGlassIcon, PencilIcon, PhoneIcon, StarIcon, UserPlusIcon, UsersIcon, VideoCameraIcon, } from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import React from 'react'; import * as CallActionCreators from '~/actions/CallActionCreators'; import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators'; import * as FavoritesActionCreators from '~/actions/FavoritesActionCreators'; import * as LayoutActionCreators from '~/actions/LayoutActionCreators'; import * as ModalActionCreators from '~/actions/ModalActionCreators'; import {modal} from '~/actions/ModalActionCreators'; import * as ToastActionCreators from '~/actions/ToastActionCreators'; import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators'; import {ChannelTypes, ME, RelationshipTypes} from '~/Constants'; import {ChannelDetailsBottomSheet} from '~/components/bottomsheets/ChannelDetailsBottomSheet'; import {MessageSearchBar} from '~/components/channel/MessageSearchBar'; import {GroupDMAvatar} from '~/components/common/GroupDMAvatar'; import {NativeDragRegion} from '~/components/layout/NativeDragRegion'; import {AddFriendsToGroupModal} from '~/components/modals/AddFriendsToGroupModal'; import {ChannelTopicModal} from '~/components/modals/ChannelTopicModal'; import {CreateDMModal} from '~/components/modals/CreateDMModal'; import {EditGroupModal} from '~/components/modals/EditGroupModal'; import FocusRing from '~/components/uikit/FocusRing/FocusRing'; import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar'; import {useCanFitMemberList} from '~/hooks/useMemberListVisible'; import {useTextOverflow} from '~/hooks/useTextOverflow'; import {ComponentDispatch} from '~/lib/ComponentDispatch'; import {SafeMarkdown} from '~/lib/markdown'; import {MarkdownContext} from '~/lib/markdown/renderers'; import {useLocation} from '~/lib/router/react'; import {Routes} from '~/Routes'; import type {ChannelRecord} from '~/records/ChannelRecord'; import AccessibilityStore from '~/stores/AccessibilityStore'; import CallStateStore from '~/stores/CallStateStore'; import FavoritesStore from '~/stores/FavoritesStore'; import MemberListStore from '~/stores/MemberListStore'; import MobileLayoutStore from '~/stores/MobileLayoutStore'; import RelationshipStore from '~/stores/RelationshipStore'; import MediaEngineStore from '~/stores/voice/MediaEngineFacade'; import markupStyles from '~/styles/Markup.module.css'; import * as CallUtils from '~/utils/CallUtils'; import * as ChannelUtils from '~/utils/ChannelUtils'; import {MAX_GROUP_DM_RECIPIENTS} from '~/utils/groupDmUtils'; import * as RouterUtils from '~/utils/RouterUtils'; import type {SearchSegment} from '~/utils/SearchSegmentManager'; import {UserTag} from '../channel/UserTag'; import {ChannelContextMenu} from '../uikit/ContextMenu/ChannelContextMenu'; import {MenuGroup} from '../uikit/ContextMenu/MenuGroup'; import {MenuItem} from '../uikit/ContextMenu/MenuItem'; import {Tooltip} from '../uikit/Tooltip/Tooltip'; import {CallButtons} from './ChannelHeader/CallButtons'; import {ChannelHeaderIcon} from './ChannelHeader/ChannelHeaderIcon'; import {ChannelNotificationSettingsButton} from './ChannelHeader/ChannelNotificationSettingsButton'; import {ChannelPinsButton} from './ChannelHeader/ChannelPinsButton'; import {UpdaterIcon} from './ChannelHeader/UpdaterIcon'; import {InboxButton} from './ChannelHeader/UtilityButtons'; import styles from './ChannelHeader.module.css'; import {useChannelHeaderData} from './channel-header/useChannelHeaderData'; const {VoiceCallButton, VideoCallButton} = CallButtons; interface ChannelHeaderProps { channel?: ChannelRecord; leftContent?: React.ReactNode; showMembersToggle?: boolean; showPins?: boolean; onSearchSubmit?: (query: string, segments: Array) => void; onSearchClose?: () => void; isSearchResultsOpen?: boolean; forceVoiceCallStyle?: boolean; } export const ChannelHeader = observer( ({ channel, leftContent, showMembersToggle = false, showPins = true, onSearchSubmit, onSearchClose, isSearchResultsOpen, forceVoiceCallStyle = false, }: ChannelHeaderProps) => { const {t, i18n} = useLingui(); const location = useLocation(); const {isMembersOpen} = MemberListStore; const isMobile = MobileLayoutStore.isMobileLayout(); const isCallChannelConnected = Boolean(MediaEngineStore.connected && MediaEngineStore.channelId === channel?.id); const isVoiceCallActive = !isMobile && Boolean( channel && (channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM) && isCallChannelConnected && CallStateStore.hasActiveCall(channel.id), ); const isVoiceHeaderActive = isVoiceCallActive || forceVoiceCallStyle; const canFitMemberList = useCanFitMemberList(); const [channelDetailsOpen, setChannelDetailsOpen] = React.useState(false); const [openSearchImmediately, setOpenSearchImmediately] = React.useState(false); const [initialTab, setInitialTab] = React.useState<'members' | 'pins'>('members'); const [searchQuery, setSearchQuery] = React.useState(''); const [searchSegments, setSearchSegments] = React.useState>([]); const latestSearchQueryRef = React.useRef(''); const latestSearchSegmentsRef = React.useRef>([]); const topicButtonRef = React.useRef(null); const [isTopicOverflowing, setIsTopicOverflowing] = React.useState(false); React.useEffect(() => { latestSearchQueryRef.current = searchQuery; latestSearchSegmentsRef.current = searchSegments; }, [searchQuery, searchSegments]); const searchInputRef = React.useRef(null); const dmNameRef = React.useRef(null); const groupDMNameRef = React.useRef(null); const guildChannelNameRef = React.useRef(null); const isDMNameOverflowing = useTextOverflow(dmNameRef); const isGroupDMNameOverflowing = useTextOverflow(groupDMNameRef); const isGuildChannelNameOverflowing = useTextOverflow(guildChannelNameRef); const { isDM, isGroupDM, isPersonalNotes, isGuildChannel, isVoiceChannel, recipient, directMessageName, groupDMName, channelName, } = useChannelHeaderData(channel); const isBotDMRecipient = isDM && recipient?.bot; const isFavorited = channel && !isPersonalNotes ? !!FavoritesStore.getChannel(channel.id) : false; const handleOpenCreateGroupDM = React.useCallback(() => { if (!channel) return; const initialRecipientIds = Array.from(channel.recipientIds); const excludeChannelId = channel.type === ChannelTypes.GROUP_DM ? channel.id : undefined; ModalActionCreators.push( modal(() => ( )), ); }, [channel]); const handleOpenEditGroup = React.useCallback(() => { if (!channel) return; ModalActionCreators.push(modal(() => )); }, [channel]); const handleOpenAddFriendsToGroup = React.useCallback(() => { if (!channel) return; ModalActionCreators.push(modal(() => )); }, [channel]); const handleToggleMembers = React.useCallback(() => { if (!canFitMemberList) return; LayoutActionCreators.toggleMembers(!isMembersOpen); }, [isMembersOpen, canFitMemberList]); React.useEffect(() => { const handleChannelDetailsOpen = (payload?: unknown) => { const {initialTab} = (payload ?? {}) as {initialTab?: 'members' | 'pins'}; setInitialTab(initialTab || 'members'); setOpenSearchImmediately(false); setChannelDetailsOpen(true); }; return ComponentDispatch.subscribe('CHANNEL_DETAILS_OPEN', handleChannelDetailsOpen); }, []); React.useEffect(() => { if (!showMembersToggle) return; return ComponentDispatch.subscribe('CHANNEL_MEMBER_LIST_TOGGLE', () => { if (canFitMemberList) { LayoutActionCreators.toggleMembers(!isMembersOpen); } }); }, [showMembersToggle, canFitMemberList, isMembersOpen]); React.useEffect(() => { if (!channel?.topic) { setIsTopicOverflowing(false); return; } const el = topicButtonRef.current; if (!el) return; const checkOverflow = () => { const {scrollWidth, clientWidth} = el; setIsTopicOverflowing(scrollWidth - clientWidth > 1); }; checkOverflow(); const resizeObserver = new ResizeObserver(checkOverflow); resizeObserver.observe(el); return () => { resizeObserver.disconnect(); }; }, [channel?.topic]); const handleOpenUserProfile = React.useCallback(() => { if (!recipient) return; UserProfileActionCreators.openUserProfile(recipient.id); }, [recipient]); const handleBackClick = React.useCallback(() => { if (isDM || isGroupDM || isPersonalNotes) { RouterUtils.transitionTo(Routes.ME); } else if (Routes.isFavoritesRoute(location.pathname)) { RouterUtils.transitionTo(Routes.FAVORITES); } else if (isGuildChannel && channel?.guildId) { RouterUtils.transitionTo(Routes.guildChannel(channel.guildId)); } else { window.history.back(); } }, [isDM, isGroupDM, isPersonalNotes, isGuildChannel, channel?.guildId, location.pathname]); const handleChannelDetailsClick = () => { setInitialTab('members'); setOpenSearchImmediately(false); setChannelDetailsOpen(true); }; const handleSearchClick = () => { setInitialTab('members'); setOpenSearchImmediately(true); setChannelDetailsOpen(true); }; const handleContextMenu = React.useCallback( (event: React.MouseEvent) => { if (channel && isGuildChannel) { event.preventDefault(); event.stopPropagation(); ContextMenuActionCreators.openFromEvent(event, ({onClose}) => ( )); } }, [channel, isGuildChannel], ); const handleMobileVoiceCall = React.useCallback( async (event: React.MouseEvent) => { if (!channel) return; const isConnected = MediaEngineStore.connected; const connectedChannelId = MediaEngineStore.channelId; const isInCall = isConnected && connectedChannelId === channel.id; if (isInCall) { void CallActionCreators.leaveCall(channel.id); } else if (CallStateStore.hasActiveCall(channel.id)) { CallActionCreators.joinCall(channel.id); } else { const silent = event.shiftKey; await CallUtils.checkAndStartCall(channel.id, silent); } }, [channel], ); const handleMobileVideoCall = React.useCallback( async (event: React.MouseEvent) => { if (!channel) return; const isConnected = MediaEngineStore.connected; const connectedChannelId = MediaEngineStore.channelId; const isInCall = isConnected && connectedChannelId === channel.id; if (isInCall) { void CallActionCreators.leaveCall(channel.id); } else if (CallStateStore.hasActiveCall(channel.id)) { CallActionCreators.joinCall(channel.id); } else { const silent = event.shiftKey; await CallUtils.checkAndStartCall(channel.id, silent); } }, [channel], ); const handleToggleFavorite = React.useCallback(() => { if (!channel || isPersonalNotes) return; if (isFavorited) { FavoritesStore.removeChannel(channel.id); ToastActionCreators.createToast({type: 'success', children: t`Channel removed from favorites`}); } else { FavoritesStore.addChannel(channel.id, channel.guildId ?? ME); ToastActionCreators.createToast({type: 'success', children: t`Channel added to favorites`}); } }, [channel, isPersonalNotes, isFavorited]); const handleFavoriteContextMenu = React.useCallback( (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); ContextMenuActionCreators.openFromEvent(event, ({onClose}) => ( } onClick={() => { onClose(); FavoritesActionCreators.confirmHideFavorites(undefined, i18n); }} danger > {t`Hide Favorites`} )); }, [t], ); const isGroupDMFull = channel ? channel.recipientIds.length + 1 >= MAX_GROUP_DM_RECIPIENTS : false; const isFriendDM = isDM && recipient && !isBotDMRecipient && RelationshipStore.getRelationship(recipient.id)?.type === RelationshipTypes.FRIEND; const shouldShowCreateGroupButton = !!channel && !isMobile && !isPersonalNotes && isFriendDM && !isGroupDM; const shouldShowAddFriendsButton = !!channel && !isMobile && !isPersonalNotes && isGroupDM && !isGroupDMFull; return ( <>
{isMobile ? ( ) : ( )}
{leftContent ? ( leftContent ) : channel ? ( isMobile ? ( ) : isDM && recipient ? ( ) : isGroupDM ? ( isMobile ? (
{groupDMName}
) : (
{ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleOpenEditGroup(); } }} >
{groupDMName}
) ) : isPersonalNotes ? (
{ChannelUtils.getIcon(channel, {className: styles.channelIcon})} {channelName}
) : ( // biome-ignore lint/a11y/noStaticElementInteractions: Context menu requires onContextMenu handler on this container
{ChannelUtils.getIcon(channel, {className: styles.channelIcon})} {channelName} {channel.topic && ( <>
ModalActionCreators.push(modal(() => )) } onKeyDown={(e) => e.key === 'Enter' && ModalActionCreators.push(modal(() => )) } tabIndex={0} >
)}
) ) : null}
{isMobile && channel && !isPersonalNotes && AccessibilityStore.showFavorites && ( )} {isMobile && (isDM || isGroupDM) && !isPersonalNotes && ( <> )} {isMobile && isGuildChannel && ( )} {channel && !isMobile && !isPersonalNotes && AccessibilityStore.showFavorites && ( )} {channel && isGuildChannel && !isMobile && !isVoiceChannel && !isPersonalNotes && ( )} {showPins && channel && !isMobile && } {(isDM || isGroupDM) && channel && !isMobile && !(isDM && isBotDMRecipient) && ( <> )} {shouldShowCreateGroupButton && ( )} {shouldShowAddFriendsButton && ( )} {showMembersToggle && !isMobile && ( )} {!isMobile && channel && !isVoiceChannel && (
{ setSearchQuery(query); setSearchSegments(segments); latestSearchQueryRef.current = query; latestSearchSegmentsRef.current = segments; }} onSearch={() => { const q = latestSearchQueryRef.current; if (q.trim()) { onSearchSubmit?.(q, latestSearchSegmentsRef.current); } }} onClear={() => { setSearchQuery(''); setSearchSegments([]); latestSearchQueryRef.current = ''; latestSearchSegmentsRef.current = []; onSearchClose?.(); }} isResultsOpen={Boolean(isSearchResultsOpen)} onCloseResults={() => onSearchClose?.()} inputRefExternal={searchInputRef} />
)} {!isMobile && } {!isMobile && }
{channel && ( { setChannelDetailsOpen(false); setOpenSearchImmediately(false); setInitialTab('members'); }} channel={channel} initialTab={initialTab} openSearchImmediately={openSearchImmediately} /> )} ); }, );