diff --git a/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelOverviewTab.tsx b/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelOverviewTab.tsx index d47a733cb..49afa01ba 100644 --- a/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelOverviewTab.tsx +++ b/fluxer_app/src/features/channel/components/modals/channel_tabs/ChannelOverviewTab.tsx @@ -83,7 +83,6 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId}) const isVoiceChannel = channel?.type === ChannelTypes.GUILD_VOICE; const [rtcRegions, setRtcRegions] = useState>([]); const [isLoadingRegions, setIsLoadingRegions] = useState(false); - const remoteSlowmodeSeconds = channel == null ? 0 : channel.rateLimitPerUser; const form = useForm({ defaultValues: { name: '', @@ -303,7 +302,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId}) initialTopic={remoteValues != null && remoteValues.topic != null ? remoteValues.topic : ''} onTopicExceedsLimit={handleTopicExceedsLimit} /> - + )} {showVoiceSection && ( diff --git a/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl.tsx b/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl.tsx index 9f6ee65d8..b81e45f78 100644 --- a/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl.tsx +++ b/fluxer_app/src/features/channel/components/modals/channel_tabs/channel_overview_tab/SlowmodeControl.tsx @@ -12,7 +12,6 @@ import type {I18n} from '@lingui/core'; import {msg} from '@lingui/core/macro'; import {useLingui} from '@lingui/react/macro'; import type React from 'react'; -import {useMemo} from 'react'; import {Controller, type UseFormReturn} from 'react-hook-form'; const SLOWMODE_DESCRIPTOR = msg({ @@ -48,30 +47,6 @@ const SLOWMODE_STOP_SECONDS: ReadonlyArray = [ ]; const SLOWMODE_MARKER_SECONDS: ReadonlyArray = [0, 60, 3600, 21600]; -function buildSlowmodeStops(remoteSeconds: number): Array { - const boundedSeconds = Math.min( - Math.max(Math.round(remoteSeconds), CHANNEL_RATE_LIMIT_PER_USER_MIN), - CHANNEL_RATE_LIMIT_PER_USER_MAX, - ); - if (SLOWMODE_STOP_SECONDS.includes(boundedSeconds)) { - return [...SLOWMODE_STOP_SECONDS]; - } - return [...SLOWMODE_STOP_SECONDS, boundedSeconds].sort((left, right) => left - right); -} - -function findNearestStopIndex(stops: ReadonlyArray, seconds: number): number { - let nearestIndex = 0; - let nearestDistance = Number.POSITIVE_INFINITY; - for (let index = 0; index < stops.length; index++) { - const distance = Math.abs(stops[index] - seconds); - if (distance < nearestDistance) { - nearestIndex = index; - nearestDistance = distance; - } - } - return nearestIndex; -} - function formatSlowmodeDuration(i18n: I18n, seconds: number): string { if (seconds === 0) { return i18n._(OFF_DESCRIPTOR); @@ -87,13 +62,10 @@ function formatSlowmodeDuration(i18n: I18n, seconds: number): string { interface SlowmodeControlProps { form: UseFormReturn; - remoteSlowmodeSeconds: number; } -export const SlowmodeControl: React.FC = ({form, remoteSlowmodeSeconds}) => { +export const SlowmodeControl: React.FC = ({form}) => { const {i18n} = useLingui(); - const stops = useMemo(() => buildSlowmodeStops(remoteSlowmodeSeconds), [remoteSlowmodeSeconds]); - const markerIndexes = useMemo(() => SLOWMODE_MARKER_SECONDS.map((seconds) => stops.indexOf(seconds)), [stops]); const slowmodeLabel = i18n._(SLOWMODE_DESCRIPTOR); const bypassSlowmodePermissionLabel = formatPermissionLabel(i18n, Permissions.BYPASS_SLOWMODE); const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR); @@ -108,7 +80,6 @@ export const SlowmodeControl: React.FC = ({form, remoteSlo } else { currentSeconds = 0; } - const currentIndex = findNearestStopIndex(stops, currentSeconds); return ( = ({form, remoteSlo >
formatSlowmodeDuration(i18n, stops[index])} - onValueRender={(index) => formatSlowmodeDuration(i18n, stops[Math.round(index)])} - onValueChange={(index) => field.onChange(stops[Math.round(index)])} + onMarkerRender={(seconds) => + SLOWMODE_MARKER_SECONDS.includes(seconds) ? formatSlowmodeDuration(i18n, seconds) : null + } + onValueRender={(seconds) => formatSlowmodeDuration(i18n, Math.round(seconds))} + onValueChange={(seconds) => field.onChange(Math.round(seconds))} showResetButton={true} onReset={() => field.onChange(0)} resetTooltip={resetSliderLabel} diff --git a/fluxer_app/src/features/ui/components/Slider.tsx b/fluxer_app/src/features/ui/components/Slider.tsx index eb71b757e..726b83fdb 100644 --- a/fluxer_app/src/features/ui/components/Slider.tsx +++ b/fluxer_app/src/features/ui/components/Slider.tsx @@ -1,7 +1,13 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import styles from '@app/features/ui/components/Slider.module.css'; -import {buildMarkerState, clamp, snapValueToMarker} from '@app/features/ui/components/slider/SliderMath'; +import { + buildMarkerState, + clamp, + scaleValueEquidistant, + snapValueToMarker, + unscaleValueEquidistant, +} from '@app/features/ui/components/slider/SliderMath'; import {SliderResetControls} from '@app/features/ui/components/slider/SliderResetControls'; import {SliderTooltipPortal} from '@app/features/ui/components/slider/SliderTooltipPortal'; import type {SliderProps} from '@app/features/ui/components/slider/SliderTypes'; @@ -28,6 +34,10 @@ function getSingleValue(value: number | ReadonlyArray): number { return typeof value === 'number' ? value : (value[0] ?? 0); } +function isPointerInteractionReason(reason: string): boolean { + return reason === 'track-press' || reason === 'drag'; +} + export const Slider = React.forwardRef( ( { @@ -90,6 +100,29 @@ export const Slider = React.forwardRef( [markerState, stickToMarkers], ); const value = normalizeValue(rawValue); + const usesEquidistantScale = equidistant && markerState.sortedMarkers.length > 1; + const visualPercent = usesEquidistantScale ? scaleValueEquidistant(markerState.sortedMarkers, value) : undefined; + const resolveBaseValue = useCallback( + (newValue: number | ReadonlyArray, reason: string) => { + const baseValue = getSingleValue(newValue); + if (!usesEquidistantScale || !isPointerInteractionReason(reason)) { + return normalizeValue(baseValue); + } + const basePercent = ((baseValue - baseMin) / (baseMax - baseMin)) * 100; + return normalizeValue(unscaleValueEquidistant(markerState.sortedMarkers, basePercent)); + }, + [baseMax, baseMin, markerState.sortedMarkers, normalizeValue, usesEquidistantScale], + ); + const indicatorStyle = useMemo(() => { + if (visualPercent === undefined) return fillStyles; + return orientation === 'vertical' + ? {...fillStyles, height: `${visualPercent}%`} + : {...fillStyles, width: `${visualPercent}%`}; + }, [fillStyles, orientation, visualPercent]); + const thumbStyle = useMemo(() => { + if (visualPercent === undefined) return undefined; + return orientation === 'vertical' ? {bottom: `${visualPercent}%`} : {insetInlineStart: `${visualPercent}%`}; + }, [orientation, visualPercent]); const isAtFactoryDefault = factoryDefaultValue !== undefined && value === factoryDefaultValue; const canReset = showResetButton && !isAtFactoryDefault && !disabled; const inlineResetLabel = resetTooltip ?? i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR); @@ -130,20 +163,20 @@ export const Slider = React.forwardRef( [asValueChanges, controlledValue, onValueChange], ); const handleBaseValueChange = useCallback( - (newValue: number | ReadonlyArray) => { - commitControlledValue(normalizeValue(getSingleValue(newValue))); + (newValue: number | ReadonlyArray, eventDetails: BaseSlider.Root.ChangeEventDetails) => { + commitControlledValue(resolveBaseValue(newValue, eventDetails.reason)); }, - [commitControlledValue, normalizeValue], + [commitControlledValue, resolveBaseValue], ); const handleBaseValueCommitted = useCallback( - (newValue: number | ReadonlyArray) => { - const nextValue = normalizeValue(getSingleValue(newValue)); + (newValue: number | ReadonlyArray, eventDetails: BaseSlider.Root.CommitEventDetails) => { + const nextValue = resolveBaseValue(newValue, eventDetails.reason); if (asValueChanges) { onValueChange?.(nextValue); } endPointerInteraction(); }, - [asValueChanges, endPointerInteraction, normalizeValue, onValueChange], + [asValueChanges, endPointerInteraction, onValueChange, resolveBaseValue], ); const handleControlPointerDownCapture = useCallback( (event: React.PointerEvent) => { @@ -274,7 +307,7 @@ export const Slider = React.forwardRef( {children} @@ -282,6 +315,7 @@ export const Slider = React.forwardRef( ref={thumbRef} inputRef={thumbInputRef} className={styles.grabber} + style={thumbStyle} aria-label={ariaLabelledBy ? undefined : sliderLabel} aria-labelledby={ariaLabelledBy} getAriaValueText={ariaValueText ? () => ariaValueText : undefined} diff --git a/fluxer_app/src/features/ui/components/slider/SliderMath.ts b/fluxer_app/src/features/ui/components/slider/SliderMath.ts index b48e6a0ef..b3f8ec5b3 100644 --- a/fluxer_app/src/features/ui/components/slider/SliderMath.ts +++ b/fluxer_app/src/features/ui/components/slider/SliderMath.ts @@ -3,7 +3,7 @@ import type {MarkerState} from '@app/features/ui/components/slider/SliderTypes'; interface BuildMarkerStateOptions { - markers?: Array; + markers?: ReadonlyArray; minValue: number; maxValue: number; value: number; @@ -14,7 +14,7 @@ export function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } -export function findClosestMarkerIndex(value: number, markerArray: Array): number { +export function findClosestMarkerIndex(value: number, markerArray: ReadonlyArray): number { if (markerArray.length === 0) return 0; let previousDiff = 0; for (let i = 0; i < markerArray.length; i++) { @@ -53,6 +53,13 @@ export function buildMarkerState({ }; } const sortedMarkers = [...markers].sort((a, b) => a - b); + if (equidistant) { + for (let index = 0; index < sortedMarkers.length; index++) { + if (!Number.isFinite(sortedMarkers[index]) || (index > 0 && sortedMarkers[index] <= sortedMarkers[index - 1])) { + throw new Error('Equidistant slider markers must be finite and strictly increasing.'); + } + } + } const closestMarkerIndex = findClosestMarkerIndex(value, sortedMarkers); const min = sortedMarkers[0]; const max = sortedMarkers[sortedMarkers.length - 1]; @@ -78,6 +85,32 @@ export function scaleValue(markerState: Pick, valu return (100 * (value - markerState.min)) / markerState.range; } +export function scaleValueEquidistant(markers: ReadonlyArray, value: number): number { + if (markers.length < 2) return 0; + const boundedValue = clamp(value, markers[0], markers[markers.length - 1]); + if (boundedValue === markers[markers.length - 1]) return 100; + let upperIndex = 1; + while (upperIndex < markers.length && boundedValue > markers[upperIndex]) { + upperIndex++; + } + const lowerIndex = upperIndex - 1; + const segmentRange = markers[upperIndex] - markers[lowerIndex]; + if (segmentRange <= 0) { + throw new Error('Equidistant slider markers must be strictly increasing.'); + } + const segmentProgress = (boundedValue - markers[lowerIndex]) / segmentRange; + return (100 * (lowerIndex + segmentProgress)) / (markers.length - 1); +} + +export function unscaleValueEquidistant(markers: ReadonlyArray, percentage: number): number { + if (markers.length < 2) return markers[0] ?? 0; + const boundedPercentage = clamp(percentage, 0, 100); + const segmentPosition = (boundedPercentage / 100) * (markers.length - 1); + const lowerIndex = Math.min(Math.floor(segmentPosition), markers.length - 2); + const segmentProgress = segmentPosition - lowerIndex; + return markers[lowerIndex] + (markers[lowerIndex + 1] - markers[lowerIndex]) * segmentProgress; +} + export function snapValueToMarker(value: number, markerState: MarkerState): number { if (markerState.sortedMarkers.length === 0) { return value; diff --git a/fluxer_app/src/features/ui/components/slider/SliderTypes.ts b/fluxer_app/src/features/ui/components/slider/SliderTypes.ts index 1df4782c6..ecf4b5f88 100644 --- a/fluxer_app/src/features/ui/components/slider/SliderTypes.ts +++ b/fluxer_app/src/features/ui/components/slider/SliderTypes.ts @@ -13,7 +13,7 @@ export interface SliderProps { maxValue?: number; disabled?: boolean; equidistant?: boolean; - markers?: Array; + markers?: ReadonlyArray; className?: string; ariaLabel?: string; ariaLabelledBy?: string;