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);
}