diff --git a/fluxer_app/src/features/app/components/layout/ChannelItem.tsx b/fluxer_app/src/features/app/components/layout/ChannelItem.tsx index cfb269073..fd91ac116 100644 --- a/fluxer_app/src/features/app/components/layout/ChannelItem.tsx +++ b/fluxer_app/src/features/app/components/layout/ChannelItem.tsx @@ -21,6 +21,7 @@ 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'; @@ -219,6 +220,12 @@ 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); @@ -394,11 +401,12 @@ 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]); + }, [guild.id, channel.id, preloadChannelNow]); const collapseVoiceCallView = useCallback(() => { if (!channelIsVoice) return; CompactVoiceCallHeight.setExpandedForKey(getGuildVoiceCallExpansionKey(channel.id), false); @@ -496,10 +504,12 @@ 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); diff --git a/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx b/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx index 519356312..8b6017b85 100644 --- a/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx +++ b/fluxer_app/src/features/app/components/layout/FavoritesChannelListContent.tsx @@ -19,6 +19,7 @@ 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'; @@ -54,6 +55,7 @@ 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'; @@ -233,11 +235,17 @@ 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) => { @@ -301,6 +309,8 @@ 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" >
{ - 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 {preloadChannelNow, selectedChannelId} = useGuildListItemPreload({ + guild, + isHovering, + isMobileExperience, + isSortingList, + }); const iconURL = AvatarSourceUtils.getGuildIconURL(guild, false); const hoverIconURL = AvatarSourceUtils.getGuildIconURL(guild, true); const isAnimatableIcon = hoverIconURL !== iconURL; @@ -85,12 +84,13 @@ 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, selectedChannelId]); + }, [guild.id, isMobileExperience, preloadChannelNow, selectedChannelId]); const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (!isKeyboardActivationKey(event.key)) return; diff --git a/fluxer_app/src/features/app/components/layout/sidebar_nav/UseGuildListItemPreload.ts b/fluxer_app/src/features/app/components/layout/sidebar_nav/UseGuildListItemPreload.ts new file mode 100644 index 000000000..a6dbf107e --- /dev/null +++ b/fluxer_app/src/features/app/components/layout/sidebar_nav/UseGuildListItemPreload.ts @@ -0,0 +1,87 @@ +// 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}; +} diff --git a/fluxer_app/src/features/app/hooks/useChannelHoverPreload.ts b/fluxer_app/src/features/app/hooks/useChannelHoverPreload.ts new file mode 100644 index 000000000..05f96753a --- /dev/null +++ b/fluxer_app/src/features/app/hooks/useChannelHoverPreload.ts @@ -0,0 +1,73 @@ +// 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(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}; +} diff --git a/fluxer_app/src/features/channel/components/direct_message/DMListItem.tsx b/fluxer_app/src/features/channel/components/direct_message/DMListItem.tsx index 8c752c5de..43b23821e 100644 --- a/fluxer_app/src/features/channel/components/direct_message/DMListItem.tsx +++ b/fluxer_app/src/features/channel/components/direct_message/DMListItem.tsx @@ -5,6 +5,7 @@ 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'; @@ -198,9 +199,15 @@ 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]); + }, [handlers.navigateTo, preloadChannelNow]); const mobileMenuGroups = buildMobileMenuGroups({ channel, recipient, @@ -500,6 +507,8 @@ 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" diff --git a/fluxer_app/src/features/messaging/state/MessagingMessages.ts b/fluxer_app/src/features/messaging/state/MessagingMessages.ts index 416c3885e..630c71138 100644 --- a/fluxer_app/src/features/messaging/state/MessagingMessages.ts +++ b/fluxer_app/src/features/messaging/state/MessagingMessages.ts @@ -206,6 +206,26 @@ 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); }