perf(app): stop preloading channels and guilds on hover (#3133)

This commit is contained in:
Hampus
2026-10-02 19:04:14 +02:00
committed by GitHub
parent 69d93f9fee
commit 27fc634bc9
7 changed files with 13 additions and 222 deletions
@@ -21,7 +21,6 @@ import {type DragItem, DragItemType, type DropResult} from '@app/features/app/co
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';
@@ -220,12 +219,6 @@ export const ChannelItem = observer(
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);
@@ -401,12 +394,11 @@ export const ChannelItem = observer(
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]);
}, [guild.id, channel.id]);
const collapseVoiceCallView = useCallback(() => {
if (!channelIsVoice) return;
CompactVoiceCallHeight.setExpandedForKey(getGuildVoiceCallExpansionKey(channel.id), false);
@@ -504,12 +496,10 @@ export const ChannelItem = observer(
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);
@@ -19,7 +19,6 @@ import {DragItemType} from '@app/features/app/components/layout/types/DndTypes';
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
import {GuildChannelListSkeleton} from '@app/features/app/components/skeleton/GuildSidebarSkeleton';
import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload';
import {useMergeRefs} from '@app/features/app/hooks/useMergeRefs';
import * as LinkChannelCommands from '@app/features/channel/commands/LinkChannelCommands';
import type {Channel} from '@app/features/channel/models/Channel';
@@ -55,7 +54,6 @@ import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
import Users from '@app/features/user/state/Users';
import {FAVORITES_GUILD_ID, ME} from '@fluxer/constants/src/AppConstants';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {CaretDownIcon, PlusIcon, UserPlusIcon} from '@phosphor-icons/react';
@@ -235,17 +233,11 @@ const FavoriteChannelResolvedItem = observer(
showFadedUnreadOnMutedChannels: Accessibility.showFadedUnreadOnMutedChannels,
unreadBadgesLevel,
});
const {scheduleChannelPreload, cancelChannelPreload, preloadChannelNow} = useChannelHoverPreload({
channel,
guild,
defaultHiddenForChannel: channel.type === ChannelTypes.GUILD_VOICE,
});
const handleClick = () => {
if (LinkChannelCommands.openLinkChannel(channel)) {
return;
}
markDirectSelection(DirectSelectionSurface.FAVORITES_LIST);
preloadChannelNow();
NavigationCommands.selectChannel(FAVORITES_GUILD_ID, favoriteChannel.channelId);
};
const handleContextMenu = (event: React.MouseEvent) => {
@@ -309,8 +301,6 @@ const FavoriteChannelResolvedItem = observer(
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onLongPress={() => {}}
onMouseEnter={scheduleChannelPreload}
onMouseLeave={cancelChannelPreload}
data-flx="app.favorites-channel-list-content.favorite-channel-item.generic-channel-item.click"
>
<div
@@ -1,13 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {DirectSelectionSurface, markDirectSelection} from '@app/features/app/components/layout/DirectSelectionOrigin';
import {useGuildListItemPreload} from '@app/features/app/components/layout/sidebar_nav/UseGuildListItemPreload';
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
import {useHover} from '@app/features/app/hooks/useHover';
import type {Guild} from '@app/features/guild/models/Guild';
import GuildCount from '@app/features/guild/state/GuildCount';
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import {GuildContextMenu} from '@app/features/ui/action_menu/GuildContextMenu';
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
import * as AvatarSourceUtils from '@app/features/user/utils/AvatarSourceUtils';
@@ -64,12 +65,12 @@ export function useGuildListItemInteraction({
}: UseGuildListItemInteractionOptions): GuildListItemInteraction {
const [hoverRef, isHovering] = useHover();
const contextMenuOpen = useContextMenuHoverState(itemRef, isDesktopLayout);
const {preloadChannelNow, selectedChannelId} = useGuildListItemPreload({
guild,
isHovering,
isMobileExperience,
isSortingList,
});
const selectedChannelId = SelectedChannel.selectedChannelIds.get(guild.id) ?? null;
useEffect(() => {
if (isMobileExperience || isSortingList || !isHovering) return;
const timeoutId = window.setTimeout(() => GuildCount.requestCounts(guild.id, {force: false}), 250);
return () => window.clearTimeout(timeoutId);
}, [guild.id, isHovering, isMobileExperience, isSortingList]);
const iconURL = AvatarSourceUtils.getGuildIconURL(guild, false);
const hoverIconURL = AvatarSourceUtils.getGuildIconURL(guild, true);
const isAnimatableIcon = hoverIconURL !== iconURL;
@@ -84,13 +85,12 @@ export function useGuildListItemInteraction({
}, [contextMenuOpen, hoverIconURL, isAnimatableIcon, isHovering, loadedAnimatedURL]);
const handleSelect = useCallback(() => {
markDirectSelection(DirectSelectionSurface.GUILD_RAIL);
preloadChannelNow();
if (isMobileExperience || selectedChannelId == null) {
NavigationCommands.selectGuild(guild.id);
return;
}
NavigationCommands.selectGuild(guild.id, selectedChannelId);
}, [guild.id, isMobileExperience, preloadChannelNow, selectedChannelId]);
}, [guild.id, isMobileExperience, selectedChannelId]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (!isKeyboardActivationKey(event.key)) return;
@@ -1,87 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload';
import type {Channel} from '@app/features/channel/models/Channel';
import Channels from '@app/features/channel/state/Channels';
import type {Guild} from '@app/features/guild/models/Guild';
import GuildCount from '@app/features/guild/state/GuildCount';
import {filterViewableChannels} from '@app/features/messaging/utils/ChannelShared';
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {useEffect, useMemo} from 'react';
interface UseGuildListItemPreloadOptions {
readonly guild: Guild;
readonly isHovering: boolean;
readonly isMobileExperience: boolean;
readonly isSortingList: boolean;
}
interface UseGuildListItemPreloadResult {
readonly preloadChannelNow: () => void;
readonly selectedChannelId: string | null;
}
function resolveSelectedChannel(selectedChannelId: string | null): Channel | null {
if (selectedChannelId == null) {
return null;
}
return Channels.getChannel(selectedChannelId) ?? null;
}
function resolveDefaultHiddenForChannel(channel: Channel | null): boolean {
if (channel == null) {
return false;
}
return channel.type === ChannelTypes.GUILD_VOICE;
}
export function useGuildListItemPreload({
guild,
isHovering,
isMobileExperience,
isSortingList,
}: UseGuildListItemPreloadOptions): UseGuildListItemPreloadResult {
const storedChannelId = SelectedChannel.selectedChannelIds.get(guild.id);
let selectedChannelId: string | null = null;
if (storedChannelId != null) {
selectedChannelId = storedChannelId;
}
const selectedChannel = resolveSelectedChannel(selectedChannelId);
const guildChannels = Channels.getGuildChannels(guild.id);
const preloadTargetChannel = useMemo(() => {
if (
selectedChannel != null &&
selectedChannel.guildId === guild.id &&
selectedChannel.type !== ChannelTypes.GUILD_CATEGORY &&
selectedChannel.type !== ChannelTypes.GUILD_LINK
) {
return selectedChannel;
}
const firstTextChannel = filterViewableChannels(guildChannels)[0];
if (firstTextChannel == null) {
return null;
}
return firstTextChannel;
}, [guild.id, guildChannels, selectedChannel]);
const {scheduleChannelPreload, cancelChannelPreload, preloadChannelNow} = useChannelHoverPreload({
channel: preloadTargetChannel,
guild,
defaultHiddenForChannel: resolveDefaultHiddenForChannel(preloadTargetChannel),
enabled: !guild.unavailable && !isSortingList,
});
useEffect(() => {
if (isMobileExperience || isSortingList || !isHovering) {
cancelChannelPreload();
return;
}
scheduleChannelPreload();
return cancelChannelPreload;
}, [cancelChannelPreload, isHovering, isMobileExperience, isSortingList, scheduleChannelPreload]);
useEffect(() => {
if (isMobileExperience || isSortingList || !isHovering) return;
const timeoutId = window.setTimeout(() => GuildCount.requestCounts(guild.id, {force: false}), 250);
return () => window.clearTimeout(timeoutId);
}, [guild.id, isHovering, isMobileExperience, isSortingList]);
return {preloadChannelNow, selectedChannelId};
}
@@ -1,73 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {Channel} from '@app/features/channel/models/Channel';
import type {Guild} from '@app/features/guild/models/Guild';
import {ensureMembersForMessages} from '@app/features/messaging/commands/MessageCommands';
import Messages from '@app/features/messaging/state/MessagingMessages';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {useCallback, useEffect, useRef} from 'react';
const CHANNEL_HOVER_PRELOAD_DELAY_MS = 220;
interface UseChannelHoverPreloadOptions {
channel: Channel | null | undefined;
guild?: Guild | null;
defaultHiddenForChannel?: boolean;
enabled?: boolean;
preloadMemberList?: boolean;
preloadMessages?: boolean;
}
export function ensureMembersForCachedChannelMessages(channelId: string): void {
const messages = Messages.getCachedMessages(channelId);
if (!messages || messages.length === 0) {
return;
}
void ensureMembersForMessages(messages.toArray());
}
export function useChannelHoverPreload({
channel,
guild = null,
enabled = true,
preloadMessages = true,
}: UseChannelHoverPreloadOptions): {
scheduleChannelPreload: () => void;
cancelChannelPreload: () => void;
preloadChannelNow: () => void;
} {
const timerRef = useRef<number | null>(null);
const cancelChannelPreload = useCallback(() => {
if (timerRef.current == null || typeof window === 'undefined') {
timerRef.current = null;
return;
}
window.clearTimeout(timerRef.current);
timerRef.current = null;
}, []);
const preloadChannelNow = useCallback(() => {
cancelChannelPreload();
if (
!enabled ||
!channel ||
channel.type === ChannelTypes.GUILD_CATEGORY ||
channel.type === ChannelTypes.GUILD_LINK
) {
return;
}
if (preloadMessages) {
if (!Messages.preloadLatestPage(channel.id, guild?.id ?? channel.guildId ?? null)) {
ensureMembersForCachedChannelMessages(channel.id);
}
}
}, [cancelChannelPreload, channel, enabled, guild, preloadMessages]);
const scheduleChannelPreload = useCallback(() => {
if (!enabled || !channel || typeof window === 'undefined') {
return;
}
cancelChannelPreload();
timerRef.current = window.setTimeout(preloadChannelNow, CHANNEL_HOVER_PRELOAD_DELAY_MS);
}, [cancelChannelPreload, channel, enabled, preloadChannelNow]);
useEffect(() => cancelChannelPreload, [cancelChannelPreload]);
return {scheduleChannelPreload, cancelChannelPreload, preloadChannelNow};
}
@@ -5,7 +5,6 @@ import {LongPressable} from '@app/features/app/components/LongPressable';
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
import {CustomStatusDisplay} from '@app/features/app/components/shared/custom_status_display/CustomStatusDisplay';
import {GroupDMAvatar} from '@app/features/app/components/shared/GroupDMAvatar';
import {useChannelHoverPreload} from '@app/features/app/hooks/useChannelHoverPreload';
import {useContextMenuHoverState} from '@app/features/app/hooks/useContextMenuHoverState';
import {UserTag} from '@app/features/channel/components/ChannelUserTag';
import styles from '@app/features/channel/components/direct_message/DirectMessageList.module.css';
@@ -199,15 +198,9 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
leaveGroup,
i18n,
});
const {scheduleChannelPreload, cancelChannelPreload, preloadChannelNow} = useChannelHoverPreload({
channel,
guild: null,
preloadMemberList: false,
});
const handleNavigate = useCallback(() => {
preloadChannelNow();
handlers.navigateTo();
}, [handlers.navigateTo, preloadChannelNow]);
}, [handlers.navigateTo]);
const mobileMenuGroups = buildMobileMenuGroups({
channel,
recipient,
@@ -507,8 +500,6 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
)}
onClick={handleNavigate}
onContextMenu={handleContextMenu}
onMouseEnter={scheduleChannelPreload}
onMouseLeave={cancelChannelPreload}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
data-dm-list-focus-item="true"
@@ -206,26 +206,6 @@ class Messages {
return messages.ready && messages.length > 0 && !messages.cached;
}
shouldPreloadLatestPage(channelId: string): boolean {
if (!GatewayConnection.isConnected || !Channels.getChannel(channelId)) {
return false;
}
const messages = ChannelMessages.get(channelId);
if (!messages) return true;
if (messages.loadingMore || ChannelMessages.isRetained(channelId)) return false;
return messages.length === 0 ? !messages.ready : messages.cached;
}
preloadLatestPage(channelId: string, guildId?: string | null): boolean {
if (!this.shouldPreloadLatestPage(channelId)) {
return false;
}
const channel = Channels.getChannel(channelId);
const resolvedGuildId = guildId ?? channel?.guildId ?? (channel?.isPrivate() ? ME : undefined);
this.handleChannelSelect({guildId: resolvedGuildId ?? undefined, channelId});
return true;
}
getMessage(channelId: string, messageId: string): Message | undefined {
return ChannelMessages.getOrCreate(channelId).get(messageId);
}