mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(app): allow custom slowmode values (#1584)
This commit is contained in:
1 parent
09ea748479
commit
5cc92469aa
5 files changed
+91
-51
No files matched your search
+1
-2
@@ -83,7 +83,6 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
|||||||
const isVoiceChannel = channel?.type === ChannelTypes.GUILD_VOICE;
|
const isVoiceChannel = channel?.type === ChannelTypes.GUILD_VOICE;
|
||||||
const [rtcRegions, setRtcRegions] = useState<Array<ChannelRtcRegion>>([]);
|
const [rtcRegions, setRtcRegions] = useState<Array<ChannelRtcRegion>>([]);
|
||||||
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
|
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
|
||||||
const remoteSlowmodeSeconds = channel == null ? 0 : channel.rateLimitPerUser;
|
|
||||||
const form = useForm<FormInputs>({
|
const form = useForm<FormInputs>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -303,7 +302,7 @@ const ChannelOverviewTab: React.FC<{channelId: string}> = observer(({channelId})
|
|||||||
initialTopic={remoteValues != null && remoteValues.topic != null ? remoteValues.topic : ''}
|
initialTopic={remoteValues != null && remoteValues.topic != null ? remoteValues.topic : ''}
|
||||||
onTopicExceedsLimit={handleTopicExceedsLimit}
|
onTopicExceedsLimit={handleTopicExceedsLimit}
|
||||||
/>
|
/>
|
||||||
<SlowmodeControl form={form} remoteSlowmodeSeconds={remoteSlowmodeSeconds} />
|
<SlowmodeControl form={form} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showVoiceSection && (
|
{showVoiceSection && (
|
||||||
|
|||||||
+12
-38
@@ -12,7 +12,6 @@ import type {I18n} from '@lingui/core';
|
|||||||
import {msg} from '@lingui/core/macro';
|
import {msg} from '@lingui/core/macro';
|
||||||
import {useLingui} from '@lingui/react/macro';
|
import {useLingui} from '@lingui/react/macro';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import {useMemo} from 'react';
|
|
||||||
import {Controller, type UseFormReturn} from 'react-hook-form';
|
import {Controller, type UseFormReturn} from 'react-hook-form';
|
||||||
|
|
||||||
const SLOWMODE_DESCRIPTOR = msg({
|
const SLOWMODE_DESCRIPTOR = msg({
|
||||||
@@ -48,30 +47,6 @@ const SLOWMODE_STOP_SECONDS: ReadonlyArray<number> = [
|
|||||||
];
|
];
|
||||||
const SLOWMODE_MARKER_SECONDS: ReadonlyArray<number> = [0, 60, 3600, 21600];
|
const SLOWMODE_MARKER_SECONDS: ReadonlyArray<number> = [0, 60, 3600, 21600];
|
||||||
|
|
||||||
function buildSlowmodeStops(remoteSeconds: number): Array<number> {
|
|
||||||
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<number>, 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 {
|
function formatSlowmodeDuration(i18n: I18n, seconds: number): string {
|
||||||
if (seconds === 0) {
|
if (seconds === 0) {
|
||||||
return i18n._(OFF_DESCRIPTOR);
|
return i18n._(OFF_DESCRIPTOR);
|
||||||
@@ -87,13 +62,10 @@ function formatSlowmodeDuration(i18n: I18n, seconds: number): string {
|
|||||||
|
|
||||||
interface SlowmodeControlProps {
|
interface SlowmodeControlProps {
|
||||||
form: UseFormReturn<FormInputs>;
|
form: UseFormReturn<FormInputs>;
|
||||||
remoteSlowmodeSeconds: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SlowmodeControl: React.FC<SlowmodeControlProps> = ({form, remoteSlowmodeSeconds}) => {
|
export const SlowmodeControl: React.FC<SlowmodeControlProps> = ({form}) => {
|
||||||
const {i18n} = useLingui();
|
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 slowmodeLabel = i18n._(SLOWMODE_DESCRIPTOR);
|
||||||
const bypassSlowmodePermissionLabel = formatPermissionLabel(i18n, Permissions.BYPASS_SLOWMODE);
|
const bypassSlowmodePermissionLabel = formatPermissionLabel(i18n, Permissions.BYPASS_SLOWMODE);
|
||||||
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
const resetSliderLabel = i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
||||||
@@ -108,7 +80,6 @@ export const SlowmodeControl: React.FC<SlowmodeControlProps> = ({form, remoteSlo
|
|||||||
} else {
|
} else {
|
||||||
currentSeconds = 0;
|
currentSeconds = 0;
|
||||||
}
|
}
|
||||||
const currentIndex = findNearestStopIndex(stops, currentSeconds);
|
|
||||||
return (
|
return (
|
||||||
<SettingsControlRow
|
<SettingsControlRow
|
||||||
label={slowmodeLabel}
|
label={slowmodeLabel}
|
||||||
@@ -118,18 +89,21 @@ export const SlowmodeControl: React.FC<SlowmodeControlProps> = ({form, remoteSlo
|
|||||||
>
|
>
|
||||||
<div className={styles.settingsSliderControl}>
|
<div className={styles.settingsSliderControl}>
|
||||||
<Slider
|
<Slider
|
||||||
value={currentIndex}
|
value={currentSeconds}
|
||||||
defaultValue={currentIndex}
|
defaultValue={currentSeconds}
|
||||||
factoryDefaultValue={0}
|
factoryDefaultValue={0}
|
||||||
minValue={0}
|
minValue={CHANNEL_RATE_LIMIT_PER_USER_MIN}
|
||||||
maxValue={stops.length - 1}
|
maxValue={CHANNEL_RATE_LIMIT_PER_USER_MAX}
|
||||||
step={1}
|
step={1}
|
||||||
markers={markerIndexes}
|
equidistant
|
||||||
|
markers={SLOWMODE_STOP_SECONDS}
|
||||||
ariaLabel={slowmodeLabel}
|
ariaLabel={slowmodeLabel}
|
||||||
ariaValueText={formatSlowmodeDuration(i18n, currentSeconds)}
|
ariaValueText={formatSlowmodeDuration(i18n, currentSeconds)}
|
||||||
onMarkerRender={(index) => formatSlowmodeDuration(i18n, stops[index])}
|
onMarkerRender={(seconds) =>
|
||||||
onValueRender={(index) => formatSlowmodeDuration(i18n, stops[Math.round(index)])}
|
SLOWMODE_MARKER_SECONDS.includes(seconds) ? formatSlowmodeDuration(i18n, seconds) : null
|
||||||
onValueChange={(index) => field.onChange(stops[Math.round(index)])}
|
}
|
||||||
|
onValueRender={(seconds) => formatSlowmodeDuration(i18n, Math.round(seconds))}
|
||||||
|
onValueChange={(seconds) => field.onChange(Math.round(seconds))}
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
onReset={() => field.onChange(0)}
|
onReset={() => field.onChange(0)}
|
||||||
resetTooltip={resetSliderLabel}
|
resetTooltip={resetSliderLabel}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import styles from '@app/features/ui/components/Slider.module.css';
|
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 {SliderResetControls} from '@app/features/ui/components/slider/SliderResetControls';
|
||||||
import {SliderTooltipPortal} from '@app/features/ui/components/slider/SliderTooltipPortal';
|
import {SliderTooltipPortal} from '@app/features/ui/components/slider/SliderTooltipPortal';
|
||||||
import type {SliderProps} from '@app/features/ui/components/slider/SliderTypes';
|
import type {SliderProps} from '@app/features/ui/components/slider/SliderTypes';
|
||||||
@@ -28,6 +34,10 @@ function getSingleValue(value: number | ReadonlyArray<number>): number {
|
|||||||
return typeof value === 'number' ? value : (value[0] ?? 0);
|
return typeof value === 'number' ? value : (value[0] ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPointerInteractionReason(reason: string): boolean {
|
||||||
|
return reason === 'track-press' || reason === 'drag';
|
||||||
|
}
|
||||||
|
|
||||||
export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -90,6 +100,29 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
|||||||
[markerState, stickToMarkers],
|
[markerState, stickToMarkers],
|
||||||
);
|
);
|
||||||
const value = normalizeValue(rawValue);
|
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<number>, 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 isAtFactoryDefault = factoryDefaultValue !== undefined && value === factoryDefaultValue;
|
||||||
const canReset = showResetButton && !isAtFactoryDefault && !disabled;
|
const canReset = showResetButton && !isAtFactoryDefault && !disabled;
|
||||||
const inlineResetLabel = resetTooltip ?? i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
const inlineResetLabel = resetTooltip ?? i18n._(RESET_SLIDER_TO_DEFAULT_VALUE_DESCRIPTOR);
|
||||||
@@ -130,20 +163,20 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
|||||||
[asValueChanges, controlledValue, onValueChange],
|
[asValueChanges, controlledValue, onValueChange],
|
||||||
);
|
);
|
||||||
const handleBaseValueChange = useCallback(
|
const handleBaseValueChange = useCallback(
|
||||||
(newValue: number | ReadonlyArray<number>) => {
|
(newValue: number | ReadonlyArray<number>, eventDetails: BaseSlider.Root.ChangeEventDetails) => {
|
||||||
commitControlledValue(normalizeValue(getSingleValue(newValue)));
|
commitControlledValue(resolveBaseValue(newValue, eventDetails.reason));
|
||||||
},
|
},
|
||||||
[commitControlledValue, normalizeValue],
|
[commitControlledValue, resolveBaseValue],
|
||||||
);
|
);
|
||||||
const handleBaseValueCommitted = useCallback(
|
const handleBaseValueCommitted = useCallback(
|
||||||
(newValue: number | ReadonlyArray<number>) => {
|
(newValue: number | ReadonlyArray<number>, eventDetails: BaseSlider.Root.CommitEventDetails) => {
|
||||||
const nextValue = normalizeValue(getSingleValue(newValue));
|
const nextValue = resolveBaseValue(newValue, eventDetails.reason);
|
||||||
if (asValueChanges) {
|
if (asValueChanges) {
|
||||||
onValueChange?.(nextValue);
|
onValueChange?.(nextValue);
|
||||||
}
|
}
|
||||||
endPointerInteraction();
|
endPointerInteraction();
|
||||||
},
|
},
|
||||||
[asValueChanges, endPointerInteraction, normalizeValue, onValueChange],
|
[asValueChanges, endPointerInteraction, onValueChange, resolveBaseValue],
|
||||||
);
|
);
|
||||||
const handleControlPointerDownCapture = useCallback(
|
const handleControlPointerDownCapture = useCallback(
|
||||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
@@ -274,7 +307,7 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
|||||||
<BaseSlider.Track className={styles.track} style={barStyles} data-flx="ui.slider.base-slider.track">
|
<BaseSlider.Track className={styles.track} style={barStyles} data-flx="ui.slider.base-slider.track">
|
||||||
<BaseSlider.Indicator
|
<BaseSlider.Indicator
|
||||||
className={styles.barFill}
|
className={styles.barFill}
|
||||||
style={fillStyles}
|
style={indicatorStyle}
|
||||||
data-flx="ui.slider.base-slider.indicator"
|
data-flx="ui.slider.base-slider.indicator"
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
@@ -282,6 +315,7 @@ export const Slider = React.forwardRef<HTMLDivElement, SliderProps>(
|
|||||||
ref={thumbRef}
|
ref={thumbRef}
|
||||||
inputRef={thumbInputRef}
|
inputRef={thumbInputRef}
|
||||||
className={styles.grabber}
|
className={styles.grabber}
|
||||||
|
style={thumbStyle}
|
||||||
aria-label={ariaLabelledBy ? undefined : sliderLabel}
|
aria-label={ariaLabelledBy ? undefined : sliderLabel}
|
||||||
aria-labelledby={ariaLabelledBy}
|
aria-labelledby={ariaLabelledBy}
|
||||||
getAriaValueText={ariaValueText ? () => ariaValueText : undefined}
|
getAriaValueText={ariaValueText ? () => ariaValueText : undefined}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type {MarkerState} from '@app/features/ui/components/slider/SliderTypes';
|
import type {MarkerState} from '@app/features/ui/components/slider/SliderTypes';
|
||||||
|
|
||||||
interface BuildMarkerStateOptions {
|
interface BuildMarkerStateOptions {
|
||||||
markers?: Array<number>;
|
markers?: ReadonlyArray<number>;
|
||||||
minValue: number;
|
minValue: number;
|
||||||
maxValue: number;
|
maxValue: number;
|
||||||
value: 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));
|
return Math.max(min, Math.min(max, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findClosestMarkerIndex(value: number, markerArray: Array<number>): number {
|
export function findClosestMarkerIndex(value: number, markerArray: ReadonlyArray<number>): number {
|
||||||
if (markerArray.length === 0) return 0;
|
if (markerArray.length === 0) return 0;
|
||||||
let previousDiff = 0;
|
let previousDiff = 0;
|
||||||
for (let i = 0; i < markerArray.length; i++) {
|
for (let i = 0; i < markerArray.length; i++) {
|
||||||
@@ -53,6 +53,13 @@ export function buildMarkerState({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const sortedMarkers = [...markers].sort((a, b) => a - b);
|
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 closestMarkerIndex = findClosestMarkerIndex(value, sortedMarkers);
|
||||||
const min = sortedMarkers[0];
|
const min = sortedMarkers[0];
|
||||||
const max = sortedMarkers[sortedMarkers.length - 1];
|
const max = sortedMarkers[sortedMarkers.length - 1];
|
||||||
@@ -78,6 +85,32 @@ export function scaleValue(markerState: Pick<MarkerState, 'min' | 'range'>, valu
|
|||||||
return (100 * (value - markerState.min)) / markerState.range;
|
return (100 * (value - markerState.min)) / markerState.range;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function scaleValueEquidistant(markers: ReadonlyArray<number>, 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<number>, 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 {
|
export function snapValueToMarker(value: number, markerState: MarkerState): number {
|
||||||
if (markerState.sortedMarkers.length === 0) {
|
if (markerState.sortedMarkers.length === 0) {
|
||||||
return value;
|
return value;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface SliderProps {
|
|||||||
maxValue?: number;
|
maxValue?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
equidistant?: boolean;
|
equidistant?: boolean;
|
||||||
markers?: Array<number>;
|
markers?: ReadonlyArray<number>;
|
||||||
className?: string;
|
className?: string;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
ariaLabelledBy?: string;
|
ariaLabelledBy?: string;
|
||||||
|
|||||||
Reference in new issue
Block a user