fix(app): backport rendering improvements (#1580)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-13 19:52:00 +02:00
1 parent 0c291a01da
commit 42cdc1f877
513 files changed
+98743 -70397

No files matched your search

+13 -10
View File
@@ -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}
+6 -2
View File
@@ -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"
@@ -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}
@@ -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"
@@ -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>
);
@@ -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;
}
@@ -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"
@@ -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
@@ -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})}
@@ -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