Files
fluxer/fluxer_app/src/features/app/components/layout/ChannelItem.tsx
T

827 lines
33 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import Accessibility, {ChannelTypingIndicatorMode} from '@app/features/accessibility/state/Accessibility';
import styles from '@app/features/app/components/layout/ChannelItem.module.css';
import {ChannelItemContent} from '@app/features/app/components/layout/ChannelItemContent';
import {ChannelItemIcon} from '@app/features/app/components/layout/ChannelItemIcon';
import channelItemSurfaceStyles from '@app/features/app/components/layout/ChannelItemSurface.module.css';
import {
DirectSelectionSurface,
markDirectSelection,
peekDirectSelection,
} from '@app/features/app/components/layout/DirectSelectionOrigin';
import {
type ChannelReorderTarget,
canChannelDropOnTarget,
selectChannelReorderResolution,
} from '@app/features/app/components/layout/dnd/ChannelReorderStateMachine';
import {GenericChannelItem} from '@app/features/app/components/layout/GenericChannelItem';
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
import {type DragItem, DragItemType, type DropResult} from '@app/features/app/components/layout/types/DndTypes';
import {isCategory, isTextChannel} from '@app/features/app/components/layout/utils/ChannelOrganization';
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
import {VoiceChannelUserCount} from '@app/features/app/components/layout/VoiceChannelUserCount';
import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload';
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
import {useTextOverflow} from '@app/features/app/hooks/useTextOverflow';
import * as LinkChannelCommands from '@app/features/channel/commands/LinkChannelCommands';
import {CategoryBottomSheet} from '@app/features/channel/components/bottomsheets/CategoryBottomSheet';
import {ChannelBottomSheet} from '@app/features/channel/components/bottomsheets/ChannelBottomSheet';
import {Typing} from '@app/features/channel/components/ChannelTyping';
import {ChannelCreateModal} from '@app/features/channel/components/modals/ChannelCreateModal';
import {ChannelSettingsModal} from '@app/features/channel/components/modals/ChannelSettingsModal';
import {getTypingText, usePresentableTypingUsers} from '@app/features/channel/components/TypingUsers';
import type {Channel} from '@app/features/channel/models/Channel';
import Channels from '@app/features/channel/state/Channels';
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
import type {Guild} from '@app/features/guild/models/Guild';
import {
CREATE_CHANNEL_DESCRIPTOR,
MENTION_COUNT_ARIA_DESCRIPTOR,
} from '@app/features/i18n/utils/CommonMessageDescriptors';
import {isKeyboardActivationKey, stopPropagationOnEnterSpace} from '@app/features/input/utils/KeyboardUtils';
import {InviteModal} from '@app/features/invite/components/modals/InviteModal';
import * as InviteUtils from '@app/features/invite/utils/InviteUtils';
import * as GuildMemberCommands from '@app/features/member/commands/GuildMemberCommands';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import Permission from '@app/features/permissions/state/Permission';
import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
import ReadStates from '@app/features/read_state/state/ReadStates';
import Autocomplete from '@app/features/search/state/Autocomplete';
import {CategoryContextMenu} from '@app/features/ui/action_menu/CategoryContextMenu';
import {ChannelContextMenu} from '@app/features/ui/action_menu/ChannelContextMenu';
import {AvatarStack} from '@app/features/ui/avatars/AvatarStack';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import * as LayoutCommands from '@app/features/ui/commands/LayoutCommands';
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
import {modal} from '@app/features/ui/commands/ModalCommands';
import {MentionBadge} from '@app/features/ui/components/MentionBadge';
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
import MobileLayout from '@app/features/ui/state/MobileLayout';
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import {CHANNEL_SETTINGS_LABEL_DESCRIPTOR} from '@app/features/user/components/settings_utils/ChannelSettingsConstants';
import type {User} from '@app/features/user/models/User';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import Users from '@app/features/user/state/Users';
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
import {usePendingVoiceConnection} from '@app/features/voice/hooks/usePendingVoiceConnection';
import {computeChannelE2EEStatus} from '@app/features/voice/state/ChannelE2EEStatus';
import CompactVoiceCallHeight, {getGuildVoiceCallExpansionKey} from '@app/features/voice/state/CompactVoiceCallHeight';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {CaretDownIcon, ChatTeardropIcon, GearIcon, PlusIcon, UserPlusIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import type {ConnectableElement} from 'react-dnd';
import {useDrag, useDrop} from 'react-dnd';
import {getEmptyImage} from 'react-dnd-html5-backend';
const CATEGORY_DESCRIPTOR = msg({
message: 'category',
comment: 'Lowercase channel type label used inside channel-list accessible text.',
});
const VOICE_CHANNEL_DESCRIPTOR = msg({
message: 'voice channel',
comment: 'Lowercase channel type label used inside channel-list accessible text.',
});
const CHANNEL_DESCRIPTOR = msg({
message: 'channel',
comment: 'Lowercase generic channel type label used inside channel-list accessible text.',
});
const SELECTED_DESCRIPTOR = msg({
message: 'selected',
comment: 'Lowercase state label used inside channel-list accessible text for the selected channel.',
});
const COLLAPSED_DESCRIPTOR = msg({
message: 'collapsed',
comment: 'Lowercase state label used inside channel-list accessible text for a collapsed category.',
});
const EXPANDED_DESCRIPTOR = msg({
message: 'expanded',
comment: 'Lowercase state label used inside channel-list accessible text for an expanded category.',
});
const UNREAD_DESCRIPTOR = msg({
message: 'unread',
comment: 'Lowercase state label used inside channel-list accessible text when a channel has unread messages.',
});
const MUTED_DESCRIPTOR = msg({
message: 'muted',
comment: 'Lowercase state label used inside channel-list accessible text when a channel is muted.',
});
const CONNECTED_DESCRIPTOR = msg({
message: 'connected',
comment: 'Lowercase state label used inside channel-list accessible text for the current voice channel.',
});
const EDIT_CATEGORY_DESCRIPTOR = msg({
message: 'Edit category',
comment: 'Tooltip and accessible label for the category settings button in the channel list.',
});
const INVITE_MEMBERS_DESCRIPTOR = msg({
message: 'Invite members',
comment: 'Tooltip and accessible label for the invite button shown beside a channel in the channel list.',
});
const OPEN_CHAT_DESCRIPTOR = msg({
message: 'Open chat',
comment: 'Tooltip and accessible label for the chat icon shown beside a voice channel in the channel list.',
});
const EMPTY_TYPING_USERS: ReadonlyArray<User> = Object.freeze([]);
interface ChannelItemCoreProps {
channel: {
name: string;
type: number;
};
isSelected?: boolean;
forceHover?: boolean;
typingIndicator?: React.ReactNode;
className?: string;
}
export const ChannelItemCore: React.FC<ChannelItemCoreProps> = observer(
({channel, isSelected = false, forceHover = false, typingIndicator, className}) => {
const channelLabelRef = useRef<HTMLSpanElement>(null);
const isChannelNameOverflowing = useTextOverflow(channelLabelRef);
return (
<div
className={clsx(
styles.channelItemCore,
isSelected ? styles.channelItemCoreSelected : styles.channelItemCoreUnselected,
!isSelected && forceHover && styles.channelItemCoreHovered,
className,
)}
data-flx="app.channel-item.channel-item-core.channel-item-core"
>
<Tooltip text={channel.name} data-flx="app.channel-item.channel-item-core.tooltip">
<div data-flx="app.channel-item.channel-item-core.div">
{ChannelUtils.getIcon(channel, {
className: clsx(
styles.channelItemIcon,
isSelected ? styles.channelItemIconSelected : styles.channelItemIconUnselected,
),
})}
</div>
</Tooltip>
<Tooltip
text={isChannelNameOverflowing ? channel.name : ''}
data-flx="app.channel-item.channel-item-core.tooltip--2"
>
<span
ref={channelLabelRef}
className={styles.channelItemLabel}
data-flx="app.channel-item.channel-item-core.channel-item-label"
>
{channel.name}
</span>
</Tooltip>
<div className={styles.channelItemActions} data-flx="app.channel-item.channel-item-core.channel-item-actions">
{typingIndicator}
</div>
</div>
);
},
);
export interface ChannelItemProps {
guild: Guild;
channel: Channel;
isCollapsed?: boolean;
onToggle?: () => void;
isDraggingAnything: boolean;
activeDragItem?: DragItem | null;
onChannelDrop?: (item: DragItem, result: DropResult) => void;
onDragStateChange?: (item: DragItem | null) => void;
isSelectedByPath?: boolean;
isOnMembersRoute?: boolean;
}
export const ChannelItem = observer(
({
guild,
channel,
isCollapsed,
onToggle,
isDraggingAnything,
activeDragItem,
onChannelDrop,
onDragStateChange,
isSelectedByPath = false,
isOnMembersRoute = false,
}: ChannelItemProps) => {
const {i18n} = useLingui();
const elementRef = useRef<HTMLDivElement | null>(null);
const dropTargetRef = useRef<HTMLDivElement | null>(null);
const contextMenuOpen = useContextMenuHoverState(elementRef, !MobileLayout.enabled);
const channelType = channel.type;
const channelIsCategory = isCategory(channel);
const channelIsVoice = channelType === ChannelTypes.GUILD_VOICE;
const channelIsText = isTextChannel(channel);
const {scheduleChannelPreload, cancelChannelPreload, preloadChannelNow} = useChannelHoverPreload({
channel,
guild,
defaultHiddenForChannel: channelIsVoice,
enabled: !channelIsCategory,
});
const draggingChannel = activeDragItem?.type === DragItemType.CHANNEL ? activeDragItem : null;
const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE;
const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null);
const unreadCount = ReadStates.getUnreadCount(channel.id);
const hasUnread = ReadStates.hasUnread(channel.id);
const connectedVoiceGuildId = channelIsVoice ? MediaEngine.guildId : null;
const connectedVoiceChannelId = channelIsVoice ? MediaEngine.channelId : null;
const canManageChannels = Permission.can(Permissions.MANAGE_CHANNELS, channel);
const canUpdateRtcRegion = channelIsVoice && Permission.can(Permissions.UPDATE_RTC_REGION, channel);
const canEditChannel = canManageChannels || canUpdateRtcRegion;
const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId);
const mobileLayout = MobileLayout;
const isMuted = UserGuildSettings.isGuildOrChannelMuted(guild.id, channel.id);
const isChannelDirectlyMuted = UserGuildSettings.isChannelDirectlyMuted(guild.id, channel.id);
const currentUserCount =
channelIsVoice && channel.userLimit != null && channel.userLimit > 0
? Object.keys(MediaEngine.getAllVoiceStatesInChannel(guild.id, channel.id)).length
: 0;
const isMobileLayout = mobileLayout.enabled;
const allowHoverAffordances = !isMobileLayout;
const [menuOpen, setMenuOpen] = useState(false);
const [categoryMenuOpen, setCategoryMenuOpen] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const [actionModalOpen, setActionModalOpen] = useState(false);
const {keyboardModeEnabled} = KeyboardMode;
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
const revealActionButtons = showKeyboardAffordances || actionModalOpen;
const actionButtonTabIndex = revealActionButtons ? 0 : -1;
const isVoiceSelected =
channelIsVoice && connectedVoiceGuildId === guild.id && connectedVoiceChannelId === channel.id;
const channelIsE2EEEncrypted = isVoiceSelected && computeChannelE2EEStatus(guild.id, channel.id) === 'encrypted';
const isSelected = isVoiceSelected || (isSelectedByPath && !isOnMembersRoute);
const mentionCount = ReadStates.getMentionCount(channel.id);
const unreadBadgesLevel = UserGuildSettings.resolvedUnreadBadgesLevel({
id: channel.id,
guildId: channel.guildId ?? undefined,
parentId: channel.parentId ?? undefined,
type: channel.type,
});
const unreadState = getChannelUnreadState({
hasUnread,
unreadCount,
mentionCount,
isMuted: isChannelDirectlyMuted,
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
unreadBadgesLevel,
});
const hasUnreadMessages = unreadState.hasUnreadMessages;
const hasMentions = unreadState.hasMentions;
const isHighlight = unreadState.isHighlight;
let scrollIndicatorSeverity: ScrollIndicatorSeverity | undefined;
if (!channelIsCategory) {
if (hasMentions) scrollIndicatorSeverity = 'mention';
else if (unreadState.shouldShowUnreadIndicator) scrollIndicatorSeverity = 'unread';
}
const scrollIndicatorId = `channel-${channel.id}`;
const isAutocompleteHighlight = Autocomplete.highlightChannelId === channel.id;
const channelTypingIndicatorMode = Accessibility.channelTypingIndicatorMode;
const typingIndicatorEligible =
!channelIsCategory && channelTypingIndicatorMode !== ChannelTypingIndicatorMode.HIDDEN;
const typingUsers = typingIndicatorEligible ? usePresentableTypingUsers(channel) : EMPTY_TYPING_USERS;
const [dropIndicator, setDropIndicator] = useState<{position: 'top' | 'bottom'; isValid: boolean} | null>(null);
const dragItemData = useMemo<DragItem>(
() => ({
type: channelIsCategory ? DragItemType.CATEGORY : DragItemType.CHANNEL,
id: channel.id,
channelType: channel.type,
parentId: channel.parentId,
guildId: guild.id,
}),
[channelIsCategory, channel.id, channel.type, channel.parentId, guild.id],
);
const dropTargetData = useMemo<ChannelReorderTarget>(
() => ({
id: channel.id,
channelType: channel.type,
parentId: channel.parentId,
guildId: guild.id,
}),
[channel.id, channel.type, channel.parentId, guild.id],
);
const dndEnabled = !mobileLayout.enabled;
const [{isDragging}, dragRef, preview] = useDrag(
() => ({
type: dragItemData.type,
item: () => {
onDragStateChange?.(dragItemData);
return dragItemData;
},
canDrag: dndEnabled && canManageChannels,
collect: (monitor) => ({isDragging: monitor.isDragging()}),
end: () => {
onDragStateChange?.(null);
setDropIndicator(null);
},
}),
[dragItemData, canManageChannels, dndEnabled, onDragStateChange],
);
const [{isOver}, dropRef] = useDrop(
() => ({
accept: [DragItemType.CHANNEL, DragItemType.CATEGORY, DragItemType.VOICE_PARTICIPANT],
canDrop: (item: DragItem) => canChannelDropOnTarget(item, dropTargetData),
hover: (item: DragItem, monitor) => {
const node = dropTargetRef.current;
if (!node) return;
const hoverBoundingRect = node.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const resolution = selectChannelReorderResolution(item, dropTargetData, clientOffset, hoverBoundingRect);
setDropIndicator(resolution.indicator);
},
drop: (item: DragItem, monitor): DropResult | undefined => {
if (!monitor.canDrop()) {
setDropIndicator(null);
return;
}
if (item.type === DragItemType.VOICE_PARTICIPANT && channelIsVoice) {
const canMove = Permission.can(Permissions.MOVE_MEMBERS, {guildId: guild.id});
if (!canMove || item.currentChannelId === channel.id) {
setDropIndicator(null);
return;
}
const currentUserId = Users.getCurrentUser()?.id ?? null;
if (currentUserId && item.userId === currentUserId) {
void MediaEngine.connectToVoiceChannel(guild.id, channel.id);
setDropIndicator(null);
return;
}
const targetChannel = Channels.getChannel(channel.id);
if (targetChannel) {
const canTargetConnect = PermissionUtils.can(Permissions.CONNECT, item.userId!, targetChannel.toJSON());
if (!canTargetConnect) {
setDropIndicator(null);
return;
}
}
void GuildMemberCommands.update(guild.id, item.userId!, {channel_id: channel.id});
setDropIndicator(null);
return;
}
const node = dropTargetRef.current;
if (!node) return;
const hoverBoundingRect = node.getBoundingClientRect();
const clientOffset = monitor.getClientOffset();
if (!clientOffset) return;
const result = selectChannelReorderResolution(item, dropTargetData, clientOffset, hoverBoundingRect).intent
?.result;
if (!result) {
setDropIndicator(null);
return;
}
onChannelDrop?.(item, result);
setDropIndicator(null);
return result;
},
collect: (monitor) => ({
isOver: monitor.isOver({shallow: true}),
canDrop: monitor.canDrop(),
}),
}),
[dropTargetData, guild.id, channelIsVoice, onChannelDrop],
);
useEffect(() => {
if (!isOver) setDropIndicator(null);
}, [isOver]);
useEffect(() => {
preview(getEmptyImage(), {captureDraggingState: true});
}, [preview]);
const {startConnection: startVoiceConnection} = usePendingVoiceConnection({
guildId: guild.id,
channelId: channel.id,
});
const singleClickConnectsToVoice =
channelIsVoice && !Accessibility.voiceChannelJoinRequiresDoubleClick && !isVoiceSelected;
const navigateToChannel = useCallback(() => {
preloadChannelNow();
NavigationCommands.selectChannel(guild.id, channel.id);
if (MobileLayout.isMobileLayout()) {
LayoutCommands.updateMobileLayoutState(false, true);
}
}, [guild.id, channel.id, preloadChannelNow]);
const collapseVoiceCallView = useCallback(() => {
if (!channelIsVoice) return;
CompactVoiceCallHeight.setExpandedForKey(getGuildVoiceCallExpansionKey(channel.id), false);
}, [channelIsVoice, channel.id]);
const handleSelect = useCallback(() => {
markDirectSelection(DirectSelectionSurface.CHANNEL_LIST);
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
onToggle?.();
return;
}
if (LinkChannelCommands.openLinkChannel(channel)) {
return;
}
if (singleClickConnectsToVoice && Permission.can(Permissions.CONNECT, channel)) {
startVoiceConnection();
return;
}
navigateToChannel();
}, [channel, onToggle, singleClickConnectsToVoice, startVoiceConnection, navigateToChannel]);
const handleDoubleClick = useCallback(() => {
if (!channelIsVoice || isVoiceSelected) return;
if (!Permission.can(Permissions.CONNECT, channel)) return;
startVoiceConnection({skipConfirm: true});
}, [channelIsVoice, isVoiceSelected, channel, startVoiceConnection]);
const handleContextMenu = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (isMobileLayout) {
return;
}
ContextMenuCommands.openFromEvent(event, ({onClose}) =>
channelIsCategory ? (
<CategoryContextMenu
category={channel}
onClose={onClose}
data-flx="app.channel-item.handle-context-menu.category-context-menu"
/>
) : (
<ChannelContextMenu
channel={channel}
onClose={onClose}
data-flx="app.channel-item.handle-context-menu.channel-context-menu"
/>
),
);
},
[channel, channelIsCategory, isMobileLayout],
);
const dragConnectorRef = useCallback(
(node: ConnectableElement | null) => {
dragRef(node);
},
[dragRef],
);
const dropConnectorRef = useCallback(
(node: ConnectableElement | null) => {
dropRef(node);
dropTargetRef.current = node as HTMLDivElement | null;
},
[dropRef],
);
const mergedRef = useMergeRefs([dragConnectorRef, elementRef]);
const shouldShowSelectedState = !channelIsCategory && isSelected && (!channelIsVoice || isSelectedByPath);
const hasMountedRef = useRef(false);
useEffect(() => {
const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.CHANNEL_LIST);
if (shouldShowSelectedState && hasMountedRef.current && !selectedFromThisRow) {
elementRef.current?.scrollIntoView({block: 'nearest'});
}
hasMountedRef.current = true;
}, [shouldShowSelectedState]);
const extraContent = unreadState.shouldShowUnreadIndicator ? (
<div
className={clsx(styles.unreadIndicator, unreadState.isUnreadIndicatorMuted && styles.unreadIndicatorMuted)}
data-flx="app.channel-item.unread-indicator"
/>
) : null;
const channelKindLabel = channelIsCategory
? i18n._(CATEGORY_DESCRIPTOR)
: channelIsVoice
? i18n._(VOICE_CHANNEL_DESCRIPTOR)
: i18n._(CHANNEL_DESCRIPTOR);
const ariaLabelParts = [channel.name, channelKindLabel];
if (shouldShowSelectedState) ariaLabelParts.push(i18n._(SELECTED_DESCRIPTOR));
if (channelIsCategory)
ariaLabelParts.push(isCollapsed ? i18n._(COLLAPSED_DESCRIPTOR) : i18n._(EXPANDED_DESCRIPTOR));
if (!channelIsCategory) {
if (mentionCount > 0) ariaLabelParts.push(i18n._(MENTION_COUNT_ARIA_DESCRIPTOR, {mentionCount}));
else if (hasUnreadMessages) ariaLabelParts.push(i18n._(UNREAD_DESCRIPTOR));
if (isMuted) ariaLabelParts.push(i18n._(MUTED_DESCRIPTOR));
if (isVoiceSelected) ariaLabelParts.push(i18n._(CONNECTED_DESCRIPTOR));
}
const ariaLabel = ariaLabelParts.join(', ');
const [isPointerHovered, setIsPointerHovered] = useState(false);
const handleMouseEnter = useCallback(() => {
setIsPointerHovered(true);
scheduleChannelPreload();
}, [scheduleChannelPreload]);
const handleMouseLeave = useCallback(() => {
setIsPointerHovered(false);
cancelChannelPreload();
}, [cancelChannelPreload]);
const hoverAffordancesActive =
allowHoverAffordances &&
(contextMenuOpen || showKeyboardAffordances || shouldShowSelectedState || isPointerHovered);
const hasVoiceUserLimit = channelIsVoice && channel.userLimit != null && channel.userLimit > 0;
const showChatAffordance =
allowHoverAffordances && channelIsVoice && !Accessibility.voiceChannelJoinRequiresDoubleClick;
const hasVoiceHoverAffordances =
allowHoverAffordances &&
!isDraggingAnything &&
!channelIsCategory &&
(canInvite || canEditChannel || showChatAffordance);
const shouldShowVoiceUserCount = hasVoiceUserLimit && !(hasVoiceHoverAffordances && hoverAffordancesActive);
const showMentionBadge = !isSelected && hasMentions && !hoverAffordancesActive;
const channelItemClassName = clsx(
styles.channelItem,
channelItemSurfaceStyles.channelItemSurface,
shouldShowSelectedState && channelItemSurfaceStyles.channelItemSurfaceSelected,
isAutocompleteHighlight && styles.channelItemAutocompleteHighlight,
channelIsCategory ? styles.channelItemCategory : styles.channelItemRegular,
!channelIsCategory && isHighlight && !shouldShowSelectedState && styles.channelItemHighlight,
!channelIsCategory && !(isHighlight || isSelected || isVoiceSelected) && styles.channelItemMuted,
shouldShowSelectedState && styles.channelItemSelected,
shouldShowSelectedState && isHighlight && styles.channelItemSelectedWithUnread,
!channelIsCategory && (!isSelected || (channelIsVoice && !isSelectedByPath)) && styles.channelItemHoverable,
isOver && styles.channelItemOver,
contextMenuOpen && !isSelected && !channelIsCategory && styles.channelItemContextMenu,
contextMenuOpen && channelIsCategory && styles.channelItemCategoryContextMenu,
isDragging && styles.channelItemDragging,
shouldDimForVoiceDrag && !isSelected && styles.channelItemDimmed,
isChannelDirectlyMuted && styles.channelItemMutedState,
contextMenuOpen && styles.contextMenuOpen,
revealActionButtons && styles.keyboardFocus,
channelIsVoice && styles.channelItemVoice,
hoverAffordancesActive && styles.channelItemHoverAffordancesActive,
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (!isKeyboardActivationKey(e.key)) return;
e.preventDefault();
handleSelect();
},
[handleSelect],
);
const handleFocus = useCallback(() => {
setIsFocused(true);
setActionModalOpen(false);
}, []);
const handleBlur = useCallback((e: React.FocusEvent<HTMLElement>) => {
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setIsFocused(false);
}, []);
const armActionModalReturn = useCallback(() => {
if (KeyboardMode.keyboardModeEnabled) {
setActionModalOpen(true);
}
}, []);
const handleLongPress = useCallback(() => {
if (isMobileLayout) {
if (channelIsCategory) {
setCategoryMenuOpen(true);
} else {
setMenuOpen(true);
}
}
}, [isMobileLayout, channelIsCategory]);
const handleInviteClick = useCallback(() => {
armActionModalReturn();
ModalCommands.push(
modal(() => (
<InviteModal channelId={channel.id} data-flx="app.channel-item.handle-invite-click.invite-modal" />
)),
);
}, [channel.id, armActionModalReturn]);
const handleOpenChatClick = useCallback(() => {
collapseVoiceCallView();
navigateToChannel();
}, [collapseVoiceCallView, navigateToChannel]);
const handleCreateChannelClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
armActionModalReturn();
ModalCommands.push(
modal(() => (
<ChannelCreateModal
guildId={guild.id}
parentId={channel.id}
data-flx="app.channel-item.handle-create-channel-click.channel-create-modal"
/>
)),
);
},
[guild.id, channel.id, armActionModalReturn],
);
const handleChannelSettingsClick = useCallback(() => {
armActionModalReturn();
ModalCommands.push(
modal(() => (
<ChannelSettingsModal
channelId={channel.id}
data-flx="app.channel-item.handle-channel-settings-click.channel-settings-modal"
/>
)),
);
}, [channel.id, armActionModalReturn]);
const channelSettingsLabel = channelIsCategory
? i18n._(EDIT_CATEGORY_DESCRIPTOR)
: i18n._(CHANNEL_SETTINGS_LABEL_DESCRIPTOR);
const voiceLocked = channelIsVoice && !Permission.can(Permissions.CONNECT, channel);
const channelIconNode = channelIsCategory
? null
: ChannelUtils.getIcon(
channel,
{
className: clsx(
styles.channelItemIcon,
shouldShowSelectedState || (isHighlight && isSelected)
? styles.channelItemIconSelected
: isVoiceSelected && channel.type === ChannelTypes.GUILD_VOICE
? styles.channelItemHighlight
: isHighlight && !isSelected
? styles.channelItemIconHighlight
: styles.channelItemIconUnselected,
),
},
voiceLocked ? {locked: true} : channelIsE2EEEncrypted ? {e2eeEncrypted: true} : undefined,
);
const typingIndicatorNode =
!channelIsCategory &&
typingUsers.length > 0 &&
channelTypingIndicatorMode !== ChannelTypingIndicatorMode.HIDDEN &&
!isSelected ? (
<Tooltip
text={() => (
<span className={styles.typingTooltip} data-flx="app.channel-item.typing-tooltip">
{getTypingText(i18n, typingUsers, channel)}
</span>
)}
data-flx="app.channel-item.tooltip"
>
<div className={styles.channelTypingIndicator} data-flx="app.channel-item.channel-typing-indicator">
<Typing
className={styles.typingIndicatorIcon}
size={20}
data-flx="app.channel-item.typing-indicator-icon"
/>
{channelTypingIndicatorMode === ChannelTypingIndicatorMode.AVATARS && (
<AvatarStack
size={12}
maxVisible={5}
className={styles.typingAvatars}
users={typingUsers}
guildId={channel.guildId}
channelId={channel.id}
data-flx="app.channel-item.typing-avatars"
/>
)}
</div>
</Tooltip>
) : null;
const categoryCaretNode = channelIsCategory ? (
<CaretDownIcon
weight="bold"
className={styles.categoryIcon}
style={{transform: `rotate(${isCollapsed ? -90 : 0}deg)`}}
data-flx="app.channel-item.category-icon"
/>
) : null;
const channelActionsNode = (
<>
{typingIndicatorNode}
{!isDraggingAnything && (
<>
{!channelIsCategory && showMentionBadge && (
<MentionBadge mentionCount={mentionCount} size="small" data-flx="app.channel-item.mention-badge" />
)}
{shouldShowVoiceUserCount && channel.userLimit != null && (
<div className={styles.voiceUserCount} data-flx="app.channel-item.voice-user-count">
<VoiceChannelUserCount
currentUserCount={currentUserCount}
userLimit={channel.userLimit}
data-flx="app.channel-item.voice-channel-user-count"
/>
</div>
)}
{showChatAffordance && (
<div className={styles.hoverAffordance} data-flx="app.channel-item.hover-affordance--chat">
<ChannelItemIcon
icon={ChatTeardropIcon}
label={i18n._(OPEN_CHAT_DESCRIPTOR)}
selected={shouldShowSelectedState}
onClick={handleOpenChatClick}
tabIndex={actionButtonTabIndex}
data-flx="app.channel-item.channel-item-icon.open-chat-click"
/>
</div>
)}
{allowHoverAffordances && canInvite && !channelIsCategory && (
<div className={styles.hoverAffordance} data-flx="app.channel-item.hover-affordance">
<ChannelItemIcon
icon={UserPlusIcon}
label={i18n._(INVITE_MEMBERS_DESCRIPTOR)}
selected={shouldShowSelectedState}
onClick={handleInviteClick}
tabIndex={actionButtonTabIndex}
data-flx="app.channel-item.channel-item-icon.invite-click"
/>
</div>
)}
{allowHoverAffordances && channelIsCategory && canManageChannels && (
<div className={styles.hoverAffordance} data-flx="app.channel-item.hover-affordance--2">
<Tooltip text={i18n._(CREATE_CHANNEL_DESCRIPTOR)} data-flx="app.channel-item.tooltip--2">
<FocusRing offset={-2} data-flx="app.channel-item.focus-ring">
<button
type="button"
tabIndex={actionButtonTabIndex}
className={styles.createChannelButton}
aria-label={i18n._(CREATE_CHANNEL_DESCRIPTOR)}
onClick={handleCreateChannelClick}
onKeyDown={stopPropagationOnEnterSpace}
data-flx="app.channel-item.create-channel-button.create-channel-click"
>
<PlusIcon
weight="bold"
className={styles.createChannelIcon}
data-flx="app.channel-item.create-channel-icon"
/>
</button>
</FocusRing>
</Tooltip>
</div>
)}
{allowHoverAffordances && canEditChannel && (
<div className={styles.hoverAffordance} data-flx="app.channel-item.hover-affordance--3">
<ChannelItemIcon
icon={GearIcon}
label={channelSettingsLabel}
selected={shouldShowSelectedState}
onClick={handleChannelSettingsClick}
tabIndex={actionButtonTabIndex}
data-flx="app.channel-item.channel-item-icon.channel-settings-click"
/>
</div>
)}
</>
)}
{categoryCaretNode}
</>
);
const channelItem = (
<GenericChannelItem
innerRef={mergedRef}
ref={dropConnectorRef as React.Ref<HTMLDivElement>}
containerClassName={clsx(
styles.container,
styles.channelReorderTarget,
channelIsCategory && styles.channelCategoryReorderTarget,
)}
extraContent={extraContent}
isOver={isOver}
dropIndicator={dropIndicator}
disabled={!isMobileLayout}
data-dnd-name={channel.name}
dataScrollIndicator={scrollIndicatorSeverity}
dataScrollId={scrollIndicatorId}
aria-label={ariaLabel}
aria-current={shouldShowSelectedState ? 'page' : undefined}
aria-expanded={channelIsCategory ? !isCollapsed : undefined}
className={channelItemClassName}
onClick={handleSelect}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
onLongPress={handleLongPress}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
data-flx="app.channel-item.generic-channel-item.select"
>
<ChannelItemContent
icon={channelIconNode}
name={channel.name ?? ''}
actions={channelActionsNode}
isCategory={channelIsCategory}
data-flx="app.channel-item.channel-item-content"
/>
</GenericChannelItem>
);
const handleCloseMenu = useCallback(() => setMenuOpen(false), []);
const handleCloseCategoryMenu = useCallback(() => setCategoryMenuOpen(false), []);
return (
<>
{channelItem}
{isMobileLayout && !channelIsCategory && (
<ChannelBottomSheet
isOpen={menuOpen}
onClose={handleCloseMenu}
channel={channel}
guild={guild}
data-flx="app.channel-item.channel-bottom-sheet"
/>
)}
{isMobileLayout && channelIsCategory && (
<CategoryBottomSheet
isOpen={categoryMenuOpen}
onClose={handleCloseCategoryMenu}
category={channel}
data-flx="app.channel-item.category-bottom-sheet"
/>
)}
</>
);
},
);