mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(app): backport rendering improvements (#1580)
This commit is contained in:
1 parent
0c291a01da
commit
42cdc1f877
513 files changed
+98743
-70397
No files matched your search
+13
-10
@@ -9,7 +9,6 @@ import ScreenReader from '@app/features/accessibility/state/ScreenReader';
|
||||
import {DndContext} from '@app/features/app/components/layout/DndContext';
|
||||
import GlobalOverlays from '@app/features/app/components/layout/GlobalOverlays';
|
||||
import {NativeTitlebar} from '@app/features/app/components/layout/NativeTitlebar';
|
||||
import {NativeTrafficLightsBackdrop} from '@app/features/app/components/layout/NativeTrafficLightsBackdrop';
|
||||
import {useDesktopAllowTransparency} from '@app/features/app/hooks/useDesktopAllowTransparency';
|
||||
import {useDesktopElectronBridges} from '@app/features/app/hooks/useDesktopElectronBridges';
|
||||
import {useDocumentClassToggle} from '@app/features/app/hooks/useDocumentClassToggle';
|
||||
@@ -27,6 +26,7 @@ import Authentication from '@app/features/auth/state/Authentication';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import {showMyselfTypingHelper} from '@app/features/devtools/utils/ShowMyselfTypingHelper';
|
||||
import GatewayConnection from '@app/features/gateway/transport/GatewayConnection';
|
||||
import MemberSidebar from '@app/features/member/state/MemberSidebar';
|
||||
import {startDeepLinkHandling} from '@app/features/navigation/utils/DeepLinkUtils';
|
||||
import {Outlet, RouterProvider} from '@app/features/platform/components/router/RouterReact';
|
||||
import {ensureAutostartDefaultEnabled} from '@app/features/platform/utils/Autostart';
|
||||
@@ -62,7 +62,6 @@ import {VoiceLiveKitRoot} from '@app/features/voice/components/VoiceLiveKitRoot'
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useElectronScreenSharePicker} from '@app/features/voice/hooks/useElectronScreenSharePicker';
|
||||
import {startScreenSharePiPController} from '@app/features/voice/state/ScreenSharePiPController';
|
||||
import VoiceCallFullscreen from '@app/features/voice/state/VoiceCallFullscreen';
|
||||
import {startMediaDeviceStartupPreload} from '@app/features/voice/utils/MediaDeviceStartupPreload';
|
||||
import {useNativeTitleBar} from '@app/features/window/hooks/useNativeTitleBar';
|
||||
import {useStopFlashFrameOnFocus} from '@app/features/window/hooks/useStopFlashFrameOnFocus';
|
||||
@@ -72,6 +71,7 @@ import {msg} from '@lingui/core/macro';
|
||||
import {I18nProvider} from '@lingui/react';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {IconContext} from '@phosphor-icons/react';
|
||||
import {reaction} from 'mobx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
@@ -89,10 +89,9 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
const reducedMotion = Accessibility.useReducedMotion;
|
||||
const stayInteractiveWhenUnfocused = Accessibility.stayInteractiveWhenUnfocused;
|
||||
const firstClickPassThroughWhenUnfocused = Accessibility.firstClickPassThroughWhenUnfocused;
|
||||
const {platform, isNative, isMacOS} = useNativePlatform();
|
||||
const {platform, isNative} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const messageDisplayCompact = UserSettings.getMessageDisplayCompact();
|
||||
const isVoiceCallFullscreenActive = VoiceCallFullscreen.isActive;
|
||||
const isRootDocumentFullscreen = useIsRootDocumentFullscreen();
|
||||
const [layoutVariant, setLayoutVariant] = useState<LayoutVariant>('app');
|
||||
const layoutVariantContextValue = useMemo(
|
||||
@@ -138,6 +137,15 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
showMyselfTypingHelper.start();
|
||||
return () => showMyselfTypingHelper.stop();
|
||||
}, []);
|
||||
useEffect(
|
||||
() =>
|
||||
reaction(
|
||||
() => GatewayConnection.sessionId,
|
||||
(sessionId) => MemberSidebar.synchronizeGatewaySession(sessionId),
|
||||
{fireImmediately: true},
|
||||
),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
const clearForeignPortalHost = (): void => {
|
||||
const activePortalHost = getActivePortalHost();
|
||||
@@ -202,12 +210,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
|
||||
>
|
||||
{i18n._(SKIP_TO_CONTENT_DESCRIPTOR)}
|
||||
</a>
|
||||
<NativeTrafficLightsBackdrop
|
||||
variant={layoutVariant}
|
||||
hidden={isVoiceCallFullscreenActive}
|
||||
data-flx="app.app.app-wrapper.native-traffic-lights-backdrop"
|
||||
/>
|
||||
{isNative && !isMacOS && !useSystemTitleBar && !isRootDocumentFullscreen && (
|
||||
{isNative && !useSystemTitleBar && !isRootDocumentFullscreen && (
|
||||
<NativeTitlebar platform={platform} data-flx="app.app.app-wrapper.native-titlebar" />
|
||||
)}
|
||||
{children}
|
||||
|
||||
@@ -86,6 +86,7 @@ var {
|
||||
--z-index-toast: 50000;
|
||||
--z-index-titlebar: 100000;
|
||||
--native-titlebar-height: 2rem;
|
||||
--macos-traffic-light-inset: 78px;
|
||||
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
@@ -118,6 +119,9 @@ var {
|
||||
);
|
||||
|
||||
--spoiler-border-radius: 0.375rem;
|
||||
--markup-restricted-inline-icon-baseline-shift: -0.125em;
|
||||
--markup-restricted-inline-emoji-size: 1.25em;
|
||||
--markup-restricted-inline-emoji-baseline-shift: -0.18em;
|
||||
|
||||
--font-size-xs: 0.75rem;
|
||||
--emoji-size-emoji: 1.5em;
|
||||
@@ -139,7 +143,7 @@ var {
|
||||
--spacing-24: 6rem;
|
||||
|
||||
--layout-guild-list-width: 4.5rem;
|
||||
--layout-sidebar-width: 16.875rem;
|
||||
--layout-sidebar-width: 20rem;
|
||||
--layout-header-height: 3.5rem;
|
||||
--layout-user-area-height: var(--input-container-min-height);
|
||||
--layout-user-area-reserved-height: 0px;
|
||||
@@ -224,7 +228,7 @@ html.allow-transparency body {
|
||||
z-index: var(--z-index-titlebar);
|
||||
}
|
||||
|
||||
html.platform-native:not(.platform-macos):not(.native-system-titlebar) #fluxer-startup-native-titlebar {
|
||||
html.platform-native:not(.native-system-titlebar) #fluxer-startup-native-titlebar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
@@ -1319,8 +1319,7 @@ class Accessibility {
|
||||
this.firstClickPassThroughWhenUnfocused = validated.firstClickPassThroughWhenUnfocused;
|
||||
if (validated.scrollToBottomOnMessageSend !== undefined)
|
||||
this.scrollToBottomOnMessageSend = validated.scrollToBottomOnMessageSend;
|
||||
if (validated.sequentialFileSend !== undefined)
|
||||
this.sequentialFileSend = validated.sequentialFileSend;
|
||||
if (validated.sequentialFileSend !== undefined) this.sequentialFileSend = validated.sequentialFileSend;
|
||||
if (validated.showNeko !== undefined && validated.showNeko !== this.showNeko) {
|
||||
this.showNeko = validated.showNeko;
|
||||
persistLocalShowNeko(validated.showNeko);
|
||||
@@ -1463,14 +1462,7 @@ class Accessibility {
|
||||
|
||||
async applyZoom(level: number): Promise<void> {
|
||||
const zoomLevel = clampZoomLevel(level);
|
||||
const electronApi = (
|
||||
window as {
|
||||
electron?: {
|
||||
setZoomFactor?: (factor: number) => void;
|
||||
};
|
||||
}
|
||||
).electron;
|
||||
applyAppZoomToDocument(zoomLevel * 100, electronApi);
|
||||
applyAppZoomToDocument(zoomLevel * 100, window.electron);
|
||||
}
|
||||
|
||||
async applyStoredZoom(): Promise<void> {
|
||||
|
||||
@@ -52,7 +52,7 @@ async function cleanupRuntimeStateOnCrash(): Promise<void> {
|
||||
|
||||
export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
const {i18n} = useLingui();
|
||||
const {platform, isNative, isMacOS} = useNativePlatform();
|
||||
const {platform, isNative} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
@@ -111,7 +111,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = ({error}) => {
|
||||
}, [stackTraceText]);
|
||||
return (
|
||||
<div className={errorFallbackStyles.errorFallbackContainer} data-flx="app.error-fallback.div">
|
||||
{isNative && !isMacOS && !useSystemTitleBar && (
|
||||
{isNative && !useSystemTitleBar && (
|
||||
<NativeTitlebar platform={platform} data-flx="app.error-fallback.native-titlebar" />
|
||||
)}
|
||||
<FluxerIcon className={errorFallbackStyles.errorFallbackIcon} data-flx="app.error-fallback.fluxer-icon" />
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
createRangesForSection,
|
||||
setHighlightRanges,
|
||||
} from '@app/features/messaging/utils/CSSHighlightSearch';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {AccessibilityInlineContent} from '@app/features/user/components/modals/tabs/accessibility_tab/AccessibilityTabInline';
|
||||
import {AccountSecurityInlineTab} from '@app/features/user/components/modals/tabs/account_security_tab/AccountSecurityTabInline';
|
||||
import {AppearanceInlineContent} from '@app/features/user/components/modals/tabs/appearance_tab/AppearanceTabInline';
|
||||
@@ -149,7 +150,7 @@ const SettingsSection: React.FC<SettingsSectionProps> = observer(
|
||||
/>
|
||||
</span>
|
||||
<CaretRightIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
className={clsx(styles.expandIcon, isExpanded && styles.expandIconExpanded)}
|
||||
data-flx="app.all-settings-renderer.settings-section.expand-icon"
|
||||
|
||||
+8
-4
@@ -2,6 +2,7 @@
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {SettingsModalHeader} from '@app/features/app/components/dialogs/components/SettingsModalHeader';
|
||||
import {resolveSettingsTitle} from '@app/features/app/components/dialogs/shared/SettingsContentPresentation';
|
||||
import {
|
||||
SettingsModalDesktopContent,
|
||||
SettingsModalDesktopScroll,
|
||||
@@ -117,6 +118,11 @@ export const DesktopChannelSettingsView: React.FC<DesktopChannelSettingsViewProp
|
||||
const useOverride = SettingsSidebar.useOverride;
|
||||
const activeTabPanelId = selectedTab ? `channel-settings-tabpanel-${selectedTab}` : undefined;
|
||||
const activeTabId = selectedTab ? `channel-settings-tab-${selectedTab}` : undefined;
|
||||
const fallbackSettingsTitle = i18n._(
|
||||
isCategory ? CATEGORY_SETTINGS_LABEL_DESCRIPTOR : CHANNEL_SETTINGS_LABEL_DESCRIPTOR,
|
||||
);
|
||||
const currentTabLabel = currentTab ? currentTab.label : null;
|
||||
const settingsTitle = resolveSettingsTitle(currentTabLabel, fallbackSettingsTitle);
|
||||
const scrollKey = useMemo(
|
||||
() => `channel-settings-${channel.id}-${selectedTab ?? 'none'}`,
|
||||
[channel.id, selectedTab],
|
||||
@@ -263,10 +269,8 @@ export const DesktopChannelSettingsView: React.FC<DesktopChannelSettingsViewProp
|
||||
data-flx="app.desktop-channel-settings-view.settings-modal-desktop-content"
|
||||
>
|
||||
<SettingsModalHeader
|
||||
title={
|
||||
currentTab?.label ||
|
||||
i18n._(isCategory ? CATEGORY_SETTINGS_LABEL_DESCRIPTOR : CHANNEL_SETTINGS_LABEL_DESCRIPTOR)
|
||||
}
|
||||
title={settingsTitle}
|
||||
pageLinkHref={null}
|
||||
showUnsavedBanner={showUnsavedBanner}
|
||||
flashBanner={flashBanner}
|
||||
tabData={tabData}
|
||||
|
||||
+2
-1
@@ -23,6 +23,7 @@ import {isStockCommunityGuild} from '@app/features/guild/utils/GuildCommunityUti
|
||||
import {openMessageHistoryThresholdSettings} from '@app/features/guild/utils/guild_tabs/GuildOverviewTabUtils';
|
||||
import {BACK_TO_SETTINGS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -246,7 +247,7 @@ export const DesktopGuildSettingsView: React.FC<DesktopGuildSettingsViewProps> =
|
||||
{i18n._(MEMBERS_PAGE_ACTION_DESCRIPTOR)}
|
||||
</span>
|
||||
<ArrowSquareOutIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
className={styles.externalTabIcon}
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
SIGN_OUT_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -269,7 +270,7 @@ const BreadcrumbTitle: React.FC<BreadcrumbTitleProps> = ({parentLabel, currentLa
|
||||
{parentLabel}
|
||||
</button>
|
||||
<CaretRightIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
className={styles.breadcrumbChevron}
|
||||
aria-hidden="true"
|
||||
|
||||
+10
-16
@@ -7,6 +7,8 @@ import {
|
||||
MobileSettingsDangerItem,
|
||||
MobileSettingsList,
|
||||
} from '@app/features/app/components/dialogs/shared/MobileSettingsComponents';
|
||||
import {resolveSettingsTitle} from '@app/features/app/components/dialogs/shared/SettingsContentPresentation';
|
||||
import {usePreservedScrollerPosition} from '@app/features/app/components/dialogs/shared/UsePreservedScrollerPosition';
|
||||
import {ChannelDeleteModal} from '@app/features/channel/components/modals/ChannelDeleteModal';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {DELETE_CATEGORY_DESCRIPTOR} from '@app/features/channel/utils/ChannelMessageDescriptors';
|
||||
@@ -15,7 +17,7 @@ import channelStyles from '@app/features/guild/components/modals/GuildSettingsMo
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import styles from '@app/features/user/components/modals/UserSettingsModal.module.css';
|
||||
import type {
|
||||
ChannelSettingsTab,
|
||||
@@ -30,7 +32,7 @@ import {TrashIcon} from '@phosphor-icons/react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {type UIEvent, useCallback, useEffect, useRef} from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const DELETE_CHANNEL_DESCRIPTOR = msg({
|
||||
message: 'Delete channel',
|
||||
@@ -102,19 +104,10 @@ export const MobileChannelSettingsView: React.FC<MobileChannelSettingsViewProps>
|
||||
});
|
||||
const showMobileList = mobileNav.isRootView;
|
||||
const showMobileContent = !mobileNav.isRootView;
|
||||
const listScrollPositionRef = useRef(0);
|
||||
const listScrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const handleListScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||
listScrollPositionRef.current = event.currentTarget.scrollTop;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!showMobileList) return;
|
||||
const scroller = listScrollerRef.current;
|
||||
if (!scroller) return;
|
||||
const target = listScrollPositionRef.current;
|
||||
if (target === 0) return;
|
||||
scroller.scrollTo({to: target, animate: false});
|
||||
}, [showMobileList]);
|
||||
const {scrollerRef: listScrollerRef, handleScroll: handleListScroll} = usePreservedScrollerPosition(showMobileList);
|
||||
const currentTabLabel = currentTab ? currentTab.label : null;
|
||||
const currentViewTitle = mobileNav.currentView ? mobileNav.currentView.title : null;
|
||||
const mobileTitle = resolveSettingsTitle(currentTabLabel, currentViewTitle);
|
||||
const dangerAction = canManageChannel ? (
|
||||
<MobileSettingsDangerItem
|
||||
icon={TrashIcon}
|
||||
@@ -177,7 +170,8 @@ export const MobileChannelSettingsView: React.FC<MobileChannelSettingsViewProps>
|
||||
data-flx="app.mobile-channel-settings-view.mobile-header-content--2"
|
||||
>
|
||||
<MobileHeaderWithBanner
|
||||
title={currentTab.label || mobileNav.currentView?.title}
|
||||
pageLinkHref={null}
|
||||
title={mobileTitle}
|
||||
onBack={handleBack}
|
||||
showUnsavedBanner={showUnsavedBanner}
|
||||
flashBanner={flashBanner}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
SEARCH_SETTINGS_FIELD_LABEL_DESCRIPTOR,
|
||||
SEARCH_SETTINGS_PLACEHOLDER_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -91,7 +92,7 @@ export const SettingsSearch: React.FC<SettingsSearchProps> = observer(
|
||||
aria-label={i18n._(CLEAR_SEARCH_DESCRIPTOR)}
|
||||
data-flx="app.settings-search.clear-button"
|
||||
>
|
||||
<XIcon size={14} weight="bold" data-flx="app.settings-search.x-icon" />
|
||||
<XIcon size={remFromPx(14)} weight="bold" data-flx="app.settings-search.x-icon" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
) : undefined;
|
||||
@@ -110,7 +111,11 @@ export const SettingsSearch: React.FC<SettingsSearchProps> = observer(
|
||||
placeholder={placeholder ?? i18n._(SEARCH_SETTINGS_PLACEHOLDER_DESCRIPTOR)}
|
||||
aria-label={i18n._(SEARCH_SETTINGS_FIELD_LABEL_DESCRIPTOR)}
|
||||
leftIcon={
|
||||
<MagnifyingGlassIcon size={16} weight="bold" data-flx="app.settings-search.magnifying-glass-icon" />
|
||||
<MagnifyingGlassIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.settings-search.magnifying-glass-icon"
|
||||
/>
|
||||
}
|
||||
rightElement={rightElement}
|
||||
data-flx="app.settings-search.input.query-change.text"
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {formatSlowmodeTime} from '@app/features/channel/components/SlowmodeIndicator';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {isForwardableChannelType} from './ForwardChannelEligibility';
|
||||
|
||||
const GUILD_MESSAGES_DISABLED_DESCRIPTOR = msg({
|
||||
message: 'Sending messages is disabled in this community',
|
||||
comment: 'Short label in the settings dialog forward channel selection.',
|
||||
});
|
||||
const MEMBER_TIMED_OUT_DESCRIPTOR = msg({
|
||||
message: "You're on timeout in this community",
|
||||
comment: 'Short label in the settings dialog forward channel selection. Keep the tone plain and specific.',
|
||||
});
|
||||
const SEND_MESSAGES_PERMISSION_REQUIRED_DESCRIPTOR = msg({
|
||||
message: 'You need the "{sendMessagesPermissionLabel}" permission to send messages in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the user lacks the Send Messages permission in the target channel. Permission name is interpolated.',
|
||||
});
|
||||
const EMBED_LINKS_PERMISSION_REQUIRED_DESCRIPTOR = msg({
|
||||
message: 'You need the "{embedLinksPermissionLabel}" permission to embed links in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the forwarded message contains embeds and the user lacks Embed Links in the target channel.',
|
||||
});
|
||||
const ATTACH_FILES_PERMISSION_REQUIRED_DESCRIPTOR = msg({
|
||||
message: 'You need the "{attachFilesPermissionLabel}" permission to attach files in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the forwarded message has attachments and the user lacks Attach Files in the target channel.',
|
||||
});
|
||||
const SLOWMODE_WAIT_DESCRIPTOR = msg({
|
||||
message: 'Slowmode · wait {remaining}',
|
||||
comment:
|
||||
'Short label in the settings dialog forward channel selection. Preserve {remaining}; it is inserted by code.',
|
||||
});
|
||||
|
||||
const FORWARD_CHANNEL_RESULT_LIMIT = 100;
|
||||
|
||||
export interface ForwardMessageMediaSelection {
|
||||
readonly hasAttachments: boolean;
|
||||
readonly hasEmbeds: boolean;
|
||||
}
|
||||
|
||||
interface ResolveForwardMessageMediaSelectionArgs {
|
||||
readonly message: Message;
|
||||
readonly override: ForwardMessageMediaSelection | undefined;
|
||||
}
|
||||
|
||||
function hasSnapshotAttachments(message: Message): boolean {
|
||||
if (message.attachments.length > 0) return true;
|
||||
const snapshots = message.messageSnapshots;
|
||||
if (snapshots === undefined) return false;
|
||||
return snapshots.some((snapshot) => {
|
||||
const attachments = snapshot.attachments;
|
||||
return attachments != null && attachments.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function hasSnapshotEmbeds(message: Message): boolean {
|
||||
if (message.embeds.length > 0) return true;
|
||||
const snapshots = message.messageSnapshots;
|
||||
if (snapshots === undefined) return false;
|
||||
return snapshots.some((snapshot) => {
|
||||
const embeds = snapshot.embeds;
|
||||
return embeds != null && embeds.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveForwardMessageMediaSelection({
|
||||
message,
|
||||
override,
|
||||
}: ResolveForwardMessageMediaSelectionArgs): ForwardMessageMediaSelection {
|
||||
if (override !== undefined) return override;
|
||||
return Object.freeze({
|
||||
hasAttachments: hasSnapshotAttachments(message),
|
||||
hasEmbeds: hasSnapshotEmbeds(message),
|
||||
});
|
||||
}
|
||||
|
||||
export interface ForwardChannelObservation {
|
||||
readonly canAttachFiles: boolean;
|
||||
readonly canEmbedLinks: boolean;
|
||||
readonly canSendMessages: boolean;
|
||||
readonly categoryName: string | null;
|
||||
readonly channel: Channel;
|
||||
readonly displayName: string;
|
||||
readonly guildMessagesDisabled: boolean;
|
||||
readonly guildName: string | null;
|
||||
readonly memberTimedOut: boolean;
|
||||
readonly slowmodeEnabled: boolean;
|
||||
readonly slowmodeRemainingMs: number;
|
||||
}
|
||||
|
||||
export interface ForwardChannelOption {
|
||||
readonly categoryName: string | null;
|
||||
readonly channel: Channel;
|
||||
readonly disableReason: string | null;
|
||||
readonly displayName: string;
|
||||
readonly guildName: string | null;
|
||||
readonly slowmodeEnabled: boolean;
|
||||
readonly slowmodeRemainingMs: number;
|
||||
}
|
||||
|
||||
interface IndexedForwardChannelOption extends ForwardChannelOption {
|
||||
readonly channelNameSearchValue: string;
|
||||
readonly displayNameSearchValue: string;
|
||||
readonly guildNameSearchValue: string;
|
||||
readonly isPersonalNotes: boolean;
|
||||
readonly isSource: boolean;
|
||||
readonly recentRank: number | null;
|
||||
}
|
||||
|
||||
interface BuildForwardChannelIndexRequest {
|
||||
readonly excludedChannelId: string;
|
||||
readonly i18n: I18n;
|
||||
readonly mediaSelection: ForwardMessageMediaSelection;
|
||||
readonly observations: ReadonlyArray<ForwardChannelObservation>;
|
||||
readonly recentChannelIds: ReadonlyArray<string>;
|
||||
}
|
||||
|
||||
interface ForwardChannelSelectionDisabledRequest {
|
||||
readonly maxSelections: number;
|
||||
readonly option: ForwardChannelOption;
|
||||
readonly selectedChannelIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export class ForwardChannelIndex {
|
||||
private readonly i18n: I18n;
|
||||
private readonly options: ReadonlyArray<IndexedForwardChannelOption>;
|
||||
private readonly optionsByChannelId: ReadonlyMap<string, IndexedForwardChannelOption>;
|
||||
|
||||
constructor({
|
||||
excludedChannelId,
|
||||
i18n,
|
||||
mediaSelection,
|
||||
observations,
|
||||
recentChannelIds,
|
||||
}: BuildForwardChannelIndexRequest) {
|
||||
this.i18n = i18n;
|
||||
const recentRanks = ForwardChannelIndex.buildRecentRanks(recentChannelIds);
|
||||
const options = observations
|
||||
.filter((observation) => isForwardableChannelType(observation.channel.type))
|
||||
.map((observation) => this.buildOption({excludedChannelId, mediaSelection, observation, recentRanks}));
|
||||
options.sort((left, right) => this.compareOptions(left, right));
|
||||
this.options = Object.freeze(options);
|
||||
this.optionsByChannelId = new Map(options.map((option) => [option.channel.id, option]));
|
||||
}
|
||||
|
||||
filter(searchQuery: string): ReadonlyArray<ForwardChannelOption> {
|
||||
if (searchQuery.trim().length === 0) {
|
||||
return this.options.slice(0, FORWARD_CHANNEL_RESULT_LIMIT);
|
||||
}
|
||||
const normalizedQuery = searchQuery.toLowerCase();
|
||||
const personalNotesSearchValue = this.i18n._(PERSONAL_NOTES_DESCRIPTOR).toLowerCase();
|
||||
const matches: Array<IndexedForwardChannelOption> = [];
|
||||
for (const option of this.options) {
|
||||
if (!ForwardChannelIndex.matchesSearch({normalizedQuery, personalNotesSearchValue, option})) continue;
|
||||
matches.push(option);
|
||||
if (matches.length === FORWARD_CHANNEL_RESULT_LIMIT) break;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
isSelectionDisabled({maxSelections, option, selectedChannelIds}: ForwardChannelSelectionDisabledRequest): boolean {
|
||||
if (option.disableReason != null) return true;
|
||||
if (selectedChannelIds.has(option.channel.id)) return false;
|
||||
return selectedChannelIds.size >= maxSelections;
|
||||
}
|
||||
|
||||
select(selectedChannelIds: ReadonlySet<string>): ReadonlyArray<ForwardChannelOption> {
|
||||
const selected: Array<IndexedForwardChannelOption> = [];
|
||||
for (const channelId of selectedChannelIds) {
|
||||
const option = this.optionsByChannelId.get(channelId);
|
||||
if (option != null) selected.push(option);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static buildRecentRanks(recentChannelIds: ReadonlyArray<string>): ReadonlyMap<string, number> {
|
||||
return new Map(recentChannelIds.map((channelId, index) => [channelId, index]));
|
||||
}
|
||||
|
||||
private static matchesSearch({
|
||||
normalizedQuery,
|
||||
personalNotesSearchValue,
|
||||
option,
|
||||
}: {
|
||||
readonly normalizedQuery: string;
|
||||
readonly personalNotesSearchValue: string;
|
||||
readonly option: IndexedForwardChannelOption;
|
||||
}): boolean {
|
||||
if (option.isPersonalNotes && personalNotesSearchValue.includes(normalizedQuery)) return true;
|
||||
if (option.displayNameSearchValue.includes(normalizedQuery)) return true;
|
||||
if (option.channelNameSearchValue.includes(normalizedQuery)) return true;
|
||||
return option.guildNameSearchValue.includes(normalizedQuery);
|
||||
}
|
||||
|
||||
private buildOption({
|
||||
excludedChannelId,
|
||||
mediaSelection,
|
||||
observation,
|
||||
recentRanks,
|
||||
}: {
|
||||
readonly excludedChannelId: string;
|
||||
readonly mediaSelection: ForwardMessageMediaSelection;
|
||||
readonly observation: ForwardChannelObservation;
|
||||
readonly recentRanks: ReadonlyMap<string, number>;
|
||||
}): IndexedForwardChannelOption {
|
||||
const permissionIssue = this.resolvePermissionIssue(observation, mediaSelection);
|
||||
const disableReason = this.resolveDisableReason(observation, permissionIssue);
|
||||
const recentRankValue = recentRanks.get(observation.channel.id);
|
||||
let recentRank: number | null = null;
|
||||
if (recentRankValue !== undefined) recentRank = recentRankValue;
|
||||
let channelNameSearchValue = '';
|
||||
if (observation.channel.name) channelNameSearchValue = observation.channel.name.toLowerCase();
|
||||
let guildNameSearchValue = '';
|
||||
if (observation.guildName != null) guildNameSearchValue = observation.guildName.toLowerCase();
|
||||
return Object.freeze({
|
||||
categoryName: observation.categoryName,
|
||||
channel: observation.channel,
|
||||
channelNameSearchValue,
|
||||
disableReason,
|
||||
displayName: observation.displayName,
|
||||
displayNameSearchValue: observation.displayName.toLowerCase(),
|
||||
guildName: observation.guildName,
|
||||
guildNameSearchValue,
|
||||
isPersonalNotes: observation.channel.type === ChannelTypes.DM_PERSONAL_NOTES,
|
||||
isSource: observation.channel.id === excludedChannelId,
|
||||
recentRank,
|
||||
slowmodeEnabled: observation.slowmodeEnabled,
|
||||
slowmodeRemainingMs: observation.slowmodeRemainingMs,
|
||||
});
|
||||
}
|
||||
|
||||
private compareOptions(left: IndexedForwardChannelOption, right: IndexedForwardChannelOption): number {
|
||||
if (left.isSource !== right.isSource) return left.isSource ? 1 : -1;
|
||||
const leftUnavailable = left.disableReason != null;
|
||||
const rightUnavailable = right.disableReason != null;
|
||||
if (leftUnavailable !== rightUnavailable) return leftUnavailable ? 1 : -1;
|
||||
if (left.recentRank != null && right.recentRank != null) return left.recentRank - right.recentRank;
|
||||
if (left.recentRank != null) return -1;
|
||||
if (right.recentRank != null) return 1;
|
||||
return left.displayNameSearchValue.localeCompare(right.displayNameSearchValue);
|
||||
}
|
||||
|
||||
private resolveDisableReason(observation: ForwardChannelObservation, permissionIssue: string | null): string | null {
|
||||
if (permissionIssue != null) return permissionIssue;
|
||||
if (observation.slowmodeRemainingMs <= 0) return null;
|
||||
const remaining = formatSlowmodeTime(observation.slowmodeRemainingMs, this.i18n.locale);
|
||||
return this.i18n._(SLOWMODE_WAIT_DESCRIPTOR, {remaining});
|
||||
}
|
||||
|
||||
private resolvePermissionIssue(
|
||||
observation: ForwardChannelObservation,
|
||||
mediaSelection: ForwardMessageMediaSelection,
|
||||
): string | null {
|
||||
if (observation.guildMessagesDisabled) return this.i18n._(GUILD_MESSAGES_DISABLED_DESCRIPTOR);
|
||||
if (observation.memberTimedOut) return this.i18n._(MEMBER_TIMED_OUT_DESCRIPTOR);
|
||||
if (!observation.canSendMessages) {
|
||||
const sendMessagesPermissionLabel = formatPermissionLabel(this.i18n, Permissions.SEND_MESSAGES);
|
||||
return this.i18n._(SEND_MESSAGES_PERMISSION_REQUIRED_DESCRIPTOR, {sendMessagesPermissionLabel});
|
||||
}
|
||||
if (mediaSelection.hasEmbeds && !observation.canEmbedLinks) {
|
||||
const embedLinksPermissionLabel = formatPermissionLabel(this.i18n, Permissions.EMBED_LINKS);
|
||||
return this.i18n._(EMBED_LINKS_PERMISSION_REQUIRED_DESCRIPTOR, {embedLinksPermissionLabel});
|
||||
}
|
||||
if (mediaSelection.hasAttachments && !observation.canAttachFiles) {
|
||||
const attachFilesPermissionLabel = formatPermissionLabel(this.i18n, Permissions.ATTACH_FILES);
|
||||
return this.i18n._(ATTACH_FILES_PERMISSION_REQUIRED_DESCRIPTOR, {attachFilesPermissionLabel});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,288 +1,119 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {formatSlowmodeTime} from '@app/features/channel/components/SlowmodeIndicator';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {
|
||||
ForwardChannelIndex,
|
||||
type ForwardChannelOption,
|
||||
type ForwardMessageMediaSelection,
|
||||
resolveForwardMessageMediaSelection,
|
||||
} from '@app/features/app/components/dialogs/shared/ForwardChannelIndex';
|
||||
import {useForwardChannelObservations} from '@app/features/app/components/dialogs/shared/UseForwardChannelObservations';
|
||||
import {useShallowStableArray} from '@app/features/app/hooks/useShallowStableArray';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import {formatPermissionLabel} from '@app/features/permissions/utils/PermissionUtils';
|
||||
import Slowmode from '@app/features/slowmode/state/Slowmode';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {isForwardableChannelType} from './ForwardChannelEligibility';
|
||||
|
||||
const CHANNEL_DESCRIPTOR = msg({
|
||||
message: 'Channel {id}',
|
||||
comment: 'Short label in the settings dialog forward channel selection. Preserve {id}; it is inserted by code.',
|
||||
});
|
||||
const SENDING_MESSAGES_IS_DISABLED_IN_THIS_COMMUNITY_DESCRIPTOR = msg({
|
||||
message: 'Sending messages is disabled in this community',
|
||||
comment: 'Short label in the settings dialog forward channel selection.',
|
||||
});
|
||||
const YOU_RE_ON_TIMEOUT_IN_THIS_COMMUNITY_DESCRIPTOR = msg({
|
||||
message: "You're on timeout in this community",
|
||||
comment: 'Short label in the settings dialog forward channel selection. Keep the tone plain and specific.',
|
||||
});
|
||||
const YOU_NEED_THE_PERMISSION_TO_SEND_MESSAGES_IN_DESCRIPTOR = msg({
|
||||
message: 'You need the "{sendMessagesPermissionLabel}" permission to send messages in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the user lacks the Send Messages permission in the target channel. Permission name is interpolated.',
|
||||
});
|
||||
const YOU_NEED_THE_PERMISSION_TO_EMBED_LINKS_IN_DESCRIPTOR = msg({
|
||||
message: 'You need the "{embedLinksPermissionLabel}" permission to embed links in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the forwarded message contains embeds and the user lacks Embed Links in the target channel.',
|
||||
});
|
||||
const YOU_NEED_THE_PERMISSION_TO_ATTACH_FILES_IN_DESCRIPTOR = msg({
|
||||
message: 'You need the "{attachFilesPermissionLabel}" permission to attach files in this channel',
|
||||
comment:
|
||||
'Forward dialog error shown when the forwarded message has attachments and the user lacks Attach Files in the target channel.',
|
||||
});
|
||||
const SLOWMODE_WAIT_DESCRIPTOR = msg({
|
||||
message: 'Slowmode · wait {remaining}',
|
||||
comment:
|
||||
'Short label in the settings dialog forward channel selection. Preserve {remaining}; it is inserted by code.',
|
||||
});
|
||||
|
||||
export interface ChannelSlowmodeState {
|
||||
isEnabled: boolean;
|
||||
isCoolingDown: boolean;
|
||||
remainingMs: number;
|
||||
}
|
||||
|
||||
const EMPTY_SLOWMODE_STATE: ChannelSlowmodeState = {isEnabled: false, isCoolingDown: false, remainingMs: 0};
|
||||
|
||||
export function getChannelSlowmodeState(channel: Channel): ChannelSlowmodeState {
|
||||
if (!channel.guildId || channel.rateLimitPerUser <= 0) return EMPTY_SLOWMODE_STATE;
|
||||
if (Permission.can(Permissions.BYPASS_SLOWMODE, channel)) return EMPTY_SLOWMODE_STATE;
|
||||
if (DeveloperOptions.mockSlowmodeActive) {
|
||||
return {isEnabled: true, isCoolingDown: true, remainingMs: DeveloperOptions.mockSlowmodeRemaining};
|
||||
}
|
||||
const remainingMs = Slowmode.getSlowmodeRemaining(channel.id, channel.rateLimitPerUser);
|
||||
return {isEnabled: true, isCoolingDown: remainingMs > 0, remainingMs};
|
||||
}
|
||||
|
||||
export const getForwardChannelDisplayName = (channel: Channel, i18n?: I18n): string => {
|
||||
if (
|
||||
channel.type === ChannelTypes.DM_PERSONAL_NOTES ||
|
||||
channel.type === ChannelTypes.DM ||
|
||||
channel.type === ChannelTypes.GROUP_DM
|
||||
) {
|
||||
return ChannelUtils.getDMDisplayName(channel);
|
||||
}
|
||||
if (channel.name) return channel.name;
|
||||
if (!i18n) return `Channel ${channel.id}`;
|
||||
const id = channel.id;
|
||||
return i18n._(CHANNEL_DESCRIPTOR, {id});
|
||||
};
|
||||
export const getForwardChannelGuildName = (channel: Channel): string | null => {
|
||||
if (!channel.guildId) return null;
|
||||
const guild = Guilds.getGuild(channel.guildId);
|
||||
return guild?.name ?? null;
|
||||
};
|
||||
export const getForwardChannelCategoryName = (channel: Channel): string | null => {
|
||||
if (!channel.parentId) return null;
|
||||
const category = Channels.getChannel(channel.parentId);
|
||||
if (!category) return null;
|
||||
return category.name || null;
|
||||
};
|
||||
|
||||
function messageHasMedia(message: Message): {
|
||||
hasEmbeds: boolean;
|
||||
hasAttachments: boolean;
|
||||
} {
|
||||
const hasEmbeds =
|
||||
message.embeds.length > 0 ||
|
||||
(message.messageSnapshots?.some((s) => s.embeds != null && s.embeds.length > 0) ?? false);
|
||||
const hasAttachments =
|
||||
message.attachments.length > 0 ||
|
||||
(message.messageSnapshots?.some((s) => s.attachments != null && s.attachments.length > 0) ?? false);
|
||||
return {hasEmbeds, hasAttachments};
|
||||
}
|
||||
import {type Dispatch, type SetStateAction, useCallback, useMemo, useState} from 'react';
|
||||
|
||||
interface UseForwardChannelSelectionOptions {
|
||||
excludedChannelId: string;
|
||||
message: Message;
|
||||
maxSelections?: number;
|
||||
mediaSelection?: {
|
||||
hasEmbeds: boolean;
|
||||
hasAttachments: boolean;
|
||||
};
|
||||
readonly excludedChannelId: string;
|
||||
readonly message: Message;
|
||||
readonly maxSelections?: number;
|
||||
readonly mediaSelection?: ForwardMessageMediaSelection;
|
||||
}
|
||||
|
||||
export const useForwardChannelSelection = ({
|
||||
export interface ForwardChannelSelectionState {
|
||||
readonly filteredChannels: ReadonlyArray<ForwardChannelOption>;
|
||||
readonly handleToggleChannel: (channelId: string) => void;
|
||||
readonly isChannelSelectionDisabled: (option: ForwardChannelOption) => boolean;
|
||||
readonly maxSelections: number;
|
||||
readonly searchQuery: string;
|
||||
readonly selectedChannelIds: ReadonlySet<string>;
|
||||
readonly setSearchQuery: Dispatch<SetStateAction<string>>;
|
||||
readonly slowmodeActiveSelectedChannelOptions: ReadonlyArray<ForwardChannelOption>;
|
||||
readonly slowmodeEnabledSelectedChannelOptions: ReadonlyArray<ForwardChannelOption>;
|
||||
}
|
||||
|
||||
function toggleForwardChannelSelection(
|
||||
channelId: string,
|
||||
maxSelections: number,
|
||||
previousChannelIds: Set<string>,
|
||||
): Set<string> {
|
||||
const nextChannelIds = new Set(previousChannelIds);
|
||||
if (nextChannelIds.has(channelId)) {
|
||||
nextChannelIds.delete(channelId);
|
||||
return nextChannelIds;
|
||||
}
|
||||
if (nextChannelIds.size >= maxSelections) return previousChannelIds;
|
||||
nextChannelIds.add(channelId);
|
||||
return nextChannelIds;
|
||||
}
|
||||
|
||||
export function useForwardChannelSelection({
|
||||
excludedChannelId,
|
||||
message,
|
||||
maxSelections = 5,
|
||||
mediaSelection,
|
||||
}: UseForwardChannelSelectionOptions) => {
|
||||
}: UseForwardChannelSelectionOptions): ForwardChannelSelectionState {
|
||||
const {i18n} = useLingui();
|
||||
const locale = i18n.locale;
|
||||
const allKnownChannels = Channels.allChannels;
|
||||
const recentChannelIds = SelectedChannel.recentChannels;
|
||||
const {hasEmbeds, hasAttachments} = useMemo(
|
||||
() => mediaSelection ?? messageHasMedia(message),
|
||||
const allKnownChannels = useShallowStableArray(Channels.allChannels);
|
||||
const recentChannelIds = useShallowStableArray(SelectedChannel.recentChannels);
|
||||
const currentUser = Users.currentUser;
|
||||
const currentUserId = currentUser ? currentUser.id : null;
|
||||
const resolvedMediaSelection = useMemo(
|
||||
() => resolveForwardMessageMediaSelection({message, override: mediaSelection}),
|
||||
[message, mediaSelection],
|
||||
);
|
||||
const observations = useForwardChannelObservations({channels: allKnownChannels, currentUserId, i18n});
|
||||
const channelIndex = useMemo(
|
||||
() =>
|
||||
new ForwardChannelIndex({
|
||||
excludedChannelId,
|
||||
i18n,
|
||||
mediaSelection: resolvedMediaSelection,
|
||||
observations,
|
||||
recentChannelIds,
|
||||
}),
|
||||
[excludedChannelId, i18n, locale, observations, recentChannelIds, resolvedMediaSelection],
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedChannelIds, setSelectedChannelIds] = useState<Set<string>>(new Set());
|
||||
const [slowmodeTick, setSlowmodeTick] = useState(0);
|
||||
const getChannelPermissionIssue = useCallback(
|
||||
(channel: Channel): string | null => {
|
||||
if (!channel.guildId) return null;
|
||||
const guild = Guilds.getGuild(channel.guildId);
|
||||
if (guild && (guild.disabledOperations & GuildOperations.SEND_MESSAGE) !== 0) {
|
||||
return i18n._(SENDING_MESSAGES_IS_DISABLED_IN_THIS_COMMUNITY_DESCRIPTOR);
|
||||
}
|
||||
const currentUserId = Users.currentUser?.id;
|
||||
const member = currentUserId ? GuildMembers.getMember(channel.guildId, currentUserId) : null;
|
||||
if (member?.isTimedOut()) {
|
||||
return i18n._(YOU_RE_ON_TIMEOUT_IN_THIS_COMMUNITY_DESCRIPTOR);
|
||||
}
|
||||
if (!Permission.can(Permissions.SEND_MESSAGES, channel)) {
|
||||
const sendMessagesPermissionLabel = formatPermissionLabel(i18n, Permissions.SEND_MESSAGES);
|
||||
return i18n._(YOU_NEED_THE_PERMISSION_TO_SEND_MESSAGES_IN_DESCRIPTOR, {sendMessagesPermissionLabel});
|
||||
}
|
||||
if (!hasEmbeds && !hasAttachments) return null;
|
||||
if (hasEmbeds && !Permission.can(Permissions.EMBED_LINKS, {channelId: channel.id, guildId: channel.guildId})) {
|
||||
const embedLinksPermissionLabel = formatPermissionLabel(i18n, Permissions.EMBED_LINKS);
|
||||
return i18n._(YOU_NEED_THE_PERMISSION_TO_EMBED_LINKS_IN_DESCRIPTOR, {embedLinksPermissionLabel});
|
||||
}
|
||||
if (
|
||||
hasAttachments &&
|
||||
!Permission.can(Permissions.ATTACH_FILES, {channelId: channel.id, guildId: channel.guildId})
|
||||
) {
|
||||
const attachFilesPermissionLabel = formatPermissionLabel(i18n, Permissions.ATTACH_FILES);
|
||||
return i18n._(YOU_NEED_THE_PERMISSION_TO_ATTACH_FILES_IN_DESCRIPTOR, {attachFilesPermissionLabel});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[hasEmbeds, hasAttachments, i18n],
|
||||
);
|
||||
const allChannels = useMemo(() => {
|
||||
const channels = allKnownChannels.filter((channel) => isForwardableChannelType(channel.type));
|
||||
return channels.sort((a, b) => {
|
||||
const aIsSource = a.id === excludedChannelId;
|
||||
const bIsSource = b.id === excludedChannelId;
|
||||
if (aIsSource && bIsSource) return 0;
|
||||
if (aIsSource) return 1;
|
||||
if (bIsSource) return -1;
|
||||
const aIsUnavailable = getChannelPermissionIssue(a) != null || getChannelSlowmodeState(a).isCoolingDown;
|
||||
const bIsUnavailable = getChannelPermissionIssue(b) != null || getChannelSlowmodeState(b).isCoolingDown;
|
||||
if (aIsUnavailable !== bIsUnavailable) return aIsUnavailable ? 1 : -1;
|
||||
const aIndex = recentChannelIds.indexOf(a.id);
|
||||
const bIndex = recentChannelIds.indexOf(b.id);
|
||||
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
|
||||
if (aIndex !== -1) return -1;
|
||||
if (bIndex !== -1) return 1;
|
||||
const aName = getForwardChannelDisplayName(a, i18n).toLowerCase();
|
||||
const bName = getForwardChannelDisplayName(b, i18n).toLowerCase();
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}, [allKnownChannels, recentChannelIds, excludedChannelId, getChannelPermissionIssue, i18n.locale, slowmodeTick]);
|
||||
const filteredChannels = useMemo(() => {
|
||||
if (!searchQuery.trim()) return allChannels;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return allChannels.filter((channel) => {
|
||||
const channelName = (channel.name || '').toLowerCase();
|
||||
const displayName = getForwardChannelDisplayName(channel, i18n).toLowerCase();
|
||||
const guildName = channel.guildId ? (Guilds.getGuild(channel.guildId)?.name ?? '').toLowerCase() : '';
|
||||
if (channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
const notes = i18n._(PERSONAL_NOTES_DESCRIPTOR).toLowerCase();
|
||||
if (notes.includes(query)) return true;
|
||||
}
|
||||
return displayName.includes(query) || channelName.includes(query) || guildName.includes(query);
|
||||
});
|
||||
}, [allChannels, searchQuery, i18n.locale]);
|
||||
const filteredChannels = useMemo(() => channelIndex.filter(searchQuery), [channelIndex, searchQuery]);
|
||||
const handleToggleChannel = useCallback(
|
||||
(channelId: string) => {
|
||||
setSelectedChannelIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(channelId)) {
|
||||
next.delete(channelId);
|
||||
return next;
|
||||
}
|
||||
if (next.size >= maxSelections) return prev;
|
||||
next.add(channelId);
|
||||
return next;
|
||||
});
|
||||
setSelectedChannelIds((previousChannelIds) =>
|
||||
toggleForwardChannelSelection(channelId, maxSelections, previousChannelIds),
|
||||
);
|
||||
},
|
||||
[maxSelections],
|
||||
);
|
||||
const hasAnySlowmodeChannel = useMemo(
|
||||
() =>
|
||||
DeveloperOptions.mockSlowmodeActive ||
|
||||
allChannels.some(
|
||||
(channel) =>
|
||||
channel.guildId != null &&
|
||||
channel.rateLimitPerUser > 0 &&
|
||||
!Permission.can(Permissions.BYPASS_SLOWMODE, channel),
|
||||
),
|
||||
[allChannels],
|
||||
const isChannelSelectionDisabled = useCallback(
|
||||
(option: ForwardChannelOption) => channelIndex.isSelectionDisabled({maxSelections, option, selectedChannelIds}),
|
||||
[maxSelections, channelIndex, selectedChannelIds],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!hasAnySlowmodeChannel) return;
|
||||
const intervalId = window.setInterval(() => {
|
||||
setSlowmodeTick((current) => (current + 1) % 1000000);
|
||||
}, 1000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [hasAnySlowmodeChannel]);
|
||||
const isChannelDisabled = useCallback(
|
||||
(channelId: string) => {
|
||||
if (!selectedChannelIds.has(channelId) && selectedChannelIds.size >= maxSelections) return true;
|
||||
const channel = Channels.getChannel(channelId);
|
||||
if (!channel) return false;
|
||||
if (getChannelPermissionIssue(channel) != null) return true;
|
||||
if (getChannelSlowmodeState(channel).isCoolingDown) return true;
|
||||
return false;
|
||||
},
|
||||
[selectedChannelIds, maxSelections, getChannelPermissionIssue],
|
||||
const selectedChannelOptions = useMemo(
|
||||
() => channelIndex.select(selectedChannelIds),
|
||||
[channelIndex, selectedChannelIds],
|
||||
);
|
||||
const getChannelDisableReason = useCallback(
|
||||
(channel: Channel): string | null => {
|
||||
const permissionIssue = getChannelPermissionIssue(channel);
|
||||
if (permissionIssue != null) return permissionIssue;
|
||||
const slowmode = getChannelSlowmodeState(channel);
|
||||
if (slowmode.isCoolingDown) {
|
||||
const remaining = formatSlowmodeTime(slowmode.remainingMs, locale);
|
||||
return i18n._(SLOWMODE_WAIT_DESCRIPTOR, {remaining});
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[getChannelPermissionIssue, i18n, locale],
|
||||
const slowmodeEnabledSelectedChannelOptions = useMemo(
|
||||
() => selectedChannelOptions.filter((option) => option.slowmodeEnabled),
|
||||
[selectedChannelOptions],
|
||||
);
|
||||
const selectedChannels = useMemo(
|
||||
() =>
|
||||
Array.from(selectedChannelIds)
|
||||
.map((channelId) => Channels.getChannel(channelId))
|
||||
.filter((channel): channel is Channel => channel != null),
|
||||
[selectedChannelIds],
|
||||
);
|
||||
const slowmodeEnabledSelectedChannels = useMemo(
|
||||
() => selectedChannels.filter((channel) => getChannelSlowmodeState(channel).isEnabled),
|
||||
[selectedChannels],
|
||||
const slowmodeActiveSelectedChannelOptions = useMemo(
|
||||
() => selectedChannelOptions.filter((option) => option.slowmodeRemainingMs > 0),
|
||||
[selectedChannelOptions],
|
||||
);
|
||||
|
||||
return {
|
||||
filteredChannels,
|
||||
handleToggleChannel,
|
||||
isChannelDisabled,
|
||||
getChannelDisableReason,
|
||||
isChannelSelectionDisabled,
|
||||
maxSelections,
|
||||
searchQuery,
|
||||
selectedChannelIds,
|
||||
setSearchQuery,
|
||||
maxSelections,
|
||||
selectedChannels,
|
||||
slowmodeEnabledSelectedChannels,
|
||||
slowmodeActiveSelectedChannelOptions,
|
||||
slowmodeEnabledSelectedChannelOptions,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import styles from '@app/features/app/components/dialogs/shared/PermissionComponents.module.css';
|
||||
import PermissionLayout from '@app/features/permissions/state/PermissionLayout';
|
||||
import type * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Switch as UISwitch} from '@app/features/ui/components/form/FormSwitch';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -69,7 +70,11 @@ export const PermissionStateButtons: React.FC<{
|
||||
aria-label={i18n._(DENY_DESCRIPTOR)}
|
||||
data-flx="app.permission-components.permission-state-buttons.deny.button"
|
||||
>
|
||||
<XIcon weight="bold" size={16} data-flx="app.permission-components.permission-state-buttons.x-icon" />
|
||||
<XIcon
|
||||
weight="bold"
|
||||
size={remFromPx(16)}
|
||||
data-flx="app.permission-components.permission-state-buttons.x-icon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={styles.stateDivider}
|
||||
@@ -83,7 +88,11 @@ export const PermissionStateButtons: React.FC<{
|
||||
aria-label={i18n._(NEUTRAL_INHERIT_DESCRIPTOR)}
|
||||
data-flx="app.permission-components.permission-state-buttons.neutral.button"
|
||||
>
|
||||
<MinusIcon weight="bold" size={16} data-flx="app.permission-components.permission-state-buttons.minus-icon" />
|
||||
<MinusIcon
|
||||
weight="bold"
|
||||
size={remFromPx(16)}
|
||||
data-flx="app.permission-components.permission-state-buttons.minus-icon"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={styles.stateDivider}
|
||||
@@ -97,7 +106,11 @@ export const PermissionStateButtons: React.FC<{
|
||||
aria-label={i18n._(ALLOW_DESCRIPTOR)}
|
||||
data-flx="app.permission-components.permission-state-buttons.allow.button"
|
||||
>
|
||||
<CheckIcon weight="bold" size={16} data-flx="app.permission-components.permission-state-buttons.check-icon" />
|
||||
<CheckIcon
|
||||
weight="bold"
|
||||
size={remFromPx(16)}
|
||||
data-flx="app.permission-components.permission-state-buttons.check-icon"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
+35
-12
@@ -52,8 +52,14 @@
|
||||
.itemList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: var(--spacing-2) 0;
|
||||
gap: 0.0625rem;
|
||||
padding: 0 0 1rem;
|
||||
}
|
||||
|
||||
.itemGroupList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
}
|
||||
|
||||
.itemButton {
|
||||
@@ -65,9 +71,10 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-3);
|
||||
border: 0.0625rem solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
padding: var(--spacing-2) 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
@@ -75,17 +82,26 @@
|
||||
}
|
||||
|
||||
.itemButton::before {
|
||||
content: none;
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 var(--spacing-2);
|
||||
border-radius: var(--radius-md);
|
||||
transition: background-color 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.itemButton:hover:not(:disabled):not(.itemButtonSelected) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
border-color: transparent;
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.itemButton:hover:not(:disabled)::before {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.itemButtonSelected {
|
||||
background-color: var(--background-modifier-selected);
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.itemButtonSelected::before {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.itemButtonDisabled {
|
||||
@@ -95,10 +111,15 @@
|
||||
|
||||
.itemButtonDisabled:hover {
|
||||
background-color: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.itemButtonDisabled:hover::before {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.itemContent {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -132,6 +153,8 @@
|
||||
}
|
||||
|
||||
.itemAction {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex-shrink: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export function resolveSettingsTitle(primaryTitle: string | null, fallbackTitle: string | null): string | null {
|
||||
if (primaryTitle != null && primaryTitle.length > 0) {
|
||||
return primaryTitle;
|
||||
}
|
||||
return fallbackTitle;
|
||||
}
|
||||
+6
-10
@@ -290,10 +290,6 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebarNavWrapper > div {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.sidebarNav {
|
||||
flex: 1;
|
||||
margin-inline-end: -0.5rem;
|
||||
@@ -376,7 +372,7 @@
|
||||
}
|
||||
|
||||
.sidebarItemSelected,
|
||||
.sidebarItem:focus:not(.sidebarItemDanger) {
|
||||
.sidebarItem.sidebarItemFocused:not(.sidebarItemDanger) {
|
||||
background: var(--settings-sidebar-selected-background);
|
||||
color: var(--settings-selected-color);
|
||||
}
|
||||
@@ -422,7 +418,7 @@
|
||||
.sidebarItemSelected .sidebarItemChevron,
|
||||
.sidebarItem[aria-expanded='true'] .sidebarItemChevron,
|
||||
.sidebarItem:hover:not(.sidebarItemSelected) .sidebarItemChevron,
|
||||
.sidebarItem:focus-visible .sidebarItemChevron {
|
||||
.sidebarItem.sidebarItemFocused .sidebarItemChevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -431,7 +427,7 @@
|
||||
}
|
||||
|
||||
.sidebarItemSelected .sidebarItemChevron,
|
||||
.sidebarItem:focus:not(.sidebarItemDanger) .sidebarItemChevron {
|
||||
.sidebarItem.sidebarItemFocused:not(.sidebarItemDanger) .sidebarItemChevron {
|
||||
color: var(--settings-selected-color);
|
||||
}
|
||||
|
||||
@@ -440,7 +436,7 @@
|
||||
}
|
||||
|
||||
.sidebarItemSelected .sidebarItemIcon,
|
||||
.sidebarItem:focus:not(.sidebarItemDanger) .sidebarItemIcon {
|
||||
.sidebarItem.sidebarItemFocused:not(.sidebarItemDanger) .sidebarItemIcon {
|
||||
color: var(--settings-sidebar-highlight);
|
||||
}
|
||||
|
||||
@@ -612,8 +608,8 @@
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: var(--active-top, 0px);
|
||||
height: var(--active-height, 0px);
|
||||
top: var(--active-top, 0);
|
||||
height: var(--active-height, 0);
|
||||
width: var(--settings-sidebar-subtree-line-width);
|
||||
background: var(--settings-sidebar-highlight);
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import styles from '@app/features/app/components/dialogs/shared/SettingsModalLayout.module.css';
|
||||
import {SETTINGS_SECTIONS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {
|
||||
@@ -343,7 +344,7 @@ export const SettingsModalSidebarItem: React.FC<SettingsModalSidebarItemProps> =
|
||||
<>
|
||||
<IconComponent
|
||||
className={styles.sidebarItemIcon}
|
||||
size={20}
|
||||
size={remFromPx(20)}
|
||||
weight={iconWeight}
|
||||
data-flx="app.settings-modal-layout.settings-modal-sidebar-item.sidebar-item-icon"
|
||||
/>
|
||||
@@ -356,7 +357,7 @@ export const SettingsModalSidebarItem: React.FC<SettingsModalSidebarItemProps> =
|
||||
{expandable && (
|
||||
<CaretRightIcon
|
||||
className={styles.sidebarItemChevron}
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
aria-hidden="true"
|
||||
data-flx="app.settings-modal-layout.settings-modal-sidebar-item.sidebar-item-chevron"
|
||||
@@ -365,7 +366,11 @@ export const SettingsModalSidebarItem: React.FC<SettingsModalSidebarItemProps> =
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<FocusRing offset={-2} data-flx="app.settings-modal-layout.settings-modal-sidebar-item.focus-ring">
|
||||
<FocusRing
|
||||
offset={-2}
|
||||
focusClassName={styles.sidebarItemFocused}
|
||||
data-flx="app.settings-modal-layout.settings-modal-sidebar-item.focus-ring"
|
||||
>
|
||||
{isTabRole ? (
|
||||
<button
|
||||
data-flx="app.settings-modal-layout.settings-modal-sidebar-item.button"
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ForwardChannelObservation} from '@app/features/app/components/dialogs/shared/ForwardChannelIndex';
|
||||
import {useShallowStableArray} from '@app/features/app/hooks/useShallowStableArray';
|
||||
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 DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import Slowmode from '@app/features/slowmode/state/Slowmode';
|
||||
import {useNow} from '@app/features/ui/state/Tick';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {computed, type IComputedValue} from 'mobx';
|
||||
import {useMemo, useRef} from 'react';
|
||||
|
||||
const CHANNEL_DESCRIPTOR = msg({
|
||||
message: 'Channel {id}',
|
||||
comment: 'Short label in the settings dialog forward channel selection. Preserve {id}; it is inserted by code.',
|
||||
});
|
||||
|
||||
interface UseForwardChannelObservationsOptions {
|
||||
readonly channels: ReadonlyArray<Channel>;
|
||||
readonly currentUserId: string | null;
|
||||
readonly i18n: I18n;
|
||||
}
|
||||
|
||||
interface ForwardChannelObservationBase {
|
||||
readonly canAttachFiles: boolean;
|
||||
readonly canEmbedLinks: boolean;
|
||||
readonly canSendMessages: boolean;
|
||||
readonly categoryName: string | null;
|
||||
readonly channel: Channel;
|
||||
readonly displayName: string;
|
||||
readonly guildMessagesDisabled: boolean;
|
||||
readonly guildName: string | null;
|
||||
readonly memberTimedOut: boolean;
|
||||
readonly slowmodeEnabled: boolean;
|
||||
}
|
||||
|
||||
interface ForwardSlowmodeChannel {
|
||||
readonly channelId: string;
|
||||
readonly index: number;
|
||||
readonly rateLimitPerUser: number;
|
||||
}
|
||||
|
||||
interface ForwardChannelObservationSelection {
|
||||
readonly bases: ReadonlyArray<ForwardChannelObservationBase>;
|
||||
readonly slowmodeChannels: ReadonlyArray<ForwardSlowmodeChannel>;
|
||||
}
|
||||
|
||||
function resolveChannelDisplayName(channel: Channel, i18n: I18n): string {
|
||||
if (
|
||||
channel.type === ChannelTypes.DM_PERSONAL_NOTES ||
|
||||
channel.type === ChannelTypes.DM ||
|
||||
channel.type === ChannelTypes.GROUP_DM
|
||||
) {
|
||||
return ChannelUtils.getDMDisplayName(channel);
|
||||
}
|
||||
if (channel.name) return channel.name;
|
||||
return i18n._(CHANNEL_DESCRIPTOR, {id: channel.id});
|
||||
}
|
||||
|
||||
function resolveChannelCategoryName(channel: Channel): string | null {
|
||||
if (!channel.parentId) return null;
|
||||
const category = Channels.getChannel(channel.parentId);
|
||||
if (!category || !category.name) return null;
|
||||
return category.name;
|
||||
}
|
||||
|
||||
function isCurrentMemberTimedOut(channel: Channel, currentUserId: string | null): boolean {
|
||||
if (!channel.guildId || currentUserId === null) return false;
|
||||
const member = GuildMembers.getMember(channel.guildId, currentUserId);
|
||||
if (!member) return false;
|
||||
return member.isTimedOut();
|
||||
}
|
||||
|
||||
function resolveSlowmodeRemainingMs(
|
||||
entry: ForwardSlowmodeChannel,
|
||||
mockSlowmodeActive: boolean,
|
||||
mockSlowmodeRemaining: number,
|
||||
): number {
|
||||
if (mockSlowmodeActive) return mockSlowmodeRemaining;
|
||||
return Slowmode.getSlowmodeRemaining(entry.channelId, entry.rateLimitPerUser);
|
||||
}
|
||||
|
||||
function areForwardChannelObservationBasesEqual(
|
||||
left: ForwardChannelObservationBase,
|
||||
right: ForwardChannelObservationBase,
|
||||
): boolean {
|
||||
if (left.channel !== right.channel || left.displayName !== right.displayName) return false;
|
||||
if (left.guildName !== right.guildName || left.categoryName !== right.categoryName) return false;
|
||||
if (left.guildMessagesDisabled !== right.guildMessagesDisabled || left.memberTimedOut !== right.memberTimedOut) {
|
||||
return false;
|
||||
}
|
||||
if (left.canSendMessages !== right.canSendMessages || left.canEmbedLinks !== right.canEmbedLinks) return false;
|
||||
return left.canAttachFiles === right.canAttachFiles && left.slowmodeEnabled === right.slowmodeEnabled;
|
||||
}
|
||||
|
||||
function areForwardSlowmodeChannelsEqual(left: ForwardSlowmodeChannel, right: ForwardSlowmodeChannel): boolean {
|
||||
return (
|
||||
left.channelId === right.channelId && left.index === right.index && left.rateLimitPerUser === right.rateLimitPerUser
|
||||
);
|
||||
}
|
||||
|
||||
function areForwardChannelObservationSelectionsEqual(
|
||||
left: ForwardChannelObservationSelection,
|
||||
right: ForwardChannelObservationSelection,
|
||||
): boolean {
|
||||
if (left.bases.length !== right.bases.length || left.slowmodeChannels.length !== right.slowmodeChannels.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.bases.length; index += 1) {
|
||||
const leftBase = left.bases[index];
|
||||
const rightBase = right.bases[index];
|
||||
if (leftBase === undefined || rightBase === undefined) {
|
||||
throw new Error(`Forward channel observation ${index.toString()} is missing`);
|
||||
}
|
||||
if (!areForwardChannelObservationBasesEqual(leftBase, rightBase)) return false;
|
||||
}
|
||||
for (let index = 0; index < left.slowmodeChannels.length; index += 1) {
|
||||
const leftEntry = left.slowmodeChannels[index];
|
||||
const rightEntry = right.slowmodeChannels[index];
|
||||
if (leftEntry === undefined || rightEntry === undefined) {
|
||||
throw new Error(`Forward slowmode channel ${index.toString()} is missing`);
|
||||
}
|
||||
if (!areForwardSlowmodeChannelsEqual(leftEntry, rightEntry)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
class ForwardChannelObservationsOwner {
|
||||
private readonly selection: IComputedValue<ForwardChannelObservationSelection>;
|
||||
private readonly locale: string;
|
||||
|
||||
constructor(private readonly request: UseForwardChannelObservationsOptions) {
|
||||
this.locale = request.i18n.locale;
|
||||
this.selection = computed(() => this.buildSelection(), {equals: areForwardChannelObservationSelectionsEqual});
|
||||
}
|
||||
|
||||
matches(request: UseForwardChannelObservationsOptions): boolean {
|
||||
return (
|
||||
this.request.channels === request.channels &&
|
||||
this.request.currentUserId === request.currentUserId &&
|
||||
this.request.i18n === request.i18n &&
|
||||
this.locale === request.i18n.locale
|
||||
);
|
||||
}
|
||||
|
||||
read(): ForwardChannelObservationSelection {
|
||||
return this.selection.get();
|
||||
}
|
||||
|
||||
private buildSelection(): ForwardChannelObservationSelection {
|
||||
const bases: Array<ForwardChannelObservationBase> = [];
|
||||
const slowmodeChannels: Array<ForwardSlowmodeChannel> = [];
|
||||
for (const channel of this.request.channels) {
|
||||
const index = bases.length;
|
||||
const base = this.buildBase(channel);
|
||||
bases.push(base);
|
||||
if (base.slowmodeEnabled) {
|
||||
slowmodeChannels.push(
|
||||
Object.freeze({channelId: channel.id, index, rateLimitPerUser: channel.rateLimitPerUser}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze({bases: Object.freeze(bases), slowmodeChannels: Object.freeze(slowmodeChannels)});
|
||||
}
|
||||
|
||||
private buildBase(channel: Channel): ForwardChannelObservationBase {
|
||||
const guildId = channel.guildId;
|
||||
if (!guildId) {
|
||||
return Object.freeze({
|
||||
canAttachFiles: true,
|
||||
canEmbedLinks: true,
|
||||
canSendMessages: true,
|
||||
categoryName: resolveChannelCategoryName(channel),
|
||||
channel,
|
||||
displayName: resolveChannelDisplayName(channel, this.request.i18n),
|
||||
guildMessagesDisabled: false,
|
||||
guildName: null,
|
||||
memberTimedOut: false,
|
||||
slowmodeEnabled: false,
|
||||
});
|
||||
}
|
||||
const guild = Guilds.getGuild(guildId);
|
||||
let guildName: string | null = null;
|
||||
let guildMessagesDisabled = false;
|
||||
if (guild) {
|
||||
guildName = guild.name;
|
||||
guildMessagesDisabled = (guild.disabledOperations & GuildOperations.SEND_MESSAGE) !== 0;
|
||||
}
|
||||
let slowmodeEnabled = false;
|
||||
if (
|
||||
Number.isSafeInteger(channel.rateLimitPerUser) &&
|
||||
channel.rateLimitPerUser > 0 &&
|
||||
channel.rateLimitPerUser <= CHANNEL_RATE_LIMIT_PER_USER_MAX
|
||||
) {
|
||||
slowmodeEnabled = !Permission.can(Permissions.BYPASS_SLOWMODE, channel);
|
||||
}
|
||||
return Object.freeze({
|
||||
canAttachFiles: Permission.can(Permissions.ATTACH_FILES, channel),
|
||||
canEmbedLinks: Permission.can(Permissions.EMBED_LINKS, channel),
|
||||
canSendMessages: Permission.can(Permissions.SEND_MESSAGES, channel),
|
||||
categoryName: resolveChannelCategoryName(channel),
|
||||
channel,
|
||||
displayName: resolveChannelDisplayName(channel, this.request.i18n),
|
||||
guildMessagesDisabled,
|
||||
guildName,
|
||||
memberTimedOut: isCurrentMemberTimedOut(channel, this.request.currentUserId),
|
||||
slowmodeEnabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardChannelObservations(
|
||||
selection: ForwardChannelObservationSelection,
|
||||
slowmodeRemainingValues: ReadonlyArray<number>,
|
||||
): ReadonlyArray<ForwardChannelObservation> {
|
||||
const remainingByIndex = new Map<number, number>();
|
||||
for (let entryIndex = 0; entryIndex < selection.slowmodeChannels.length; entryIndex += 1) {
|
||||
const entry = selection.slowmodeChannels[entryIndex];
|
||||
const remainingMs = slowmodeRemainingValues[entryIndex];
|
||||
if (entry === undefined || remainingMs === undefined) {
|
||||
throw new Error(`Forward slowmode remaining value ${entryIndex.toString()} is missing`);
|
||||
}
|
||||
remainingByIndex.set(entry.index, remainingMs);
|
||||
}
|
||||
return Object.freeze(
|
||||
selection.bases.map((base, index) => {
|
||||
const remainingMs = remainingByIndex.get(index);
|
||||
return Object.freeze({
|
||||
canAttachFiles: base.canAttachFiles,
|
||||
canEmbedLinks: base.canEmbedLinks,
|
||||
canSendMessages: base.canSendMessages,
|
||||
categoryName: base.categoryName,
|
||||
channel: base.channel,
|
||||
displayName: base.displayName,
|
||||
guildMessagesDisabled: base.guildMessagesDisabled,
|
||||
guildName: base.guildName,
|
||||
memberTimedOut: base.memberTimedOut,
|
||||
slowmodeEnabled: base.slowmodeEnabled,
|
||||
slowmodeRemainingMs: remainingMs === undefined ? 0 : remainingMs,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function useForwardChannelObservations({
|
||||
channels,
|
||||
currentUserId,
|
||||
i18n,
|
||||
}: UseForwardChannelObservationsOptions): ReadonlyArray<ForwardChannelObservation> {
|
||||
const request: UseForwardChannelObservationsOptions = {channels, currentUserId, i18n};
|
||||
const ownerRef = useRef<ForwardChannelObservationsOwner | null>(null);
|
||||
let owner = ownerRef.current;
|
||||
if (owner === null || !owner.matches(request)) {
|
||||
owner = new ForwardChannelObservationsOwner(request);
|
||||
ownerRef.current = owner;
|
||||
}
|
||||
const selection = owner.read();
|
||||
const mockSlowmodeActive = DeveloperOptions.mockSlowmodeActive;
|
||||
const mockSlowmodeRemaining = DeveloperOptions.mockSlowmodeRemaining;
|
||||
const slowmodeRemainingValues = useShallowStableArray(
|
||||
selection.slowmodeChannels.map((entry) =>
|
||||
resolveSlowmodeRemainingMs(entry, mockSlowmodeActive, mockSlowmodeRemaining),
|
||||
),
|
||||
);
|
||||
let hasActiveSlowmodeChannel = false;
|
||||
for (const remainingMs of slowmodeRemainingValues) {
|
||||
if (remainingMs > 0) {
|
||||
hasActiveSlowmodeChannel = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
useNow(hasActiveSlowmodeChannel);
|
||||
return useMemo(
|
||||
() => buildForwardChannelObservations(selection, slowmodeRemainingValues),
|
||||
[selection, slowmodeRemainingValues],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import type {RefObject, UIEvent} from 'react';
|
||||
import {useCallback, useEffect, useRef} from 'react';
|
||||
|
||||
export interface PreservedScrollerPosition {
|
||||
scrollerRef: RefObject<ScrollerHandle | null>;
|
||||
handleScroll: (event: UIEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function usePreservedScrollerPosition(active: boolean): PreservedScrollerPosition {
|
||||
const scrollPositionRef = useRef(0);
|
||||
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const handleScroll = useCallback((event: UIEvent<HTMLDivElement>) => {
|
||||
scrollPositionRef.current = event.currentTarget.scrollTop;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const scroller = scrollerRef.current;
|
||||
if (scroller == null) return;
|
||||
const target = scrollPositionRef.current;
|
||||
if (target === 0) return;
|
||||
scroller.scrollTo({to: target, animate: false});
|
||||
}, [active]);
|
||||
return {scrollerRef, handleScroll};
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {COPY_USERNAME_DESCRIPTOR, UNKNOWN_DESCRIPTOR} from '@app/features/i18n/u
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
import {formatClientBuildInfo, getClientInfo, getClientInfoSync} from '@app/features/platform/utils/ClientInfo';
|
||||
import Presence from '@app/features/presence/state/Presence';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {getUserAccentColor} from '@app/features/theme/utils/AccentColorUtils';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -564,7 +565,7 @@ export const UserAreaPopout = observer(() => {
|
||||
aria-label={i18n._(COPY_USERNAME_DESCRIPTOR)}
|
||||
data-flx="app.floating.user-area-popout.copy-username-button.copy-user-tag"
|
||||
>
|
||||
<CopyIcon size={14} weight="fill" data-flx="app.floating.user-area-popout.copy-icon" />
|
||||
<CopyIcon size={remFromPx(14)} weight="fill" data-flx="app.floating.user-area-popout.copy-icon" />
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
@@ -592,7 +593,7 @@ export const UserAreaPopout = observer(() => {
|
||||
data-flx="app.floating.user-area-popout.custom-status-placeholder.open-custom-status.button"
|
||||
>
|
||||
<SmileyIcon
|
||||
size={14}
|
||||
size={remFromPx(14)}
|
||||
weight="regular"
|
||||
className={styles.customStatusPlaceholderIcon}
|
||||
data-flx="app.floating.user-area-popout.custom-status-placeholder-icon"
|
||||
@@ -672,7 +673,7 @@ export const UserAreaPopout = observer(() => {
|
||||
<ActionButton
|
||||
icon={
|
||||
<UsersThreeIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.users-three-icon"
|
||||
/>
|
||||
@@ -687,7 +688,7 @@ export const UserAreaPopout = observer(() => {
|
||||
<ActionButton
|
||||
icon={
|
||||
<IdentificationBadgeIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.identification-badge-icon"
|
||||
/>
|
||||
@@ -700,7 +701,13 @@ export const UserAreaPopout = observer(() => {
|
||||
<>
|
||||
<div className={styles.actionDivider} data-flx="app.floating.user-area-popout.action-divider--3" />
|
||||
<ActionButton
|
||||
icon={<InfoIcon size={16} weight="bold" data-flx="app.floating.user-area-popout.info-icon" />}
|
||||
icon={
|
||||
<InfoIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.info-icon"
|
||||
/>
|
||||
}
|
||||
label={<Trans>Copy build info</Trans>}
|
||||
onClick={handleCopyBuildInfo}
|
||||
data-flx="app.floating.user-area-popout.action-button.copy-build-info"
|
||||
@@ -709,7 +716,7 @@ export const UserAreaPopout = observer(() => {
|
||||
<ActionButton
|
||||
icon={
|
||||
<ChartLineIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.chart-line-icon"
|
||||
/>
|
||||
@@ -726,7 +733,13 @@ export const UserAreaPopout = observer(() => {
|
||||
<Button
|
||||
variant="primary"
|
||||
fitContainer
|
||||
leftIcon={<PencilIcon size={16} weight="bold" data-flx="app.floating.user-area-popout.pencil-icon" />}
|
||||
leftIcon={
|
||||
<PencilIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.floating.user-area-popout.pencil-icon"
|
||||
/>
|
||||
}
|
||||
onClick={openEditProfile}
|
||||
className={styles.editProfileButton}
|
||||
data-flx="app.floating.user-area-popout.edit-profile-button.open-edit-profile"
|
||||
|
||||
@@ -35,7 +35,7 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
const [splashAlignment, setSplashAlignment] = useState<GuildSplashCardAlignmentValue>(
|
||||
GuildSplashCardAlignment.CENTER,
|
||||
);
|
||||
const {isNative, isMacOS, platform} = useNativePlatform();
|
||||
const {isNative, platform} = useNativePlatform();
|
||||
const useSystemTitleBar = useNativeTitleBar();
|
||||
const splashUrlRef = useRef<string | null>(null);
|
||||
const registerFormDraftsRef = useRef<Map<string, AuthRegisterFormDraft>>(new Map());
|
||||
@@ -187,7 +187,7 @@ const AuthLayoutContent = observer(function AuthLayoutContent({children}: {child
|
||||
key="auth-layout-scroller"
|
||||
data-flx="app.auth-layout.auth-layout-content.container"
|
||||
>
|
||||
{isNative && !isMacOS && !useSystemTitleBar && (
|
||||
{isNative && !useSystemTitleBar && (
|
||||
<NativeTitlebar platform={platform} data-flx="app.auth-layout.auth-layout-content.native-titlebar" />
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
|
||||
.channelListScroller {
|
||||
background-color: var(--background-secondary);
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.channelListScrollerWrapper {
|
||||
--floating-unread-indicator-bottom-inset: calc(
|
||||
var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +
|
||||
0.5rem
|
||||
);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navigationContainer {
|
||||
|
||||
@@ -34,12 +34,14 @@ import Permission from '@app/features/permissions/state/Permission';
|
||||
import {useLocation} from '@app/features/platform/components/router/RouterReact';
|
||||
import {failureCode} from '@app/features/platform/utils/ResponseInspection';
|
||||
import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {ChannelListContextMenu} from '@app/features/ui/action_menu/ChannelListContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import * as DimensionCommands from '@app/features/ui/commands/DimensionCommands';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import {useDragAutoScroll} from '@app/features/ui/hooks/useDragAutoScroll';
|
||||
import Dimension from '@app/features/ui/state/Dimension';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
@@ -168,6 +170,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
[channels.length, ReadStates.version, userGuildSettings, hideMutedChannels, showFadedUnreadOnMutedChannels],
|
||||
);
|
||||
const getChannelScrollContainer = useCallback(() => scrollerRef.current?.getScrollerNode() ?? null, [scrollerRef]);
|
||||
useDragAutoScroll({active: isDraggingAnything, getScrollElement: getChannelScrollContainer});
|
||||
const handleChannelDrop = useCallback(
|
||||
(item: DragItem, result: DropResult) => {
|
||||
if (!result) return;
|
||||
@@ -331,7 +334,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
<ChannelItemContent
|
||||
icon={
|
||||
<UsersIcon
|
||||
size={20}
|
||||
size={remFromPx(20)}
|
||||
className={clsx(
|
||||
channelItemStyles.channelItemIcon,
|
||||
isMembersSelected
|
||||
@@ -572,6 +575,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
</Scroller>
|
||||
<ScrollIndicatorOverlay
|
||||
getScrollContainer={getChannelScrollContainer}
|
||||
scrollContainerIdentity={guild.id}
|
||||
dependencies={channelIndicatorDependencies}
|
||||
label={i18n._(NEW_MESSAGES_DESCRIPTOR)}
|
||||
data-flx="app.channel-list-content.scroll-indicator-overlay"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import styles from '@app/features/app/components/layout/DiscoveryGuildHeader.module.css';
|
||||
import guildHeaderStyles from '@app/features/app/components/layout/GuildHeader.module.css';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CompassIcon} from '@phosphor-icons/react';
|
||||
@@ -17,7 +18,7 @@ export function DiscoveryGuildHeader() {
|
||||
return (
|
||||
<div
|
||||
className={clsx(guildHeaderStyles.headerContainer, guildHeaderStyles.headerContainerNoBanner)}
|
||||
style={{height: 56}}
|
||||
style={{height: remFromPx(56)}}
|
||||
data-flx="app.discovery-guild-header.div"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,7 @@ import channelItemSurfaceStyles from '@app/features/app/components/layout/Channe
|
||||
import styles from '@app/features/app/components/layout/ChannelListContent.module.css';
|
||||
import {ChannelListSkeleton} from '@app/features/app/components/layout/ChannelListSkeleton';
|
||||
import {computeVerticalDropPosition} from '@app/features/app/components/layout/dnd/DndDropPosition';
|
||||
import {useDragTargetRect} from '@app/features/app/components/layout/dnd/useDragTargetRect';
|
||||
import favoritesChannelListStyles from '@app/features/app/components/layout/FavoritesChannelListContent.module.css';
|
||||
import {GenericChannelItem} from '@app/features/app/components/layout/GenericChannelItem';
|
||||
import {getChannelUnreadState} from '@app/features/app/components/layout/utils/ChannelUnreadState';
|
||||
@@ -39,9 +40,10 @@ import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuComma
|
||||
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 {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {useDragAutoScroll} from '@app/features/ui/hooks/useDragAutoScroll';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
|
||||
@@ -56,7 +58,7 @@ 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 {useDrag, useDragLayer, useDrop} from 'react-dnd';
|
||||
import {getEmptyImage} from 'react-dnd-html5-backend';
|
||||
|
||||
const EMPTY_FAVORITES_DESCRIPTOR = msg({
|
||||
@@ -97,7 +99,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
({favoriteChannel, channel, guild}: {favoriteChannel: FavoriteChannel; channel: Channel; guild: Guild | null}) => {
|
||||
const {i18n} = useLingui();
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropTargetRectRef = useRef<DOMRect | null>(null);
|
||||
const getDropTargetRect = useDragTargetRect(elementRef);
|
||||
const [dropIndicator, setDropIndicator] = useState<FavoriteDropIndicator | null>(null);
|
||||
const setFavoriteDropIndicator = useCallback((indicator: FavoriteDropIndicator | null) => {
|
||||
setDropIndicator((current) => {
|
||||
@@ -112,7 +114,6 @@ const FavoriteChannelResolvedItem = observer(
|
||||
});
|
||||
}, []);
|
||||
const resetFavoriteDropIndicator = useCallback(() => {
|
||||
dropTargetRectRef.current = null;
|
||||
setFavoriteDropIndicator(null);
|
||||
}, [setFavoriteDropIndicator]);
|
||||
const location = useLocation();
|
||||
@@ -141,12 +142,10 @@ const FavoriteChannelResolvedItem = observer(
|
||||
const [{isOver}, dropRef] = useDrop<ChannelDragItem, unknown, {isOver: boolean}>({
|
||||
accept: DND_TYPES.FAVORITES_CHANNEL,
|
||||
hover: (_item, monitor) => {
|
||||
const node = elementRef.current;
|
||||
if (!node) return;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
const hoverBoundingRect = dropTargetRectRef.current ?? node.getBoundingClientRect();
|
||||
dropTargetRectRef.current = hoverBoundingRect;
|
||||
const hoverBoundingRect = getDropTargetRect();
|
||||
if (!hoverBoundingRect) return;
|
||||
const dropPos = computeVerticalDropPosition(clientOffset, hoverBoundingRect);
|
||||
setFavoriteDropIndicator({
|
||||
position: dropPos === 'before' ? 'top' : 'bottom',
|
||||
@@ -407,7 +406,7 @@ const FavoriteCategoryItem = observer(
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const dropTargetRectRef = useRef<DOMRect | null>(null);
|
||||
const getDropTargetRect = useDragTargetRect(elementRef);
|
||||
const [dropIndicator, setDropIndicator] = useState<FavoriteDropIndicator | null>(null);
|
||||
const setCategoryDropIndicator = useCallback((indicator: FavoriteDropIndicator | null) => {
|
||||
setDropIndicator((current) => {
|
||||
@@ -422,7 +421,6 @@ const FavoriteCategoryItem = observer(
|
||||
});
|
||||
}, []);
|
||||
const resetCategoryDropIndicator = useCallback(() => {
|
||||
dropTargetRectRef.current = null;
|
||||
setCategoryDropIndicator(null);
|
||||
}, [setCategoryDropIndicator]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
@@ -454,12 +452,10 @@ const FavoriteCategoryItem = observer(
|
||||
resetCategoryDropIndicator();
|
||||
return;
|
||||
}
|
||||
const node = elementRef.current;
|
||||
if (!node) return;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
const hoverBoundingRect = dropTargetRectRef.current ?? node.getBoundingClientRect();
|
||||
dropTargetRectRef.current = hoverBoundingRect;
|
||||
const hoverBoundingRect = getDropTargetRect();
|
||||
if (!hoverBoundingRect) return;
|
||||
const dropPos = computeVerticalDropPosition(clientOffset, hoverBoundingRect);
|
||||
setCategoryDropIndicator({
|
||||
position: dropPos === 'before' ? 'top' : 'bottom',
|
||||
@@ -641,6 +637,19 @@ const UncategorizedGroup = ({children}: {children: React.ReactNode}) => {
|
||||
};
|
||||
export const FavoritesChannelListContent = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const isDraggingFavorite = useDragLayer((monitor) => {
|
||||
const itemType = monitor.getItemType();
|
||||
return (
|
||||
monitor.isDragging() && (itemType === DND_TYPES.FAVORITES_CHANNEL || itemType === DND_TYPES.FAVORITES_CATEGORY)
|
||||
);
|
||||
});
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const getScrollElement = useCallback(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (scroller == null) return null;
|
||||
return scroller.getScrollerNode();
|
||||
}, []);
|
||||
useDragAutoScroll({active: isDraggingFavorite, getScrollElement});
|
||||
const favorites = Favorites.sortedChannels;
|
||||
const categories = Favorites.sortedCategories;
|
||||
const hideMutedChannels = Favorites.hideMutedChannels;
|
||||
@@ -684,6 +693,7 @@ export const FavoritesChannelListContent = observer(() => {
|
||||
if (!hasVisibleChannels && categories.length === 0) {
|
||||
return (
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
className={styles.channelListScroller}
|
||||
key="favorites-channel-list-empty-scroller"
|
||||
data-flx="app.favorites-channel-list-content.channel-list-scroller"
|
||||
@@ -702,6 +712,7 @@ export const FavoritesChannelListContent = observer(() => {
|
||||
}
|
||||
return (
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
className={styles.channelListScroller}
|
||||
key="favorites-channel-list-scroller"
|
||||
data-flx="app.favorites-channel-list-content.channel-list-scroller--2"
|
||||
|
||||
@@ -6,6 +6,7 @@ import styles from '@app/features/app/components/layout/FavoritesGuildHeader.mod
|
||||
import guildHeaderStyles from '@app/features/app/components/layout/GuildHeader.module.css';
|
||||
import {GuildHeaderShell} from '@app/features/app/components/layout/GuildHeaderShell';
|
||||
import {FAVORITES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {FavoritesGuildContextMenu} from '@app/features/ui/action_menu/FavoritesGuildContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
@@ -43,7 +44,7 @@ export const FavoritesGuildHeader = observer(() => {
|
||||
guildHeaderStyles.headerContainerNoBanner,
|
||||
isOpen && guildHeaderStyles.headerContainerActive,
|
||||
)}
|
||||
style={{height: 56}}
|
||||
style={{height: remFromPx(56)}}
|
||||
data-flx="app.favorites-guild-header.div"
|
||||
>
|
||||
<GuildHeaderShell
|
||||
|
||||
@@ -10,10 +10,12 @@ import {GuildHeaderBottomSheet} from '@app/features/guild/components/bottomsheet
|
||||
import {GuildBadge} from '@app/features/guild/components/GuildBadge';
|
||||
import {GuildHeaderPopout} from '@app/features/guild/components/popouts/GuildHeaderPopout';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {GuildContextMenu} from '@app/features/ui/action_menu/GuildContextMenu';
|
||||
import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import Popout from '@app/features/ui/state/Popout';
|
||||
import {SIDEBAR_WIDTH_DEFAULT} from '@app/features/ui/state/SidebarWidth';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -30,7 +32,6 @@ const OPEN_COMMUNITY_MENU_FOR_DESCRIPTOR = msg({
|
||||
comment: 'Short label in the app layout guild header. Preserve {guildName}; it is inserted by code.',
|
||||
});
|
||||
const HEADER_MIN_HEIGHT = 56;
|
||||
const DEFAULT_SIDEBAR_WIDTH = 270;
|
||||
const DEFAULT_BANNER_ASPECT_RATIO = 16 / 9;
|
||||
const MAX_VIEWPORT_HEIGHT_FRACTION = 0.3;
|
||||
export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
@@ -56,7 +57,7 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
const headerContainerRef = useRef<HTMLElement | null>(null);
|
||||
const mergedHeaderContainerRef = useMergeRefs([headerContainerRef, bannerHoverRef]);
|
||||
const [containerWidth, setContainerWidth] = useState<number>(() =>
|
||||
isMobile && typeof window !== 'undefined' ? window.innerWidth : DEFAULT_SIDEBAR_WIDTH,
|
||||
isMobile && typeof window !== 'undefined' ? window.innerWidth : SIDEBAR_WIDTH_DEFAULT,
|
||||
);
|
||||
const [viewportHeight, setViewportHeight] = useState<number>(() =>
|
||||
typeof window !== 'undefined' ? window.innerHeight : 0,
|
||||
@@ -122,7 +123,7 @@ export const GuildHeader = observer(({guild}: {guild: Guild}) => {
|
||||
!showIntegratedBanner && styles.headerContainerNoBanner,
|
||||
!showIntegratedBanner && isOpen && styles.headerContainerActive,
|
||||
)}
|
||||
style={{height: showIntegratedBanner ? bannerMaxHeight : HEADER_MIN_HEIGHT}}
|
||||
style={{height: showIntegratedBanner ? bannerMaxHeight : remFromPx(HEADER_MIN_HEIGHT)}}
|
||||
data-flx="app.guild-header.header-container.context-menu"
|
||||
>
|
||||
{showIntegratedBanner && (
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
--layout-mobile-bottom-nav-reserved-height: var(--mobile-bottom-nav-height);
|
||||
}
|
||||
|
||||
:global(html.platform-native):not(.platform-macos) .guildNavbarContainer {
|
||||
:global(html.platform-native) .guildNavbarContainer {
|
||||
border-top-left-radius: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
background-clip: padding-box;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(html.platform-native):not(.platform-macos) .skeletonContainer {
|
||||
:global(html.platform-native) .skeletonContainer {
|
||||
border-top-left-radius: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
background-clip: padding-box;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
--layout-user-area-overlay-height,
|
||||
var(--layout-user-area-reserved-height, 0px)
|
||||
);
|
||||
--floating-unread-indicator-bottom-inset: calc(
|
||||
var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +
|
||||
0.5rem
|
||||
);
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
min-height: 0;
|
||||
@@ -62,9 +66,14 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
background-color: var(--background-secondary);
|
||||
overflow-anchor: none;
|
||||
--guild-badge-surface: var(--background-secondary);
|
||||
padding-top: var(--spacing-1);
|
||||
padding-bottom: var(--spacing-2);
|
||||
scroll-padding-bottom: calc(
|
||||
var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +
|
||||
var(--spacing-2)
|
||||
);
|
||||
}
|
||||
|
||||
.guildListContent {
|
||||
@@ -323,10 +332,11 @@
|
||||
}
|
||||
|
||||
.dmListSection {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--guild-list-item-gap);
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -377,7 +387,28 @@
|
||||
composes: guildListItem;
|
||||
}
|
||||
|
||||
.dmListItemWrapper {
|
||||
.dmListRow {
|
||||
position: relative;
|
||||
height: calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap));
|
||||
width: 100%;
|
||||
flex-shrink: 0;
|
||||
overflow: visible;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.dmListRow[data-pending-removal='true'] {
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dmListRow[data-last-row='true'] {
|
||||
height: var(--guild-list-item-box-size);
|
||||
}
|
||||
|
||||
.dmListItemBox {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
height: var(--guild-list-item-box-size);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -582,31 +613,24 @@
|
||||
background: #000;
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.platform-macos)) .guildsLayoutContainer {
|
||||
:global(html.platform-native) .guildsLayoutContainer {
|
||||
padding-top: var(--native-titlebar-height);
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.platform-macos)) .guildsLayoutFullscreen {
|
||||
:global(html.platform-native) .guildsLayoutFullscreen {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos) .guildListScrollContainer {
|
||||
padding-top: var(--native-titlebar-height);
|
||||
}
|
||||
|
||||
:global(html:not(.platform-native)) .outlineFrame,
|
||||
:global(html.platform-native.platform-macos) .outlineFrame {
|
||||
:global(html:not(.platform-native)) .outlineFrame {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.platform-macos)) .outlineFrame {
|
||||
:global(html.platform-native) .outlineFrame {
|
||||
border-top: 0.0625rem solid var(--user-area-divider-color);
|
||||
--outline-radius: clamp(0.5rem, 1.2vw, 0.875rem);
|
||||
}
|
||||
|
||||
:global(html:not(.platform-native)) .outlineFrameFullscreen,
|
||||
:global(html.platform-native.platform-macos) .outlineFrameFullscreen,
|
||||
:global(html.platform-native:not(.platform-macos)) .outlineFrameFullscreen {
|
||||
:global(html.platform-native) .outlineFrame.outlineFrameFullscreen {
|
||||
border: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Routes} from '@app/app/Routes';
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {useActiveNagbars, useNagbarConditions} from '@app/features/app/components/layout/app_layout/AppLayoutHooks';
|
||||
import {NagbarContainer} from '@app/features/app/components/layout/app_layout/NagbarContainer';
|
||||
import {TopNagbarContext} from '@app/features/app/components/layout/app_layout/TopNagbarContext';
|
||||
import styles from '@app/features/app/components/layout/GuildsLayout.module.css';
|
||||
import {
|
||||
DM_LIST_REMOVAL_DELAY_MS,
|
||||
DMListAnimatedRow,
|
||||
type DMListRow,
|
||||
useFrameBatchedDMListRows,
|
||||
} from '@app/features/app/components/layout/guilds_layout/GuildListDMAnimation';
|
||||
import {OutlineFrame} from '@app/features/app/components/layout/OutlineFrame';
|
||||
import {ScrollIndicatorOverlay} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import {AddGuildButton} from '@app/features/app/components/layout/sidebar_nav/AddGuildButton';
|
||||
@@ -13,7 +20,6 @@ import {DownloadButton} from '@app/features/app/components/layout/sidebar_nav/Do
|
||||
import {FavoritesButton} from '@app/features/app/components/layout/sidebar_nav/FavoritesButton';
|
||||
import {FluxerButton} from '@app/features/app/components/layout/sidebar_nav/FluxerButton';
|
||||
import {GuildFolderItem} from '@app/features/app/components/layout/sidebar_nav/GuildFolderItem';
|
||||
import {DMListItem} from '@app/features/app/components/layout/sidebar_nav/GuildListDMItem';
|
||||
import {GuildListItem} from '@app/features/app/components/layout/sidebar_nav/GuildListItem';
|
||||
import {HelpButton} from '@app/features/app/components/layout/sidebar_nav/HelpButton';
|
||||
import {DND_TYPES, type GuildDragItem, type GuildDropResult} from '@app/features/app/components/layout/types/DndTypes';
|
||||
@@ -38,11 +44,15 @@ import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import ReadStates from '@app/features/read_state/state/ReadStates';
|
||||
import * as DimensionCommands from '@app/features/ui/commands/DimensionCommands';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {useHoverDeferredOrderedItems} from '@app/features/ui/hooks/UseHoverDeferredOrderedItems';
|
||||
import {useDragAutoScroll} from '@app/features/ui/hooks/useDragAutoScroll';
|
||||
import {useListScrollAnchor} from '@app/features/ui/hooks/useScrollAnchor';
|
||||
import Dimension from '@app/features/ui/state/Dimension';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import Nagbar from '@app/features/ui/state/Nagbar';
|
||||
import SidebarPreferences from '@app/features/ui/state/SidebarPreferences';
|
||||
import SidebarWidth from '@app/features/ui/state/SidebarWidth';
|
||||
import WhatsNew from '@app/features/ui/state/WhatsNew';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import * as UserSettingsCommands from '@app/features/user/commands/UserSettingsCommands';
|
||||
@@ -60,7 +70,7 @@ import {ExclamationMarkIcon} 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 {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
import {useDrop} from 'react-dnd';
|
||||
|
||||
const NEW_DESCRIPTOR = msg({
|
||||
@@ -71,12 +81,26 @@ const NEW_DESCRIPTOR = msg({
|
||||
const isSelectedPath = (pathname: string, path: string) => {
|
||||
return pathname.startsWith(path);
|
||||
};
|
||||
const DM_LIST_REMOVAL_DELAY_MS = 750;
|
||||
const UNAVAILABLE_INDICATOR_DEBOUNCE_MS = 1500;
|
||||
const GUILD_LIST_FOCUSABLE_SELECTOR = '[data-guild-list-focus-item="true"]';
|
||||
const WHEEL_LINE_HEIGHT_PX = 16;
|
||||
const DOM_DELTA_LINE = 1;
|
||||
const DOM_DELTA_PAGE = 2;
|
||||
const getChannelId = (channel: Channel): string => channel.id;
|
||||
const EMPTY_CHANNELS: ReadonlyArray<Channel> = Object.freeze([]);
|
||||
|
||||
interface GuildsLayoutSidebarStyle extends React.CSSProperties {
|
||||
'--layout-sidebar-width': string;
|
||||
}
|
||||
|
||||
function resolveGuildsLayoutSidebarStyle(
|
||||
mobileEnabled: boolean,
|
||||
sidebarWidth: string | null,
|
||||
): React.CSSProperties | undefined {
|
||||
if (mobileEnabled || sidebarWidth == null || sidebarWidth === '') return undefined;
|
||||
const style: GuildsLayoutSidebarStyle = {'--layout-sidebar-width': sidebarWidth};
|
||||
return style;
|
||||
}
|
||||
|
||||
function getResizeObserverEntryBlockSize(entry: ResizeObserverEntry): number {
|
||||
const borderBoxSize = entry.borderBoxSize;
|
||||
@@ -84,8 +108,7 @@ function getResizeObserverEntryBlockSize(entry: ResizeObserverEntry): number {
|
||||
return firstBorderBoxSize?.blockSize ?? entry.contentRect.height;
|
||||
}
|
||||
|
||||
const getUnreadDMChannels = (): Array<Channel> => {
|
||||
const dmChannels = Channels.dmChannels;
|
||||
const getUnreadDMChannels = (dmChannels: ReadonlyArray<Channel>): Array<Channel> => {
|
||||
const out: Array<Channel> = [];
|
||||
for (let i = 0; i < dmChannels.length; i++) {
|
||||
if (ReadStates.hasUnread(dmChannels[i].id)) out.push(dmChannels[i]);
|
||||
@@ -93,6 +116,91 @@ const getUnreadDMChannels = (): Array<Channel> => {
|
||||
return out;
|
||||
};
|
||||
|
||||
interface DMChannelVisibilityControllerState {
|
||||
readonly removalDelayMs: number;
|
||||
readonly removalTimers: React.MutableRefObject<Map<string, number>>;
|
||||
readonly orderedUnreadChannels: ReadonlyArray<Channel>;
|
||||
readonly setVisibleChannels: React.Dispatch<React.SetStateAction<ReadonlyArray<Channel>>>;
|
||||
readonly unreadChannels: ReadonlyArray<Channel>;
|
||||
readonly unreadIdsRef: React.MutableRefObject<ReadonlySet<string>>;
|
||||
}
|
||||
|
||||
interface ProjectVisibleDMChannelsRequest {
|
||||
readonly currentChannels: ReadonlyArray<Channel>;
|
||||
readonly unreadIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
class DMChannelVisibilityController {
|
||||
private readonly removalDelayMs: number;
|
||||
private readonly removalTimers: React.MutableRefObject<Map<string, number>>;
|
||||
private readonly orderedUnreadChannels: ReadonlyArray<Channel>;
|
||||
private readonly setVisibleChannels: React.Dispatch<React.SetStateAction<ReadonlyArray<Channel>>>;
|
||||
private readonly unreadChannels: ReadonlyArray<Channel>;
|
||||
private readonly unreadIdsRef: React.MutableRefObject<ReadonlySet<string>>;
|
||||
|
||||
public constructor({
|
||||
removalDelayMs,
|
||||
removalTimers,
|
||||
orderedUnreadChannels,
|
||||
setVisibleChannels,
|
||||
unreadChannels,
|
||||
unreadIdsRef,
|
||||
}: DMChannelVisibilityControllerState) {
|
||||
this.removalDelayMs = removalDelayMs;
|
||||
this.removalTimers = removalTimers;
|
||||
this.orderedUnreadChannels = orderedUnreadChannels;
|
||||
this.setVisibleChannels = setVisibleChannels;
|
||||
this.unreadChannels = unreadChannels;
|
||||
this.unreadIdsRef = unreadIdsRef;
|
||||
}
|
||||
|
||||
public synchronize(): void {
|
||||
this.setVisibleChannels((currentChannels) =>
|
||||
this.projectVisibleChannels({currentChannels, unreadIds: this.unreadIdsRef.current}),
|
||||
);
|
||||
for (const channel of this.unreadChannels) this.cancelRemoval(channel.id);
|
||||
}
|
||||
|
||||
private projectVisibleChannels({currentChannels, unreadIds}: ProjectVisibleDMChannelsRequest): Array<Channel> {
|
||||
const leavingChannels = currentChannels.filter((channel) => !unreadIds.has(channel.id));
|
||||
for (const channel of leavingChannels) this.scheduleRemoval(channel.id);
|
||||
return [...this.orderedUnreadChannels, ...leavingChannels];
|
||||
}
|
||||
|
||||
private scheduleRemoval(channelId: string): void {
|
||||
if (this.removalTimers.current.has(channelId)) return;
|
||||
const timer = window.setTimeout(() => this.removeChannel(channelId), this.removalDelayMs);
|
||||
this.removalTimers.current.set(channelId, timer);
|
||||
}
|
||||
|
||||
private removeChannel(channelId: string): void {
|
||||
this.cancelRemoval(channelId);
|
||||
if (this.unreadIdsRef.current.has(channelId)) return;
|
||||
this.setVisibleChannels((channels) => channels.filter((channel) => channel.id !== channelId));
|
||||
}
|
||||
|
||||
public completeRemoval(channelId: string): void {
|
||||
this.removeChannel(channelId);
|
||||
}
|
||||
|
||||
private cancelRemoval(channelId: string): void {
|
||||
const timer = this.removalTimers.current.get(channelId);
|
||||
if (timer == null) return;
|
||||
window.clearTimeout(timer);
|
||||
this.removalTimers.current.delete(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
interface ClearHoveredChannelRequest {
|
||||
readonly currentChannelId: string | null;
|
||||
readonly endedChannelId: string;
|
||||
}
|
||||
|
||||
function clearHoveredChannelId({currentChannelId, endedChannelId}: ClearHoveredChannelRequest): string | null {
|
||||
if (currentChannelId === endedChannelId) return null;
|
||||
return currentChannelId;
|
||||
}
|
||||
|
||||
function getOrganizedItemKey(item: OrganizedItem): string {
|
||||
if (item.type === 'folder') {
|
||||
return `folder-${item.folder.id}`;
|
||||
@@ -100,6 +208,13 @@ function getOrganizedItemKey(item: OrganizedItem): string {
|
||||
return item.guild.id;
|
||||
}
|
||||
|
||||
function getGuildListAnchorKeyForItem(item: OrganizedItem): string {
|
||||
if (item.type === 'folder') {
|
||||
return `folder-${item.folder.id}`;
|
||||
}
|
||||
return `guild-${item.guild.id}`;
|
||||
}
|
||||
|
||||
function buildGuildNavigationIndexMap(organizedItems: ReadonlyArray<OrganizedItem>): Map<string, number> {
|
||||
const guildNavigationIndexes = new Map<string, number>();
|
||||
let guildIndex = 0;
|
||||
@@ -138,10 +253,12 @@ function getWheelScrollDeltaY(event: WheelEvent | React.WheelEvent<HTMLDivElemen
|
||||
}
|
||||
|
||||
function isWheelEventOverElement(event: WheelEvent, element: HTMLElement): boolean {
|
||||
if (event.target instanceof Node && element.contains(event.target)) {
|
||||
const ownerDocument = element.ownerDocument;
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
if (ownerWindow != null && event.target instanceof ownerWindow.Node && element.contains(event.target)) {
|
||||
return true;
|
||||
}
|
||||
const pointElement = document.elementFromPoint(event.clientX, event.clientY);
|
||||
const pointElement = ownerDocument.elementFromPoint(event.clientX, event.clientY);
|
||||
return pointElement !== null && element.contains(pointElement);
|
||||
}
|
||||
|
||||
@@ -318,11 +435,26 @@ const GuildList = observer(() => {
|
||||
const guildNavigationIndexes = useMemo(() => buildGuildNavigationIndexMap(organizedItems), [organizedItems]);
|
||||
const unavailableGuilds = GuildAvailability.unavailableGuilds;
|
||||
const unavailableCount = unavailableGuilds.size;
|
||||
const unreadDMChannels = getUnreadDMChannels();
|
||||
let unreadDMChannelIds = '';
|
||||
for (let i = 0; i < unreadDMChannels.length; i++) {
|
||||
unreadDMChannelIds += i === 0 ? unreadDMChannels[i].id : `,${unreadDMChannels[i].id}`;
|
||||
}
|
||||
const readVersion = ReadStates.version;
|
||||
const dmChannels = Channels.dmChannels;
|
||||
const unreadDMChannels = useMemo(() => getUnreadDMChannels(dmChannels), [dmChannels, readVersion]);
|
||||
const unreadDMChannelIdSet = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (let index = 0; index < unreadDMChannels.length; index++) {
|
||||
ids.add(unreadDMChannels[index].id);
|
||||
}
|
||||
return ids;
|
||||
}, [unreadDMChannels]);
|
||||
const unreadDMChannelIdSetRef = useRef<ReadonlySet<string>>(unreadDMChannelIdSet);
|
||||
unreadDMChannelIdSetRef.current = unreadDMChannelIdSet;
|
||||
const unreadDMChannelIds = useMemo(() => unreadDMChannels.map(getChannelId).join(','), [unreadDMChannels]);
|
||||
const [hoveredInlineDMChannelId, setHoveredInlineDMChannelId] = useState<string | null>(null);
|
||||
const orderedUnreadDMChannels = useHoverDeferredOrderedItems({
|
||||
items: unreadDMChannels,
|
||||
getKey: getChannelId,
|
||||
isHoveringDynamicItem: hoveredInlineDMChannelId != null,
|
||||
releaseToken: readVersion,
|
||||
});
|
||||
const scrollRef = useRef<ScrollerHandle>(null);
|
||||
const pendingScrollTopRef = useRef<number | null>(null);
|
||||
const scrollPersistRafRef = useRef<number | null>(null);
|
||||
@@ -332,12 +464,12 @@ const GuildList = observer(() => {
|
||||
const unavailableIndicatorHideTimer = useRef<NodeJS.Timeout | null>(null);
|
||||
const hasUnavailableGuilds = visibleUnavailableCount > 0;
|
||||
const guildReadVersion = GuildReadState.version;
|
||||
const readVersion = ReadStates.version;
|
||||
const guildIndicatorDependencies = useMemo(
|
||||
() => [guilds.length, guildReadVersion, readVersion, unreadDMChannelIds],
|
||||
[guilds.length, guildReadVersion, readVersion, unreadDMChannelIds],
|
||||
);
|
||||
const getGuildScrollContainer = useCallback(() => scrollRef.current?.getScrollerNode() ?? null, []);
|
||||
useDragAutoScroll({active: isDragging, getScrollElement: getGuildScrollContainer});
|
||||
const scrollGuildListByWheel = useCallback((event: WheelEvent | React.WheelEvent<HTMLDivElement>) => {
|
||||
const scrollNode = scrollRef.current?.getScrollerNode();
|
||||
if (!scrollNode) return false;
|
||||
@@ -351,7 +483,7 @@ const GuildList = observer(() => {
|
||||
scrollNode.scrollTop = nextScrollTop;
|
||||
return true;
|
||||
}, []);
|
||||
const [visibleDMChannels, setVisibleDMChannels] = useState(unreadDMChannels);
|
||||
const [visibleDMChannels, setVisibleDMChannels] = useState<ReadonlyArray<Channel>>(orderedUnreadDMChannels);
|
||||
const directMessagesDisabled = RuntimeConfig.directMessagesDisabled;
|
||||
let pinnedCallChannel: Channel | null = null;
|
||||
if (!directMessagesDisabled && MediaEngine.connected) {
|
||||
@@ -368,19 +500,76 @@ const GuildList = observer(() => {
|
||||
}
|
||||
}
|
||||
const inlineDmsCollapsed = SidebarPreferences.inlineDmsCollapsed;
|
||||
const baseDMChannels = inlineDmsCollapsed || directMessagesDisabled ? [] : visibleDMChannels;
|
||||
const filteredDMChannels = pinnedCallChannel
|
||||
? baseDMChannels.filter((channel) => channel.id !== pinnedCallChannel.id)
|
||||
: baseDMChannels;
|
||||
const hasVisibleDMChannels = filteredDMChannels.length > 0 || Boolean(pinnedCallChannel);
|
||||
let baseDMChannels: ReadonlyArray<Channel>;
|
||||
if (inlineDmsCollapsed || directMessagesDisabled) {
|
||||
baseDMChannels = EMPTY_CHANNELS;
|
||||
} else {
|
||||
baseDMChannels = visibleDMChannels;
|
||||
}
|
||||
const filteredDMChannels = useMemo(() => {
|
||||
if (pinnedCallChannel != null) {
|
||||
return baseDMChannels.filter((channel) => channel.id !== pinnedCallChannel.id);
|
||||
}
|
||||
return baseDMChannels;
|
||||
}, [baseDMChannels, pinnedCallChannel]);
|
||||
const targetDMListRows = useMemo<Array<DMListRow>>(() => {
|
||||
const rows: Array<DMListRow> = [];
|
||||
if (pinnedCallChannel != null) {
|
||||
rows.push({type: 'channel', channel: pinnedCallChannel, voiceCallActive: true, pendingRemoval: false});
|
||||
}
|
||||
for (let index = 0; index < filteredDMChannels.length; index++) {
|
||||
const channel = filteredDMChannels[index];
|
||||
rows.push({
|
||||
type: 'channel',
|
||||
channel,
|
||||
voiceCallActive: false,
|
||||
pendingRemoval: !unreadDMChannelIdSet.has(channel.id),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [filteredDMChannels, pinnedCallChannel, unreadDMChannelIdSet]);
|
||||
const visibleDMListRows = useFrameBatchedDMListRows(targetDMListRows);
|
||||
const {
|
||||
getAnchorRef: getGuildListAnchorRef,
|
||||
handleResize: handleGuildListAnchorResize,
|
||||
handleScroll: handleGuildListAnchorScroll,
|
||||
stabilize: stabilizeGuildListAnchor,
|
||||
} = useListScrollAnchor({scrollerRef: scrollRef});
|
||||
const hasVisibleDMChannels = targetDMListRows.length > 0 || visibleDMListRows.length > 0;
|
||||
const shouldShowTopDivider = (guilds.length > 0 || hasUnavailableGuilds) && !hasVisibleDMChannels;
|
||||
const shouldShowEmptyStateDivider = !hasVisibleDMChannels && !hasUnavailableGuilds && guilds.length === 0;
|
||||
const shouldRenderGuildListItems = hasUnavailableGuilds || organizedItems.length > 0;
|
||||
useLayoutEffect(() => {
|
||||
stabilizeGuildListAnchor();
|
||||
}, [
|
||||
hasUnavailableGuilds,
|
||||
hasVisibleDMChannels,
|
||||
organizedItems,
|
||||
shouldRenderGuildListItems,
|
||||
stabilizeGuildListAnchor,
|
||||
visibleDMListRows,
|
||||
visibleUnavailableCount,
|
||||
]);
|
||||
const selectedGuildIndex = useMemo(
|
||||
() => getSelectedGuildNavigationIndex(location.pathname, guildNavigationIndexes),
|
||||
[location.pathname, guildNavigationIndexes],
|
||||
);
|
||||
const removalTimers = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
const removalTimers = useRef<Map<string, number>>(new Map());
|
||||
const dmVisibilityControllerRef = useRef<DMChannelVisibilityController | null>(null);
|
||||
let dmRemovalDelayMs = DM_LIST_REMOVAL_DELAY_MS;
|
||||
if (Accessibility.useReducedMotion) dmRemovalDelayMs = 0;
|
||||
const handleInlineDMHoverStart = useCallback((channelId: string) => {
|
||||
setHoveredInlineDMChannelId(channelId);
|
||||
}, []);
|
||||
const handleInlineDMHoverEnd = useCallback((channelId: string) => {
|
||||
setHoveredInlineDMChannelId((currentChannelId) =>
|
||||
clearHoveredChannelId({currentChannelId, endedChannelId: channelId}),
|
||||
);
|
||||
}, []);
|
||||
const handleInlineDMRemovalAnimationComplete = useCallback((channelId: string) => {
|
||||
const controller = dmVisibilityControllerRef.current;
|
||||
if (controller != null) controller.completeRemoval(channelId);
|
||||
}, []);
|
||||
const guildListNavigationRef = useRovingFocusList<HTMLDivElement>({
|
||||
focusableSelector: GUILD_LIST_FOCUSABLE_SELECTOR,
|
||||
orientation: 'vertical',
|
||||
@@ -390,38 +579,22 @@ const GuildList = observer(() => {
|
||||
manageTabIndex: true,
|
||||
});
|
||||
useEffect(() => {
|
||||
const unreadIds = new Set<string>();
|
||||
for (let i = 0; i < unreadDMChannels.length; i++) unreadIds.add(unreadDMChannels[i].id);
|
||||
setVisibleDMChannels((current) => {
|
||||
const leftover: Array<Channel> = [];
|
||||
for (let i = 0; i < current.length; i++) {
|
||||
const channel = current[i];
|
||||
if (!unreadIds.has(channel.id)) leftover.push(channel);
|
||||
}
|
||||
for (let i = 0; i < leftover.length; i++) {
|
||||
const channel = leftover[i];
|
||||
if (!removalTimers.current.has(channel.id)) {
|
||||
const timer = setTimeout(() => {
|
||||
removalTimers.current.delete(channel.id);
|
||||
setVisibleDMChannels((latest) => latest.filter((latestChannel) => latestChannel.id !== channel.id));
|
||||
}, DM_LIST_REMOVAL_DELAY_MS);
|
||||
removalTimers.current.set(channel.id, timer);
|
||||
}
|
||||
}
|
||||
const next: Array<Channel> = new Array(unreadDMChannels.length + leftover.length);
|
||||
for (let i = 0; i < unreadDMChannels.length; i++) next[i] = unreadDMChannels[i];
|
||||
for (let i = 0; i < leftover.length; i++) next[unreadDMChannels.length + i] = leftover[i];
|
||||
return next;
|
||||
const controller = new DMChannelVisibilityController({
|
||||
removalDelayMs: dmRemovalDelayMs,
|
||||
removalTimers,
|
||||
orderedUnreadChannels: orderedUnreadDMChannels,
|
||||
setVisibleChannels: setVisibleDMChannels,
|
||||
unreadChannels: unreadDMChannels,
|
||||
unreadIdsRef: unreadDMChannelIdSetRef,
|
||||
});
|
||||
for (let i = 0; i < unreadDMChannels.length; i++) {
|
||||
const channel = unreadDMChannels[i];
|
||||
const timer = removalTimers.current.get(channel.id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
removalTimers.current.delete(channel.id);
|
||||
}
|
||||
}
|
||||
}, [unreadDMChannelIds]);
|
||||
dmVisibilityControllerRef.current = controller;
|
||||
controller.synchronize();
|
||||
}, [dmRemovalDelayMs, orderedUnreadDMChannels, unreadDMChannels]);
|
||||
useEffect(() => {
|
||||
if (hoveredInlineDMChannelId == null) return;
|
||||
const hoveredRow = visibleDMListRows.find((row) => row.channel.id === hoveredInlineDMChannelId);
|
||||
if (hoveredRow == null || hoveredRow.pendingRemoval) setHoveredInlineDMChannelId(null);
|
||||
}, [hoveredInlineDMChannelId, visibleDMListRows]);
|
||||
useEffect(() => {
|
||||
if (unavailableCount > 0) {
|
||||
if (unavailableIndicatorHideTimer.current) {
|
||||
@@ -439,31 +612,15 @@ const GuildList = observer(() => {
|
||||
}, [unavailableCount]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
dmVisibilityControllerRef.current = null;
|
||||
if (unavailableIndicatorHideTimer.current) {
|
||||
clearTimeout(unavailableIndicatorHideTimer.current);
|
||||
unavailableIndicatorHideTimer.current = null;
|
||||
}
|
||||
removalTimers.current.forEach((timer) => clearTimeout(timer));
|
||||
removalTimers.current.forEach((timer) => window.clearTimeout(timer));
|
||||
removalTimers.current.clear();
|
||||
};
|
||||
}, []);
|
||||
const renderDMListItems = (channels: Array<Channel>) =>
|
||||
channels.map((channel) => {
|
||||
const isSelected = isSelectedPath(location.pathname, Routes.dmChannel(channel.id));
|
||||
return (
|
||||
<div
|
||||
key={channel.id}
|
||||
className={styles.dmListItemWrapper}
|
||||
data-flx="app.guilds-layout.render-dm-list-items.dm-list-item-wrapper"
|
||||
>
|
||||
<DMListItem
|
||||
channel={channel}
|
||||
isSelected={isSelected}
|
||||
data-flx="app.guilds-layout.render-dm-list-items.dm-list-item"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const handleGuildDrop = useCallback(
|
||||
(item: GuildDragItem, result: GuildDropResult) => {
|
||||
const sourceKey = item.id;
|
||||
@@ -611,17 +768,21 @@ const GuildList = observer(() => {
|
||||
const handleDragStateChange = useCallback((item: GuildDragItem | null) => {
|
||||
setIsDragging(item !== null);
|
||||
}, []);
|
||||
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
|
||||
const scrollTop = event.currentTarget.scrollTop;
|
||||
pendingScrollTopRef.current = scrollTop;
|
||||
if (scrollPersistRafRef.current != null) return;
|
||||
scrollPersistRafRef.current = requestAnimationFrame(() => {
|
||||
scrollPersistRafRef.current = null;
|
||||
const pendingScrollTop = pendingScrollTopRef.current;
|
||||
if (pendingScrollTop == null) return;
|
||||
DimensionCommands.updateGuildListScroll(pendingScrollTop);
|
||||
});
|
||||
}, []);
|
||||
const handleScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
handleGuildListAnchorScroll(event.currentTarget);
|
||||
const scrollTop = event.currentTarget.scrollTop;
|
||||
pendingScrollTopRef.current = scrollTop;
|
||||
if (scrollPersistRafRef.current != null) return;
|
||||
scrollPersistRafRef.current = requestAnimationFrame(() => {
|
||||
scrollPersistRafRef.current = null;
|
||||
const pendingScrollTop = pendingScrollTopRef.current;
|
||||
if (pendingScrollTop == null) return;
|
||||
DimensionCommands.updateGuildListScroll(pendingScrollTop);
|
||||
});
|
||||
},
|
||||
[handleGuildListAnchorScroll],
|
||||
);
|
||||
const handleWheel = useCallback(
|
||||
(event: React.WheelEvent<HTMLDivElement>) => {
|
||||
if (!isDragging || event.defaultPrevented) return;
|
||||
@@ -634,19 +795,23 @@ const GuildList = observer(() => {
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const scrollNode = getGuildScrollContainer();
|
||||
if (scrollNode == null) return;
|
||||
const ownerWindow = scrollNode.ownerDocument.defaultView;
|
||||
if (ownerWindow == null) return;
|
||||
const handleWindowWheel = (event: WheelEvent) => {
|
||||
const scrollNode = scrollRef.current?.getScrollerNode();
|
||||
if (!scrollNode || !isWheelEventOverElement(event, scrollNode)) return;
|
||||
const currentScrollNode = getGuildScrollContainer();
|
||||
if (currentScrollNode == null || !isWheelEventOverElement(event, currentScrollNode)) return;
|
||||
if (!scrollGuildListByWheel(event)) return;
|
||||
if (event.cancelable) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
window.addEventListener('wheel', handleWindowWheel, {capture: true, passive: false});
|
||||
ownerWindow.addEventListener('wheel', handleWindowWheel, {capture: true, passive: false});
|
||||
return () => {
|
||||
window.removeEventListener('wheel', handleWindowWheel, true);
|
||||
ownerWindow.removeEventListener('wheel', handleWindowWheel, true);
|
||||
};
|
||||
}, [isDragging, scrollGuildListByWheel]);
|
||||
}, [getGuildScrollContainer, isDragging, scrollGuildListByWheel]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollPersistRafRef.current != null) {
|
||||
@@ -673,6 +838,7 @@ const GuildList = observer(() => {
|
||||
className={styles.guildListScrollContainer}
|
||||
showTrack={false}
|
||||
onScroll={handleScroll}
|
||||
onResize={handleGuildListAnchorResize}
|
||||
onWheel={handleWheel}
|
||||
key="guild-list-scroller"
|
||||
data-flx="app.guilds-layout.guild-list.guild-list-scroll-container"
|
||||
@@ -686,37 +852,43 @@ const GuildList = observer(() => {
|
||||
<FluxerButton data-flx="app.guilds-layout.guild-list.fluxer-button" />
|
||||
<FavoritesButton data-flx="app.guilds-layout.guild-list.favorites-button" />
|
||||
<div className={styles.dmListSection} data-flx="app.guilds-layout.guild-list.dm-list-section">
|
||||
{pinnedCallChannel && (
|
||||
<div
|
||||
className={styles.dmListItemWrapper}
|
||||
key={`pinned-call-${pinnedCallChannel.id}`}
|
||||
data-flx="app.guilds-layout.guild-list.dm-list-item-wrapper"
|
||||
>
|
||||
<DMListItem
|
||||
channel={pinnedCallChannel}
|
||||
isSelected={isSelectedPath(location.pathname, Routes.dmChannel(pinnedCallChannel.id))}
|
||||
voiceCallActive
|
||||
data-flx="app.guilds-layout.guild-list.dm-list-item"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{renderDMListItems(filteredDMChannels)}
|
||||
{visibleDMListRows.map((row, index) => (
|
||||
<DMListAnimatedRow
|
||||
key={row.channel.id}
|
||||
channel={row.channel}
|
||||
isLast={index === visibleDMListRows.length - 1}
|
||||
isSelected={isSelectedPath(location.pathname, Routes.dmChannel(row.channel.id))}
|
||||
pendingRemoval={row.pendingRemoval}
|
||||
reducedMotion={Accessibility.useReducedMotion}
|
||||
onHoverStart={handleInlineDMHoverStart}
|
||||
onHoverEnd={handleInlineDMHoverEnd}
|
||||
onRemovalAnimationComplete={handleInlineDMRemovalAnimationComplete}
|
||||
scrollAnchorRef={getGuildListAnchorRef(`dm-${row.channel.id}`)}
|
||||
voiceCallActive={row.voiceCallActive}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasVisibleDMChannels && (
|
||||
<div className={styles.guildDivider} data-flx="app.guilds-layout.guild-list.guild-divider" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={getGuildListAnchorRef('guild-section')}
|
||||
className={styles.guildListGuildsSection}
|
||||
data-flx="app.guilds-layout.guild-list.guild-list-guilds-section"
|
||||
>
|
||||
{shouldShowTopDivider && (
|
||||
<div className={styles.guildDivider} data-flx="app.guilds-layout.guild-list.guild-divider--2" />
|
||||
<div
|
||||
ref={getGuildListAnchorRef('guild-divider')}
|
||||
className={styles.guildDivider}
|
||||
data-flx="app.guilds-layout.guild-list.guild-divider--2"
|
||||
/>
|
||||
)}
|
||||
{shouldRenderGuildListItems && (
|
||||
<div className={styles.guildListItems} data-flx="app.guilds-layout.guild-list.guild-list-items">
|
||||
{hasUnavailableGuilds && (
|
||||
<div
|
||||
ref={getGuildListAnchorRef('guild-outage-indicator')}
|
||||
className={styles.guildListItemSlot}
|
||||
key="guild-outage-indicator"
|
||||
data-flx="app.guilds-layout.guild-list.guild-list-item-slot"
|
||||
@@ -764,6 +936,7 @@ const GuildList = observer(() => {
|
||||
);
|
||||
return (
|
||||
<div
|
||||
ref={getGuildListAnchorRef(getGuildListAnchorKeyForItem(item))}
|
||||
className={styles.guildListItemSlot}
|
||||
key={getOrganizedItemKey(item)}
|
||||
data-flx="app.guilds-layout.guild-list.guild-list-item-slot--2"
|
||||
@@ -784,6 +957,7 @@ const GuildList = observer(() => {
|
||||
}
|
||||
return (
|
||||
<div
|
||||
ref={getGuildListAnchorRef(getGuildListAnchorKeyForItem(item))}
|
||||
className={styles.guildListItemSlot}
|
||||
key={item.guild.id}
|
||||
data-flx="app.guilds-layout.guild-list.guild-list-item-slot--3"
|
||||
@@ -827,6 +1001,7 @@ const GuildList = observer(() => {
|
||||
</Scroller>
|
||||
<ScrollIndicatorOverlay
|
||||
getScrollContainer={getGuildScrollContainer}
|
||||
scrollContainerIdentity="guild-list"
|
||||
dependencies={guildIndicatorDependencies}
|
||||
label={i18n._(NEW_DESCRIPTOR)}
|
||||
data-flx="app.guilds-layout.guild-list.scroll-indicator-overlay"
|
||||
@@ -945,6 +1120,7 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) =
|
||||
shouldReserveUserAreaSpace && styles.guildsLayoutReserveSpace,
|
||||
showBottomNav && styles.guildsLayoutReserveMobileBottomNav,
|
||||
)}
|
||||
style={resolveGuildsLayoutSidebarStyle(mobileLayout.enabled, SidebarWidth.cssValue)}
|
||||
data-flx="app.guilds-layout.guilds-layout"
|
||||
>
|
||||
{!isVoiceCallFullscreenActive && (!mobileLayout.enabled || showGuildListOnMobile) && (
|
||||
@@ -963,6 +1139,7 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) =
|
||||
<OutlineFrame
|
||||
className={clsx(styles.outlineFrame, isVoiceCallFullscreenActive && styles.outlineFrameFullscreen)}
|
||||
sidebarDivider={!isVoiceCallFullscreenActive && shouldShowSidebarDivider}
|
||||
sidebarResizeHandle={!isVoiceCallFullscreenActive && shouldShowSidebarDivider}
|
||||
nagbar={
|
||||
!isVoiceCallFullscreenActive && activeNagbars.length > 0 ? (
|
||||
<div className={styles.nagbarStack} data-flx="app.guilds-layout.nagbar-stack">
|
||||
|
||||
@@ -15,15 +15,16 @@ type NativeDragRegionProps = Omit<React.HTMLAttributes<HTMLElement>, 'style'> &
|
||||
export const NativeDragRegion = React.forwardRef<HTMLElement, NativeDragRegionProps>(
|
||||
function NativeDragRegionInner(props, ref) {
|
||||
const {as, disabled = false, className, ...rest} = props;
|
||||
const Component = (as ?? 'div') as ElementType;
|
||||
return (
|
||||
<Component
|
||||
ref={ref as React.Ref<HTMLElement>}
|
||||
className={clsx(className, !disabled && styles.nativeDragRegion)}
|
||||
data-flx="app.native-drag-region.native-drag-region-inner.native-drag-region"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
let Component: ElementType = 'div';
|
||||
if (as != null) {
|
||||
Component = as;
|
||||
}
|
||||
return React.createElement(Component, {
|
||||
...rest,
|
||||
ref: ref as React.Ref<HTMLElement>,
|
||||
className: clsx(className, !disabled && styles.nativeDragRegion),
|
||||
'data-flx': 'app.native-drag-region.native-drag-region-inner.native-drag-region',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
z-index: var(--z-index-titlebar);
|
||||
}
|
||||
|
||||
.titlebar[data-platform='macos'] {
|
||||
padding-left: calc(var(--macos-traffic-light-inset) + env(safe-area-inset-left));
|
||||
padding-right: calc(var(--spacing-3) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
:global(html.platform-native:not(.window-focused)) .titlebar,
|
||||
:global(html.platform-native.window-focus-activation-guard) .titlebar {
|
||||
pointer-events: auto;
|
||||
@@ -29,9 +34,11 @@
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
height: 0.875rem;
|
||||
display: block;
|
||||
height: 0.8125rem;
|
||||
width: auto;
|
||||
color: var(--text-primary-muted);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
|
||||
@@ -4,32 +4,54 @@ import styles from '@app/features/app/components/layout/NativeTitlebar.module.cs
|
||||
import {FluxerWordmark} from '@app/features/ui/components/icons/FluxerWordmark';
|
||||
import {getElectronAPI, type NativePlatform} from '@app/features/ui/utils/NativeUtils';
|
||||
import type React from 'react';
|
||||
import {useLayoutEffect} from 'react';
|
||||
import {NativeWindowControls} from './NativeWindowControls';
|
||||
|
||||
const STARTUP_NATIVE_TITLEBAR_ID = 'fluxer-startup-native-titlebar';
|
||||
|
||||
interface NativeTitlebarProps {
|
||||
platform: NativePlatform;
|
||||
}
|
||||
|
||||
export const NativeTitlebar: React.FC<NativeTitlebarProps> = ({platform}) => {
|
||||
const isMacOS = platform === 'macos';
|
||||
useLayoutEffect(() => {
|
||||
const startupTitlebar = document.getElementById(STARTUP_NATIVE_TITLEBAR_ID);
|
||||
if (startupTitlebar != null) {
|
||||
startupTitlebar.remove();
|
||||
}
|
||||
}, []);
|
||||
const handleDoubleClick = () => {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.windowMaximize) return;
|
||||
electronApi.windowMaximize();
|
||||
};
|
||||
const brand = (
|
||||
<div className={styles.left} data-flx="app.native-titlebar.left">
|
||||
<FluxerWordmark className={styles.wordmark} data-flx="app.native-titlebar.wordmark" />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
className={styles.titlebar}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onDoubleClick={isMacOS ? undefined : handleDoubleClick}
|
||||
data-platform={platform}
|
||||
data-native-titlebar=""
|
||||
data-flx="app.native-titlebar.titlebar"
|
||||
>
|
||||
<div className={styles.left} data-flx="app.native-titlebar.left">
|
||||
<FluxerWordmark className={styles.wordmark} data-flx="app.native-titlebar.wordmark" />
|
||||
</div>
|
||||
<div className={styles.spacer} data-flx="app.native-titlebar.spacer" />
|
||||
<NativeWindowControls data-flx="app.native-titlebar.controls" />
|
||||
{isMacOS ? (
|
||||
<>
|
||||
<div className={styles.spacer} data-flx="app.native-titlebar.spacer" />
|
||||
{brand}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{brand}
|
||||
<div className={styles.spacer} data-flx="app.native-titlebar.spacer" />
|
||||
<NativeWindowControls data-flx="app.native-titlebar.controls" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.backdropBase {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: var(--native-titlebar-height);
|
||||
background: var(--background-secondary);
|
||||
pointer-events: none;
|
||||
-webkit-app-region: no-drag;
|
||||
z-index: var(--z-index-elevated-2);
|
||||
width: var(--traffic-lights-backdrop-width, var(--layout-guild-list-width, 4.5rem));
|
||||
}
|
||||
|
||||
.backdropApp {
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.backdropAuth {
|
||||
height: 2rem;
|
||||
width: 4.75rem;
|
||||
top: 0;
|
||||
left: 0;
|
||||
border-bottom-right-radius: var(--radius-xl);
|
||||
box-shadow: 0 0.75rem 1.5rem rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos) .backdropBase {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos[data-voice-call-fullscreen='true']) .backdropBase,
|
||||
:global(html.platform-native.platform-macos:fullscreen) .backdropBase {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(html.platform-native.platform-macos:-webkit-full-screen) .backdropBase {
|
||||
display: none;
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/NativeTrafficLightsBackdrop.module.css';
|
||||
import type {LayoutVariant} from '@app/features/app/state/LayoutVariantContext';
|
||||
import {getFullscreenElement} from '@app/features/platform/utils/FullscreenMediaUtils';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const FULLSCREEN_CHANGE_EVENTS = [
|
||||
'fullscreenchange',
|
||||
'webkitfullscreenchange',
|
||||
'mozfullscreenchange',
|
||||
'MSFullscreenChange',
|
||||
];
|
||||
|
||||
function hasFullscreenElement(): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return getFullscreenElement() !== null;
|
||||
}
|
||||
|
||||
function useHasFullscreenElement(): boolean {
|
||||
const [active, setActive] = useState(hasFullscreenElement);
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
setActive(hasFullscreenElement());
|
||||
};
|
||||
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
|
||||
document.addEventListener(eventName, handleFullscreenChange);
|
||||
}
|
||||
handleFullscreenChange();
|
||||
return () => {
|
||||
for (const eventName of FULLSCREEN_CHANGE_EVENTS) {
|
||||
document.removeEventListener(eventName, handleFullscreenChange);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return active;
|
||||
}
|
||||
|
||||
interface NativeTrafficLightsBackdropProps {
|
||||
variant?: LayoutVariant;
|
||||
className?: string;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
export const NativeTrafficLightsBackdrop: React.FC<NativeTrafficLightsBackdropProps> = ({
|
||||
variant = 'app',
|
||||
className,
|
||||
hidden = false,
|
||||
}) => {
|
||||
const fullscreenActive = useHasFullscreenElement();
|
||||
if (hidden || fullscreenActive) return null;
|
||||
const backdropStyle = variant === 'auth' ? styles.backdropAuth : styles.backdropApp;
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={clsx(styles.backdropBase, backdropStyle, className)}
|
||||
data-flx="app.native-traffic-lights-backdrop.backdrop-base"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -8,9 +8,8 @@ import {useLingui} from '@lingui/react/macro';
|
||||
import {CopySimpleIcon, MinusIcon, SquareIcon, XIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useEffect, useLayoutEffect, useState} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
|
||||
const STARTUP_NATIVE_TITLEBAR_ID = 'fluxer-startup-native-titlebar';
|
||||
const MINIMIZE_WINDOW_DESCRIPTOR = msg({
|
||||
message: 'Minimize window',
|
||||
comment: 'Short label in the app layout native titlebar.',
|
||||
@@ -39,9 +38,6 @@ export const NativeWindowControls: React.FC<NativeWindowControlsProps> = ({
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
useLayoutEffect(() => {
|
||||
document.getElementById(STARTUP_NATIVE_TITLEBAR_ID)?.remove();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return;
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import {FrameContext, type FrameSides} from '@app/features/app/components/layout/FrameContext';
|
||||
import styles from '@app/features/app/components/layout/OutlineFrame.module.css';
|
||||
import {SidebarResizeHandle} from '@app/features/app/components/layout/SidebarResizeHandle';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
interface OutlineFrameProps {
|
||||
sidebarDivider?: boolean;
|
||||
sidebarResizeHandle?: boolean;
|
||||
hideTopBorder?: boolean;
|
||||
sides?: FrameSides;
|
||||
nagbar?: React.ReactNode;
|
||||
@@ -18,6 +20,7 @@ interface OutlineFrameProps {
|
||||
|
||||
export const OutlineFrame: React.FC<OutlineFrameProps> = ({
|
||||
sidebarDivider = false,
|
||||
sidebarResizeHandle = false,
|
||||
hideTopBorder = false,
|
||||
sides,
|
||||
topBanner,
|
||||
@@ -58,6 +61,7 @@ export const OutlineFrame: React.FC<OutlineFrameProps> = ({
|
||||
{nagbar}
|
||||
<div className={styles.contentWrapper} data-flx="app.outline-frame.content-wrapper">
|
||||
{sidebarDivider && <div className={styles.divider} aria-hidden data-flx="app.outline-frame.divider" />}
|
||||
{sidebarResizeHandle && <SidebarResizeHandle />}
|
||||
<div className={styles.body} data-flx="app.outline-frame.body">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
|
||||
.indicatorSlotBottom {
|
||||
bottom: 0.5rem;
|
||||
bottom: var(--floating-unread-indicator-bottom-inset, 0.5rem);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
|
||||
@@ -5,14 +5,14 @@ import styles from '@app/features/app/components/layout/ScrollIndicatorOverlay.m
|
||||
import {
|
||||
type ActiveScrollIndicator,
|
||||
createScrollIndicatorSnapshot,
|
||||
type ScrollIndicatorDirection,
|
||||
type ScrollIndicatorMachineEvent,
|
||||
type ScrollIndicatorSeverity,
|
||||
type ScrollIndicatorTargetMeasurement,
|
||||
selectActiveScrollIndicator,
|
||||
selectActiveScrollIndicators,
|
||||
transitionScrollIndicatorSnapshot,
|
||||
} from '@app/features/app/components/layout/ScrollIndicatorStateMachine';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {resolveVisibleScrollViewportHeight} from '@app/features/ui/utils/ScrollViewportGeometry';
|
||||
import {clsx} from 'clsx';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import type React from 'react';
|
||||
@@ -20,7 +20,43 @@ import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
export type {ScrollIndicatorSeverity};
|
||||
|
||||
const SCROLL_DIRECTION_EPSILON = 0.5;
|
||||
const SCROLL_TARGET_PADDING_PX = 12;
|
||||
|
||||
interface OwnedAnimationFrame {
|
||||
id: number;
|
||||
ownerWindow: Window & typeof globalThis;
|
||||
}
|
||||
|
||||
function resolveOwnerWindow(container: HTMLElement): Window & typeof globalThis {
|
||||
const ownerWindow = container.ownerDocument.defaultView;
|
||||
if (ownerWindow == null) {
|
||||
throw new Error('Scroll indicator container has no owner window');
|
||||
}
|
||||
return ownerWindow;
|
||||
}
|
||||
|
||||
function cancelOwnedAnimationFrame(frame: OwnedAnimationFrame | null): void {
|
||||
if (frame == null) return;
|
||||
frame.ownerWindow.cancelAnimationFrame(frame.id);
|
||||
}
|
||||
|
||||
function scrollIndicatorNodeIntoVisibleViewport(
|
||||
container: HTMLElement,
|
||||
node: HTMLElement,
|
||||
direction: 'top' | 'bottom',
|
||||
behavior: ScrollBehavior,
|
||||
): void {
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
const nodeTop = container.scrollTop + nodeRect.top - containerRect.top;
|
||||
const nodeBottom = container.scrollTop + nodeRect.bottom - containerRect.top;
|
||||
let top = nodeTop - SCROLL_TARGET_PADDING_PX;
|
||||
if (direction === 'bottom') {
|
||||
top = nodeBottom + SCROLL_TARGET_PADDING_PX - resolveVisibleScrollViewportHeight(container);
|
||||
}
|
||||
const maximumScrollTop = Math.max(0, container.scrollHeight - container.clientHeight);
|
||||
container.scrollTo({top: Math.min(maximumScrollTop, Math.max(0, top)), behavior});
|
||||
}
|
||||
|
||||
interface MeasurementRect {
|
||||
top: number;
|
||||
@@ -34,12 +70,12 @@ function hasPositiveArea(rect: MeasurementRect): boolean {
|
||||
}
|
||||
|
||||
function getMeasurableRectInsideScrollContent(node: HTMLElement, container: HTMLElement): MeasurementRect | null {
|
||||
const nodeStyle = getComputedStyle(node);
|
||||
const nodeStyle = resolveOwnerWindow(node).getComputedStyle(node);
|
||||
if (nodeStyle.display === 'none' || nodeStyle.visibility === 'hidden') return null;
|
||||
const nodeRect = node.getBoundingClientRect();
|
||||
if (!hasPositiveArea(nodeRect)) return null;
|
||||
for (let parent = node.parentElement; parent && parent !== container; parent = parent.parentElement) {
|
||||
const parentStyle = getComputedStyle(parent);
|
||||
const parentStyle = resolveOwnerWindow(parent).getComputedStyle(parent);
|
||||
if (parentStyle.display === 'none' || parentStyle.visibility === 'hidden') return null;
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
if (!hasPositiveArea(parentRect)) return null;
|
||||
@@ -80,90 +116,130 @@ function findScrollIndicatorNode(container: HTMLElement, id: string): HTMLElemen
|
||||
|
||||
export const useScrollEdgeIndicators = (
|
||||
getScrollContainer: () => HTMLElement | null,
|
||||
scrollContainerIdentity: string,
|
||||
dependencies: React.DependencyList = [],
|
||||
) => {
|
||||
const [snapshot, setSnapshot] = useState(() => createScrollIndicatorSnapshot());
|
||||
const activeIndicator = selectActiveScrollIndicator(snapshot);
|
||||
const preferredDirectionRef = useRef<ScrollIndicatorDirection | null>(null);
|
||||
const lastScrollTopRef = useRef(0);
|
||||
const activeIndicators = selectActiveScrollIndicators(snapshot);
|
||||
const refreshFrameRef = useRef<OwnedAnimationFrame | null>(null);
|
||||
const visibleViewportHeightRef = useRef(0);
|
||||
const layoutInvalidatedRef = useRef(true);
|
||||
const send = useCallback((event: ScrollIndicatorMachineEvent) => {
|
||||
setSnapshot((previous) => transitionScrollIndicatorSnapshot(previous, event));
|
||||
}, []);
|
||||
const refresh = useCallback(() => {
|
||||
const refreshNow = useCallback(() => {
|
||||
const container = getScrollContainer();
|
||||
if (!container) {
|
||||
send({type: 'scrollIndicator.reset'});
|
||||
return;
|
||||
}
|
||||
if (layoutInvalidatedRef.current) {
|
||||
layoutInvalidatedRef.current = false;
|
||||
visibleViewportHeightRef.current = resolveVisibleScrollViewportHeight(container);
|
||||
}
|
||||
send({
|
||||
type: 'scrollIndicator.measured',
|
||||
measurement: {
|
||||
scrollTop: container.scrollTop,
|
||||
viewportHeight: container.clientHeight,
|
||||
viewportHeight: visibleViewportHeightRef.current,
|
||||
targets: measureScrollIndicatorTargets(container),
|
||||
preferredDirection: preferredDirectionRef.current,
|
||||
},
|
||||
});
|
||||
}, [getScrollContainer, send]);
|
||||
const scheduleRefresh = useCallback(
|
||||
(invalidateLayout = false) => {
|
||||
if (invalidateLayout) layoutInvalidatedRef.current = true;
|
||||
const container = getScrollContainer();
|
||||
if (container == null) {
|
||||
refreshNow();
|
||||
return;
|
||||
}
|
||||
const ownerWindow = resolveOwnerWindow(container);
|
||||
const pendingFrame = refreshFrameRef.current;
|
||||
if (pendingFrame != null && pendingFrame.ownerWindow === ownerWindow) return;
|
||||
cancelOwnedAnimationFrame(pendingFrame);
|
||||
const id = ownerWindow.requestAnimationFrame(() => {
|
||||
refreshFrameRef.current = null;
|
||||
refreshNow();
|
||||
});
|
||||
refreshFrameRef.current = {id, ownerWindow};
|
||||
},
|
||||
[getScrollContainer, refreshNow],
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
refresh();
|
||||
}, [refresh, ...dependencies]);
|
||||
layoutInvalidatedRef.current = true;
|
||||
refreshNow();
|
||||
}, [refreshNow, scrollContainerIdentity, ...dependencies]);
|
||||
useLayoutEffect(() => {
|
||||
const container = getScrollContainer();
|
||||
if (!container) return;
|
||||
const content = container.firstElementChild instanceof HTMLElement ? container.firstElementChild : null;
|
||||
const ownerWindow = resolveOwnerWindow(container);
|
||||
const content = container.firstElementChild instanceof ownerWindow.HTMLElement ? container.firstElementChild : null;
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(() => {
|
||||
refresh();
|
||||
ownerWindow.ResizeObserver != null
|
||||
? new ownerWindow.ResizeObserver((entries) => {
|
||||
scheduleRefresh(entries.some((entry) => entry.target === container));
|
||||
})
|
||||
: null;
|
||||
const mutationObserver =
|
||||
typeof MutationObserver !== 'undefined'
|
||||
? new MutationObserver(() => {
|
||||
refresh();
|
||||
const contentMutationObserver =
|
||||
ownerWindow.MutationObserver != null
|
||||
? new ownerWindow.MutationObserver(() => {
|
||||
scheduleRefresh(false);
|
||||
})
|
||||
: null;
|
||||
resizeObserver?.observe(container);
|
||||
if (content) resizeObserver?.observe(content);
|
||||
mutationObserver?.observe(container, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributeFilter: ['data-scroll-indicator', 'data-scroll-id', 'class', 'style'],
|
||||
});
|
||||
const layoutMutationObserver =
|
||||
ownerWindow.MutationObserver != null
|
||||
? new ownerWindow.MutationObserver(() => {
|
||||
scheduleRefresh(true);
|
||||
})
|
||||
: null;
|
||||
if (resizeObserver != null) {
|
||||
resizeObserver.observe(container);
|
||||
if (content != null) resizeObserver.observe(content);
|
||||
}
|
||||
if (contentMutationObserver != null) {
|
||||
contentMutationObserver.observe(container, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributeFilter: ['data-scroll-indicator', 'data-scroll-id', 'class', 'style'],
|
||||
});
|
||||
}
|
||||
for (let element: HTMLElement | null = container; element != null; element = element.parentElement) {
|
||||
if (layoutMutationObserver != null) {
|
||||
layoutMutationObserver.observe(element, {attributes: true, attributeFilter: ['class', 'style']});
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
if (resizeObserver != null) resizeObserver.disconnect();
|
||||
if (contentMutationObserver != null) contentMutationObserver.disconnect();
|
||||
if (layoutMutationObserver != null) layoutMutationObserver.disconnect();
|
||||
cancelOwnedAnimationFrame(refreshFrameRef.current);
|
||||
refreshFrameRef.current = null;
|
||||
};
|
||||
}, [getScrollContainer, refresh]);
|
||||
}, [getScrollContainer, scheduleRefresh, scrollContainerIdentity]);
|
||||
useEffect(() => {
|
||||
const container = getScrollContainer();
|
||||
if (!container) return;
|
||||
lastScrollTopRef.current = container.scrollTop;
|
||||
const handleScroll = () => {
|
||||
const currentScrollTop = container.scrollTop;
|
||||
if (currentScrollTop > lastScrollTopRef.current + SCROLL_DIRECTION_EPSILON) {
|
||||
preferredDirectionRef.current = 'bottom';
|
||||
} else if (currentScrollTop < lastScrollTopRef.current - SCROLL_DIRECTION_EPSILON) {
|
||||
preferredDirectionRef.current = 'top';
|
||||
}
|
||||
lastScrollTopRef.current = currentScrollTop;
|
||||
refresh();
|
||||
scheduleRefresh();
|
||||
};
|
||||
container.addEventListener('scroll', handleScroll, {passive: true});
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [getScrollContainer, refresh]);
|
||||
}, [getScrollContainer, scheduleRefresh, scrollContainerIdentity]);
|
||||
useEffect(() => {
|
||||
const handleResize = () => refresh();
|
||||
window.addEventListener('resize', handleResize);
|
||||
const container = getScrollContainer();
|
||||
if (container == null) return;
|
||||
const ownerWindow = resolveOwnerWindow(container);
|
||||
const handleResize = () => scheduleRefresh(true);
|
||||
ownerWindow.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
ownerWindow.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [refresh]);
|
||||
return {activeIndicator, refresh};
|
||||
}, [getScrollContainer, scheduleRefresh, scrollContainerIdentity]);
|
||||
return {activeIndicators, refresh: refreshNow, scheduleRefresh};
|
||||
};
|
||||
|
||||
interface FloatingScrollIndicatorProps {
|
||||
@@ -201,42 +277,62 @@ const FloatingScrollIndicator = ({label, severity, onClick}: FloatingScrollIndic
|
||||
|
||||
interface ScrollIndicatorOverlayProps {
|
||||
getScrollContainer: () => HTMLElement | null;
|
||||
scrollContainerIdentity: string;
|
||||
dependencies?: React.DependencyList;
|
||||
label: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ScrollIndicatorOverlay = ({getScrollContainer, dependencies = [], label}: ScrollIndicatorOverlayProps) => {
|
||||
const {activeIndicator, refresh} = useScrollEdgeIndicators(getScrollContainer, dependencies);
|
||||
export const ScrollIndicatorOverlay = ({
|
||||
getScrollContainer,
|
||||
scrollContainerIdentity,
|
||||
dependencies = [],
|
||||
label,
|
||||
}: ScrollIndicatorOverlayProps) => {
|
||||
const {activeIndicators, refresh, scheduleRefresh} = useScrollEdgeIndicators(
|
||||
getScrollContainer,
|
||||
scrollContainerIdentity,
|
||||
dependencies,
|
||||
);
|
||||
const scrollIndicatorIntoView = (indicator: ActiveScrollIndicator) => {
|
||||
const container = getScrollContainer();
|
||||
const node = container ? findScrollIndicatorNode(container, indicator.indicator.id) : null;
|
||||
if (!container) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
const node = findScrollIndicatorNode(container, indicator.indicator.id);
|
||||
if (!node) {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
node.scrollIntoView({behavior: Accessibility.useSmoothScrolling ? 'smooth' : 'auto', block: 'nearest'});
|
||||
refresh();
|
||||
requestAnimationFrame(refresh);
|
||||
scrollIndicatorNodeIntoVisibleViewport(
|
||||
container,
|
||||
node,
|
||||
indicator.direction,
|
||||
Accessibility.useSmoothScrolling ? 'smooth' : 'auto',
|
||||
);
|
||||
scheduleRefresh();
|
||||
};
|
||||
return (
|
||||
<div className={styles.scrollIndicatorLayer} data-flx="app.scroll-indicator-overlay.scroll-indicator-layer">
|
||||
<AnimatePresence initial={false} data-flx="app.scroll-indicator-overlay.animate-presence">
|
||||
{activeIndicator && (
|
||||
<div
|
||||
key={`${activeIndicator.direction}:${activeIndicator.indicator.id}:${activeIndicator.indicator.severity}`}
|
||||
className={clsx(
|
||||
styles.indicatorSlot,
|
||||
activeIndicator.direction === 'top' ? styles.indicatorSlotTop : styles.indicatorSlotBottom,
|
||||
)}
|
||||
data-flx="app.scroll-indicator-overlay.indicator-slot"
|
||||
>
|
||||
<FloatingScrollIndicator
|
||||
severity={activeIndicator.indicator.severity}
|
||||
onClick={() => scrollIndicatorIntoView(activeIndicator)}
|
||||
label={label}
|
||||
data-flx="app.scroll-indicator-overlay.floating-scroll-indicator.scroll-indicator-into-view"
|
||||
/>
|
||||
</div>
|
||||
{Object.values(activeIndicators).map((activeIndicator) =>
|
||||
activeIndicator == null ? null : (
|
||||
<div
|
||||
key={`${activeIndicator.direction}:${activeIndicator.indicator.id}:${activeIndicator.indicator.severity}`}
|
||||
className={clsx(
|
||||
styles.indicatorSlot,
|
||||
activeIndicator.direction === 'top' ? styles.indicatorSlotTop : styles.indicatorSlotBottom,
|
||||
)}
|
||||
data-flx="app.scroll-indicator-overlay.indicator-slot"
|
||||
>
|
||||
<FloatingScrollIndicator
|
||||
severity={activeIndicator.indicator.severity}
|
||||
onClick={() => scrollIndicatorIntoView(activeIndicator)}
|
||||
label={label}
|
||||
data-flx="app.scroll-indicator-overlay.floating-scroll-indicator.scroll-indicator-into-view"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,11 @@ export interface ActiveScrollIndicator {
|
||||
indicator: ScrollIndicatorEdgeCandidate;
|
||||
}
|
||||
|
||||
export interface ActiveScrollIndicators {
|
||||
top: ActiveScrollIndicator | null;
|
||||
bottom: ActiveScrollIndicator | null;
|
||||
}
|
||||
|
||||
export interface ScrollIndicatorMeasurement {
|
||||
scrollTop: number;
|
||||
viewportHeight: number;
|
||||
@@ -31,13 +36,11 @@ export interface ScrollIndicatorMeasurement {
|
||||
}
|
||||
|
||||
interface ScrollIndicatorMachineInput {
|
||||
activeIndicator?: ActiveScrollIndicator | null;
|
||||
lastDirection?: ScrollIndicatorDirection | null;
|
||||
activeIndicators?: ActiveScrollIndicators;
|
||||
}
|
||||
|
||||
interface ScrollIndicatorMachineContext {
|
||||
activeIndicator: ActiveScrollIndicator | null;
|
||||
lastDirection: ScrollIndicatorDirection | null;
|
||||
activeIndicators: ActiveScrollIndicators;
|
||||
}
|
||||
|
||||
export type ScrollIndicatorMachineEvent =
|
||||
@@ -162,6 +165,32 @@ export function resolveActiveScrollIndicator(
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveActiveScrollIndicators(measurement: ScrollIndicatorMeasurement): ActiveScrollIndicators {
|
||||
const {topIndicator, bottomIndicator} = resolveScrollIndicatorEdgeCandidates(measurement);
|
||||
return {
|
||||
top: topIndicator == null ? null : {direction: 'top', indicator: topIndicator},
|
||||
bottom: bottomIndicator == null ? null : {direction: 'bottom', indicator: bottomIndicator},
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_ACTIVE_SCROLL_INDICATORS: ActiveScrollIndicators = Object.freeze({top: null, bottom: null});
|
||||
|
||||
function areActiveScrollIndicatorsEqual(left: ActiveScrollIndicators, right: ActiveScrollIndicators): boolean {
|
||||
return (
|
||||
areActiveScrollIndicatorsEqualAtEdge(left.top, right.top) &&
|
||||
areActiveScrollIndicatorsEqualAtEdge(left.bottom, right.bottom)
|
||||
);
|
||||
}
|
||||
|
||||
function areActiveScrollIndicatorsEqualAtEdge(
|
||||
left: ActiveScrollIndicator | null,
|
||||
right: ActiveScrollIndicator | null,
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (left == null || right == null) return false;
|
||||
return left.indicator.id === right.indicator.id && left.indicator.severity === right.indicator.severity;
|
||||
}
|
||||
|
||||
export const scrollIndicatorStateMachine = setup({
|
||||
types: {} as {
|
||||
context: ScrollIndicatorMachineContext;
|
||||
@@ -169,30 +198,21 @@ export const scrollIndicatorStateMachine = setup({
|
||||
input: ScrollIndicatorMachineInput;
|
||||
},
|
||||
guards: {
|
||||
hasActiveIndicator: ({context}) => context.activeIndicator != null,
|
||||
hasActiveIndicator: ({context}) => context.activeIndicators.top != null || context.activeIndicators.bottom != null,
|
||||
},
|
||||
actions: {
|
||||
applyMeasurement: assign(({context, event}) => {
|
||||
if (event.type !== 'scrollIndicator.measured') return {};
|
||||
const activeIndicator = resolveActiveScrollIndicator(
|
||||
event.measurement,
|
||||
context.activeIndicator?.direction ?? context.lastDirection,
|
||||
);
|
||||
return {
|
||||
activeIndicator,
|
||||
lastDirection: activeIndicator?.direction ?? context.lastDirection,
|
||||
};
|
||||
const activeIndicators = resolveActiveScrollIndicators(event.measurement);
|
||||
if (areActiveScrollIndicatorsEqual(context.activeIndicators, activeIndicators)) return {};
|
||||
return {activeIndicators};
|
||||
}),
|
||||
reset: assign(() => ({
|
||||
activeIndicator: null,
|
||||
lastDirection: null,
|
||||
})),
|
||||
reset: assign(() => ({activeIndicators: EMPTY_ACTIVE_SCROLL_INDICATORS})),
|
||||
},
|
||||
}).createMachine({
|
||||
id: 'scrollIndicator',
|
||||
context: ({input}) => ({
|
||||
activeIndicator: input.activeIndicator ?? null,
|
||||
lastDirection: input.lastDirection ?? input.activeIndicator?.direction ?? null,
|
||||
activeIndicators: input.activeIndicators == null ? EMPTY_ACTIVE_SCROLL_INDICATORS : input.activeIndicators,
|
||||
}),
|
||||
initial: 'routing',
|
||||
states: {
|
||||
@@ -233,5 +253,10 @@ export function getScrollIndicatorStateValue(snapshot: ScrollIndicatorMachineSna
|
||||
}
|
||||
|
||||
export function selectActiveScrollIndicator(snapshot: ScrollIndicatorMachineSnapshot): ActiveScrollIndicator | null {
|
||||
return snapshot.context.activeIndicator;
|
||||
const topIndicator = snapshot.context.activeIndicators.top;
|
||||
return topIndicator == null ? snapshot.context.activeIndicators.bottom : topIndicator;
|
||||
}
|
||||
|
||||
export function selectActiveScrollIndicators(snapshot: ScrollIndicatorMachineSnapshot): ActiveScrollIndicators {
|
||||
return snapshot.context.activeIndicators;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: var(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px));
|
||||
left: var(--layout-sidebar-width);
|
||||
width: 0.375rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
background-color: transparent;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
--sidebar-resize-handle-color: color-mix(in srgb, var(--background-secondary-lighter) 65%, var(--text-primary) 35%);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 0.375rem;
|
||||
transform: translateX(-50%) scaleX(0.333333);
|
||||
transform-origin: center;
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--sidebar-resize-handle-color);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
opacity 160ms ease;
|
||||
}
|
||||
|
||||
.handle:hover .indicator,
|
||||
.handle:focus-visible .indicator {
|
||||
transform: translateX(-50%) scaleX(0.666667);
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
:global(html.sidebar-resizing) .indicator {
|
||||
transform: translateX(-50%) scaleX(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:global(html.sidebar-resizing),
|
||||
:global(html.sidebar-resizing) * {
|
||||
cursor: col-resize !important;
|
||||
user-select: none !important;
|
||||
-webkit-user-select: none !important;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/SidebarResizeHandle.module.css';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import SidebarWidth, {
|
||||
SIDEBAR_WIDTH_DEFAULT,
|
||||
SIDEBAR_WIDTH_MAX,
|
||||
SIDEBAR_WIDTH_MIN,
|
||||
} from '@app/features/ui/state/SidebarWidth';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
const RESIZE_SIDEBAR_HANDLE_DESCRIPTOR = msg({
|
||||
message: 'Resize sidebar',
|
||||
comment: 'Accessible label for the draggable handle that resizes the channel or direct message sidebar.',
|
||||
});
|
||||
const RESIZING_CLASS = 'sidebar-resizing';
|
||||
const KEYBOARD_STEP = 12;
|
||||
const KEYBOARD_STEP_LARGE = 48;
|
||||
|
||||
interface DragState {
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
latest: number;
|
||||
zoom: number;
|
||||
handle: HTMLElement;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
function resolveZoomFactor(ownerDocument: Document): number {
|
||||
const zoomFactor = getAppZoomFactor(ownerDocument);
|
||||
if (!Number.isFinite(zoomFactor) || zoomFactor <= 0) return 1;
|
||||
return zoomFactor;
|
||||
}
|
||||
|
||||
function clampWidth(value: number): number {
|
||||
return Math.max(SIDEBAR_WIDTH_MIN, Math.min(Math.round(value), SIDEBAR_WIDTH_MAX));
|
||||
}
|
||||
|
||||
function resolveSidebarWidth(): number {
|
||||
const width = SidebarWidth.width;
|
||||
if (width == null) return SIDEBAR_WIDTH_DEFAULT;
|
||||
return width;
|
||||
}
|
||||
|
||||
function measureSidebarWidth(handle: HTMLElement): number {
|
||||
const frame = handle.parentElement;
|
||||
if (frame == null) return resolveSidebarWidth();
|
||||
const handleRect = handle.getBoundingClientRect();
|
||||
const frameRect = frame.getBoundingClientRect();
|
||||
const measured = handleRect.left + handleRect.width / 2 - frameRect.left;
|
||||
if (!Number.isFinite(measured) || measured <= 0) return resolveSidebarWidth();
|
||||
return clampWidth(measured / resolveZoomFactor(handle.ownerDocument));
|
||||
}
|
||||
|
||||
function resolveCurrentSidebarWidth(handle: HTMLElement): number {
|
||||
const storedWidth = SidebarWidth.width;
|
||||
if (storedWidth != null) return storedWidth;
|
||||
return measureSidebarWidth(handle);
|
||||
}
|
||||
|
||||
function shouldResetSidebarWidth(key: string): boolean {
|
||||
return key === 'Enter' || key === 'Backspace' || key === 'Delete';
|
||||
}
|
||||
|
||||
export const SidebarResizeHandle = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const handleRef = useRef<HTMLDivElement | null>(null);
|
||||
const configuredWidth = resolveSidebarWidth();
|
||||
const [ariaValueNow, setAriaValueNow] = useState(configuredWidth);
|
||||
|
||||
const finalizeDrag = useCallback((persistWidth: boolean) => {
|
||||
const state = dragRef.current;
|
||||
if (state == null) return;
|
||||
dragRef.current = null;
|
||||
state.cleanup();
|
||||
state.handle.ownerDocument.documentElement.classList.remove(RESIZING_CLASS);
|
||||
if (state.handle.hasPointerCapture(state.pointerId)) {
|
||||
state.handle.releasePointerCapture(state.pointerId);
|
||||
}
|
||||
if (persistWidth) SidebarWidth.setWidth(state.latest, true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => finalizeDrag(false);
|
||||
}, [finalizeDrag]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const handle = handleRef.current;
|
||||
if (handle == null || dragRef.current != null) return;
|
||||
const measuredWidth = measureSidebarWidth(handle);
|
||||
setAriaValueNow((current) => (current === measuredWidth ? current : measuredWidth));
|
||||
}, [configuredWidth]);
|
||||
|
||||
const onPointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
if (!event.isPrimary || event.button !== 0) return;
|
||||
if (dragRef.current != null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
const handle = event.currentTarget;
|
||||
const ownerDocument = handle.ownerDocument;
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
if (ownerWindow == null) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const startWidth = measureSidebarWidth(handle);
|
||||
const state: DragState = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startWidth,
|
||||
latest: startWidth,
|
||||
zoom: resolveZoomFactor(ownerDocument),
|
||||
handle,
|
||||
cleanup: () => {},
|
||||
};
|
||||
const onDocumentPointerMove = (nativeEvent: PointerEvent) => {
|
||||
if (dragRef.current !== state || nativeEvent.pointerId !== state.pointerId) return;
|
||||
nativeEvent.preventDefault();
|
||||
const next = clampWidth(state.startWidth + (nativeEvent.clientX - state.startX) / state.zoom);
|
||||
state.latest = next;
|
||||
SidebarWidth.setWidth(next, false);
|
||||
setAriaValueNow(next);
|
||||
};
|
||||
const onDocumentPointerEnd = (nativeEvent: PointerEvent) => {
|
||||
if (dragRef.current !== state || nativeEvent.pointerId !== state.pointerId) return;
|
||||
nativeEvent.preventDefault();
|
||||
finalizeDrag(true);
|
||||
};
|
||||
const onWindowBlur = () => {
|
||||
if (dragRef.current === state) finalizeDrag(true);
|
||||
};
|
||||
state.cleanup = () => {
|
||||
ownerDocument.removeEventListener('pointermove', onDocumentPointerMove, true);
|
||||
ownerDocument.removeEventListener('pointerup', onDocumentPointerEnd, true);
|
||||
ownerDocument.removeEventListener('pointercancel', onDocumentPointerEnd, true);
|
||||
ownerWindow.removeEventListener('blur', onWindowBlur);
|
||||
};
|
||||
dragRef.current = state;
|
||||
ownerDocument.addEventListener('pointermove', onDocumentPointerMove, {capture: true, passive: false});
|
||||
ownerDocument.addEventListener('pointerup', onDocumentPointerEnd, {capture: true, passive: false});
|
||||
ownerDocument.addEventListener('pointercancel', onDocumentPointerEnd, {capture: true, passive: false});
|
||||
ownerWindow.addEventListener('blur', onWindowBlur);
|
||||
ownerDocument.documentElement.classList.add(RESIZING_CLASS);
|
||||
try {
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
finalizeDrag(false);
|
||||
return;
|
||||
}
|
||||
setAriaValueNow(startWidth);
|
||||
},
|
||||
[finalizeDrag],
|
||||
);
|
||||
|
||||
const onLostPointerCapture = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
const state = dragRef.current;
|
||||
if (state != null && state.pointerId === event.pointerId) finalizeDrag(true);
|
||||
},
|
||||
[finalizeDrag],
|
||||
);
|
||||
|
||||
const onPointerMove = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
||||
const state = dragRef.current;
|
||||
if (state == null || state.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const onPointerCancel = useCallback(
|
||||
(event: React.PointerEvent<HTMLElement>) => {
|
||||
const state = dragRef.current;
|
||||
if (state == null || state.pointerId !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
finalizeDrag(true);
|
||||
},
|
||||
[finalizeDrag],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback((event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (shouldResetSidebarWidth(event.key)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
SidebarWidth.reset();
|
||||
setAriaValueNow(SIDEBAR_WIDTH_DEFAULT);
|
||||
return;
|
||||
}
|
||||
const handle = event.currentTarget;
|
||||
const step = event.shiftKey ? KEYBOARD_STEP_LARGE : KEYBOARD_STEP;
|
||||
const base = resolveCurrentSidebarWidth(handle);
|
||||
let next: number;
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
next = base - step;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
next = base + step;
|
||||
break;
|
||||
case 'Home':
|
||||
next = SIDEBAR_WIDTH_MIN;
|
||||
break;
|
||||
case 'End':
|
||||
next = SIDEBAR_WIDTH_MAX;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const committedWidth = SidebarWidth.setWidth(next, true);
|
||||
setAriaValueNow(committedWidth);
|
||||
}, []);
|
||||
|
||||
const onDoubleClick = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
SidebarWidth.reset();
|
||||
setAriaValueNow(SIDEBAR_WIDTH_DEFAULT);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FocusRing offset={-2}>
|
||||
<div
|
||||
ref={handleRef}
|
||||
role="separator"
|
||||
tabIndex={0}
|
||||
aria-orientation="vertical"
|
||||
aria-label={i18n._(RESIZE_SIDEBAR_HANDLE_DESCRIPTOR)}
|
||||
aria-valuemin={SIDEBAR_WIDTH_MIN}
|
||||
aria-valuemax={SIDEBAR_WIDTH_MAX}
|
||||
aria-valuenow={ariaValueNow}
|
||||
aria-keyshortcuts="ArrowLeft ArrowRight Home End Enter Backspace Delete"
|
||||
className={styles.handle}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerCancel={onPointerCancel}
|
||||
onLostPointerCapture={onLostPointerCapture}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onKeyDown={onKeyDown}
|
||||
data-flx="app.sidebar-resize-handle"
|
||||
>
|
||||
<span className={styles.indicator} aria-hidden />
|
||||
</div>
|
||||
</FocusRing>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type RefObject, useCallback, useEffect, useRef} from 'react';
|
||||
|
||||
export function useDragTargetRect(targetRef: RefObject<HTMLElement | null>): () => DOMRect | null {
|
||||
const cachedRectRef = useRef<DOMRect | null>(null);
|
||||
const clearFrameRef = useRef<number | null>(null);
|
||||
const clearFrameWindowRef = useRef<Window | null>(null);
|
||||
const getTargetRect = useCallback((): DOMRect | null => {
|
||||
const target = targetRef.current;
|
||||
if (target == null) return null;
|
||||
if (cachedRectRef.current != null) return cachedRectRef.current;
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
cachedRectRef.current = targetRect;
|
||||
const ownerWindow = target.ownerDocument.defaultView;
|
||||
if (ownerWindow != null && clearFrameRef.current == null) {
|
||||
clearFrameWindowRef.current = ownerWindow;
|
||||
clearFrameRef.current = ownerWindow.requestAnimationFrame(() => {
|
||||
clearFrameRef.current = null;
|
||||
clearFrameWindowRef.current = null;
|
||||
cachedRectRef.current = null;
|
||||
});
|
||||
}
|
||||
return targetRect;
|
||||
}, [targetRef]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clearFrameWindowRef.current != null && clearFrameRef.current != null) {
|
||||
clearFrameWindowRef.current.cancelAnimationFrame(clearFrameRef.current);
|
||||
}
|
||||
clearFrameRef.current = null;
|
||||
clearFrameWindowRef.current = null;
|
||||
cachedRectRef.current = null;
|
||||
};
|
||||
}, [targetRef]);
|
||||
return getTargetRect;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import styles from '@app/features/app/components/layout/GuildsLayout.module.css';
|
||||
import {DMListItem} from '@app/features/app/components/layout/sidebar_nav/GuildListDMItem';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {motion} from 'framer-motion';
|
||||
import type React from 'react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
const DM_LIST_ENTER_X = -88;
|
||||
export const DM_LIST_ENTER_DURATION_MS = 260;
|
||||
const DM_LIST_EXIT_DURATION_MS = 200;
|
||||
export const DM_LIST_REMOVAL_DELAY_MS = DM_LIST_EXIT_DURATION_MS + 120;
|
||||
const DM_LIST_ENTER_EASING: [number, number, number, number] = [0.165, 0.84, 0.44, 1];
|
||||
const DM_LIST_EXIT_EASING: [number, number, number, number] = [0.645, 0.045, 0.355, 1];
|
||||
|
||||
export interface DMListRow {
|
||||
readonly type: 'channel';
|
||||
readonly channel: Channel;
|
||||
readonly voiceCallActive: boolean;
|
||||
readonly pendingRemoval: boolean;
|
||||
}
|
||||
|
||||
interface DMListAnimatedRowProps {
|
||||
readonly channel: Channel;
|
||||
readonly isLast: boolean;
|
||||
readonly isSelected: boolean;
|
||||
readonly pendingRemoval: boolean;
|
||||
readonly reducedMotion: boolean;
|
||||
readonly onHoverStart: (channelId: string) => void;
|
||||
readonly onHoverEnd: (channelId: string) => void;
|
||||
readonly onRemovalAnimationComplete: (channelId: string) => void;
|
||||
readonly scrollAnchorRef: React.RefCallback<HTMLElement>;
|
||||
readonly voiceCallActive: boolean;
|
||||
}
|
||||
|
||||
export const DMListAnimatedRow: React.FC<DMListAnimatedRowProps> = ({
|
||||
channel,
|
||||
isLast,
|
||||
isSelected,
|
||||
pendingRemoval,
|
||||
reducedMotion,
|
||||
onHoverStart,
|
||||
onHoverEnd,
|
||||
onRemovalAnimationComplete,
|
||||
scrollAnchorRef,
|
||||
voiceCallActive,
|
||||
}) => {
|
||||
const previousPendingRemovalRef = useRef(pendingRemoval);
|
||||
const removalCycleRef = useRef(0);
|
||||
if (previousPendingRemovalRef.current !== pendingRemoval) {
|
||||
previousPendingRemovalRef.current = pendingRemoval;
|
||||
removalCycleRef.current += 1;
|
||||
}
|
||||
const removalCycle = removalCycleRef.current;
|
||||
let rowHeight = 'calc(var(--guild-list-item-box-size) + var(--guild-list-item-gap))';
|
||||
if (isLast) rowHeight = 'var(--guild-list-item-box-size)';
|
||||
let durationMs = DM_LIST_ENTER_DURATION_MS;
|
||||
let easing = DM_LIST_ENTER_EASING;
|
||||
if (pendingRemoval) {
|
||||
durationMs = DM_LIST_EXIT_DURATION_MS;
|
||||
easing = DM_LIST_EXIT_EASING;
|
||||
}
|
||||
if (reducedMotion) durationMs = 0;
|
||||
return (
|
||||
<motion.div
|
||||
ref={scrollAnchorRef}
|
||||
className={styles.dmListRow}
|
||||
initial={reducedMotion ? false : {height: 0}}
|
||||
animate={{height: pendingRemoval ? 0 : rowHeight}}
|
||||
transition={{duration: durationMs / 1000, ease: easing}}
|
||||
onAnimationComplete={() => {
|
||||
if (pendingRemoval && removalCycleRef.current === removalCycle) {
|
||||
onRemovalAnimationComplete(channel.id);
|
||||
}
|
||||
}}
|
||||
data-scroll-anchor-key={`dm-${channel.id}`}
|
||||
data-pending-removal={pendingRemoval ? 'true' : undefined}
|
||||
data-last-row={isLast ? 'true' : undefined}
|
||||
data-flx="app.guild-list-dm-animation.dm-list-row"
|
||||
>
|
||||
<motion.div
|
||||
className={styles.dmListItemBox}
|
||||
initial={reducedMotion ? false : {x: DM_LIST_ENTER_X}}
|
||||
animate={{x: pendingRemoval ? DM_LIST_ENTER_X : 0}}
|
||||
transition={{duration: durationMs / 1000, ease: easing}}
|
||||
data-flx="app.guild-list-dm-animation.dm-list-item-box"
|
||||
>
|
||||
<DMListItem
|
||||
channel={channel}
|
||||
isSelected={isSelected}
|
||||
onHoverStart={onHoverStart}
|
||||
onHoverEnd={onHoverEnd}
|
||||
voiceCallActive={voiceCallActive}
|
||||
data-flx="app.guild-list-dm-animation.dm-list-item"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export function useFrameBatchedDMListRows(rows: ReadonlyArray<DMListRow>): ReadonlyArray<DMListRow> {
|
||||
const [batchedRows, setBatchedRows] = useState<ReadonlyArray<DMListRow>>(rows);
|
||||
const latestRowsRef = useRef(rows);
|
||||
const pendingRowsRef = useRef(rows);
|
||||
const batchAnimationFrameRef = useRef<number | null>(null);
|
||||
latestRowsRef.current = rows;
|
||||
useEffect(() => {
|
||||
pendingRowsRef.current = latestRowsRef.current;
|
||||
if (globalThis.requestAnimationFrame == null) {
|
||||
setBatchedRows(latestRowsRef.current);
|
||||
return;
|
||||
}
|
||||
if (batchAnimationFrameRef.current != null) return;
|
||||
batchAnimationFrameRef.current = requestAnimationFrame(() => {
|
||||
batchAnimationFrameRef.current = null;
|
||||
setBatchedRows(pendingRowsRef.current);
|
||||
});
|
||||
}, [rows]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (batchAnimationFrameRef.current != null) {
|
||||
cancelAnimationFrame(batchAnimationFrameRef.current);
|
||||
batchAnimationFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
return batchedRows;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuComma
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {StarIcon} from '@phosphor-icons/react';
|
||||
@@ -62,11 +63,12 @@ export const FavoritesButton = observer(({className}: FavoritesButtonProps = {})
|
||||
));
|
||||
};
|
||||
const shouldShowHoverState = isHovering || contextMenuOpen;
|
||||
const indicatorHeight = (() => {
|
||||
if (isSelected) return 40;
|
||||
if (shouldShowHoverState) return 20;
|
||||
return 8;
|
||||
})();
|
||||
const indicatorHeight =
|
||||
(() => {
|
||||
if (isSelected) return 40;
|
||||
if (shouldShowHoverState) return 20;
|
||||
return 8;
|
||||
})() * getAppZoomFactor();
|
||||
const isActive = shouldShowHoverState || isSelected;
|
||||
if (!Accessibility.showFavorites) {
|
||||
return null;
|
||||
|
||||
@@ -21,6 +21,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import SidebarPreferences from '@app/features/ui/state/SidebarPreferences';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -116,11 +117,12 @@ export const FluxerButton = observer(() => {
|
||||
/>
|
||||
));
|
||||
}, []);
|
||||
const indicatorHeight = (() => {
|
||||
if (isSelected) return 40;
|
||||
if (isHovering) return 20;
|
||||
return 8;
|
||||
})();
|
||||
const indicatorHeight =
|
||||
(() => {
|
||||
if (isSelected) return 40;
|
||||
if (isHovering) return 20;
|
||||
return 8;
|
||||
})() * getAppZoomFactor();
|
||||
const isActive = isHovering || isSelected;
|
||||
if (RuntimeConfig.directMessagesDisabled) {
|
||||
return null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type GuildReorderTarget,
|
||||
selectGuildReorderIntent,
|
||||
} from '@app/features/app/components/layout/dnd/GuildReorderStateMachine';
|
||||
import {useDragTargetRect} from '@app/features/app/components/layout/dnd/useDragTargetRect';
|
||||
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import styles from '@app/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css';
|
||||
import {GuildListItem} from '@app/features/app/components/layout/sidebar_nav/GuildListItem';
|
||||
@@ -27,6 +28,7 @@ import {MentionBadgeAnimated} from '@app/features/ui/components/MentionBadge';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import MediaEngine from '@app/features/voice/engine/MediaEngineFacade';
|
||||
import {useVoiceGatewayStateVersion} from '@app/features/voice/engine/v2/VoiceEngineV2AppVoiceStateAdapter';
|
||||
@@ -205,12 +207,11 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
const itemRef = useRef<HTMLElement | null>(null);
|
||||
const mobileLayout = MobileLayout;
|
||||
const [dropIndicator, setDropIndicator] = useState<'top' | 'bottom' | 'inside' | null>(null);
|
||||
const dropTargetRectRef = useRef<DOMRect | null>(null);
|
||||
const getDropTargetRect = useDragTargetRect(itemRef);
|
||||
const setFolderDropIndicator = useCallback((indicator: 'top' | 'bottom' | 'inside' | null) => {
|
||||
setDropIndicator((current) => (current === indicator ? current : indicator));
|
||||
}, []);
|
||||
const resetFolderDropIndicator = useCallback(() => {
|
||||
dropTargetRectRef.current = null;
|
||||
setFolderDropIndicator(null);
|
||||
}, [setFolderDropIndicator]);
|
||||
const derivedFolderName = useMemo(() => {
|
||||
@@ -323,12 +324,10 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
resetFolderDropIndicator();
|
||||
return;
|
||||
}
|
||||
const node = itemRef.current;
|
||||
if (!node) return;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
const boundingRect = dropTargetRectRef.current ?? node.getBoundingClientRect();
|
||||
dropTargetRectRef.current = boundingRect;
|
||||
const boundingRect = getDropTargetRect();
|
||||
if (!boundingRect) return;
|
||||
const intent = selectGuildReorderIntent(item, dropTargetData, clientOffset, boundingRect);
|
||||
if (!intent || intent.indicator === 'combine') {
|
||||
resetFolderDropIndicator();
|
||||
@@ -360,7 +359,7 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
isOver: monitor.isOver({shallow: true}),
|
||||
}),
|
||||
}),
|
||||
[dropTargetData, onGuildDrop, resetFolderDropIndicator, setFolderDropIndicator],
|
||||
[dropTargetData, getDropTargetRect, onGuildDrop, resetFolderDropIndicator, setFolderDropIndicator],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isOver) resetFolderDropIndicator();
|
||||
@@ -409,11 +408,12 @@ export const GuildFolderItem = observer((props: GuildFolderItemProps) => {
|
||||
[folder, guilds],
|
||||
);
|
||||
const shouldShowHoverState = isHovering;
|
||||
const indicatorHeight = (() => {
|
||||
if (isSelected) return 40;
|
||||
if (shouldShowHoverState) return 20;
|
||||
return 8;
|
||||
})();
|
||||
const indicatorHeight =
|
||||
(() => {
|
||||
if (isSelected) return 40;
|
||||
if (shouldShowHoverState) return 20;
|
||||
return 8;
|
||||
})() * getAppZoomFactor();
|
||||
const prefersReducedMotion = Accessibility.useReducedMotion;
|
||||
const firstFourGuilds = guilds.slice(0, 4);
|
||||
const showCollapsedIcon = shouldShowCollapsedFolderIcon(folder.flags);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {isMobileExperienceEnabled} from '@app/features/ui/utils/MobileExperience';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
|
||||
@@ -70,6 +71,8 @@ interface DMListItemProps {
|
||||
isSelected: boolean;
|
||||
className?: string;
|
||||
voiceCallActive?: boolean;
|
||||
onHoverStart: (channelId: string) => void;
|
||||
onHoverEnd: (channelId: string) => void;
|
||||
}
|
||||
|
||||
interface ResolvedDMListItemProps extends DMListItemProps {
|
||||
@@ -94,6 +97,8 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
isSelected,
|
||||
className,
|
||||
voiceCallActive = false,
|
||||
onHoverStart,
|
||||
onHoverEnd,
|
||||
isGroupDM,
|
||||
recipient,
|
||||
}: ResolvedDMListItemProps) {
|
||||
@@ -233,6 +238,12 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
handleOpenBottomSheet();
|
||||
}
|
||||
}, [isMobileExperience, handleOpenBottomSheet]);
|
||||
const handleHoverStart = useCallback(() => {
|
||||
onHoverStart(channel.id);
|
||||
}, [channel.id, onHoverStart]);
|
||||
const handleHoverEnd = useCallback(() => {
|
||||
onHoverEnd(channel.id);
|
||||
}, [channel.id, onHoverEnd]);
|
||||
const handleContextMenu = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -259,11 +270,12 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
},
|
||||
[channel, isGroupDM, recipient, isMobileExperience],
|
||||
);
|
||||
const indicatorHeight = (() => {
|
||||
if (isSelected) return 40;
|
||||
if (isHovering) return 20;
|
||||
return 8;
|
||||
})();
|
||||
const indicatorHeight =
|
||||
(() => {
|
||||
if (isSelected) return 40;
|
||||
if (isHovering) return 20;
|
||||
return 8;
|
||||
})() * getAppZoomFactor();
|
||||
const tooltipContent = useMemo<string | (() => React.ReactNode)>(() => {
|
||||
if (!hasVoiceActivity) {
|
||||
return displayName;
|
||||
@@ -344,6 +356,8 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
data-guild-list-focus-item="true"
|
||||
onClick={handleSelect}
|
||||
onContextMenu={handleContextMenu}
|
||||
onMouseEnter={handleHoverStart}
|
||||
onMouseLeave={handleHoverEnd}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
ref={mergedButtonRef}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type GuildReorderTarget,
|
||||
selectGuildReorderIntent,
|
||||
} from '@app/features/app/components/layout/dnd/GuildReorderStateMachine';
|
||||
import {useDragTargetRect} from '@app/features/app/components/layout/dnd/useDragTargetRect';
|
||||
import styles from '@app/features/app/components/layout/GuildsLayout.module.css';
|
||||
import type {ScrollIndicatorSeverity} from '@app/features/app/components/layout/ScrollIndicatorOverlay';
|
||||
import {VoiceBadge, type VoiceBadgeActivity} from '@app/features/app/components/layout/sidebar_nav/VoiceBadge';
|
||||
@@ -44,6 +45,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {KeybindHint} from '@app/features/ui/keybind_hint/KeybindHint';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {isMobileExperienceEnabled} from '@app/features/ui/utils/MobileExperience';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import UserGuildSettings from '@app/features/user/state/UserGuildSettings';
|
||||
@@ -349,13 +351,18 @@ export const GuildListItem = observer(
|
||||
: 'voice';
|
||||
const iconUrl = AvatarUtils.getGuildIconURL(guild, false);
|
||||
const hoverIconUrl = AvatarUtils.getGuildIconURL(guild, true);
|
||||
const [isStaticLoaded, setIsStaticLoaded] = useState(ImageCacheUtils.hasImage(iconUrl));
|
||||
const [isAnimatedLoaded, setIsAnimatedLoaded] = useState(ImageCacheUtils.hasImage(hoverIconUrl));
|
||||
const [shouldPlayAnimated, setShouldPlayAnimated] = useState(false);
|
||||
const [loadedIconUrl, setLoadedIconUrl] = useState<string | null>(() =>
|
||||
ImageCacheUtils.hasImage(iconUrl) ? iconUrl : null,
|
||||
);
|
||||
const [loadedHoverIconUrl, setLoadedHoverIconUrl] = useState<string | null>(() =>
|
||||
ImageCacheUtils.hasImage(hoverIconUrl) ? hoverIconUrl : null,
|
||||
);
|
||||
const isStaticLoaded = iconUrl.length > 0 && loadedIconUrl === iconUrl;
|
||||
const isAnimatedLoaded = hoverIconUrl.length > 0 && loadedHoverIconUrl === hoverIconUrl;
|
||||
const [dropIndicator, setDropIndicator] = useState<'top' | 'bottom' | 'combine' | null>(null);
|
||||
const [combineSourceGuildId, setCombineSourceGuildId] = useState<string | null>(null);
|
||||
const itemRef = useRef<HTMLElement | null>(null);
|
||||
const dropTargetRectRef = useRef<DOMRect | null>(null);
|
||||
const getDropTargetRect = useDragTargetRect(itemRef);
|
||||
const setGuildDropState = useCallback(
|
||||
(indicator: 'top' | 'bottom' | 'combine' | null, sourceGuildId: string | null) => {
|
||||
setDropIndicator((current) => (current === indicator ? current : indicator));
|
||||
@@ -364,7 +371,6 @@ export const GuildListItem = observer(
|
||||
[],
|
||||
);
|
||||
const resetGuildDropState = useCallback(() => {
|
||||
dropTargetRectRef.current = null;
|
||||
setGuildDropState(null, null);
|
||||
}, [setGuildDropState]);
|
||||
const dragItemData = useMemo<GuildDragItem>(
|
||||
@@ -411,12 +417,10 @@ export const GuildListItem = observer(
|
||||
resetGuildDropState();
|
||||
return;
|
||||
}
|
||||
const node = itemRef.current;
|
||||
if (!node) return;
|
||||
const clientOffset = monitor.getClientOffset();
|
||||
if (!clientOffset) return;
|
||||
const boundingRect = dropTargetRectRef.current ?? node.getBoundingClientRect();
|
||||
dropTargetRectRef.current = boundingRect;
|
||||
const boundingRect = getDropTargetRect();
|
||||
if (!boundingRect) return;
|
||||
const intent = selectGuildReorderIntent(item, dropTargetData, clientOffset, boundingRect);
|
||||
if (!intent || intent.indicator === 'inside') {
|
||||
resetGuildDropState();
|
||||
@@ -448,7 +452,7 @@ export const GuildListItem = observer(
|
||||
isOver: monitor.isOver({shallow: true}),
|
||||
}),
|
||||
}),
|
||||
[dropTargetData, onGuildDrop, resetGuildDropState, setGuildDropState],
|
||||
[dropTargetData, getDropTargetRect, onGuildDrop, resetGuildDropState, setGuildDropState],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!isOver) {
|
||||
@@ -460,14 +464,26 @@ export const GuildListItem = observer(
|
||||
}, [preview]);
|
||||
const contextMenuOpen = useContextMenuHoverState(itemRef, !mobileLayout.enabled);
|
||||
useEffect(() => {
|
||||
ImageCacheUtils.loadImage(iconUrl, () => setIsStaticLoaded(true));
|
||||
if (isHovering || contextMenuOpen) {
|
||||
ImageCacheUtils.loadImage(hoverIconUrl, () => setIsAnimatedLoaded(true));
|
||||
let active = true;
|
||||
let cleanupIconLoad: () => void = () => {};
|
||||
if (iconUrl.length > 0 && !isStaticLoaded) {
|
||||
cleanupIconLoad = ImageCacheUtils.loadImage(iconUrl, () => {
|
||||
if (active) setLoadedIconUrl(iconUrl);
|
||||
});
|
||||
}
|
||||
let cleanupHoverIconLoad: () => void = () => {};
|
||||
if ((isHovering || contextMenuOpen) && hoverIconUrl.length > 0 && !isAnimatedLoaded) {
|
||||
cleanupHoverIconLoad = ImageCacheUtils.loadImage(hoverIconUrl, () => {
|
||||
if (active) setLoadedHoverIconUrl(hoverIconUrl);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
active = false;
|
||||
cleanupIconLoad();
|
||||
cleanupHoverIconLoad();
|
||||
};
|
||||
}, [iconUrl, hoverIconUrl, isHovering, contextMenuOpen]);
|
||||
useEffect(() => {
|
||||
setShouldPlayAnimated((isHovering || contextMenuOpen) && isAnimatedLoaded);
|
||||
}, [isHovering, isAnimatedLoaded, contextMenuOpen]);
|
||||
const shouldPlayAnimated = (isHovering || contextMenuOpen) && isAnimatedLoaded;
|
||||
const handleSelect = () => {
|
||||
preloadChannelNow();
|
||||
NavigationCommands.selectGuild(guild.id, isMobileExperience ? undefined : selectedChannel);
|
||||
@@ -503,11 +519,12 @@ export const GuildListItem = observer(
|
||||
}
|
||||
}, [handleOpenBottomSheet, isMobileExperience, isSortingList]);
|
||||
const shouldShowHoverState = isHovering || contextMenuOpen;
|
||||
const indicatorHeight = (() => {
|
||||
if (isSelected) return 40;
|
||||
if (shouldShowHoverState) return 20;
|
||||
return 8;
|
||||
})();
|
||||
const indicatorHeight =
|
||||
(() => {
|
||||
if (isSelected) return 40;
|
||||
if (shouldShowHoverState) return 20;
|
||||
return 8;
|
||||
})() * getAppZoomFactor();
|
||||
const isActive = isSelected || shouldShowHoverState;
|
||||
const focusableRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusRingTargetRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -275,12 +275,12 @@ const SERVICES_NONE_DESCRIPTOR = msg({
|
||||
comment: 'Message shown when no optional services are available in the setup wizard.',
|
||||
});
|
||||
const SERVICE_GIF_LABEL_DESCRIPTOR = msg({
|
||||
message: 'KLIPY GIFs',
|
||||
comment: 'Label for the KLIPY GIF service toggle in the setup wizard.',
|
||||
message: 'Klipy GIFs',
|
||||
comment: 'Label for the Klipy GIF service toggle in the setup wizard.',
|
||||
});
|
||||
const SERVICE_GIF_DESC_DESCRIPTOR = msg({
|
||||
message: 'Let people search and send GIFs powered by KLIPY.',
|
||||
comment: 'Description for the KLIPY GIF service toggle in the setup wizard.',
|
||||
message: 'Let people search and send GIFs powered by Klipy.',
|
||||
comment: 'Description for the Klipy GIF service toggle in the setup wizard.',
|
||||
});
|
||||
const SERVICE_YOUTUBE_LABEL_DESCRIPTOR = msg({
|
||||
message: 'YouTube enrichment',
|
||||
@@ -888,7 +888,7 @@ const GIF_SETUP_TITLE_DESCRIPTOR = msg({
|
||||
comment: 'Setup wizard title for GIF integration credentials.',
|
||||
});
|
||||
const GIF_SETUP_BODY_DESCRIPTOR = msg({
|
||||
message: 'Add a KLIPY API key to enable GIF search at runtime.',
|
||||
message: 'Add a Klipy API key to enable GIF search at runtime.',
|
||||
comment: 'Setup wizard body for GIF integration credentials.',
|
||||
});
|
||||
const YOUTUBE_SETUP_TITLE_DESCRIPTOR = msg({
|
||||
|
||||
@@ -8,10 +8,15 @@ import {getGroupDMAccentColor} from '@app/features/channel/utils/GroupDMColorUti
|
||||
import {cdnUrl} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import Presence from '@app/features/presence/state/Presence';
|
||||
import TransientPresence from '@app/features/presence/state/TransientPresence';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import type {AvatarStatusLayout} from '@app/features/ui/components/AvatarStatusLayout';
|
||||
import {getAvatarStatusLayout} from '@app/features/ui/components/AvatarStatusLayout';
|
||||
import baseAvatarStyles from '@app/features/ui/components/BaseAvatar.module.css';
|
||||
import {TYPING_BRIDGE_RIGHT_SHIFT_RATIO} from '@app/features/ui/constants/TypingConstants';
|
||||
import {
|
||||
TYPING_BRIDGE_RIGHT_SHIFT_RATIO,
|
||||
TYPING_DOT_GAP_RATIO,
|
||||
TYPING_DOT_SIZE_RATIO,
|
||||
} from '@app/features/ui/constants/TypingConstants';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
@@ -72,11 +77,11 @@ function renderGroupStatusDot(status: StatusType, size: number, isTyping?: boole
|
||||
<div
|
||||
className={styles.statusDot}
|
||||
style={{
|
||||
right: bubbleRight,
|
||||
bottom: bubbleBottom,
|
||||
width: bubbleWidth,
|
||||
height: bubbleHeight,
|
||||
borderRadius: bubbleHeight / 2,
|
||||
right: remFromPx(bubbleRight),
|
||||
bottom: remFromPx(bubbleBottom),
|
||||
width: remFromPx(bubbleWidth),
|
||||
height: remFromPx(bubbleHeight),
|
||||
borderRadius: remFromPx(bubbleHeight / 2),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -99,14 +104,26 @@ function renderGroupStatusDot(status: StatusType, size: number, isTyping?: boole
|
||||
}}
|
||||
data-flx="app.group-dm-avatar.render-group-status-dot.div"
|
||||
>
|
||||
<div className={baseAvatarStyles.typingDots} data-flx="app.group-dm-avatar.render-group-status-dot.div--2">
|
||||
<div
|
||||
className={baseAvatarStyles.typingDots}
|
||||
style={{
|
||||
gap: remFromPx(
|
||||
Math.round(Math.min(layout.innerTypingWidth, layout.innerTypingHeight) * TYPING_DOT_GAP_RATIO),
|
||||
),
|
||||
}}
|
||||
data-flx="app.group-dm-avatar.render-group-status-dot.div--2"
|
||||
>
|
||||
{[0, 0.25, 0.5].map((delay, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={baseAvatarStyles.typingDot}
|
||||
style={{
|
||||
width: Math.min(layout.innerStatusWidth, layout.innerStatusHeight) * 0.25,
|
||||
height: Math.min(layout.innerStatusWidth, layout.innerStatusHeight) * 0.25,
|
||||
width: remFromPx(
|
||||
Math.round(Math.min(layout.innerTypingWidth, layout.innerTypingHeight) * TYPING_DOT_SIZE_RATIO),
|
||||
),
|
||||
height: remFromPx(
|
||||
Math.round(Math.min(layout.innerTypingWidth, layout.innerTypingHeight) * TYPING_DOT_SIZE_RATIO),
|
||||
),
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '50%',
|
||||
animationDelay: `${delay}s`,
|
||||
@@ -265,7 +282,11 @@ export const GroupDMAvatar: React.FC<GroupDMAvatarProps> = observer(
|
||||
? renderGroupStatusDot(statusForIndicator, size, shouldShowTypingIndicator, i18n)
|
||||
: null;
|
||||
return (
|
||||
<div className={styles.container} style={{width: size, height: size}} data-flx="app.group-dm-avatar.container">
|
||||
<div
|
||||
className={styles.container}
|
||||
style={{width: remFromPx(size), height: remFromPx(size)}}
|
||||
data-flx="app.group-dm-avatar.container"
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className={styles.iconImageContainer}
|
||||
@@ -321,8 +342,8 @@ export const GroupDMAvatar: React.FC<GroupDMAvatarProps> = observer(
|
||||
<div
|
||||
className={styles.defaultIconContainer}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
width: remFromPx(size),
|
||||
height: remFromPx(size),
|
||||
backgroundColor: accentColor,
|
||||
}}
|
||||
data-flx="app.group-dm-avatar.default-icon-container"
|
||||
@@ -330,7 +351,7 @@ export const GroupDMAvatar: React.FC<GroupDMAvatarProps> = observer(
|
||||
<UsersIcon
|
||||
weight="fill"
|
||||
className={styles.defaultIcon}
|
||||
style={{width: size * 0.5, height: size * 0.5}}
|
||||
style={{width: remFromPx(size * 0.5), height: remFromPx(size * 0.5)}}
|
||||
data-flx="app.group-dm-avatar.default-icon"
|
||||
/>
|
||||
{statusDot}
|
||||
@@ -353,7 +374,7 @@ export const GroupDMAvatar: React.FC<GroupDMAvatarProps> = observer(
|
||||
return (
|
||||
<div
|
||||
className={styles.multiAvatarContainer}
|
||||
style={{width: clusterSize, height: clusterSize}}
|
||||
style={{width: remFromPx(clusterSize), height: remFromPx(clusterSize)}}
|
||||
data-flx="app.group-dm-avatar.multi-avatar-container"
|
||||
>
|
||||
<svg
|
||||
|
||||
+3
-2
@@ -12,6 +12,7 @@ import UnicodeEmojis from '@app/features/expressions/utils/UnicodeEmojis';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {setUrlQueryParams} from '@app/features/messaging/utils/MessagingUrlUtils';
|
||||
import {usePresenceCustomStatus} from '@app/features/presence/hooks/usePresenceCustomStatus';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {EmojiTooltipContent} from '@app/features/ui/emoji_tooltip_content/EmojiTooltipContent';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {useTextOverflow} from '@app/features/ui/hooks/useTextOverflow';
|
||||
@@ -299,7 +300,7 @@ export const CustomStatusDisplay = observer(
|
||||
data-flx="app.custom-status-display.custom-status-display.placeholder.edit.button"
|
||||
>
|
||||
<SmileyIcon
|
||||
size={14}
|
||||
size={remFromPx(14)}
|
||||
weight="regular"
|
||||
className={styles.placeholderIcon}
|
||||
data-flx="app.custom-status-display.custom-status-display.placeholder-icon"
|
||||
@@ -397,7 +398,7 @@ export const CustomStatusDisplay = observer(
|
||||
</div>
|
||||
{isEmojiOnly && (
|
||||
<PencilIcon
|
||||
size={12}
|
||||
size={remFromPx(12)}
|
||||
weight="bold"
|
||||
className={styles.editPencilIcon}
|
||||
data-flx="app.custom-status-display.custom-status-display.edit-pencil-icon"
|
||||
|
||||
@@ -6,6 +6,7 @@ import styles from '@app/features/app/components/whats_new/WhatsNewModal.module.
|
||||
import {BLUESKY_PROVIDER_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {SafeMarkdown} from '@app/features/messaging/components/markdown';
|
||||
import {MarkdownContext} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import markupStyles from '@app/features/theme/styles/Markup.module.css';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -154,7 +155,11 @@ export function WhatsNewModal({entry}: WhatsNewModalProps) {
|
||||
data-flx="app.whats-new.whats-new-modal.scroll-hint.scroll-to-bottom.button"
|
||||
>
|
||||
<div className={styles.scrollHintCircle} data-flx="app.whats-new.whats-new-modal.scroll-hint-circle">
|
||||
<CaretDownIcon size={16} weight="bold" data-flx="app.whats-new.whats-new-modal.caret-down-icon" />
|
||||
<CaretDownIcon
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="app.whats-new.whats-new-modal.caret-down-icon"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,12 @@ export interface AnimatedImageDecoderState {
|
||||
naturalHeight: number;
|
||||
}
|
||||
|
||||
interface DecoderSourceIdentity {
|
||||
src: string | null;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
maxCachedFrames: number | undefined;
|
||||
}
|
||||
|
||||
export interface AnimatedImageFrameAdvanceState {
|
||||
frameIndex: number;
|
||||
frameCount: number;
|
||||
@@ -37,17 +43,43 @@ export interface AnimatedImageFrameAdvanceResult {
|
||||
|
||||
const DEFAULT_FRAME_DURATION_MS = 100;
|
||||
const DEFAULT_MAX_CACHED_FRAMES = 24;
|
||||
const MAX_CACHED_FRAME_BYTES = 64 * 1024 * 1024;
|
||||
const MAXIMUM_FRAME_TIMER_DELAY_MS = 2_147_000_000;
|
||||
const MAX_ANIMATED_IMAGE_ENCODED_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_ANIMATED_IMAGE_RESPONSE_CHUNKS = 4096;
|
||||
const ANIMATED_IMAGE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
function createIdleDecoderState(): AnimatedImageDecoderState {
|
||||
return {
|
||||
supported: getImageDecoderConstructor() !== null,
|
||||
loaded: false,
|
||||
error: false,
|
||||
naturalWidth: 0,
|
||||
naturalHeight: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function sameDecoderSourceIdentity(current: DecoderSourceIdentity, next: DecoderSourceIdentity): boolean {
|
||||
return current.src === next.src && current.canvas === next.canvas && current.maxCachedFrames === next.maxCachedFrames;
|
||||
}
|
||||
|
||||
interface CachedAnimatedImageFrame {
|
||||
image: CanvasImageSource;
|
||||
width: number;
|
||||
height: number;
|
||||
byteSize: number;
|
||||
durationMs: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
interface AnimatedImageResponseData {
|
||||
type: string;
|
||||
encodedBytes: Uint8Array;
|
||||
}
|
||||
|
||||
const guessMimeFromUrl = (url: string): string => {
|
||||
const lower = url.split('?')[0]?.toLowerCase() ?? '';
|
||||
const path = url.split('?')[0];
|
||||
const lower = path === undefined ? '' : path.toLowerCase();
|
||||
if (lower.endsWith('.webp')) return 'image/webp';
|
||||
if (lower.endsWith('.gif')) return 'image/gif';
|
||||
if (lower.endsWith('.apng') || lower.endsWith('.png')) return 'image/png';
|
||||
@@ -55,6 +87,97 @@ const guessMimeFromUrl = (url: string): string => {
|
||||
return 'image/webp';
|
||||
};
|
||||
|
||||
function resolveMimeFromResponse(contentType: string | null, source: string): string {
|
||||
if (contentType === null) return guessMimeFromUrl(source);
|
||||
const separator = contentType.indexOf(';');
|
||||
const mediaType = separator < 0 ? contentType : contentType.slice(0, separator);
|
||||
const normalized = mediaType.trim().toLowerCase();
|
||||
return normalized.length === 0 ? guessMimeFromUrl(source) : normalized;
|
||||
}
|
||||
|
||||
function decodedFrameByteSize(width: number, height: number): number | null {
|
||||
if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height)) return null;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
const byteSize = width * height * 4;
|
||||
if (!Number.isSafeInteger(byteSize)) return null;
|
||||
return byteSize;
|
||||
}
|
||||
|
||||
function frameDurationMs(durationMicroseconds: number | null | undefined): number {
|
||||
if (durationMicroseconds === null || durationMicroseconds === undefined) return DEFAULT_FRAME_DURATION_MS;
|
||||
if (!Number.isFinite(durationMicroseconds) || durationMicroseconds <= 0) return DEFAULT_FRAME_DURATION_MS;
|
||||
return Math.min(MAXIMUM_FRAME_TIMER_DELAY_MS, Math.max(16, durationMicroseconds / 1000));
|
||||
}
|
||||
|
||||
function cancelResponseBody(response: Response): void {
|
||||
const body = response.body;
|
||||
if (body == null) return;
|
||||
try {
|
||||
void body.cancel().catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function closeVideoFrame(image: VideoFrame): void {
|
||||
try {
|
||||
image.close();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function readBoundedResponseBytes(response: Response): Promise<Uint8Array> {
|
||||
const body = response.body;
|
||||
if (body == null) throw new Error('Animated image response did not provide a body');
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength !== null) {
|
||||
const maximumContentLengthDigits = Number.MAX_SAFE_INTEGER.toString().length;
|
||||
if (
|
||||
contentLength.length === 0 ||
|
||||
contentLength.length > maximumContentLengthDigits ||
|
||||
!/^[0-9]+$/.test(contentLength)
|
||||
) {
|
||||
cancelResponseBody(response);
|
||||
throw new Error('Animated image response has an invalid Content-Length header');
|
||||
}
|
||||
const declaredBytes = Number(contentLength);
|
||||
if (!Number.isSafeInteger(declaredBytes) || declaredBytes > MAX_ANIMATED_IMAGE_ENCODED_BYTES) {
|
||||
cancelResponseBody(response);
|
||||
throw new Error('Animated image response exceeded its byte limit');
|
||||
}
|
||||
}
|
||||
const reader = body.getReader();
|
||||
const chunks: Array<Uint8Array> = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const result = await reader.read();
|
||||
if (result.done) break;
|
||||
const chunk = result.value;
|
||||
if (chunk == null) continue;
|
||||
if (chunks.length >= MAX_ANIMATED_IMAGE_RESPONSE_CHUNKS) {
|
||||
throw new Error('Animated image response exceeded its chunk limit');
|
||||
}
|
||||
totalBytes += chunk.byteLength;
|
||||
if (totalBytes > MAX_ANIMATED_IMAGE_ENCODED_BYTES) {
|
||||
throw new Error('Animated image response exceeded its byte limit');
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {}
|
||||
throw error;
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function normalizeRepetitionCount(repetitionCount: number): number {
|
||||
if (repetitionCount === Infinity) return Infinity;
|
||||
if (!Number.isFinite(repetitionCount)) return 0;
|
||||
@@ -63,18 +186,21 @@ function normalizeRepetitionCount(repetitionCount: number): number {
|
||||
|
||||
async function createCachedAnimatedImageFrame(
|
||||
image: VideoFrame,
|
||||
byteSize: number,
|
||||
durationMs: number,
|
||||
ownerWindow: Window,
|
||||
): Promise<CachedAnimatedImageFrame> {
|
||||
const width = image.displayWidth;
|
||||
const height = image.displayHeight;
|
||||
if (typeof createImageBitmap === 'function') {
|
||||
if (typeof ownerWindow.createImageBitmap === 'function') {
|
||||
try {
|
||||
const bitmap = await createImageBitmap(image as unknown as ImageBitmapSource);
|
||||
image.close();
|
||||
const bitmap = await ownerWindow.createImageBitmap(image as unknown as ImageBitmapSource);
|
||||
closeVideoFrame(image);
|
||||
return {
|
||||
image: bitmap,
|
||||
width,
|
||||
height,
|
||||
byteSize,
|
||||
durationMs,
|
||||
close: () => bitmap.close(),
|
||||
};
|
||||
@@ -84,6 +210,7 @@ async function createCachedAnimatedImageFrame(
|
||||
image: image as unknown as CanvasImageSource,
|
||||
width,
|
||||
height,
|
||||
byteSize,
|
||||
durationMs,
|
||||
close: () => image.close(),
|
||||
};
|
||||
@@ -113,13 +240,9 @@ export function useAnimatedImageDecoder({
|
||||
canvas,
|
||||
maxCachedFrames = DEFAULT_MAX_CACHED_FRAMES,
|
||||
}: UseAnimatedImageDecoderOptions): AnimatedImageDecoderState {
|
||||
const [state, setState] = useState<AnimatedImageDecoderState>(() => ({
|
||||
supported: getImageDecoderConstructor() !== null,
|
||||
loaded: false,
|
||||
error: false,
|
||||
naturalWidth: 0,
|
||||
naturalHeight: 0,
|
||||
}));
|
||||
const sourceIdentity: DecoderSourceIdentity = {src, canvas, maxCachedFrames};
|
||||
const [state, setState] = useState<AnimatedImageDecoderState>(createIdleDecoderState);
|
||||
const [stateIdentity, setStateIdentity] = useState<DecoderSourceIdentity>(sourceIdentity);
|
||||
const runnerRef = useRef<{
|
||||
kick: () => void;
|
||||
pause: () => void;
|
||||
@@ -127,12 +250,21 @@ export function useAnimatedImageDecoder({
|
||||
} | null>(null);
|
||||
const playingRef = useRef(playing);
|
||||
playingRef.current = playing;
|
||||
const stateIsCurrent = sameDecoderSourceIdentity(stateIdentity, sourceIdentity);
|
||||
const visibleState = stateIsCurrent ? state : createIdleDecoderState();
|
||||
useEffect(() => {
|
||||
setStateIdentity(sourceIdentity);
|
||||
const Ctor = getImageDecoderConstructor();
|
||||
setState(createIdleDecoderState);
|
||||
if (!Ctor || !src || !canvas) {
|
||||
if (!Ctor) setState((prev) => ({...prev, supported: false}));
|
||||
return;
|
||||
}
|
||||
const ownerWindow = canvas.ownerDocument.defaultView;
|
||||
if (ownerWindow == null) {
|
||||
setState((prev) => ({...prev, error: true, supported: false}));
|
||||
return;
|
||||
}
|
||||
const ctx = canvas.getContext('2d', {alpha: true});
|
||||
if (!ctx) {
|
||||
setState((prev) => ({...prev, error: true}));
|
||||
@@ -143,6 +275,7 @@ export function useAnimatedImageDecoder({
|
||||
: DEFAULT_MAX_CACHED_FRAMES;
|
||||
const runner = {cancelled: false, kick: () => {}, pause: () => {}};
|
||||
runnerRef.current = runner;
|
||||
const fetchController = new ownerWindow.AbortController();
|
||||
let decoder: FluxerImageDecoderInstance | null = null;
|
||||
let frameCount = Number.POSITIVE_INFINITY;
|
||||
let repetitionCount = 0;
|
||||
@@ -151,18 +284,54 @@ export function useAnimatedImageDecoder({
|
||||
let timer: number | null = null;
|
||||
let resolveTimer: (() => void) | null = null;
|
||||
let advancing = false;
|
||||
let cachedFrameBytes = 0;
|
||||
const frameCache = new Map<number, CachedAnimatedImageFrame>();
|
||||
setState((prev) => ({...prev, loaded: false, error: false, supported: true}));
|
||||
const isCurrentRunner = () => !runner.cancelled && runnerRef.current === runner;
|
||||
const publishState = (
|
||||
nextState: AnimatedImageDecoderState | ((previous: AnimatedImageDecoderState) => AnimatedImageDecoderState),
|
||||
) => {
|
||||
if (!isCurrentRunner()) return;
|
||||
setState(nextState);
|
||||
};
|
||||
const closeFrameCache = () => {
|
||||
for (const frame of frameCache.values()) {
|
||||
try {
|
||||
frame.close();
|
||||
} catch {}
|
||||
}
|
||||
frameCache.clear();
|
||||
cachedFrameBytes = 0;
|
||||
};
|
||||
const closeDecoder = () => {
|
||||
if (decoder == null) return;
|
||||
try {
|
||||
decoder.close();
|
||||
} catch {}
|
||||
decoder = null;
|
||||
};
|
||||
const failRunner = () => {
|
||||
if (runner.cancelled) return;
|
||||
publishState((prev) => ({...prev, error: true}));
|
||||
runner.cancelled = true;
|
||||
try {
|
||||
fetchController.abort();
|
||||
} catch {}
|
||||
clearTimer();
|
||||
closeFrameCache();
|
||||
closeDecoder();
|
||||
};
|
||||
publishState((prev) => ({...prev, loaded: false, error: false, supported: true}));
|
||||
const clearTimer = () => {
|
||||
if (timer != null) {
|
||||
window.clearTimeout(timer);
|
||||
ownerWindow.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
resolveTimer?.();
|
||||
const resolve = resolveTimer;
|
||||
resolveTimer = null;
|
||||
if (resolve != null) resolve();
|
||||
};
|
||||
const draw = (frame: CachedAnimatedImageFrame) => {
|
||||
if (runner.cancelled) return;
|
||||
const draw = (frame: CachedAnimatedImageFrame): boolean => {
|
||||
if (!isCurrentRunner()) return false;
|
||||
const w = frame.width;
|
||||
const h = frame.height;
|
||||
if (canvas.width !== w) canvas.width = w;
|
||||
@@ -170,38 +339,63 @@ export function useAnimatedImageDecoder({
|
||||
try {
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.drawImage(frame.image, 0, 0, w, h);
|
||||
} catch {}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const getFrame = async (index: number) => {
|
||||
const getFrame = async (index: number): Promise<CachedAnimatedImageFrame | null> => {
|
||||
const cached = frameCache.get(index);
|
||||
if (cached) {
|
||||
frameCache.delete(index);
|
||||
frameCache.set(index, cached);
|
||||
return cached;
|
||||
}
|
||||
if (!decoder) return null;
|
||||
const activeDecoder = decoder;
|
||||
if (activeDecoder == null) return null;
|
||||
let decodedImage: VideoFrame | null = null;
|
||||
try {
|
||||
const result = await decoder.decode({frameIndex: index, completeFramesOnly: true});
|
||||
if (runner.cancelled) {
|
||||
result.image.close();
|
||||
const result = await activeDecoder.decode({frameIndex: index, completeFramesOnly: true});
|
||||
decodedImage = result.image;
|
||||
if (!isCurrentRunner()) {
|
||||
closeVideoFrame(decodedImage);
|
||||
decodedImage = null;
|
||||
return null;
|
||||
}
|
||||
const durationMs = (result.image.duration ?? DEFAULT_FRAME_DURATION_MS * 1000) / 1000;
|
||||
const entry = await createCachedAnimatedImageFrame(result.image, durationMs);
|
||||
if (runner.cancelled) {
|
||||
const byteSize = decodedFrameByteSize(decodedImage.displayWidth, decodedImage.displayHeight);
|
||||
if (byteSize == null || byteSize > MAX_CACHED_FRAME_BYTES) {
|
||||
closeVideoFrame(decodedImage);
|
||||
decodedImage = null;
|
||||
failRunner();
|
||||
return null;
|
||||
}
|
||||
const durationMs = frameDurationMs(decodedImage.duration);
|
||||
const entry = await createCachedAnimatedImageFrame(decodedImage, byteSize, durationMs, ownerWindow);
|
||||
decodedImage = null;
|
||||
if (!isCurrentRunner()) {
|
||||
entry.close();
|
||||
return null;
|
||||
}
|
||||
frameCache.set(index, entry);
|
||||
while (frameCache.size > normalizedMaxCachedFrames) {
|
||||
cachedFrameBytes += entry.byteSize;
|
||||
while (frameCache.size > normalizedMaxCachedFrames || cachedFrameBytes > MAX_CACHED_FRAME_BYTES) {
|
||||
const oldestIndex = frameCache.keys().next().value;
|
||||
if (oldestIndex === undefined) break;
|
||||
const oldest = frameCache.get(oldestIndex);
|
||||
frameCache.delete(oldestIndex);
|
||||
oldest?.close();
|
||||
if (oldest != null) {
|
||||
cachedFrameBytes -= oldest.byteSize;
|
||||
try {
|
||||
oldest.close();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
} catch {
|
||||
if (decodedImage != null) {
|
||||
closeVideoFrame(decodedImage);
|
||||
}
|
||||
failRunner();
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -221,11 +415,14 @@ export function useAnimatedImageDecoder({
|
||||
if (!frame || runner.cancelled || !playingRef.current) return;
|
||||
frameIndex = next.frameIndex;
|
||||
completedRepetitions = next.completedRepetitions;
|
||||
draw(frame);
|
||||
if (!draw(frame)) {
|
||||
failRunner();
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
clearTimer();
|
||||
resolveTimer = resolve;
|
||||
timer = window.setTimeout(
|
||||
timer = ownerWindow.setTimeout(
|
||||
() => {
|
||||
timer = null;
|
||||
resolveTimer = null;
|
||||
@@ -247,31 +444,89 @@ export function useAnimatedImageDecoder({
|
||||
runner.pause = () => {
|
||||
clearTimer();
|
||||
};
|
||||
const loadAnimatedImage = async (): Promise<AnimatedImageResponseData | null> => {
|
||||
let response: Response | null = null;
|
||||
let timeout: number | null = null;
|
||||
const operation = (async (): Promise<AnimatedImageResponseData | null> => {
|
||||
const fetchedResponse = await ownerWindow.fetch(src, {
|
||||
cache: 'force-cache',
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
signal: fetchController.signal,
|
||||
});
|
||||
response = fetchedResponse;
|
||||
if (!fetchedResponse.ok || fetchedResponse.body == null) {
|
||||
cancelResponseBody(fetchedResponse);
|
||||
throw new Error('Animated image request failed');
|
||||
}
|
||||
const type = resolveMimeFromResponse(fetchedResponse.headers.get('content-type'), src);
|
||||
const isSupported = await Ctor.isTypeSupported(type).catch(() => false);
|
||||
if (!isCurrentRunner()) return null;
|
||||
if (!isSupported) {
|
||||
cancelResponseBody(fetchedResponse);
|
||||
return null;
|
||||
}
|
||||
const encodedBytes = await readBoundedResponseBytes(fetchedResponse);
|
||||
return {type, encodedBytes};
|
||||
})();
|
||||
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
||||
timeout = ownerWindow.setTimeout(() => {
|
||||
try {
|
||||
fetchController.abort();
|
||||
} catch {}
|
||||
if (response != null) cancelResponseBody(response);
|
||||
reject(new Error('Animated image request timed out'));
|
||||
}, ANIMATED_IMAGE_REQUEST_TIMEOUT_MS);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([operation, timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout != null) ownerWindow.clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
const start = async () => {
|
||||
try {
|
||||
const response = await fetch(src, {cache: 'force-cache'});
|
||||
if (runner.cancelled) return;
|
||||
if (!response.ok || !response.body) {
|
||||
setState((prev) => ({...prev, error: true}));
|
||||
const animatedImage = await loadAnimatedImage();
|
||||
if (!isCurrentRunner()) return;
|
||||
if (animatedImage == null) {
|
||||
publishState((prev) => ({...prev, supported: false}));
|
||||
runner.cancelled = true;
|
||||
return;
|
||||
}
|
||||
const type = response.headers.get('content-type') ?? guessMimeFromUrl(src);
|
||||
const isSupported = await Ctor.isTypeSupported(type).catch(() => false);
|
||||
if (!isSupported) {
|
||||
setState((prev) => ({...prev, supported: false}));
|
||||
return;
|
||||
const {type, encodedBytes} = animatedImage;
|
||||
const decoderBuffer = new ArrayBuffer(encodedBytes.byteLength);
|
||||
new Uint8Array(decoderBuffer).set(encodedBytes);
|
||||
const decoderBody = new ownerWindow.Response(decoderBuffer).body;
|
||||
if (decoderBody == null) throw new Error('Animated image decoder body was unavailable');
|
||||
const createdDecoder = new Ctor({data: decoderBody, type, preferAnimation: true});
|
||||
decoder = createdDecoder;
|
||||
await createdDecoder.completed;
|
||||
if (!isCurrentRunner()) return;
|
||||
const activeDecoder = decoder;
|
||||
if (activeDecoder == null) throw new Error('Animated image decoder was closed before completion');
|
||||
const track = activeDecoder.tracks.selectedTrack;
|
||||
if (track == null || !Number.isSafeInteger(track.frameCount) || track.frameCount < 1) {
|
||||
throw new Error('Animated image decoder returned an invalid frame count');
|
||||
}
|
||||
frameCount = track.frameCount;
|
||||
if (track.repetitionCount === undefined) {
|
||||
repetitionCount = 0;
|
||||
} else if (
|
||||
track.repetitionCount !== Infinity &&
|
||||
(!Number.isSafeInteger(track.repetitionCount) || track.repetitionCount < 0)
|
||||
) {
|
||||
throw new Error('Animated image decoder returned an invalid repetition count');
|
||||
} else {
|
||||
repetitionCount = track.repetitionCount;
|
||||
}
|
||||
if (runner.cancelled) return;
|
||||
decoder = new Ctor({data: response.body, type, preferAnimation: true});
|
||||
await decoder.completed;
|
||||
if (runner.cancelled) return;
|
||||
const track = decoder.tracks.selectedTrack;
|
||||
frameCount = track?.frameCount ?? 1;
|
||||
repetitionCount = track?.repetitionCount ?? 0;
|
||||
const first = await getFrame(0);
|
||||
if (!first || runner.cancelled) return;
|
||||
draw(first);
|
||||
setState({
|
||||
if (!first || !isCurrentRunner()) return;
|
||||
if (!draw(first)) {
|
||||
failRunner();
|
||||
return;
|
||||
}
|
||||
publishState({
|
||||
supported: true,
|
||||
loaded: true,
|
||||
error: false,
|
||||
@@ -282,27 +537,32 @@ export function useAnimatedImageDecoder({
|
||||
void advance();
|
||||
}
|
||||
} catch {
|
||||
if (!runner.cancelled) setState((prev) => ({...prev, error: true}));
|
||||
if (!isCurrentRunner()) return;
|
||||
failRunner();
|
||||
}
|
||||
};
|
||||
void start();
|
||||
return () => {
|
||||
runner.cancelled = true;
|
||||
try {
|
||||
fetchController.abort();
|
||||
} catch {}
|
||||
clearTimer();
|
||||
frameCache.forEach((frame) => frame.close());
|
||||
frameCache.clear();
|
||||
decoder?.close();
|
||||
closeFrameCache();
|
||||
closeDecoder();
|
||||
if (runnerRef.current === runner) runnerRef.current = null;
|
||||
};
|
||||
}, [canvas, maxCachedFrames, src]);
|
||||
useEffect(() => {
|
||||
const runner = runnerRef.current;
|
||||
if (runner == null) return;
|
||||
if (playing) {
|
||||
runnerRef.current?.kick();
|
||||
runner.kick();
|
||||
return;
|
||||
}
|
||||
runnerRef.current?.pause();
|
||||
runner.pause();
|
||||
}, [playing]);
|
||||
return state;
|
||||
return visibleState;
|
||||
}
|
||||
|
||||
export interface DecodedImageFrames {
|
||||
@@ -393,7 +653,9 @@ async function decodeAllFramesViaImageDecoder(
|
||||
try {
|
||||
await decoder.completed;
|
||||
const track = decoder.tracks.selectedTrack;
|
||||
const count = Math.max(1, track?.frameCount ?? 1);
|
||||
const trackFrameCount =
|
||||
track == null || !Number.isSafeInteger(track.frameCount) || track.frameCount < 1 ? 1 : track.frameCount;
|
||||
const count = Math.max(1, trackFrameCount);
|
||||
const frames: Array<ImageData> = [];
|
||||
const delays: Array<number> = [];
|
||||
let width = 0;
|
||||
@@ -412,7 +674,8 @@ async function decodeAllFramesViaImageDecoder(
|
||||
drawVideoFrameToCanvas(ctx, image);
|
||||
const data = ctx.getImageData(0, 0, w, h);
|
||||
frames.push(new ImageData(new Uint8ClampedArray(data.data), w, h));
|
||||
delays.push((image.duration ?? 0) / 1000);
|
||||
const duration = image.duration;
|
||||
delays.push(duration === null || duration === undefined ? 0 : duration / 1000);
|
||||
} finally {
|
||||
image.close();
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {type ShouldAnimateKind, useShouldAnimate} from '@app/features/app/hooks/
|
||||
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import type React from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
|
||||
interface UseAnimatedImageUrlOptions {
|
||||
staticUrl: string | null;
|
||||
@@ -21,35 +21,90 @@ interface AnimatedImageUrlState {
|
||||
showGifIndicator: boolean;
|
||||
}
|
||||
|
||||
type AnimatedImageLoadStatus = 'idle' | 'loading' | 'loaded' | 'failed';
|
||||
|
||||
interface AnimatedImageLoadState {
|
||||
url: string | null;
|
||||
status: AnimatedImageLoadStatus;
|
||||
}
|
||||
|
||||
export function useAnimatedImageUrl({
|
||||
staticUrl,
|
||||
animatedUrl,
|
||||
animatedUrl: providedAnimatedUrl,
|
||||
kind,
|
||||
isFocused,
|
||||
}: UseAnimatedImageUrlOptions): AnimatedImageUrlState {
|
||||
const animatedUrl: string | null = providedAnimatedUrl == null ? null : providedAnimatedUrl;
|
||||
const [hoverRef, isHovering] = useHover();
|
||||
const shouldAnimate = useShouldAnimate({kind, isHovering, isFocused});
|
||||
const gifAutoPlayEnabled = kind === 'gif' && UserSettings.getGifAutoPlay();
|
||||
const hasAnimatedUrl = Boolean(animatedUrl && animatedUrl !== staticUrl);
|
||||
const [isAnimatedLoaded, setIsAnimatedLoaded] = useState(() => ImageCacheUtils.hasImage(animatedUrl ?? null));
|
||||
const initialAnimatedLoadState: AnimatedImageLoadState = {
|
||||
url: animatedUrl,
|
||||
status: animatedUrl != null && ImageCacheUtils.hasImage(animatedUrl) ? 'loaded' : 'idle',
|
||||
};
|
||||
const [animatedLoadState, setAnimatedLoadState] = useState<AnimatedImageLoadState>(() => initialAnimatedLoadState);
|
||||
const animatedLoadStateRef = useRef<AnimatedImageLoadState>(initialAnimatedLoadState);
|
||||
const animatedUrlCached = animatedUrl != null && ImageCacheUtils.hasImage(animatedUrl);
|
||||
useEffect(() => {
|
||||
setIsAnimatedLoaded(ImageCacheUtils.hasImage(animatedUrl ?? null));
|
||||
const currentLoadState = animatedLoadStateRef.current;
|
||||
if (currentLoadState.url === animatedUrl) return;
|
||||
const idleState: AnimatedImageLoadState = {url: animatedUrl, status: 'idle'};
|
||||
animatedLoadStateRef.current = idleState;
|
||||
setAnimatedLoadState(idleState);
|
||||
}, [animatedUrl]);
|
||||
useEffect(() => {
|
||||
if (!shouldAnimate || !animatedUrl || isAnimatedLoaded) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
ImageCacheUtils.loadImage(animatedUrl, () => {
|
||||
if (!cancelled) {
|
||||
setIsAnimatedLoaded(true);
|
||||
}
|
||||
});
|
||||
if (!animatedUrlCached || animatedUrl == null) return;
|
||||
const currentLoadState = animatedLoadStateRef.current;
|
||||
if (currentLoadState.url === animatedUrl && currentLoadState.status === 'loaded') return;
|
||||
const loadedState: AnimatedImageLoadState = {url: animatedUrl, status: 'loaded'};
|
||||
animatedLoadStateRef.current = loadedState;
|
||||
setAnimatedLoadState(loadedState);
|
||||
}, [animatedUrl, animatedUrlCached]);
|
||||
useEffect(() => {
|
||||
if (!shouldAnimate || animatedUrl == null || animatedUrlCached) return;
|
||||
const currentLoadState = animatedLoadStateRef.current;
|
||||
if (currentLoadState.url === animatedUrl && currentLoadState.status === 'failed') return;
|
||||
if (currentLoadState.url === animatedUrl && currentLoadState.status === 'loading') return;
|
||||
if (currentLoadState.url === animatedUrl && currentLoadState.status === 'loaded') return;
|
||||
const loadingState: AnimatedImageLoadState = {url: animatedUrl, status: 'loading'};
|
||||
animatedLoadStateRef.current = loadingState;
|
||||
setAnimatedLoadState(loadingState);
|
||||
let active = true;
|
||||
const cleanup = ImageCacheUtils.loadImage(
|
||||
animatedUrl,
|
||||
() => {
|
||||
if (!active) return;
|
||||
const loadedState: AnimatedImageLoadState = {url: animatedUrl, status: 'loaded'};
|
||||
animatedLoadStateRef.current = loadedState;
|
||||
setAnimatedLoadState(loadedState);
|
||||
},
|
||||
() => {
|
||||
if (!active) return;
|
||||
const failedState: AnimatedImageLoadState = {url: animatedUrl, status: 'failed'};
|
||||
animatedLoadStateRef.current = failedState;
|
||||
setAnimatedLoadState(failedState);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
active = false;
|
||||
cleanup();
|
||||
};
|
||||
}, [animatedUrl, isAnimatedLoaded, shouldAnimate]);
|
||||
const imageUrl = shouldAnimate && animatedUrl && isAnimatedLoaded ? animatedUrl : staticUrl;
|
||||
}, [animatedUrl, animatedUrlCached, shouldAnimate]);
|
||||
useEffect(() => {
|
||||
if (shouldAnimate) return;
|
||||
const currentLoadState = animatedLoadStateRef.current;
|
||||
if (currentLoadState.status !== 'failed' && currentLoadState.status !== 'loading') return;
|
||||
const idleState: AnimatedImageLoadState = {url: animatedUrl, status: 'idle'};
|
||||
animatedLoadStateRef.current = idleState;
|
||||
setAnimatedLoadState(idleState);
|
||||
}, [animatedUrl, shouldAnimate]);
|
||||
const animatedSourceReady =
|
||||
shouldAnimate &&
|
||||
animatedUrl != null &&
|
||||
animatedLoadState.url === animatedUrl &&
|
||||
animatedLoadState.status === 'loaded';
|
||||
const imageUrl = animatedSourceReady ? animatedUrl : staticUrl;
|
||||
const showGifIndicator = kind === 'gif' && hasAnimatedUrl && !gifAutoPlayEnabled && !shouldAnimate;
|
||||
return {hoverRef, imageUrl, shouldAnimate, showGifIndicator};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import type {PopoutResizePositionSession} from '@app/features/ui/popover/PopoutResizePositionContext';
|
||||
import {appZoomCssPx, appZoomLayoutPx} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import {getAdaptivePadding} from '@app/features/ui/utils/Positioning';
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type ReferenceElement,
|
||||
shift,
|
||||
size,
|
||||
type VirtualElement,
|
||||
} from '@floating-ui/react';
|
||||
import {useCallback, useLayoutEffect, useRef, useState} from 'react';
|
||||
|
||||
@@ -29,18 +31,27 @@ const ANTI_SHIFT_AUTO_UPDATE_OPTIONS = {
|
||||
|
||||
type FloatingUpdateCallback = () => void | Promise<void>;
|
||||
|
||||
let sharedScrollListenersAttached = false;
|
||||
let sharedViewportListenersAttached = false;
|
||||
let sharedUpdateRaf: number | null = null;
|
||||
interface FloatingUpdateRegistry {
|
||||
ownerDocument: Document;
|
||||
ownerWindow: Window;
|
||||
scrollListenersAttached: boolean;
|
||||
viewportListenersAttached: boolean;
|
||||
updateRaf: number | null;
|
||||
scrollCallbacks: Set<FloatingUpdateCallback>;
|
||||
viewportCallbacks: Set<FloatingUpdateCallback>;
|
||||
scheduledCallbacks: Set<FloatingUpdateCallback>;
|
||||
handleScroll: () => void;
|
||||
handleViewport: () => void;
|
||||
}
|
||||
|
||||
const scrollUpdateCallbacks = new Set<FloatingUpdateCallback>();
|
||||
const viewportUpdateCallbacks = new Set<FloatingUpdateCallback>();
|
||||
const scheduledUpdateCallbacks = new Set<FloatingUpdateCallback>();
|
||||
const floatingUpdateRegistries = new WeakMap<Document, FloatingUpdateRegistry>();
|
||||
|
||||
interface FloatingState {
|
||||
x: number;
|
||||
y: number;
|
||||
isReady: boolean;
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
}
|
||||
|
||||
interface UseAntiShiftFloatingOptions {
|
||||
@@ -63,10 +74,10 @@ function normalizePadding(padding: Padding): {top: number; right: number; bottom
|
||||
return {top: padding, right: padding, bottom: padding, left: padding};
|
||||
}
|
||||
return {
|
||||
top: padding.top ?? 0,
|
||||
right: padding.right ?? 0,
|
||||
bottom: padding.bottom ?? 0,
|
||||
left: padding.left ?? 0,
|
||||
top: padding.top == null ? 0 : padding.top,
|
||||
right: padding.right == null ? 0 : padding.right,
|
||||
bottom: padding.bottom == null ? 0 : padding.bottom,
|
||||
left: padding.left == null ? 0 : padding.left,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,121 +89,148 @@ function clampToViewportBoundary(
|
||||
): {x: number; y: number} {
|
||||
const normalizedPadding = normalizePadding(padding);
|
||||
const rect = floating.getBoundingClientRect();
|
||||
const minX = normalizedPadding.left;
|
||||
const minY = normalizedPadding.top;
|
||||
const maxX = Math.max(minX, window.innerWidth - normalizedPadding.right - rect.width);
|
||||
const maxY = Math.max(minY, window.innerHeight - normalizedPadding.bottom - rect.height);
|
||||
const ownerWindow = floating.ownerDocument.defaultView;
|
||||
if (ownerWindow == null) return {x, y};
|
||||
const visualViewport = ownerWindow.visualViewport;
|
||||
const viewportLeft = visualViewport == null ? 0 : visualViewport.offsetLeft;
|
||||
const viewportTop = visualViewport == null ? 0 : visualViewport.offsetTop;
|
||||
const viewportWidth = visualViewport == null ? ownerWindow.innerWidth : visualViewport.width;
|
||||
const viewportHeight = visualViewport == null ? ownerWindow.innerHeight : visualViewport.height;
|
||||
const minX = viewportLeft + normalizedPadding.left;
|
||||
const minY = viewportTop + normalizedPadding.top;
|
||||
const maxX = Math.max(minX, viewportLeft + viewportWidth - normalizedPadding.right - rect.width);
|
||||
const maxY = Math.max(minY, viewportTop + viewportHeight - normalizedPadding.bottom - rect.height);
|
||||
return {
|
||||
x: clamp(x, minX, maxX),
|
||||
y: clamp(y, minY, maxY),
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleFloatingUpdates(callbacks: Iterable<FloatingUpdateCallback>): void {
|
||||
function scheduleFloatingUpdates(registry: FloatingUpdateRegistry, callbacks: Iterable<FloatingUpdateCallback>): void {
|
||||
for (const callback of callbacks) {
|
||||
scheduledUpdateCallbacks.add(callback);
|
||||
registry.scheduledCallbacks.add(callback);
|
||||
}
|
||||
if (scheduledUpdateCallbacks.size === 0 || sharedUpdateRaf != null) return;
|
||||
sharedUpdateRaf = requestAnimationFrame(() => {
|
||||
sharedUpdateRaf = null;
|
||||
const callbacksToRun = Array.from(scheduledUpdateCallbacks);
|
||||
scheduledUpdateCallbacks.clear();
|
||||
if (registry.scheduledCallbacks.size === 0 || registry.updateRaf != null) return;
|
||||
registry.updateRaf = registry.ownerWindow.requestAnimationFrame(() => {
|
||||
registry.updateRaf = null;
|
||||
const callbacksToRun = Array.from(registry.scheduledCallbacks);
|
||||
registry.scheduledCallbacks.clear();
|
||||
for (const callback of callbacksToRun) {
|
||||
void callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleSharedScrollUpdate(): void {
|
||||
scheduleFloatingUpdates(scrollUpdateCallbacks);
|
||||
function getFloatingUpdateRegistry(ownerDocument: Document): FloatingUpdateRegistry | null {
|
||||
const current = floatingUpdateRegistries.get(ownerDocument);
|
||||
if (current != null) return current;
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
if (ownerWindow == null) return null;
|
||||
let registry: FloatingUpdateRegistry;
|
||||
registry = {
|
||||
ownerDocument,
|
||||
ownerWindow,
|
||||
scrollListenersAttached: false,
|
||||
viewportListenersAttached: false,
|
||||
updateRaf: null,
|
||||
scrollCallbacks: new Set(),
|
||||
viewportCallbacks: new Set(),
|
||||
scheduledCallbacks: new Set(),
|
||||
handleScroll: () => scheduleFloatingUpdates(registry, registry.scrollCallbacks),
|
||||
handleViewport: () => {
|
||||
scheduleFloatingUpdates(registry, registry.scrollCallbacks);
|
||||
scheduleFloatingUpdates(registry, registry.viewportCallbacks);
|
||||
},
|
||||
};
|
||||
floatingUpdateRegistries.set(ownerDocument, registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
function handleSharedViewportUpdate(): void {
|
||||
scheduleFloatingUpdates(scrollUpdateCallbacks);
|
||||
scheduleFloatingUpdates(viewportUpdateCallbacks);
|
||||
function ensureSharedScrollListeners(registry: FloatingUpdateRegistry): void {
|
||||
if (registry.scrollListenersAttached) return;
|
||||
registry.ownerDocument.addEventListener('scroll', registry.handleScroll, {capture: true, passive: true});
|
||||
registry.scrollListenersAttached = true;
|
||||
}
|
||||
|
||||
function ensureSharedScrollListeners(): void {
|
||||
if (sharedScrollListenersAttached || typeof document === 'undefined') return;
|
||||
document.addEventListener('scroll', handleSharedScrollUpdate, {capture: true, passive: true});
|
||||
sharedScrollListenersAttached = true;
|
||||
}
|
||||
|
||||
function ensureSharedViewportListeners(): void {
|
||||
if (sharedViewportListenersAttached || typeof window === 'undefined') return;
|
||||
window.addEventListener('resize', handleSharedViewportUpdate);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', handleSharedViewportUpdate);
|
||||
window.visualViewport.addEventListener('scroll', handleSharedViewportUpdate);
|
||||
function ensureSharedViewportListeners(registry: FloatingUpdateRegistry): void {
|
||||
if (registry.viewportListenersAttached) return;
|
||||
registry.ownerWindow.addEventListener('resize', registry.handleViewport);
|
||||
const visualViewport = registry.ownerWindow.visualViewport;
|
||||
if (visualViewport != null) {
|
||||
visualViewport.addEventListener('resize', registry.handleViewport);
|
||||
visualViewport.addEventListener('scroll', registry.handleViewport);
|
||||
}
|
||||
sharedViewportListenersAttached = true;
|
||||
registry.viewportListenersAttached = true;
|
||||
}
|
||||
|
||||
function releaseSharedScrollListeners(): void {
|
||||
if (!sharedScrollListenersAttached || scrollUpdateCallbacks.size > 0 || typeof document === 'undefined') return;
|
||||
document.removeEventListener('scroll', handleSharedScrollUpdate, true);
|
||||
sharedScrollListenersAttached = false;
|
||||
function releaseSharedScrollListeners(registry: FloatingUpdateRegistry): void {
|
||||
if (!registry.scrollListenersAttached || registry.scrollCallbacks.size > 0) return;
|
||||
registry.ownerDocument.removeEventListener('scroll', registry.handleScroll, true);
|
||||
registry.scrollListenersAttached = false;
|
||||
}
|
||||
|
||||
function releaseSharedViewportListeners(): void {
|
||||
if (
|
||||
!sharedViewportListenersAttached ||
|
||||
scrollUpdateCallbacks.size > 0 ||
|
||||
viewportUpdateCallbacks.size > 0 ||
|
||||
typeof window === 'undefined'
|
||||
) {
|
||||
function releaseSharedViewportListeners(registry: FloatingUpdateRegistry): void {
|
||||
if (!registry.viewportListenersAttached || registry.scrollCallbacks.size > 0 || registry.viewportCallbacks.size > 0) {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener('resize', handleSharedViewportUpdate);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', handleSharedViewportUpdate);
|
||||
window.visualViewport.removeEventListener('scroll', handleSharedViewportUpdate);
|
||||
registry.ownerWindow.removeEventListener('resize', registry.handleViewport);
|
||||
const visualViewport = registry.ownerWindow.visualViewport;
|
||||
if (visualViewport != null) {
|
||||
visualViewport.removeEventListener('resize', registry.handleViewport);
|
||||
visualViewport.removeEventListener('scroll', registry.handleViewport);
|
||||
}
|
||||
sharedViewportListenersAttached = false;
|
||||
if (sharedUpdateRaf != null && scheduledUpdateCallbacks.size === 0) {
|
||||
cancelAnimationFrame(sharedUpdateRaf);
|
||||
sharedUpdateRaf = null;
|
||||
registry.viewportListenersAttached = false;
|
||||
if (registry.updateRaf != null && registry.scheduledCallbacks.size === 0) {
|
||||
registry.ownerWindow.cancelAnimationFrame(registry.updateRaf);
|
||||
registry.updateRaf = null;
|
||||
}
|
||||
if (!registry.scrollListenersAttached) floatingUpdateRegistries.delete(registry.ownerDocument);
|
||||
}
|
||||
|
||||
function removeScheduledFloatingUpdate(registry: FloatingUpdateRegistry, callback: FloatingUpdateCallback): void {
|
||||
registry.scheduledCallbacks.delete(callback);
|
||||
if (registry.updateRaf != null && registry.scheduledCallbacks.size === 0) {
|
||||
registry.ownerWindow.cancelAnimationFrame(registry.updateRaf);
|
||||
registry.updateRaf = null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeScheduledFloatingUpdate(callback: FloatingUpdateCallback): void {
|
||||
scheduledUpdateCallbacks.delete(callback);
|
||||
if (sharedUpdateRaf != null && scheduledUpdateCallbacks.size === 0) {
|
||||
cancelAnimationFrame(sharedUpdateRaf);
|
||||
sharedUpdateRaf = null;
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeSharedScrollUpdate(callback: FloatingUpdateCallback): () => void {
|
||||
scrollUpdateCallbacks.add(callback);
|
||||
ensureSharedScrollListeners();
|
||||
ensureSharedViewportListeners();
|
||||
function subscribeSharedScrollUpdate(ownerDocument: Document, callback: FloatingUpdateCallback): () => void {
|
||||
const registry = getFloatingUpdateRegistry(ownerDocument);
|
||||
if (registry == null) return () => {};
|
||||
registry.scrollCallbacks.add(callback);
|
||||
ensureSharedScrollListeners(registry);
|
||||
ensureSharedViewportListeners(registry);
|
||||
return () => {
|
||||
scrollUpdateCallbacks.delete(callback);
|
||||
removeScheduledFloatingUpdate(callback);
|
||||
releaseSharedScrollListeners();
|
||||
releaseSharedViewportListeners();
|
||||
registry.scrollCallbacks.delete(callback);
|
||||
removeScheduledFloatingUpdate(registry, callback);
|
||||
releaseSharedScrollListeners(registry);
|
||||
releaseSharedViewportListeners(registry);
|
||||
};
|
||||
}
|
||||
|
||||
function subscribeSharedViewportUpdate(callback: FloatingUpdateCallback): () => void {
|
||||
viewportUpdateCallbacks.add(callback);
|
||||
ensureSharedViewportListeners();
|
||||
function subscribeSharedViewportUpdate(ownerDocument: Document, callback: FloatingUpdateCallback): () => void {
|
||||
const registry = getFloatingUpdateRegistry(ownerDocument);
|
||||
if (registry == null) return () => {};
|
||||
registry.viewportCallbacks.add(callback);
|
||||
ensureSharedViewportListeners(registry);
|
||||
return () => {
|
||||
viewportUpdateCallbacks.delete(callback);
|
||||
removeScheduledFloatingUpdate(callback);
|
||||
releaseSharedViewportListeners();
|
||||
registry.viewportCallbacks.delete(callback);
|
||||
removeScheduledFloatingUpdate(registry, callback);
|
||||
releaseSharedViewportListeners(registry);
|
||||
};
|
||||
}
|
||||
|
||||
function observeFloatingResize(floating: HTMLElement, updatePosition: () => void): () => void {
|
||||
const cleanupCallbacks: Array<() => void> = [];
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const resizeObserver = new ResizeObserver(updatePosition);
|
||||
const ownerWindow = floating.ownerDocument.defaultView;
|
||||
const ResizeObserverConstructor = ownerWindow == null ? null : ownerWindow.ResizeObserver;
|
||||
if (ResizeObserverConstructor != null) {
|
||||
const resizeObserver = new ResizeObserverConstructor(updatePosition);
|
||||
resizeObserver.observe(floating);
|
||||
cleanupCallbacks.push(() => resizeObserver.disconnect());
|
||||
}
|
||||
cleanupCallbacks.push(subscribeSharedViewportUpdate(updatePosition));
|
||||
cleanupCallbacks.push(subscribeSharedViewportUpdate(floating.ownerDocument, updatePosition));
|
||||
return () => {
|
||||
for (const cleanup of cleanupCallbacks) {
|
||||
cleanup();
|
||||
@@ -200,23 +238,19 @@ function observeFloatingResize(floating: HTMLElement, updatePosition: () => void
|
||||
};
|
||||
}
|
||||
|
||||
function getNativeTitlebarInset(): number {
|
||||
if (typeof document === 'undefined') {
|
||||
return 0;
|
||||
}
|
||||
const titlebar = document.querySelector<HTMLElement>(TITLEBAR_SELECTOR);
|
||||
if (!titlebar) {
|
||||
return 0;
|
||||
}
|
||||
function getNativeTitlebarInset(ownerDocument: Document): number {
|
||||
const titlebar = ownerDocument.querySelector<HTMLElement>(TITLEBAR_SELECTOR);
|
||||
if (titlebar == null) return 0;
|
||||
const rect = titlebar.getBoundingClientRect();
|
||||
if (rect.height <= 0 || rect.bottom <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return rect.bottom;
|
||||
if (rect.height <= 0 || rect.bottom <= 0) return 0;
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
const visualViewport = ownerWindow == null ? null : ownerWindow.visualViewport;
|
||||
const viewportTop = visualViewport == null ? 0 : visualViewport.offsetTop;
|
||||
return Math.max(0, rect.bottom - viewportTop);
|
||||
}
|
||||
|
||||
function getBoundaryPadding(basePadding: number): Padding {
|
||||
const titlebarInset = getNativeTitlebarInset();
|
||||
function getBoundaryPadding(ownerDocument: Document, basePadding: number): Padding {
|
||||
const titlebarInset = getNativeTitlebarInset(ownerDocument);
|
||||
if (titlebarInset <= 0) {
|
||||
return basePadding;
|
||||
}
|
||||
@@ -246,24 +280,37 @@ export function useAntiShiftFloating(
|
||||
const floatingRef = useRef<HTMLElement>(null);
|
||||
const [state, setState] = useState<FloatingState>(() => {
|
||||
const {x, y} = target ? getInitialGuess(target, placement, offsetMainAxis, offsetCrossAxis) : {x: -9999, y: -9999};
|
||||
return {x, y, isReady: false};
|
||||
return {x, y, isReady: false, offsetX: 0, offsetY: 0};
|
||||
});
|
||||
const cleanupRef = useRef<(() => void) | null>(null);
|
||||
const isCalculatingRef = useRef(false);
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const rafOwnerWindowRef = useRef<Window | null>(null);
|
||||
const pendingUpdateRef = useRef(false);
|
||||
const manualSessionIdRef = useRef<number | null>(null);
|
||||
const positionRevisionRef = useRef(0);
|
||||
const basePositionRef = useRef({x: state.x, y: state.y});
|
||||
const manualOffsetRef = useRef({x: 0, y: 0});
|
||||
const manualAbsolutePositionRef = useRef<{x: number; y: number} | null>(null);
|
||||
const requestPositionUpdateRef = useRef<() => void>(() => {});
|
||||
const updatePositionNow = useCallback(async () => {
|
||||
if (!enabled || !target || !floatingRef.current || isCalculatingRef.current) {
|
||||
if (!enabled || target == null || floatingRef.current == null) return;
|
||||
if (manualSessionIdRef.current != null || isCalculatingRef.current) {
|
||||
pendingUpdateRef.current = true;
|
||||
return;
|
||||
}
|
||||
pendingUpdateRef.current = false;
|
||||
isCalculatingRef.current = true;
|
||||
const calculationRevision = positionRevisionRef.current;
|
||||
try {
|
||||
const floating = floatingRef.current;
|
||||
if (!floating) {
|
||||
return;
|
||||
}
|
||||
const adaptivePadding = enableSmartBoundary ? getAdaptivePadding() : 8;
|
||||
if (floating == null) return;
|
||||
const ownerDocument = floating.ownerDocument;
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
if (ownerWindow == null) return;
|
||||
const adaptivePadding = enableSmartBoundary ? getAdaptivePadding(ownerWindow) : 8;
|
||||
const shiftPadding = Math.max(6, adaptivePadding);
|
||||
const boundaryPadding = getBoundaryPadding(shiftPadding);
|
||||
const boundaryPadding = getBoundaryPadding(ownerDocument, shiftPadding);
|
||||
const middleware: Array<Middleware> = [
|
||||
offset({mainAxis: offsetMainAxis, crossAxis: offsetCrossAxis}),
|
||||
flip({padding: boundaryPadding}),
|
||||
@@ -302,24 +349,60 @@ export function useAntiShiftFloating(
|
||||
placement,
|
||||
middleware,
|
||||
});
|
||||
const safePosition = clampToViewportBoundary(x, y, floating, boundaryPadding);
|
||||
if (
|
||||
calculationRevision !== positionRevisionRef.current ||
|
||||
manualSessionIdRef.current != null ||
|
||||
floatingRef.current !== floating
|
||||
) {
|
||||
pendingUpdateRef.current = true;
|
||||
return;
|
||||
}
|
||||
const safeBasePosition = clampToViewportBoundary(x, y, floating, boundaryPadding);
|
||||
const manualAbsolutePosition = manualAbsolutePositionRef.current;
|
||||
let desiredPosition: {x: number; y: number};
|
||||
if (manualAbsolutePosition == null) {
|
||||
desiredPosition = {
|
||||
x: safeBasePosition.x + manualOffsetRef.current.x,
|
||||
y: safeBasePosition.y + manualOffsetRef.current.y,
|
||||
};
|
||||
} else {
|
||||
desiredPosition = manualAbsolutePosition;
|
||||
}
|
||||
const safePosition = clampToViewportBoundary(desiredPosition.x, desiredPosition.y, floating, boundaryPadding);
|
||||
const nextOffset = {
|
||||
x: safePosition.x - safeBasePosition.x,
|
||||
y: safePosition.y - safeBasePosition.y,
|
||||
};
|
||||
basePositionRef.current = safeBasePosition;
|
||||
manualOffsetRef.current = nextOffset;
|
||||
manualAbsolutePositionRef.current = null;
|
||||
Object.assign(floating.style, {
|
||||
left: appZoomCssPx(safePosition.x),
|
||||
top: appZoomCssPx(safePosition.y),
|
||||
visibility: 'visible',
|
||||
});
|
||||
setState((prev) =>
|
||||
prev.x !== safePosition.x || prev.y !== safePosition.y || !prev.isReady
|
||||
? {...safePosition, isReady: true}
|
||||
: prev,
|
||||
);
|
||||
setState((prev) => {
|
||||
if (
|
||||
prev.x === safeBasePosition.x &&
|
||||
prev.y === safeBasePosition.y &&
|
||||
prev.isReady &&
|
||||
prev.offsetX === nextOffset.x &&
|
||||
prev.offsetY === nextOffset.y
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {...safeBasePosition, isReady: true, offsetX: nextOffset.x, offsetY: nextOffset.y};
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error positioning floating element', error);
|
||||
if (floatingRef.current) {
|
||||
if (floatingRef.current != null) {
|
||||
floatingRef.current.style.visibility = 'visible';
|
||||
}
|
||||
} finally {
|
||||
isCalculatingRef.current = false;
|
||||
if (pendingUpdateRef.current && manualSessionIdRef.current == null) {
|
||||
requestPositionUpdateRef.current();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
@@ -332,28 +415,74 @@ export function useAntiShiftFloating(
|
||||
constrainHeight,
|
||||
]);
|
||||
const updatePosition = useCallback(() => {
|
||||
if (rafIdRef.current !== null || isCalculatingRef.current) {
|
||||
return;
|
||||
}
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
pendingUpdateRef.current = true;
|
||||
if (manualSessionIdRef.current != null || rafIdRef.current != null) return;
|
||||
const floating = floatingRef.current;
|
||||
if (floating == null) return;
|
||||
const ownerWindow = floating.ownerDocument.defaultView;
|
||||
if (ownerWindow == null) return;
|
||||
rafOwnerWindowRef.current = ownerWindow;
|
||||
rafIdRef.current = ownerWindow.requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
rafOwnerWindowRef.current = null;
|
||||
pendingUpdateRef.current = false;
|
||||
void updatePositionNow();
|
||||
});
|
||||
}, [updatePositionNow]);
|
||||
requestPositionUpdateRef.current = updatePosition;
|
||||
const beginManualPositioning = useCallback((): PopoutResizePositionSession => {
|
||||
positionRevisionRef.current += 1;
|
||||
const sessionId = positionRevisionRef.current;
|
||||
manualSessionIdRef.current = sessionId;
|
||||
pendingUpdateRef.current = false;
|
||||
if (rafIdRef.current != null && rafOwnerWindowRef.current != null) {
|
||||
rafOwnerWindowRef.current.cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
rafOwnerWindowRef.current = null;
|
||||
}
|
||||
const baseOffset = manualOffsetRef.current;
|
||||
const sessionBasePosition = basePositionRef.current;
|
||||
let finished = false;
|
||||
const publishOffset = (offset: {x: number; y: number}) => {
|
||||
if (finished || manualSessionIdRef.current !== sessionId) return;
|
||||
const nextOffset = {x: baseOffset.x + offset.x, y: baseOffset.y + offset.y};
|
||||
manualOffsetRef.current = nextOffset;
|
||||
manualAbsolutePositionRef.current = {
|
||||
x: sessionBasePosition.x + nextOffset.x,
|
||||
y: sessionBasePosition.y + nextOffset.y,
|
||||
};
|
||||
setState((prev) => {
|
||||
if (prev.offsetX === nextOffset.x && prev.offsetY === nextOffset.y) return prev;
|
||||
return {...prev, offsetX: nextOffset.x, offsetY: nextOffset.y};
|
||||
});
|
||||
};
|
||||
return {
|
||||
updateOffset: publishOffset,
|
||||
finish: (offset) => {
|
||||
if (finished || manualSessionIdRef.current !== sessionId) return;
|
||||
publishOffset(offset);
|
||||
finished = true;
|
||||
manualSessionIdRef.current = null;
|
||||
pendingUpdateRef.current = true;
|
||||
requestPositionUpdateRef.current();
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || !target || !floatingRef.current) {
|
||||
setState((prev) => ({...prev, isReady: false}));
|
||||
if (!enabled || target == null || floatingRef.current == null) {
|
||||
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
|
||||
return;
|
||||
}
|
||||
if (!isReferenceConnected(target)) {
|
||||
setState((prev) => ({...prev, isReady: false}));
|
||||
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
|
||||
return;
|
||||
}
|
||||
const floating = floatingRef.current;
|
||||
updatePosition();
|
||||
if (shouldAutoUpdate) {
|
||||
const cleanupCallbacks = [
|
||||
autoUpdate(target, floatingRef.current, updatePosition, ANTI_SHIFT_AUTO_UPDATE_OPTIONS),
|
||||
subscribeSharedScrollUpdate(updatePositionNow),
|
||||
autoUpdate(target, floating, updatePosition, ANTI_SHIFT_AUTO_UPDATE_OPTIONS),
|
||||
subscribeSharedScrollUpdate(floating.ownerDocument, updatePositionNow),
|
||||
];
|
||||
cleanupRef.current = () => {
|
||||
for (const cleanup of cleanupCallbacks) {
|
||||
@@ -364,13 +493,19 @@ export function useAntiShiftFloating(
|
||||
cleanupRef.current = observeFloatingResize(floatingRef.current, updatePosition);
|
||||
}
|
||||
return () => {
|
||||
cleanupRef.current?.();
|
||||
positionRevisionRef.current += 1;
|
||||
manualSessionIdRef.current = null;
|
||||
manualOffsetRef.current = {x: 0, y: 0};
|
||||
manualAbsolutePositionRef.current = null;
|
||||
pendingUpdateRef.current = false;
|
||||
if (cleanupRef.current != null) cleanupRef.current();
|
||||
cleanupRef.current = null;
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
if (rafIdRef.current != null && rafOwnerWindowRef.current != null) {
|
||||
rafOwnerWindowRef.current.cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
rafOwnerWindowRef.current = null;
|
||||
}
|
||||
setState((prev) => ({...prev, isReady: false}));
|
||||
setState((prev) => ({...prev, isReady: false, offsetX: 0, offsetY: 0}));
|
||||
};
|
||||
}, [enabled, target, shouldAutoUpdate, shouldObserveFloatingResize, updatePosition, updatePositionNow]);
|
||||
return {
|
||||
@@ -378,18 +513,29 @@ export function useAntiShiftFloating(
|
||||
state,
|
||||
style: {
|
||||
position: 'fixed' as const,
|
||||
left: appZoomLayoutPx(state.x),
|
||||
top: appZoomLayoutPx(state.y),
|
||||
left: appZoomLayoutPx(state.x + state.offsetX),
|
||||
top: appZoomLayoutPx(state.y + state.offsetY),
|
||||
},
|
||||
updatePosition,
|
||||
beginManualPositioning,
|
||||
};
|
||||
}
|
||||
|
||||
function getReferenceContextElement(target: ReferenceElement): Element | null {
|
||||
if ('ownerDocument' in target) return target as Element;
|
||||
const virtualTarget = target as VirtualElement;
|
||||
const contextElement = virtualTarget.contextElement;
|
||||
if (contextElement == null) return null;
|
||||
return contextElement;
|
||||
}
|
||||
|
||||
function isReferenceConnected(target: ReferenceElement): boolean {
|
||||
if (typeof Element === 'undefined' || !(target instanceof Element)) {
|
||||
return true;
|
||||
}
|
||||
return document.contains(target);
|
||||
const contextElement = getReferenceContextElement(target);
|
||||
if (contextElement == null) return true;
|
||||
const ownerDocument = contextElement.ownerDocument;
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
if (ownerWindow == null || !(contextElement instanceof ownerWindow.Element)) return true;
|
||||
return ownerDocument.contains(contextElement);
|
||||
}
|
||||
|
||||
function getInitialGuess(
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useRef} from 'react';
|
||||
|
||||
export function useShallowStableArray<T, Values extends ReadonlyArray<T>>(values: Values): Values {
|
||||
const stableValuesRef = useRef(values);
|
||||
const stableValues = stableValuesRef.current;
|
||||
if (stableValues.length !== values.length || values.some((value, index) => !Object.is(value, stableValues[index]))) {
|
||||
stableValuesRef.current = values;
|
||||
}
|
||||
return stableValuesRef.current;
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import {describe, expect, it} from 'vitest';
|
||||
import {normalizeGifProviderInfo} from './GifProviderConfig';
|
||||
|
||||
describe('normalizeGifProviderInfo', () => {
|
||||
it('uses KLIPY as the default GIF provider display name', () => {
|
||||
it('uses Klipy as the default GIF provider display name', () => {
|
||||
expect(normalizeGifProviderInfo()).toEqual({
|
||||
name: 'klipy',
|
||||
displayName: 'KLIPY',
|
||||
attributionRequired: true,
|
||||
displayName: 'Klipy',
|
||||
attributionRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,11 +21,11 @@ describe('normalizeGifProviderInfo', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back unknown provider names to KLIPY', () => {
|
||||
it('falls back unknown provider names to Klipy', () => {
|
||||
expect(normalizeGifProviderInfo({name: 'other'})).toEqual({
|
||||
name: 'klipy',
|
||||
displayName: 'KLIPY',
|
||||
attributionRequired: true,
|
||||
displayName: 'Klipy',
|
||||
attributionRequired: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,12 +17,12 @@ export interface GifProviderInfoInput {
|
||||
type KnownGifProvider = 'klipy' | 'tenor';
|
||||
|
||||
const GIF_PROVIDER_DISPLAY_NAMES: Record<KnownGifProvider, string> = {
|
||||
klipy: 'KLIPY',
|
||||
klipy: 'Klipy',
|
||||
tenor: 'Tenor',
|
||||
};
|
||||
|
||||
const GIF_PROVIDER_DEFAULT_ATTRIBUTION: Record<KnownGifProvider, boolean> = {
|
||||
klipy: true,
|
||||
klipy: false,
|
||||
tenor: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {getAccountAvatarUrl, getAccountDisplayName} from '@app/features/auth/com
|
||||
import styles from '@app/features/auth/components/accounts/AccountRow.module.css';
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {MockAvatar} from '@app/features/ui/components/MockAvatar';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
@@ -131,7 +132,7 @@ export const AccountRow = observer(
|
||||
className={styles.globeButtonCompact}
|
||||
data-flx="auth.accounts.account-row.globe-button-compact"
|
||||
>
|
||||
<GlobeIcon size={12} weight="bold" data-flx="auth.accounts.account-row.globe-icon" />
|
||||
<GlobeIcon size={remFromPx(12)} weight="bold" data-flx="auth.accounts.account-row.globe-icon" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -163,7 +164,11 @@ export const AccountRow = observer(
|
||||
className={styles.globeButtonCompact}
|
||||
data-flx="auth.accounts.account-row.globe-button-compact--2"
|
||||
>
|
||||
<GlobeIcon size={12} weight="bold" data-flx="auth.accounts.account-row.globe-icon--2" />
|
||||
<GlobeIcon
|
||||
size={remFromPx(12)}
|
||||
weight="bold"
|
||||
data-flx="auth.accounts.account-row.globe-icon--2"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
@@ -196,12 +201,16 @@ export const AccountRow = observer(
|
||||
</div>
|
||||
{isCurrent && variant !== 'manage' ? (
|
||||
<div className={styles.checkIndicator} data-flx="auth.accounts.account-row.check-indicator">
|
||||
<CheckIcon size={10} weight="bold" data-flx="auth.accounts.account-row.check-icon" />
|
||||
<CheckIcon size={remFromPx(10)} weight="bold" data-flx="auth.accounts.account-row.check-icon" />
|
||||
</div>
|
||||
) : null}
|
||||
{showCaretIndicator ? (
|
||||
<div className={styles.caretIndicator} data-flx="auth.accounts.account-row.caret-indicator">
|
||||
<CaretRightIcon size={18} weight="bold" data-flx="auth.accounts.account-row.caret-right-icon" />
|
||||
<CaretRightIcon
|
||||
size={remFromPx(18)}
|
||||
weight="bold"
|
||||
data-flx="auth.accounts.account-row.caret-right-icon"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</MainButtonComponent>
|
||||
@@ -216,7 +225,7 @@ export const AccountRow = observer(
|
||||
data-flx="auth.accounts.account-row.menu-button.menu-click"
|
||||
>
|
||||
<DotsThreeIcon
|
||||
size={20}
|
||||
size={remFromPx(20)}
|
||||
weight="bold"
|
||||
className={styles.menuIcon}
|
||||
data-flx="auth.accounts.account-row.menu-icon"
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as Modal from '@app/features/app/components/dialogs/Modal';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import {CLOSE_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {acceptTerms} from '@app/features/terms/commands/TermsAcceptanceCommands';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import markupStyles from '@app/features/theme/styles/Markup.module.css';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -146,7 +147,7 @@ export const TermsAcceptanceModal = observer(() => {
|
||||
</Modal.Description>
|
||||
<Modal.Description
|
||||
className={markupStyles.markup}
|
||||
style={{marginTop: 12, opacity: 0.7, fontSize: '0.85em'}}
|
||||
style={{marginTop: remFromPx(12), opacity: 0.7, fontSize: '0.85em'}}
|
||||
data-flx="auth.terms-acceptance-modal.modal-description--2"
|
||||
>
|
||||
{i18n._(UPDATED_POLICIES_AGREEMENT_DESCRIPTOR, {productName})}
|
||||
|
||||
+4
-1
@@ -5,6 +5,7 @@ import styles from '@app/features/auth/components/pages/OAuthAuthorizePage.modul
|
||||
import {useAccountSwitcherLogic} from '@app/features/auth/utils/AccountSwitcherModalUtils';
|
||||
import {CANCEL_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import type {Account} from '@app/features/platform/state/AuthSession';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -104,7 +105,9 @@ export const OAuthAccountStep: React.FC<OAuthAccountStepProps> = observer(({clie
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
leftIcon={<PlusIcon size={18} weight="bold" data-flx="auth.o-auth-authorize-page.account-step.plus-icon" />}
|
||||
leftIcon={
|
||||
<PlusIcon size={remFromPx(18)} weight="bold" data-flx="auth.o-auth-authorize-page.account-step.plus-icon" />
|
||||
}
|
||||
onClick={handleAddAccount}
|
||||
disabled={isBusy}
|
||||
fitContainer
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as AuthenticationCommands from '@app/features/auth/commands/Authenticat
|
||||
import styles from '@app/features/auth/flow/BrowserLoginHandoffModal.module.css';
|
||||
import {HandoffCodeDisplay} from '@app/features/auth/flow/HandoffCodeDisplay';
|
||||
import type {LoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -250,7 +251,7 @@ const BrowserLoginHandoffModal = observer(
|
||||
data-flx="auth.flow.browser-login-handoff-modal.button.open-browser"
|
||||
>
|
||||
<ArrowSquareOutIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
data-flx="auth.flow.browser-login-handoff-modal.arrow-square-out-icon"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import {Routes} from '@app/app/Routes';
|
||||
import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import styles from '@app/features/auth/flow/DesktopDeepLinkPrompt.module.css';
|
||||
import {Platform} from '@app/features/platform/types/Platform';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {buildAppProtocolUrl} from '@app/features/ui/utils/AppProtocol';
|
||||
import {isDesktop, openExternalUrl} from '@app/features/ui/utils/NativeUtils';
|
||||
@@ -108,7 +109,7 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
|
||||
data-flx="auth.flow.desktop-deep-link-prompt.cta.open"
|
||||
>
|
||||
<ArrowSquareOutIcon
|
||||
size={18}
|
||||
size={remFromPx(18)}
|
||||
weight="fill"
|
||||
data-flx="auth.flow.desktop-deep-link-prompt.arrow-square-out-icon"
|
||||
/>
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {DesktopHandoffInfoResponse} from '@app/features/auth/commands/Authe
|
||||
import type {DesktopHandoffMode} from '@app/features/auth/flow/auth_login_core/useDesktopHandoffFlow';
|
||||
import styles from '@app/features/auth/flow/HandoffApprovalFlow.module.css';
|
||||
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
@@ -94,7 +95,7 @@ export function HandoffApprovalFlow({
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container">
|
||||
<div className={styles.warningBox} data-flx="auth.flow.handoff-approval-flow.warning-box">
|
||||
<ShieldWarningIcon
|
||||
size={32}
|
||||
size={remFromPx(32)}
|
||||
weight="fill"
|
||||
className={styles.warningIcon}
|
||||
data-flx="auth.flow.handoff-approval-flow.warning-icon"
|
||||
@@ -241,7 +242,7 @@ export function HandoffApprovalFlow({
|
||||
return (
|
||||
<div className={styles.container} data-flx="auth.flow.handoff-approval-flow.container--6">
|
||||
<CheckCircleIcon
|
||||
size={48}
|
||||
size={remFromPx(48)}
|
||||
weight="fill"
|
||||
className={styles.successIcon}
|
||||
data-flx="auth.flow.handoff-approval-flow.success-icon"
|
||||
|
||||
@@ -4,6 +4,7 @@ import {EXAMPLE_INSTANCE_DOMAIN} from '@app/features/app/config/I18nDisplayConst
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import styles from '@app/features/auth/flow/InstanceSelector.module.css';
|
||||
import AppStorage from '@app/features/platform/state/PersistentStorage';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {Input} from '@app/features/ui/components/form/FormInput';
|
||||
import {Spinner} from '@app/features/ui/components/Spinner';
|
||||
@@ -214,7 +215,7 @@ export const InstanceSelector = observer(function InstanceSelector({
|
||||
<CheckCircleIcon
|
||||
weight="fill"
|
||||
className={styles.statusSuccess}
|
||||
size={18}
|
||||
size={remFromPx(18)}
|
||||
data-flx="auth.flow.instance-selector.status-icon.status-success"
|
||||
/>
|
||||
);
|
||||
@@ -224,7 +225,7 @@ export const InstanceSelector = observer(function InstanceSelector({
|
||||
<WarningCircleIcon
|
||||
weight="fill"
|
||||
className={styles.statusError}
|
||||
size={18}
|
||||
size={remFromPx(18)}
|
||||
data-flx="auth.flow.instance-selector.status-icon.status-error"
|
||||
/>
|
||||
);
|
||||
@@ -242,7 +243,9 @@ export const InstanceSelector = observer(function InstanceSelector({
|
||||
onFocus={handleInputFocus}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
leftIcon={<GlobeIcon size={18} weight="regular" data-flx="auth.flow.instance-selector.globe-icon" />}
|
||||
leftIcon={
|
||||
<GlobeIcon size={remFromPx(18)} weight="regular" data-flx="auth.flow.instance-selector.globe-icon" />
|
||||
}
|
||||
rightElement={
|
||||
<div className={styles.inputActions} data-flx="auth.flow.instance-selector.input-actions">
|
||||
{statusIcon}
|
||||
@@ -256,7 +259,7 @@ export const InstanceSelector = observer(function InstanceSelector({
|
||||
data-flx="auth.flow.instance-selector.dropdown-toggle.button"
|
||||
>
|
||||
<CaretDownIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="bold"
|
||||
className={clsx(styles.caretIcon, showDropdown && styles.caretIconOpen)}
|
||||
data-flx="auth.flow.instance-selector.caret-icon"
|
||||
@@ -284,7 +287,7 @@ export const InstanceSelector = observer(function InstanceSelector({
|
||||
data-flx="auth.flow.instance-selector.dropdown-item.select-recent.button"
|
||||
>
|
||||
<GlobeIcon
|
||||
size={16}
|
||||
size={remFromPx(16)}
|
||||
weight="regular"
|
||||
className={styles.instanceIcon}
|
||||
data-flx="auth.flow.instance-selector.instance-icon"
|
||||
@@ -304,7 +307,11 @@ export const InstanceSelector = observer(function InstanceSelector({
|
||||
aria-label={i18n._(REMOVE_FROM_RECENT_INSTANCES_DESCRIPTOR, {domain: instance.domain})}
|
||||
data-flx="auth.flow.instance-selector.remove-button.remove-recent"
|
||||
>
|
||||
<TrashIcon size={14} weight="regular" data-flx="auth.flow.instance-selector.trash-icon" />
|
||||
<TrashIcon
|
||||
size={remFromPx(14)}
|
||||
weight="regular"
|
||||
data-flx="auth.flow.instance-selector.trash-icon"
|
||||
/>
|
||||
</button>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -5,6 +5,7 @@ import styles from '@app/features/auth/flow/IpAuthorizationScreen.module.css';
|
||||
import type {IpAuthorizationChallenge, LoginSuccessPayload} from '@app/features/auth/state/AuthFlow';
|
||||
import {TRY_AGAIN_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {Button} from '@app/features/ui/button/Button';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
@@ -101,10 +102,14 @@ const IpAuthorizationScreen = ({challenge, onAuthorized, onBack}: IpAuthorizatio
|
||||
<div className={styles.container} data-flx="auth.flow.ip-authorization-screen.container">
|
||||
<div className={styles.icon} data-flx="auth.flow.ip-authorization-screen.icon">
|
||||
{pollingState === 'error' ? (
|
||||
<WarningCircleIcon size={48} weight="fill" data-flx="auth.flow.ip-authorization-screen.warning-circle-icon" />
|
||||
<WarningCircleIcon
|
||||
size={remFromPx(48)}
|
||||
weight="fill"
|
||||
data-flx="auth.flow.ip-authorization-screen.warning-circle-icon"
|
||||
/>
|
||||
) : (
|
||||
<EnvelopeSimpleIcon
|
||||
size={48}
|
||||
size={remFromPx(48)}
|
||||
weight="fill"
|
||||
data-flx="auth.flow.ip-authorization-screen.envelope-simple-icon"
|
||||
/>
|
||||
|
||||
@@ -7,10 +7,13 @@ import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import Slowmode from '@app/features/slowmode/state/Slowmode';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {CHANNEL_RATE_LIMIT_PER_USER_MAX} from '@fluxer/constants/src/LimitConstants';
|
||||
import type {Channel, ChannelSlowmodeStateResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
|
||||
const logger = new Logger('Channels');
|
||||
const MAX_CONCURRENT_SLOWMODE_STATE_REQUESTS = 32;
|
||||
const SLOWMODE_STATE_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export interface ChannelRtcRegion {
|
||||
id: string;
|
||||
@@ -71,7 +74,8 @@ function isPrivateChannel(channelId: string): boolean {
|
||||
}
|
||||
|
||||
function shouldOptimisticallyRemove(channelId: string, options?: RemoveChannelOptions): boolean {
|
||||
return options?.optimistic ?? isPrivateChannel(channelId);
|
||||
if (options !== undefined && options.optimistic !== undefined) return options.optimistic;
|
||||
return isPrivateChannel(channelId);
|
||||
}
|
||||
|
||||
function deleteChannelQuery(
|
||||
@@ -87,10 +91,30 @@ function deleteChannelQuery(
|
||||
|
||||
function syncSlowmodeTimestamp(channelId: string, data: ChannelSlowmodeStateResponse): void {
|
||||
const {rate_limit_per_user, retry_after_ms, can_bypass} = data;
|
||||
if (typeof can_bypass !== 'boolean') {
|
||||
logger.warn(`Ignoring invalid slowmode bypass state for channel ${channelId}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(rate_limit_per_user) ||
|
||||
rate_limit_per_user < 0 ||
|
||||
rate_limit_per_user > CHANNEL_RATE_LIMIT_PER_USER_MAX
|
||||
) {
|
||||
logger.warn(`Ignoring invalid slowmode rate limit for channel ${channelId}`);
|
||||
return;
|
||||
}
|
||||
if (rate_limit_per_user <= 0 || can_bypass) {
|
||||
Slowmode.clearChannel(channelId);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(retry_after_ms) ||
|
||||
retry_after_ms < 0 ||
|
||||
retry_after_ms > CHANNEL_RATE_LIMIT_PER_USER_MAX * 1000
|
||||
) {
|
||||
logger.warn(`Ignoring invalid slowmode retry window for channel ${channelId}`);
|
||||
return;
|
||||
}
|
||||
if (retry_after_ms <= 0) {
|
||||
Slowmode.clearChannel(channelId);
|
||||
return;
|
||||
@@ -98,9 +122,16 @@ function syncSlowmodeTimestamp(channelId: string, data: ChannelSlowmodeStateResp
|
||||
Slowmode.updateSlowmodeRemaining(channelId, retry_after_ms);
|
||||
}
|
||||
|
||||
async function requestSlowmodeState(channelId: string): Promise<ChannelSlowmodeStateResponse | null> {
|
||||
const response = await http.get<ChannelSlowmodeStateResponse>(Endpoints.CHANNEL_SLOWMODE(channelId));
|
||||
async function requestSlowmodeState(
|
||||
channelId: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<ChannelSlowmodeStateResponse | null> {
|
||||
const response = await http.get<ChannelSlowmodeStateResponse>(Endpoints.CHANNEL_SLOWMODE(channelId), {
|
||||
signal,
|
||||
timeoutMs: SLOWMODE_STATE_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
const data = response.body;
|
||||
if (signal.aborted) return null;
|
||||
if (!data) return null;
|
||||
syncSlowmodeTimestamp(channelId, data);
|
||||
return data;
|
||||
@@ -155,8 +186,9 @@ export async function remove(
|
||||
Channels.removeChannelOptimistically(channelId);
|
||||
}
|
||||
try {
|
||||
const deleteMessages = options === undefined ? undefined : options.deleteMessages;
|
||||
await http.delete(Endpoints.CHANNEL(channelId), {
|
||||
query: deleteChannelQuery(silent, options?.deleteMessages),
|
||||
query: deleteChannelQuery(silent, deleteMessages),
|
||||
});
|
||||
if (removeOptimistically) {
|
||||
Channels.clearOptimisticallyRemovedChannel(channelId);
|
||||
@@ -206,22 +238,44 @@ export async function fetchChannelInvites(channelId: string): Promise<Array<Invi
|
||||
}
|
||||
}
|
||||
|
||||
const inFlightSlowmodeFetches = new Map<string, Promise<ChannelSlowmodeStateResponse | null>>();
|
||||
interface SlowmodeFetchEntry {
|
||||
promise: Promise<ChannelSlowmodeStateResponse | null>;
|
||||
}
|
||||
|
||||
const inFlightSlowmodeFetches = new Map<string, SlowmodeFetchEntry>();
|
||||
|
||||
export function fetchSlowmodeState(channelId: string): Promise<ChannelSlowmodeStateResponse | null> {
|
||||
const existing = inFlightSlowmodeFetches.get(channelId);
|
||||
if (existing) return existing;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
return await requestSlowmodeState(channelId);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch slowmode state for channel ${channelId}:`, error);
|
||||
if (existing !== undefined) return existing.promise;
|
||||
if (inFlightSlowmodeFetches.size >= MAX_CONCURRENT_SLOWMODE_STATE_REQUESTS) {
|
||||
logger.warn(`Skipping slowmode state fetch for channel ${channelId}; request capacity is full`);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
let timeoutId = 0;
|
||||
const operation = Promise.resolve()
|
||||
.then(() => requestSlowmodeState(channelId, abortController.signal))
|
||||
.catch((error: unknown) => {
|
||||
if (!abortController.signal.aborted) {
|
||||
logger.error(`Failed to fetch slowmode state for channel ${channelId}:`, error);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
});
|
||||
const promise = new Promise<ChannelSlowmodeStateResponse | null>((resolve) => {
|
||||
timeoutId = window.setTimeout(() => {
|
||||
abortController.abort();
|
||||
resolve(null);
|
||||
}, SLOWMODE_STATE_REQUEST_TIMEOUT_MS);
|
||||
operation.then(resolve, () => resolve(null));
|
||||
}).finally(() => {
|
||||
window.clearTimeout(timeoutId);
|
||||
const current = inFlightSlowmodeFetches.get(channelId);
|
||||
if (current !== undefined && current.promise === promise) {
|
||||
inFlightSlowmodeFetches.delete(channelId);
|
||||
}
|
||||
})();
|
||||
inFlightSlowmodeFetches.set(channelId, promise);
|
||||
});
|
||||
const entry: SlowmodeFetchEntry = {promise};
|
||||
inFlightSlowmodeFetches.set(channelId, entry);
|
||||
return promise;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
.container {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
background-color: var(--background-primary);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.containerAttached {
|
||||
border-radius: 0.5rem 0.5rem 0 0;
|
||||
border: 0.0625rem solid var(--background-modifier-accent);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 0.24);
|
||||
}
|
||||
|
||||
.containerDetached {
|
||||
@@ -23,3 +23,12 @@
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.sectionHeading {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -4,24 +4,33 @@ import {useListNavigation} from '@app/features/app/hooks/useListNavigation';
|
||||
import styles from '@app/features/channel/components/Autocomplete.module.css';
|
||||
import {AutocompleteChannel} from '@app/features/channel/components/AutocompleteChannel';
|
||||
import {AutocompleteCommand} from '@app/features/channel/components/AutocompleteCommand';
|
||||
import {AutocompleteCommandChoice} from '@app/features/channel/components/AutocompleteCommandChoice';
|
||||
import {AutocompleteCommandOptionalAdd} from '@app/features/channel/components/AutocompleteCommandOptionalAdd';
|
||||
import {AutocompleteEmoji} from '@app/features/channel/components/AutocompleteEmoji';
|
||||
import {AutocompleteGif} from '@app/features/channel/components/AutocompleteGif';
|
||||
import {AutocompleteMeme} from '@app/features/channel/components/AutocompleteMeme';
|
||||
import {AutocompleteMention} from '@app/features/channel/components/AutocompleteMention';
|
||||
import {AutocompleteSticker} from '@app/features/channel/components/AutocompleteSticker';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Command} from '@app/features/devtools/hooks/useCommands';
|
||||
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||
import type {Gif} from '@app/features/expressions/commands/GifCommands';
|
||||
import type {FavoriteMeme} from '@app/features/expressions/models/FavoriteMeme';
|
||||
import type {GuildSticker} from '@app/features/expressions/models/GuildSticker';
|
||||
import type {GuildRole} from '@app/features/guild/models/GuildRole';
|
||||
import type {GuildMember} from '@app/features/member/models/GuildMember';
|
||||
import {
|
||||
type AutocompleteOption,
|
||||
type AutocompleteType,
|
||||
getAutocompleteOptionId,
|
||||
isMentionMember,
|
||||
isMentionRole,
|
||||
isMentionUser,
|
||||
} from '@app/features/channel/components/AutocompleteTypes';
|
||||
import {
|
||||
MEDIA_DESCRIPTOR,
|
||||
MEMBERS_DESCRIPTOR,
|
||||
MENTIONS_DESCRIPTOR,
|
||||
ROLES_DESCRIPTOR,
|
||||
STICKERS_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {usePortalHost} from '@app/features/ui/overlay/PortalHostContext';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import {autoUpdate, FloatingPortal, flip, offset, size, useFloating} from '@floating-ui/react';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -32,41 +41,129 @@ const SUGGESTIONS_DESCRIPTOR = msg({
|
||||
message: 'Suggestions',
|
||||
comment: 'Short label in the channel and chat autocomplete. Keep it concise.',
|
||||
});
|
||||
const CHANNELS_DESCRIPTOR = msg({
|
||||
message: 'Channels',
|
||||
comment: 'Section heading in the channel and chat autocomplete for channel suggestions.',
|
||||
});
|
||||
const COMMANDS_DESCRIPTOR = msg({
|
||||
message: 'Commands',
|
||||
comment: 'Section heading in the channel and chat autocomplete for command suggestions.',
|
||||
});
|
||||
const CHOICES_DESCRIPTOR = msg({
|
||||
message: 'Choices',
|
||||
comment: 'Section heading in the channel and chat autocomplete for slash-command argument choices.',
|
||||
});
|
||||
const OPTIONAL_ARGUMENTS_DESCRIPTOR = msg({
|
||||
message: 'Optional arguments',
|
||||
comment: 'Section heading in the channel and chat autocomplete for adding optional slash-command arguments.',
|
||||
});
|
||||
const USERS_DESCRIPTOR = msg({
|
||||
message: 'Users',
|
||||
comment: 'Section heading in the channel and chat autocomplete for user suggestions.',
|
||||
});
|
||||
|
||||
function resolveMentionAutocompleteHeading(options: Array<AutocompleteOption>, i18n: I18n): string {
|
||||
if (options.some(isMentionMember)) {
|
||||
return i18n._(MEMBERS_DESCRIPTOR);
|
||||
}
|
||||
if (options.some(isMentionUser)) {
|
||||
return i18n._(USERS_DESCRIPTOR);
|
||||
}
|
||||
if (options.some(isMentionRole)) {
|
||||
return i18n._(ROLES_DESCRIPTOR);
|
||||
}
|
||||
return i18n._(MENTIONS_DESCRIPTOR);
|
||||
}
|
||||
|
||||
function resolveAutocompleteHeading(
|
||||
type: AutocompleteType,
|
||||
options: Array<AutocompleteOption>,
|
||||
i18n: I18n,
|
||||
): string | null {
|
||||
switch (type) {
|
||||
case 'mention':
|
||||
return resolveMentionAutocompleteHeading(options, i18n);
|
||||
case 'channel':
|
||||
return i18n._(CHANNELS_DESCRIPTOR);
|
||||
case 'command':
|
||||
return i18n._(COMMANDS_DESCRIPTOR);
|
||||
case 'commandChoice':
|
||||
return i18n._(CHOICES_DESCRIPTOR);
|
||||
case 'commandOptionalAdd':
|
||||
return i18n._(OPTIONAL_ARGUMENTS_DESCRIPTOR);
|
||||
case 'meme':
|
||||
return i18n._(MEDIA_DESCRIPTOR);
|
||||
case 'sticker':
|
||||
return i18n._(STICKERS_DESCRIPTOR);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAutocompleteHeading(heading: string | null): React.ReactNode {
|
||||
if (heading == null) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={styles.sectionHeading} aria-hidden={true} data-flx="channel.autocomplete.section-heading">
|
||||
{heading}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function resolveListboxId(listboxId: string | undefined, generatedListboxId: string): string {
|
||||
if (listboxId === undefined) {
|
||||
return generatedListboxId;
|
||||
}
|
||||
return listboxId;
|
||||
}
|
||||
|
||||
function resolveKeyboardFocusIndex(externalIndex: number | undefined, internalIndex: number): number {
|
||||
if (externalIndex === undefined) {
|
||||
return internalIndex;
|
||||
}
|
||||
return externalIndex;
|
||||
}
|
||||
|
||||
function resolveReferenceElement(referenceElement: HTMLElement | null | undefined): HTMLElement | null {
|
||||
if (referenceElement === undefined) {
|
||||
return null;
|
||||
}
|
||||
return referenceElement;
|
||||
}
|
||||
|
||||
function resolvePortalRoot(portalHost: HTMLElement | null): HTMLElement | undefined {
|
||||
if (portalHost === null) {
|
||||
return undefined;
|
||||
}
|
||||
return portalHost;
|
||||
}
|
||||
|
||||
function resolveZIndex(zIndex: number | undefined): number | undefined {
|
||||
if (zIndex === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return zIndex;
|
||||
}
|
||||
|
||||
type ScrollerWithScrollableElement = ScrollerHandle & {
|
||||
getScrollableElement?: () => HTMLElement | null;
|
||||
};
|
||||
export type AutocompleteOption =
|
||||
| {type: 'mention'; kind: 'member'; member: GuildMember}
|
||||
| {type: 'mention'; kind: 'user'; user: User}
|
||||
| {type: 'mention'; kind: 'role'; role: GuildRole}
|
||||
| {type: 'mention'; kind: '@everyone' | '@here'}
|
||||
| {type: 'channel'; channel: Channel}
|
||||
| {type: 'emoji'; emoji: FlatEmoji}
|
||||
| {type: 'command'; command: Command}
|
||||
| {type: 'meme'; meme: FavoriteMeme}
|
||||
| {type: 'gif'; gif: Gif}
|
||||
| {type: 'sticker'; sticker: GuildSticker};
|
||||
export type AutocompleteType = 'mention' | 'channel' | 'emoji' | 'command' | 'meme' | 'gif' | 'sticker';
|
||||
|
||||
export function getAutocompleteOptionId(listboxId: string, index: number): string {
|
||||
return `${listboxId}-option-${index}`;
|
||||
}
|
||||
|
||||
export const isMentionMember = (o: AutocompleteOption): o is {type: 'mention'; kind: 'member'; member: GuildMember} =>
|
||||
o.type === 'mention' && o.kind === 'member';
|
||||
export const isMentionUser = (o: AutocompleteOption): o is {type: 'mention'; kind: 'user'; user: User} =>
|
||||
o.type === 'mention' && o.kind === 'user';
|
||||
export const isMentionRole = (o: AutocompleteOption): o is {type: 'mention'; kind: 'role'; role: GuildRole} =>
|
||||
o.type === 'mention' && o.kind === 'role';
|
||||
export const isSpecialMention = (o: AutocompleteOption): o is {type: 'mention'; kind: '@everyone' | '@here'} =>
|
||||
o.type === 'mention' && (o.kind === '@everyone' || o.kind === '@here');
|
||||
export const isChannel = (o: AutocompleteOption): o is {type: 'channel'; channel: Channel} => o.type === 'channel';
|
||||
export const isEmoji = (o: AutocompleteOption): o is {type: 'emoji'; emoji: FlatEmoji} => o.type === 'emoji';
|
||||
export const isCommand = (o: AutocompleteOption): o is {type: 'command'; command: Command} => o.type === 'command';
|
||||
export const isMeme = (o: AutocompleteOption): o is {type: 'meme'; meme: FavoriteMeme} => o.type === 'meme';
|
||||
export const isGif = (o: AutocompleteOption): o is {type: 'gif'; gif: Gif} => o.type === 'gif';
|
||||
export const isSticker = (o: AutocompleteOption): o is {type: 'sticker'; sticker: GuildSticker} => o.type === 'sticker';
|
||||
export type {AutocompleteOption, AutocompleteType} from '@app/features/channel/components/AutocompleteTypes';
|
||||
export {
|
||||
getAutocompleteOptionId,
|
||||
isChannel,
|
||||
isCommand,
|
||||
isEmoji,
|
||||
isGif,
|
||||
isMeme,
|
||||
isMentionMember,
|
||||
isMentionRole,
|
||||
isMentionUser,
|
||||
isSpecialMention,
|
||||
isSticker,
|
||||
} from '@app/features/channel/components/AutocompleteTypes';
|
||||
export const Autocomplete = observer(
|
||||
({
|
||||
type,
|
||||
@@ -78,6 +175,7 @@ export const Autocomplete = observer(
|
||||
zIndex,
|
||||
attached = false,
|
||||
listboxId,
|
||||
mainAxisOffset,
|
||||
}: {
|
||||
type: AutocompleteType;
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
@@ -89,10 +187,11 @@ export const Autocomplete = observer(
|
||||
query?: string;
|
||||
attached?: boolean;
|
||||
listboxId?: string;
|
||||
mainAxisOffset?: number;
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const generatedListboxId = useId();
|
||||
const resolvedListboxId = listboxId ?? generatedListboxId;
|
||||
const resolvedListboxId = resolveListboxId(listboxId, generatedListboxId);
|
||||
const getOptionId = useCallback(
|
||||
(index: number) => getAutocompleteOptionId(resolvedListboxId, index),
|
||||
[resolvedListboxId],
|
||||
@@ -109,24 +208,31 @@ export const Autocomplete = observer(
|
||||
initialIndex: 0,
|
||||
loop: true,
|
||||
});
|
||||
const keyboardFocusIndex = externalSelectedIndex ?? internalKeyboardFocusIndex;
|
||||
const [referenceState, setReferenceState] = useState<HTMLElement | null>(referenceElement ?? null);
|
||||
const keyboardFocusIndex = resolveKeyboardFocusIndex(externalSelectedIndex, internalKeyboardFocusIndex);
|
||||
const [referenceState, setReferenceState] = useState<HTMLElement | null>(resolveReferenceElement(referenceElement));
|
||||
useEffect(() => {
|
||||
setReferenceState(referenceElement ?? null);
|
||||
setReferenceState(resolveReferenceElement(referenceElement));
|
||||
}, [referenceElement]);
|
||||
const portalHost = usePortalHost();
|
||||
let resolvedMainAxisOffset = attached ? 0 : 8;
|
||||
if (mainAxisOffset != null) {
|
||||
resolvedMainAxisOffset = mainAxisOffset;
|
||||
}
|
||||
const resolvedCrossAxisOffset = attached ? 8 : 0;
|
||||
const heading = resolveAutocompleteHeading(type, options, i18n);
|
||||
const {refs, floatingStyles} = useFloating({
|
||||
placement: 'top-start',
|
||||
open: true,
|
||||
whileElementsMounted: autoUpdate,
|
||||
elements: {reference: referenceState},
|
||||
middleware: [
|
||||
offset(attached ? 0 : 8),
|
||||
offset({mainAxis: resolvedMainAxisOffset, crossAxis: resolvedCrossAxisOffset}),
|
||||
flip({padding: 16}),
|
||||
size({
|
||||
apply({rects, elements}) {
|
||||
const width = attached ? Math.max(0, rects.reference.width - 16) : rects.reference.width;
|
||||
Object.assign(elements.floating.style, {
|
||||
width: `${rects.reference.width}px`,
|
||||
width: `${width}px`,
|
||||
});
|
||||
},
|
||||
padding: 16,
|
||||
@@ -203,10 +309,16 @@ export const Autocomplete = observer(
|
||||
scroller.scrollIntoViewNode({node, padding: margin});
|
||||
return;
|
||||
}
|
||||
const scrollerEl =
|
||||
scroller?.getScrollableElement?.() ||
|
||||
node.closest('[data-scrollable], .overflow-y-auto, .overflow-y-scroll') ||
|
||||
node.parentElement;
|
||||
let scrollerEl: HTMLElement | null = null;
|
||||
if (scroller != null && typeof scroller.getScrollableElement === 'function') {
|
||||
scrollerEl = scroller.getScrollableElement();
|
||||
}
|
||||
if (scrollerEl == null) {
|
||||
scrollerEl = node.closest('[data-scrollable], .overflow-y-auto, .overflow-y-scroll');
|
||||
}
|
||||
if (scrollerEl == null) {
|
||||
scrollerEl = node.parentElement;
|
||||
}
|
||||
if (scrollerEl && scrollerEl instanceof HTMLElement) {
|
||||
const sRect = scrollerEl.getBoundingClientRect();
|
||||
const nRect = node.getBoundingClientRect();
|
||||
@@ -222,16 +334,16 @@ export const Autocomplete = observer(
|
||||
node.scrollIntoView({block: 'nearest'});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const node = rowRefs.current[keyboardFocusIndex] ?? null;
|
||||
const node = rowRefs.current[keyboardFocusIndex];
|
||||
if (!node) return;
|
||||
const raf = requestAnimationFrame(() => scrollChildIntoView(node, 32));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [keyboardFocusIndex, options.length, scrollChildIntoView]);
|
||||
return (
|
||||
<FloatingPortal root={portalHost ?? undefined} data-flx="channel.autocomplete.floating-portal">
|
||||
<FloatingPortal root={resolvePortalRoot(portalHost)} data-flx="channel.autocomplete.floating-portal">
|
||||
<div
|
||||
ref={refs.setFloating}
|
||||
style={{...floatingStyles, zIndex: zIndex ?? undefined}}
|
||||
style={{...floatingStyles, zIndex: resolveZIndex(zIndex)}}
|
||||
className={`${styles.container} ${attached ? styles.containerAttached : styles.containerDetached}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="listbox"
|
||||
@@ -258,6 +370,7 @@ export const Autocomplete = observer(
|
||||
key="autocomplete-scroller"
|
||||
data-flx="channel.autocomplete.scroller"
|
||||
>
|
||||
{renderAutocompleteHeading(heading)}
|
||||
{type === 'mention' ? (
|
||||
<AutocompleteMention
|
||||
onSelect={onSelect}
|
||||
@@ -294,6 +407,28 @@ export const Autocomplete = observer(
|
||||
getOptionId={getOptionId}
|
||||
data-flx="channel.autocomplete.autocomplete-command.select"
|
||||
/>
|
||||
) : type === 'commandChoice' ? (
|
||||
<AutocompleteCommandChoice
|
||||
onSelect={onSelect}
|
||||
keyboardFocusIndex={keyboardFocusIndex}
|
||||
hoverIndex={hoverIndexForRender}
|
||||
options={options}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
rowRefs={rowRefs}
|
||||
getOptionId={getOptionId}
|
||||
/>
|
||||
) : type === 'commandOptionalAdd' ? (
|
||||
<AutocompleteCommandOptionalAdd
|
||||
onSelect={onSelect}
|
||||
keyboardFocusIndex={keyboardFocusIndex}
|
||||
hoverIndex={hoverIndexForRender}
|
||||
options={options}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
rowRefs={rowRefs}
|
||||
getOptionId={getOptionId}
|
||||
/>
|
||||
) : type === 'meme' ? (
|
||||
<AutocompleteMeme
|
||||
onSelect={onSelect}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AutocompleteItem} from '@app/features/channel/components/AutocompleteItem';
|
||||
import {type AutocompleteOption, isCommandChoice} from '@app/features/channel/components/AutocompleteTypes';
|
||||
import type React from 'react';
|
||||
|
||||
interface AutocompleteCommandChoiceProps {
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
keyboardFocusIndex: number;
|
||||
hoverIndex: number;
|
||||
options: Array<AutocompleteOption>;
|
||||
onMouseEnter: (index: number) => void;
|
||||
onMouseLeave: () => void;
|
||||
rowRefs?: React.MutableRefObject<Array<HTMLButtonElement | null>>;
|
||||
getOptionId?: (index: number) => string;
|
||||
}
|
||||
|
||||
export function AutocompleteCommandChoice({
|
||||
onSelect,
|
||||
keyboardFocusIndex,
|
||||
hoverIndex,
|
||||
options,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
rowRefs,
|
||||
getOptionId,
|
||||
}: AutocompleteCommandChoiceProps): React.ReactNode {
|
||||
const choices = options.filter(isCommandChoice);
|
||||
return choices.map((option, index) => (
|
||||
<AutocompleteItem
|
||||
key={option.choice.value}
|
||||
id={getOptionId ? getOptionId(index) : undefined}
|
||||
name={option.choice.name}
|
||||
description={option.description}
|
||||
isKeyboardSelected={index === keyboardFocusIndex}
|
||||
isHovered={index === hoverIndex}
|
||||
onSelect={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(index)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
innerRef={
|
||||
rowRefs
|
||||
? (node: HTMLButtonElement | null) => {
|
||||
rowRefs.current[index] = node;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
data-flx="channel.autocomplete-command-choice.autocomplete-item.select"
|
||||
/>
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AutocompleteItem} from '@app/features/channel/components/AutocompleteItem';
|
||||
import {type AutocompleteOption, isCommandOptionalAdd} from '@app/features/channel/components/AutocompleteTypes';
|
||||
import type React from 'react';
|
||||
|
||||
interface AutocompleteCommandOptionalAddProps {
|
||||
onSelect: (option: AutocompleteOption) => void;
|
||||
keyboardFocusIndex: number;
|
||||
hoverIndex: number;
|
||||
options: Array<AutocompleteOption>;
|
||||
onMouseEnter: (index: number) => void;
|
||||
onMouseLeave: () => void;
|
||||
rowRefs?: React.MutableRefObject<Array<HTMLButtonElement | null>>;
|
||||
getOptionId?: (index: number) => string;
|
||||
}
|
||||
|
||||
export function AutocompleteCommandOptionalAdd({
|
||||
onSelect,
|
||||
keyboardFocusIndex,
|
||||
hoverIndex,
|
||||
options,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
rowRefs,
|
||||
getOptionId,
|
||||
}: AutocompleteCommandOptionalAddProps): React.ReactNode {
|
||||
const additions = options.filter(isCommandOptionalAdd);
|
||||
return additions.map((option, index) => (
|
||||
<AutocompleteItem
|
||||
key={option.name}
|
||||
id={getOptionId ? getOptionId(index) : undefined}
|
||||
name={option.name}
|
||||
description={option.description}
|
||||
isKeyboardSelected={index === keyboardFocusIndex}
|
||||
isHovered={index === hoverIndex}
|
||||
onSelect={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(index)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
innerRef={
|
||||
rowRefs
|
||||
? (node: HTMLButtonElement | null) => {
|
||||
rowRefs.current[index] = node;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
data-flx="channel.autocomplete-command-optional-add.autocomplete-item.select"
|
||||
/>
|
||||
));
|
||||
}
|
||||
@@ -25,12 +25,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.attribution {
|
||||
height: 1rem;
|
||||
width: auto;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.scroller {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {useAnimatedMediaVideoPlayback} from '@app/features/app/hooks/useAnimatedMediaPlayback';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {type AutocompleteOption, isGif} from '@app/features/channel/components/Autocomplete';
|
||||
import styles from '@app/features/channel/components/AutocompleteGif.module.css';
|
||||
import * as KlipyUtils from '@app/features/expressions/utils/KlipyUtils';
|
||||
import {GIFS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import PoweredByKlipySvg from '@app/media/images/powered-by-klipy.svg?react';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect, useMemo, useRef} from 'react';
|
||||
|
||||
const FROM_DESCRIPTOR = msg({
|
||||
message: 'From {providerDisplayName}',
|
||||
comment:
|
||||
'Short label in the channel and chat autocomplete gif. Keep it concise. Preserve {providerDisplayName}; it is inserted by code.',
|
||||
});
|
||||
const NO_GIFS_FOUND_DESCRIPTOR = msg({
|
||||
message: 'No GIFs match',
|
||||
comment: 'Empty-state text in the channel and chat autocomplete gif.',
|
||||
@@ -60,9 +53,6 @@ export const AutocompleteGif = observer(
|
||||
getOptionId?: (index: number) => string;
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const showKlipyWatermark = RuntimeConfig.gifAttributionRequired && RuntimeConfig.gifProvider === 'klipy';
|
||||
const providerDisplayName = RuntimeConfig.gifProviderDisplayName;
|
||||
const fromProviderText = i18n._(FROM_DESCRIPTOR, {providerDisplayName});
|
||||
const gifs = useMemo(() => options.filter(isGif), [options]);
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
useEffect(() => {
|
||||
@@ -93,9 +83,6 @@ export const AutocompleteGif = observer(
|
||||
<div className={styles.container} data-flx="channel.autocomplete-gif.container">
|
||||
<div className={styles.heading} data-flx="channel.autocomplete-gif.heading">
|
||||
<span data-flx="channel.autocomplete-gif.span">{i18n._(GIFS_DESCRIPTOR)}</span>
|
||||
{showKlipyWatermark ? (
|
||||
<PoweredByKlipySvg className={styles.attribution} data-flx="channel.autocomplete-gif.attribution" />
|
||||
) : null}
|
||||
</div>
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
@@ -123,7 +110,7 @@ export const AutocompleteGif = observer(
|
||||
onClick={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(index)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
aria-label={`${title} - ${fromProviderText}`}
|
||||
aria-label={title}
|
||||
role="option"
|
||||
aria-selected={index === keyboardFocusIndex}
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AutocompleteItem} from '@app/features/channel/components/AutocompleteItem';
|
||||
import styles from '@app/features/channel/components/AutocompleteMention.module.css';
|
||||
import {
|
||||
type AutocompleteMentionMemberOption,
|
||||
type AutocompleteMentionRoleOption,
|
||||
type AutocompleteMentionUserOption,
|
||||
type AutocompleteOption,
|
||||
type AutocompleteSpecialMentionOption,
|
||||
isMentionMember,
|
||||
isMentionRole,
|
||||
isMentionUser,
|
||||
isSpecialMention,
|
||||
} from '@app/features/channel/components/Autocomplete';
|
||||
import {AutocompleteItem} from '@app/features/channel/components/AutocompleteItem';
|
||||
import styles from '@app/features/channel/components/AutocompleteMention.module.css';
|
||||
} from '@app/features/channel/components/AutocompleteTypes';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {useParams} from '@app/features/platform/components/router/RouterReact';
|
||||
import * as ColorUtils from '@app/features/theme/utils/ColorUtils';
|
||||
import {openRoleContextMenu} from '@app/features/ui/action_menu/RoleContextMenu';
|
||||
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import * as DisplayNameUtils from '@app/features/user/utils/DisplayNameUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -32,6 +36,30 @@ const NOTIFY_USERS_WITH_THIS_ROLE_WHO_HAVE_PERMISSION_DESCRIPTOR = msg({
|
||||
message: 'Notify users with this role who have permission to view this channel.',
|
||||
comment: 'Description text in the channel and chat autocomplete mention. Keep the tone plain and specific.',
|
||||
});
|
||||
|
||||
type RowRef = React.MutableRefObject<Array<HTMLButtonElement | null>> | undefined;
|
||||
type OptionIdGetter = ((index: number) => string) | undefined;
|
||||
|
||||
function resolveOptionId(getOptionId: OptionIdGetter, index: number): string | undefined {
|
||||
if (getOptionId == null) {
|
||||
return undefined;
|
||||
}
|
||||
return getOptionId(index);
|
||||
}
|
||||
|
||||
function resolveRowRef(rowRefs: RowRef, index: number): React.Ref<HTMLButtonElement> | undefined {
|
||||
if (rowRefs == null) {
|
||||
return undefined;
|
||||
}
|
||||
return (node: HTMLButtonElement | null) => {
|
||||
rowRefs.current[index] = node;
|
||||
};
|
||||
}
|
||||
|
||||
function renderDivider(): React.ReactNode {
|
||||
return <div className={styles.divider} aria-hidden={true} data-flx="channel.autocomplete-mention.divider" />;
|
||||
}
|
||||
|
||||
export const AutocompleteMention = observer(function AutocompleteMention({
|
||||
onSelect,
|
||||
keyboardFocusIndex,
|
||||
@@ -52,156 +80,133 @@ export const AutocompleteMention = observer(function AutocompleteMention({
|
||||
getOptionId?: (index: number) => string;
|
||||
}) {
|
||||
const {i18n} = useLingui();
|
||||
const {guildId} = useParams() as {guildId?: string};
|
||||
const guild = Guilds.getGuild(guildId ?? '');
|
||||
const routeParams = useParams() as {guildId?: string};
|
||||
const guildId = routeParams.guildId;
|
||||
const guild = Guilds.getGuild(guildId == null ? '' : guildId);
|
||||
const nicknameGuildId = guild == null ? undefined : guild.id;
|
||||
const members = options.filter(isMentionMember);
|
||||
const users = options.filter(isMentionUser);
|
||||
const roles = options.filter(isMentionRole);
|
||||
const specialMentions = options.filter(isSpecialMention);
|
||||
const renderMember = (option: AutocompleteMentionMemberOption, index: number) => (
|
||||
<AutocompleteItem
|
||||
key={option.member.user.id}
|
||||
id={resolveOptionId(getOptionId, index)}
|
||||
icon={
|
||||
<StatusAwareAvatar
|
||||
user={option.member.user}
|
||||
size={24}
|
||||
guildId={guildId}
|
||||
data-flx="channel.autocomplete-mention.status-aware-avatar"
|
||||
/>
|
||||
}
|
||||
name={DisplayNameUtils.getGuildMemberNickname(option.member)}
|
||||
description={DisplayNameUtils.formatUserTagForStreamerMode(option.member.user)}
|
||||
isKeyboardSelected={index === keyboardFocusIndex}
|
||||
isHovered={index === hoverIndex}
|
||||
onSelect={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(index)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
innerRef={resolveRowRef(rowRefs, index)}
|
||||
data-flx="channel.autocomplete-mention.autocomplete-item.select"
|
||||
/>
|
||||
);
|
||||
const renderUser = (option: AutocompleteMentionUserOption, index: number) => {
|
||||
const currentIndex = members.length + index;
|
||||
return (
|
||||
<AutocompleteItem
|
||||
key={option.user.id}
|
||||
id={resolveOptionId(getOptionId, currentIndex)}
|
||||
icon={
|
||||
<StatusAwareAvatar
|
||||
user={option.user}
|
||||
size={24}
|
||||
data-flx="channel.autocomplete-mention.status-aware-avatar--2"
|
||||
/>
|
||||
}
|
||||
name={DisplayNameUtils.getNickname(option.user, nicknameGuildId)}
|
||||
description={DisplayNameUtils.formatUserTagForStreamerMode(option.user)}
|
||||
isKeyboardSelected={currentIndex === keyboardFocusIndex}
|
||||
isHovered={currentIndex === hoverIndex}
|
||||
onSelect={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(currentIndex)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
innerRef={resolveRowRef(rowRefs, currentIndex)}
|
||||
data-flx="channel.autocomplete-mention.autocomplete-item.select--2"
|
||||
/>
|
||||
);
|
||||
};
|
||||
const renderSpecialMention = (option: AutocompleteSpecialMentionOption, index: number) => {
|
||||
const currentIndex = members.length + users.length + index;
|
||||
return (
|
||||
<AutocompleteItem
|
||||
key={option.kind}
|
||||
id={resolveOptionId(getOptionId, currentIndex)}
|
||||
name={option.kind}
|
||||
description={
|
||||
option.kind === '@everyone'
|
||||
? i18n._(NOTIFY_EVERYONE_WHO_HAS_PERMISSION_TO_VIEW_THIS_DESCRIPTOR)
|
||||
: i18n._(NOTIFY_EVERYONE_ONLINE_WHO_HAS_PERMISSION_TO_VIEW_DESCRIPTOR)
|
||||
}
|
||||
isKeyboardSelected={currentIndex === keyboardFocusIndex}
|
||||
isHovered={currentIndex === hoverIndex}
|
||||
onSelect={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(currentIndex)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
innerRef={resolveRowRef(rowRefs, currentIndex)}
|
||||
data-flx="channel.autocomplete-mention.autocomplete-item.select--3"
|
||||
/>
|
||||
);
|
||||
};
|
||||
const renderRole = (option: AutocompleteMentionRoleOption, index: number) => {
|
||||
const currentIndex = members.length + users.length + specialMentions.length + index;
|
||||
const roleColor =
|
||||
option.role.color === 0 || Number.isNaN(option.role.color) ? undefined : ColorUtils.int2rgb(option.role.color);
|
||||
return (
|
||||
<AutocompleteItem
|
||||
key={option.role.id}
|
||||
id={resolveOptionId(getOptionId, currentIndex)}
|
||||
name={<span style={{color: roleColor}}>@{option.role.name}</span>}
|
||||
description={i18n._(NOTIFY_USERS_WITH_THIS_ROLE_WHO_HAVE_PERMISSION_DESCRIPTOR)}
|
||||
isKeyboardSelected={currentIndex === keyboardFocusIndex}
|
||||
isHovered={currentIndex === hoverIndex}
|
||||
onSelect={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(currentIndex)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onContextMenu={(event) => openRoleContextMenu(event, option.role.id)}
|
||||
innerRef={resolveRowRef(rowRefs, currentIndex)}
|
||||
data-flx="channel.autocomplete-mention.autocomplete-item.select--4"
|
||||
/>
|
||||
);
|
||||
};
|
||||
let hasRowsAfterMembers = users.length > 0;
|
||||
if (!hasRowsAfterMembers) {
|
||||
hasRowsAfterMembers = specialMentions.length > 0;
|
||||
}
|
||||
if (!hasRowsAfterMembers) {
|
||||
hasRowsAfterMembers = roles.length > 0;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{members.length > 0 && (
|
||||
<>
|
||||
{members.map((option, index) => (
|
||||
<AutocompleteItem
|
||||
key={option.member.user.id}
|
||||
id={getOptionId?.(index)}
|
||||
icon={
|
||||
<StatusAwareAvatar
|
||||
user={option.member.user}
|
||||
size={24}
|
||||
guildId={guildId}
|
||||
data-flx="channel.autocomplete-mention.status-aware-avatar"
|
||||
/>
|
||||
}
|
||||
name={NicknameUtils.getNickname(option.member.user, guild?.id)}
|
||||
description={NicknameUtils.formatUserTagForStreamerMode(option.member.user)}
|
||||
isKeyboardSelected={index === keyboardFocusIndex}
|
||||
isHovered={index === hoverIndex}
|
||||
onSelect={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(index)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
innerRef={
|
||||
rowRefs
|
||||
? (node) => {
|
||||
rowRefs.current[index] = node;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
data-flx="channel.autocomplete-mention.autocomplete-item.select"
|
||||
/>
|
||||
))}
|
||||
{(users.length > 0 || specialMentions.length > 0 || roles.length > 0) && (
|
||||
<div className={styles.divider} aria-hidden={true} data-flx="channel.autocomplete-mention.divider" />
|
||||
)}
|
||||
{members.map(renderMember)}
|
||||
{hasRowsAfterMembers && renderDivider()}
|
||||
</>
|
||||
)}
|
||||
{users.length > 0 && (
|
||||
<>
|
||||
{users.map((option, index) => {
|
||||
const currentIndex = members.length + index;
|
||||
return (
|
||||
<AutocompleteItem
|
||||
key={option.user.id}
|
||||
id={getOptionId?.(currentIndex)}
|
||||
icon={
|
||||
<StatusAwareAvatar
|
||||
user={option.user}
|
||||
size={24}
|
||||
data-flx="channel.autocomplete-mention.status-aware-avatar--2"
|
||||
/>
|
||||
}
|
||||
name={NicknameUtils.getNickname(option.user, guild?.id)}
|
||||
description={NicknameUtils.formatUserTagForStreamerMode(option.user)}
|
||||
isKeyboardSelected={currentIndex === keyboardFocusIndex}
|
||||
isHovered={currentIndex === hoverIndex}
|
||||
onSelect={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(currentIndex)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
innerRef={
|
||||
rowRefs
|
||||
? (node) => {
|
||||
rowRefs.current[currentIndex] = node;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
data-flx="channel.autocomplete-mention.autocomplete-item.select--2"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(specialMentions.length > 0 || roles.length > 0) && (
|
||||
<div className={styles.divider} aria-hidden={true} data-flx="channel.autocomplete-mention.divider--2" />
|
||||
)}
|
||||
{users.map(renderUser)}
|
||||
{(specialMentions.length > 0 || roles.length > 0) && renderDivider()}
|
||||
</>
|
||||
)}
|
||||
{specialMentions.length > 0 && (
|
||||
<>
|
||||
{specialMentions.map((option, index) => {
|
||||
const currentIndex = members.length + users.length + index;
|
||||
return (
|
||||
<AutocompleteItem
|
||||
key={option.kind}
|
||||
id={getOptionId?.(currentIndex)}
|
||||
name={option.kind}
|
||||
description={
|
||||
option.kind === '@everyone'
|
||||
? i18n._(NOTIFY_EVERYONE_WHO_HAS_PERMISSION_TO_VIEW_THIS_DESCRIPTOR)
|
||||
: i18n._(NOTIFY_EVERYONE_ONLINE_WHO_HAS_PERMISSION_TO_VIEW_DESCRIPTOR)
|
||||
}
|
||||
isKeyboardSelected={currentIndex === keyboardFocusIndex}
|
||||
isHovered={currentIndex === hoverIndex}
|
||||
onSelect={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(currentIndex)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
innerRef={
|
||||
rowRefs
|
||||
? (node) => {
|
||||
rowRefs.current[currentIndex] = node;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
data-flx="channel.autocomplete-mention.autocomplete-item.select--3"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{roles.length > 0 && (
|
||||
<div className={styles.divider} aria-hidden={true} data-flx="channel.autocomplete-mention.divider--3" />
|
||||
)}
|
||||
{specialMentions.map(renderSpecialMention)}
|
||||
{roles.length > 0 && renderDivider()}
|
||||
</>
|
||||
)}
|
||||
{roles.length > 0 &&
|
||||
roles.map((option, index) => {
|
||||
const currentIndex = members.length + users.length + specialMentions.length + index;
|
||||
return (
|
||||
<AutocompleteItem
|
||||
key={option.role.id}
|
||||
id={getOptionId?.(currentIndex)}
|
||||
name={
|
||||
<span
|
||||
style={{color: option.role.color ? ColorUtils.int2rgb(option.role.color) : undefined}}
|
||||
data-flx="channel.autocomplete-mention.span"
|
||||
>
|
||||
@{option.role.name}
|
||||
</span>
|
||||
}
|
||||
description={i18n._(NOTIFY_USERS_WITH_THIS_ROLE_WHO_HAVE_PERMISSION_DESCRIPTOR)}
|
||||
isKeyboardSelected={currentIndex === keyboardFocusIndex}
|
||||
isHovered={currentIndex === hoverIndex}
|
||||
onSelect={() => onSelect(option)}
|
||||
onMouseEnter={() => onMouseEnter(currentIndex)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onContextMenu={(event) => openRoleContextMenu(event, option.role.id)}
|
||||
innerRef={
|
||||
rowRefs
|
||||
? (node) => {
|
||||
rowRefs.current[currentIndex] = node;
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
data-flx="channel.autocomplete-mention.autocomplete-item.select--4"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{roles.map(renderRole)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Command} from '@app/features/devtools/hooks/useCommands';
|
||||
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||
import type {Gif} from '@app/features/expressions/commands/GifCommands';
|
||||
import type {FavoriteMeme} from '@app/features/expressions/models/FavoriteMeme';
|
||||
import type {GuildSticker} from '@app/features/expressions/models/GuildSticker';
|
||||
import type {GuildRole} from '@app/features/guild/models/GuildRole';
|
||||
import type {GuildMember} from '@app/features/member/models/GuildMember';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
|
||||
export interface AutocompleteMentionMemberOption {
|
||||
type: 'mention';
|
||||
kind: 'member';
|
||||
member: GuildMember;
|
||||
}
|
||||
|
||||
export interface AutocompleteMentionUserOption {
|
||||
type: 'mention';
|
||||
kind: 'user';
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface AutocompleteMentionRoleOption {
|
||||
type: 'mention';
|
||||
kind: 'role';
|
||||
role: GuildRole;
|
||||
}
|
||||
|
||||
export interface AutocompleteSpecialMentionOption {
|
||||
type: 'mention';
|
||||
kind: '@everyone' | '@here';
|
||||
}
|
||||
|
||||
export type AutocompleteOption =
|
||||
| AutocompleteMentionMemberOption
|
||||
| AutocompleteMentionUserOption
|
||||
| AutocompleteMentionRoleOption
|
||||
| AutocompleteSpecialMentionOption
|
||||
| {type: 'channel'; channel: Channel}
|
||||
| {type: 'emoji'; emoji: FlatEmoji}
|
||||
| {type: 'command'; command: Command}
|
||||
| {type: 'meme'; meme: FavoriteMeme}
|
||||
| {type: 'gif'; gif: Gif}
|
||||
| {type: 'sticker'; sticker: GuildSticker}
|
||||
| {type: 'commandChoice'; choice: {name: string; value: string}; description: string}
|
||||
| {type: 'commandOptionalAdd'; name: string; description: string};
|
||||
|
||||
export type AutocompleteType =
|
||||
| 'mention'
|
||||
| 'channel'
|
||||
| 'emoji'
|
||||
| 'command'
|
||||
| 'meme'
|
||||
| 'gif'
|
||||
| 'sticker'
|
||||
| 'commandChoice'
|
||||
| 'commandOptionalAdd';
|
||||
|
||||
export function getAutocompleteOptionId(listboxId: string, index: number): string {
|
||||
return `${listboxId}-option-${index}`;
|
||||
}
|
||||
|
||||
export const isMentionMember = (o: AutocompleteOption): o is AutocompleteMentionMemberOption =>
|
||||
o.type === 'mention' && o.kind === 'member';
|
||||
export const isMentionUser = (o: AutocompleteOption): o is AutocompleteMentionUserOption =>
|
||||
o.type === 'mention' && o.kind === 'user';
|
||||
export const isMentionRole = (o: AutocompleteOption): o is AutocompleteMentionRoleOption =>
|
||||
o.type === 'mention' && o.kind === 'role';
|
||||
export const isSpecialMention = (o: AutocompleteOption): o is AutocompleteSpecialMentionOption =>
|
||||
o.type === 'mention' && (o.kind === '@everyone' || o.kind === '@here');
|
||||
export const isChannel = (o: AutocompleteOption): o is {type: 'channel'; channel: Channel} => o.type === 'channel';
|
||||
export const isEmoji = (o: AutocompleteOption): o is {type: 'emoji'; emoji: FlatEmoji} => o.type === 'emoji';
|
||||
export const isCommand = (o: AutocompleteOption): o is {type: 'command'; command: Command} => o.type === 'command';
|
||||
export const isMeme = (o: AutocompleteOption): o is {type: 'meme'; meme: FavoriteMeme} => o.type === 'meme';
|
||||
export const isGif = (o: AutocompleteOption): o is {type: 'gif'; gif: Gif} => o.type === 'gif';
|
||||
export const isSticker = (o: AutocompleteOption): o is {type: 'sticker'; sticker: GuildSticker} => o.type === 'sticker';
|
||||
export const isCommandChoice = (
|
||||
o: AutocompleteOption,
|
||||
): o is {type: 'commandChoice'; choice: {name: string; value: string}; description: string} =>
|
||||
o.type === 'commandChoice';
|
||||
export const isCommandOptionalAdd = (
|
||||
o: AutocompleteOption,
|
||||
): o is {type: 'commandOptionalAdd'; name: string; description: string} => o.type === 'commandOptionalAdd';
|
||||
@@ -19,8 +19,9 @@ import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import * as MediaViewerCommands from '@app/features/ui/commands/MediaViewerCommands';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {useDragAutoScroll} from '@app/features/ui/hooks/useDragAutoScroll';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
|
||||
@@ -592,6 +593,13 @@ export const ChannelAttachmentArea = observer(({channelId}: {channelId: string})
|
||||
const forceJumpFrameRef = useRef<number | null>(null);
|
||||
const channelIdRef = useRef(channelId);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const getScrollElement = useCallback(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (scroller == null) return null;
|
||||
return scroller.getScrollerNode();
|
||||
}, []);
|
||||
useDragAutoScroll({active: isDragging, axis: 'horizontal', getScrollElement});
|
||||
channelIdRef.current = channelId;
|
||||
const handleAttachmentDrop = useCallback(
|
||||
(item: AttachmentDragItem, result: AttachmentDropResult) => {
|
||||
@@ -683,6 +691,7 @@ export const ChannelAttachmentArea = observer(({channelId}: {channelId: string})
|
||||
return (
|
||||
<>
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
key="channel-attachment-scroller"
|
||||
orientation="horizontal"
|
||||
fade={false}
|
||||
|
||||
@@ -24,46 +24,6 @@
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.typingArea {
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.typingContent {
|
||||
position: absolute;
|
||||
left: var(--textarea-horizontal-padding);
|
||||
right: var(--textarea-horizontal-padding);
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.typingLeft {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
min-width: 0;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.slowmodePin {
|
||||
position: absolute;
|
||||
right: var(--textarea-horizontal-padding);
|
||||
bottom: 0;
|
||||
transform: translateY(50%);
|
||||
pointer-events: auto;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.textareaArea {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
@@ -72,7 +32,7 @@
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
background-color: var(--background-secondary-lighter);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {fetchSlowmodeState} from '@app/features/channel/commands/ChannelCommands';
|
||||
import styles from '@app/features/channel/components/ChannelChatLayout.module.css';
|
||||
import {SlowmodeIndicator} from '@app/features/channel/components/SlowmodeIndicator';
|
||||
import {TypingUsers} from '@app/features/channel/components/TypingUsers';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
import {useSlowmode} from '@app/features/slowmode/hooks/useSlowmode';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect} from 'react';
|
||||
import {createContext, useContext} from 'react';
|
||||
|
||||
const MESSAGES_DESCRIPTOR = msg({
|
||||
message: 'Messages',
|
||||
@@ -23,46 +17,19 @@ const MESSAGE_COMPOSER_DESCRIPTOR = msg({
|
||||
});
|
||||
|
||||
interface ChannelChatLayoutProps {
|
||||
channel: Channel;
|
||||
messages: React.ReactNode;
|
||||
textarea: React.ReactNode;
|
||||
hideBottomBar?: boolean;
|
||||
}
|
||||
|
||||
export function getChannelChatStatusVisibility({
|
||||
hideBottomBar = false,
|
||||
isSlowmodeEnabled,
|
||||
messagesReady,
|
||||
}: {
|
||||
hideBottomBar?: boolean;
|
||||
isSlowmodeEnabled: boolean;
|
||||
messagesReady: boolean;
|
||||
}) {
|
||||
const showFloatingStatus = !hideBottomBar;
|
||||
const showTypingUsers = messagesReady && showFloatingStatus;
|
||||
const showSlowmodeIndicator = isSlowmodeEnabled && showFloatingStatus;
|
||||
return {
|
||||
showTypingUsers,
|
||||
showSlowmodeIndicator,
|
||||
showRow: showTypingUsers || showSlowmodeIndicator,
|
||||
};
|
||||
const ChannelComposerAmbientStatusVisibilityContext = createContext(true);
|
||||
|
||||
export function useChannelComposerAmbientStatusVisibility(): boolean {
|
||||
return useContext(ChannelComposerAmbientStatusVisibilityContext);
|
||||
}
|
||||
|
||||
export const ChannelChatLayout = observer(({channel, messages, textarea, hideBottomBar}: ChannelChatLayoutProps) => {
|
||||
export const ChannelChatLayout = observer(({messages, textarea, hideBottomBar = false}: ChannelChatLayoutProps) => {
|
||||
const {i18n} = useLingui();
|
||||
const {slowmodeRemaining, isSlowmodeEnabled, isSlowmodeImmune} = useSlowmode(channel);
|
||||
void Messages.version;
|
||||
const messagesReady = Messages.getMessages(channel.id).ready;
|
||||
const shouldFetchSlowmode = Boolean(channel.guildId) && (channel.rateLimitPerUser ?? 0) > 0;
|
||||
useEffect(() => {
|
||||
if (!shouldFetchSlowmode) return;
|
||||
void fetchSlowmodeState(channel.id);
|
||||
}, [channel.id, shouldFetchSlowmode]);
|
||||
const {showTypingUsers, showSlowmodeIndicator} = getChannelChatStatusVisibility({
|
||||
hideBottomBar,
|
||||
isSlowmodeEnabled,
|
||||
messagesReady,
|
||||
});
|
||||
return (
|
||||
<div className={styles.container} data-flx="channel.channel-chat-layout.container">
|
||||
<section
|
||||
@@ -72,31 +39,14 @@ export const ChannelChatLayout = observer(({channel, messages, textarea, hideBot
|
||||
>
|
||||
{messages}
|
||||
</section>
|
||||
<div className={styles.typingArea} data-flx="channel.channel-chat-layout.typing-area">
|
||||
{showTypingUsers && (
|
||||
<div className={styles.typingContent} data-flx="channel.channel-chat-layout.typing-content">
|
||||
<div className={styles.typingLeft} data-flx="channel.channel-chat-layout.typing-left">
|
||||
<TypingUsers channel={channel} data-flx="channel.channel-chat-layout.typing-users" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showSlowmodeIndicator && (
|
||||
<div className={styles.slowmodePin} data-flx="channel.channel-chat-layout.slowmode-target">
|
||||
<SlowmodeIndicator
|
||||
slowmodeRemaining={slowmodeRemaining}
|
||||
slowmodeDuration={channel.rateLimitPerUser * 1000}
|
||||
isImmune={isSlowmodeImmune}
|
||||
data-flx="channel.channel-chat-layout.slowmode-indicator"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<section
|
||||
className={styles.textareaArea}
|
||||
aria-label={i18n._(MESSAGE_COMPOSER_DESCRIPTOR)}
|
||||
data-flx="channel.channel-chat-layout.textarea-area"
|
||||
>
|
||||
{textarea}
|
||||
<ChannelComposerAmbientStatusVisibilityContext.Provider value={!hideBottomBar}>
|
||||
{textarea}
|
||||
</ChannelComposerAmbientStatusVisibilityContext.Provider>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {EditBar} from '@app/features/channel/components/ChannelEditBar';
|
||||
import {ReplyBar} from '@app/features/channel/components/ChannelReplyBar';
|
||||
import {SlowmodeIndicator} from '@app/features/channel/components/SlowmodeIndicator';
|
||||
import {TypingUsers, usePresentableTypingUsers} from '@app/features/channel/components/TypingUsers';
|
||||
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
|
||||
interface ChannelComposerStatusRailProps {
|
||||
channel: Channel;
|
||||
editingMessage: Message | null;
|
||||
referencedMessage: Message | null;
|
||||
shouldReplyMention: boolean;
|
||||
setShouldReplyMention: (mentioning: boolean) => void;
|
||||
autocompleteVisible: boolean;
|
||||
showTypingStatus: boolean;
|
||||
showSlowmodeStatus: boolean;
|
||||
slowmodeEnabled: boolean;
|
||||
slowmodeRemaining: number;
|
||||
slowmodeImmune: boolean;
|
||||
mobile: boolean;
|
||||
leadingContentInFlow: boolean;
|
||||
onCancelEdit: () => void;
|
||||
}
|
||||
|
||||
export const ChannelComposerStatusRail = observer(function ChannelComposerStatusRail({
|
||||
channel,
|
||||
editingMessage,
|
||||
referencedMessage,
|
||||
shouldReplyMention,
|
||||
setShouldReplyMention,
|
||||
autocompleteVisible,
|
||||
showTypingStatus,
|
||||
showSlowmodeStatus,
|
||||
slowmodeEnabled,
|
||||
slowmodeRemaining,
|
||||
slowmodeImmune,
|
||||
mobile,
|
||||
leadingContentInFlow,
|
||||
onCancelEdit,
|
||||
}: ChannelComposerStatusRailProps) {
|
||||
const presentableTypingUsers = usePresentableTypingUsers(channel);
|
||||
const mobileEditVisible = editingMessage !== null && mobile;
|
||||
const replyVisible = !mobileEditVisible && referencedMessage !== null;
|
||||
const leadingContentVisible = mobileEditVisible || replyVisible;
|
||||
const typingVisible = showTypingStatus && !autocompleteVisible && presentableTypingUsers.length > 0;
|
||||
const slowmodeVisible = showSlowmodeStatus && slowmodeEnabled;
|
||||
if (!leadingContentVisible && !typingVisible && !slowmodeVisible) {
|
||||
return null;
|
||||
}
|
||||
let topBar: React.ReactNode = null;
|
||||
if (mobileEditVisible) {
|
||||
topBar = <EditBar channel={channel} onCancel={onCancelEdit} data-flx="channel.composer-status-rail.edit-bar" />;
|
||||
} else if (referencedMessage !== null) {
|
||||
topBar = (
|
||||
<ReplyBar
|
||||
replyingMessageObject={referencedMessage}
|
||||
shouldReplyMention={shouldReplyMention}
|
||||
setShouldReplyMention={setShouldReplyMention}
|
||||
channel={channel}
|
||||
data-flx="channel.composer-status-rail.reply-bar"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={clsx(wrapperStyles.statusRail, leadingContentInFlow && wrapperStyles.statusRailInFlow)}
|
||||
data-flx="channel.composer-status-rail.container"
|
||||
>
|
||||
<div
|
||||
className={clsx(wrapperStyles.statusRailLeft, leadingContentVisible && wrapperStyles.statusRailLeftWithLeading)}
|
||||
data-flx="channel.composer-status-rail.left"
|
||||
>
|
||||
{topBar !== null && (
|
||||
<div className={wrapperStyles.topBarContainer} data-flx="channel.composer-status-rail.top-bar">
|
||||
{topBar}
|
||||
</div>
|
||||
)}
|
||||
{typingVisible && (
|
||||
<div className={wrapperStyles.statusTypingSlot} data-flx="channel.composer-status-rail.typing-slot">
|
||||
<TypingUsers channel={channel} data-flx="channel.composer-status-rail.typing-users" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{slowmodeVisible && (
|
||||
<div className={wrapperStyles.statusSlowmodeSlot} data-flx="channel.composer-status-rail.slowmode-slot">
|
||||
<SlowmodeIndicator
|
||||
slowmodeRemaining={slowmodeRemaining}
|
||||
slowmodeDuration={channel.rateLimitPerUser * 1000}
|
||||
isImmune={slowmodeImmune}
|
||||
data-flx="channel.composer-status-rail.slowmode-indicator"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -4,31 +4,42 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0.5rem 0 0.5rem 1rem;
|
||||
width: var(--composer-status-pill-height, 1.5rem);
|
||||
height: var(--composer-status-pill-height, 1.5rem);
|
||||
padding: 0;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--text-primary-muted);
|
||||
line-height: 0;
|
||||
transition: color 200ms;
|
||||
transition:
|
||||
color 200ms,
|
||||
background-color 200ms;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
REMOVE_FROM_FAVORITES_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import type {LexicalSearchInputHandle} from '@app/features/lexical/search/LexicalSearchInput';
|
||||
import {useCanFitMemberList} from '@app/features/member/hooks/useMemberListVisible';
|
||||
import MemberList from '@app/features/member/state/MemberList';
|
||||
import * as FavoritesCommands from '@app/features/messaging/commands/FavoritesCommands';
|
||||
@@ -201,7 +202,7 @@ export const ChannelHeader = observer(
|
||||
onVoiceCallChromePinChange?.(false);
|
||||
};
|
||||
}, [onVoiceCallChromePinChange]);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<LexicalSearchInputHandle>(null);
|
||||
const isSearchResultsVisible = isSearchResultsOpen ?? isSearchActive;
|
||||
const dmNameRef = useRef<HTMLSpanElement>(null);
|
||||
const groupDMNameRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
@@ -5,14 +5,37 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.groupHeader,
|
||||
.virtualGroupRow {
|
||||
color: var(--text-primary-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.groupHeaderLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupHeaderSeparator {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupHeaderCount {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.membersList {
|
||||
@@ -49,34 +72,14 @@
|
||||
|
||||
.virtualGroupRow {
|
||||
display: flex;
|
||||
height: 1.875rem;
|
||||
min-height: 1.875rem;
|
||||
align-items: flex-end;
|
||||
gap: 0.375rem;
|
||||
height: 2rem;
|
||||
min-height: 2rem;
|
||||
align-items: flex-start;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0.1875rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.25rem;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.virtualGroupLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.virtualGroupSeparator {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.virtualGroupCount {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupSpacer {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {OutlineFrame} from '@app/features/app/components/layout/OutlineFrame';
|
||||
import Authentication from '@app/features/auth/state/Authentication';
|
||||
import styles from '@app/features/channel/components/ChannelMembers.module.css';
|
||||
import {UserTag} from '@app/features/channel/components/ChannelUserTag';
|
||||
import {CompactMemberCustomStatus} from '@app/features/channel/components/CompactMemberCustomStatus';
|
||||
import {MemberListContainer} from '@app/features/channel/components/MemberListContainer';
|
||||
import {MemberListItem} from '@app/features/channel/components/MemberListItem';
|
||||
import memberItemStyles from '@app/features/channel/components/MemberListItem.module.css';
|
||||
import {MemberListUnavailableFallback} from '@app/features/channel/components/shared/MemberListUnavailableFallback';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
@@ -24,7 +22,6 @@ import {
|
||||
getTotalRowsFromLayout,
|
||||
} from '@app/features/member/utils/MemberListLayout';
|
||||
import {
|
||||
areNormalizedMemberListRangesCovered,
|
||||
areNormalizedMemberListRangesEqual,
|
||||
buildMemberListRangeWindow,
|
||||
buildMemberListRenderWindow,
|
||||
@@ -36,29 +33,23 @@ import * as MemberListUtils from '@app/features/member/utils/MemberListUtils';
|
||||
import * as PermissionUtils from '@app/features/permissions/utils/PermissionUtils';
|
||||
import Presence from '@app/features/presence/state/Presence';
|
||||
import {openRoleContextMenu, openRoleContextMenuForElement} from '@app/features/ui/action_menu/RoleContextMenu';
|
||||
import {BaseAvatar} from '@app/features/ui/components/BaseAvatar';
|
||||
import type {ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import {getAppZoomFactor} from '@app/features/ui/utils/AppZoomUtils';
|
||||
import type {User} from '@app/features/user/models/User';
|
||||
import type {CustomStatus} from '@app/features/user/state/CustomStatus';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import * as AvatarUtils from '@app/features/user/utils/AvatarUtils';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {MEMBER_LIST_RANGE_MAX_SPAN} from '@fluxer/constants/src/GatewayConstants';
|
||||
import {GuildFeatures, GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {StatusType} from '@fluxer/constants/src/StatusConstants';
|
||||
import {isOfflineStatus} from '@fluxer/constants/src/StatusConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {useLingui as useLinguiRuntime} from '@lingui/react';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {CrownIcon} from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {CSSProperties, ReactNode, RefObject, UIEvent} from 'react';
|
||||
import type {ReactNode, UIEvent} from 'react';
|
||||
import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const MEMBER_ITEM_HEIGHT = 44;
|
||||
const GROUP_HEADER_HEIGHT = 30;
|
||||
const GROUP_HEADER_HEIGHT = 32;
|
||||
const INITIAL_SUBSCRIPTION_RANGE: [number, number] = [0, MEMBER_LIST_RANGE_MAX_SPAN];
|
||||
const INITIAL_RENDER_RANGE: [number, number] = [0, 64];
|
||||
const INITIAL_SUBSCRIPTION_RANGES = normalizeMemberListRanges([INITIAL_SUBSCRIPTION_RANGE]);
|
||||
@@ -69,57 +60,8 @@ const SUBSCRIPTION_OVERSCAN_PAGES = 0;
|
||||
const RENDER_BUFFER_ROWS = 6;
|
||||
const AVATAR_DEFER_AFTER_SCROLL_IDLE_MS = 180;
|
||||
const MEMBER_LIST_AVATAR_MEDIA_SIZE = 64;
|
||||
|
||||
type FrozenMemberListRow =
|
||||
| {
|
||||
type: 'skeleton';
|
||||
key: string;
|
||||
rowIndex: number;
|
||||
rowTop: number;
|
||||
}
|
||||
| {
|
||||
type: 'group';
|
||||
key: string;
|
||||
groupName: string;
|
||||
count: number;
|
||||
rowTop: number;
|
||||
}
|
||||
| {
|
||||
type: 'member';
|
||||
key: string;
|
||||
avatarUrl: string;
|
||||
bot: boolean;
|
||||
customStatus: CustomStatus | null;
|
||||
displayName: string;
|
||||
isCurrentUser: boolean;
|
||||
isOffline: boolean;
|
||||
roleColor?: string;
|
||||
showOwnerCrown: boolean;
|
||||
status: StatusType;
|
||||
system: boolean;
|
||||
userTag: string;
|
||||
rowTop: number;
|
||||
};
|
||||
|
||||
interface FrozenMemberListSnapshot {
|
||||
channelId: string;
|
||||
estimatedContentSize: number;
|
||||
virtualContentHeight: number;
|
||||
rows: Array<FrozenMemberListRow>;
|
||||
}
|
||||
|
||||
function getFrozenRowStyle(rowTop: number): CSSProperties {
|
||||
return {transform: `translateY(${rowTop}px)`};
|
||||
}
|
||||
|
||||
function createInitialFrozenMemberListSnapshot(channelId: string): FrozenMemberListSnapshot {
|
||||
return {
|
||||
channelId,
|
||||
estimatedContentSize: 0,
|
||||
virtualContentHeight: 0,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
const INITIAL_LOADING_SKELETON_COUNT = 24;
|
||||
const MEMBER_LIST_SKELETON_INDEXES = Array.from({length: INITIAL_LOADING_SKELETON_COUNT}, (_, index) => index);
|
||||
|
||||
function getSeededRandom(seed: number): number {
|
||||
const x = Math.sin(seed) * 10000;
|
||||
@@ -157,141 +99,30 @@ function SkeletonMemberItem({index}: {index: number}) {
|
||||
);
|
||||
}
|
||||
|
||||
function FrozenMemberListItem({row}: {row: Extract<FrozenMemberListRow, {type: 'member'}>}) {
|
||||
const nameStyle = row.roleColor ? ({['--member-role-color' as string]: row.roleColor} as CSSProperties) : undefined;
|
||||
const MemberListLoadingSkeleton = memo(function MemberListLoadingSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className={clsx(memberItemStyles.button, row.isOffline && memberItemStyles.buttonOffline)}
|
||||
aria-hidden="true"
|
||||
data-flx="channel.channel-members.frozen-member-list-item"
|
||||
>
|
||||
<div className={memberItemStyles.grid} data-flx="channel.channel-members.frozen-member-list-item.grid">
|
||||
<span className={memberItemStyles.content} data-flx="channel.channel-members.frozen-member-list-item.content">
|
||||
<div
|
||||
className={memberItemStyles.avatarContainer}
|
||||
data-flx="channel.channel-members.frozen-member-list-item.avatar-container"
|
||||
>
|
||||
<BaseAvatar
|
||||
size={32}
|
||||
avatarUrl={row.avatarUrl}
|
||||
status={row.status}
|
||||
showOffline={row.isCurrentUser}
|
||||
userTag={row.userTag}
|
||||
disableStatusTooltip
|
||||
data-flx="channel.channel-members.frozen-member-list-item.avatar"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={memberItemStyles.userInfoContainer}
|
||||
data-flx="channel.channel-members.frozen-member-list-item.user-info-container"
|
||||
>
|
||||
<div
|
||||
className={memberItemStyles.nameContainer}
|
||||
data-flx="channel.channel-members.frozen-member-list-item.name-container"
|
||||
>
|
||||
<span
|
||||
className={clsx(memberItemStyles.name, row.roleColor && memberItemStyles.nameRoleColored)}
|
||||
style={nameStyle}
|
||||
data-flx="channel.channel-members.frozen-member-list-item.name"
|
||||
>
|
||||
{row.displayName}
|
||||
</span>
|
||||
{row.showOwnerCrown && (
|
||||
<div
|
||||
className={memberItemStyles.ownerIcon}
|
||||
data-flx="channel.channel-members.frozen-member-list-item.owner-icon"
|
||||
>
|
||||
<CrownIcon
|
||||
className={memberItemStyles.crownIcon}
|
||||
aria-hidden="true"
|
||||
data-flx="channel.channel-members.frozen-member-list-item.crown-icon"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{row.bot && (
|
||||
<UserTag
|
||||
className={memberItemStyles.userTag}
|
||||
system={row.system}
|
||||
data-flx="channel.channel-members.frozen-member-list-item.user-tag"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<CompactMemberCustomStatus
|
||||
customStatus={row.customStatus}
|
||||
className={memberItemStyles.memberCustomStatus}
|
||||
data-flx="channel.channel-members.frozen-member-list-item.custom-status"
|
||||
/>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.membersList}>
|
||||
{MEMBER_LIST_SKELETON_INDEXES.map((index) => (
|
||||
<SkeletonMemberItem key={`member-loading-skeleton-${index}`} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface MemberListGroupHeaderContentProps {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const FrozenMemberList = memo(function FrozenMemberList({
|
||||
snapshot,
|
||||
scrollerRef,
|
||||
}: {
|
||||
snapshot: FrozenMemberListSnapshot;
|
||||
scrollerRef: RefObject<ScrollerHandle | null>;
|
||||
}) {
|
||||
function MemberListGroupHeaderContent({name, count}: MemberListGroupHeaderContentProps) {
|
||||
return (
|
||||
<MemberListContainer
|
||||
channelId={snapshot.channelId}
|
||||
scrollerRef={scrollerRef}
|
||||
estimatedContentSize={snapshot.estimatedContentSize}
|
||||
data-flx="channel.channel-members.frozen-member-list.member-list-container"
|
||||
>
|
||||
<div
|
||||
className={styles.virtualListContent}
|
||||
style={{height: `${Math.max(0, snapshot.virtualContentHeight)}px`}}
|
||||
data-flx="channel.channel-members.frozen-member-list.virtual-list-content"
|
||||
>
|
||||
{snapshot.rows.map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={clsx(styles.virtualRow, row.type === 'group' ? styles.virtualGroupRow : styles.virtualMemberRow)}
|
||||
style={getFrozenRowStyle(row.rowTop)}
|
||||
data-flx="channel.channel-members.frozen-member-list.virtual-row"
|
||||
>
|
||||
{row.type === 'skeleton' ? (
|
||||
<SkeletonMemberItem
|
||||
index={row.rowIndex}
|
||||
data-flx="channel.channel-members.frozen-member-list.skeleton-member-item"
|
||||
/>
|
||||
) : row.type === 'group' ? (
|
||||
<>
|
||||
<span
|
||||
className={styles.virtualGroupLabel}
|
||||
data-flx="channel.channel-members.frozen-member-list.group-label"
|
||||
>
|
||||
{row.groupName}
|
||||
</span>
|
||||
<span
|
||||
className={styles.virtualGroupSeparator}
|
||||
data-flx="channel.channel-members.frozen-member-list.group-separator"
|
||||
>
|
||||
{'\u2014'}
|
||||
</span>
|
||||
<span
|
||||
className={styles.virtualGroupCount}
|
||||
data-flx="channel.channel-members.frozen-member-list.group-count"
|
||||
>
|
||||
{row.count}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<FrozenMemberListItem
|
||||
row={row}
|
||||
data-flx="channel.channel-members.frozen-member-list.frozen-member-list-item"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</MemberListContainer>
|
||||
<>
|
||||
<span className={styles.groupHeaderLabel}>{name}</span>
|
||||
<span className={styles.groupHeaderSeparator}>{'—'}</span>
|
||||
<span className={styles.groupHeaderCount}>{count}</span>
|
||||
</>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
interface GroupDMMemberListGroupProps {
|
||||
group: GroupDMMemberGroup;
|
||||
@@ -302,7 +133,7 @@ interface GroupDMMemberListGroupProps {
|
||||
const GroupDMMemberListGroup = observer(({group, channelId, ownerId}: GroupDMMemberListGroupProps) => (
|
||||
<div className={styles.groupContainer} data-flx="channel.channel-members.group-dm-member-list-group.group-container">
|
||||
<div className={styles.groupHeader} data-flx="channel.channel-members.group-dm-member-list-group.group-header">
|
||||
{group.displayName} {'\u2014'} {group.count}
|
||||
<MemberListGroupHeaderContent name={group.displayName} count={group.count} />
|
||||
</div>
|
||||
<div className={styles.membersList} data-flx="channel.channel-members.group-dm-member-list-group.members-list">
|
||||
{group.users.map((user) => {
|
||||
@@ -338,28 +169,22 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
const avatarDeferDeadlineRef = useRef(0);
|
||||
const pendingScrollMetricsRef = useRef<{scrollTop: number; clientHeight: number} | null>(null);
|
||||
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const frozenSnapshotRef = useRef<FrozenMemberListSnapshot | null>(null);
|
||||
const wasSubscriptionPausedRef = useRef(false);
|
||||
const [renderWindowRanges, setRenderWindowRanges] = useState<NormalizedMemberListRanges>(INITIAL_RENDER_RANGES);
|
||||
const [deferAvatarLoad, setDeferAvatarLoad] = useState(false);
|
||||
const [keepFrozenAfterResume, setKeepFrozenAfterResume] = useState(false);
|
||||
const memberListIdentityKey = MemberSidebar.getListIdentityKey(guild.id, channel.id);
|
||||
const memberListUpdatesDisabled = (guild.disabledOperations & GuildOperations.MEMBER_LIST_UPDATES) !== 0;
|
||||
const currentUserId = Authentication.currentUserId;
|
||||
const lacksMemberViewPermission =
|
||||
currentUserId != null && !PermissionUtils.can(Permissions.VIEW_CHANNEL_MEMBERS, currentUserId, channel.toJSON());
|
||||
const {subscribe, isPaused: isSubscriptionPaused} = useMemberListSubscription({
|
||||
const {subscribe} = useMemberListSubscription({
|
||||
guildId: guild.id,
|
||||
channelId: channel.id,
|
||||
enabled: !memberListUpdatesDisabled && !lacksMemberViewPermission,
|
||||
});
|
||||
const initialFrozenSnapshot = useMemo(
|
||||
() => createInitialFrozenMemberListSnapshot(memberListIdentityKey),
|
||||
[memberListIdentityKey],
|
||||
);
|
||||
const memberListState = isSubscriptionPaused ? undefined : MemberSidebar.getList(guild.id, channel.id);
|
||||
const memberListState = MemberSidebar.getList(guild.id, channel.id);
|
||||
const memberCount = memberListState?.memberCount ?? 0;
|
||||
const groups = memberListState?.groups ?? [];
|
||||
const zoomLevel = Accessibility.zoomLevel;
|
||||
const layouts = useMemo(() => buildMemberListLayout(groups), [groups]);
|
||||
const groupById = useMemo(() => new Map(groups.map((group) => [group.id, group])), [groups]);
|
||||
const totalRows = useMemo(() => {
|
||||
@@ -379,7 +204,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
headerHeight: scaledGroupHeaderHeight,
|
||||
})
|
||||
: null,
|
||||
[layouts, totalRows, scaledMemberItemHeight, scaledGroupHeaderHeight],
|
||||
[layouts, totalRows, scaledMemberItemHeight, scaledGroupHeaderHeight, zoomLevel],
|
||||
);
|
||||
const contentHeight = useMemo(
|
||||
() => (rowOffsets != null ? rowOffsets[rowOffsets.length - 1]! : Math.max(0, totalRows * scaledMemberItemHeight)),
|
||||
@@ -397,16 +222,6 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
[memberListState?.hasReceivedInitialPayload, renderWindowRanges, subscribedRanges, totalRows],
|
||||
);
|
||||
const {isInitialLoading, renderRanges} = viewportModel;
|
||||
const canThawFrozenMemberList =
|
||||
!isSubscriptionPaused &&
|
||||
memberListState != null &&
|
||||
memberListState.hasReceivedInitialPayload &&
|
||||
areNormalizedMemberListRangesCovered(subscriptionRangesRef.current, subscribedRanges);
|
||||
const shouldStartResumeFreeze =
|
||||
!isSubscriptionPaused &&
|
||||
wasSubscriptionPausedRef.current &&
|
||||
frozenSnapshotRef.current?.channelId === memberListIdentityKey;
|
||||
const shouldKeepFrozenAfterResume = (keepFrozenAfterResume || shouldStartResumeFreeze) && !canThawFrozenMemberList;
|
||||
const getGroupName = useCallback(
|
||||
(groupId: string) => {
|
||||
if (groupId === 'online') {
|
||||
@@ -421,9 +236,6 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
);
|
||||
const commitRangeUpdate = useCallback(
|
||||
(scrollTop: number, clientHeight: number) => {
|
||||
if (isSubscriptionPaused) {
|
||||
return;
|
||||
}
|
||||
const nextSubscriptionRanges = buildMemberListRangeWindow({
|
||||
scrollTop,
|
||||
clientHeight,
|
||||
@@ -450,7 +262,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
subscribe(nextSubscriptionRanges);
|
||||
}
|
||||
},
|
||||
[isSubscriptionPaused, subscribe, totalRows, rowOffsets, scaledMemberItemHeight],
|
||||
[subscribe, totalRows, rowOffsets, scaledMemberItemHeight, zoomLevel],
|
||||
);
|
||||
const finishAvatarLoadingDeferralAfterIdle = useCallback(() => {
|
||||
avatarDeferTimerRef.current = null;
|
||||
@@ -511,40 +323,16 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
scheduleRangeUpdateFromScroller();
|
||||
}, [scheduleRangeUpdateFromScroller]);
|
||||
useEffect(() => {
|
||||
setDeferAvatarLoad(false);
|
||||
const initialSubscriptionRanges = INITIAL_SUBSCRIPTION_RANGES;
|
||||
const initialRenderRanges = INITIAL_RENDER_RANGES;
|
||||
subscriptionRangesRef.current = initialSubscriptionRanges;
|
||||
renderRangesRef.current = initialRenderRanges;
|
||||
wasSubscriptionPausedRef.current = false;
|
||||
setKeepFrozenAfterResume(false);
|
||||
setRenderWindowRanges(initialRenderRanges);
|
||||
}, [memberListIdentityKey, guild.id]);
|
||||
useEffect(() => {
|
||||
if (isSubscriptionPaused) {
|
||||
return;
|
||||
}
|
||||
scheduleRangeUpdateFromScroller();
|
||||
}, [isSubscriptionPaused, scheduleRangeUpdateFromScroller, totalRows]);
|
||||
useEffect(() => {
|
||||
if (isSubscriptionPaused) {
|
||||
wasSubscriptionPausedRef.current = true;
|
||||
setKeepFrozenAfterResume(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
wasSubscriptionPausedRef.current &&
|
||||
frozenSnapshotRef.current?.channelId === memberListIdentityKey &&
|
||||
!canThawFrozenMemberList
|
||||
) {
|
||||
setKeepFrozenAfterResume(true);
|
||||
}
|
||||
wasSubscriptionPausedRef.current = false;
|
||||
}, [memberListIdentityKey, isSubscriptionPaused, canThawFrozenMemberList]);
|
||||
useEffect(() => {
|
||||
if (keepFrozenAfterResume && canThawFrozenMemberList) {
|
||||
setKeepFrozenAfterResume(false);
|
||||
}
|
||||
}, [keepFrozenAfterResume, canThawFrozenMemberList]);
|
||||
}, [scheduleRangeUpdateFromScroller, totalRows]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (scrollFrameRef.current != null) {
|
||||
@@ -584,26 +372,6 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
</MemberListContainer>
|
||||
);
|
||||
}
|
||||
const currentFrozenSnapshot =
|
||||
frozenSnapshotRef.current?.channelId === memberListIdentityKey ? frozenSnapshotRef.current : initialFrozenSnapshot;
|
||||
if (isSubscriptionPaused) {
|
||||
return (
|
||||
<FrozenMemberList
|
||||
snapshot={currentFrozenSnapshot}
|
||||
scrollerRef={scrollerRef}
|
||||
data-flx="channel.channel-members.lazy-member-list.frozen-member-list"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (shouldKeepFrozenAfterResume && frozenSnapshotRef.current?.channelId === memberListIdentityKey) {
|
||||
return (
|
||||
<FrozenMemberList
|
||||
snapshot={frozenSnapshotRef.current}
|
||||
scrollerRef={scrollerRef}
|
||||
data-flx="channel.channel-members.lazy-member-list.frozen-member-list--2"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isInitialLoading || !memberListState) {
|
||||
return (
|
||||
<MemberListContainer
|
||||
@@ -611,14 +379,11 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
identityKey={memberListIdentityKey}
|
||||
data-flx="channel.channel-members.lazy-member-list.member-list-container--3"
|
||||
>
|
||||
{null}
|
||||
<MemberListLoadingSkeleton />
|
||||
</MemberListContainer>
|
||||
);
|
||||
}
|
||||
const virtualRows: Array<ReactNode> = [];
|
||||
const frozenRows: Array<FrozenMemberListRow> = [];
|
||||
const virtualContentHeight = Math.max(0, totalRows * scaledMemberItemHeight);
|
||||
const hideOwnerCrown = guild.features.has(GuildFeatures.HIDE_OWNER_CROWN);
|
||||
for (const [rangeStart, rangeEnd] of renderRanges) {
|
||||
const firstRow = Math.max(0, rangeStart);
|
||||
const lastRow = totalRows > 0 ? Math.min(rangeEnd, totalRows - 1) : -1;
|
||||
@@ -628,12 +393,6 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
if (layouts.length === 0) {
|
||||
const item = memberListState.items.get(rowIndex);
|
||||
if (!item) {
|
||||
frozenRows.push({
|
||||
type: 'skeleton',
|
||||
key: `member-skeleton-${rowIndex}`,
|
||||
rowIndex,
|
||||
rowTop,
|
||||
});
|
||||
virtualRows.push(
|
||||
<div
|
||||
key={`member-skeleton-${rowIndex}`}
|
||||
@@ -662,29 +421,6 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
userId: user.id,
|
||||
});
|
||||
const roleColor = member.getColorString?.() ?? undefined;
|
||||
frozenRows.push({
|
||||
type: 'member',
|
||||
key: `member-${rowIndex}-${user.id}`,
|
||||
avatarUrl: AvatarUtils.getGuildMemberDisplayAvatarURL({
|
||||
guildId: guild.id,
|
||||
user,
|
||||
memberAvatar: member.avatar,
|
||||
avatarUnset: member.isAvatarUnset(),
|
||||
animated: false,
|
||||
size: MEMBER_LIST_AVATAR_MEDIA_SIZE,
|
||||
}),
|
||||
bot: user.bot,
|
||||
customStatus,
|
||||
displayName,
|
||||
isCurrentUser: user.id === currentUserId,
|
||||
isOffline: user.id !== currentUserId && isOfflineStatus(status),
|
||||
roleColor,
|
||||
showOwnerCrown: guild.isOwner(user.id) && !hideOwnerCrown,
|
||||
status,
|
||||
system: user.system,
|
||||
userTag: user.tag,
|
||||
rowTop,
|
||||
});
|
||||
virtualRows.push(
|
||||
<div
|
||||
key={`member-${rowIndex}-${user.id}`}
|
||||
@@ -720,35 +456,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
const group = groupById.get(layout.id) ?? {id: layout.id, count: layout.count};
|
||||
const role = group.id === 'online' || group.id === 'offline' ? null : (guild.getRole(group.id) ?? null);
|
||||
const groupName = getGroupName(group.id);
|
||||
const groupRowContent = (
|
||||
<>
|
||||
<span
|
||||
className={styles.virtualGroupLabel}
|
||||
data-flx="channel.channel-members.lazy-member-list.virtual-row.group-label"
|
||||
>
|
||||
{groupName}
|
||||
</span>
|
||||
<span
|
||||
className={styles.virtualGroupSeparator}
|
||||
data-flx="channel.channel-members.lazy-member-list.virtual-row.group-separator"
|
||||
>
|
||||
{'\u2014'}
|
||||
</span>
|
||||
<span
|
||||
className={styles.virtualGroupCount}
|
||||
data-flx="channel.channel-members.lazy-member-list.virtual-row.group-count"
|
||||
>
|
||||
{group.count}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
frozenRows.push({
|
||||
type: 'group',
|
||||
key: `group-${rowIndex}-${group.id}`,
|
||||
groupName,
|
||||
count: group.count,
|
||||
rowTop,
|
||||
});
|
||||
const groupRowContent = <MemberListGroupHeaderContent name={groupName} count={group.count} />;
|
||||
if (role) {
|
||||
virtualRows.push(
|
||||
<div
|
||||
@@ -786,12 +494,6 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
}
|
||||
const item = memberListState.items.get(rowIndex);
|
||||
if (!item) {
|
||||
frozenRows.push({
|
||||
type: 'skeleton',
|
||||
key: `member-skeleton-${rowIndex}`,
|
||||
rowIndex,
|
||||
rowTop,
|
||||
});
|
||||
virtualRows.push(
|
||||
<div
|
||||
key={`member-skeleton-${rowIndex}`}
|
||||
@@ -820,29 +522,6 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
userId: user.id,
|
||||
});
|
||||
const roleColor = member.getColorString?.() ?? undefined;
|
||||
frozenRows.push({
|
||||
type: 'member',
|
||||
key: `member-${rowIndex}-${user.id}`,
|
||||
avatarUrl: AvatarUtils.getGuildMemberDisplayAvatarURL({
|
||||
guildId: guild.id,
|
||||
user,
|
||||
memberAvatar: member.avatar,
|
||||
avatarUnset: member.isAvatarUnset(),
|
||||
animated: false,
|
||||
size: MEMBER_LIST_AVATAR_MEDIA_SIZE,
|
||||
}),
|
||||
bot: user.bot,
|
||||
customStatus,
|
||||
displayName,
|
||||
isCurrentUser: user.id === currentUserId,
|
||||
isOffline: user.id !== currentUserId && isOfflineStatus(status),
|
||||
roleColor,
|
||||
showOwnerCrown: guild.isOwner(user.id) && !hideOwnerCrown,
|
||||
status,
|
||||
system: user.system,
|
||||
userTag: user.tag,
|
||||
rowTop,
|
||||
});
|
||||
virtualRows.push(
|
||||
<div
|
||||
key={`member-${rowIndex}-${user.id}`}
|
||||
@@ -870,12 +549,6 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
);
|
||||
}
|
||||
}
|
||||
frozenSnapshotRef.current = {
|
||||
channelId: memberListIdentityKey,
|
||||
estimatedContentSize: contentHeight,
|
||||
virtualContentHeight,
|
||||
rows: frozenRows,
|
||||
};
|
||||
return (
|
||||
<MemberListContainer
|
||||
channelId={channel.id}
|
||||
@@ -888,7 +561,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
|
||||
>
|
||||
<div
|
||||
className={styles.virtualListContent}
|
||||
style={{height: `${virtualContentHeight}px`}}
|
||||
style={{height: `${contentHeight}px`}}
|
||||
data-flx="channel.channel-members.lazy-member-list.virtual-list-content"
|
||||
>
|
||||
{virtualRows}
|
||||
|
||||
@@ -76,6 +76,29 @@
|
||||
}
|
||||
|
||||
.newMessagesBar {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 0.625rem;
|
||||
transform: translateX(-50%);
|
||||
z-index: var(--z-index-elevated-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: max-content;
|
||||
max-width: calc(100% - 2rem);
|
||||
min-height: 2.75rem;
|
||||
padding: 0.375rem 0.5rem 0.375rem 0.875rem;
|
||||
border: 0.0625rem solid color-mix(in srgb, var(--background-modifier-accent) 80%, transparent);
|
||||
border-radius: var(--radius-full);
|
||||
background: color-mix(in srgb, var(--background-textarea) 88%, var(--background-tertiary) 12%);
|
||||
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.18);
|
||||
box-sizing: border-box;
|
||||
color: var(--text-primary);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.newMessagesBarBanner {
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
@@ -90,22 +113,7 @@
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.jumpToPresentBar {
|
||||
box-sizing: border-box;
|
||||
border: 0.0625rem solid color-mix(in srgb, var(--background-modifier-accent) 80%, transparent);
|
||||
border-bottom: 0;
|
||||
background: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.jumpToPresentBar .newMessagesBarText {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.jumpToPresentBar .newMessagesBarAction {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.newMessagesBarText {
|
||||
.newMessagesBarBannerText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -123,7 +131,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newMessagesBarAction {
|
||||
.newMessagesBarBannerAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
@@ -137,6 +145,82 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.messageBottomPill {
|
||||
top: auto;
|
||||
bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.jumpToPresentBar {
|
||||
background: color-mix(in srgb, var(--background-textarea) 90%, var(--background-tertiary) 10%);
|
||||
}
|
||||
|
||||
.jumpToPresentBar .newMessagesBarText {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loadErrorBar {
|
||||
border-color: color-mix(in srgb, var(--status-danger) 48%, var(--background-modifier-accent) 52%);
|
||||
background: color-mix(in srgb, var(--status-danger) 12%, var(--background-textarea) 88%);
|
||||
}
|
||||
|
||||
.loadErrorBar .newMessagesBarText {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.newMessagesBarText {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: min(34rem, calc(100vw - 14rem));
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.newMessagesBarAction {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
min-height: 2rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newMessagesBarAction:hover:not(:disabled) {
|
||||
background: var(--background-modifier-active);
|
||||
}
|
||||
|
||||
.newMessagesBarAction:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.newMessagesBar {
|
||||
max-width: calc(100% - 1rem);
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
.newMessagesBarText {
|
||||
max-width: min(45vw, 14rem);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.loadMoreContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@@ -679,18 +679,10 @@ export const Messages = observer(function Messages({
|
||||
</div>
|
||||
</Scroller>
|
||||
</div>
|
||||
{loadErrorBar ?? jumpToPresentBar}
|
||||
{loadErrorBar !== null ? loadErrorBar : jumpToPresentBar}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
const getBottomBarStyle = (background: string): React.CSSProperties => ({
|
||||
borderRadius: '0.5rem 0.5rem 0 0',
|
||||
bottom: '-6px',
|
||||
background,
|
||||
paddingBottom: '6px',
|
||||
paddingTop: 0,
|
||||
top: 'auto',
|
||||
});
|
||||
const JumpToPresentBar = observer(function JumpToPresentBar({
|
||||
loadingMore,
|
||||
jumpedToPresent,
|
||||
@@ -703,23 +695,19 @@ const JumpToPresentBar = observer(function JumpToPresentBar({
|
||||
const {i18n} = useLingui();
|
||||
const jumpIsActiveNow = loadingMore && jumpedToPresent;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.newMessagesBar, styles.jumpToPresentBar].join(' ')}
|
||||
style={{
|
||||
...getBottomBarStyle('var(--background-tertiary)'),
|
||||
cursor: jumpIsActiveNow ? 'wait' : 'pointer',
|
||||
}}
|
||||
onClick={onJumpToPresent}
|
||||
disabled={jumpIsActiveNow}
|
||||
<div
|
||||
className={[styles.newMessagesBar, styles.messageBottomPill, styles.jumpToPresentBar].join(' ')}
|
||||
aria-busy={jumpIsActiveNow}
|
||||
data-flx="channel.messages.jump-to-present-bar.new-messages-bar.jump-to-present.button"
|
||||
data-flx="channel.messages.jump-to-present-bar"
|
||||
>
|
||||
<span className={styles.newMessagesBarText} data-flx="channel.messages.jump-to-present-bar.new-messages-bar-text">
|
||||
{i18n._(YOU_RE_VIEWING_OLDER_MESSAGES_DESCRIPTOR)}
|
||||
</span>
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
className={styles.newMessagesBarAction}
|
||||
onClick={onJumpToPresent}
|
||||
disabled={jumpIsActiveNow}
|
||||
data-flx="channel.messages.jump-to-present-bar.new-messages-bar-action"
|
||||
>
|
||||
{jumpIsActiveNow ? (
|
||||
@@ -727,36 +715,35 @@ const JumpToPresentBar = observer(function JumpToPresentBar({
|
||||
) : (
|
||||
i18n._(JUMP_TO_PRESENT_DESCRIPTOR)
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function LoadErrorBar({loading, onRetry}: {loading: boolean; onRetry: () => void}) {
|
||||
const {i18n} = useLingui();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
aria-busy={loading}
|
||||
className={styles.newMessagesBar}
|
||||
disabled={loading}
|
||||
onClick={onRetry}
|
||||
style={{
|
||||
...getBottomBarStyle('var(--status-danger)'),
|
||||
cursor: loading ? 'wait' : 'pointer',
|
||||
}}
|
||||
data-flx="channel.messages.load-error-bar.new-messages-bar.retry.button"
|
||||
className={[styles.newMessagesBar, styles.messageBottomPill, styles.loadErrorBar].join(' ')}
|
||||
data-flx="channel.messages.load-error-bar"
|
||||
>
|
||||
<span className={styles.newMessagesBarText} data-flx="channel.messages.load-error-bar.new-messages-bar-text">
|
||||
{i18n._(MESSAGES_FAILED_TO_LOAD_DESCRIPTOR)}
|
||||
</span>
|
||||
<span className={styles.newMessagesBarAction} data-flx="channel.messages.load-error-bar.new-messages-bar-action">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.newMessagesBarAction}
|
||||
disabled={loading}
|
||||
onClick={onRetry}
|
||||
data-flx="channel.messages.load-error-bar.new-messages-bar-action"
|
||||
>
|
||||
{loading ? (
|
||||
<Spinner size="small" data-flx="channel.messages.load-error-bar.spinner" />
|
||||
) : (
|
||||
i18n._(TRY_AGAIN_DESCRIPTOR)
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -20,28 +21,40 @@
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.authorName {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.replyTargetButton:hover .authorName,
|
||||
.replyTargetButton:focus-visible .authorName {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.authorName {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 0.125rem;
|
||||
min-height: var(--composer-status-pill-height, 1.5rem);
|
||||
}
|
||||
|
||||
.mentionToggle {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
justify-content: center;
|
||||
height: calc(var(--composer-status-pill-height, 1.5rem) - 0.25rem);
|
||||
inline-size: 3.5rem;
|
||||
flex: 0 0 3.5rem;
|
||||
padding: 0 0.5rem;
|
||||
border: 0;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mentionToggleOn {
|
||||
@@ -59,9 +72,9 @@
|
||||
}
|
||||
|
||||
.mentionIcon {
|
||||
margin-right: 0.25rem;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
margin-right: 0.1875rem;
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.divider {
|
||||
@@ -71,25 +84,30 @@
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0.5rem 0 0.5rem 1rem;
|
||||
width: var(--composer-status-pill-height, 1.5rem);
|
||||
height: var(--composer-status-pill-height, 1.5rem);
|
||||
padding: 0;
|
||||
border-radius: var(--radius-full);
|
||||
color: var(--text-primary-muted);
|
||||
line-height: 0;
|
||||
transition: color 200ms;
|
||||
transition:
|
||||
color 200ms,
|
||||
background-color 200ms;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.topBorder {
|
||||
box-shadow: inset 0 0.0625rem 0 var(--user-area-divider-color);
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import styles from '@app/features/channel/components/ChannelReplyBar.module.css'
|
||||
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
|
||||
@@ -128,12 +127,6 @@ export const ReplyBar = observer(function ReplyBar({
|
||||
}
|
||||
setShouldReplyMention(next);
|
||||
};
|
||||
const handleKeyDown = (handler: () => void) => (event: React.KeyboardEvent) => {
|
||||
if (isKeyboardActivationKey(event.key)) {
|
||||
event.preventDefault();
|
||||
handler();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
@@ -141,7 +134,6 @@ export const ReplyBar = observer(function ReplyBar({
|
||||
wrapperStyles.wrapperSides,
|
||||
wrapperStyles.roundedTop,
|
||||
wrapperStyles.noBottomBorder,
|
||||
styles.topBorder,
|
||||
)}
|
||||
data-flx="channel.reply-bar.top-border"
|
||||
>
|
||||
@@ -177,26 +169,21 @@ export const ReplyBar = observer(function ReplyBar({
|
||||
data-flx="channel.reply-bar.tooltip"
|
||||
>
|
||||
<FocusRing offset={-2} data-flx="channel.reply-bar.focus-ring">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(
|
||||
styles.mentionToggle,
|
||||
shouldMention ? styles.mentionToggleOn : styles.mentionToggleOff,
|
||||
)}
|
||||
role="switch"
|
||||
aria-checked={shouldMention}
|
||||
aria-label={i18n._(MENTION_REPLIED_USER_DESCRIPTOR)}
|
||||
tabIndex={0}
|
||||
onClick={toggleMention}
|
||||
onKeyDown={handleKeyDown(toggleMention)}
|
||||
data-flx="channel.reply-bar.switch.toggle-mention"
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.mentionToggle,
|
||||
shouldMention ? styles.mentionToggleOn : styles.mentionToggleOff,
|
||||
)}
|
||||
data-flx="channel.reply-bar.mention-toggle"
|
||||
>
|
||||
<AtIcon weight="bold" className={styles.mentionIcon} data-flx="channel.reply-bar.mention-icon" />
|
||||
{shouldMention ? i18n._(ON_DESCRIPTOR) : i18n._(OFF_DESCRIPTOR)}
|
||||
</div>
|
||||
</div>
|
||||
<AtIcon weight="bold" className={styles.mentionIcon} data-flx="channel.reply-bar.mention-icon" />
|
||||
{shouldMention ? i18n._(ON_DESCRIPTOR) : i18n._(OFF_DESCRIPTOR)}
|
||||
</button>
|
||||
</FocusRing>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {DIRECT_MESSAGE_DESCRIPTOR, PERSONAL_NOTES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {Link} from '@app/features/platform/components/router/RouterReact';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {MentionBadge} from '@app/features/ui/components/MentionBadge';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
@@ -79,7 +80,7 @@ function renderAvatarImage(props: {url: string; label: string; size: number}): R
|
||||
return (
|
||||
<div
|
||||
className={styles.avatar}
|
||||
style={{width: size, height: size, backgroundImage: `url(${url})`}}
|
||||
style={{width: remFromPx(size), height: remFromPx(size), backgroundImage: `url(${url})`}}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
data-flx="channel.channel-source-preview.render-avatar-image.avatar"
|
||||
@@ -97,7 +98,7 @@ function renderAvatarFallback(props: {
|
||||
return (
|
||||
<div
|
||||
className={styles.avatar}
|
||||
style={{width: size, height: size, backgroundColor: background}}
|
||||
style={{width: remFromPx(size), height: remFromPx(size), backgroundColor: background}}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
data-flx="channel.channel-source-preview.render-avatar-fallback.avatar"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import styles from '@app/features/theme/styles/Typing.module.css';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -34,8 +35,8 @@ export const Typing = observer(
|
||||
<svg
|
||||
x={x}
|
||||
y={y}
|
||||
width={width}
|
||||
height={height}
|
||||
width={remFromPx(width)}
|
||||
height={remFromPx(height)}
|
||||
viewBox={`0 0 ${viewBoxWidth} ${viewBoxHeight}`}
|
||||
className={className}
|
||||
style={mergedStyle}
|
||||
|
||||
@@ -51,3 +51,14 @@
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor {
|
||||
--composer-placeholder-left: 0px;
|
||||
--composer-placeholder-right: 0px;
|
||||
flex: 1 1 0%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor [data-channel-textarea] {
|
||||
max-height: 50svh;
|
||||
}
|
||||
@@ -1,49 +1,78 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Limits} from '@app/features/app/utils/UserLimits';
|
||||
import {Autocomplete} from '@app/features/channel/components/Autocomplete';
|
||||
import editingStyles from '@app/features/channel/components/EditingMessageInput.module.css';
|
||||
import {MessageCharacterCounter} from '@app/features/channel/components/MessageCharacterCounter';
|
||||
import wrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';
|
||||
import {TextareaButton} from '@app/features/channel/components/textarea/TextareaButton';
|
||||
import styles from '@app/features/channel/components/textarea/TextareaInput.module.css';
|
||||
import {TextareaInputField} from '@app/features/channel/components/textarea/TextareaInputField';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {FlatEmoji} from '@app/features/emoji/types/EmojiTypes';
|
||||
import {ExpressionPickerSheet} from '@app/features/expressions/components/modals/ExpressionPickerSheet';
|
||||
import {ExpressionPickerPopout} from '@app/features/expressions/components/popouts/ExpressionPickerPopout';
|
||||
import GuildVerification, {VerificationFailureReason} from '@app/features/guild/state/GuildVerification';
|
||||
import {
|
||||
CANNOT_SEND_MESSAGES_IN_CHANNEL_DESCRIPTOR,
|
||||
EDIT_MESSAGE_DESCRIPTOR,
|
||||
EMOJIS_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {useMarkdownFormattingShortcut, useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
|
||||
import {useTextareaAutocomplete} from '@app/features/messaging/hooks/useTextareaAutocomplete';
|
||||
import {useTextareaEmojiPicker} from '@app/features/messaging/hooks/useTextareaEmojiPicker';
|
||||
import {useTextareaPaste} from '@app/features/messaging/hooks/useTextareaPaste';
|
||||
import {useTextareaSegments} from '@app/features/messaging/hooks/useTextareaSegments';
|
||||
import Keybind from '@app/features/input/state/InputKeybind';
|
||||
import {LexicalRichInput, type LexicalRichInputHandle} from '@app/features/lexical/composer/LexicalRichInput';
|
||||
import {doesEventMatchShortcut, useMarkdownKeybinds} from '@app/features/messaging/hooks/useMarkdownKeybinds';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
import TextareaSelection from '@app/features/messaging/state/TextareaSelection';
|
||||
import {applyMarkdownSegments} from '@app/features/messaging/utils/MarkdownToSegmentUtils';
|
||||
import {
|
||||
captureTextareaSelection,
|
||||
focusTextareaWithSelection,
|
||||
} from '@app/features/messaging/utils/TextareaSelectionUtils';
|
||||
import type {MentionSegment} from '@app/features/messaging/utils/TextareaSegmentManager';
|
||||
import {TextareaSegmentManager} from '@app/features/messaging/utils/TextareaSegmentManager';
|
||||
import {ComponentDispatch} from '@app/features/platform/utils/ComponentBus';
|
||||
import * as PopoutCommands from '@app/features/ui/commands/PopoutCommands';
|
||||
import {Scroller, type ScrollerHandle} from '@app/features/ui/components/Scroller';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {openPopout} from '@app/features/ui/popover/PopoverPopout';
|
||||
import KeyboardMode from '@app/features/ui/state/KeyboardMode';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import Users from '@app/features/user/state/Users';
|
||||
import {MAX_MESSAGE_LENGTH_NON_PREMIUM, MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {SmileyIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useId, useMemo, useRef, useState} from 'react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
const EDITING_ALLOWED_TRIGGERS = ['emoji', 'mention', 'channel'] as const;
|
||||
|
||||
interface EditingFocusRequest {
|
||||
requestedChannelId: string | null;
|
||||
enterKeyboardMode: boolean | null;
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
editingDisabled: boolean;
|
||||
composer: LexicalRichInputHandle | null;
|
||||
}
|
||||
|
||||
function focusEditingInput(request: EditingFocusRequest): boolean | null {
|
||||
if (
|
||||
request.requestedChannelId != null &&
|
||||
request.requestedChannelId !== '' &&
|
||||
request.requestedChannelId !== request.channelId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (request.editingDisabled) {
|
||||
return false;
|
||||
}
|
||||
if (!MessageEdit.isEditing(request.channelId, request.messageId)) {
|
||||
return false;
|
||||
}
|
||||
if (request.enterKeyboardMode === true) {
|
||||
KeyboardMode.enterKeyboardMode(true);
|
||||
} else {
|
||||
KeyboardMode.exitKeyboardMode();
|
||||
}
|
||||
if (request.composer != null) {
|
||||
request.composer.focus();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const EditingMessageInput = observer(
|
||||
({
|
||||
@@ -58,273 +87,200 @@ export const EditingMessageInput = observer(
|
||||
onSubmit: (actualContent?: string) => void;
|
||||
}) => {
|
||||
const {i18n} = useLingui();
|
||||
const currentUser = Users.getCurrentUser();
|
||||
const maxMessageLength = currentUser?.maxMessageLength ?? MAX_MESSAGE_LENGTH_NON_PREMIUM;
|
||||
const maxMessageLength = Limits.getMaxMessageLength();
|
||||
const premiumMaxLength = Limits.getPremiumValue('max_message_length', MAX_MESSAGE_LENGTH_PREMIUM);
|
||||
const [value, setValue] = useState(() => {
|
||||
const persistedDraft =
|
||||
MessageEdit.getEditingContent(channel.id, message.id) ?? MessageEdit.getDraftContent(message.id);
|
||||
return persistedDraft ?? message.content;
|
||||
const [initial] = useState(() => {
|
||||
let wire = MessageEdit.getEditingContent(channel.id, message.id);
|
||||
if (wire == null) {
|
||||
wire = MessageEdit.getDraftContent(message.id);
|
||||
}
|
||||
if (wire == null) {
|
||||
wire = message.content;
|
||||
}
|
||||
const segments = new TextareaSegmentManager();
|
||||
const display = applyMarkdownSegments(wire, channel.guildId, segments);
|
||||
return {display, segments: segments.getSegmentsCopy(), wire};
|
||||
});
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [expressionPickerOpen, setExpressionPickerOpen] = useState(false);
|
||||
const autocompleteListId = useId();
|
||||
const hasInitializedRef = useRef(false);
|
||||
const [actualContent, setActualContent] = useState(initial.wire);
|
||||
const composerRef = useRef<LexicalRichInputHandle | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const mobileLayout = MobileLayout;
|
||||
const editableRef = useRef<HTMLElement | null>(null);
|
||||
const expressionPickerTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [expressionPickerOpen, setExpressionPickerOpen] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
useMarkdownKeybinds(isFocused);
|
||||
const [textareaHeight, setTextareaHeight] = useState(0);
|
||||
const hasScrolledInitiallyRef = useRef(false);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const hasFocusedInitiallyRef = useRef(false);
|
||||
const mobileLayout = MobileLayout;
|
||||
useMarkdownKeybinds(isFocused, {preserveEditableFocusActions: false});
|
||||
const editingDisabled =
|
||||
channel.guildId != null &&
|
||||
GuildVerification.getFailureReason(channel.guildId) === VerificationFailureReason.TIMED_OUT;
|
||||
const placeholderText = editingDisabled ? i18n._(CANNOT_SEND_MESSAGES_IN_CHANNEL_DESCRIPTOR) : '';
|
||||
const rememberTextareaSelection = useCallback(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
let placeholderText = '';
|
||||
if (editingDisabled) {
|
||||
placeholderText = i18n._(CANNOT_SEND_MESSAGES_IN_CHANNEL_DESCRIPTOR);
|
||||
}
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container == null) {
|
||||
editableRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!MessageEdit.isEditing(channel.id, message.id)) {
|
||||
const editable = container.querySelector<HTMLElement>('[data-channel-textarea]');
|
||||
if (editable == null) {
|
||||
editableRef.current = null;
|
||||
return;
|
||||
}
|
||||
TextareaSelection.setEditingSelection(channel.id, message.id, captureTextareaSelection(textarea));
|
||||
}, [channel.id, message.id]);
|
||||
const focusEditingTextarea = useCallback(
|
||||
(fallbackPosition?: number) => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
focusTextareaWithSelection(
|
||||
textarea,
|
||||
TextareaSelection.getEditingSelection(channel.id, message.id),
|
||||
fallbackPosition,
|
||||
);
|
||||
editableRef.current = editable;
|
||||
}, []);
|
||||
const handleChange = useCallback(
|
||||
(_display: string, _segments: Array<MentionSegment>, wire: string) => {
|
||||
setActualContent(wire);
|
||||
MessageEdit.setEditingContent(channel.id, message.id, wire);
|
||||
},
|
||||
[channel.id, message.id],
|
||||
);
|
||||
const handleScroll = useCallback(() => {
|
||||
const distance = scrollerRef.current?.getDistanceFromBottom?.();
|
||||
if (distance == null) return;
|
||||
shouldStickToBottomRef.current = distance <= 8;
|
||||
const handleEmojiSelect = useCallback((emoji: FlatEmoji) => {
|
||||
const composer = composerRef.current;
|
||||
if (composer != null) {
|
||||
composer.insertEmoji(emoji);
|
||||
}
|
||||
}, []);
|
||||
const {segmentManagerRef, previousValueRef, displayToActual, prepareTextChange, handleTextChange} =
|
||||
useTextareaSegments();
|
||||
const handleFormattingShortcut = useMarkdownFormattingShortcut({
|
||||
textareaRef,
|
||||
value,
|
||||
setValue,
|
||||
handleTextChange,
|
||||
previousValueRef,
|
||||
});
|
||||
const handleTextareaKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
handleFormattingShortcut(event);
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (editingDisabled || actualContent.length > maxMessageLength) {
|
||||
return;
|
||||
}
|
||||
onSubmit(actualContent);
|
||||
}, [actualContent, editingDisabled, maxMessageLength, onSubmit]);
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (event.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
const composer = composerRef.current;
|
||||
const selection = composer == null ? null : composer.getSelection();
|
||||
const hasSelectionRange = selection != null && selection.start !== selection.end;
|
||||
const inboxCombo = Keybind.getByAction('chat_toggle_inbox').combo;
|
||||
if (doesEventMatchShortcut(event, inboxCombo) && !hasSelectionRange && actualContent.trim().length === 0) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ComponentDispatch.dispatch('INBOX_OPEN');
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ComponentDispatch.dispatch('ESCAPE_PRESSED', {channelId: channel.id});
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[handleFormattingShortcut, onCancel],
|
||||
[actualContent, channel.id, onCancel],
|
||||
);
|
||||
const {handleEmojiSelect} = useTextareaEmojiPicker({
|
||||
setValue,
|
||||
textareaRef,
|
||||
segmentManagerRef,
|
||||
previousValueRef,
|
||||
prepareTextChange,
|
||||
});
|
||||
const actualContent = useMemo(() => displayToActual(value), [displayToActual, value]);
|
||||
const {
|
||||
autocompleteQuery,
|
||||
autocompleteOptions,
|
||||
autocompleteType,
|
||||
selectedIndex,
|
||||
isAutocompleteAttached,
|
||||
setSelectedIndex,
|
||||
onCursorMove,
|
||||
handleSelect,
|
||||
} = useTextareaAutocomplete({
|
||||
channel,
|
||||
value,
|
||||
setValue,
|
||||
textareaRef,
|
||||
segmentManagerRef,
|
||||
previousValueRef,
|
||||
prepareTextChange,
|
||||
allowedTriggers: ['emoji', 'emojiReaction', 'mention', 'channel'],
|
||||
});
|
||||
useTextareaPaste({
|
||||
channel,
|
||||
textareaRef,
|
||||
segmentManagerRef,
|
||||
setValue,
|
||||
previousValueRef,
|
||||
prepareTextChange,
|
||||
disabled: editingDisabled,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (editingDisabled) return;
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) return;
|
||||
const rememberActiveTextareaSelection = () => {
|
||||
if (document.activeElement !== textarea) return;
|
||||
rememberTextareaSelection();
|
||||
};
|
||||
textarea.addEventListener('blur', rememberTextareaSelection);
|
||||
textarea.addEventListener('input', rememberActiveTextareaSelection);
|
||||
textarea.addEventListener('keyup', rememberActiveTextareaSelection);
|
||||
textarea.addEventListener('mouseup', rememberActiveTextareaSelection);
|
||||
textarea.addEventListener('select', rememberActiveTextareaSelection);
|
||||
document.addEventListener('selectionchange', rememberActiveTextareaSelection);
|
||||
return () => {
|
||||
rememberTextareaSelection();
|
||||
textarea.removeEventListener('blur', rememberTextareaSelection);
|
||||
textarea.removeEventListener('input', rememberActiveTextareaSelection);
|
||||
textarea.removeEventListener('keyup', rememberActiveTextareaSelection);
|
||||
textarea.removeEventListener('mouseup', rememberActiveTextareaSelection);
|
||||
textarea.removeEventListener('select', rememberActiveTextareaSelection);
|
||||
document.removeEventListener('selectionchange', rememberActiveTextareaSelection);
|
||||
};
|
||||
}, [editingDisabled, rememberTextareaSelection]);
|
||||
useEffect(() => {
|
||||
if (editingDisabled) {
|
||||
if (editingDisabled || hasFocusedInitiallyRef.current) {
|
||||
return;
|
||||
}
|
||||
if (hasFocusedInitiallyRef.current) {
|
||||
textareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
if (!hasInitializedRef.current && value) {
|
||||
hasInitializedRef.current = true;
|
||||
const displayText = applyMarkdownSegments(value, channel.guildId, segmentManagerRef.current);
|
||||
setValue(displayText);
|
||||
previousValueRef.current = displayText;
|
||||
hasFocusedInitiallyRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
focusEditingTextarea(displayText.length);
|
||||
});
|
||||
} else {
|
||||
hasFocusedInitiallyRef.current = true;
|
||||
focusEditingTextarea();
|
||||
}
|
||||
}, [editingDisabled, value, channel.guildId, segmentManagerRef, previousValueRef, focusEditingTextarea]);
|
||||
hasFocusedInitiallyRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
const composer = composerRef.current;
|
||||
if (composer != null) {
|
||||
composer.focus();
|
||||
}
|
||||
});
|
||||
}, [editingDisabled]);
|
||||
useEffect(() => {
|
||||
if (!editingDisabled) {
|
||||
return;
|
||||
}
|
||||
textareaRef.current?.blur();
|
||||
const container = containerRef.current;
|
||||
const active = document.activeElement;
|
||||
if (container != null && active instanceof HTMLElement && container.contains(active)) {
|
||||
active.blur();
|
||||
}
|
||||
PopoutCommands.close(`editing-expression-picker-${channel.id}`);
|
||||
setExpressionPickerOpen(false);
|
||||
}, [channel.id, editingDisabled]);
|
||||
useEffect(() => {
|
||||
if (hasScrolledInitiallyRef.current) return;
|
||||
if (!scrollerRef.current) return;
|
||||
if (textareaHeight <= 0) return;
|
||||
scrollerRef.current.scrollToBottom({animate: false});
|
||||
hasScrolledInitiallyRef.current = true;
|
||||
shouldStickToBottomRef.current = true;
|
||||
}, [textareaHeight]);
|
||||
useEffect(() => {
|
||||
if (hasInitializedRef.current) {
|
||||
MessageEdit.setEditingContent(channel.id, message.id, actualContent);
|
||||
}
|
||||
}, [channel.id, message.id, actualContent]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = ComponentDispatch.subscribe('FOCUS_TEXTAREA', (payload?: unknown) => {
|
||||
const {channelId, enterKeyboardMode} = (payload ?? {}) as {channelId?: string; enterKeyboardMode?: boolean};
|
||||
if (channelId && channelId !== channel.id) return;
|
||||
if (editingDisabled) return false;
|
||||
if (!MessageEdit.isEditing(channel.id, message.id)) return false;
|
||||
if (enterKeyboardMode) {
|
||||
KeyboardMode.enterKeyboardMode(true);
|
||||
} else {
|
||||
KeyboardMode.exitKeyboardMode();
|
||||
const data = payload as {channelId?: string; enterKeyboardMode?: boolean} | undefined;
|
||||
let requestedChannelId: string | null = null;
|
||||
let enterKeyboardMode: boolean | null = null;
|
||||
if (data != null) {
|
||||
if (data.channelId != null) {
|
||||
requestedChannelId = data.channelId;
|
||||
}
|
||||
if (data.enterKeyboardMode != null) {
|
||||
enterKeyboardMode = data.enterKeyboardMode;
|
||||
}
|
||||
}
|
||||
focusEditingTextarea();
|
||||
return true;
|
||||
return focusEditingInput({
|
||||
requestedChannelId,
|
||||
enterKeyboardMode,
|
||||
channelId: channel.id,
|
||||
messageId: message.id,
|
||||
editingDisabled,
|
||||
composer: composerRef.current,
|
||||
});
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [channel.id, editingDisabled, focusEditingTextarea, message.id]);
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (editingDisabled) {
|
||||
return;
|
||||
}
|
||||
if (actualContent.length > maxMessageLength) {
|
||||
return;
|
||||
}
|
||||
onSubmit(actualContent);
|
||||
}, [actualContent, editingDisabled, onSubmit, maxMessageLength]);
|
||||
}, [channel.id, editingDisabled, message.id]);
|
||||
const handleExpressionPickerToggle = useCallback(() => {
|
||||
if (editingDisabled) {
|
||||
return;
|
||||
}
|
||||
const triggerElement = expressionPickerTriggerRef.current;
|
||||
if (!triggerElement) return;
|
||||
if (triggerElement == null) {
|
||||
return;
|
||||
}
|
||||
const popoutKey = `editing-expression-picker-${channel.id}`;
|
||||
const isOpen = expressionPickerOpen;
|
||||
if (isOpen) {
|
||||
if (expressionPickerOpen) {
|
||||
PopoutCommands.close(popoutKey);
|
||||
setExpressionPickerOpen(false);
|
||||
} else {
|
||||
openPopout(
|
||||
triggerElement,
|
||||
{
|
||||
render: ({onClose}) => (
|
||||
<ExpressionPickerPopout
|
||||
channelId={channel.id}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
onClose={onClose}
|
||||
visibleTabs={['emojis']}
|
||||
data-flx="channel.editing-message-input.handle-expression-picker-toggle.expression-picker-popout"
|
||||
/>
|
||||
),
|
||||
position: 'top-end',
|
||||
animationType: 'none',
|
||||
offsetCrossAxis: 16,
|
||||
onOpen: () => setExpressionPickerOpen(true),
|
||||
onClose: () => setExpressionPickerOpen(false),
|
||||
returnFocusRef: textareaRef,
|
||||
},
|
||||
popoutKey,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}, [channel.id, editingDisabled, expressionPickerOpen, handleEmojiSelect, textareaRef]);
|
||||
openPopout(
|
||||
triggerElement,
|
||||
{
|
||||
render: ({onClose}) => (
|
||||
<ExpressionPickerPopout
|
||||
channelId={channel.id}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
onClose={onClose}
|
||||
visibleTabs={['emojis']}
|
||||
/>
|
||||
),
|
||||
position: 'top-end',
|
||||
animationType: 'none',
|
||||
offsetCrossAxis: 16,
|
||||
onOpen: () => setExpressionPickerOpen(true),
|
||||
onClose: () => setExpressionPickerOpen(false),
|
||||
returnFocusRef: editableRef,
|
||||
},
|
||||
popoutKey,
|
||||
);
|
||||
}, [channel.id, editingDisabled, expressionPickerOpen, handleEmojiSelect]);
|
||||
useEffect(() => {
|
||||
const unsubscribe = ComponentDispatch.subscribe('EDITING_EXPRESSION_PICKER_TAB_TOGGLE', (payload?: unknown) => {
|
||||
const data = payload as {channelId?: string; messageId?: string; tab?: string} | undefined;
|
||||
if (!data || data.channelId !== channel.id || data.messageId !== message.id || data.tab !== 'emojis') return;
|
||||
if (data == null || data.channelId !== channel.id || data.messageId !== message.id || data.tab !== 'emojis') {
|
||||
return;
|
||||
}
|
||||
handleExpressionPickerToggle();
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [channel.id, handleExpressionPickerToggle, message.id]);
|
||||
const showAutocomplete = !editingDisabled && isAutocompleteAttached;
|
||||
const displayValue = editingDisabled ? '' : value;
|
||||
const displayContentLength = editingDisabled ? 0 : actualContent.length;
|
||||
let expressionPickerButtonRef: React.Ref<HTMLButtonElement> | undefined;
|
||||
if (!mobileLayout.enabled) {
|
||||
expressionPickerButtonRef = expressionPickerTriggerRef;
|
||||
}
|
||||
let handleExpressionPickerButtonClick = handleExpressionPickerToggle;
|
||||
if (mobileLayout.enabled) {
|
||||
handleExpressionPickerButtonClick = () => setExpressionPickerOpen(true);
|
||||
}
|
||||
let displayContentLength = actualContent.length;
|
||||
if (editingDisabled) {
|
||||
displayContentLength = 0;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{showAutocomplete && (
|
||||
<Autocomplete
|
||||
type={autocompleteType}
|
||||
onSelect={handleSelect}
|
||||
selectedIndex={selectedIndex}
|
||||
options={autocompleteOptions}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
referenceElement={containerRef.current}
|
||||
query={autocompleteQuery}
|
||||
listboxId={autocompleteListId}
|
||||
data-flx="channel.editing-message-input.autocomplete.select"
|
||||
/>
|
||||
)}
|
||||
<FocusRing within={true} offset={-2} data-flx="channel.editing-message-input.focus-ring">
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -336,66 +292,42 @@ export const EditingMessageInput = observer(
|
||||
data-flx="channel.editing-message-input.main-wrapper-editing"
|
||||
>
|
||||
<div className={styles.contentAreaEditing} data-flx="channel.editing-message-input.content-area-editing">
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
fade={true}
|
||||
className={editingStyles.scroller}
|
||||
key="editing-message-input-scroller"
|
||||
onScroll={handleScroll}
|
||||
data-flx="channel.editing-message-input.scroller"
|
||||
>
|
||||
<div className={editingStyles.flexColumnContainer} data-flx="channel.editing-message-input.div">
|
||||
<span
|
||||
key={textareaHeight}
|
||||
className={editingStyles.hiddenSpan}
|
||||
data-flx="channel.editing-message-input.span"
|
||||
/>
|
||||
<TextareaInputField
|
||||
channelId={channel.id}
|
||||
disabled={editingDisabled}
|
||||
isMobile={mobileLayout.enabled}
|
||||
value={displayValue}
|
||||
placeholder={placeholderText}
|
||||
textareaRef={textareaRef}
|
||||
scrollerRef={scrollerRef}
|
||||
shouldStickToBottomRef={shouldStickToBottomRef}
|
||||
isFocused={isFocused}
|
||||
isAutocompleteAttached={showAutocomplete}
|
||||
autocompleteListId={autocompleteListId}
|
||||
autocompleteOptions={autocompleteOptions}
|
||||
selectedIndex={selectedIndex}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onChange={(newValue, inputType, hint) => {
|
||||
handleTextChange(newValue, previousValueRef.current, inputType, hint);
|
||||
setValue(newValue);
|
||||
}}
|
||||
onHeightChange={setTextareaHeight}
|
||||
onCursorMove={onCursorMove}
|
||||
onArrowUp={() => {}}
|
||||
onEnter={handleSubmit}
|
||||
onAutocompleteSelect={handleSelect}
|
||||
setSelectedIndex={setSelectedIndex}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
data-flx="channel.editing-message-input.textarea-input-field.text-change"
|
||||
/>
|
||||
</div>
|
||||
</Scroller>
|
||||
<LexicalRichInput
|
||||
initialValue={initial.display}
|
||||
initialSegments={initial.segments}
|
||||
placeholder={placeholderText}
|
||||
disabled={editingDisabled}
|
||||
channel={channel}
|
||||
allowedTriggers={[...EDITING_ALLOWED_TRIGGERS]}
|
||||
markdown={true}
|
||||
singleLine={!mobileLayout.enabled}
|
||||
size="chat"
|
||||
className={editingStyles.editor}
|
||||
autocompleteAnchor={containerRef.current}
|
||||
ariaLabel={i18n._(EDIT_MESSAGE_DESCRIPTOR)}
|
||||
richInputRef={composerRef}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
i18n={i18n}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={styles.buttonContainerEditing}
|
||||
data-flx="channel.editing-message-input.button-container-editing"
|
||||
>
|
||||
<TextareaButton
|
||||
ref={mobileLayout.enabled ? undefined : expressionPickerTriggerRef}
|
||||
ref={expressionPickerButtonRef}
|
||||
icon={SmileyIcon}
|
||||
iconProps={{weight: 'fill'}}
|
||||
label={i18n._(EMOJIS_DESCRIPTOR)}
|
||||
isSelected={expressionPickerOpen}
|
||||
onClick={mobileLayout.enabled ? () => setExpressionPickerOpen(true) : handleExpressionPickerToggle}
|
||||
compact={true}
|
||||
disabled={editingDisabled}
|
||||
data-expression-picker-tab="emojis"
|
||||
compact={true}
|
||||
onClick={handleExpressionPickerButtonClick}
|
||||
data-flx="channel.editing-message-input.textarea-button.set-expression-picker-open"
|
||||
/>
|
||||
</div>
|
||||
@@ -409,18 +341,12 @@ export const EditingMessageInput = observer(
|
||||
/>
|
||||
</div>
|
||||
</FocusRing>
|
||||
<div className={editingStyles.footer} data-flx="channel.editing-message-input.div--2">
|
||||
<div data-flx="channel.editing-message-input.div--3">
|
||||
<div className={editingStyles.footer} data-flx="channel.editing-message-input.footer">
|
||||
<div data-flx="channel.editing-message-input.hints">
|
||||
<Trans>
|
||||
escape to{' '}
|
||||
<FocusRing offset={-2} data-flx="channel.editing-message-input.focus-ring--2">
|
||||
<button
|
||||
type="button"
|
||||
className={editingStyles.footerLink}
|
||||
onClick={onCancel}
|
||||
key="cancel"
|
||||
data-flx="channel.editing-message-input.button.cancel"
|
||||
>
|
||||
<button type="button" className={editingStyles.footerLink} onClick={onCancel} key="cancel">
|
||||
cancel
|
||||
</button>
|
||||
</FocusRing>
|
||||
@@ -428,7 +354,7 @@ export const EditingMessageInput = observer(
|
||||
<div
|
||||
aria-hidden={true}
|
||||
className={editingStyles.separator}
|
||||
data-flx="channel.editing-message-input.div--4"
|
||||
data-flx="channel.editing-message-input.separator"
|
||||
/>
|
||||
<Trans>
|
||||
enter to{' '}
|
||||
@@ -439,7 +365,6 @@ export const EditingMessageInput = observer(
|
||||
onClick={handleSubmit}
|
||||
disabled={editingDisabled}
|
||||
key="save"
|
||||
data-flx="channel.editing-message-input.button.submit"
|
||||
>
|
||||
save
|
||||
</button>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {useSearchInputAutofocus} from '@app/features/app/hooks/useSearchInputAut
|
||||
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
|
||||
import styles from '@app/features/channel/components/EmojiPicker.module.css';
|
||||
import {EmojiPickerCategoryList} from '@app/features/channel/components/emoji_picker/EmojiPickerCategoryList';
|
||||
import {getEmojiGridColumns} from '@app/features/channel/components/emoji_picker/EmojiPickerConstants';
|
||||
import {EmojiPickerInspector} from '@app/features/channel/components/emoji_picker/EmojiPickerInspector';
|
||||
import {EmojiPickerSearchBar} from '@app/features/channel/components/emoji_picker/EmojiPickerSearchBar';
|
||||
import {useEmojiCategories} from '@app/features/channel/components/emoji_picker/hooks/useEmojiCategories';
|
||||
@@ -12,6 +13,7 @@ import {useVirtualRows} from '@app/features/channel/components/emoji_picker/hook
|
||||
import {VirtualizedRow} from '@app/features/channel/components/emoji_picker/VirtualRow';
|
||||
import {PremiumUpsellBanner} from '@app/features/channel/components/PremiumUpsellBanner';
|
||||
import premiumStyles from '@app/features/channel/components/PremiumUpsellBanner.module.css';
|
||||
import {useScrollerViewport} from '@app/features/channel/components/pickers/shared/useScrollerViewport';
|
||||
import Channels from '@app/features/channel/state/Channels';
|
||||
import * as EmojiPickerCommands from '@app/features/emoji/commands/EmojiPickerCommands';
|
||||
import Emoji, {normalizeEmojiSearchQuery} from '@app/features/emoji/state/Emoji';
|
||||
@@ -56,6 +58,8 @@ export const EmojiPicker = observer(
|
||||
const [selectedColumn, setSelectedColumn] = useState(-1);
|
||||
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const {viewportSize, handleResize} = useScrollerViewport(scrollerRef);
|
||||
const gridColumns = useMemo(() => getEmojiGridColumns(viewportSize.width), [viewportSize.width]);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const emojiRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const normalizedSearchTerm = useMemo(() => normalizeEmojiSearchQuery(searchTerm), [searchTerm]);
|
||||
@@ -142,6 +146,7 @@ export const EmojiPicker = observer(
|
||||
frequentlyUsedEmojis,
|
||||
customEmojisByGuildId,
|
||||
unicodeEmojisByCategory,
|
||||
gridColumns,
|
||||
);
|
||||
const lockedEmojiCount = allUpsell.summary.lockedItems.length;
|
||||
const communityCount = allUpsell.summary.communityCount;
|
||||
@@ -276,6 +281,7 @@ export const EmojiPicker = observer(
|
||||
className={`${styles.list} ${styles.listWrapper}`}
|
||||
fade={false}
|
||||
key="emoji_picker-scroller"
|
||||
onResize={handleResize}
|
||||
data-emoji-picker-scroll-root="true"
|
||||
data-flx="channel.emoji-picker.list"
|
||||
>
|
||||
@@ -313,6 +319,7 @@ export const EmojiPicker = observer(
|
||||
skinTone={skinTone}
|
||||
channel={channel}
|
||||
allowAnimation={shouldAnimateEmoji}
|
||||
gridColumns={gridColumns}
|
||||
hoveredEmoji={hoveredEmoji}
|
||||
selectedRow={selectedRow}
|
||||
selectedColumn={selectedColumn}
|
||||
|
||||
@@ -6,6 +6,7 @@ import styles from '@app/features/channel/components/GuildMembersPage.module.css
|
||||
import {MembersTableView} from '@app/features/channel/components/guild_members_page/MembersTableView';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {MEMBERS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {useFluxerDocumentTitle} from '@app/features/window/hooks/useFluxerDocumentTitle';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {UsersIcon} from '@phosphor-icons/react';
|
||||
@@ -29,7 +30,7 @@ export const GuildMembersPage: React.FC<GuildMembersPageProps> = observer(({guil
|
||||
>
|
||||
<UsersIcon
|
||||
className={styles.headerIcon}
|
||||
size={20}
|
||||
size={remFromPx(20)}
|
||||
data-flx="channel.guild-members-page.header-left-content.header-icon"
|
||||
/>
|
||||
<span className={styles.headerLabel} data-flx="channel.guild-members-page.header-left-content.header-label">
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
||||
|
||||
.composerHost {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.composerEditable {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@
|
||||
import {EVERYONE_MENTION, HERE_MENTION} from '@app/features/app/config/I18nDisplayConstants';
|
||||
import styles from '@app/features/channel/components/MentionEveryonePopout.module.css';
|
||||
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
|
||||
import type {I18n} from '@lingui/core';
|
||||
import {msg, ph} from '@lingui/core/macro';
|
||||
@@ -151,7 +152,7 @@ export const MentionEveryonePopout = ({
|
||||
>
|
||||
<div className={styles.header} data-flx="channel.mention-everyone-popout.header">
|
||||
<WarningIcon
|
||||
size={20}
|
||||
size={remFromPx(20)}
|
||||
weight="fill"
|
||||
className={styles.warningIcon}
|
||||
data-flx="channel.mention-everyone-popout.warning-icon"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {COPY_LINK_DESCRIPTOR, OPEN_LINK_DESCRIPTOR} from '@app/features/i18n/uti
|
||||
import {useMessageReactions as useMessageReactionsSnapshot} from '@app/features/messaging/hooks/useMessageReactionStore';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {getEmojiNameWithColons, toReactionEmoji} from '@app/features/messaging/utils/ReactionUtils';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {CopyLinkIcon, OpenLinkIcon} from '@app/features/ui/action_menu/ContextMenuIcons';
|
||||
import * as TextCopyCommands from '@app/features/ui/commands/TextCopyCommands';
|
||||
import type {MenuGroupType} from '@app/features/ui/menu_bottom_sheet/MenuBottomSheet';
|
||||
@@ -149,7 +150,7 @@ export const MessageActionBottomSheet: React.FC<MessageActionBottomSheetProps> =
|
||||
className={quickReactionStyles.button}
|
||||
data-flx="channel.message-action-bottom-sheet.button.add-reaction"
|
||||
>
|
||||
<PlusIcon size={24} weight="bold" data-flx="channel.message-action-bottom-sheet.plus-icon" />
|
||||
<PlusIcon size={remFromPx(24)} weight="bold" data-flx="channel.message-action-bottom-sheet.plus-icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loaded 100 of 513 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user