/* * 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 {CaretDownIcon, HashIcon, PlusIcon, UserPlusIcon} from '@phosphor-icons/react'; import {clsx} from 'clsx'; import {observer} from 'mobx-react-lite'; import React from 'react'; import type {ConnectableElement} from 'react-dnd'; import {useDrag, useDrop} from 'react-dnd'; import {getEmptyImage} from 'react-dnd-html5-backend'; import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators'; import * as ModalActionCreators from '~/actions/ModalActionCreators'; import {modal} from '~/actions/ModalActionCreators'; import {ME} from '~/Constants'; import {GroupDMAvatar} from '~/components/common/GroupDMAvatar'; import {ChannelItemIcon} from '~/components/layout/ChannelItemIcon'; import {ChannelListSkeleton} from '~/components/layout/ChannelListSkeleton'; import {GenericChannelItem} from '~/components/layout/GenericChannelItem'; import {AddFavoriteChannelModal} from '~/components/modals/AddFavoriteChannelModal'; import {InviteModal} from '~/components/modals/InviteModal'; import {FavoritesCategoryContextMenu} from '~/components/uikit/ContextMenu/FavoritesCategoryContextMenu'; import {FavoritesChannelContextMenu} from '~/components/uikit/ContextMenu/FavoritesChannelContextMenu'; import {FavoritesChannelListContextMenu} from '~/components/uikit/ContextMenu/FavoritesChannelListContextMenu'; import FocusRing from '~/components/uikit/FocusRing/FocusRing'; import {MentionBadge} from '~/components/uikit/MentionBadge'; import {Scroller} from '~/components/uikit/Scroller'; import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar'; import {Tooltip} from '~/components/uikit/Tooltip/Tooltip'; import {useMergeRefs} from '~/hooks/useMergeRefs'; import {useLocation} from '~/lib/router'; import {Routes} from '~/Routes'; import type {ChannelRecord} from '~/records/ChannelRecord'; import type {GuildRecord} from '~/records/GuildRecord'; import ChannelStore from '~/stores/ChannelStore'; import FavoritesStore, {type FavoriteChannel} from '~/stores/FavoritesStore'; import GuildStore from '~/stores/GuildStore'; import KeyboardModeStore from '~/stores/KeyboardModeStore'; import ReadStateStore from '~/stores/ReadStateStore'; import TypingStore from '~/stores/TypingStore'; import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore'; import UserStore from '~/stores/UserStore'; import * as AvatarUtils from '~/utils/AvatarUtils'; import * as ChannelUtils from '~/utils/ChannelUtils'; import * as InviteUtils from '~/utils/InviteUtils'; import * as RouterUtils from '~/utils/RouterUtils'; import channelItemSurfaceStyles from './ChannelItemSurface.module.css'; import styles from './ChannelListContent.module.css'; import favoritesChannelListStyles from './FavoritesChannelListContent.module.css'; const DND_TYPES = { FAVORITES_CHANNEL: 'favorites-channel', FAVORITES_CATEGORY: 'favorites-category', } as const; interface DragItem { type: string; channelId: string; parentId: string | null; } interface FavoriteChannelGroup { category: {id: string; name: string} | null; channels: Array<{ favoriteChannel: FavoriteChannel; channel: ChannelRecord | null; guild: GuildRecord | null; }>; } const FavoriteChannelItem = observer( ({ favoriteChannel, channel, guild, }: { favoriteChannel: FavoriteChannel; channel: ChannelRecord | null; guild: GuildRecord | null; }) => { const {t} = useLingui(); const elementRef = React.useRef(null); const [dropIndicator, setDropIndicator] = React.useState<{position: 'top' | 'bottom'; isValid: boolean} | null>( null, ); const location = useLocation(); const isSelected = location.pathname === Routes.favoritesChannel(favoriteChannel.channelId); const shouldShowSelectedState = isSelected; React.useEffect(() => { if (isSelected) { elementRef.current?.scrollIntoView({block: 'nearest'}); } }, [isSelected]); const [isFocused, setIsFocused] = React.useState(false); const {keyboardModeEnabled} = KeyboardModeStore; const showKeyboardAffordances = keyboardModeEnabled && isFocused; const [{isDragging}, dragRef, preview] = useDrag({ type: DND_TYPES.FAVORITES_CHANNEL, item: { type: DND_TYPES.FAVORITES_CHANNEL, channelId: favoriteChannel.channelId, parentId: favoriteChannel.parentId, }, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }); const [{isOver}, dropRef] = useDrop({ accept: DND_TYPES.FAVORITES_CHANNEL, hover: (_item, monitor) => { const node = elementRef.current; if (!node) return; const hoverBoundingRect = node.getBoundingClientRect(); const clientOffset = monitor.getClientOffset(); if (!clientOffset) return; const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; const hoverClientY = clientOffset.y - hoverBoundingRect.top; setDropIndicator({ position: hoverClientY < hoverMiddleY ? 'top' : 'bottom', isValid: true, }); }, drop: (item, monitor) => { setDropIndicator(null); if (item.channelId === favoriteChannel.channelId) return; const node = elementRef.current; if (!node) return; const hoverBoundingRect = node.getBoundingClientRect(); const clientOffset = monitor.getClientOffset(); if (!clientOffset) return; const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; const hoverClientY = clientOffset.y - hoverBoundingRect.top; const position = hoverClientY < hoverMiddleY ? 'before' : 'after'; const channels = FavoritesStore.getChannelsInCategory(favoriteChannel.parentId); let targetIndex = channels.findIndex((ch) => ch.channelId === favoriteChannel.channelId); if (targetIndex !== -1) { if (position === 'after') { targetIndex += 1; } FavoritesStore.moveChannel(item.channelId, favoriteChannel.parentId, targetIndex); } }, collect: (monitor) => ({ isOver: monitor.isOver(), }), }); React.useEffect(() => { if (!isOver) setDropIndicator(null); }, [isOver]); React.useEffect(() => { preview(getEmptyImage()); }, [preview]); const dragConnectorRef = React.useCallback( (node: ConnectableElement | null) => { dragRef(node); }, [dragRef], ); const dropConnectorRef = React.useCallback( (node: ConnectableElement | null) => { dropRef(node); }, [dropRef], ); const refs = useMergeRefs([dragConnectorRef, dropConnectorRef, elementRef]); if (!channel) { return (
{t`Channel not found`}
); } const unreadCount = ReadStateStore.getUnreadCount(channel.id); const mentionCount = ReadStateStore.getMentionCount(channel.id); const hasUnread = unreadCount > 0 || mentionCount > 0; const isGroupDM = channel.isGroupDM(); const isDM = channel.isDM(); const recipientId = isDM ? (channel.recipientIds[0] ?? '') : ''; const recipient = recipientId ? (UserStore.getUser(recipientId) ?? null) : null; const isTyping = recipientId ? TypingStore.isTyping(channel.id, recipientId) : false; const channelDisplayName = channel.isPrivate() ? ChannelUtils.getDMDisplayName(channel) : channel.name; const displayName = favoriteChannel.nickname || channelDisplayName || t`Unknown Channel`; const guildIconUrl = guild ? AvatarUtils.getGuildIconURL({id: guild.id, icon: guild.icon}) : null; const isMuted = channel.guildId ? UserGuildSettingsStore.isChannelMuted(channel.guildId, channel.id) : false; const handleClick = () => { RouterUtils.transitionTo(Routes.favoritesChannel(favoriteChannel.channelId)); }; const handleContextMenu = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); ContextMenuActionCreators.openFromEvent(event, ({onClose}) => ( )); }; const handleInvite = () => { ModalActionCreators.push(modal(() => )); }; const canInvite = channel.guildId && InviteUtils.canInviteToChannel(channel.id, channel.guildId); return ( e.key === 'Enter' && handleClick()} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} onLongPress={() => {}} >
{isGroupDM ? ( ) : recipient ? ( ) : guildIconUrl ? ( ) : (
{guild ? guild.name.charAt(0).toUpperCase() : 'DM'}
)} {!channel.isPrivate() && (
{ChannelUtils.getIcon(channel, { className: clsx( favoritesChannelListStyles.channelBadgeIcon, shouldShowSelectedState && favoritesChannelListStyles.channelBadgeSelectedIcon, ), })}
)}
{displayName}
{canInvite && (
)} {hasUnread && }
); }, ); const FavoriteCategoryItem = observer( ({ category, isCollapsed, onToggle, onAddChannel, }: { category: {id: string; name: string}; isCollapsed: boolean; onToggle: () => void; onAddChannel: () => void; }) => { const {t} = useLingui(); const [isFocused, setIsFocused] = React.useState(false); const {keyboardModeEnabled} = KeyboardModeStore; const showKeyboardAffordances = keyboardModeEnabled && isFocused; const [{isOver}, dropRef] = useDrop({ accept: DND_TYPES.FAVORITES_CHANNEL, drop: (item) => { if (item.parentId === category.id) return; const channels = FavoritesStore.getChannelsInCategory(category.id); FavoritesStore.moveChannel(item.channelId, category.id, channels.length); }, collect: (monitor) => ({ isOver: monitor.isOver(), }), }); const dropConnectorRef = React.useCallback( (node: ConnectableElement | null) => { dropRef(node); }, [dropRef], ); const refs = useMergeRefs([dropConnectorRef]); const handleContextMenu = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); ContextMenuActionCreators.openFromEvent(event, ({onClose}) => ( )); }; return ( e.key === 'Enter' && onToggle()} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} >
{category.name}
); }, ); const UncategorizedGroup = ({children}: {children: React.ReactNode}) => { const [{isOver}, dropRef] = useDrop({ accept: DND_TYPES.FAVORITES_CHANNEL, drop: (item, monitor) => { if (monitor.didDrop()) return; if (item.parentId === null) return; const channels = FavoritesStore.getChannelsInCategory(null); FavoritesStore.moveChannel(item.channelId, null, channels.length); }, collect: (monitor) => ({ isOver: monitor.isOver({shallow: true}), }), }); const dropConnectorRef = React.useCallback( (node: ConnectableElement | null) => { dropRef(node); }, [dropRef], ); return (
{children}
); }; export const FavoritesChannelListContent = observer(() => { const favorites = FavoritesStore.sortedChannels; const categories = FavoritesStore.sortedCategories; const hideMutedChannels = FavoritesStore.hideMutedChannels; const channelGroups = React.useMemo(() => { const groups: Array = []; const categoryMap = new Map(); categoryMap.set(null, {category: null, channels: []}); for (const cat of categories) { categoryMap.set(cat.id, {category: cat, channels: []}); } for (const fav of favorites) { const channel = ChannelStore.getChannel(fav.channelId); const guild = fav.guildId === ME ? null : GuildStore.getGuild(fav.guildId); if (hideMutedChannels && channel && channel.guildId) { if (UserGuildSettingsStore.isGuildOrChannelMuted(channel.guildId, channel.id)) { continue; } } const group = categoryMap.get(fav.parentId); if (group) { group.channels.push({favoriteChannel: fav, channel: channel ?? null, guild: guild ?? null}); } } for (const [, group] of categoryMap) { if (group.category || group.channels.length > 0 || group === categoryMap.get(null)) { groups.push(group); } } return groups; }, [favorites, categories, hideMutedChannels]); const handleContextMenu = React.useCallback((event: React.MouseEvent) => { ContextMenuActionCreators.openFromEvent(event, ({onClose}) => ( )); }, []); if (favorites.length === 0) { return (
); } return (
{channelGroups.map((group) => { const isCollapsed = group.category ? FavoritesStore.isCategoryCollapsed(group.category.id) : false; const handleAddChannel = () => { ModalActionCreators.push(modal(() => )); }; const content = ( <> {group.category && ( FavoritesStore.toggleCategoryCollapsed(group.category!.id)} onAddChannel={handleAddChannel} /> )} {!isCollapsed && group.channels.map(({favoriteChannel, channel, guild}) => ( ))} ); return (
{group.category ? content : {content}}
); })}
); });