refactor progress
This commit is contained in:
@@ -17,29 +17,21 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {updateDocumentTitleBadge} from '@app/hooks/useFluxerDocumentTitle';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import GuildReadStateStore from '@app/stores/GuildReadStateStore';
|
||||
import NotificationStore from '@app/stores/NotificationStore';
|
||||
import RelationshipStore from '@app/stores/RelationshipStore';
|
||||
import {getElectronAPI} from '@app/utils/NativeUtils';
|
||||
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import Favico from 'favico.js';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useEffect} from 'react';
|
||||
import {RelationshipTypes} from '~/Constants';
|
||||
import {updateDocumentTitleBadge} from '~/hooks/useFluxerDocumentTitle';
|
||||
import {Logger} from '~/lib/Logger';
|
||||
import GuildReadStateStore from '~/stores/GuildReadStateStore';
|
||||
import NotificationStore from '~/stores/NotificationStore';
|
||||
import RelationshipStore from '~/stores/RelationshipStore';
|
||||
import {getElectronAPI} from '~/utils/NativeUtils';
|
||||
|
||||
declare global {
|
||||
interface Navigator {
|
||||
setAppBadge?: (contents?: number) => Promise<void>;
|
||||
clearAppBadge?: () => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
const logger = new Logger('AppBadge');
|
||||
|
||||
const UNREAD_INDICATOR = -1;
|
||||
type BadgeValue = number;
|
||||
|
||||
let favico: Favico | null = null;
|
||||
|
||||
@@ -55,9 +47,9 @@ const initFavico = (): Favico | null => {
|
||||
}
|
||||
};
|
||||
|
||||
const setElectronBadge = (badge: BadgeValue): void => {
|
||||
const setElectronBadge = (badge: number): void => {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return;
|
||||
if (!electronApi?.setBadgeCount) return;
|
||||
|
||||
const electronBadge = badge > 0 ? badge : 0;
|
||||
try {
|
||||
@@ -67,7 +59,7 @@ const setElectronBadge = (badge: BadgeValue): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const setFaviconBadge = (badge: BadgeValue): void => {
|
||||
const setFaviconBadge = (badge: number): void => {
|
||||
const fav = initFavico();
|
||||
if (!fav) return;
|
||||
|
||||
@@ -82,7 +74,7 @@ const setFaviconBadge = (badge: BadgeValue): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const setPwaBadge = (badge: BadgeValue): void => {
|
||||
const setPwaBadge = (badge: number): void => {
|
||||
if (!navigator.setAppBadge || !navigator.clearAppBadge) {
|
||||
return;
|
||||
}
|
||||
@@ -100,7 +92,7 @@ const setPwaBadge = (badge: BadgeValue): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const setBadge = (badge: BadgeValue): void => {
|
||||
const setBadge = (badge: number): void => {
|
||||
setElectronBadge(badge);
|
||||
setFaviconBadge(badge);
|
||||
setPwaBadge(badge);
|
||||
@@ -119,7 +111,7 @@ export const AppBadge: React.FC = observer(() => {
|
||||
|
||||
const totalCount = mentionCount + pendingCount;
|
||||
|
||||
let badge: BadgeValue = 0;
|
||||
let badge: number = 0;
|
||||
if (totalCount > 0) {
|
||||
badge = totalCount;
|
||||
} else if (hasUnread && unreadMessageBadgeEnabled) {
|
||||
|
||||
@@ -17,24 +17,27 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/ErrorFallback.module.css';
|
||||
import {FluxerIcon} from '@app/components/icons/FluxerIcon';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import AppStorage from '@app/lib/AppStorage';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import React from 'react';
|
||||
import {FluxerIcon} from '~/components/icons/FluxerIcon';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import AppStorage from '~/lib/AppStorage';
|
||||
import styles from './ErrorFallback.module.css';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
interface BootstrapErrorScreenProps {
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
const PRESERVED_RESET_STORAGE_KEYS = ['DraftStore'] as const;
|
||||
|
||||
export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error}) => {
|
||||
const handleRetry = React.useCallback(() => {
|
||||
const handleRetry = useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
AppStorage.clear();
|
||||
const handleReset = useCallback(() => {
|
||||
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS);
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -17,29 +17,47 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import errorFallbackStyles from '@app/components/ErrorFallback.module.css';
|
||||
import {FluxerIcon} from '@app/components/icons/FluxerIcon';
|
||||
import {NativeTitlebar} from '@app/components/layout/NativeTitlebar';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {useNativePlatform} from '@app/hooks/useNativePlatform';
|
||||
import AppStorage from '@app/lib/AppStorage';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import {ensureLatestAssets} from '@app/lib/Versioning';
|
||||
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
|
||||
import LayerManager from '@app/stores/LayerManager';
|
||||
import MediaEngineStore from '@app/stores/voice/MediaEngineFacade';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import errorFallbackStyles from '~/components/ErrorFallback.module.css';
|
||||
import {FluxerIcon} from '~/components/icons/FluxerIcon';
|
||||
import {NativeTitlebar} from '~/components/layout/NativeTitlebar';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {useNativePlatform} from '~/hooks/useNativePlatform';
|
||||
import AppStorage from '~/lib/AppStorage';
|
||||
import {ensureLatestAssets} from '~/lib/versioning';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
interface ErrorFallbackProps {
|
||||
error?: Error;
|
||||
reset?: () => void;
|
||||
}
|
||||
|
||||
const logger = new Logger('ErrorFallback');
|
||||
const PRESERVED_RESET_STORAGE_KEYS = ['DraftStore'] as const;
|
||||
|
||||
export const ErrorFallback: React.FC<ErrorFallbackProps> = observer(() => {
|
||||
const {platform, isNative, isMacOS} = useNativePlatform();
|
||||
const [updateAvailable, setUpdateAvailable] = React.useState(false);
|
||||
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||
const [checkingForUpdates, setCheckingForUpdates] = React.useState(true);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [checkingForUpdates, setCheckingForUpdates] = useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
try {
|
||||
GatewayConnectionStore.logout();
|
||||
LayerManager.closeAll();
|
||||
MediaEngineStore.cleanup();
|
||||
} catch (error) {
|
||||
logger.error('Failed to clean up runtime state on crash screen', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const run = async () => {
|
||||
@@ -49,7 +67,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = observer(() => {
|
||||
setUpdateAvailable(updateFound);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ErrorFallback] Failed to check for updates:', error);
|
||||
logger.error('Failed to check for updates:', error);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setCheckingForUpdates(false);
|
||||
@@ -64,7 +82,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = observer(() => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUpdate = React.useCallback(async () => {
|
||||
const handleUpdate = useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const {updateFound} = await ensureLatestAssets({force: true});
|
||||
@@ -73,7 +91,7 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = observer(() => {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ErrorFallback] Failed to apply update:', error);
|
||||
logger.error('Failed to apply update:', error);
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, []);
|
||||
@@ -99,19 +117,14 @@ export const ErrorFallback: React.FC<ErrorFallbackProps> = observer(() => {
|
||||
<div className={errorFallbackStyles.errorFallbackActions}>
|
||||
<Button
|
||||
onClick={updateAvailable ? handleUpdate : () => location.reload()}
|
||||
disabled={checkingForUpdates || isUpdating}
|
||||
disabled={checkingForUpdates}
|
||||
submitting={isUpdating}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Trans>Updating...</Trans>
|
||||
) : checkingForUpdates || updateAvailable ? (
|
||||
<Trans>Update app</Trans>
|
||||
) : (
|
||||
<Trans>Reload app</Trans>
|
||||
)}
|
||||
{checkingForUpdates || updateAvailable ? <Trans>Update app</Trans> : <Trans>Reload app</Trans>}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
AppStorage.clear();
|
||||
AppStorage.clearExcept(PRESERVED_RESET_STORAGE_KEYS);
|
||||
location.reload();
|
||||
}}
|
||||
variant="danger-primary"
|
||||
|
||||
@@ -17,9 +17,7 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export const LONG_PRESS_DURATION_MS = 500;
|
||||
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
|
||||
|
||||
const LONG_PRESS_MOVEMENT_THRESHOLD = 10;
|
||||
|
||||
@@ -31,6 +29,8 @@ const MAX_VELOCITY_SAMPLE_AGE = 100;
|
||||
|
||||
const PRESS_HIGHLIGHT_DELAY_MS = 100;
|
||||
|
||||
const LONG_PRESS_DURATION_MS = 500;
|
||||
|
||||
const HAS_POINTER_EVENTS = 'PointerEvent' in window;
|
||||
|
||||
interface VelocitySample {
|
||||
@@ -54,19 +54,19 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
{delay = LONG_PRESS_DURATION_MS, onLongPress, disabled, pressedClassName, onPressStateChange, ...rest},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const innerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const longPressTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const highlightTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pressStartPos = React.useRef<{x: number; y: number} | null>(null);
|
||||
const pointerIdRef = React.useRef<number | null>(null);
|
||||
const storedEvent = React.useRef<LongPressEvent | null>(null);
|
||||
const velocitySamples = React.useRef<Array<VelocitySample>>([]);
|
||||
const isPressIntent = React.useRef(false);
|
||||
const [isPressed, setIsPressed] = React.useState(false);
|
||||
const innerRef = useRef<HTMLDivElement | null>(null);
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const highlightTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pressStartPos = useRef<{x: number; y: number} | null>(null);
|
||||
const pointerIdRef = useRef<number | null>(null);
|
||||
const storedEvent = useRef<LongPressEvent | null>(null);
|
||||
const velocitySamples = useRef<Array<VelocitySample>>([]);
|
||||
const isPressIntent = useRef(false);
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
|
||||
React.useImperativeHandle(forwardedRef, () => innerRef.current as HTMLDivElement);
|
||||
useImperativeHandle(forwardedRef, () => innerRef.current as HTMLDivElement);
|
||||
|
||||
const setPressed = React.useCallback(
|
||||
const setPressed = useCallback(
|
||||
(pressed: boolean) => {
|
||||
setIsPressed(pressed);
|
||||
onPressStateChange?.(pressed);
|
||||
@@ -74,7 +74,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
[onPressStateChange],
|
||||
);
|
||||
|
||||
const calculateVelocity = React.useCallback((): number => {
|
||||
const calculateVelocity = useCallback((): number => {
|
||||
const samples = velocitySamples.current;
|
||||
if (samples.length < MIN_VELOCITY_SAMPLES) return 0;
|
||||
|
||||
@@ -94,7 +94,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
return distance / dt;
|
||||
}, []);
|
||||
|
||||
const clearTimer = React.useCallback(() => {
|
||||
const clearTimer = useCallback(() => {
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
@@ -129,7 +129,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
...restWithoutPointer
|
||||
} = rest;
|
||||
|
||||
const startLongPressTimer = React.useCallback(
|
||||
const startLongPressTimer = useCallback(
|
||||
(event: LongPressEvent, x: number, y: number, pointerId?: number, capturePointer = false) => {
|
||||
if (disabled || !onLongPress) return;
|
||||
clearTimer();
|
||||
@@ -163,7 +163,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
[clearTimer, delay, disabled, onLongPress, setPressed],
|
||||
);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
const handlePointerDown = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
userOnPointerDown?.(event);
|
||||
if (disabled || !onLongPress || event.button !== 0) return;
|
||||
@@ -173,7 +173,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
[disabled, onLongPress, startLongPressTimer, userOnPointerDown],
|
||||
);
|
||||
|
||||
const handlePointerMove = React.useCallback(
|
||||
const handlePointerMove = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
userOnPointerMove?.(event);
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
@@ -201,7 +201,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
[clearTimer, calculateVelocity, userOnPointerMove],
|
||||
);
|
||||
|
||||
const handlePointerUp = React.useCallback(
|
||||
const handlePointerUp = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (pointerIdRef.current === event.pointerId) {
|
||||
clearTimer();
|
||||
@@ -211,7 +211,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
[clearTimer, userOnPointerUp],
|
||||
);
|
||||
|
||||
const handlePointerCancel = React.useCallback(
|
||||
const handlePointerCancel = useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (pointerIdRef.current === event.pointerId) {
|
||||
clearTimer();
|
||||
@@ -221,7 +221,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
[clearTimer, userOnPointerCancel],
|
||||
);
|
||||
|
||||
const handleTouchStart = React.useCallback(
|
||||
const handleTouchStart = useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
userOnTouchStart?.(event);
|
||||
if (disabled || !onLongPress) return;
|
||||
@@ -232,7 +232,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
[disabled, onLongPress, startLongPressTimer, userOnTouchStart],
|
||||
);
|
||||
|
||||
const handleTouchMove = React.useCallback(
|
||||
const handleTouchMove = useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
userOnTouchMove?.(event);
|
||||
if (!pressStartPos.current) return;
|
||||
@@ -260,7 +260,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
[clearTimer, calculateVelocity, userOnTouchMove],
|
||||
);
|
||||
|
||||
const handleTouchEnd = React.useCallback(
|
||||
const handleTouchEnd = useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
clearTimer();
|
||||
userOnTouchEnd?.(event);
|
||||
@@ -268,7 +268,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
[clearTimer, userOnTouchEnd],
|
||||
);
|
||||
|
||||
const handleTouchCancel = React.useCallback(
|
||||
const handleTouchCancel = useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
clearTimer();
|
||||
userOnTouchCancel?.(event);
|
||||
@@ -276,7 +276,7 @@ export const LongPressable = React.forwardRef<HTMLDivElement, LongPressableProps
|
||||
[clearTimer, userOnTouchCancel],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (isPressIntent.current) {
|
||||
clearTimer();
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/ErrorFallback.module.css';
|
||||
import {FluxerIcon} from '@app/components/icons/FluxerIcon';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import React from 'react';
|
||||
import {FluxerIcon} from '~/components/icons/FluxerIcon';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import styles from './ErrorFallback.module.css';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
export const NetworkErrorScreen: React.FC = () => {
|
||||
const handleRetry = React.useCallback(() => {
|
||||
export const NetworkErrorScreen = () => {
|
||||
const handleRetry = useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -17,17 +17,19 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/accounts/AccountListItem.module.css';
|
||||
import {MockAvatar} from '@app/components/uikit/MockAvatar';
|
||||
import type {Account} from '@app/lib/SessionManager';
|
||||
import RuntimeConfigStore, {describeApiEndpoint} from '@app/stores/RuntimeConfigStore';
|
||||
import * as AvatarUtils from '@app/utils/AvatarUtils';
|
||||
import {getCurrentLocale} from '@app/utils/LocaleUtils';
|
||||
import {formatLastActive} from '@fluxer/date_utils/src/DateFormatting';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import clsx from 'clsx';
|
||||
import type {ReactNode} from 'react';
|
||||
import {MockAvatar} from '~/components/uikit/MockAvatar';
|
||||
import type {AccountSummary} from '~/stores/AccountManager';
|
||||
import RuntimeConfigStore, {describeApiEndpoint} from '~/stores/RuntimeConfigStore';
|
||||
import * as AvatarUtils from '~/utils/AvatarUtils';
|
||||
import styles from './AccountListItem.module.css';
|
||||
|
||||
export interface AccountListItemProps {
|
||||
account: AccountSummary;
|
||||
interface AccountListItemProps {
|
||||
account: Account;
|
||||
disabled?: boolean;
|
||||
isCurrent?: boolean;
|
||||
onClick?: () => void;
|
||||
@@ -37,7 +39,7 @@ export interface AccountListItemProps {
|
||||
meta?: ReactNode;
|
||||
}
|
||||
|
||||
export const getAccountAvatarUrl = (account: AccountSummary): string | undefined => {
|
||||
export const getAccountAvatarUrl = (account: Account): string | undefined => {
|
||||
const avatar = account.userData?.avatar ?? null;
|
||||
try {
|
||||
const mediaEndpoint = account.instance?.mediaEndpoint ?? RuntimeConfigStore.getSnapshot().mediaEndpoint;
|
||||
@@ -50,11 +52,6 @@ export const getAccountAvatarUrl = (account: AccountSummary): string | undefined
|
||||
}
|
||||
};
|
||||
|
||||
export const formatLastActive = (timestamp: number): string => {
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {dateStyle: 'medium', timeStyle: 'short'});
|
||||
return formatter.format(new Date(timestamp));
|
||||
};
|
||||
|
||||
export const AccountListItem = ({
|
||||
account,
|
||||
disabled = false,
|
||||
@@ -75,7 +72,7 @@ export const AccountListItem = ({
|
||||
isCurrent ? (
|
||||
(account.userData?.email ?? t`Email unavailable`)
|
||||
) : (
|
||||
<Trans>Last active {formatLastActive(account.lastActive)}</Trans>
|
||||
<Trans>Last active {formatLastActive(account.lastActive, getCurrentLocale())}</Trans>
|
||||
)
|
||||
) : (
|
||||
(account.userData?.email ?? t`Email unavailable`)
|
||||
|
||||
@@ -127,6 +127,11 @@ button.clickable:active {
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.menuButtonActive {
|
||||
color: var(--text-primary);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
@@ -17,21 +17,26 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {getAccountAvatarUrl} from '@app/components/accounts/AccountListItem';
|
||||
import styles from '@app/components/accounts/AccountRow.module.css';
|
||||
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
|
||||
import {MockAvatar} from '@app/components/uikit/MockAvatar';
|
||||
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
|
||||
import {useContextMenuHoverState} from '@app/hooks/useContextMenuHoverState';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {Account} from '@app/lib/SessionManager';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CaretRightIcon, CheckIcon, DotsThreeIcon, GlobeIcon} from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {MockAvatar} from '~/components/uikit/MockAvatar';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip';
|
||||
import type {AccountSummary} from '~/stores/AccountManager';
|
||||
import {getAccountAvatarUrl} from './AccountListItem';
|
||||
import styles from './AccountRow.module.css';
|
||||
import type React from 'react';
|
||||
import {useCallback, useRef} from 'react';
|
||||
|
||||
const STANDARD_INSTANCES = new Set(['web.fluxer.app', 'web.canary.fluxer.app']);
|
||||
|
||||
function getInstanceHost(account: AccountSummary): string | null {
|
||||
const logger = new Logger('AccountRow');
|
||||
|
||||
function getInstanceHost(account: Account): string | null {
|
||||
const endpoint = account.instance?.apiEndpoint;
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
@@ -40,19 +45,19 @@ function getInstanceHost(account: AccountSummary): string | null {
|
||||
try {
|
||||
return new URL(endpoint).hostname;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse instance host:', error);
|
||||
logger.error('Failed to parse instance host:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getInstanceEndpoint(account: AccountSummary): string | null {
|
||||
function getInstanceEndpoint(account: Account): string | null {
|
||||
return account.instance?.apiEndpoint ?? null;
|
||||
}
|
||||
|
||||
type AccountRowVariant = 'default' | 'manage' | 'compact';
|
||||
|
||||
interface AccountRowProps {
|
||||
account: AccountSummary;
|
||||
account: Account;
|
||||
variant?: AccountRowVariant;
|
||||
isCurrent?: boolean;
|
||||
isExpired?: boolean;
|
||||
@@ -84,8 +89,10 @@ export const AccountRow = observer(
|
||||
const instanceHost = showInstance ? getInstanceHost(account) : null;
|
||||
const instanceEndpoint = showInstance ? getInstanceEndpoint(account) : null;
|
||||
const shouldShowInstance = typeof instanceHost === 'string' && !STANDARD_INSTANCES.has(instanceHost);
|
||||
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const isContextMenuOpen = useContextMenuHoverState(menuButtonRef, Boolean(onMenuClick));
|
||||
|
||||
const handleMenuClick = React.useCallback(
|
||||
const handleMenuClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@@ -175,7 +182,13 @@ export const AccountRow = observer(
|
||||
) : null}
|
||||
{onMenuClick && variant !== 'compact' && !showCaretIndicator ? (
|
||||
<FocusRing offset={-2}>
|
||||
<button type="button" className={styles.menuButton} onClick={handleMenuClick} aria-label={t`More`}>
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
className={clsx(styles.menuButton, isContextMenuOpen && styles.menuButtonActive)}
|
||||
onClick={handleMenuClick}
|
||||
aria-label={t`More`}
|
||||
>
|
||||
<DotsThreeIcon size={20} weight="bold" className={styles.menuIcon} />
|
||||
</button>
|
||||
</FocusRing>
|
||||
|
||||
@@ -17,25 +17,28 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ContextMenuActionCreators from '@app/actions/ContextMenuActionCreators';
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
|
||||
import {AccountRow} from '@app/components/accounts/AccountRow';
|
||||
import styles from '@app/components/accounts/AccountSelector.module.css';
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {MenuGroup} from '@app/components/uikit/context_menu/MenuGroup';
|
||||
import {MenuItem} from '@app/components/uikit/context_menu/MenuItem';
|
||||
import {Scroller} from '@app/components/uikit/Scroller';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {Account} from '@app/lib/SessionManager';
|
||||
import AccountManager from '@app/stores/AccountManager';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {PlusIcon, SignOutIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
|
||||
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
|
||||
import {Scroller} from '~/components/uikit/Scroller';
|
||||
import AccountManager, {type AccountSummary} from '~/stores/AccountManager';
|
||||
import {AccountRow} from './AccountRow';
|
||||
import styles from './AccountSelector.module.css';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
interface AccountSelectorProps {
|
||||
accounts: Array<AccountSummary>;
|
||||
accounts: Array<Account>;
|
||||
currentAccountId?: string | null;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
@@ -44,11 +47,13 @@ interface AccountSelectorProps {
|
||||
showInstance?: boolean;
|
||||
clickableRows?: boolean;
|
||||
addButtonLabel?: React.ReactNode;
|
||||
onSelectAccount: (account: AccountSummary) => void;
|
||||
onSelectAccount: (account: Account) => void;
|
||||
onAddAccount?: () => void;
|
||||
scrollerKey?: string;
|
||||
}
|
||||
|
||||
const logger = new Logger('AccountSelector');
|
||||
|
||||
export const AccountSelector = observer(
|
||||
({
|
||||
accounts,
|
||||
@@ -69,8 +74,8 @@ export const AccountSelector = observer(
|
||||
const defaultDescription = <Trans>Select an account to continue, or add a different one.</Trans>;
|
||||
const hasMultipleAccounts = accounts.length > 1;
|
||||
|
||||
const openSignOutConfirm = React.useCallback(
|
||||
(account: AccountSummary) => {
|
||||
const openSignOutConfirm = useCallback(
|
||||
(account: Account) => {
|
||||
const displayName = account.userData?.username ?? account.userId;
|
||||
|
||||
ModalActionCreators.push(
|
||||
@@ -90,7 +95,7 @@ export const AccountSelector = observer(
|
||||
try {
|
||||
await AccountManager.removeStoredAccount(account.userId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove account', error);
|
||||
logger.error('Failed to remove account', error);
|
||||
ToastActionCreators.error(t`We couldn't remove that account. Please try again.`);
|
||||
}
|
||||
}}
|
||||
@@ -101,8 +106,8 @@ export const AccountSelector = observer(
|
||||
[hasMultipleAccounts, t],
|
||||
);
|
||||
|
||||
const openMenu = React.useCallback(
|
||||
(account: AccountSummary) => (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
const openMenu = useCallback(
|
||||
(account: Account) => (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ContextMenuActionCreators.openFromEvent(event, (props) => (
|
||||
|
||||
@@ -17,17 +17,18 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {AccountRow} from '@app/components/accounts/AccountRow';
|
||||
import styles from '@app/components/accounts/AccountSwitcherModal.module.css';
|
||||
import * as Modal from '@app/components/modals/Modal';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Scroller} from '@app/components/uikit/Scroller';
|
||||
import {Spinner} from '@app/components/uikit/Spinner';
|
||||
import {openAccountContextMenu, useAccountSwitcherLogic} from '@app/utils/accounts/AccountSwitcherModalUtils';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {PlusIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as Modal from '~/components/modals/Modal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Scroller} from '~/components/uikit/Scroller';
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
import {openAccountContextMenu, useAccountSwitcherLogic} from '~/utils/accounts/AccountSwitcherModalUtils';
|
||||
import {AccountRow} from './AccountRow';
|
||||
import styles from './AccountSwitcherModal.module.css';
|
||||
import type React from 'react';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
const AccountSwitcherModal = observer(() => {
|
||||
const {
|
||||
@@ -38,12 +39,12 @@ const AccountSwitcherModal = observer(() => {
|
||||
handleReLogin,
|
||||
handleAddAccount,
|
||||
handleLogout,
|
||||
handleRemoveAccount,
|
||||
handleLogoutStoredAccount,
|
||||
} = useAccountSwitcherLogic();
|
||||
|
||||
const hasMultipleAccounts = accounts.length > 1;
|
||||
|
||||
const openMenu = React.useCallback(
|
||||
const openMenu = useCallback(
|
||||
(account: (typeof accounts)[number]) => (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -55,7 +56,7 @@ const AccountSwitcherModal = observer(() => {
|
||||
onSwitch: handleSwitchAccount,
|
||||
onReLogin: handleReLogin,
|
||||
onLogout: handleLogout,
|
||||
onRemoveAccount: handleRemoveAccount,
|
||||
onLogoutStoredAccount: handleLogoutStoredAccount,
|
||||
});
|
||||
},
|
||||
[
|
||||
@@ -64,7 +65,7 @@ const AccountSwitcherModal = observer(() => {
|
||||
handleSwitchAccount,
|
||||
handleReLogin,
|
||||
handleLogout,
|
||||
handleRemoveAccount,
|
||||
handleLogoutStoredAccount,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const CallNotRingableModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {openNativePermissionSettings} from '@app/utils/NativePermissions';
|
||||
import {isDesktop, isNativeMacOS} from '@app/utils/NativeUtils';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {openNativePermissionSettings} from '~/utils/NativePermissions';
|
||||
import {isDesktop, isNativeMacOS} from '~/utils/NativeUtils';
|
||||
|
||||
export const CameraPermissionDeniedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const ChannelPermissionsUpdateFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Failed to update channel permissions`}
|
||||
title={t`Failed to Update Channel Permissions`}
|
||||
message={t`We couldn't save your channel permission changes at this time.`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const DMCloseFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal title={t`Failed to close DM`} message={t`We couldn't close the direct message at this time.`} />
|
||||
<GenericErrorModal title={t`Failed to Close DM`} message={t`We couldn't close the direct message at this time.`} />
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const FeatureTemporarilyDisabledModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,22 +17,54 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import * as PremiumModalActionCreators from '@app/actions/PremiumModalActionCreators';
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import {formatFileSize} from '@app/utils/FileUtils';
|
||||
import {Limits} from '@app/utils/limits/UserLimits';
|
||||
import {shouldShowPremiumFeatures} from '@app/utils/PremiumUtils';
|
||||
import {ATTACHMENT_MAX_SIZE_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import * as PremiumModalActionCreators from '~/actions/PremiumModalActionCreators';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
export const FileSizeTooLargeModal = observer(() => {
|
||||
interface FileSizeTooLargeModalProps {
|
||||
oversizedFileCount?: number;
|
||||
}
|
||||
|
||||
export const FileSizeTooLargeModal = observer(({oversizedFileCount}: FileSizeTooLargeModalProps) => {
|
||||
const {t} = useLingui();
|
||||
const user = UserStore.currentUser;
|
||||
const hasPremium = user?.isPremium() ?? false;
|
||||
const showPremium = shouldShowPremiumFeatures();
|
||||
const maxAttachmentFileSize = user?.maxAttachmentFileSize ?? 25 * 1024 * 1024;
|
||||
const premiumMaxAttachmentFileSize = Limits.getPremiumValue('max_attachment_file_size', ATTACHMENT_MAX_SIZE_PREMIUM);
|
||||
const canUpgradeAttachmentLimit = maxAttachmentFileSize < premiumMaxAttachmentFileSize;
|
||||
const maxSizeFormatted = formatFileSize(maxAttachmentFileSize);
|
||||
const hasKnownOversizedFileCount = oversizedFileCount != null;
|
||||
const hasMultipleOversizedFiles = (oversizedFileCount ?? 0) > 1;
|
||||
const handleGetPlutoniumClick = useCallback(() => {
|
||||
window.setTimeout(() => {
|
||||
PremiumModalActionCreators.open();
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
if (hasPremium) {
|
||||
const baseDescription = hasKnownOversizedFileCount
|
||||
? hasMultipleOversizedFiles
|
||||
? t`Some files you're trying to upload exceed the maximum size limit of ${maxSizeFormatted} per file.`
|
||||
: t`The file you're trying to upload exceeds the maximum size limit of ${maxSizeFormatted}.`
|
||||
: t`One or more files you're trying to upload exceed the maximum size limit of ${maxSizeFormatted} per file.`;
|
||||
|
||||
if (!showPremium || !canUpgradeAttachmentLimit) {
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`File size too large`}
|
||||
description={t`The file you're trying to upload exceeds the maximum size limit of 500 MB for Plutonium subscribers.`}
|
||||
description={
|
||||
!showPremium ? (
|
||||
<Trans>{baseDescription} This limit is configured by your instance administrator.</Trans>
|
||||
) : (
|
||||
baseDescription
|
||||
)
|
||||
}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
@@ -41,11 +73,17 @@ export const FileSizeTooLargeModal = observer(() => {
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`File Size Limit Exceeded`}
|
||||
description={t`The file you're trying to upload exceeds the maximum size limit of 25 MB for non-subscribers. With Plutonium, you can upload files up to 500 MB, use animated avatars and banners, write longer bios, and unlock many other premium features.`}
|
||||
title={t`File size limit exceeded`}
|
||||
description={
|
||||
<Trans>
|
||||
{baseDescription} With Plutonium, your per-file upload limit increases to{' '}
|
||||
{formatFileSize(premiumMaxAttachmentFileSize)}, plus animated avatars, longer messages, and many other premium
|
||||
features.
|
||||
</Trans>
|
||||
}
|
||||
primaryText={t`Get Plutonium`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => PremiumModalActionCreators.open()}
|
||||
onPrimary={handleGetPlutoniumClick}
|
||||
secondaryText={t`Cancel`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
interface GenericErrorModalProps {
|
||||
title: string;
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const GroupLeaveFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Failed to leave group`}
|
||||
title={t`Failed to Leave Group`}
|
||||
message={t`We couldn't remove you from the group at this time.`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,17 +17,21 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {ReactElement} from 'react';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const GroupOwnershipTransferFailedModal: React.FC<{username?: string}> = observer(({username}) => {
|
||||
interface GroupOwnershipTransferFailedModalProps {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const GroupOwnershipTransferFailedModal = observer(({username}: GroupOwnershipTransferFailedModalProps) => {
|
||||
const {t} = useLingui();
|
||||
const message: ReactElement = (
|
||||
<Trans>
|
||||
Ownership could not be transferred to <strong>{username}</strong> at this time.
|
||||
</Trans>
|
||||
);
|
||||
return <GenericErrorModal title={t`Failed to transfer ownership`} message={message} />;
|
||||
return <GenericErrorModal title={t`Failed to Transfer Ownership`} message={message} />;
|
||||
});
|
||||
|
||||
@@ -17,17 +17,21 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type {ReactElement} from 'react';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const GroupRemoveUserFailedModal: React.FC<{username?: string}> = observer(({username}) => {
|
||||
interface GroupRemoveUserFailedModalProps {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export const GroupRemoveUserFailedModal = observer(({username}: GroupRemoveUserFailedModalProps) => {
|
||||
const {t} = useLingui();
|
||||
const message: ReactElement = (
|
||||
<Trans>
|
||||
We couldn't remove the user from the group at this time. <strong>{username}</strong> is still in the group.
|
||||
</Trans>
|
||||
);
|
||||
return <GenericErrorModal title={t`Failed to remove from group`} message={message} />;
|
||||
return <GenericErrorModal title={t`Failed to Remove from Group`} message={message} />;
|
||||
});
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const GuildAtCapacityModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const InviteAcceptFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<GenericErrorModal title={t`Failed to accept invite`} message={t`We couldn't join this community at this time.`} />
|
||||
<GenericErrorModal title={t`Failed to Accept Invite`} message={t`We couldn't join this community at this time.`} />
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const InviteRevokeFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Failed to revoke invite`}
|
||||
title={t`Failed to Revoke Invite`}
|
||||
message={t`We couldn't revoke the invite at this time. The invite link may still be active. Please try again in a moment.`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const InvitesDisabledModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const InvitesLoadFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal title={t`Failed to load invites`} message={t`We couldn't load the invites at this time.`} />
|
||||
<GenericErrorModal title={t`Failed to Load Invites`} message={t`We couldn't load the invites at this time.`} />
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,49 +17,62 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro';
|
||||
import {modal, push} from '@app/actions/ModalActionCreators';
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {PremiumModal} from '@app/components/modals/PremiumModal';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import {Limits} from '@app/utils/limits/UserLimits';
|
||||
import {shouldShowPremiumFeatures} from '@app/utils/PremiumUtils';
|
||||
import {MAX_BOOKMARKS_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {modal, push} from '~/actions/ModalActionCreators';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {PremiumModal} from '~/components/modals/PremiumModal';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
|
||||
export const MaxBookmarksModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const currentUser = UserStore.currentUser!;
|
||||
const isPremium = currentUser.isPremium();
|
||||
const showPremium = shouldShowPremiumFeatures();
|
||||
const maxBookmarks = currentUser.maxBookmarks;
|
||||
const premiumBookmarks = Limits.getPremiumValue('max_bookmarks', MAX_BOOKMARKS_PREMIUM);
|
||||
const canUpgradeBookmarks = maxBookmarks < premiumBookmarks;
|
||||
|
||||
if (isPremium) {
|
||||
const bookmarksText = maxBookmarks === 1 ? t`${maxBookmarks} bookmark` : t`${maxBookmarks} bookmarks`;
|
||||
|
||||
if (!showPremium) {
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Bookmark Limit Reached`}
|
||||
description={
|
||||
<Trans>
|
||||
You've reached the maximum number of bookmarks (
|
||||
<Plural value={maxBookmarks} one="# bookmark" other="# bookmarks" />
|
||||
). Please remove some bookmarks before adding new ones.
|
||||
</Trans>
|
||||
}
|
||||
description={t`You've reached the maximum number of bookmarks (${bookmarksText}). This limit is configured by your instance administrator. Please remove some bookmarks before adding new ones.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canUpgradeBookmarks) {
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Bookmark Limit Reached`}
|
||||
description={t`You've reached the maximum number of bookmarks (${bookmarksText}). Please remove some bookmarks before adding new ones.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const premiumBookmarksText =
|
||||
premiumBookmarks === 1 ? t`${premiumBookmarks} bookmark` : t`${premiumBookmarks} bookmarks`;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Bookmark Limit Reached`}
|
||||
description={
|
||||
<Trans>
|
||||
You've reached the maximum number of bookmarks for free users (
|
||||
<Plural value={maxBookmarks} one="# bookmark" other="# bookmarks" />
|
||||
). Upgrade to Plutonium to increase your limit to 300 bookmarks, or remove some bookmarks to add new ones.
|
||||
</Trans>
|
||||
}
|
||||
description={t`You've reached the maximum number of bookmarks for free users (${bookmarksText}). Upgrade to Plutonium to increase your limit to ${premiumBookmarksText}, or remove some bookmarks to add new ones.`}
|
||||
primaryText={t`Upgrade to Plutonium`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => push(modal(() => <PremiumModal />))}
|
||||
onPrimary={() => {
|
||||
window.setTimeout(() => {
|
||||
push(modal(() => <PremiumModal />));
|
||||
}, 0);
|
||||
}}
|
||||
secondaryText={t`Dismiss`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,35 +17,63 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {modal, push} from '@app/actions/ModalActionCreators';
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {PremiumModal} from '@app/components/modals/PremiumModal';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import {Limits} from '@app/utils/limits/UserLimits';
|
||||
import {shouldShowPremiumFeatures} from '@app/utils/PremiumUtils';
|
||||
import {MAX_FAVORITE_MEMES_PREMIUM} from '@fluxer/constants/src/LimitConstants';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {modal, push} from '~/actions/ModalActionCreators';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {PremiumModal} from '~/components/modals/PremiumModal';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
|
||||
export const MaxFavoriteMemesModal = observer(function MaxFavoriteMemesModal() {
|
||||
export const MaxFavoriteMemesModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const currentUser = UserStore.currentUser;
|
||||
const isPremium = currentUser?.isPremium() ?? false;
|
||||
const showPremium = shouldShowPremiumFeatures();
|
||||
const premiumLimit = Limits.getPremiumValue('max_favorite_memes', MAX_FAVORITE_MEMES_PREMIUM);
|
||||
const maxFavoriteMemes = currentUser?.maxFavoriteMemes ?? premiumLimit;
|
||||
const canUpgradeFavoriteMemes = maxFavoriteMemes < premiumLimit;
|
||||
|
||||
if (isPremium) {
|
||||
const freeItemsText =
|
||||
maxFavoriteMemes === 1 ? t`${maxFavoriteMemes} saved media item` : t`${maxFavoriteMemes} saved media items`;
|
||||
|
||||
if (!showPremium) {
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Saved Media Limit Reached`}
|
||||
description={t`You've reached the maximum limit of 500 saved media items for Plutonium users. To add more, you'll need to remove some existing items from your collection.`}
|
||||
secondaryText={t`Close`}
|
||||
title={t`Saved media limit reached`}
|
||||
description={t`You've reached the maximum limit of ${freeItemsText}. This limit is configured by your instance administrator.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canUpgradeFavoriteMemes) {
|
||||
const description =
|
||||
maxFavoriteMemes === 1
|
||||
? t`You've reached the maximum limit of ${maxFavoriteMemes} saved media item. To add more, you'll need to remove some existing items from your collection.`
|
||||
: t`You've reached the maximum limit of ${maxFavoriteMemes} saved media items. To add more, you'll need to remove some existing items from your collection.`;
|
||||
|
||||
return <ConfirmModal title={t`Saved media limit reached`} description={description} secondaryText={t`Close`} />;
|
||||
}
|
||||
|
||||
const premiumItemsText =
|
||||
premiumLimit === 1 ? t`${premiumLimit} saved media item` : t`${premiumLimit} saved media items`;
|
||||
|
||||
const freeDescription = t`You've reached the maximum limit of ${freeItemsText} for free users. Upgrade to Plutonium to increase your limit to ${premiumItemsText}!`;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Saved Media Limit Reached`}
|
||||
description={t`You've reached the maximum limit of 50 saved media items for free users. Upgrade to Plutonium to increase your limit to 500 saved media items!`}
|
||||
title={t`Saved media limit reached`}
|
||||
description={freeDescription}
|
||||
primaryText={t`Upgrade to Plutonium`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => push(modal(() => <PremiumModal />))}
|
||||
onPrimary={() => {
|
||||
window.setTimeout(() => {
|
||||
push(modal(() => <PremiumModal />));
|
||||
}, 0);
|
||||
}}
|
||||
secondaryText={t`Maybe Later`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro';
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
|
||||
export const MaxGuildsModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
@@ -31,11 +31,9 @@ export const MaxGuildsModal = observer(() => {
|
||||
<ConfirmModal
|
||||
title={t`Too Many Communities`}
|
||||
description={
|
||||
<Trans>
|
||||
You've reached the maximum number of communities you can join (
|
||||
<Plural value={maxGuilds} one="# community" other="# communities" />
|
||||
). Please leave a community before joining another one.
|
||||
</Trans>
|
||||
maxGuilds === 1
|
||||
? t`You've reached the maximum number of communities you can join (${maxGuilds} community). Please leave a community before joining another one.`
|
||||
: t`You've reached the maximum number of communities you can join (${maxGuilds} communities). Please leave a community before joining another one.`
|
||||
}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const MessageDeleteFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const MessageDeleteTooQuickModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const MessageEditFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {RateLimitedConfirmModal} from '@app/components/alerts/RateLimitedConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {RateLimitedConfirmModal} from '~/components/alerts/RateLimitedConfirmModal';
|
||||
|
||||
interface MessageEditTooQuickModalProps {
|
||||
retryAfter?: number;
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const MessageForwardFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Failed to forward message`}
|
||||
title={t`Failed to Forward Message`}
|
||||
message={t`We couldn't forward the message at this time.`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const MessageSendFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {RateLimitedConfirmModal} from '@app/components/alerts/RateLimitedConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {RateLimitedConfirmModal} from '~/components/alerts/RateLimitedConfirmModal';
|
||||
|
||||
interface MessageSendTooQuickModalProps {
|
||||
retryAfter?: number;
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {openNativePermissionSettings} from '@app/utils/NativePermissions';
|
||||
import {isDesktop, isNativeMacOS} from '@app/utils/NativeUtils';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {openNativePermissionSettings} from '~/utils/NativePermissions';
|
||||
import {isDesktop, isNativeMacOS} from '~/utils/NativeUtils';
|
||||
|
||||
export const MicrophonePermissionDeniedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const NSFWContentRejectedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export type PinFailureReason = 'dm_restricted' | 'generic';
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
interface RateLimitedConfirmModalProps {
|
||||
title: string;
|
||||
@@ -32,7 +32,7 @@ export const RateLimitedConfirmModal = observer(({title, retryAfter, onRetry}: R
|
||||
const {t} = useLingui();
|
||||
const hasRetryAfter = retryAfter != null;
|
||||
|
||||
const formatRateLimitTime = React.useCallback(
|
||||
const formatRateLimitTime = useCallback(
|
||||
(totalSeconds: number): string => {
|
||||
if (totalSeconds < 60) {
|
||||
return totalSeconds === 1 ? t`${totalSeconds} second` : t`${totalSeconds} seconds`;
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const ReactionInteractionDisabledModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const RoleCreateFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<GenericErrorModal title={t`Failed to create role`} message={t`We couldn't create a new role at this time.`} />
|
||||
<GenericErrorModal title={t`Failed to Create Role`} message={t`We couldn't create a new role at this time.`} />
|
||||
);
|
||||
});
|
||||
|
||||
@@ -17,16 +17,20 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const RoleDeleteFailedModal: React.FC<{roleName?: string}> = observer(({roleName}) => {
|
||||
interface RoleDeleteFailedModalProps {
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
export const RoleDeleteFailedModal = observer(({roleName}: RoleDeleteFailedModalProps) => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Failed to delete role`}
|
||||
title={t`Failed to Delete Role`}
|
||||
message={
|
||||
<Trans>
|
||||
The role <strong>"{roleName}"</strong> could not be deleted at this time. Please try again.
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const RoleNameBlankModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const RoleUpdateFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Failed to update roles`}
|
||||
title={t`Failed to Update Roles`}
|
||||
message={t`We couldn't save your role changes at this time.`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -17,17 +17,17 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {openNativePermissionSettings} from '@app/utils/NativePermissions';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {openNativePermissionSettings} from '~/utils/NativePermissions';
|
||||
|
||||
export const ScreenRecordingPermissionDeniedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Screen Recording Permission Required`}
|
||||
title={t`Screen recording permission required`}
|
||||
description={t`Fluxer needs access to screen recording. Open System Settings → Privacy & Security → Screen Recording, allow Fluxer, and then try again.`}
|
||||
primaryText={t`Open Settings`}
|
||||
primaryVariant="primary"
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const ScreenShareUnsupportedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Screen Sharing Not Supported`}
|
||||
title={t`Screen sharing not supported`}
|
||||
description={t`Screen sharing is not supported on this device or browser. This feature requires a desktop browser that supports screen sharing, such as Chrome, Firefox, or Edge on Windows, macOS, or Linux.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
|
||||
@@ -17,10 +17,10 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
interface SlowmodeRateLimitedModalProps {
|
||||
retryAfter: number;
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const TemporaryInviteRequiresPresenceModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,17 +17,24 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {MAX_ATTACHMENTS_PER_MESSAGE} from '~/Constants';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const TooManyAttachmentsModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const currentUser = UserStore.currentUser;
|
||||
const maxAttachments = currentUser?.maxAttachmentsPerMessage ?? 10;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Whoa, this is heavy`}
|
||||
description={t`You can only upload ${MAX_ATTACHMENTS_PER_MESSAGE} files at a time. Try again with fewer files.`}
|
||||
description={
|
||||
maxAttachments === 1
|
||||
? t`You can only upload 1 file at a time. Try again with fewer files.`
|
||||
: t`You can only upload ${maxAttachments} files at a time. Try again with fewer files.`
|
||||
}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const TooManyReactionsModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,17 +17,19 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {PlutoniumUpsell} from '~/components/uikit/PlutoniumUpsell/PlutoniumUpsell';
|
||||
|
||||
export const PerGuildPremiumUpsell = observer(() => {
|
||||
export const TtsUnsupportedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<PlutoniumUpsell>
|
||||
<Trans>
|
||||
Customizing your avatar, banner, accent color, and bio for individual communities requires Plutonium. Community
|
||||
nickname and pronouns are free for everyone.
|
||||
</Trans>
|
||||
</PlutoniumUpsell>
|
||||
<ConfirmModal
|
||||
title={t`Text-to-Speech Not Supported`}
|
||||
description={t`Text-to-speech is not supported by your browser. Some browsers like Brave block this feature for privacy reasons. Try using Chrome, Firefox, or Edge, or adjust your browser's privacy settings.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const UserBannedFromGuildModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const UserIpBannedFromGuildModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GenericErrorModal} from '@app/components/alerts/GenericErrorModal';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const VoiceChannelFullModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
@@ -17,22 +17,24 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as Modal from '~/components/modals/Modal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import styles from '@app/components/alerts/VoiceConnectionConfirmModal.module.css';
|
||||
import * as Modal from '@app/components/modals/Modal';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {
|
||||
useVoiceConnectionConfirmModalLogic,
|
||||
type VoiceConnectionConfirmModalProps,
|
||||
} from '~/utils/alerts/VoiceConnectionConfirmModalUtils';
|
||||
import styles from './VoiceConnectionConfirmModal.module.css';
|
||||
} from '@app/utils/alerts/VoiceConnectionConfirmModalUtils';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
|
||||
export const VoiceConnectionConfirmModal: React.FC<VoiceConnectionConfirmModalProps> = observer(
|
||||
({guildId: _guildId, channelId: _channelId, onSwitchDevice, onJustJoin, onCancel}) => {
|
||||
({guildId, channelId, onSwitchDevice, onJustJoin, onCancel}) => {
|
||||
const {t} = useLingui();
|
||||
const {existingConnectionsCount, handleSwitchDevice, handleJustJoin, handleCancel} =
|
||||
useVoiceConnectionConfirmModalLogic({
|
||||
guildId,
|
||||
channelId,
|
||||
onSwitchDevice,
|
||||
onJustJoin,
|
||||
onCancel,
|
||||
@@ -42,11 +44,9 @@ export const VoiceConnectionConfirmModal: React.FC<VoiceConnectionConfirmModalPr
|
||||
<Modal.Root size="small" centered>
|
||||
<Modal.Header title={t`Voice Connection Confirmation`} />
|
||||
<Modal.Content>
|
||||
<Trans>
|
||||
You're already connected to this voice channel from{' '}
|
||||
<Plural value={existingConnectionsCount} one="# other device" other="# other devices" />. What would you
|
||||
like to do?
|
||||
</Trans>
|
||||
{existingConnectionsCount === 1
|
||||
? t`You're already connected to this voice channel from ${existingConnectionsCount} other device. What would you like to do?`
|
||||
: t`You're already connected to this voice channel from ${existingConnectionsCount} other devices. What would you like to do?`}
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className={styles.footer}>
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/layout/AuthLayout.module.css';
|
||||
import AccessibilityStore from '@app/stores/AccessibilityStore';
|
||||
import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
import {motion} from 'framer-motion';
|
||||
import type React from 'react';
|
||||
import {GuildSplashCardAlignment} from '~/Constants';
|
||||
import styles from '~/components/layout/AuthLayout.module.css';
|
||||
|
||||
const getSplashAlignmentStyles = (
|
||||
alignment: (typeof GuildSplashCardAlignment)[keyof typeof GuildSplashCardAlignment],
|
||||
) => {
|
||||
const getSplashAlignmentStyles = (alignment: ValueOf<typeof GuildSplashCardAlignment>) => {
|
||||
switch (alignment) {
|
||||
case GuildSplashCardAlignment.LEFT:
|
||||
return {transformOrigin: 'bottom left', objectPosition: 'left bottom'};
|
||||
@@ -44,7 +44,7 @@ export interface AuthBackgroundProps {
|
||||
patternImageUrl: string;
|
||||
className?: string;
|
||||
useFullCover?: boolean;
|
||||
splashAlignment?: (typeof GuildSplashCardAlignment)[keyof typeof GuildSplashCardAlignment];
|
||||
splashAlignment?: ValueOf<typeof GuildSplashCardAlignment>;
|
||||
}
|
||||
|
||||
export const AuthBackground: React.FC<AuthBackgroundProps> = ({
|
||||
@@ -68,7 +68,7 @@ export const AuthBackground: React.FC<AuthBackgroundProps> = ({
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: splashLoaded ? 1 : 0}}
|
||||
transition={{duration: 0.5, ease: 'easeInOut'}}
|
||||
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.5, ease: 'easeInOut'}}
|
||||
style={{position: 'absolute', inset: 0}}
|
||||
>
|
||||
<img
|
||||
@@ -95,7 +95,7 @@ export const AuthBackground: React.FC<AuthBackgroundProps> = ({
|
||||
className={styles.splashImage}
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: splashLoaded ? 1 : 0}}
|
||||
transition={{duration: 0.5, ease: 'easeInOut'}}
|
||||
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.5, ease: 'easeInOut'}}
|
||||
style={{
|
||||
width: splashDimensions.width,
|
||||
height: splashDimensions.height,
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {AuthRouterLink} from '@app/components/auth/AuthRouterLink';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import {AuthRouterLink} from './AuthRouterLink';
|
||||
|
||||
interface AuthBottomLinkProps {
|
||||
variant: 'login' | 'register';
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/AuthCardContainer.module.css';
|
||||
import authLayoutStyles from '@app/components/layout/AuthLayout.module.css';
|
||||
import FluxerLogo from '@app/images/fluxer-logo-color.svg?react';
|
||||
import FluxerWordmark from '@app/images/fluxer-wordmark.svg?react';
|
||||
import clsx from 'clsx';
|
||||
import type {ReactNode} from 'react';
|
||||
import authLayoutStyles from '~/components/layout/AuthLayout.module.css';
|
||||
import FluxerLogo from '~/images/fluxer-logo-color.svg?react';
|
||||
import FluxerWordmark from '~/images/fluxer-wordmark.svg?react';
|
||||
import styles from './AuthCardContainer.module.css';
|
||||
|
||||
export interface AuthCardContainerProps {
|
||||
interface AuthCardContainerProps {
|
||||
showLogoSide?: boolean;
|
||||
children: ReactNode;
|
||||
isInert?: boolean;
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import type {Icon} from '@phosphor-icons/react';
|
||||
import {QuestionIcon} from '@phosphor-icons/react';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
|
||||
interface AuthErrorStateProps {
|
||||
icon?: Icon;
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {Spinner} from '@app/components/uikit/Spinner';
|
||||
import type {JSX} from 'react';
|
||||
|
||||
export function AuthLoadingState() {
|
||||
export function AuthLoadingState(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner />
|
||||
|
||||
@@ -17,26 +17,29 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import {AccountSelector} from '@app/components/accounts/AccountSelector';
|
||||
import {AuthRouterLink} from '@app/components/auth/AuthRouterLink';
|
||||
import AuthLoginEmailPasswordForm from '@app/components/auth/auth_login_core/AuthLoginEmailPasswordForm';
|
||||
import AuthLoginPasskeyActions, {AuthLoginDivider} from '@app/components/auth/auth_login_core/AuthLoginPasskeyActions';
|
||||
import {useDesktopHandoffFlow} from '@app/components/auth/auth_login_core/useDesktopHandoffFlow';
|
||||
import DesktopHandoffAccountSelector from '@app/components/auth/DesktopHandoffAccountSelector';
|
||||
import {HandoffCodeDisplay} from '@app/components/auth/HandoffCodeDisplay';
|
||||
import IpAuthorizationScreen from '@app/components/auth/IpAuthorizationScreen';
|
||||
import styles from '@app/components/pages/LoginPage.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {useLoginFormController} from '@app/hooks/useLoginFlow';
|
||||
import {IS_DEV} from '@app/lib/Env';
|
||||
import {type Account, SessionExpiredError} from '@app/lib/SessionManager';
|
||||
import AccountManager from '@app/stores/AccountManager';
|
||||
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
|
||||
import {isDesktop} from '@app/utils/NativeUtils';
|
||||
import * as RouterUtils from '@app/utils/RouterUtils';
|
||||
import {type IpAuthorizationChallenge, type LoginSuccessPayload, startSsoLogin} from '@app/viewmodels/auth/AuthFlow';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import clsx from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {cloneElement, type ReactElement, type ReactNode, useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import {AccountSelector} from '~/components/accounts/AccountSelector';
|
||||
import AuthLoginEmailPasswordForm from '~/components/auth/AuthLoginCore/AuthLoginEmailPasswordForm';
|
||||
import AuthLoginPasskeyActions, {AuthLoginDivider} from '~/components/auth/AuthLoginCore/AuthLoginPasskeyActions';
|
||||
import {useDesktopHandoffFlow} from '~/components/auth/AuthLoginCore/useDesktopHandoffFlow';
|
||||
import {AuthRouterLink} from '~/components/auth/AuthRouterLink';
|
||||
import DesktopHandoffAccountSelector from '~/components/auth/DesktopHandoffAccountSelector';
|
||||
import {HandoffCodeDisplay} from '~/components/auth/HandoffCodeDisplay';
|
||||
import IpAuthorizationScreen from '~/components/auth/IpAuthorizationScreen';
|
||||
import styles from '~/components/pages/LoginPage.module.css';
|
||||
import {type IpAuthorizationChallenge, type LoginSuccessPayload, useLoginFormController} from '~/hooks/useLoginFlow';
|
||||
import {IS_DEV} from '~/lib/env';
|
||||
import {SessionExpiredError} from '~/lib/SessionManager';
|
||||
import AccountManager, {type AccountSummary} from '~/stores/AccountManager';
|
||||
import {isDesktop} from '~/utils/NativeUtils';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
|
||||
interface AuthLoginLayoutProps {
|
||||
redirectPath?: string;
|
||||
@@ -51,7 +54,7 @@ interface AuthLoginLayoutProps {
|
||||
initialEmail?: string;
|
||||
}
|
||||
|
||||
const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
export const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
redirectPath,
|
||||
inviteCode,
|
||||
desktopHandoff = false,
|
||||
@@ -67,6 +70,10 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
const currentUserId = AccountManager.currentUserId;
|
||||
const accounts = AccountManager.orderedAccounts;
|
||||
const hasStoredAccounts = accounts.length > 0;
|
||||
const ssoConfig = RuntimeConfigStore.sso;
|
||||
const isSsoEnforced = Boolean(ssoConfig?.enforced);
|
||||
const ssoDisplayName = ssoConfig?.display_name ?? 'Single Sign-On';
|
||||
const [isStartingSso, setIsStartingSso] = useState(false);
|
||||
|
||||
const handoffAccounts =
|
||||
desktopHandoff && excludeCurrentUser ? accounts.filter((a) => a.userId !== currentUserId) : accounts;
|
||||
@@ -84,7 +91,7 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
const [switchError, setSwitchError] = useState<string | null>(null);
|
||||
const [prefillEmail, setPrefillEmail] = useState<string | null>(() => initialEmail ?? null);
|
||||
|
||||
const showLoginFormForAccount = useCallback((account: AccountSummary, message?: string | null) => {
|
||||
const showLoginFormForAccount = useCallback((account: Account, message?: string | null) => {
|
||||
setShowAccountSelector(false);
|
||||
setSwitchError(message ?? null);
|
||||
setPrefillEmail(account.userData?.email ?? null);
|
||||
@@ -143,7 +150,7 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
}, [form, prefillEmail]);
|
||||
|
||||
const handleSelectExistingAccount = useCallback(
|
||||
async (account: AccountSummary) => {
|
||||
async (account: Account) => {
|
||||
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
|
||||
const expiredMessage = t`Session expired for ${identifier}. Please log in again.`;
|
||||
|
||||
@@ -177,6 +184,21 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
setPrefillEmail(null);
|
||||
}, []);
|
||||
|
||||
const handleStartSso = useCallback(async () => {
|
||||
if (!ssoConfig?.enabled) return;
|
||||
try {
|
||||
setIsStartingSso(true);
|
||||
const {authorizationUrl} = await startSsoLogin({
|
||||
redirectTo: redirectPath,
|
||||
});
|
||||
window.location.assign(authorizationUrl);
|
||||
} catch (error) {
|
||||
setSwitchError(error instanceof Error ? error.message : t`Failed to start SSO`);
|
||||
} finally {
|
||||
setIsStartingSso(false);
|
||||
}
|
||||
}, [ssoConfig?.enabled, redirectPath, t]);
|
||||
|
||||
const styledRegisterLink = useMemo(() => {
|
||||
const {className: linkClassName} = registerLink.props as {className?: string};
|
||||
return cloneElement(registerLink, {
|
||||
@@ -193,6 +215,21 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
);
|
||||
}
|
||||
|
||||
if (isSsoEnforced) {
|
||||
return (
|
||||
<div className={styles.loginContainer}>
|
||||
<h1 className={styles.title}>{ssoDisplayName}</h1>
|
||||
<p className={styles.ssoSubtitle}>
|
||||
<Trans>Sign in with your organization's single sign-on provider.</Trans>
|
||||
</p>
|
||||
<Button fitContainer onClick={handleStartSso} submitting={isStartingSso} type="button">
|
||||
<Trans>Continue with SSO</Trans>
|
||||
</Button>
|
||||
{switchError && <div className={styles.loginNotice}>{switchError}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showAccountSelector && hasStoredAccounts && !desktopHandoff) {
|
||||
return (
|
||||
<AccountSelector
|
||||
@@ -237,6 +274,21 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
|
||||
{!showAccountSelector && switchError ? <div className={styles.loginNotice}>{switchError}</div> : null}
|
||||
|
||||
{ssoConfig?.enabled ? (
|
||||
<div className={styles.ssoBlock}>
|
||||
<Button fitContainer onClick={handleStartSso} submitting={isStartingSso} type="button">
|
||||
<Trans>Continue with SSO</Trans>
|
||||
</Button>
|
||||
<div className={styles.ssoSubtitle}>
|
||||
{ssoConfig.enforced ? (
|
||||
<Trans>SSO is required to access this workspace.</Trans>
|
||||
) : (
|
||||
<Trans>Prefer using SSO? Continue with {ssoDisplayName}.</Trans>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<AuthLoginEmailPasswordForm
|
||||
form={form}
|
||||
isLoading={isLoading}
|
||||
@@ -268,7 +320,7 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
onPasskeyLogin={handlePasskeyLogin}
|
||||
showBrowserOption={showBrowserPasskey}
|
||||
onBrowserLogin={handlePasskeyBrowserLogin}
|
||||
browserLabel={<Trans>Log in via browser or custom instance</Trans>}
|
||||
browserLabel={<Trans>Log in via browser</Trans>}
|
||||
/>
|
||||
|
||||
<div className={styles.footer}>
|
||||
@@ -282,5 +334,3 @@ const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export {AuthLoginLayout};
|
||||
|
||||
@@ -17,18 +17,24 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {DateOfBirthField} from '@app/components/auth/DateOfBirthField';
|
||||
import FormField from '@app/components/auth/FormField';
|
||||
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '@app/components/auth/SubmitTooltip';
|
||||
import {ExternalLink} from '@app/components/common/ExternalLink';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
|
||||
import {
|
||||
type AuthRegisterFormDraft,
|
||||
EMPTY_AUTH_REGISTER_FORM_DRAFT,
|
||||
useAuthRegisterDraftContext,
|
||||
} from '@app/contexts/AuthRegisterDraftContext';
|
||||
import {useAuthForm} from '@app/hooks/useAuthForm';
|
||||
import {useLocation} from '@app/lib/router/React';
|
||||
import {Routes} from '@app/Routes';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {useId, useMemo, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import {DateOfBirthField} from '~/components/auth/DateOfBirthField';
|
||||
import FormField from '~/components/auth/FormField';
|
||||
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '~/components/auth/SubmitTooltip';
|
||||
import {ExternalLink} from '~/components/common/ExternalLink';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
|
||||
import {useAuthForm} from '~/hooks/useAuthForm';
|
||||
import {Routes} from '~/Routes';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import {useCallback, useId, useMemo, useRef, useState} from 'react';
|
||||
|
||||
interface AuthMinimalRegisterFormCoreProps {
|
||||
submitLabel: React.ReactNode;
|
||||
@@ -46,17 +52,81 @@ export function AuthMinimalRegisterFormCore({
|
||||
extraContent,
|
||||
}: AuthMinimalRegisterFormCoreProps) {
|
||||
const {t} = useLingui();
|
||||
const location = useLocation();
|
||||
const draftKey = `register:${location.pathname}${location.search}`;
|
||||
const {getRegisterFormDraft, setRegisterFormDraft, clearRegisterFormDraft} = useAuthRegisterDraftContext();
|
||||
const globalNameId = useId();
|
||||
|
||||
const [selectedMonth, setSelectedMonth] = useState('');
|
||||
const [selectedDay, setSelectedDay] = useState('');
|
||||
const [selectedYear, setSelectedYear] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const initialDraft = useMemo<AuthRegisterFormDraft>(() => {
|
||||
const persistedDraft = getRegisterFormDraft(draftKey);
|
||||
if (!persistedDraft) {
|
||||
return EMPTY_AUTH_REGISTER_FORM_DRAFT;
|
||||
}
|
||||
return {
|
||||
...persistedDraft,
|
||||
formValues: {...persistedDraft.formValues},
|
||||
};
|
||||
}, [draftKey, getRegisterFormDraft]);
|
||||
const draftRef = useRef<AuthRegisterFormDraft>({
|
||||
...initialDraft,
|
||||
formValues: {...initialDraft.formValues},
|
||||
});
|
||||
|
||||
const [selectedMonth, setSelectedMonthState] = useState(initialDraft.selectedMonth);
|
||||
const [selectedDay, setSelectedDayState] = useState(initialDraft.selectedDay);
|
||||
const [selectedYear, setSelectedYearState] = useState(initialDraft.selectedYear);
|
||||
const [consent, setConsentState] = useState(initialDraft.consent);
|
||||
|
||||
const initialValues: Record<string, string> = {
|
||||
global_name: '',
|
||||
global_name: initialDraft.formValues.global_name ?? '',
|
||||
};
|
||||
|
||||
const persistDraft = useCallback(
|
||||
(partialDraft: Partial<AuthRegisterFormDraft>) => {
|
||||
const currentDraft = draftRef.current;
|
||||
const nextDraft: AuthRegisterFormDraft = {
|
||||
...currentDraft,
|
||||
...partialDraft,
|
||||
formValues: partialDraft.formValues ? {...partialDraft.formValues} : currentDraft.formValues,
|
||||
};
|
||||
draftRef.current = nextDraft;
|
||||
setRegisterFormDraft(draftKey, nextDraft);
|
||||
},
|
||||
[draftKey, setRegisterFormDraft],
|
||||
);
|
||||
|
||||
const handleMonthChange = useCallback(
|
||||
(month: string) => {
|
||||
setSelectedMonthState(month);
|
||||
persistDraft({selectedMonth: month});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleDayChange = useCallback(
|
||||
(day: string) => {
|
||||
setSelectedDayState(day);
|
||||
persistDraft({selectedDay: day});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleYearChange = useCallback(
|
||||
(year: string) => {
|
||||
setSelectedYearState(year);
|
||||
persistDraft({selectedYear: year});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleConsentChange = useCallback(
|
||||
(nextConsent: boolean) => {
|
||||
setConsentState(nextConsent);
|
||||
persistDraft({consent: nextConsent});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleRegisterSubmit = async (values: Record<string, string>) => {
|
||||
const dateOfBirth =
|
||||
selectedYear && selectedMonth && selectedDay
|
||||
@@ -65,7 +135,6 @@ export function AuthMinimalRegisterFormCore({
|
||||
|
||||
const response = await AuthenticationActionCreators.register({
|
||||
global_name: values.global_name || undefined,
|
||||
beta_code: '',
|
||||
date_of_birth: dateOfBirth,
|
||||
consent,
|
||||
invite_code: inviteCode,
|
||||
@@ -79,6 +148,7 @@ export function AuthMinimalRegisterFormCore({
|
||||
userId: response.user_id,
|
||||
});
|
||||
}
|
||||
clearRegisterFormDraft(draftKey);
|
||||
};
|
||||
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
@@ -87,10 +157,22 @@ export function AuthMinimalRegisterFormCore({
|
||||
redirectPath,
|
||||
firstFieldName: 'global_name',
|
||||
});
|
||||
|
||||
const setDraftedFormValue = useCallback(
|
||||
(fieldName: string, value: string) => {
|
||||
form.setValue(fieldName, value);
|
||||
const nextFormValues = {
|
||||
...draftRef.current.formValues,
|
||||
[fieldName]: value,
|
||||
};
|
||||
persistDraft({formValues: nextFormValues});
|
||||
},
|
||||
[form, persistDraft],
|
||||
);
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: Array<MissingField> = [];
|
||||
if (!selectedMonth || !selectedDay || !selectedYear) {
|
||||
missing.push({key: 'date_of_birth', label: t`Date of birth`});
|
||||
missing.push({key: 'date_of_birth', label: t`Date of Birth`});
|
||||
}
|
||||
return missing;
|
||||
}, [selectedMonth, selectedDay, selectedYear]);
|
||||
@@ -103,10 +185,10 @@ export function AuthMinimalRegisterFormCore({
|
||||
id={globalNameId}
|
||||
name="global_name"
|
||||
type="text"
|
||||
label={t`Display name (optional)`}
|
||||
label={t`Display Name (Optional)`}
|
||||
placeholder={t`What should people call you?`}
|
||||
value={globalNameValue}
|
||||
onChange={(value) => form.setValue('global_name', value)}
|
||||
onChange={(value) => setDraftedFormValue('global_name', value)}
|
||||
error={form.getError('global_name') || fieldErrors?.global_name}
|
||||
/>
|
||||
|
||||
@@ -114,16 +196,16 @@ export function AuthMinimalRegisterFormCore({
|
||||
selectedMonth={selectedMonth}
|
||||
selectedDay={selectedDay}
|
||||
selectedYear={selectedYear}
|
||||
onMonthChange={setSelectedMonth}
|
||||
onDayChange={setSelectedDay}
|
||||
onYearChange={setSelectedYear}
|
||||
onMonthChange={handleMonthChange}
|
||||
onDayChange={handleDayChange}
|
||||
onYearChange={handleYearChange}
|
||||
error={fieldErrors?.date_of_birth}
|
||||
/>
|
||||
|
||||
{extraContent}
|
||||
|
||||
<div className={styles.consentRow}>
|
||||
<Checkbox checked={consent} onChange={setConsent}>
|
||||
<Checkbox checked={consent} onChange={handleConsentChange}>
|
||||
<span className={styles.consentLabel}>
|
||||
<Trans>I agree to the</Trans>{' '}
|
||||
<ExternalLink href={Routes.terms()} className={styles.policyLink}>
|
||||
|
||||
@@ -17,11 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {SealCheckIcon} from '@phosphor-icons/react';
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {GuildBadge} from '@app/components/guild/GuildBadge';
|
||||
import type {ReactNode} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
|
||||
interface AuthPageHeaderStatProps {
|
||||
value: string | number;
|
||||
@@ -32,12 +30,11 @@ interface AuthPageHeaderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
verified?: boolean;
|
||||
features?: ReadonlyArray<string>;
|
||||
stats?: Array<AuthPageHeaderStatProps>;
|
||||
}
|
||||
|
||||
export function AuthPageHeader({icon, title, subtitle, verified, stats}: AuthPageHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
export function AuthPageHeader({icon, title, subtitle, features, stats}: AuthPageHeaderProps) {
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
{icon}
|
||||
@@ -45,11 +42,7 @@ export function AuthPageHeader({icon, title, subtitle, verified, stats}: AuthPag
|
||||
<p className={styles.entityText}>{title}</p>
|
||||
<div className={styles.entityTitleWrapper}>
|
||||
<h2 className={styles.entityTitle}>{subtitle}</h2>
|
||||
{verified && (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={styles.verifiedIcon} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{features && <GuildBadge features={features} />}
|
||||
</div>
|
||||
{stats && stats.length > 0 && (
|
||||
<div className={styles.entityStats}>
|
||||
|
||||
@@ -298,6 +298,28 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.suggestionLink {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-link);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: text-decoration 150ms ease;
|
||||
}
|
||||
|
||||
.suggestionLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.usernameError {
|
||||
margin-top: 0.25rem;
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.consentRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -369,22 +391,6 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.betaCodeHint {
|
||||
margin-top: -0.75rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.usernameValidation {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
border-color: var(--background-modifier-accent);
|
||||
background-color: var(--background-secondary);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.giftIconContainer {
|
||||
display: flex;
|
||||
height: 5rem;
|
||||
|
||||
@@ -17,30 +17,33 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {DateOfBirthField} from '@app/components/auth/DateOfBirthField';
|
||||
import FormField from '@app/components/auth/FormField';
|
||||
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '@app/components/auth/SubmitTooltip';
|
||||
import {ExternalLink} from '@app/components/common/ExternalLink';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
|
||||
import {
|
||||
type AuthRegisterFormDraft,
|
||||
EMPTY_AUTH_REGISTER_FORM_DRAFT,
|
||||
useAuthRegisterDraftContext,
|
||||
} from '@app/contexts/AuthRegisterDraftContext';
|
||||
import {useAuthForm} from '@app/hooks/useAuthForm';
|
||||
import {useUsernameSuggestions} from '@app/hooks/useUsernameSuggestions';
|
||||
import {useLocation} from '@app/lib/router/React';
|
||||
import {Routes} from '@app/Routes';
|
||||
import AccessibilityStore from '@app/stores/AccessibilityStore';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {AnimatePresence} from 'framer-motion';
|
||||
import {useId, useMemo, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import {DateOfBirthField} from '~/components/auth/DateOfBirthField';
|
||||
import FormField from '~/components/auth/FormField';
|
||||
import {type MissingField, SubmitTooltip, shouldDisableSubmit} from '~/components/auth/SubmitTooltip';
|
||||
import {UsernameSuggestions} from '~/components/auth/UsernameSuggestions';
|
||||
import {ExternalLink} from '~/components/common/ExternalLink';
|
||||
import {UsernameValidationRules} from '~/components/form/UsernameValidationRules';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
|
||||
import {useAuthForm} from '~/hooks/useAuthForm';
|
||||
import {useUsernameSuggestions} from '~/hooks/useUsernameSuggestions';
|
||||
import {MODE} from '~/lib/env';
|
||||
import {Routes} from '~/Routes';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {useCallback, useId, useMemo, useRef, useState} from 'react';
|
||||
|
||||
interface FieldConfig {
|
||||
showEmail?: boolean;
|
||||
showPassword?: boolean;
|
||||
showPasswordConfirmation?: boolean;
|
||||
showUsernameValidation?: boolean;
|
||||
showBetaCodeHint?: boolean;
|
||||
requireBetaCode?: boolean;
|
||||
}
|
||||
|
||||
interface AuthRegisterFormCoreProps {
|
||||
@@ -64,31 +67,102 @@ export function AuthRegisterFormCore({
|
||||
const {
|
||||
showEmail = false,
|
||||
showPassword = false,
|
||||
showPasswordConfirmation = false,
|
||||
showUsernameValidation = false,
|
||||
requireBetaCode = MODE !== 'development',
|
||||
} = fields;
|
||||
const location = useLocation();
|
||||
const draftKey = `register:${location.pathname}${location.search}`;
|
||||
const {getRegisterFormDraft, setRegisterFormDraft, clearRegisterFormDraft} = useAuthRegisterDraftContext();
|
||||
|
||||
const emailId = useId();
|
||||
const globalNameId = useId();
|
||||
const usernameId = useId();
|
||||
const passwordId = useId();
|
||||
const betaCodeId = useId();
|
||||
const confirmPasswordId = useId();
|
||||
|
||||
const [selectedMonth, setSelectedMonth] = useState('');
|
||||
const [selectedDay, setSelectedDay] = useState('');
|
||||
const [selectedYear, setSelectedYear] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [usernameFocused, setUsernameFocused] = useState(false);
|
||||
const initialDraft = useMemo<AuthRegisterFormDraft>(() => {
|
||||
const persistedDraft = getRegisterFormDraft(draftKey);
|
||||
if (!persistedDraft) {
|
||||
return EMPTY_AUTH_REGISTER_FORM_DRAFT;
|
||||
}
|
||||
return {
|
||||
...persistedDraft,
|
||||
formValues: {...persistedDraft.formValues},
|
||||
};
|
||||
}, [draftKey, getRegisterFormDraft]);
|
||||
const draftRef = useRef<AuthRegisterFormDraft>({
|
||||
...initialDraft,
|
||||
formValues: {...initialDraft.formValues},
|
||||
});
|
||||
|
||||
const [selectedMonth, setSelectedMonthState] = useState(initialDraft.selectedMonth);
|
||||
const [selectedDay, setSelectedDayState] = useState(initialDraft.selectedDay);
|
||||
const [selectedYear, setSelectedYearState] = useState(initialDraft.selectedYear);
|
||||
const [consent, setConsentState] = useState(initialDraft.consent);
|
||||
const [_usernameFocused, setUsernameFocused] = useState(false);
|
||||
|
||||
const initialValues: Record<string, string> = {
|
||||
global_name: '',
|
||||
username: '',
|
||||
betaCode: '',
|
||||
global_name: initialDraft.formValues.global_name ?? '',
|
||||
username: initialDraft.formValues.username ?? '',
|
||||
};
|
||||
if (showEmail) initialValues.email = '';
|
||||
if (showPassword) initialValues.password = '';
|
||||
if (showEmail) initialValues.email = initialDraft.formValues.email ?? '';
|
||||
if (showPassword) initialValues.password = initialDraft.formValues.password ?? '';
|
||||
if (showPassword && showPasswordConfirmation) {
|
||||
initialValues.confirm_password = initialDraft.formValues.confirm_password ?? '';
|
||||
}
|
||||
|
||||
const persistDraft = useCallback(
|
||||
(partialDraft: Partial<AuthRegisterFormDraft>) => {
|
||||
const currentDraft = draftRef.current;
|
||||
const nextDraft: AuthRegisterFormDraft = {
|
||||
...currentDraft,
|
||||
...partialDraft,
|
||||
formValues: partialDraft.formValues ? {...partialDraft.formValues} : currentDraft.formValues,
|
||||
};
|
||||
draftRef.current = nextDraft;
|
||||
setRegisterFormDraft(draftKey, nextDraft);
|
||||
},
|
||||
[draftKey, setRegisterFormDraft],
|
||||
);
|
||||
|
||||
const handleMonthChange = useCallback(
|
||||
(month: string) => {
|
||||
setSelectedMonthState(month);
|
||||
persistDraft({selectedMonth: month});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleDayChange = useCallback(
|
||||
(day: string) => {
|
||||
setSelectedDayState(day);
|
||||
persistDraft({selectedDay: day});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleYearChange = useCallback(
|
||||
(year: string) => {
|
||||
setSelectedYearState(year);
|
||||
persistDraft({selectedYear: year});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleConsentChange = useCallback(
|
||||
(nextConsent: boolean) => {
|
||||
setConsentState(nextConsent);
|
||||
persistDraft({consent: nextConsent});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
const handleRegisterSubmit = async (values: Record<string, string>) => {
|
||||
if (showPasswordConfirmation && showPassword && values.password !== values.confirm_password) {
|
||||
form.setError('confirm_password', t`Passwords do not match`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dateOfBirth =
|
||||
selectedYear && selectedMonth && selectedDay
|
||||
? `${selectedYear}-${selectedMonth.padStart(2, '0')}-${selectedDay.padStart(2, '0')}`
|
||||
@@ -99,7 +173,6 @@ export function AuthRegisterFormCore({
|
||||
username: values.username || undefined,
|
||||
email: showEmail ? values.email : undefined,
|
||||
password: showPassword ? values.password : undefined,
|
||||
beta_code: values.betaCode || '',
|
||||
date_of_birth: dateOfBirth,
|
||||
consent,
|
||||
invite_code: inviteCode,
|
||||
@@ -113,6 +186,7 @@ export function AuthRegisterFormCore({
|
||||
userId: response.user_id,
|
||||
});
|
||||
}
|
||||
clearRegisterFormDraft(draftKey);
|
||||
};
|
||||
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
@@ -122,6 +196,18 @@ export function AuthRegisterFormCore({
|
||||
firstFieldName: showEmail ? 'email' : 'global_name',
|
||||
});
|
||||
|
||||
const setDraftedFormValue = useCallback(
|
||||
(fieldName: string, value: string) => {
|
||||
form.setValue(fieldName, value);
|
||||
const nextFormValues = {
|
||||
...draftRef.current.formValues,
|
||||
[fieldName]: value,
|
||||
};
|
||||
persistDraft({formValues: nextFormValues});
|
||||
},
|
||||
[form, persistDraft],
|
||||
);
|
||||
|
||||
const {suggestions} = useUsernameSuggestions({
|
||||
globalName: form.getValue('global_name'),
|
||||
username: form.getValue('username'),
|
||||
@@ -135,17 +221,36 @@ export function AuthRegisterFormCore({
|
||||
if (showPassword && !form.getValue('password')) {
|
||||
missing.push({key: 'password', label: t`Password`});
|
||||
}
|
||||
if (!selectedMonth || !selectedDay || !selectedYear) {
|
||||
missing.push({key: 'date_of_birth', label: t`Date of birth`});
|
||||
if (showPassword && showPasswordConfirmation && !form.getValue('confirm_password')) {
|
||||
missing.push({key: 'confirm_password', label: t`Confirm Password`});
|
||||
}
|
||||
if (requireBetaCode && !form.getValue('betaCode')) {
|
||||
missing.push({key: 'betaCode', label: t`Beta code`});
|
||||
if (!selectedMonth || !selectedDay || !selectedYear) {
|
||||
missing.push({key: 'date_of_birth', label: t`Date of Birth`});
|
||||
}
|
||||
return missing;
|
||||
}, [form, selectedMonth, selectedDay, selectedYear, showEmail, showPassword, requireBetaCode]);
|
||||
}, [form, selectedMonth, selectedDay, selectedYear, showEmail, showPassword, showPasswordConfirmation]);
|
||||
|
||||
type HelperTextState = {type: 'error'; message: string} | {type: 'suggestion'; username: string} | {type: 'hint'};
|
||||
|
||||
const usernameValue = form.getValue('username');
|
||||
const showValidationRules = showUsernameValidation && usernameValue && (usernameFocused || usernameValue.length > 0);
|
||||
const helperTextState = useMemo<HelperTextState>(() => {
|
||||
const trimmed = usernameValue?.trim() || '';
|
||||
|
||||
if (showUsernameValidation && trimmed.length > 0) {
|
||||
if (trimmed.length > 32) {
|
||||
return {type: 'error', message: t`Username must be 32 characters or less`};
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(trimmed)) {
|
||||
return {type: 'error', message: t`Only letters, numbers, and underscores`};
|
||||
}
|
||||
}
|
||||
|
||||
if (trimmed.length === 0 && suggestions.length === 1) {
|
||||
return {type: 'suggestion', username: suggestions[0]};
|
||||
}
|
||||
|
||||
return {type: 'hint'};
|
||||
}, [usernameValue, suggestions, showUsernameValidation, t]);
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={form.handleSubmit}>
|
||||
@@ -158,7 +263,7 @@ export function AuthRegisterFormCore({
|
||||
required
|
||||
label={t`Email`}
|
||||
value={form.getValue('email')}
|
||||
onChange={(value) => form.setValue('email', value)}
|
||||
onChange={(value) => setDraftedFormValue('email', value)}
|
||||
error={form.getError('email') || fieldErrors?.email}
|
||||
/>
|
||||
)}
|
||||
@@ -167,10 +272,10 @@ export function AuthRegisterFormCore({
|
||||
id={globalNameId}
|
||||
name="global_name"
|
||||
type="text"
|
||||
label={t`Display name (optional)`}
|
||||
label={t`Display Name (Optional)`}
|
||||
placeholder={t`What should people call you?`}
|
||||
value={form.getValue('global_name')}
|
||||
onChange={(value) => form.setValue('global_name', value)}
|
||||
onChange={(value) => setDraftedFormValue('global_name', value)}
|
||||
error={form.getError('global_name') || fieldErrors?.global_name}
|
||||
/>
|
||||
|
||||
@@ -180,32 +285,60 @@ export function AuthRegisterFormCore({
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
label={t`Username (optional)`}
|
||||
label={t`Username (Optional)`}
|
||||
placeholder={t`Leave blank for a random username`}
|
||||
value={usernameValue}
|
||||
onChange={(value) => form.setValue('username', value)}
|
||||
onChange={(value) => setDraftedFormValue('username', value)}
|
||||
onFocus={() => setUsernameFocused(true)}
|
||||
onBlur={() => setUsernameFocused(false)}
|
||||
error={form.getError('username') || fieldErrors?.username}
|
||||
/>
|
||||
<span className={styles.usernameHint}>
|
||||
<Trans>A 4-digit tag will be added automatically to ensure uniqueness</Trans>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{showUsernameValidation && (
|
||||
<AnimatePresence>
|
||||
{showValidationRules && (
|
||||
<div className={styles.usernameValidation}>
|
||||
<UsernameValidationRules username={usernameValue} />
|
||||
</div>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{helperTextState.type === 'error' && (
|
||||
<motion.span
|
||||
key="error"
|
||||
className={styles.usernameError}
|
||||
initial={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: -5}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
|
||||
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.2}}
|
||||
>
|
||||
{helperTextState.message}
|
||||
</motion.span>
|
||||
)}
|
||||
{helperTextState.type === 'suggestion' && (
|
||||
<motion.span
|
||||
key="suggestion"
|
||||
className={styles.usernameHint}
|
||||
initial={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: -5}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
|
||||
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.2}}
|
||||
>
|
||||
<Trans>How about:</Trans>{' '}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.suggestionLink}
|
||||
onClick={() => setDraftedFormValue('username', helperTextState.username)}
|
||||
>
|
||||
{helperTextState.username}
|
||||
</button>
|
||||
</motion.span>
|
||||
)}
|
||||
{helperTextState.type === 'hint' && (
|
||||
<motion.span
|
||||
key="hint"
|
||||
className={styles.usernameHint}
|
||||
initial={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: -5}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
exit={AccessibilityStore.useReducedMotion ? {opacity: 1, y: 0} : {opacity: 0, y: 5}}
|
||||
transition={{duration: AccessibilityStore.useReducedMotion ? 0 : 0.2}}
|
||||
>
|
||||
<Trans>A 4-digit tag will be added automatically to ensure uniqueness</Trans>
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{!usernameValue && (
|
||||
<UsernameSuggestions suggestions={suggestions} onSelect={(username) => form.setValue('username', username)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPassword && (
|
||||
<FormField
|
||||
@@ -216,31 +349,21 @@ export function AuthRegisterFormCore({
|
||||
required
|
||||
label={t`Password`}
|
||||
value={form.getValue('password')}
|
||||
onChange={(value) => form.setValue('password', value)}
|
||||
onChange={(value) => setDraftedFormValue('password', value)}
|
||||
error={form.getError('password') || fieldErrors?.password}
|
||||
/>
|
||||
)}
|
||||
|
||||
{requireBetaCode ? (
|
||||
{showPassword && showPasswordConfirmation && (
|
||||
<FormField
|
||||
id={betaCodeId}
|
||||
name="betaCode"
|
||||
type="text"
|
||||
id={confirmPasswordId}
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
label={t`Beta code`}
|
||||
value={form.getValue('betaCode')}
|
||||
onChange={(value) => form.setValue('betaCode', value)}
|
||||
error={form.getError('betaCode') || fieldErrors?.beta_code}
|
||||
/>
|
||||
) : (
|
||||
<FormField
|
||||
id={betaCodeId}
|
||||
name="betaCode"
|
||||
type="text"
|
||||
label={t`Beta code (optional)`}
|
||||
value={form.getValue('betaCode')}
|
||||
onChange={(value) => form.setValue('betaCode', value)}
|
||||
error={form.getError('betaCode') || fieldErrors?.beta_code}
|
||||
label={t`Confirm Password`}
|
||||
value={form.getValue('confirm_password')}
|
||||
onChange={(value) => setDraftedFormValue('confirm_password', value)}
|
||||
error={form.getError('confirm_password')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -248,16 +371,16 @@ export function AuthRegisterFormCore({
|
||||
selectedMonth={selectedMonth}
|
||||
selectedDay={selectedDay}
|
||||
selectedYear={selectedYear}
|
||||
onMonthChange={setSelectedMonth}
|
||||
onDayChange={setSelectedDay}
|
||||
onYearChange={setSelectedYear}
|
||||
onMonthChange={handleMonthChange}
|
||||
onDayChange={handleDayChange}
|
||||
onYearChange={handleYearChange}
|
||||
error={fieldErrors?.date_of_birth}
|
||||
/>
|
||||
|
||||
{extraContent}
|
||||
|
||||
<div className={styles.consentRow}>
|
||||
<Checkbox checked={consent} onChange={setConsent}>
|
||||
<Checkbox checked={consent} onChange={handleConsentChange}>
|
||||
<span className={styles.consentLabel}>
|
||||
<Trans>I agree to the</Trans>{' '}
|
||||
<ExternalLink href={Routes.terms()} className={styles.policyLink}>
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
|
||||
import {Link as RouterLink} from '@app/lib/router/React';
|
||||
import type {ReactNode} from 'react';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {Link as RouterLink} from '~/lib/router';
|
||||
|
||||
interface AuthRouterLinkProps {
|
||||
ringOffset?: number;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
to: string;
|
||||
search?: Record<string, string | undefined>;
|
||||
search?: Record<string, string>;
|
||||
}
|
||||
|
||||
export function AuthRouterLink({ringOffset = -2, children, className, to, search}: AuthRouterLinkProps) {
|
||||
|
||||
@@ -17,24 +17,21 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import styles from '@app/components/auth/BrowserLoginHandoffModal.module.css';
|
||||
import {Input} from '@app/components/form/Input';
|
||||
import * as Modal from '@app/components/modals/Modal';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
|
||||
import {getElectronAPI, openExternalUrl} from '@app/utils/NativeUtils';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {ArrowSquareOutIcon, CheckCircleIcon} from '@phosphor-icons/react';
|
||||
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import * as Modal from '~/components/modals/Modal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {IS_DEV} from '~/lib/env';
|
||||
import HttpClient from '~/lib/HttpClient';
|
||||
import RuntimeConfigStore, {describeApiEndpoint, type InstanceDiscoveryResponse} from '~/stores/RuntimeConfigStore';
|
||||
import {isDesktop, openExternalUrl} from '~/utils/NativeUtils';
|
||||
|
||||
import styles from './BrowserLoginHandoffModal.module.css';
|
||||
import type React from 'react';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
interface LoginSuccessPayload {
|
||||
token: string;
|
||||
@@ -47,35 +44,9 @@ interface BrowserLoginHandoffModalProps {
|
||||
prefillEmail?: string;
|
||||
}
|
||||
|
||||
interface ValidatedInstance {
|
||||
apiEndpoint: string;
|
||||
webAppUrl: string;
|
||||
}
|
||||
|
||||
type ModalView = 'main' | 'instance';
|
||||
|
||||
const CODE_LENGTH = 8;
|
||||
const VALID_CODE_PATTERN = /^[A-Za-z0-9]{8}$/;
|
||||
|
||||
const normalizeEndpoint = (input: string): string => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('API endpoint is required');
|
||||
}
|
||||
|
||||
let candidate = trimmed;
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(candidate)) {
|
||||
candidate = `https://${candidate}`;
|
||||
}
|
||||
|
||||
const url = new URL(candidate);
|
||||
if (url.pathname === '' || url.pathname === '/') {
|
||||
url.pathname = '/api';
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/+$/, '');
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const formatCodeForDisplay = (raw: string): string => {
|
||||
const cleaned = raw
|
||||
.replace(/[^A-Za-z0-9]/g, '')
|
||||
@@ -95,24 +66,46 @@ const extractRawCode = (formatted: string): string => {
|
||||
.slice(0, CODE_LENGTH);
|
||||
};
|
||||
|
||||
function normalizeInstanceOrigin(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Instance URL is required');
|
||||
}
|
||||
|
||||
const candidate = /^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
|
||||
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new Error('Instance URL must use http or https');
|
||||
}
|
||||
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
const BrowserLoginHandoffModal = observer(
|
||||
({onSuccess, targetWebAppUrl, prefillEmail}: BrowserLoginHandoffModalProps) => {
|
||||
const {i18n} = useLingui();
|
||||
|
||||
const [view, setView] = React.useState<ModalView>('main');
|
||||
const [code, setCode] = React.useState('');
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const electronApi = getElectronAPI();
|
||||
const switchInstanceUrl = electronApi?.switchInstanceUrl;
|
||||
const canSwitchInstanceUrl = typeof switchInstanceUrl === 'function';
|
||||
|
||||
const [customInstance, setCustomInstance] = React.useState('');
|
||||
const [instanceValidating, setInstanceValidating] = React.useState(false);
|
||||
const [instanceError, setInstanceError] = React.useState<string | null>(null);
|
||||
const [validatedInstance, setValidatedInstance] = React.useState<ValidatedInstance | null>(null);
|
||||
const currentWebAppUrl = RuntimeConfigStore.webAppBaseUrl;
|
||||
const [instanceUrl, setInstanceUrl] = useState(() => targetWebAppUrl ?? currentWebAppUrl);
|
||||
const [instanceUrlError, setInstanceUrlError] = useState<string | null>(null);
|
||||
|
||||
const showInstanceOption = IS_DEV || isDesktop();
|
||||
const [code, setCode] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const switchingInstanceRef = useRef(false);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
const instanceUrlHelper = useMemo(
|
||||
() => (canSwitchInstanceUrl ? i18n._(msg`The URL of the Fluxer instance you want to sign in to.`) : null),
|
||||
[canSwitchInstanceUrl, i18n],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (rawCode: string) => {
|
||||
if (!VALID_CODE_PATTERN.test(rawCode)) {
|
||||
return;
|
||||
@@ -120,15 +113,42 @@ const BrowserLoginHandoffModal = observer(
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
setInstanceUrlError(null);
|
||||
|
||||
try {
|
||||
const customApiEndpoint = validatedInstance?.apiEndpoint;
|
||||
const result = await AuthenticationActionCreators.pollDesktopHandoffStatus(rawCode, customApiEndpoint);
|
||||
if (canSwitchInstanceUrl) {
|
||||
const trimmedInstanceUrl = instanceUrl.trim();
|
||||
if (trimmedInstanceUrl) {
|
||||
let instanceOrigin: string;
|
||||
try {
|
||||
instanceOrigin = normalizeInstanceOrigin(trimmedInstanceUrl);
|
||||
} catch {
|
||||
setInstanceUrlError(
|
||||
i18n._(msg`Invalid instance URL. Try something like "example.com" or "https://example.com".`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (instanceOrigin !== window.location.origin) {
|
||||
try {
|
||||
switchingInstanceRef.current = true;
|
||||
await switchInstanceUrl({
|
||||
instanceUrl: instanceOrigin,
|
||||
desktopHandoffCode: rawCode,
|
||||
});
|
||||
} catch (switchError) {
|
||||
switchingInstanceRef.current = false;
|
||||
const detail = switchError instanceof Error ? switchError.message : String(switchError);
|
||||
setInstanceUrlError(detail);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await AuthenticationActionCreators.pollDesktopHandoffStatus(rawCode);
|
||||
|
||||
if (result.status === 'completed' && result.token && result.user_id) {
|
||||
if (customApiEndpoint) {
|
||||
await RuntimeConfigStore.connectToEndpoint(customApiEndpoint);
|
||||
}
|
||||
await onSuccess({token: result.token, userId: result.user_id});
|
||||
ModalActionCreators.pop();
|
||||
return;
|
||||
@@ -143,17 +163,25 @@ const BrowserLoginHandoffModal = observer(
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
if (!switchingInstanceRef.current) {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[i18n, onSuccess, validatedInstance],
|
||||
[canSwitchInstanceUrl, i18n, instanceUrl, onSuccess, switchInstanceUrl],
|
||||
);
|
||||
|
||||
const handleCodeChange = React.useCallback(
|
||||
const handleInstanceUrlChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInstanceUrl(e.target.value);
|
||||
setInstanceUrlError(null);
|
||||
}, []);
|
||||
|
||||
const handleCodeChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawCode = extractRawCode(e.target.value);
|
||||
setCode(rawCode);
|
||||
setError(null);
|
||||
setInstanceUrlError(null);
|
||||
|
||||
if (VALID_CODE_PATTERN.test(rawCode)) {
|
||||
void handleSubmit(rawCode);
|
||||
@@ -162,126 +190,61 @@ const BrowserLoginHandoffModal = observer(
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
const handleOpenBrowser = React.useCallback(async () => {
|
||||
const currentWebAppUrl = RuntimeConfigStore.webAppBaseUrl;
|
||||
const baseUrl = validatedInstance?.webAppUrl || targetWebAppUrl || currentWebAppUrl;
|
||||
const handleOpenBrowser = useCallback(async () => {
|
||||
const fallbackUrl = targetWebAppUrl || currentWebAppUrl;
|
||||
let baseUrl = fallbackUrl;
|
||||
|
||||
const params = new URLSearchParams({desktop_handoff: '1'});
|
||||
if (canSwitchInstanceUrl && instanceUrl.trim()) {
|
||||
try {
|
||||
baseUrl = normalizeInstanceOrigin(instanceUrl);
|
||||
} catch {
|
||||
setInstanceUrlError(
|
||||
i18n._(msg`Invalid instance URL. Try something like "example.com" or "https://example.com".`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const loginUrl = new URL('/login', baseUrl);
|
||||
loginUrl.searchParams.set('desktop_handoff', '1');
|
||||
if (prefillEmail) {
|
||||
params.set('email', prefillEmail);
|
||||
loginUrl.searchParams.set('email', prefillEmail);
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/login?${params.toString()}`;
|
||||
await openExternalUrl(url);
|
||||
}, [prefillEmail, targetWebAppUrl, validatedInstance]);
|
||||
await openExternalUrl(loginUrl.toString());
|
||||
}, [canSwitchInstanceUrl, currentWebAppUrl, i18n, instanceUrl, prefillEmail, targetWebAppUrl]);
|
||||
|
||||
const handleShowInstanceView = React.useCallback(() => {
|
||||
setView('instance');
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleBackToMain = React.useCallback(() => {
|
||||
setView('main');
|
||||
setInstanceError(null);
|
||||
}, []);
|
||||
|
||||
const handleSaveInstance = React.useCallback(async () => {
|
||||
if (!customInstance.trim()) {
|
||||
setInstanceError(i18n._(msg`Please enter an API endpoint.`));
|
||||
return;
|
||||
}
|
||||
|
||||
setInstanceValidating(true);
|
||||
setInstanceError(null);
|
||||
|
||||
try {
|
||||
const apiEndpoint = normalizeEndpoint(customInstance);
|
||||
const instanceUrl = `${apiEndpoint}/instance`;
|
||||
|
||||
const response = await HttpClient.get<InstanceDiscoveryResponse>({url: instanceUrl});
|
||||
|
||||
if (!response.ok) {
|
||||
const status = String(response.status);
|
||||
throw new Error(i18n._(msg`Failed to reach instance (${status})`));
|
||||
}
|
||||
|
||||
const instance = response.body;
|
||||
if (!instance.endpoints?.webapp) {
|
||||
throw new Error(i18n._(msg`Invalid instance response: missing webapp URL.`));
|
||||
}
|
||||
|
||||
const webAppUrl = instance.endpoints.webapp.replace(/\/$/, '');
|
||||
setValidatedInstance({apiEndpoint, webAppUrl});
|
||||
setView('main');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setInstanceError(message);
|
||||
} finally {
|
||||
setInstanceValidating(false);
|
||||
}
|
||||
}, [customInstance, i18n]);
|
||||
|
||||
const handleClearInstance = React.useCallback(() => {
|
||||
setValidatedInstance(null);
|
||||
setCustomInstance('');
|
||||
setInstanceError(null);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (view === 'main') {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [view]);
|
||||
|
||||
if (view === 'instance') {
|
||||
return (
|
||||
<Modal.Root size="small" centered onClose={ModalActionCreators.pop}>
|
||||
<Modal.Header title={i18n._(msg`Custom instance`)} />
|
||||
<Modal.Content className={styles.content}>
|
||||
<Input
|
||||
label={i18n._(msg`API Endpoint`)}
|
||||
type="url"
|
||||
placeholder="https://api.example.com"
|
||||
value={customInstance}
|
||||
onChange={(e) => {
|
||||
setCustomInstance(e.target.value);
|
||||
setInstanceError(null);
|
||||
}}
|
||||
error={instanceError ?? undefined}
|
||||
disabled={instanceValidating}
|
||||
footer={
|
||||
!instanceError ? (
|
||||
<p className={styles.inputHelper}>
|
||||
<Trans>Enter the API endpoint of the Fluxer instance you want to connect to.</Trans>
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={handleBackToMain} disabled={instanceValidating}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSaveInstance}
|
||||
disabled={instanceValidating || !customInstance.trim()}
|
||||
>
|
||||
{instanceValidating ? <Trans>Checking...</Trans> : <Trans>Save</Trans>}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal.Root size="small" centered onClose={ModalActionCreators.pop}>
|
||||
<Modal.Header title={i18n._(msg`Add account`)} />
|
||||
<Modal.Content className={styles.content}>
|
||||
<Modal.Content contentClassName={styles.content}>
|
||||
<p className={styles.description}>
|
||||
<Trans>Log in using your browser, then enter the code shown to add the account.</Trans>
|
||||
</p>
|
||||
|
||||
{canSwitchInstanceUrl ? (
|
||||
<div className={styles.codeInputSection}>
|
||||
<Input
|
||||
label={i18n._(msg`Instance URL`)}
|
||||
value={instanceUrl}
|
||||
onChange={handleInstanceUrlChange}
|
||||
error={instanceUrlError ?? undefined}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="url"
|
||||
placeholder="example.com"
|
||||
footer={
|
||||
instanceUrlHelper && !instanceUrlError ? (
|
||||
<p className={styles.inputHelper}>{instanceUrlHelper}</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.codeInputSection}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
@@ -294,22 +257,6 @@ const BrowserLoginHandoffModal = observer(
|
||||
/>
|
||||
</div>
|
||||
|
||||
{validatedInstance ? (
|
||||
<div className={styles.instanceBadge}>
|
||||
<CheckCircleIcon size={14} weight="fill" className={styles.instanceBadgeIcon} />
|
||||
<span className={styles.instanceBadgeText}>
|
||||
<Trans>Using {describeApiEndpoint(validatedInstance.apiEndpoint)}</Trans>
|
||||
</span>
|
||||
<button type="button" className={styles.instanceBadgeClear} onClick={handleClearInstance}>
|
||||
<Trans>Clear</Trans>
|
||||
</button>
|
||||
</div>
|
||||
) : showInstanceOption ? (
|
||||
<button type="button" className={styles.instanceLink} onClick={handleShowInstanceView}>
|
||||
<Trans>I want to use a custom Fluxer instance</Trans>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{prefillEmail ? (
|
||||
<p className={styles.prefillHint}>
|
||||
<Trans>We will prefill {prefillEmail} once the browser login opens.</Trans>
|
||||
|
||||
@@ -112,11 +112,6 @@
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.nativeDateInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--background-modifier-accent-focus);
|
||||
}
|
||||
|
||||
.nativeDateInput[aria-invalid='true'] {
|
||||
border-color: var(--status-danger);
|
||||
}
|
||||
|
||||
@@ -17,43 +17,20 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/DateOfBirthField.module.css';
|
||||
import {Select} from '@app/components/form/Select';
|
||||
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
|
||||
import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/lib/PasswordManagerAutocomplete';
|
||||
import {getCurrentLocale} from '@app/utils/LocaleUtils';
|
||||
import {isMobileExperienceEnabled} from '@app/utils/MobileExperience';
|
||||
import {getDateFieldOrder, getMonthNames} from '@fluxer/date_utils/src/DateIntrospection';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useMemo} from 'react';
|
||||
import {Select} from '~/components/form/Select';
|
||||
import {getCurrentLocale} from '~/utils/LocaleUtils';
|
||||
import styles from './DateOfBirthField.module.css';
|
||||
|
||||
type DateFieldType = 'month' | 'day' | 'year';
|
||||
|
||||
function isMobileWebBrowser(): boolean {
|
||||
return /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function getDateFieldOrder(locale: string): Array<DateFieldType> {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(new Date(2000, 0, 1));
|
||||
const order: Array<DateFieldType> = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'month' && !order.includes('month')) {
|
||||
order.push('month');
|
||||
} else if (part.type === 'day' && !order.includes('day')) {
|
||||
order.push('day');
|
||||
} else if (part.type === 'year' && !order.includes('year')) {
|
||||
order.push('year');
|
||||
}
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
interface DateOfBirthFieldProps {
|
||||
selectedMonth: string;
|
||||
selectedDay: string;
|
||||
@@ -122,16 +99,19 @@ function NativeDatePicker({
|
||||
</legend>
|
||||
</div>
|
||||
<div className={styles.inputsContainer}>
|
||||
<input
|
||||
type="date"
|
||||
className={styles.nativeDateInput}
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
min={minDate}
|
||||
max={maxDate}
|
||||
placeholder={dateOfBirthPlaceholder}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
<FocusRing offset={-2}>
|
||||
<input
|
||||
type="date"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
className={styles.nativeDateInput}
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
min={minDate}
|
||||
max={maxDate}
|
||||
placeholder={dateOfBirthPlaceholder}
|
||||
aria-invalid={!!error || undefined}
|
||||
/>
|
||||
</FocusRing>
|
||||
{error && <span className={styles.errorText}>{error}</span>}
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -159,12 +139,11 @@ export const DateOfBirthField = observer(function DateOfBirthField({
|
||||
const currentDate = new Date();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
|
||||
const monthNames = getMonthNames(locale);
|
||||
const allMonths = Array.from({length: 12}, (_, index) => {
|
||||
const monthDate = new Date(2000, index, 1);
|
||||
const monthName = new Intl.DateTimeFormat(locale, {month: 'long'}).format(monthDate);
|
||||
return {
|
||||
value: String(index + 1),
|
||||
label: monthName,
|
||||
label: monthNames[index],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -195,7 +174,7 @@ export const DateOfBirthField = observer(function DateOfBirthField({
|
||||
};
|
||||
}, [selectedYear, selectedMonth, locale]);
|
||||
|
||||
if (isMobileWebBrowser()) {
|
||||
if (isMobileExperienceEnabled()) {
|
||||
return (
|
||||
<NativeDatePicker
|
||||
selectedMonth={selectedMonth}
|
||||
|
||||
@@ -17,15 +17,17 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/DesktopDeepLinkPrompt.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Platform} from '@app/lib/Platform';
|
||||
import {Routes} from '@app/Routes';
|
||||
import {buildAppProtocolUrl} from '@app/utils/AppProtocol';
|
||||
import {checkDesktopAvailable, navigateInDesktop} from '@app/utils/DesktopRpcClient';
|
||||
import {isDesktop, openExternalUrl} from '@app/utils/NativeUtils';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {ArrowSquareOutIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Routes} from '~/Routes';
|
||||
import {checkDesktopAvailable, navigateInDesktop} from '~/utils/DesktopRpcClient';
|
||||
import {isDesktop} from '~/utils/NativeUtils';
|
||||
import {Button} from '../uikit/Button/Button';
|
||||
import styles from './DesktopDeepLinkPrompt.module.css';
|
||||
|
||||
interface DesktopDeepLinkPromptProps {
|
||||
code: string;
|
||||
@@ -37,9 +39,12 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [desktopAvailable, setDesktopAvailable] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMobileBrowser = Platform.isMobileBrowser;
|
||||
const useProtocolLaunch = kind === 'invite';
|
||||
const shouldProbeDesktopAvailability = !useProtocolLaunch;
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop()) return;
|
||||
if (isDesktop() || !shouldProbeDesktopAvailability) return;
|
||||
|
||||
let cancelled = false;
|
||||
checkDesktopAvailable().then(({available}) => {
|
||||
@@ -50,11 +55,11 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [shouldProbeDesktopAvailability]);
|
||||
|
||||
if (isDesktop()) return null;
|
||||
if (isDesktop() || isMobileBrowser) return null;
|
||||
|
||||
if (desktopAvailable !== true) return null;
|
||||
if (shouldProbeDesktopAvailability && desktopAvailable !== true) return null;
|
||||
|
||||
const getPath = (): string => {
|
||||
switch (kind) {
|
||||
@@ -73,6 +78,17 @@ export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({cod
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
if (useProtocolLaunch) {
|
||||
try {
|
||||
await openExternalUrl(buildAppProtocolUrl(path));
|
||||
} catch {
|
||||
setError('Failed to open in desktop app');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await navigateInDesktop(path);
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -17,14 +17,14 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import {AccountSelector} from '@app/components/accounts/AccountSelector';
|
||||
import {HandoffCodeDisplay} from '@app/components/auth/HandoffCodeDisplay';
|
||||
import {type Account, SessionExpiredError} from '@app/lib/SessionManager';
|
||||
import AccountManager from '@app/stores/AccountManager';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import {AccountSelector} from '~/components/accounts/AccountSelector';
|
||||
import {HandoffCodeDisplay} from '~/components/auth/HandoffCodeDisplay';
|
||||
import {SessionExpiredError} from '~/lib/SessionManager';
|
||||
import AccountManager, {type AccountSummary} from '~/stores/AccountManager';
|
||||
|
||||
type HandoffState = 'selecting' | 'generating' | 'displaying' | 'error';
|
||||
|
||||
@@ -48,7 +48,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
|
||||
const accounts = excludeCurrentUser ? allAccounts.filter((account) => account.userId !== currentUserId) : allAccounts;
|
||||
const isGenerating = handoffState === 'generating';
|
||||
|
||||
const handleSelectAccount = useCallback(async (account: AccountSummary) => {
|
||||
const handleSelectAccount = useCallback(async (account: Account) => {
|
||||
setSelectedAccountId(account.userId);
|
||||
setHandoffState('generating');
|
||||
setHandoffError(null);
|
||||
@@ -105,7 +105,7 @@ const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSel
|
||||
return (
|
||||
<AccountSelector
|
||||
accounts={accounts}
|
||||
title={<Trans>Choose an account</Trans>}
|
||||
title={<Trans>Choose an Account</Trans>}
|
||||
description={<Trans>Select the account you want to sign in with on the desktop app.</Trans>}
|
||||
disabled={isGenerating}
|
||||
showInstance
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Input} from '@app/components/form/Input';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {Input} from '~/components/form/Input';
|
||||
|
||||
interface FormFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> {
|
||||
name: string;
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {Gift} from '@app/actions/GiftActionCreators';
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {getPremiumGiftDurationText} from '@app/utils/GiftUtils';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {GiftIcon} from '@phosphor-icons/react';
|
||||
import type {Gift} from '~/actions/GiftActionCreators';
|
||||
import {getPremiumGiftDurationText} from '~/utils/giftUtils';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
|
||||
interface GiftHeaderProps {
|
||||
gift: Gift;
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as TextCopyActionCreators from '@app/actions/TextCopyActionCreators';
|
||||
import styles from '@app/components/auth/HandoffCodeDisplay.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import i18n from '@app/I18n';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {CheckCircleIcon, ClipboardIcon} from '@phosphor-icons/react';
|
||||
import {useCallback, useState} from 'react';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import i18n from '~/i18n';
|
||||
import styles from './HandoffCodeDisplay.module.css';
|
||||
|
||||
interface HandoffCodeDisplayProps {
|
||||
code: string | null;
|
||||
|
||||
191
fluxer_app/src/components/auth/InstanceSelector.module.css
Normal file
191
fluxer_app/src/components/auth/InstanceSelector.module.css
Normal file
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.statusSpinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.statusSuccess {
|
||||
color: var(--status-positive);
|
||||
}
|
||||
|
||||
.statusError {
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.dropdownToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
border-radius: 4px;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdownToggle:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.dropdownToggle:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.caretIcon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.caretIconOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
margin-top: 4px;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdownHeader {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.dropdownList {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.dropdownItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdownItem:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.instanceIcon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.instanceDomain {
|
||||
flex: 1;
|
||||
font-size: 0.875rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instanceName {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.removeButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-tertiary);
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dropdownItem:hover .removeButton {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.removeButton:hover {
|
||||
color: var(--status-danger);
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background-color: hsla(0, calc(100% * var(--saturation-factor, 1)), 50%, 0.1);
|
||||
border: 1px solid hsla(0, calc(100% * var(--saturation-factor, 1)), 50%, 0.2);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.connectButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
313
fluxer_app/src/components/auth/InstanceSelector.tsx
Normal file
313
fluxer_app/src/components/auth/InstanceSelector.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/InstanceSelector.module.css';
|
||||
import {Input} from '@app/components/form/Input';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Spinner} from '@app/components/uikit/Spinner';
|
||||
import AppStorage from '@app/lib/AppStorage';
|
||||
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CaretDownIcon, CheckCircleIcon, GlobeIcon, TrashIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
const RECENT_INSTANCES_KEY = 'federation_recent_instances';
|
||||
const MAX_RECENT_INSTANCES = 5;
|
||||
|
||||
export type InstanceDiscoveryStatus = 'idle' | 'discovering' | 'success' | 'error';
|
||||
|
||||
export interface InstanceInfo {
|
||||
domain: string;
|
||||
name?: string;
|
||||
lastUsed: number;
|
||||
}
|
||||
|
||||
interface InstanceSelectorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onInstanceDiscovered?: (domain: string) => void;
|
||||
onDiscoveryStatusChange?: (status: InstanceDiscoveryStatus) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function loadRecentInstances(): Array<InstanceInfo> {
|
||||
const stored = AppStorage.getJSON<Array<InstanceInfo>>(RECENT_INSTANCES_KEY);
|
||||
if (!stored || !Array.isArray(stored)) {
|
||||
return [];
|
||||
}
|
||||
return stored.sort((a, b) => b.lastUsed - a.lastUsed).slice(0, MAX_RECENT_INSTANCES);
|
||||
}
|
||||
|
||||
function saveRecentInstance(domain: string, name?: string): void {
|
||||
const recent = loadRecentInstances();
|
||||
const normalizedDomain = domain.toLowerCase().trim();
|
||||
|
||||
const existingIndex = recent.findIndex((inst) => inst.domain.toLowerCase() === normalizedDomain);
|
||||
if (existingIndex !== -1) {
|
||||
recent.splice(existingIndex, 1);
|
||||
}
|
||||
|
||||
recent.unshift({
|
||||
domain: normalizedDomain,
|
||||
name,
|
||||
lastUsed: Date.now(),
|
||||
});
|
||||
|
||||
AppStorage.setJSON(RECENT_INSTANCES_KEY, recent.slice(0, MAX_RECENT_INSTANCES));
|
||||
}
|
||||
|
||||
function removeRecentInstance(domain: string): void {
|
||||
const recent = loadRecentInstances();
|
||||
const normalizedDomain = domain.toLowerCase().trim();
|
||||
const filtered = recent.filter((inst) => inst.domain.toLowerCase() !== normalizedDomain);
|
||||
AppStorage.setJSON(RECENT_INSTANCES_KEY, filtered);
|
||||
}
|
||||
|
||||
export const InstanceSelector = observer(function InstanceSelector({
|
||||
value,
|
||||
onChange,
|
||||
onInstanceDiscovered,
|
||||
onDiscoveryStatusChange,
|
||||
disabled = false,
|
||||
className,
|
||||
}: InstanceSelectorProps) {
|
||||
const {t} = useLingui();
|
||||
const [discoveryStatus, setDiscoveryStatus] = useState<InstanceDiscoveryStatus>('idle');
|
||||
const [discoveryError, setDiscoveryError] = useState<string | null>(null);
|
||||
const [recentInstances, setRecentInstances] = useState<Array<InstanceInfo>>(() => loadRecentInstances());
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const discoveryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const updateDiscoveryStatus = useCallback(
|
||||
(status: InstanceDiscoveryStatus) => {
|
||||
setDiscoveryStatus(status);
|
||||
onDiscoveryStatusChange?.(status);
|
||||
},
|
||||
[onDiscoveryStatusChange],
|
||||
);
|
||||
|
||||
const discoverInstance = useCallback(
|
||||
async (instanceUrl: string) => {
|
||||
if (!instanceUrl.trim()) {
|
||||
updateDiscoveryStatus('idle');
|
||||
setDiscoveryError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
updateDiscoveryStatus('discovering');
|
||||
setDiscoveryError(null);
|
||||
|
||||
try {
|
||||
await RuntimeConfigStore.connectToEndpoint(instanceUrl);
|
||||
updateDiscoveryStatus('success');
|
||||
saveRecentInstance(instanceUrl);
|
||||
setRecentInstances(loadRecentInstances());
|
||||
onInstanceDiscovered?.(instanceUrl);
|
||||
} catch (error) {
|
||||
updateDiscoveryStatus('error');
|
||||
const errorMessage = error instanceof Error ? error.message : t`Failed to connect to instance`;
|
||||
setDiscoveryError(errorMessage);
|
||||
}
|
||||
},
|
||||
[onInstanceDiscovered, updateDiscoveryStatus, t],
|
||||
);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = event.target.value;
|
||||
onChange(newValue);
|
||||
updateDiscoveryStatus('idle');
|
||||
setDiscoveryError(null);
|
||||
|
||||
if (discoveryTimeoutRef.current) {
|
||||
clearTimeout(discoveryTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (newValue.trim()) {
|
||||
discoveryTimeoutRef.current = setTimeout(() => {
|
||||
discoverInstance(newValue);
|
||||
}, 800);
|
||||
}
|
||||
},
|
||||
[onChange, discoverInstance, updateDiscoveryStatus],
|
||||
);
|
||||
|
||||
const handleSelectRecent = useCallback(
|
||||
(instance: InstanceInfo) => {
|
||||
onChange(instance.domain);
|
||||
setShowDropdown(false);
|
||||
discoverInstance(instance.domain);
|
||||
},
|
||||
[onChange, discoverInstance],
|
||||
);
|
||||
|
||||
const handleRemoveRecent = useCallback((event: React.MouseEvent, domain: string) => {
|
||||
event.stopPropagation();
|
||||
removeRecentInstance(domain);
|
||||
setRecentInstances(loadRecentInstances());
|
||||
}, []);
|
||||
|
||||
const handleConnectClick = useCallback(() => {
|
||||
if (value.trim()) {
|
||||
discoverInstance(value);
|
||||
}
|
||||
}, [value, discoverInstance]);
|
||||
|
||||
const handleDropdownToggle = useCallback(() => {
|
||||
if (recentInstances.length > 0 && !disabled) {
|
||||
setShowDropdown((prev) => !prev);
|
||||
}
|
||||
}, [recentInstances.length, disabled]);
|
||||
|
||||
const handleInputFocus = useCallback(() => {
|
||||
if (recentInstances.length > 0 && !value.trim()) {
|
||||
setShowDropdown(true);
|
||||
}
|
||||
}, [recentInstances.length, value]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
inputRef.current &&
|
||||
!inputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowDropdown(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (discoveryTimeoutRef.current) {
|
||||
clearTimeout(discoveryTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
if (discoveryStatus === 'discovering') {
|
||||
return <Spinner size="small" className={styles.statusSpinner} />;
|
||||
}
|
||||
if (discoveryStatus === 'success') {
|
||||
return <CheckCircleIcon weight="fill" className={styles.statusSuccess} size={18} />;
|
||||
}
|
||||
if (discoveryStatus === 'error') {
|
||||
return <WarningCircleIcon weight="fill" className={styles.statusError} size={18} />;
|
||||
}
|
||||
return null;
|
||||
}, [discoveryStatus]);
|
||||
|
||||
const placeholder = t`Enter instance URL (e.g. fluxer.app)`;
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.container, className)}>
|
||||
<div className={styles.inputContainer}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleInputFocus}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
leftIcon={<GlobeIcon size={18} weight="regular" />}
|
||||
rightElement={
|
||||
<div className={styles.inputActions}>
|
||||
{statusIcon}
|
||||
{recentInstances.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.dropdownToggle}
|
||||
onClick={handleDropdownToggle}
|
||||
disabled={disabled}
|
||||
aria-label={t`Show recent instances`}
|
||||
>
|
||||
<CaretDownIcon
|
||||
size={16}
|
||||
weight="bold"
|
||||
className={clsx(styles.caretIcon, showDropdown && styles.caretIconOpen)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
aria-label={t`Instance URL`}
|
||||
aria-describedby={discoveryError ? 'instance-error' : undefined}
|
||||
/>
|
||||
|
||||
{showDropdown && recentInstances.length > 0 && (
|
||||
<div ref={dropdownRef} className={styles.dropdown}>
|
||||
<div className={styles.dropdownHeader}>
|
||||
<Trans>Recent instances</Trans>
|
||||
</div>
|
||||
<ul className={styles.dropdownList}>
|
||||
{recentInstances.map((instance) => (
|
||||
<li key={instance.domain}>
|
||||
<button type="button" className={styles.dropdownItem} onClick={() => handleSelectRecent(instance)}>
|
||||
<GlobeIcon size={16} weight="regular" className={styles.instanceIcon} />
|
||||
<span className={styles.instanceDomain}>{instance.domain}</span>
|
||||
{instance.name && <span className={styles.instanceName}>{instance.name}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.removeButton}
|
||||
onClick={(e) => handleRemoveRecent(e, instance.domain)}
|
||||
aria-label={t`Remove ${instance.domain} from recent instances`}
|
||||
>
|
||||
<TrashIcon size={14} weight="regular" />
|
||||
</button>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{discoveryError && (
|
||||
<div id="instance-error" className={styles.errorMessage}>
|
||||
{discoveryError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{discoveryStatus !== 'success' && value.trim() && (
|
||||
<Button
|
||||
onClick={handleConnectClick}
|
||||
disabled={disabled}
|
||||
submitting={discoveryStatus === 'discovering'}
|
||||
variant="secondary"
|
||||
small
|
||||
className={styles.connectButton}
|
||||
>
|
||||
<Trans>Connect</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -17,20 +17,20 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import {GuildBadge} from '@app/components/guild/GuildBadge';
|
||||
import {GuildIcon} from '@app/components/popouts/GuildIcon';
|
||||
import {Avatar} from '@app/components/uikit/Avatar';
|
||||
import {BaseAvatar} from '@app/components/uikit/BaseAvatar';
|
||||
import {UserRecord} from '@app/records/UserRecord';
|
||||
import {isGroupDmInvite, isGuildInvite, isPackInvite} from '@app/types/InviteTypes';
|
||||
import * as AvatarUtils from '@app/utils/AvatarUtils';
|
||||
import {getCurrentLocale} from '@app/utils/LocaleUtils';
|
||||
import {formatNumber} from '@fluxer/number_utils/src/NumberFormatting';
|
||||
import type {GroupDmInvite, GuildInvite, Invite, PackInvite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {SealCheckIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {GuildFeatures} from '~/Constants';
|
||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||
import {Avatar} from '~/components/uikit/Avatar';
|
||||
import {BaseAvatar} from '~/components/uikit/BaseAvatar';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import {UserRecord} from '~/records/UserRecord';
|
||||
import type {GroupDmInvite, GuildInvite, Invite, PackInvite} from '~/types/InviteTypes';
|
||||
import {isGroupDmInvite, isGuildInvite, isPackInvite} from '~/types/InviteTypes';
|
||||
import * as AvatarUtils from '~/utils/AvatarUtils';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
|
||||
interface InviteHeaderProps {
|
||||
invite: Invite;
|
||||
@@ -52,19 +52,25 @@ interface PreviewGuildInviteHeaderProps {
|
||||
guildId: string;
|
||||
guildName: string;
|
||||
guildIcon: string | null;
|
||||
isVerified: boolean;
|
||||
features: ReadonlyArray<string>;
|
||||
presenceCount: number;
|
||||
memberCount: number;
|
||||
previewIconUrl?: string | null;
|
||||
previewName?: string | null;
|
||||
}
|
||||
|
||||
function formatInviteCount(value: number): string {
|
||||
return formatNumber(value, getCurrentLocale());
|
||||
}
|
||||
|
||||
export const GuildInviteHeader = observer(function GuildInviteHeader({invite}: GuildInviteHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
const guild = invite.guild;
|
||||
const features = Array.isArray(guild.features) ? guild.features : [...guild.features];
|
||||
const isVerified = features.includes(GuildFeatures.VERIFIED);
|
||||
const presenceCount = invite.presence_count ?? 0;
|
||||
const memberCount = invite.member_count ?? 0;
|
||||
const formattedPresenceCount = formatInviteCount(presenceCount);
|
||||
const formattedMemberCount = formatInviteCount(memberCount);
|
||||
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
@@ -77,23 +83,19 @@ export const GuildInviteHeader = observer(function GuildInviteHeader({invite}: G
|
||||
</p>
|
||||
<div className={styles.entityTitleWrapper}>
|
||||
<h2 className={styles.entityTitle}>{guild.name}</h2>
|
||||
{isVerified ? (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={styles.verifiedIcon} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<GuildBadge features={features} />
|
||||
</div>
|
||||
<div className={styles.entityStats}>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.onlineDot} />
|
||||
<span className={styles.statText}>
|
||||
<Trans>{invite.presence_count} Online</Trans>
|
||||
<Trans>{formattedPresenceCount} Online</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.offlineDot} />
|
||||
<span className={styles.statText}>
|
||||
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
|
||||
{memberCount === 1 ? t`${formattedMemberCount} Member` : t`${formattedMemberCount} Members`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,6 +109,7 @@ export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite
|
||||
const inviter = invite.inviter;
|
||||
const avatarUrl = inviter ? AvatarUtils.getUserAvatarURL(inviter, false) : null;
|
||||
const memberCount = invite.member_count ?? 0;
|
||||
const formattedMemberCount = formatInviteCount(memberCount);
|
||||
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
@@ -124,7 +127,7 @@ export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.offlineDot} />
|
||||
<span className={styles.statText}>
|
||||
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
|
||||
{memberCount === 1 ? t`${formattedMemberCount} Member` : t`${formattedMemberCount} Members`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,7 +139,7 @@ export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite
|
||||
export const PackInviteHeader = observer(function PackInviteHeader({invite}: PackInviteHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
const pack = invite.pack;
|
||||
const creatorRecord = React.useMemo(() => new UserRecord(pack.creator), [pack.creator]);
|
||||
const creatorRecord = useMemo(() => new UserRecord(pack.creator), [pack.creator]);
|
||||
const packKindLabel = pack.type === 'emoji' ? t`Emoji pack` : t`Sticker pack`;
|
||||
const inviterTag = invite.inviter ? `${invite.inviter.username}#${invite.inviter.discriminator}` : null;
|
||||
|
||||
@@ -183,7 +186,7 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead
|
||||
guildId,
|
||||
guildName,
|
||||
guildIcon,
|
||||
isVerified,
|
||||
features,
|
||||
presenceCount,
|
||||
memberCount,
|
||||
previewIconUrl,
|
||||
@@ -191,9 +194,11 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead
|
||||
}: PreviewGuildInviteHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
const displayName = previewName ?? guildName;
|
||||
const [hasPreviewIconError, setPreviewIconError] = React.useState(false);
|
||||
const formattedPresenceCount = formatInviteCount(presenceCount);
|
||||
const formattedMemberCount = formatInviteCount(memberCount);
|
||||
const [hasPreviewIconError, setPreviewIconError] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
setPreviewIconError(false);
|
||||
}, [previewIconUrl]);
|
||||
|
||||
@@ -222,23 +227,19 @@ export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHead
|
||||
</p>
|
||||
<div className={styles.entityTitleWrapper}>
|
||||
<h2 className={styles.entityTitle}>{displayName}</h2>
|
||||
{isVerified ? (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={styles.verifiedIcon} />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<GuildBadge features={features} />
|
||||
</div>
|
||||
<div className={styles.entityStats}>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.onlineDot} />
|
||||
<span className={styles.statText}>
|
||||
<Trans>{presenceCount} Online</Trans>
|
||||
<Trans>{formattedPresenceCount} Online</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.offlineDot} />
|
||||
<span className={styles.statText}>
|
||||
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
|
||||
{memberCount === 1 ? t`${formattedMemberCount} Member` : t`${formattedMemberCount} Members`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,15 +17,16 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import styles from '@app/components/auth/IpAuthorizationScreen.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import type {IpAuthorizationChallenge} from '@app/viewmodels/auth/AuthFlow';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {EnvelopeSimpleIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import type {IpAuthorizationChallenge} from '~/hooks/useLoginFlow';
|
||||
import styles from './IpAuthorizationScreen.module.css';
|
||||
|
||||
type ConnectionState = 'connecting' | 'connected' | 'error';
|
||||
type PollingState = 'polling' | 'error';
|
||||
|
||||
interface IpAuthorizationScreenProps {
|
||||
challenge: IpAuthorizationChallenge;
|
||||
@@ -33,79 +34,67 @@ interface IpAuthorizationScreenProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
const MAX_POLL_ERRORS = 3;
|
||||
|
||||
const logger = new Logger('IpAuthorizationScreen');
|
||||
|
||||
const IpAuthorizationScreen = ({challenge, onAuthorized, onBack}: IpAuthorizationScreenProps) => {
|
||||
const [resendUsed, setResendUsed] = useState(false);
|
||||
const [resendIn, setResendIn] = useState(challenge.resendAvailableIn);
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('connecting');
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [pollingState, setPollingState] = useState<PollingState>('polling');
|
||||
const onAuthorizedRef = useRef(onAuthorized);
|
||||
onAuthorizedRef.current = onAuthorized;
|
||||
|
||||
useEffect(() => {
|
||||
setResendUsed(false);
|
||||
setResendIn(challenge.resendAvailableIn);
|
||||
setConnectionState('connecting');
|
||||
setRetryCount(0);
|
||||
setPollingState('polling');
|
||||
}, [challenge]);
|
||||
|
||||
useEffect(() => {
|
||||
let es: EventSource | null = null;
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let pollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let isMounted = true;
|
||||
let consecutiveErrors = 0;
|
||||
|
||||
const connect = () => {
|
||||
const poll = async () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
es = AuthenticationActionCreators.subscribeToIpAuthorization(challenge.ticket);
|
||||
try {
|
||||
const result = await AuthenticationActionCreators.pollIpAuthorization(challenge.ticket);
|
||||
|
||||
es.onopen = () => {
|
||||
if (isMounted) {
|
||||
setConnectionState('connected');
|
||||
setRetryCount(0);
|
||||
}
|
||||
};
|
||||
|
||||
es.onmessage = async (event) => {
|
||||
if (!event.data) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data?.token && data?.user_id) {
|
||||
es?.close();
|
||||
await onAuthorizedRef.current({token: data.token, userId: data.user_id});
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
es?.close();
|
||||
if (!isMounted) return;
|
||||
|
||||
setRetryCount((prev) => {
|
||||
const newCount = prev + 1;
|
||||
if (newCount < MAX_RETRY_ATTEMPTS) {
|
||||
setConnectionState('connecting');
|
||||
retryTimeout = setTimeout(connect, RETRY_DELAY_MS);
|
||||
} else {
|
||||
setConnectionState('error');
|
||||
}
|
||||
return newCount;
|
||||
});
|
||||
};
|
||||
if (result.completed && result.token && result.user_id) {
|
||||
await onAuthorizedRef.current({token: result.token, userId: result.user_id});
|
||||
return;
|
||||
}
|
||||
|
||||
consecutiveErrors = 0;
|
||||
pollTimeout = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
|
||||
consecutiveErrors++;
|
||||
|
||||
if (consecutiveErrors >= MAX_POLL_ERRORS) {
|
||||
setPollingState('error');
|
||||
logger.error('Failed to poll IP authorization after max retries', error);
|
||||
} else {
|
||||
pollTimeout = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
es?.close();
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
if (pollTimeout) {
|
||||
clearTimeout(pollTimeout);
|
||||
}
|
||||
};
|
||||
}, [challenge.ticket]);
|
||||
}, [challenge.ticket, pollingState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
@@ -122,42 +111,36 @@ const IpAuthorizationScreen = ({challenge, onAuthorized, onBack}: IpAuthorizatio
|
||||
setResendUsed(true);
|
||||
setResendIn(30);
|
||||
} catch (error) {
|
||||
console.error('Failed to resend IP authorization email', error);
|
||||
logger.error('Failed to resend IP authorization email', error);
|
||||
}
|
||||
}, [challenge.ticket, resendIn, resendUsed]);
|
||||
|
||||
const handleRetryConnection = useCallback(() => {
|
||||
setRetryCount(0);
|
||||
setConnectionState('connecting');
|
||||
const handleRetry = useCallback(() => {
|
||||
setPollingState('polling');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.icon}>
|
||||
{connectionState === 'error' ? (
|
||||
{pollingState === 'error' ? (
|
||||
<WarningCircleIcon size={48} weight="fill" />
|
||||
) : (
|
||||
<EnvelopeSimpleIcon size={48} weight="fill" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className={styles.title}>
|
||||
{connectionState === 'error' ? <Trans>Connection lost</Trans> : <Trans>Check your email</Trans>}
|
||||
{pollingState === 'error' ? <Trans>Connection lost</Trans> : <Trans>Check your email</Trans>}
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
{connectionState === 'error' ? (
|
||||
{pollingState === 'error' ? (
|
||||
<Trans>We lost the connection while waiting for authorization. Please try again.</Trans>
|
||||
) : (
|
||||
<Trans>We emailed a link to authorize this login. Please open your inbox for {challenge.email}.</Trans>
|
||||
)}
|
||||
</p>
|
||||
{connectionState === 'connecting' && retryCount > 0 && (
|
||||
<p className={styles.retryingText}>
|
||||
<Trans>Reconnecting...</Trans>
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
{connectionState === 'error' ? (
|
||||
<Button variant="primary" onClick={handleRetryConnection}>
|
||||
{pollingState === 'error' ? (
|
||||
<Button variant="primary" onClick={handleRetry}>
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footerButton:hover,
|
||||
.footerButton:focus {
|
||||
.footerButton:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import FormField from '@app/components/auth/FormField';
|
||||
import styles from '@app/components/auth/MfaScreen.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {useMfaController} from '@app/hooks/useLoginFlow';
|
||||
import type {LoginSuccessPayload, MfaChallenge} from '@app/viewmodels/auth/AuthFlow';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {useId} from 'react';
|
||||
import FormField from '~/components/auth/FormField';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {type LoginSuccessPayload, type MfaChallenge, useMfaController} from '~/hooks/useLoginFlow';
|
||||
import styles from './MfaScreen.module.css';
|
||||
|
||||
interface MfaScreenProps {
|
||||
challenge: MfaChallenge;
|
||||
|
||||
@@ -17,42 +17,21 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import authStyles from '@app/components/auth/AuthPageStyles.module.css';
|
||||
import dobStyles from '@app/components/auth/DateOfBirthField.module.css';
|
||||
import {ExternalLink} from '@app/components/common/ExternalLink';
|
||||
import inputStyles from '@app/components/form/Input.module.css';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
|
||||
import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/lib/PasswordManagerAutocomplete';
|
||||
import {Routes} from '@app/Routes';
|
||||
import {getCurrentLocale} from '@app/utils/LocaleUtils';
|
||||
import {getDateFieldOrder} from '@fluxer/date_utils/src/DateIntrospection';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {useMemo} from 'react';
|
||||
import {ExternalLink} from '~/components/common/ExternalLink';
|
||||
import inputStyles from '~/components/form/Input.module.css';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
|
||||
import {Routes} from '~/Routes';
|
||||
import {getCurrentLocale} from '~/utils/LocaleUtils';
|
||||
import authStyles from './AuthPageStyles.module.css';
|
||||
import dobStyles from './DateOfBirthField.module.css';
|
||||
|
||||
type DateFieldType = 'month' | 'day' | 'year';
|
||||
|
||||
function getDateFieldOrder(locale: string): Array<DateFieldType> {
|
||||
const formatter = new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
const parts = formatter.formatToParts(new Date(2000, 0, 1));
|
||||
const order: Array<DateFieldType> = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === 'month' && !order.includes('month')) {
|
||||
order.push('month');
|
||||
} else if (part.type === 'day' && !order.includes('day')) {
|
||||
order.push('day');
|
||||
} else if (part.type === 'year' && !order.includes('year')) {
|
||||
order.push('year');
|
||||
}
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
interface MockMinimalRegisterFormProps {
|
||||
submitLabel: React.ReactNode;
|
||||
}
|
||||
@@ -65,17 +44,38 @@ export function MockMinimalRegisterForm({submitLabel}: MockMinimalRegisterFormPr
|
||||
const dateFields: Record<DateFieldType, React.ReactElement> = {
|
||||
month: (
|
||||
<div key="month" className={dobStyles.monthField}>
|
||||
<input type="text" readOnly tabIndex={-1} placeholder={t`Month`} className={inputStyles.input} />
|
||||
<input
|
||||
type="text"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
placeholder={t`Month`}
|
||||
className={inputStyles.input}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
day: (
|
||||
<div key="day" className={dobStyles.dayField}>
|
||||
<input type="text" readOnly tabIndex={-1} placeholder={t`Day`} className={inputStyles.input} />
|
||||
<input
|
||||
type="text"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
placeholder={t`Day`}
|
||||
className={inputStyles.input}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
year: (
|
||||
<div key="year" className={dobStyles.yearField}>
|
||||
<input type="text" readOnly tabIndex={-1} placeholder={t`Year`} className={inputStyles.input} />
|
||||
<input
|
||||
type="text"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
placeholder={t`Year`}
|
||||
className={inputStyles.input}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -93,6 +93,7 @@ export function MockMinimalRegisterForm({submitLabel}: MockMinimalRegisterFormPr
|
||||
<div className={inputStyles.inputGroup}>
|
||||
<input
|
||||
type="text"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
placeholder={t`What should people call you?`}
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/auth/SubmitTooltip.module.css';
|
||||
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
|
||||
import type {MessageDescriptor} from '@lingui/core';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type {ReactNode} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import styles from './SubmitTooltip.module.css';
|
||||
|
||||
export interface MissingField {
|
||||
key: string;
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
overflow: hidden;
|
||||
animation: slideDown 300ms ease-out;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.suggestionsList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.suggestionButton {
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--background-secondary-alt);
|
||||
padding: 0.375rem 0.75rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
transform 150ms ease;
|
||||
animation: fadeInScale 200ms ease-out backwards;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.suggestionButton:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.suggestionButton:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
max-height: 500px;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@@ -17,27 +17,27 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import FormField from '@app/components/auth/FormField';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {useId} from 'react';
|
||||
import FormField from '~/components/auth/FormField';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
|
||||
type FieldErrors = Record<string, string | undefined> | null | undefined;
|
||||
|
||||
export type AuthFormControllerLike = {
|
||||
export interface AuthFormControllerLike {
|
||||
handleSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
getValue: (name: string) => string;
|
||||
setValue: (name: string, value: string) => void;
|
||||
getError: (name: string) => string | null | undefined;
|
||||
isSubmitting?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type AuthEmailPasswordFormClasses = {
|
||||
export interface AuthEmailPasswordFormClasses {
|
||||
form: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
form: AuthFormControllerLike;
|
||||
isLoading: boolean;
|
||||
fieldErrors?: FieldErrors;
|
||||
@@ -47,7 +47,7 @@ type Props = {
|
||||
links?: React.ReactNode;
|
||||
linksWrapperClassName?: string;
|
||||
disableSubmit?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AuthLoginEmailPasswordForm({
|
||||
form,
|
||||
@@ -17,15 +17,15 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {BrowserIcon, KeyIcon} from '@phosphor-icons/react';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
|
||||
export type AuthLoginDividerClasses = {
|
||||
interface AuthLoginDividerClasses {
|
||||
divider: string;
|
||||
dividerLine: string;
|
||||
dividerText: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function AuthLoginDivider({
|
||||
classes,
|
||||
@@ -43,11 +43,11 @@ export function AuthLoginDivider({
|
||||
);
|
||||
}
|
||||
|
||||
export type AuthPasskeyClasses = {
|
||||
export interface AuthPasskeyClasses {
|
||||
wrapper?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
classes?: AuthPasskeyClasses;
|
||||
|
||||
disabled: boolean;
|
||||
@@ -58,7 +58,7 @@ type Props = {
|
||||
|
||||
primaryLabel?: React.ReactNode;
|
||||
browserLabel?: React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AuthLoginPasskeyActions({
|
||||
classes,
|
||||
@@ -17,17 +17,17 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as AuthenticationActionCreators from '@app/actions/AuthenticationActionCreators';
|
||||
import {useCallback, useMemo, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
|
||||
export type DesktopHandoffMode = 'idle' | 'selecting' | 'login' | 'generating' | 'displaying' | 'error';
|
||||
type DesktopHandoffMode = 'idle' | 'selecting' | 'login' | 'generating' | 'ready' | 'displaying' | 'error';
|
||||
|
||||
type Options = {
|
||||
interface Options {
|
||||
enabled: boolean;
|
||||
hasStoredAccounts: boolean;
|
||||
|
||||
initialMode?: DesktopHandoffMode;
|
||||
};
|
||||
}
|
||||
|
||||
export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}: Options) {
|
||||
const derivedInitial = useMemo<DesktopHandoffMode>(() => {
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MuteDurationSheet} from '@app/components/bottomsheets/MuteDurationSheet';
|
||||
import {useCategoryMenuData} from '@app/components/uikit/context_menu/items/CategoryMenuData';
|
||||
import {MenuBottomSheet} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet';
|
||||
import {useMuteSheet} from '@app/hooks/useMuteSheet';
|
||||
import type {ChannelRecord} from '@app/records/ChannelRecord';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
interface CategoryBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
category: ChannelRecord;
|
||||
}
|
||||
|
||||
export const CategoryBottomSheet: React.FC<CategoryBottomSheetProps> = observer(({isOpen, onClose, category}) => {
|
||||
const {t} = useLingui();
|
||||
|
||||
const additionalMutePayload = useMemo(() => ({collapsed: true}), []);
|
||||
|
||||
const {muteSheetOpen, openMuteSheet, closeMuteSheet, handleMute, handleUnmute, muteConfig} = useMuteSheet({
|
||||
guildId: category.guildId ?? null,
|
||||
channelId: category.id,
|
||||
additionalMutePayload,
|
||||
});
|
||||
|
||||
const {groups, state} = useCategoryMenuData(category, {
|
||||
onClose,
|
||||
onOpenMuteSheet: openMuteSheet,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuBottomSheet isOpen={isOpen} onClose={onClose} groups={groups} title={category.name ?? t`Category Options`} />
|
||||
|
||||
<MuteDurationSheet
|
||||
isOpen={muteSheetOpen}
|
||||
onClose={closeMuteSheet}
|
||||
isMuted={state.isMuted}
|
||||
mutedText={state.mutedText}
|
||||
muteConfig={muteConfig}
|
||||
muteTitle={t`Mute Category`}
|
||||
unmuteTitle={t`Unmute Category`}
|
||||
onMute={handleMute}
|
||||
onUnmute={handleUnmute}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -17,57 +17,15 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {
|
||||
BellIcon,
|
||||
BellSlashIcon,
|
||||
BookOpenIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
GearIcon,
|
||||
LinkIcon,
|
||||
NotePencilIcon,
|
||||
PaperPlaneIcon,
|
||||
PushPinIcon,
|
||||
SignOutIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {MuteDurationSheet} from '@app/components/bottomsheets/MuteDurationSheet';
|
||||
import {useChannelMenuData} from '@app/components/uikit/context_menu/items/ChannelMenuData';
|
||||
import {MenuBottomSheet} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet';
|
||||
import {useMuteSheet} from '@app/hooks/useMuteSheet';
|
||||
import type {ChannelRecord} from '@app/records/ChannelRecord';
|
||||
import type {GuildRecord} from '@app/records/GuildRecord';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
|
||||
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
|
||||
import {ChannelTypes, ME, Permissions} from '~/Constants';
|
||||
import {createMuteConfig, getMuteDurationOptions} from '~/components/channel/muteOptions';
|
||||
import {ChannelSettingsModal} from '~/components/modals/ChannelSettingsModal';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {EditGroupModal} from '~/components/modals/EditGroupModal';
|
||||
import {GroupInvitesModal} from '~/components/modals/GroupInvitesModal';
|
||||
import {GuildNotificationSettingsModal} from '~/components/modals/GuildNotificationSettingsModal';
|
||||
import {InviteModal} from '~/components/modals/InviteModal';
|
||||
import type {MenuGroupType} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
|
||||
import {MenuBottomSheet} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
|
||||
import * as Sheet from '~/components/uikit/Sheet/Sheet';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import type {GuildRecord} from '~/records/GuildRecord';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import FavoritesStore from '~/stores/FavoritesStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
import {getMutedText} from '~/utils/ContextMenuUtils';
|
||||
import * as InviteUtils from '~/utils/InviteUtils';
|
||||
import styles from './ChannelDetailsBottomSheet.module.css';
|
||||
import sharedStyles from './shared.module.css';
|
||||
import type React from 'react';
|
||||
|
||||
interface ChannelBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
@@ -77,426 +35,33 @@ interface ChannelBottomSheetProps {
|
||||
}
|
||||
|
||||
export const ChannelBottomSheet: React.FC<ChannelBottomSheetProps> = observer(({isOpen, onClose, channel, guild}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const {t} = useLingui();
|
||||
|
||||
const isGroupDM = channel.type === ChannelTypes.GROUP_DM;
|
||||
const isDM = channel.type === ChannelTypes.DM;
|
||||
const isTextChannel = channel.type === ChannelTypes.GUILD_TEXT;
|
||||
const isVoiceChannel = channel.type === ChannelTypes.GUILD_VOICE;
|
||||
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
const isOwner = isGroupDM && channel.ownerId === currentUserId;
|
||||
const settingsGuildId = guild?.id ?? null;
|
||||
const channelOverride = UserGuildSettingsStore.getChannelOverride(settingsGuildId, channel.id);
|
||||
const isMuted = channelOverride?.muted ?? false;
|
||||
const muteConfig = channelOverride?.mute_config;
|
||||
const mutedText = getMutedText(isMuted, muteConfig);
|
||||
const isFavorited = !!FavoritesStore.getChannel(channel.id);
|
||||
const readState = ReadStateStore.get(channel.id);
|
||||
const hasUnread = readState.hasUnread;
|
||||
const [muteSheetOpen, setMuteSheetOpen] = React.useState(false);
|
||||
|
||||
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {
|
||||
const {muteSheetOpen, muteConfig, openMuteSheet, closeMuteSheet, handleMute, handleUnmute} = useMuteSheet({
|
||||
guildId: guild?.id ?? null,
|
||||
channelId: channel.id,
|
||||
guildId: channel.guildId,
|
||||
});
|
||||
const canInvite = InviteUtils.canInviteToChannel(channel.id, channel.guildId);
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
ReadStateActionCreators.ack(channel.id, true, true);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
onClose();
|
||||
const guildId = channel.guildId ?? ME;
|
||||
if (isFavorited) {
|
||||
FavoritesStore.removeChannel(channel.id);
|
||||
ToastActionCreators.createToast({type: 'success', children: t`Removed from favorites`});
|
||||
} else {
|
||||
FavoritesStore.addChannel(channel.id, guildId, null);
|
||||
ToastActionCreators.createToast({type: 'success', children: t`Added to favorites`});
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteMembers = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <InviteModal channelId={channel.id} />));
|
||||
};
|
||||
|
||||
const handleCopyChannelLink = async () => {
|
||||
const link = `${window.location.origin}/channels/${channel.guildId}/${channel.id}`;
|
||||
await TextCopyActionCreators.copy(i18n, link, true);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: t`Channel link copied`,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleOpenMuteSheet = () => {
|
||||
setMuteSheetOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseMuteSheet = () => {
|
||||
setMuteSheetOpen(false);
|
||||
};
|
||||
|
||||
const handleUpdateMute = (muted: boolean, duration: number | null) => {
|
||||
UserGuildSettingsActionCreators.updateChannelOverride(
|
||||
settingsGuildId,
|
||||
channel.id,
|
||||
{
|
||||
muted,
|
||||
mute_config: muted ? createMuteConfig(duration) : null,
|
||||
},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
handleCloseMuteSheet();
|
||||
};
|
||||
|
||||
const handleMuteDuration = (duration: number | null) => handleUpdateMute(true, duration);
|
||||
|
||||
const handleUnmute = () => handleUpdateMute(false, null);
|
||||
|
||||
const handleNotificationSettings = () => {
|
||||
onClose();
|
||||
if (guild) {
|
||||
ModalActionCreators.push(modal(() => <GuildNotificationSettingsModal guildId={guild.id} />));
|
||||
}
|
||||
};
|
||||
|
||||
const handleChannelSettings = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <ChannelSettingsModal channelId={channel.id} />));
|
||||
};
|
||||
|
||||
const handleDeleteChannel = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Delete Channel`}
|
||||
description={t`Are you sure you want to delete #${channel.name ?? channel.id}? This action cannot be undone.`}
|
||||
primaryText={t`Delete Channel`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
try {
|
||||
await ChannelActionCreators.remove(channel.id);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: t`Channel deleted`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete channel:', error);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: t`Failed to delete channel`,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleEditGroup = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <EditGroupModal channelId={channel.id} />));
|
||||
};
|
||||
|
||||
const handleShowInvites = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <GroupInvitesModal channelId={channel.id} />));
|
||||
};
|
||||
|
||||
const handlePinChannel = async () => {
|
||||
onClose();
|
||||
try {
|
||||
await PrivateChannelActionCreators.pinDmChannel(channel.id);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: isGroupDM ? t`Pinned group` : t`Pinned DM`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to pin:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinChannel = async () => {
|
||||
onClose();
|
||||
try {
|
||||
await PrivateChannelActionCreators.unpinDmChannel(channel.id);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: isGroupDM ? t`Unpinned group` : t`Unpinned DM`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to unpin:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveGroup = () => {
|
||||
if (!currentUserId) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Leave Group`}
|
||||
description={t`Are you sure you want to leave ${channel.name ?? 'this group'}?`}
|
||||
primaryText={t`Leave Group`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
try {
|
||||
await ChannelActionCreators.remove(channel.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to leave group:', error);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCloseDM = () => {
|
||||
onClose();
|
||||
ChannelActionCreators.remove(channel.id);
|
||||
};
|
||||
|
||||
const handleCopyChannelId = async () => {
|
||||
await TextCopyActionCreators.copy(i18n, channel.id, true);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: t`Channel ID copied`,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const menuGroups: Array<MenuGroupType> = [];
|
||||
|
||||
if (isGroupDM) {
|
||||
const primaryItems = [
|
||||
{
|
||||
icon: <NotePencilIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Edit Group`,
|
||||
onClick: handleEditGroup,
|
||||
},
|
||||
channel.isPinned
|
||||
? {
|
||||
icon: <PushPinIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Unpin Group DM`,
|
||||
onClick: handleUnpinChannel,
|
||||
}
|
||||
: {
|
||||
icon: <PushPinIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Pin Group DM`,
|
||||
onClick: handlePinChannel,
|
||||
},
|
||||
];
|
||||
|
||||
if (isOwner) {
|
||||
primaryItems.push({
|
||||
icon: <PaperPlaneIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Invites`,
|
||||
onClick: handleShowInvites,
|
||||
});
|
||||
}
|
||||
|
||||
menuGroups.push({items: primaryItems});
|
||||
|
||||
menuGroups.push({
|
||||
items: [
|
||||
{
|
||||
icon: <SignOutIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Leave Group`,
|
||||
onClick: handleLeaveGroup,
|
||||
danger: true,
|
||||
},
|
||||
{
|
||||
icon: <CopyIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Copy Channel ID`,
|
||||
onClick: handleCopyChannelId,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (isDM) {
|
||||
menuGroups.push({
|
||||
items: [
|
||||
channel.isPinned
|
||||
? {
|
||||
icon: <PushPinIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Unpin DM`,
|
||||
onClick: handleUnpinChannel,
|
||||
}
|
||||
: {
|
||||
icon: <PushPinIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Pin DM`,
|
||||
onClick: handlePinChannel,
|
||||
},
|
||||
{
|
||||
icon: <XIcon weight="bold" className={sharedStyles.icon} />,
|
||||
label: t`Close DM`,
|
||||
onClick: handleCloseDM,
|
||||
danger: true,
|
||||
},
|
||||
{
|
||||
icon: <CopyIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Copy Channel ID`,
|
||||
onClick: handleCopyChannelId,
|
||||
},
|
||||
],
|
||||
});
|
||||
} else if (guild && (isTextChannel || isVoiceChannel)) {
|
||||
if (hasUnread()) {
|
||||
menuGroups.push({
|
||||
items: [
|
||||
{
|
||||
icon: <BookOpenIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Mark as Read`,
|
||||
onClick: handleMarkAsRead,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (AccessibilityStore.showFavorites) {
|
||||
menuGroups.push({
|
||||
items: [
|
||||
{
|
||||
icon: <StarIcon weight={isFavorited ? 'fill' : 'regular'} className={sharedStyles.icon} />,
|
||||
label: isFavorited ? t`Remove from Favorites` : t`Add to Favorites`,
|
||||
onClick: handleToggleFavorite,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const inviteItems = [];
|
||||
if (canInvite) {
|
||||
inviteItems.push({
|
||||
icon: <UserPlusIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Invite People`,
|
||||
onClick: handleInviteMembers,
|
||||
});
|
||||
}
|
||||
inviteItems.push({
|
||||
icon: <LinkIcon weight="bold" className={sharedStyles.icon} />,
|
||||
label: t`Copy Channel Link`,
|
||||
onClick: handleCopyChannelLink,
|
||||
});
|
||||
menuGroups.push({items: inviteItems});
|
||||
|
||||
menuGroups.push({
|
||||
items: [
|
||||
{
|
||||
icon: isMuted ? (
|
||||
<BellIcon weight="fill" className={sharedStyles.icon} />
|
||||
) : (
|
||||
<BellSlashIcon weight="fill" className={sharedStyles.icon} />
|
||||
),
|
||||
label: isMuted ? t`Unmute Channel` : t`Mute Channel`,
|
||||
onClick: handleOpenMuteSheet,
|
||||
},
|
||||
{
|
||||
icon: <BellIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Notification Settings`,
|
||||
onClick: handleNotificationSettings,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (canManageChannels) {
|
||||
menuGroups.push({
|
||||
items: [
|
||||
{
|
||||
icon: <GearIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Edit Channel`,
|
||||
onClick: handleChannelSettings,
|
||||
},
|
||||
{
|
||||
icon: <TrashIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Delete Channel`,
|
||||
onClick: handleDeleteChannel,
|
||||
danger: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
menuGroups.push({
|
||||
items: [
|
||||
{
|
||||
icon: <CopyIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Copy Channel ID`,
|
||||
onClick: handleCopyChannelId,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
const {groups, state} = useChannelMenuData(channel, guild, {
|
||||
onClose,
|
||||
onOpenMuteSheet: openMuteSheet,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuBottomSheet
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
groups={menuGroups}
|
||||
title={channel.name ?? t`Channel Options`}
|
||||
<MenuBottomSheet isOpen={isOpen} onClose={onClose} groups={groups} title={channel.name ?? t`Channel Options`} />
|
||||
|
||||
<MuteDurationSheet
|
||||
isOpen={muteSheetOpen}
|
||||
onClose={closeMuteSheet}
|
||||
isMuted={state.isMuted}
|
||||
mutedText={state.mutedText}
|
||||
muteConfig={muteConfig}
|
||||
muteTitle={t`Mute Channel`}
|
||||
unmuteTitle={t`Unmute Channel`}
|
||||
onMute={handleMute}
|
||||
onUnmute={handleUnmute}
|
||||
/>
|
||||
|
||||
<Sheet.Root isOpen={muteSheetOpen} onClose={handleCloseMuteSheet} snapPoints={[0, 1]} initialSnap={1}>
|
||||
<Sheet.Handle />
|
||||
<Sheet.Header trailing={<Sheet.CloseButton onClick={handleCloseMuteSheet} />}>
|
||||
<Sheet.Title>{isMuted ? t`Unmute Channel` : t`Mute Channel`}</Sheet.Title>
|
||||
</Sheet.Header>
|
||||
<Sheet.Content padding="none">
|
||||
<div className={styles.muteSheetContainer}>
|
||||
<div className={styles.muteSheetContent}>
|
||||
{isMuted && mutedText ? (
|
||||
<>
|
||||
<div className={styles.muteStatusBanner}>
|
||||
<p className={styles.muteStatusText}>
|
||||
<Trans>Currently: {mutedText}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.muteOptionsContainer}>
|
||||
<button type="button" onClick={handleUnmute} className={styles.muteOptionButton}>
|
||||
<span className={styles.muteOptionLabel}>
|
||||
<Trans>Unmute</Trans>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muteOptionsContainer}>
|
||||
{getMuteDurationOptions(t).map((option, index, array) => {
|
||||
const isSelected =
|
||||
isMuted &&
|
||||
((option.value === null && !muteConfig?.end_time) ||
|
||||
(option.value !== null && muteConfig?.selected_time_window === option.value));
|
||||
|
||||
return (
|
||||
<React.Fragment key={option.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMuteDuration(option.value)}
|
||||
className={styles.muteOptionButton}
|
||||
>
|
||||
<span className={styles.muteOptionLabel}>{option.label}</span>
|
||||
{isSelected && <CheckIcon className={styles.iconMedium} weight="bold" />}
|
||||
</button>
|
||||
{index < array.length - 1 && <div className={styles.muteOptionDivider} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -127,6 +127,15 @@
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.memberListFallbackContainer {
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.memberListFallback {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mainScroller {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -347,6 +356,7 @@
|
||||
|
||||
.tabBarContainer {
|
||||
display: flex;
|
||||
border-bottom: 2px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.tabButton {
|
||||
@@ -356,6 +366,7 @@
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
@@ -557,6 +568,9 @@
|
||||
}
|
||||
|
||||
.dmMembersContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@@ -603,20 +617,6 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.membersHeader {
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.membersListContainer {
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.memberItemButton {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,11 +17,11 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {ChannelPinsContent} from '@app/components/shared/ChannelPinsContent';
|
||||
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
|
||||
import type {ChannelRecord} from '@app/records/ChannelRecord';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ChannelPinsContent} from '~/components/shared/ChannelPinsContent';
|
||||
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
|
||||
export const ChannelPinsBottomSheet = observer(
|
||||
({isOpen, onClose, channel}: {isOpen: boolean; onClose: () => void; channel: ChannelRecord}) => {
|
||||
|
||||
@@ -17,44 +17,47 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import '@app/components/channel/ChannelSearchHighlight.css';
|
||||
import * as MessageActionCreators from '@app/actions/MessageActionCreators';
|
||||
import sharedStyles from '@app/components/bottomsheets/shared.module.css';
|
||||
import {Message} from '@app/components/channel/Message';
|
||||
import {MessageActionBottomSheet} from '@app/components/channel/MessageActionBottomSheet';
|
||||
import {LongPressable} from '@app/components/LongPressable';
|
||||
import {HasFilterSheet, type HasFilterType} from '@app/components/search/HasFilterSheet';
|
||||
import {ScopeSheet} from '@app/components/search/ScopeSheet';
|
||||
import {SearchFilterChip} from '@app/components/search/SearchFilterChip';
|
||||
import {SortModeSheet} from '@app/components/search/SortModeSheet';
|
||||
import {UserFilterSheet} from '@app/components/search/UserFilterSheet';
|
||||
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
CaretDownIcon,
|
||||
CircleNotchIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
SortAscendingIcon,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
CloseIcon,
|
||||
ExpandChevronIcon,
|
||||
FilterIcon,
|
||||
LoadingIcon,
|
||||
NextIcon,
|
||||
PreviousIcon,
|
||||
SearchIcon,
|
||||
SortIcon,
|
||||
UserFilterIcon,
|
||||
} from '@app/components/uikit/context_menu/ContextMenuIcons';
|
||||
import {Scroller, type ScrollerHandle} from '@app/components/uikit/Scroller';
|
||||
import {type ChannelSearchFilters, useChannelSearch} from '@app/hooks/useChannelSearch';
|
||||
import {useMessageListKeyboardNavigation} from '@app/hooks/useMessageListKeyboardNavigation';
|
||||
import {shouldDisableAutofocusOnMobile} from '@app/lib/AutofocusUtils';
|
||||
import {PASSWORD_MANAGER_IGNORE_ATTRIBUTES} from '@app/lib/PasswordManagerAutocomplete';
|
||||
import type {ChannelRecord} from '@app/records/ChannelRecord';
|
||||
import type {MessageRecord} from '@app/records/MessageRecord';
|
||||
import ChannelStore from '@app/stores/ChannelStore';
|
||||
import UserStore from '@app/stores/UserStore';
|
||||
import styles from '@app/styles/ChannelSearchBottomSheet.module.css';
|
||||
import {applyChannelSearchHighlight, clearChannelSearchHighlight} from '@app/utils/ChannelSearchHighlight';
|
||||
import * as ChannelUtils from '@app/utils/ChannelUtils';
|
||||
import {goToMessage} from '@app/utils/MessageNavigator';
|
||||
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as MessageActionCreators from '~/actions/MessageActionCreators';
|
||||
import '~/components/channel/ChannelSearchHighlight.css';
|
||||
import {MessagePreviewContext} from '~/Constants';
|
||||
import {Message} from '~/components/channel/Message';
|
||||
import {MessageActionBottomSheet} from '~/components/channel/MessageActionBottomSheet';
|
||||
import {LongPressable} from '~/components/LongPressable';
|
||||
import {HasFilterSheet, type HasFilterType} from '~/components/search/HasFilterSheet';
|
||||
import {ScopeSheet} from '~/components/search/ScopeSheet';
|
||||
import {SearchFilterChip} from '~/components/search/SearchFilterChip';
|
||||
import {SortModeSheet} from '~/components/search/SortModeSheet';
|
||||
import {UserFilterSheet} from '~/components/search/UserFilterSheet';
|
||||
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
|
||||
import {type ChannelSearchFilters, useChannelSearch} from '~/hooks/useChannelSearch';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import type {MessageRecord} from '~/records/MessageRecord';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import styles from '~/styles/ChannelSearchBottomSheet.module.css';
|
||||
import {applyChannelSearchHighlight, clearChannelSearchHighlight} from '~/utils/ChannelSearchHighlight';
|
||||
import * as ChannelUtils from '~/utils/ChannelUtils';
|
||||
import {goToMessage} from '~/utils/MessageNavigator';
|
||||
import sharedStyles from './shared.module.css';
|
||||
import React, {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
interface ChannelSearchBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
@@ -65,19 +68,23 @@ interface ChannelSearchBottomSheetProps {
|
||||
export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> = observer(
|
||||
({isOpen, onClose, channel}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const [contentQuery, setContentQuery] = React.useState('');
|
||||
const [menuOpen, setMenuOpen] = React.useState(false);
|
||||
const [selectedMessage, setSelectedMessage] = React.useState<MessageRecord | null>(null);
|
||||
const scrollerRef = React.useRef<ScrollerHandle | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [contentQuery, setContentQuery] = useState('');
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [selectedMessage, setSelectedMessage] = useState<MessageRecord | null>(null);
|
||||
const scrollerRef = useRef<ScrollerHandle | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [hasFilters, setHasFilters] = React.useState<Array<HasFilterType>>([]);
|
||||
const [fromUserIds, setFromUserIds] = React.useState<Array<string>>([]);
|
||||
const [hasFilters, setHasFilters] = useState<Array<HasFilterType>>([]);
|
||||
const [fromUserIds, setFromUserIds] = useState<Array<string>>([]);
|
||||
|
||||
const [hasSheetOpen, setHasSheetOpen] = React.useState(false);
|
||||
const [userSheetOpen, setUserSheetOpen] = React.useState(false);
|
||||
const [sortSheetOpen, setSortSheetOpen] = React.useState(false);
|
||||
const [scopeSheetOpen, setScopeSheetOpen] = React.useState(false);
|
||||
const [hasSheetOpen, setHasSheetOpen] = useState(false);
|
||||
const [userSheetOpen, setUserSheetOpen] = useState(false);
|
||||
const [sortSheetOpen, setSortSheetOpen] = useState(false);
|
||||
const [scopeSheetOpen, setScopeSheetOpen] = useState(false);
|
||||
|
||||
useMessageListKeyboardNavigation({
|
||||
containerRef: scrollerRef,
|
||||
});
|
||||
|
||||
const {
|
||||
machineState,
|
||||
@@ -92,7 +99,13 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
reset,
|
||||
} = useChannelSearch({channel});
|
||||
|
||||
const buildFilters = React.useCallback((): ChannelSearchFilters => {
|
||||
const sortModeLabel = (() => {
|
||||
if (sortMode === 'newest') return t`Newest`;
|
||||
if (sortMode === 'oldest') return t`Oldest`;
|
||||
return t`Relevant`;
|
||||
})();
|
||||
|
||||
const buildFilters = useCallback((): ChannelSearchFilters => {
|
||||
return {
|
||||
content: contentQuery.trim() || undefined,
|
||||
has: hasFilters.length > 0 ? hasFilters : undefined,
|
||||
@@ -100,7 +113,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
};
|
||||
}, [contentQuery, hasFilters, fromUserIds]);
|
||||
|
||||
const handleSearch = React.useCallback(() => {
|
||||
const handleSearch = useCallback(() => {
|
||||
const filters = buildFilters();
|
||||
if (!filters.content && !filters.has?.length && !filters.authorIds?.length) {
|
||||
return;
|
||||
@@ -108,14 +121,14 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
performFilterSearch(filters);
|
||||
}, [buildFilters, performFilterSearch]);
|
||||
|
||||
const handleClear = React.useCallback(() => {
|
||||
const handleClear = useCallback(() => {
|
||||
setContentQuery('');
|
||||
setHasFilters([]);
|
||||
setFromUserIds([]);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const handleNextPage = React.useCallback(() => {
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (machineState.status !== 'success') return;
|
||||
const totalPages = Math.max(1, Math.ceil(machineState.total / machineState.hitsPerPage));
|
||||
if (machineState.page < totalPages) {
|
||||
@@ -123,7 +136,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
}
|
||||
}, [machineState, goToPage]);
|
||||
|
||||
const handlePrevPage = React.useCallback(() => {
|
||||
const handlePrevPage = useCallback(() => {
|
||||
if (machineState.status !== 'success' || machineState.page === 1) return;
|
||||
goToPage(machineState.page - 1);
|
||||
}, [machineState, goToPage]);
|
||||
@@ -151,7 +164,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
handleJump(message.channelId, message.id);
|
||||
};
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
const handleDelete = useCallback(
|
||||
(bypassConfirm = false) => {
|
||||
if (!selectedMessage) return;
|
||||
if (bypassConfirm) {
|
||||
@@ -163,7 +176,10 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
[t, selectedMessage],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (shouldDisableAutofocusOnMobile()) {
|
||||
return;
|
||||
}
|
||||
if (isOpen && inputRef.current) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
@@ -171,7 +187,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (hasSearched) {
|
||||
const filters = buildFilters();
|
||||
if (filters.content || filters.has?.length || filters.authorIds?.length) {
|
||||
@@ -180,7 +196,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
}
|
||||
}, [hasFilters, fromUserIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (machineState.status !== 'success' || machineState.results.length === 0) {
|
||||
clearChannelSearchHighlight();
|
||||
return;
|
||||
@@ -255,7 +271,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
return (
|
||||
<div className={styles.emptyStateContainer}>
|
||||
<div className={styles.emptyStateContent}>
|
||||
<MagnifyingGlassIcon className={styles.emptyStateIcon} />
|
||||
<SearchIcon className={styles.emptyStateIcon} />
|
||||
<h3 className={styles.emptyStateTitle}>
|
||||
<Trans>Search Messages</Trans>
|
||||
</h3>
|
||||
@@ -272,13 +288,13 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
case 'loading':
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<CircleNotchIcon className={styles.loadingIcon} />
|
||||
<LoadingIcon className={styles.loadingIcon} />
|
||||
</div>
|
||||
);
|
||||
case 'indexing':
|
||||
return (
|
||||
<div className={styles.indexingContainer}>
|
||||
<CircleNotchIcon className={styles.indexingIcon} />
|
||||
<LoadingIcon className={styles.indexingIcon} />
|
||||
<div className={styles.indexingContent}>
|
||||
<h3 className={styles.indexingTitle}>
|
||||
<Trans>Indexing Channel</Trans>
|
||||
@@ -309,7 +325,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
return (
|
||||
<div className={styles.emptyStateContainer}>
|
||||
<div className={styles.emptyStateContent}>
|
||||
<MagnifyingGlassIcon className={styles.emptyStateIcon} />
|
||||
<SearchIcon className={styles.emptyStateIcon} />
|
||||
<div className={styles.emptyStateContent}>
|
||||
<h3 className={styles.emptyStateTitle}>
|
||||
<Trans>No Results</Trans>
|
||||
@@ -361,7 +377,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
disabled={currentPage === 1}
|
||||
className={styles.paginationButton}
|
||||
>
|
||||
<ArrowLeftIcon className={sharedStyles.iconSmall} />
|
||||
<PreviousIcon className={sharedStyles.iconSmall} />
|
||||
<Trans>Previous</Trans>
|
||||
</button>
|
||||
<span className={styles.paginationText}>
|
||||
@@ -376,7 +392,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
className={styles.paginationButton}
|
||||
>
|
||||
<Trans>Next</Trans>
|
||||
<ArrowRightIcon className={sharedStyles.iconSmall} />
|
||||
<NextIcon className={sharedStyles.iconSmall} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -402,10 +418,11 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
<div className={styles.container}>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchInputWrapper}>
|
||||
<MagnifyingGlassIcon className={styles.searchIcon} weight="bold" />
|
||||
<SearchIcon className={styles.searchIcon} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
{...PASSWORD_MANAGER_IGNORE_ATTRIBUTES}
|
||||
value={contentQuery}
|
||||
onChange={(e) => setContentQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -419,7 +436,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
className={styles.clearButton}
|
||||
aria-label={t`Clear search`}
|
||||
>
|
||||
<XIcon className={sharedStyles.icon} weight="bold" />
|
||||
<CloseIcon className={sharedStyles.icon} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -428,7 +445,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
<SearchFilterChip
|
||||
label={t`From`}
|
||||
value={getFromUserLabel()}
|
||||
icon={<UserIcon size={14} weight="bold" />}
|
||||
icon={<UserFilterIcon size={14} />}
|
||||
onPress={() => setUserSheetOpen(true)}
|
||||
onRemove={fromUserIds.length > 0 ? () => setFromUserIds([]) : undefined}
|
||||
isActive={fromUserIds.length > 0}
|
||||
@@ -436,21 +453,21 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
<SearchFilterChip
|
||||
label={t`Has`}
|
||||
value={getHasFilterLabel()}
|
||||
icon={<FunnelIcon size={14} weight="bold" />}
|
||||
icon={<FilterIcon size={14} />}
|
||||
onPress={() => setHasSheetOpen(true)}
|
||||
onRemove={hasFilters.length > 0 ? () => setHasFilters([]) : undefined}
|
||||
isActive={hasFilters.length > 0}
|
||||
/>
|
||||
<SearchFilterChip
|
||||
label={t`Sort`}
|
||||
value={sortMode === 'newest' ? t`Newest` : sortMode === 'oldest' ? t`Oldest` : t`Relevant`}
|
||||
icon={<SortAscendingIcon size={14} weight="bold" />}
|
||||
value={sortModeLabel}
|
||||
icon={<SortIcon size={14} />}
|
||||
onPress={() => setSortSheetOpen(true)}
|
||||
isActive={false}
|
||||
/>
|
||||
<SearchFilterChip
|
||||
label={activeScopeOption?.label ?? t`Scope`}
|
||||
icon={<CaretDownIcon size={14} weight="bold" />}
|
||||
icon={<ExpandChevronIcon size={14} />}
|
||||
onPress={() => setScopeSheetOpen(true)}
|
||||
isActive={false}
|
||||
/>
|
||||
@@ -478,7 +495,7 @@ export const ChannelSearchBottomSheet: React.FC<ChannelSearchBottomSheetProps> =
|
||||
channel={channel}
|
||||
selectedUserIds={fromUserIds}
|
||||
onUsersChange={setFromUserIds}
|
||||
title={t`From user`}
|
||||
title={t`From User`}
|
||||
/>
|
||||
<SortModeSheet
|
||||
isOpen={sortSheetOpen}
|
||||
|
||||
@@ -17,18 +17,19 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import styles from '@app/components/bottomsheets/CreateDMBottomSheet.module.css';
|
||||
import {FriendSelector} from '@app/components/common/FriendSelector';
|
||||
import {DuplicateGroupConfirmModal} from '@app/components/modals/DuplicateGroupConfirmModal';
|
||||
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {Scroller} from '@app/components/uikit/Scroller';
|
||||
import {useCreateDMModalLogic} from '@app/utils/modals/CreateDMModalUtils';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {FriendSelector} from '~/components/common/FriendSelector';
|
||||
import {DuplicateGroupConfirmModal} from '~/components/modals/DuplicateGroupConfirmModal';
|
||||
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Scroller} from '~/components/uikit/Scroller';
|
||||
import {useCreateDMModalLogic} from '~/utils/modals/CreateDMModalUtils';
|
||||
import styles from './CreateDMBottomSheet.module.css';
|
||||
import type React from 'react';
|
||||
import {useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
|
||||
interface CreateDMBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
@@ -42,11 +43,11 @@ type ScrollContentStyle = React.CSSProperties & {
|
||||
export const CreateDMBottomSheet = observer(({isOpen, onClose}: CreateDMBottomSheetProps) => {
|
||||
const {t} = useLingui();
|
||||
const modalLogic = useCreateDMModalLogic({autoCloseOnCreate: false, resetKey: isOpen});
|
||||
const snapPoints = React.useMemo(() => [0, 1], []);
|
||||
const footerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [footerHeight, setFooterHeight] = React.useState(0);
|
||||
const snapPoints = useMemo(() => [0, 1], []);
|
||||
const footerRef = useRef<HTMLDivElement>(null);
|
||||
const [footerHeight, setFooterHeight] = useState(0);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
setFooterHeight(0);
|
||||
return undefined;
|
||||
@@ -66,26 +67,22 @@ export const CreateDMBottomSheet = observer(({isOpen, onClose}: CreateDMBottomSh
|
||||
}
|
||||
|
||||
const handleResize = () => updateHeight();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', handleResize);
|
||||
}
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
}
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const scrollContentStyle = React.useMemo<ScrollContentStyle>(() => {
|
||||
const scrollContentStyle = useMemo<ScrollContentStyle>(() => {
|
||||
if (footerHeight === 0) {
|
||||
return {};
|
||||
}
|
||||
return {'--create-dm-scroll-padding-bottom': `calc(${footerHeight}px + 16px)`};
|
||||
}, [footerHeight]);
|
||||
|
||||
const handleCreate = React.useCallback(async () => {
|
||||
const handleCreate = useCallback(async () => {
|
||||
const result = await modalLogic.handleCreate();
|
||||
if (result && result.duplicates.length > 0) {
|
||||
ModalActionCreators.push(
|
||||
@@ -105,7 +102,7 @@ export const CreateDMBottomSheet = observer(({isOpen, onClose}: CreateDMBottomSh
|
||||
return (
|
||||
<BottomSheet isOpen={isOpen} onClose={onClose} snapPoints={snapPoints} title={t`Select Friends`} disablePadding>
|
||||
<div className={styles.container}>
|
||||
<Scroller className={styles.scroller} fade={false}>
|
||||
<Scroller key="create-dm-scroller" className={styles.scroller} fade={false}>
|
||||
<div className={styles.content} style={scrollContentStyle}>
|
||||
<p className={styles.description}>
|
||||
{modalLogic.selectedUserIds.length === 0 ? (
|
||||
|
||||
@@ -17,75 +17,15 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {
|
||||
BellIcon,
|
||||
BellSlashIcon,
|
||||
BookOpenIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
IdentificationCardIcon,
|
||||
NoteIcon,
|
||||
PencilIcon,
|
||||
PhoneIcon,
|
||||
ProhibitIcon,
|
||||
PushPinIcon,
|
||||
SignOutIcon,
|
||||
StarIcon,
|
||||
TicketIcon,
|
||||
UserCircleIcon,
|
||||
UserMinusIcon,
|
||||
UserPlusIcon,
|
||||
UsersIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {MuteDurationSheet} from '@app/components/bottomsheets/MuteDurationSheet';
|
||||
import {useDMMenuData} from '@app/components/uikit/context_menu/items/DMMenuData';
|
||||
import {MenuBottomSheet} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet';
|
||||
import {useMuteSheet} from '@app/hooks/useMuteSheet';
|
||||
import type {ChannelRecord} from '@app/records/ChannelRecord';
|
||||
import type {UserRecord} from '@app/records/UserRecord';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
|
||||
import * as InviteActionCreators from '~/actions/InviteActionCreators';
|
||||
import * as MessageActionCreators from '~/actions/MessageActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
|
||||
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
|
||||
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
|
||||
import {ChannelTypes, ME, RelationshipTypes} from '~/Constants';
|
||||
import {DMCloseFailedModal} from '~/components/alerts/DMCloseFailedModal';
|
||||
import {GroupLeaveFailedModal} from '~/components/alerts/GroupLeaveFailedModal';
|
||||
import {ChangeFriendNicknameModal} from '~/components/modals/ChangeFriendNicknameModal';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {EditGroupModal} from '~/components/modals/EditGroupModal';
|
||||
import {GroupInvitesModal} from '~/components/modals/GroupInvitesModal';
|
||||
import type {MenuGroupType} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
|
||||
import {MenuBottomSheet} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
|
||||
import * as Sheet from '~/components/uikit/Sheet/Sheet';
|
||||
import {Routes} from '~/Routes';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import type {GuildRecord} from '~/records/GuildRecord';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import AccessibilityStore from '~/stores/AccessibilityStore';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import FavoritesStore from '~/stores/FavoritesStore';
|
||||
import GuildMemberStore from '~/stores/GuildMemberStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import RelationshipStore from '~/stores/RelationshipStore';
|
||||
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
|
||||
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import * as CallUtils from '~/utils/CallUtils';
|
||||
import {getMutedText} from '~/utils/ContextMenuUtils';
|
||||
import * as InviteUtils from '~/utils/InviteUtils';
|
||||
import * as RelationshipActionUtils from '~/utils/RelationshipActionUtils';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
import {fromTimestamp} from '~/utils/SnowflakeUtils';
|
||||
import sharedStyles from './shared.module.css';
|
||||
import type React from 'react';
|
||||
|
||||
interface DMBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
@@ -95,641 +35,34 @@ interface DMBottomSheetProps {
|
||||
}
|
||||
|
||||
export const DMBottomSheet: React.FC<DMBottomSheetProps> = observer(({isOpen, onClose, channel, recipient}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
const [muteSheetOpen, setMuteSheetOpen] = React.useState(false);
|
||||
const {t} = useLingui();
|
||||
|
||||
const isGroupDM = channel.type === ChannelTypes.GROUP_DM;
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
const isOwner = isGroupDM && channel.ownerId === currentUserId;
|
||||
|
||||
const channelOverride = UserGuildSettingsStore.getChannelOverride(null, channel.id);
|
||||
const isMuted = channelOverride?.muted ?? false;
|
||||
const muteConfig = channelOverride?.mute_config;
|
||||
const mutedText = getMutedText(isMuted, muteConfig);
|
||||
|
||||
const readState = ReadStateStore.get(channel.id);
|
||||
const hasUnread = () => readState?.hasUnread?.() ?? false;
|
||||
|
||||
const isFavorited = !!FavoritesStore.getChannel(channel.id);
|
||||
const isRecipientBot = recipient?.bot;
|
||||
const relationship = recipient ? RelationshipStore.getRelationship(recipient.id) : null;
|
||||
const relationshipType = relationship?.type;
|
||||
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
ReadStateActionCreators.ack(channel.id, true, true);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
onClose();
|
||||
if (isFavorited) {
|
||||
FavoritesStore.removeChannel(channel.id);
|
||||
ToastActionCreators.createToast({type: 'success', children: t`Removed from Favorites`});
|
||||
} else {
|
||||
FavoritesStore.addChannel(channel.id, ME, null);
|
||||
ToastActionCreators.createToast({type: 'success', children: t`Added to Favorites`});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenMuteSheet = () => {
|
||||
setMuteSheetOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseMuteSheet = () => {
|
||||
setMuteSheetOpen(false);
|
||||
};
|
||||
|
||||
const handlePinDM = async () => {
|
||||
onClose();
|
||||
try {
|
||||
await PrivateChannelActionCreators.pinDmChannel(channel.id);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: isGroupDM ? t`Pinned group DM` : t`Pinned DM`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to pin:', error);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: isGroupDM ? t`Failed to pin group DM` : t`Failed to pin DM`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpinDM = async () => {
|
||||
onClose();
|
||||
try {
|
||||
await PrivateChannelActionCreators.unpinDmChannel(channel.id);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: isGroupDM ? t`Unpinned group DM` : t`Unpinned DM`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to unpin:', error);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: isGroupDM ? t`Failed to unpin group DM` : t`Failed to unpin DM`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditGroup = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <EditGroupModal channelId={channel.id} />));
|
||||
};
|
||||
|
||||
const handleShowInvites = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <GroupInvitesModal channelId={channel.id} />));
|
||||
};
|
||||
|
||||
const handleViewProfile = () => {
|
||||
if (!recipient) return;
|
||||
onClose();
|
||||
UserProfileMobileStore.open(recipient.id);
|
||||
};
|
||||
|
||||
const handleStartVoiceCall = async () => {
|
||||
if (!recipient) return;
|
||||
onClose();
|
||||
try {
|
||||
const channelId = await PrivateChannelActionCreators.ensureDMChannel(recipient.id);
|
||||
await CallUtils.checkAndStartCall(channelId);
|
||||
} catch (error) {
|
||||
console.error('Failed to start voice call:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNote = () => {
|
||||
if (!recipient) return;
|
||||
onClose();
|
||||
UserProfileActionCreators.openUserProfile(recipient.id, undefined, true);
|
||||
};
|
||||
|
||||
const handleChangeFriendNickname = () => {
|
||||
if (!recipient) return;
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <ChangeFriendNicknameModal user={recipient} />));
|
||||
};
|
||||
|
||||
const getInvitableCommunities = React.useCallback((): Array<{guild: GuildRecord; channelId: string}> => {
|
||||
if (!recipient) return [];
|
||||
return GuildStore.getGuilds()
|
||||
.filter((guild) => !GuildMemberStore.getMember(guild.id, recipient.id))
|
||||
.map((guild) => {
|
||||
const selectedChannelId = SelectedChannelStore.selectedChannelIds.get(guild.id);
|
||||
if (selectedChannelId) {
|
||||
const selectedChannel = ChannelStore.getChannel(selectedChannelId);
|
||||
if (selectedChannel?.guildId && InviteUtils.canInviteToChannel(selectedChannel.id, selectedChannel.guildId)) {
|
||||
return {guild, channelId: selectedChannel.id};
|
||||
}
|
||||
}
|
||||
|
||||
const guildChannels = ChannelStore.getGuildChannels(guild.id);
|
||||
for (const guildChannel of guildChannels) {
|
||||
if (
|
||||
guildChannel.type === ChannelTypes.GUILD_TEXT &&
|
||||
InviteUtils.canInviteToChannel(guildChannel.id, guildChannel.guildId)
|
||||
) {
|
||||
return {guild, channelId: guildChannel.id};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((candidate): candidate is {guild: GuildRecord; channelId: string} => candidate !== null)
|
||||
.sort((a, b) => a.guild.name.localeCompare(b.guild.name));
|
||||
}, [recipient]);
|
||||
|
||||
const handleInviteToCommunity = async (_guildId: string, channelId: string, guildName: string) => {
|
||||
if (!recipient) return;
|
||||
onClose();
|
||||
try {
|
||||
const invite = await InviteActionCreators.create(channelId);
|
||||
const inviteUrl = `${RuntimeConfigStore.inviteEndpoint}/${invite.code}`;
|
||||
const dmChannelId = await PrivateChannelActionCreators.ensureDMChannel(recipient.id);
|
||||
await MessageActionCreators.send(dmChannelId, {
|
||||
content: inviteUrl,
|
||||
nonce: fromTimestamp(Date.now()),
|
||||
});
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: t`Invite sent to ${guildName}`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to send invite:', error);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: t`Failed to send invite`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendFriendRequest = () => {
|
||||
if (!recipient) return;
|
||||
RelationshipActionUtils.sendFriendRequest(i18n, recipient.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleAcceptFriendRequest = () => {
|
||||
if (!recipient) return;
|
||||
RelationshipActionUtils.acceptFriendRequest(i18n, recipient.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleRemoveFriend = () => {
|
||||
if (!recipient) return;
|
||||
onClose();
|
||||
RelationshipActionUtils.showRemoveFriendConfirmation(i18n, recipient);
|
||||
};
|
||||
|
||||
const handleBlockUser = () => {
|
||||
if (!recipient) return;
|
||||
onClose();
|
||||
RelationshipActionUtils.showBlockUserConfirmation(i18n, recipient);
|
||||
};
|
||||
|
||||
const handleUnblockUser = () => {
|
||||
if (!recipient) return;
|
||||
onClose();
|
||||
RelationshipActionUtils.unblockUser(i18n, recipient.id);
|
||||
};
|
||||
|
||||
const handleCloseDM = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Close DM`}
|
||||
description={t`Are you sure you want to close your DM with ${recipient?.username ?? ''}? You can always reopen it later.`}
|
||||
primaryText={t`Close DM`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
try {
|
||||
await ChannelActionCreators.remove(channel.id);
|
||||
const selectedChannel = SelectedChannelStore.selectedChannelIds.get(ME);
|
||||
if (selectedChannel === channel.id) {
|
||||
RouterUtils.transitionTo(Routes.ME);
|
||||
}
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: t`DM closed`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to close DM:', error);
|
||||
ModalActionCreators.push(modal(() => <DMCloseFailedModal />));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleLeaveGroup = () => {
|
||||
if (!currentUserId) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={t`Leave Group`}
|
||||
description={t`Are you sure you want to leave this group? You will no longer be able to see any messages.`}
|
||||
primaryText={t`Leave Group`}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
try {
|
||||
await PrivateChannelActionCreators.removeRecipient(channel.id, currentUserId);
|
||||
const selectedChannel = SelectedChannelStore.selectedChannelIds.get(ME);
|
||||
if (selectedChannel === channel.id) {
|
||||
RouterUtils.transitionTo(Routes.ME);
|
||||
}
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: t`Left group`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to leave group:', error);
|
||||
ModalActionCreators.push(modal(() => <GroupLeaveFailedModal />));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyChannelId = async () => {
|
||||
await TextCopyActionCreators.copy(i18n, channel.id, true);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: t`Channel ID copied`,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCopyUserId = async () => {
|
||||
if (!recipient) return;
|
||||
await TextCopyActionCreators.copy(i18n, recipient.id, true);
|
||||
ToastActionCreators.createToast({
|
||||
type: 'success',
|
||||
children: t`User ID copied`,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const menuGroups: Array<MenuGroupType> = [];
|
||||
|
||||
if (hasUnread()) {
|
||||
menuGroups.push({
|
||||
items: [
|
||||
{
|
||||
icon: <BookOpenIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Mark as Read`,
|
||||
onClick: handleMarkAsRead,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (AccessibilityStore.showFavorites) {
|
||||
menuGroups.push({
|
||||
items: [
|
||||
{
|
||||
icon: <StarIcon weight={isFavorited ? 'fill' : 'regular'} className={sharedStyles.icon} />,
|
||||
label: isFavorited ? t`Remove from Favorites` : t`Add to Favorites`,
|
||||
onClick: handleToggleFavorite,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient && !isGroupDM) {
|
||||
const recipientItems: MenuGroupType['items'] = [
|
||||
{
|
||||
icon: <UserCircleIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`View Profile`,
|
||||
onClick: handleViewProfile,
|
||||
},
|
||||
];
|
||||
|
||||
if (!isRecipientBot) {
|
||||
recipientItems.push({
|
||||
icon: <PhoneIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Voice Call`,
|
||||
onClick: handleStartVoiceCall,
|
||||
});
|
||||
}
|
||||
|
||||
recipientItems.push({
|
||||
icon: <NoteIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Add Note`,
|
||||
onClick: handleAddNote,
|
||||
});
|
||||
|
||||
if (relationshipType === RelationshipTypes.FRIEND) {
|
||||
recipientItems.push({
|
||||
icon: <PencilIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Change Friend Nickname`,
|
||||
onClick: handleChangeFriendNickname,
|
||||
});
|
||||
}
|
||||
|
||||
menuGroups.push({items: recipientItems});
|
||||
}
|
||||
|
||||
menuGroups.push({
|
||||
items: [
|
||||
{
|
||||
icon: isMuted ? (
|
||||
<BellSlashIcon weight="fill" className={sharedStyles.icon} />
|
||||
) : (
|
||||
<BellIcon weight="fill" className={sharedStyles.icon} />
|
||||
),
|
||||
label: isMuted ? t`Unmute Conversation` : t`Mute Conversation`,
|
||||
subtext: mutedText || undefined,
|
||||
onClick: handleOpenMuteSheet,
|
||||
},
|
||||
],
|
||||
const {muteSheetOpen, openMuteSheet, closeMuteSheet, handleMute, handleUnmute, muteConfig} = useMuteSheet({
|
||||
guildId: null,
|
||||
channelId: channel.id,
|
||||
onClose,
|
||||
});
|
||||
|
||||
const groupActionsItems: MenuGroupType['items'] = [];
|
||||
|
||||
if (isGroupDM) {
|
||||
groupActionsItems.push({
|
||||
icon: <PencilIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Edit Group`,
|
||||
onClick: handleEditGroup,
|
||||
});
|
||||
|
||||
if (isOwner) {
|
||||
groupActionsItems.push({
|
||||
icon: <TicketIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Invites`,
|
||||
onClick: handleShowInvites,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
groupActionsItems.push(
|
||||
channel.isPinned
|
||||
? {
|
||||
icon: <PushPinIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: isGroupDM ? t`Unpin Group DM` : t`Unpin DM`,
|
||||
onClick: handleUnpinDM,
|
||||
}
|
||||
: {
|
||||
icon: <PushPinIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: isGroupDM ? t`Pin Group DM` : t`Pin DM`,
|
||||
onClick: handlePinDM,
|
||||
},
|
||||
);
|
||||
|
||||
menuGroups.push({items: groupActionsItems});
|
||||
|
||||
if (recipient && !isGroupDM && !isRecipientBot) {
|
||||
const relationshipItems: MenuGroupType['items'] = [];
|
||||
|
||||
const invitableCommunities = getInvitableCommunities();
|
||||
if (invitableCommunities.length > 0) {
|
||||
const communitiesToShow = invitableCommunities.slice(0, 5);
|
||||
for (const {guild, channelId} of communitiesToShow) {
|
||||
relationshipItems.push({
|
||||
icon: <UsersIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Invite to ${guild.name}`,
|
||||
onClick: () => handleInviteToCommunity(guild.id, channelId, guild.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (relationshipType === RelationshipTypes.FRIEND) {
|
||||
relationshipItems.push({
|
||||
icon: <UserMinusIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Remove Friend`,
|
||||
onClick: handleRemoveFriend,
|
||||
danger: true,
|
||||
});
|
||||
} else if (relationshipType === RelationshipTypes.INCOMING_REQUEST) {
|
||||
relationshipItems.push({
|
||||
icon: <UserPlusIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Accept Friend Request`,
|
||||
onClick: handleAcceptFriendRequest,
|
||||
});
|
||||
} else if (
|
||||
relationshipType !== RelationshipTypes.OUTGOING_REQUEST &&
|
||||
relationshipType !== RelationshipTypes.BLOCKED &&
|
||||
!currentUserUnclaimed
|
||||
) {
|
||||
relationshipItems.push({
|
||||
icon: <UserPlusIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Add Friend`,
|
||||
onClick: handleSendFriendRequest,
|
||||
});
|
||||
}
|
||||
|
||||
if (relationshipType === RelationshipTypes.BLOCKED) {
|
||||
relationshipItems.push({
|
||||
icon: <ProhibitIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Unblock`,
|
||||
onClick: handleUnblockUser,
|
||||
});
|
||||
} else {
|
||||
relationshipItems.push({
|
||||
icon: <ProhibitIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Block`,
|
||||
onClick: handleBlockUser,
|
||||
danger: true,
|
||||
});
|
||||
}
|
||||
|
||||
menuGroups.push({items: relationshipItems});
|
||||
}
|
||||
|
||||
const closeItems: MenuGroupType['items'] = [
|
||||
isGroupDM
|
||||
? {
|
||||
icon: <SignOutIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Leave Group`,
|
||||
onClick: handleLeaveGroup,
|
||||
danger: true,
|
||||
}
|
||||
: {
|
||||
icon: <XIcon weight="bold" className={sharedStyles.icon} />,
|
||||
label: t`Close DM`,
|
||||
onClick: handleCloseDM,
|
||||
danger: true,
|
||||
},
|
||||
];
|
||||
|
||||
menuGroups.push({items: closeItems});
|
||||
|
||||
const copyItems: MenuGroupType['items'] = [];
|
||||
|
||||
if (recipient) {
|
||||
copyItems.push({
|
||||
icon: <IdentificationCardIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Copy User ID`,
|
||||
onClick: handleCopyUserId,
|
||||
});
|
||||
}
|
||||
|
||||
copyItems.push({
|
||||
icon: <CopyIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Copy Channel ID`,
|
||||
onClick: handleCopyChannelId,
|
||||
const {groups, isMuted, mutedText} = useDMMenuData(channel, recipient, {
|
||||
onClose,
|
||||
onOpenMuteSheet: openMuteSheet,
|
||||
});
|
||||
|
||||
menuGroups.push({items: copyItems});
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuBottomSheet isOpen={isOpen} onClose={onClose} groups={menuGroups} />
|
||||
<MenuBottomSheet isOpen={isOpen} onClose={onClose} groups={groups} />
|
||||
|
||||
<Sheet.Root isOpen={muteSheetOpen} onClose={handleCloseMuteSheet} snapPoints={[0, 1]} initialSnap={1}>
|
||||
<Sheet.Handle />
|
||||
<Sheet.Header trailing={<Sheet.CloseButton onClick={handleCloseMuteSheet} />}>
|
||||
<Sheet.Title>{isMuted ? t`Unmute Conversation` : t`Mute Conversation`}</Sheet.Title>
|
||||
</Sheet.Header>
|
||||
<Sheet.Content padding="none">
|
||||
<div style={{padding: '0 16px 16px'}}>
|
||||
{isMuted && mutedText ? (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
backgroundColor: 'var(--background-secondary)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<p style={{margin: 0, color: 'var(--text-secondary)', fontSize: '14px'}}>
|
||||
<Trans>Currently: {mutedText}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--background-secondary)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
UserGuildSettingsActionCreators.updateChannelOverride(
|
||||
null,
|
||||
channel.id,
|
||||
{
|
||||
muted: false,
|
||||
mute_config: null,
|
||||
},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
handleCloseMuteSheet();
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<Trans>Unmute</Trans>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--background-secondary)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{label: t`For 15 minutes`, value: 15 * 60 * 1000},
|
||||
{label: t`For 1 hour`, value: 60 * 60 * 1000},
|
||||
{label: t`For 3 hours`, value: 3 * 60 * 60 * 1000},
|
||||
{label: t`For 8 hours`, value: 8 * 60 * 60 * 1000},
|
||||
{label: t`For 24 hours`, value: 24 * 60 * 60 * 1000},
|
||||
{label: t`Until I turn it back on`, value: null},
|
||||
].map((option, index, array) => {
|
||||
const isSelected =
|
||||
isMuted &&
|
||||
((option.value === null && !muteConfig?.end_time) ||
|
||||
(option.value !== null && muteConfig?.selected_time_window === option.value));
|
||||
|
||||
return (
|
||||
<React.Fragment key={option.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newMuteConfig =
|
||||
option.value !== null
|
||||
? {
|
||||
selected_time_window: option.value,
|
||||
end_time: new Date(Date.now() + option.value).toISOString(),
|
||||
}
|
||||
: null;
|
||||
|
||||
UserGuildSettingsActionCreators.updateChannelOverride(
|
||||
null,
|
||||
channel.id,
|
||||
{
|
||||
muted: true,
|
||||
mute_config: newMuteConfig,
|
||||
},
|
||||
{persistImmediately: true},
|
||||
);
|
||||
handleCloseMuteSheet();
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '14px 16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{isSelected && <CheckIcon size={20} weight="bold" style={{color: 'var(--brand-primary)'}} />}
|
||||
</button>
|
||||
|
||||
{index < array.length - 1 && (
|
||||
<div
|
||||
style={{
|
||||
height: '1px',
|
||||
backgroundColor: 'var(--background-modifier-accent)',
|
||||
marginLeft: '16px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
<MuteDurationSheet
|
||||
isOpen={muteSheetOpen}
|
||||
onClose={closeMuteSheet}
|
||||
isMuted={isMuted}
|
||||
mutedText={mutedText}
|
||||
muteConfig={muteConfig}
|
||||
muteTitle={t`Mute Conversation`}
|
||||
unmuteTitle={t`Unmute Conversation`}
|
||||
onMute={handleMute}
|
||||
onUnmute={handleUnmute}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.statusRow {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.statusLabel {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.callPreview {
|
||||
background-color: #000;
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--background-secondary-alt);
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.actionButton:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.iconContainerBrand {
|
||||
background-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.iconContainerDanger {
|
||||
background-color: var(--status-danger);
|
||||
}
|
||||
|
||||
.iconContainerTertiary {
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.iconContainerSuccess {
|
||||
background-color: #22c55e;
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.actionIconSecondary {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.actionText {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.connectionInfo {
|
||||
border-radius: 12px;
|
||||
background-color: var(--background-secondary-alt);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.connectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.connectionStatusInfo {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.connectionTitle {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.connectionSubtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.connectionStatusDot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--status-online);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.statRow {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statValuePrimary {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.endpointValue {
|
||||
display: block;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #22c55e;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connectionIdValue {
|
||||
display: block;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maxWidth {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as CallActionCreators from '@app/actions/CallActionCreators';
|
||||
import * as LayoutActionCreators from '@app/actions/LayoutActionCreators';
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as VoiceStateActionCreators from '@app/actions/VoiceStateActionCreators';
|
||||
import styles from '@app/components/bottomsheets/DirectCallLobbyBottomSheet.module.css';
|
||||
import {useCallHeaderState} from '@app/components/channel/channel_view/useCallHeaderState';
|
||||
import {CameraPreviewModalInRoom} from '@app/components/modals/CameraPreviewModal';
|
||||
import {UserSettingsModal} from '@app/components/modals/UserSettingsModal';
|
||||
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
|
||||
import {Button} from '@app/components/uikit/button/Button';
|
||||
import {
|
||||
CameraOffIcon,
|
||||
CameraOnIcon,
|
||||
DeafenIcon,
|
||||
DisconnectCallIcon,
|
||||
MicrophoneOffIcon,
|
||||
MicrophoneOnIcon,
|
||||
SettingsIcon,
|
||||
UndeafenIcon,
|
||||
} from '@app/components/uikit/context_menu/ContextMenuIcons';
|
||||
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
|
||||
import {CompactVoiceCallView} from '@app/components/voice/CompactVoiceCallView';
|
||||
import {Logger} from '@app/lib/Logger';
|
||||
import {Routes} from '@app/Routes';
|
||||
import type {ChannelRecord} from '@app/records/ChannelRecord';
|
||||
import LocalVoiceStateStore from '@app/stores/LocalVoiceStateStore';
|
||||
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
|
||||
import MediaEngineStore from '@app/stores/voice/MediaEngineFacade';
|
||||
import * as ChannelUtils from '@app/utils/ChannelUtils';
|
||||
import {navigateToWithMobileHistory} from '@app/utils/MobileNavigation';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
|
||||
const logger = new Logger('DirectCallLobbyBottomSheet');
|
||||
|
||||
interface DirectCallLobbyBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
channel: ChannelRecord;
|
||||
}
|
||||
|
||||
export const DirectCallLobbyBottomSheet = observer(function DirectCallLobbyBottomSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
channel,
|
||||
}: DirectCallLobbyBottomSheetProps) {
|
||||
const {t} = useLingui();
|
||||
const callHeaderState = useCallHeaderState(channel);
|
||||
const voiceState = MediaEngineStore.getCurrentUserVoiceState(channel.guildId ?? null);
|
||||
const localSelfMute = LocalVoiceStateStore.selfMute;
|
||||
const localSelfDeaf = LocalVoiceStateStore.selfDeaf;
|
||||
const localSelfVideo = LocalVoiceStateStore.selfVideo;
|
||||
const currentLatency = MediaEngineStore.currentLatency;
|
||||
const voiceStats = MediaEngineStore.voiceStats;
|
||||
const voiceServerEndpoint = MediaEngineStore.voiceServerEndpoint;
|
||||
const connectionId = MediaEngineStore.connectionId;
|
||||
const isConnected = MediaEngineStore.connected && MediaEngineStore.channelId === channel.id;
|
||||
|
||||
const isMuted = voiceState ? voiceState.self_mute : localSelfMute;
|
||||
const isDeafened = voiceState ? voiceState.self_deaf : localSelfDeaf;
|
||||
const isCameraOn = voiceState ? Boolean(voiceState.self_video) : localSelfVideo;
|
||||
|
||||
const callStatusLabel = useMemo(() => {
|
||||
switch (callHeaderState.controlsVariant) {
|
||||
case 'incoming':
|
||||
return t`Incoming call`;
|
||||
case 'join':
|
||||
return t`Call available`;
|
||||
case 'connecting':
|
||||
return t`Join call`;
|
||||
case 'inCall':
|
||||
return callHeaderState.isDeviceInRoomForChannelCall ? t`In call` : t`In call on other device`;
|
||||
default:
|
||||
return t`Voice call`;
|
||||
}
|
||||
}, [callHeaderState.controlsVariant, callHeaderState.isDeviceInRoomForChannelCall, t]);
|
||||
|
||||
const handleToggleMute = useCallback(() => {
|
||||
VoiceStateActionCreators.toggleSelfMute(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleDeafen = useCallback(() => {
|
||||
VoiceStateActionCreators.toggleSelfDeaf(null);
|
||||
}, []);
|
||||
|
||||
const handleOpenVoiceSettings = useCallback(() => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <UserSettingsModal initialTab="voice_video" />));
|
||||
}, [onClose]);
|
||||
|
||||
const handleOpenCallView = useCallback(() => {
|
||||
onClose();
|
||||
const isMobile = MobileLayoutStore.isMobileLayout();
|
||||
navigateToWithMobileHistory(Routes.dmChannel(channel.id), isMobile);
|
||||
LayoutActionCreators.updateMobileLayoutState(false, true);
|
||||
}, [channel.id, onClose]);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
onClose();
|
||||
void CallActionCreators.leaveCall(channel.id);
|
||||
}, [channel.id, onClose]);
|
||||
|
||||
const handleRejectIncomingCall = useCallback(() => {
|
||||
CallActionCreators.rejectCall(channel.id);
|
||||
}, [channel.id]);
|
||||
|
||||
const handleIgnoreIncomingCall = useCallback(() => {
|
||||
CallActionCreators.ignoreCall(channel.id);
|
||||
}, [channel.id]);
|
||||
|
||||
const handleToggleCamera = useCallback(async () => {
|
||||
try {
|
||||
if (isCameraOn) {
|
||||
await MediaEngineStore.setCameraEnabled(false);
|
||||
} else {
|
||||
ModalActionCreators.push(modal(() => <CameraPreviewModalInRoom />));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to toggle camera:', err);
|
||||
}
|
||||
}, [isCameraOn]);
|
||||
|
||||
const handlePrimaryAction = useCallback(() => {
|
||||
switch (callHeaderState.controlsVariant) {
|
||||
case 'incoming':
|
||||
CallActionCreators.joinCall(channel.id);
|
||||
return;
|
||||
case 'join':
|
||||
CallActionCreators.joinCall(channel.id);
|
||||
return;
|
||||
case 'connecting':
|
||||
return;
|
||||
case 'inCall':
|
||||
if (!callHeaderState.isDeviceInRoomForChannelCall) {
|
||||
CallActionCreators.joinCall(channel.id);
|
||||
return;
|
||||
}
|
||||
handleOpenCallView();
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}, [callHeaderState.controlsVariant, callHeaderState.isDeviceInRoomForChannelCall, channel.id, handleOpenCallView]);
|
||||
|
||||
const primaryButtonLabel = useMemo(() => {
|
||||
switch (callHeaderState.controlsVariant) {
|
||||
case 'incoming':
|
||||
return t`Accept`;
|
||||
case 'join':
|
||||
return t`Join call`;
|
||||
case 'connecting':
|
||||
return t`Connecting...`;
|
||||
case 'inCall':
|
||||
return callHeaderState.isDeviceInRoomForChannelCall ? t`Open call view` : t`In call on other device (join?)`;
|
||||
default:
|
||||
return t`Voice call`;
|
||||
}
|
||||
}, [callHeaderState.controlsVariant, callHeaderState.isDeviceInRoomForChannelCall, t]);
|
||||
|
||||
const prettyEndpoint = useMemo(() => {
|
||||
if (!voiceServerEndpoint) return null;
|
||||
try {
|
||||
const url = new URL(voiceServerEndpoint);
|
||||
return url.port ? `${url.hostname}:${url.port}` : url.hostname;
|
||||
} catch {
|
||||
return voiceServerEndpoint;
|
||||
}
|
||||
}, [voiceServerEndpoint]);
|
||||
|
||||
const shouldShowControls = callHeaderState.controlsVariant !== 'hidden';
|
||||
const shouldShowDisconnect = callHeaderState.controlsVariant === 'inCall';
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (channel.name) return channel.name;
|
||||
const dmName = ChannelUtils.getDMDisplayName(channel);
|
||||
return dmName || callStatusLabel;
|
||||
}, [channel, callStatusLabel]);
|
||||
|
||||
const Row = useMemo(
|
||||
() =>
|
||||
observer(({label, value, valueClassName}: {label: string; value: React.ReactNode; valueClassName?: string}) => (
|
||||
<div className={styles.statRow}>
|
||||
<span className={styles.statLabel}>{label}</span>
|
||||
<div className={clsx(styles.statValue, valueClassName)}>{value}</div>
|
||||
</div>
|
||||
)),
|
||||
[],
|
||||
);
|
||||
|
||||
if (!shouldShowControls) return null;
|
||||
|
||||
return (
|
||||
<BottomSheet isOpen={isOpen} onClose={onClose} title={title} surface="primary" snapPoints={[0.35, 0.7, 0.95]}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.buttonRow}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handlePrimaryAction}
|
||||
className={styles.fullWidth}
|
||||
submitting={callHeaderState.controlsVariant === 'connecting'}
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
{callHeaderState.controlsVariant === 'incoming' && (
|
||||
<>
|
||||
<Button variant="danger-primary" onClick={handleRejectIncomingCall} className={styles.fullWidth}>
|
||||
{t`Reject`}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleIgnoreIncomingCall} className={styles.fullWidth}>
|
||||
{t`Ignore`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{shouldShowDisconnect && (
|
||||
<Button
|
||||
variant="danger-primary"
|
||||
onClick={handleDisconnect}
|
||||
leftIcon={<DisconnectCallIcon size={18} />}
|
||||
className={styles.fullWidth}
|
||||
>
|
||||
{t`Leave call`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.statusRow}>
|
||||
<span className={styles.statusLabel}>{callStatusLabel}</span>
|
||||
</div>
|
||||
|
||||
{callHeaderState.controlsVariant === 'inCall' && callHeaderState.isDeviceInRoomForChannelCall && (
|
||||
<div className={styles.callPreview}>
|
||||
<CompactVoiceCallView channel={channel} hideHeader={true} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actionButtons}>
|
||||
<button type="button" className={styles.actionButton} onClick={handleToggleMute}>
|
||||
<div
|
||||
className={clsx(styles.iconContainer, isMuted ? styles.iconContainerDanger : styles.iconContainerBrand)}
|
||||
>
|
||||
{isMuted ? (
|
||||
<MicrophoneOffIcon className={styles.actionIcon} size={24} />
|
||||
) : (
|
||||
<MicrophoneOnIcon className={styles.actionIcon} size={24} />
|
||||
)}
|
||||
</div>
|
||||
<span className={styles.actionText}>{isMuted ? t`Unmute` : t`Mute`}</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className={styles.actionButton} onClick={handleToggleDeafen}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.iconContainer,
|
||||
isDeafened ? styles.iconContainerDanger : styles.iconContainerTertiary,
|
||||
)}
|
||||
>
|
||||
{isDeafened ? (
|
||||
<DeafenIcon className={styles.actionIconSecondary} size={24} />
|
||||
) : (
|
||||
<UndeafenIcon className={styles.actionIconSecondary} size={24} />
|
||||
)}
|
||||
</div>
|
||||
<span className={styles.actionText}>{isDeafened ? t`Undeafen` : t`Deafen`}</span>
|
||||
</button>
|
||||
|
||||
{isConnected && (
|
||||
<button type="button" className={styles.actionButton} onClick={handleToggleCamera}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.iconContainer,
|
||||
isCameraOn ? styles.iconContainerSuccess : styles.iconContainerTertiary,
|
||||
)}
|
||||
>
|
||||
{isCameraOn ? (
|
||||
<CameraOnIcon className={styles.actionIcon} size={24} />
|
||||
) : (
|
||||
<CameraOffIcon className={styles.actionIconSecondary} size={24} />
|
||||
)}
|
||||
</div>
|
||||
<span className={styles.actionText}>{isCameraOn ? t`Camera On` : t`Camera Off`}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button type="button" className={styles.actionButton} onClick={handleOpenVoiceSettings}>
|
||||
<div className={clsx(styles.iconContainer, styles.iconContainerTertiary)}>
|
||||
<SettingsIcon className={styles.actionIconSecondary} size={24} />
|
||||
</div>
|
||||
<span className={styles.actionText}>{t`Settings`}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
<div className={styles.connectionInfo}>
|
||||
<div className={styles.connectionHeader}>
|
||||
<div className={styles.connectionStatusInfo}>
|
||||
<div className={styles.connectionTitle}>{t`Connected to call`}</div>
|
||||
<div className={styles.connectionSubtitle}>{t`You're in the call`}</div>
|
||||
</div>
|
||||
<div className={styles.connectionStatusDot} />
|
||||
</div>
|
||||
|
||||
<div className={styles.statsGrid}>
|
||||
{currentLatency !== null && (
|
||||
<Row label={t`Ping`} value={<span className={styles.statValuePrimary}>{currentLatency}ms</span>} />
|
||||
)}
|
||||
|
||||
{prettyEndpoint && (
|
||||
<Row
|
||||
label={t`Endpoint`}
|
||||
value={
|
||||
<Tooltip text={prettyEndpoint}>
|
||||
<span className={styles.endpointValue}>{prettyEndpoint}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
valueClassName={styles.maxWidth}
|
||||
/>
|
||||
)}
|
||||
|
||||
{connectionId && (
|
||||
<Row
|
||||
label={t`Connection ID`}
|
||||
value={
|
||||
<Tooltip text={connectionId}>
|
||||
<span className={styles.connectionIdValue}>{connectionId}</span>
|
||||
</Tooltip>
|
||||
}
|
||||
valueClassName={styles.maxWidth}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof voiceStats?.audioPacketLoss === 'number' && voiceStats.audioPacketLoss > 0 && (
|
||||
<Row
|
||||
label={t`Packet Loss`}
|
||||
value={<span className={styles.statValuePrimary}>{voiceStats.audioPacketLoss.toFixed(1)}%</span>}
|
||||
/>
|
||||
)}
|
||||
|
||||
{typeof voiceStats?.jitter === 'number' && voiceStats.jitter > 0 && (
|
||||
<Row
|
||||
label={t`Jitter`}
|
||||
value={<span className={styles.statValuePrimary}>{voiceStats.jitter.toFixed(1)}ms</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
);
|
||||
});
|
||||
@@ -17,17 +17,19 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/bottomsheets/EmojiInfoBottomSheet.module.css';
|
||||
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
|
||||
import UnicodeEmojis from '@app/lib/UnicodeEmojis';
|
||||
import EmojiStore from '@app/stores/EmojiStore';
|
||||
import GuildStore from '@app/stores/GuildStore';
|
||||
import * as AvatarUtils from '@app/utils/AvatarUtils';
|
||||
import * as EmojiUtils from '@app/utils/EmojiUtils';
|
||||
import {shouldUseNativeEmoji} from '@app/utils/EmojiUtils';
|
||||
import {setUrlQueryParams} from '@app/utils/UrlUtils';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
|
||||
import UnicodeEmojis from '~/lib/UnicodeEmojis';
|
||||
import EmojiStore from '~/stores/EmojiStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import * as AvatarUtils from '~/utils/AvatarUtils';
|
||||
import * as EmojiUtils from '~/utils/EmojiUtils';
|
||||
import {shouldUseNativeEmoji} from '~/utils/EmojiUtils';
|
||||
import styles from './EmojiInfoBottomSheet.module.css';
|
||||
import type React from 'react';
|
||||
import {useMemo} from 'react';
|
||||
|
||||
interface EmojiInfoData {
|
||||
id?: string;
|
||||
@@ -62,10 +64,10 @@ const EmojiInfoBottomSheetContent: React.FC<EmojiInfoBottomSheetContentProps> =
|
||||
const guildId = emojiRecord?.guildId;
|
||||
const guild = guildId ? GuildStore.getGuild(guildId) : null;
|
||||
|
||||
const emojiUrl = React.useMemo(() => {
|
||||
const emojiUrl = useMemo(() => {
|
||||
if (isCustomEmoji) {
|
||||
const url = AvatarUtils.getEmojiURL({id: emoji.id!, animated: emoji.animated ?? false});
|
||||
return `${url}?size=240&quality=lossless`;
|
||||
return setUrlQueryParams(url, {size: 240, quality: 'lossless'});
|
||||
}
|
||||
if (shouldUseNativeEmoji) {
|
||||
return null;
|
||||
|
||||
@@ -17,22 +17,27 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as FavoritesActionCreators from '@app/actions/FavoritesActionCreators';
|
||||
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
|
||||
import {modal} from '@app/actions/ModalActionCreators';
|
||||
import * as UserGuildSettingsActionCreators from '@app/actions/UserGuildSettingsActionCreators';
|
||||
import sharedStyles from '@app/components/bottomsheets/shared.module.css';
|
||||
import {AddFavoriteChannelModal} from '@app/components/modals/AddFavoriteChannelModal';
|
||||
import {CreateFavoriteCategoryModal} from '@app/components/modals/CreateFavoriteCategoryModal';
|
||||
import {
|
||||
CreateCategoryIcon,
|
||||
CreateChannelIcon,
|
||||
HideIcon,
|
||||
MuteIcon,
|
||||
} from '@app/components/uikit/context_menu/ContextMenuIcons';
|
||||
import type {MenuGroupType} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet';
|
||||
import {MenuBottomSheet} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet';
|
||||
import FavoritesStore from '@app/stores/FavoritesStore';
|
||||
import UserGuildSettingsStore from '@app/stores/UserGuildSettingsStore';
|
||||
import {FAVORITES_GUILD_ID} from '@fluxer/constants/src/AppConstants';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {BellIcon, BellSlashIcon, EyeSlashIcon, FolderPlusIcon, PlusCircleIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as FavoritesActionCreators from '~/actions/FavoritesActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
|
||||
import {FAVORITES_GUILD_ID} from '~/Constants';
|
||||
import {AddFavoriteChannelModal} from '~/components/modals/AddFavoriteChannelModal';
|
||||
import {CreateFavoriteCategoryModal} from '~/components/modals/CreateFavoriteCategoryModal';
|
||||
import type {MenuGroupType} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
|
||||
import {MenuBottomSheet} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
|
||||
import FavoritesStore from '~/stores/FavoritesStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
import sharedStyles from './shared.module.css';
|
||||
|
||||
interface FavoritesGuildHeaderBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
@@ -74,12 +79,12 @@ export const FavoritesGuildHeaderBottomSheet: React.FC<FavoritesGuildHeaderBotto
|
||||
{
|
||||
items: [
|
||||
{
|
||||
icon: <PlusCircleIcon weight="fill" className={sharedStyles.icon} />,
|
||||
icon: <CreateChannelIcon className={sharedStyles.icon} />,
|
||||
label: t`Add Channel`,
|
||||
onClick: handleAddChannel,
|
||||
},
|
||||
{
|
||||
icon: <FolderPlusIcon weight="fill" className={sharedStyles.icon} />,
|
||||
icon: <CreateCategoryIcon className={sharedStyles.icon} />,
|
||||
label: t`Create Category`,
|
||||
onClick: handleCreateCategory,
|
||||
},
|
||||
@@ -88,11 +93,7 @@ export const FavoritesGuildHeaderBottomSheet: React.FC<FavoritesGuildHeaderBotto
|
||||
{
|
||||
items: [
|
||||
{
|
||||
icon: isMuted ? (
|
||||
<BellIcon weight="fill" className={sharedStyles.icon} />
|
||||
) : (
|
||||
<BellSlashIcon weight="fill" className={sharedStyles.icon} />
|
||||
),
|
||||
icon: <MuteIcon className={sharedStyles.icon} />,
|
||||
label: isMuted ? t`Unmute Favorites` : t`Mute Favorites`,
|
||||
onClick: handleToggleMuteFavorites,
|
||||
},
|
||||
@@ -106,7 +107,7 @@ export const FavoritesGuildHeaderBottomSheet: React.FC<FavoritesGuildHeaderBotto
|
||||
{
|
||||
items: [
|
||||
{
|
||||
icon: <EyeSlashIcon weight="fill" className={sharedStyles.icon} />,
|
||||
icon: <HideIcon className={sharedStyles.icon} />,
|
||||
label: t`Hide Favorites`,
|
||||
onClick: handleHideFavorites,
|
||||
danger: true,
|
||||
|
||||
@@ -17,55 +17,20 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {
|
||||
BellIcon,
|
||||
BellSlashIcon,
|
||||
BookOpenIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FolderPlusIcon,
|
||||
GearIcon,
|
||||
PlusCircleIcon,
|
||||
ShieldIcon,
|
||||
SignOutIcon,
|
||||
UserCircleIcon,
|
||||
UserPlusIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import headerStyles from '@app/components/bottomsheets/GuildHeaderBottomSheet.module.css';
|
||||
import {MuteDurationSheet} from '@app/components/bottomsheets/MuteDurationSheet';
|
||||
import {GuildIcon} from '@app/components/popouts/GuildIcon';
|
||||
import {useGuildMenuData} from '@app/components/uikit/context_menu/items/GuildMenuData';
|
||||
import {MenuBottomSheet} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet';
|
||||
import {useMuteSheet} from '@app/hooks/useMuteSheet';
|
||||
import type {GuildRecord} from '@app/records/GuildRecord';
|
||||
import GuildMemberStore from '@app/stores/GuildMemberStore';
|
||||
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
|
||||
import PresenceStore from '@app/stores/PresenceStore';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import * as ReadStateActionCreators from '~/actions/ReadStateActionCreators';
|
||||
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
|
||||
import {Permissions} from '~/Constants';
|
||||
import {CategoryCreateModal} from '~/components/modals/CategoryCreateModal';
|
||||
import {ChannelCreateModal} from '~/components/modals/ChannelCreateModal';
|
||||
import {GuildNotificationSettingsModal} from '~/components/modals/GuildNotificationSettingsModal';
|
||||
import {GuildPrivacySettingsModal} from '~/components/modals/GuildPrivacySettingsModal';
|
||||
import {GuildSettingsModal} from '~/components/modals/GuildSettingsModal';
|
||||
import {InviteModal} from '~/components/modals/InviteModal';
|
||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||
import type {MenuGroupType} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
|
||||
import {MenuBottomSheet} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
|
||||
import * as Sheet from '~/components/uikit/Sheet/Sheet';
|
||||
import {useLeaveGuild} from '~/hooks/useLeaveGuild';
|
||||
import type {GuildRecord} from '~/records/GuildRecord';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import GuildMemberStore from '~/stores/GuildMemberStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import PresenceStore from '~/stores/PresenceStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
import UserSettingsStore from '~/stores/UserSettingsStore';
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import {getMutedText} from '~/utils/ContextMenuUtils';
|
||||
import * as InviteUtils from '~/utils/InviteUtils';
|
||||
import styles from './ChannelDetailsBottomSheet.module.css';
|
||||
import headerStyles from './GuildHeaderBottomSheet.module.css';
|
||||
import sharedStyles from './shared.module.css';
|
||||
import type React from 'react';
|
||||
import {useEffect} from 'react';
|
||||
|
||||
interface GuildHeaderBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
@@ -74,217 +39,21 @@ interface GuildHeaderBottomSheetProps {
|
||||
}
|
||||
|
||||
export const GuildHeaderBottomSheet: React.FC<GuildHeaderBottomSheetProps> = observer(({isOpen, onClose, guild}) => {
|
||||
const {t, i18n} = useLingui();
|
||||
UserSettingsStore;
|
||||
const leaveGuild = useLeaveGuild();
|
||||
const [muteSheetOpen, setMuteSheetOpen] = React.useState(false);
|
||||
const {t} = useLingui();
|
||||
|
||||
const canManageGuild = PermissionStore.can(Permissions.MANAGE_GUILD, {guildId: guild.id});
|
||||
const canManageChannels = PermissionStore.can(Permissions.MANAGE_CHANNELS, {guildId: guild.id});
|
||||
const invitableChannelId = InviteUtils.getInvitableChannelId(guild.id);
|
||||
const canInvite = InviteUtils.canInviteToChannel(invitableChannelId, guild.id);
|
||||
const canManageRoles = PermissionStore.can(Permissions.MANAGE_ROLES, {guildId: guild.id});
|
||||
const canViewAuditLog = PermissionStore.can(Permissions.VIEW_AUDIT_LOG, {guildId: guild.id});
|
||||
const canManageWebhooks = PermissionStore.can(Permissions.MANAGE_WEBHOOKS, {guildId: guild.id});
|
||||
const canManageEmojis = PermissionStore.can(Permissions.MANAGE_EXPRESSIONS, {guildId: guild.id});
|
||||
const canBanMembers = PermissionStore.can(Permissions.BAN_MEMBERS, {guildId: guild.id});
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
GatewayConnectionStore.syncGuildIfNeeded(guild.id, 'guild-header-bottom-sheet');
|
||||
}, [guild.id, isOpen]);
|
||||
|
||||
const canAccessGuildSettings =
|
||||
canManageGuild || canManageRoles || canViewAuditLog || canManageWebhooks || canManageEmojis || canBanMembers;
|
||||
|
||||
const settings = UserGuildSettingsStore.getSettings(guild.id);
|
||||
const hideMutedChannels = settings?.hide_muted_channels ?? false;
|
||||
const isMuted = settings?.muted ?? false;
|
||||
const muteConfig = settings?.mute_config;
|
||||
const mutedText = getMutedText(isMuted, muteConfig);
|
||||
|
||||
const handleToggleHideMutedChannels = (checked: boolean) => {
|
||||
const currentSettings = UserGuildSettingsStore.getSettings(guild.id);
|
||||
const currentValue = currentSettings?.hide_muted_channels ?? false;
|
||||
if (checked === currentValue) return;
|
||||
UserGuildSettingsActionCreators.toggleHideMutedChannels(guild.id);
|
||||
};
|
||||
|
||||
const handleOpenMuteSheet = () => {
|
||||
setMuteSheetOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseMuteSheet = () => {
|
||||
setMuteSheetOpen(false);
|
||||
};
|
||||
|
||||
const handleInviteMembers = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <InviteModal channelId={invitableChannelId ?? ''} />));
|
||||
};
|
||||
|
||||
const handleCommunitySettings = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <GuildSettingsModal guildId={guild.id} />));
|
||||
};
|
||||
|
||||
const handleCreateChannel = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <ChannelCreateModal guildId={guild.id} />));
|
||||
};
|
||||
|
||||
const handleCreateCategory = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <CategoryCreateModal guildId={guild.id} />));
|
||||
};
|
||||
|
||||
const handleNotificationSettings = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <GuildNotificationSettingsModal guildId={guild.id} />));
|
||||
};
|
||||
|
||||
const handlePrivacySettings = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <GuildPrivacySettingsModal guildId={guild.id} />));
|
||||
};
|
||||
|
||||
const handleEditCommunityProfile = () => {
|
||||
onClose();
|
||||
ModalActionCreators.push(modal(() => <UserSettingsModal initialGuildId={guild.id} initialTab="my_profile" />));
|
||||
};
|
||||
|
||||
const handleLeaveCommunity = () => {
|
||||
onClose();
|
||||
leaveGuild(guild.id);
|
||||
};
|
||||
|
||||
const handleCopyGuildId = () => {
|
||||
void TextCopyActionCreators.copy(i18n, guild.id);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const channels = ChannelStore.getGuildChannels(guild.id);
|
||||
const hasGuildUnread = React.useMemo(() => channels.some((channel) => ReadStateStore.hasUnread(channel.id)), [channels]);
|
||||
|
||||
const handleMarkAsRead = React.useCallback(() => {
|
||||
const channelIds = channels
|
||||
.filter((channel) => ReadStateStore.getUnreadCount(channel.id) > 0)
|
||||
.map((channel) => channel.id);
|
||||
|
||||
if (channelIds.length > 0) {
|
||||
void ReadStateActionCreators.bulkAckChannels(channelIds);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [channels, onClose]);
|
||||
|
||||
const menuGroups: Array<MenuGroupType> = [];
|
||||
|
||||
const quickActions = [];
|
||||
|
||||
if (hasGuildUnread) {
|
||||
quickActions.push({
|
||||
icon: <BookOpenIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Mark as Read`,
|
||||
onClick: handleMarkAsRead,
|
||||
});
|
||||
}
|
||||
|
||||
if (canInvite) {
|
||||
quickActions.push({
|
||||
icon: <UserPlusIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Invite Members`,
|
||||
onClick: handleInviteMembers,
|
||||
});
|
||||
}
|
||||
|
||||
if (canAccessGuildSettings) {
|
||||
quickActions.push({
|
||||
icon: <GearIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Community Settings`,
|
||||
onClick: handleCommunitySettings,
|
||||
});
|
||||
}
|
||||
|
||||
if (canManageChannels) {
|
||||
quickActions.push({
|
||||
icon: <PlusCircleIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Create Channel`,
|
||||
onClick: handleCreateChannel,
|
||||
});
|
||||
quickActions.push({
|
||||
icon: <FolderPlusIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Create Category`,
|
||||
onClick: handleCreateCategory,
|
||||
});
|
||||
}
|
||||
|
||||
if (quickActions.length > 0) {
|
||||
menuGroups.push({
|
||||
items: quickActions,
|
||||
});
|
||||
}
|
||||
|
||||
const settingsItems = [
|
||||
{
|
||||
icon: <BellIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Notification Settings`,
|
||||
onClick: handleNotificationSettings,
|
||||
},
|
||||
{
|
||||
icon: <ShieldIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Privacy Settings`,
|
||||
onClick: handlePrivacySettings,
|
||||
},
|
||||
{
|
||||
icon: <UserCircleIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Edit Community Profile`,
|
||||
onClick: handleEditCommunityProfile,
|
||||
},
|
||||
];
|
||||
|
||||
menuGroups.push({
|
||||
items: settingsItems,
|
||||
const {muteSheetOpen, muteConfig, openMuteSheet, closeMuteSheet, handleMute, handleUnmute} = useMuteSheet({
|
||||
mode: 'guild',
|
||||
guildId: guild.id,
|
||||
});
|
||||
|
||||
const muteItem = {
|
||||
icon: isMuted ? (
|
||||
<BellIcon weight="fill" className={sharedStyles.icon} />
|
||||
) : (
|
||||
<BellSlashIcon weight="fill" className={sharedStyles.icon} />
|
||||
),
|
||||
label: isMuted ? t`Unmute Community` : t`Mute Community`,
|
||||
onClick: handleOpenMuteSheet,
|
||||
};
|
||||
|
||||
const hideMutedChannelsItem = {
|
||||
label: t`Hide Muted Channels`,
|
||||
checked: hideMutedChannels,
|
||||
onChange: handleToggleHideMutedChannels,
|
||||
};
|
||||
|
||||
menuGroups.push({
|
||||
items: [muteItem, hideMutedChannelsItem],
|
||||
});
|
||||
|
||||
if (!guild.isOwner(AuthenticationStore.currentUserId)) {
|
||||
menuGroups.push({
|
||||
items: [
|
||||
{
|
||||
icon: <SignOutIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Leave Community`,
|
||||
onClick: handleLeaveCommunity,
|
||||
danger: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const utilityItems = [
|
||||
{
|
||||
icon: <CopyIcon weight="fill" className={sharedStyles.icon} />,
|
||||
label: t`Copy Guild ID`,
|
||||
onClick: handleCopyGuildId,
|
||||
},
|
||||
];
|
||||
|
||||
menuGroups.push({
|
||||
items: utilityItems,
|
||||
const {groups, isMuted, mutedText} = useGuildMenuData(guild, {
|
||||
onClose,
|
||||
onOpenMuteSheet: openMuteSheet,
|
||||
});
|
||||
|
||||
const presenceCount = PresenceStore.getPresenceCount(guild.id);
|
||||
@@ -315,89 +84,19 @@ export const GuildHeaderBottomSheet: React.FC<GuildHeaderBottomSheetProps> = obs
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuBottomSheet isOpen={isOpen} onClose={onClose} groups={menuGroups} headerContent={headerContent} />
|
||||
<MenuBottomSheet isOpen={isOpen} onClose={onClose} groups={groups} headerContent={headerContent} />
|
||||
|
||||
<Sheet.Root isOpen={muteSheetOpen} onClose={handleCloseMuteSheet} snapPoints={[0, 1]} initialSnap={1}>
|
||||
<Sheet.Handle />
|
||||
<Sheet.Header trailing={<Sheet.CloseButton onClick={handleCloseMuteSheet} />}>
|
||||
<Sheet.Title>{isMuted ? t`Unmute Community` : t`Mute Community`}</Sheet.Title>
|
||||
</Sheet.Header>
|
||||
<Sheet.Content padding="none">
|
||||
<div className={styles.muteSheetContainer}>
|
||||
<div className={styles.muteSheetContent}>
|
||||
{isMuted && mutedText ? (
|
||||
<>
|
||||
<div className={styles.muteStatusBanner}>
|
||||
<p className={styles.muteStatusText}>
|
||||
<Trans>Currently: {mutedText}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.muteOptionsContainer}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
UserGuildSettingsActionCreators.updateGuildSettings(guild.id, {
|
||||
muted: false,
|
||||
mute_config: null,
|
||||
});
|
||||
handleCloseMuteSheet();
|
||||
}}
|
||||
className={styles.muteOptionButton}
|
||||
>
|
||||
<span className={styles.muteOptionLabel}>
|
||||
<Trans>Unmute</Trans>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muteOptionsContainer}>
|
||||
{[
|
||||
{label: t`For 15 Minutes`, value: 15 * 60 * 1000},
|
||||
{label: t`For 1 Hour`, value: 60 * 60 * 1000},
|
||||
{label: t`For 3 Hours`, value: 3 * 60 * 60 * 1000},
|
||||
{label: t`For 8 Hours`, value: 8 * 60 * 60 * 1000},
|
||||
{label: t`For 24 Hours`, value: 24 * 60 * 60 * 1000},
|
||||
{label: t`Until I turn it back on`, value: null},
|
||||
].map((option, index, array) => {
|
||||
const isSelected =
|
||||
isMuted &&
|
||||
((option.value === null && !muteConfig?.end_time) ||
|
||||
(option.value !== null && muteConfig?.selected_time_window === option.value));
|
||||
|
||||
return (
|
||||
<React.Fragment key={option.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newMuteConfig = option.value
|
||||
? {
|
||||
selected_time_window: option.value,
|
||||
end_time: new Date(Date.now() + option.value).toISOString(),
|
||||
}
|
||||
: null;
|
||||
|
||||
UserGuildSettingsActionCreators.updateGuildSettings(guild.id, {
|
||||
muted: true,
|
||||
mute_config: newMuteConfig,
|
||||
});
|
||||
handleCloseMuteSheet();
|
||||
}}
|
||||
className={styles.muteOptionButton}
|
||||
>
|
||||
<span className={styles.muteOptionLabel}>{option.label}</span>
|
||||
{isSelected && <CheckIcon className={styles.iconMedium} weight="bold" />}
|
||||
</button>
|
||||
{index < array.length - 1 && <div className={styles.muteOptionDivider} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
<MuteDurationSheet
|
||||
isOpen={muteSheetOpen}
|
||||
onClose={closeMuteSheet}
|
||||
isMuted={isMuted}
|
||||
mutedText={mutedText ?? null}
|
||||
muteConfig={muteConfig}
|
||||
muteTitle={t`Mute Community`}
|
||||
unmuteTitle={t`Unmute Community`}
|
||||
onMute={handleMute}
|
||||
onUnmute={handleUnmute}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
110
fluxer_app/src/components/bottomsheets/MuteDurationSheet.tsx
Normal file
110
fluxer_app/src/components/bottomsheets/MuteDurationSheet.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import styles from '@app/components/bottomsheets/ChannelDetailsBottomSheet.module.css';
|
||||
import {getMuteDurationOptions} from '@app/components/channel/MuteOptions';
|
||||
import {CheckIcon} from '@app/components/uikit/context_menu/ContextMenuIcons';
|
||||
import * as Sheet from '@app/components/uikit/sheet/Sheet';
|
||||
import type {MuteConfig} from '@app/records/UserGuildSettingsRecord';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React, {useCallback} from 'react';
|
||||
|
||||
interface MuteDurationSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isMuted: boolean;
|
||||
mutedText: string | null | undefined;
|
||||
muteConfig: MuteConfig | null | undefined;
|
||||
muteTitle: string;
|
||||
unmuteTitle: string;
|
||||
onMute: (duration: number | null) => void;
|
||||
onUnmute: () => void;
|
||||
}
|
||||
|
||||
export const MuteDurationSheet: React.FC<MuteDurationSheetProps> = observer(
|
||||
({isOpen, onClose, isMuted, mutedText, muteConfig, muteTitle, unmuteTitle, onMute, onUnmute}) => {
|
||||
const {i18n} = useLingui();
|
||||
|
||||
const handleMuteDuration = useCallback(
|
||||
(duration: number | null) => {
|
||||
onMute(duration);
|
||||
},
|
||||
[onMute],
|
||||
);
|
||||
|
||||
const handleUnmute = useCallback(() => {
|
||||
onUnmute();
|
||||
}, [onUnmute]);
|
||||
|
||||
return (
|
||||
<Sheet.Root isOpen={isOpen} onClose={onClose} snapPoints={[0, 1]} initialSnap={1}>
|
||||
<Sheet.Handle />
|
||||
<Sheet.Header trailing={<Sheet.CloseButton onClick={onClose} />}>
|
||||
<Sheet.Title>{isMuted ? unmuteTitle : muteTitle}</Sheet.Title>
|
||||
</Sheet.Header>
|
||||
<Sheet.Content padding="none">
|
||||
<div className={styles.muteSheetContainer}>
|
||||
<div className={styles.muteSheetContent}>
|
||||
{isMuted && mutedText ? (
|
||||
<>
|
||||
<div className={styles.muteStatusBanner}>
|
||||
<p className={styles.muteStatusText}>
|
||||
<Trans>Currently: {mutedText}</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.muteOptionsContainer}>
|
||||
<button type="button" onClick={handleUnmute} className={styles.muteOptionButton}>
|
||||
<span className={styles.muteOptionLabel}>
|
||||
<Trans>Unmute</Trans>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.muteOptionsContainer}>
|
||||
{getMuteDurationOptions(i18n).map((option, index, array) => {
|
||||
const isSelected =
|
||||
isMuted &&
|
||||
((option.value === null && !muteConfig?.end_time) ||
|
||||
(option.value !== null && muteConfig?.selected_time_window === option.value));
|
||||
|
||||
return (
|
||||
<React.Fragment key={option.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMuteDuration(option.value)}
|
||||
className={styles.muteOptionButton}
|
||||
>
|
||||
<span className={styles.muteOptionLabel}>{option.label}</span>
|
||||
{isSelected && <CheckIcon className={styles.iconMedium} />}
|
||||
</button>
|
||||
{index < array.length - 1 && <div className={styles.muteOptionDivider} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Sheet.Content>
|
||||
</Sheet.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user