refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -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) {

View File

@@ -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();
}, []);

View File

@@ -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"

View File

@@ -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();

View File

@@ -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();
}, []);

View File

@@ -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`)

View File

@@ -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;

View File

@@ -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>

View File

@@ -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) => (

View File

@@ -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,
],
);

View File

@@ -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();

View File

@@ -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();

View File

@@ -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.`}
/>
);

View File

@@ -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.`} />
);
});

View File

@@ -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();

View File

@@ -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`}
/>
);

View File

@@ -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;

View File

@@ -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.`}
/>
);

View File

@@ -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} />;
});

View File

@@ -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} />;
});

View File

@@ -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();

View File

@@ -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.`} />
);
});

View File

@@ -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.`}
/>
);

View File

@@ -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();

View File

@@ -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.`} />
);
});

View File

@@ -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`}
/>
);

View File

@@ -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`}
/>
);

View File

@@ -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={() => {}}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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;

View File

@@ -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.`}
/>
);

View File

@@ -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();

View File

@@ -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;

View File

@@ -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();

View File

@@ -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();

View File

@@ -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';

View File

@@ -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`;

View File

@@ -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();

View File

@@ -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.`} />
);
});

View File

@@ -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.

View File

@@ -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();

View File

@@ -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.`}
/>
);

View File

@@ -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"

View File

@@ -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={() => {}}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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={() => {}}
/>

View File

@@ -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();

View File

@@ -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={() => {}}
/>
);
});

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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}>

View File

@@ -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,

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 />

View File

@@ -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};

View File

@@ -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}>

View File

@@ -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}>

View File

@@ -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;

View File

@@ -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}>

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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}

View File

@@ -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);

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;
}

View 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>
);
});

View File

@@ -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>

View File

@@ -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>
) : (

View File

@@ -82,7 +82,6 @@
cursor: pointer;
}
.footerButton:hover,
.footerButton:focus {
.footerButton:hover {
color: var(--text-primary);
}

View File

@@ -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;

View File

@@ -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?`}

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>(() => {

View File

@@ -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}
/>
</>
);
});

View File

@@ -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>
</>
);
});

View File

@@ -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%;

View File

@@ -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}) => {

View File

@@ -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}

View File

@@ -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 ? (

View File

@@ -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}
/>
</>
);
});

View File

@@ -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%;
}

View File

@@ -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>
);
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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}
/>
</>
);
});

View 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