initial commit
This commit is contained in:
145
fluxer_app/src/components/AppBadge.tsx
Normal file
145
fluxer_app/src/components/AppBadge.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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 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;
|
||||
|
||||
const initFavico = (): Favico | null => {
|
||||
if (favico) return favico;
|
||||
|
||||
try {
|
||||
favico = new Favico({animation: 'none'});
|
||||
return favico;
|
||||
} catch (e) {
|
||||
logger.warn('Failed to initialize Favico', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const setElectronBadge = (badge: BadgeValue): void => {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi) return;
|
||||
|
||||
const electronBadge = badge > 0 ? badge : 0;
|
||||
try {
|
||||
electronApi.setBadgeCount(electronBadge);
|
||||
} catch (e) {
|
||||
logger.warn('Failed to set Electron badge', e);
|
||||
}
|
||||
};
|
||||
|
||||
const setFaviconBadge = (badge: BadgeValue): void => {
|
||||
const fav = initFavico();
|
||||
if (!fav) return;
|
||||
|
||||
try {
|
||||
if (badge === UNREAD_INDICATOR) {
|
||||
fav.badge('•');
|
||||
} else {
|
||||
fav.badge(badge);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to set favicon badge', e);
|
||||
}
|
||||
};
|
||||
|
||||
const setPwaBadge = (badge: BadgeValue): void => {
|
||||
if (!navigator.setAppBadge || !navigator.clearAppBadge) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (badge > 0) {
|
||||
void navigator.setAppBadge(badge);
|
||||
} else if (badge === UNREAD_INDICATOR) {
|
||||
void navigator.setAppBadge();
|
||||
} else {
|
||||
void navigator.clearAppBadge();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn('Failed to set PWA badge', e);
|
||||
}
|
||||
};
|
||||
|
||||
const setBadge = (badge: BadgeValue): void => {
|
||||
setElectronBadge(badge);
|
||||
setFaviconBadge(badge);
|
||||
setPwaBadge(badge);
|
||||
};
|
||||
|
||||
export const AppBadge: React.FC = observer(() => {
|
||||
const relationships = RelationshipStore.getRelationships();
|
||||
const unreadMessageBadgeEnabled = NotificationStore.unreadMessageBadgeEnabled;
|
||||
|
||||
const mentionCount = GuildReadStateStore.getTotalMentionCount();
|
||||
const hasUnread = GuildReadStateStore.hasAnyUnread;
|
||||
|
||||
const pendingCount = relationships.filter(
|
||||
(relationship) => relationship.type === RelationshipTypes.INCOMING_REQUEST,
|
||||
).length;
|
||||
|
||||
const totalCount = mentionCount + pendingCount;
|
||||
|
||||
let badge: BadgeValue = 0;
|
||||
if (totalCount > 0) {
|
||||
badge = totalCount;
|
||||
} else if (hasUnread && unreadMessageBadgeEnabled) {
|
||||
badge = UNREAD_INDICATOR;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setBadge(badge);
|
||||
}, [badge]);
|
||||
|
||||
useEffect(() => {
|
||||
updateDocumentTitleBadge(totalCount, hasUnread && unreadMessageBadgeEnabled);
|
||||
}, [totalCount, hasUnread, unreadMessageBadgeEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setBadge(0);
|
||||
updateDocumentTitleBadge(0, false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
});
|
||||
76
fluxer_app/src/components/BootstrapErrorScreen.tsx
Normal file
76
fluxer_app/src/components/BootstrapErrorScreen.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
interface BootstrapErrorScreenProps {
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export const BootstrapErrorScreen: React.FC<BootstrapErrorScreenProps> = ({error}) => {
|
||||
const handleRetry = React.useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
AppStorage.clear();
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.errorFallbackContainer}>
|
||||
<FluxerIcon className={styles.errorFallbackIcon} />
|
||||
<div className={styles.errorFallbackContent}>
|
||||
<h1 className={styles.errorFallbackTitle}>
|
||||
<Trans>Failed to Start</Trans>
|
||||
</h1>
|
||||
<p className={styles.errorFallbackDescription}>
|
||||
<Trans>Fluxer failed to start properly. This could be due to corrupted data or a temporary issue.</Trans>
|
||||
</p>
|
||||
{error && (
|
||||
<p className={styles.errorFallbackDescription} style={{fontSize: '0.875rem', opacity: 0.8}}>
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<p className={styles.errorFallbackDescription}>
|
||||
<Trans>
|
||||
Check our{' '}
|
||||
<a href="https://bsky.app/profile/fluxer.app" target="_blank" rel="noopener noreferrer">
|
||||
Bluesky (@fluxer.app)
|
||||
</a>{' '}
|
||||
for status updates.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions}>
|
||||
<Button onClick={handleRetry}>
|
||||
<Trans>Try Again</Trans>
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="danger-primary">
|
||||
<Trans>Reset App Data</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
70
fluxer_app/src/components/ErrorFallback.module.css
Normal file
70
fluxer_app/src/components/ErrorFallback.module.css
Normal file
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.errorFallbackContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-8);
|
||||
height: 100vh;
|
||||
padding: var(--spacing-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorFallbackIcon {
|
||||
height: 6rem;
|
||||
width: 6rem;
|
||||
}
|
||||
|
||||
.errorFallbackContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.errorFallbackTitle {
|
||||
font-weight: 600;
|
||||
font-size: 1.875rem;
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
|
||||
.errorFallbackDescription {
|
||||
max-width: 21rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.errorFallbackActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.errorFallbackCopyAction {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.errorFallbackActions {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
125
fluxer_app/src/components/ErrorFallback.tsx
Normal file
125
fluxer_app/src/components/ErrorFallback.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
interface ErrorFallbackProps {
|
||||
error?: Error;
|
||||
reset?: () => void;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const {updateFound} = await ensureLatestAssets({force: true});
|
||||
if (isMounted) {
|
||||
setUpdateAvailable(updateFound);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ErrorFallback] Failed to check for updates:', error);
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setCheckingForUpdates(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUpdate = React.useCallback(async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const {updateFound} = await ensureLatestAssets({force: true});
|
||||
if (!updateFound) {
|
||||
setIsUpdating(false);
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[ErrorFallback] Failed to apply update:', error);
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={errorFallbackStyles.errorFallbackContainer}>
|
||||
{isNative && !isMacOS && <NativeTitlebar platform={platform} />}
|
||||
<FluxerIcon className={errorFallbackStyles.errorFallbackIcon} />
|
||||
<div className={errorFallbackStyles.errorFallbackContent}>
|
||||
<h1 className={errorFallbackStyles.errorFallbackTitle}>
|
||||
<Trans>Whoa, this is heavy.</Trans>
|
||||
</h1>
|
||||
<p className={errorFallbackStyles.errorFallbackDescription}>
|
||||
{checkingForUpdates ? (
|
||||
<Trans>The app has crashed. Checking for updates that might fix this issue...</Trans>
|
||||
) : updateAvailable ? (
|
||||
<Trans>Something went wrong and the app crashed. An update is available that may fix this issue.</Trans>
|
||||
) : (
|
||||
<Trans>Something went wrong and the app crashed. Try reloading or resetting the app.</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={errorFallbackStyles.errorFallbackActions}>
|
||||
<Button
|
||||
onClick={updateAvailable ? handleUpdate : () => location.reload()}
|
||||
disabled={checkingForUpdates || isUpdating}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<Trans>Updating...</Trans>
|
||||
) : checkingForUpdates || updateAvailable ? (
|
||||
<Trans>Update app</Trans>
|
||||
) : (
|
||||
<Trans>Reload app</Trans>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
AppStorage.clear();
|
||||
location.reload();
|
||||
}}
|
||||
variant="danger-primary"
|
||||
disabled={checkingForUpdates}
|
||||
>
|
||||
<Trans>Reset app data</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
317
fluxer_app/src/components/LongPressable.tsx
Normal file
317
fluxer_app/src/components/LongPressable.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
|
||||
export const LONG_PRESS_DURATION_MS = 500;
|
||||
|
||||
const LONG_PRESS_MOVEMENT_THRESHOLD = 10;
|
||||
|
||||
const SWIPE_VELOCITY_THRESHOLD = 0.4;
|
||||
|
||||
const MIN_VELOCITY_SAMPLES = 2;
|
||||
|
||||
const MAX_VELOCITY_SAMPLE_AGE = 100;
|
||||
|
||||
const PRESS_HIGHLIGHT_DELAY_MS = 100;
|
||||
|
||||
const HAS_POINTER_EVENTS = 'PointerEvent' in window;
|
||||
|
||||
interface VelocitySample {
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type LongPressEvent = React.PointerEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>;
|
||||
|
||||
interface LongPressableProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
delay?: number;
|
||||
onLongPress?: (event: LongPressEvent) => void;
|
||||
disabled?: boolean;
|
||||
pressedClassName?: string;
|
||||
onPressStateChange?: (isPressed: boolean) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
React.useImperativeHandle(forwardedRef, () => innerRef.current as HTMLDivElement);
|
||||
|
||||
const setPressed = React.useCallback(
|
||||
(pressed: boolean) => {
|
||||
setIsPressed(pressed);
|
||||
onPressStateChange?.(pressed);
|
||||
},
|
||||
[onPressStateChange],
|
||||
);
|
||||
|
||||
const calculateVelocity = React.useCallback((): number => {
|
||||
const samples = velocitySamples.current;
|
||||
if (samples.length < MIN_VELOCITY_SAMPLES) return 0;
|
||||
|
||||
const now = performance.now();
|
||||
const recentSamples = samples.filter((s) => now - s.timestamp < MAX_VELOCITY_SAMPLE_AGE);
|
||||
if (recentSamples.length < MIN_VELOCITY_SAMPLES) return 0;
|
||||
|
||||
const first = recentSamples[0];
|
||||
const last = recentSamples[recentSamples.length - 1];
|
||||
const dt = last.timestamp - first.timestamp;
|
||||
if (dt === 0) return 0;
|
||||
|
||||
const dx = last.x - first.x;
|
||||
const dy = last.y - first.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
return distance / dt;
|
||||
}, []);
|
||||
|
||||
const clearTimer = React.useCallback(() => {
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
longPressTimer.current = null;
|
||||
}
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
highlightTimer.current = null;
|
||||
}
|
||||
if (pointerIdRef.current !== null && innerRef.current?.releasePointerCapture) {
|
||||
try {
|
||||
innerRef.current.releasePointerCapture(pointerIdRef.current);
|
||||
} catch {}
|
||||
}
|
||||
pointerIdRef.current = null;
|
||||
pressStartPos.current = null;
|
||||
storedEvent.current = null;
|
||||
velocitySamples.current = [];
|
||||
isPressIntent.current = false;
|
||||
setPressed(false);
|
||||
}, [setPressed]);
|
||||
|
||||
const {
|
||||
onPointerDown: userOnPointerDown,
|
||||
onPointerMove: userOnPointerMove,
|
||||
onPointerUp: userOnPointerUp,
|
||||
onPointerCancel: userOnPointerCancel,
|
||||
onTouchStart: userOnTouchStart,
|
||||
onTouchMove: userOnTouchMove,
|
||||
onTouchEnd: userOnTouchEnd,
|
||||
onTouchCancel: userOnTouchCancel,
|
||||
className,
|
||||
...restWithoutPointer
|
||||
} = rest;
|
||||
|
||||
const startLongPressTimer = React.useCallback(
|
||||
(event: LongPressEvent, x: number, y: number, pointerId?: number, capturePointer = false) => {
|
||||
if (disabled || !onLongPress) return;
|
||||
clearTimer();
|
||||
pressStartPos.current = {x, y};
|
||||
pointerIdRef.current = pointerId ?? null;
|
||||
velocitySamples.current = [{x, y, timestamp: performance.now()}];
|
||||
isPressIntent.current = true;
|
||||
|
||||
if (capturePointer && pointerId != null && innerRef.current?.setPointerCapture) {
|
||||
try {
|
||||
innerRef.current.setPointerCapture(pointerId);
|
||||
} catch {}
|
||||
}
|
||||
storedEvent.current = event;
|
||||
|
||||
highlightTimer.current = setTimeout(() => {
|
||||
if (isPressIntent.current) {
|
||||
setPressed(true);
|
||||
}
|
||||
highlightTimer.current = null;
|
||||
}, PRESS_HIGHLIGHT_DELAY_MS);
|
||||
|
||||
longPressTimer.current = setTimeout(() => {
|
||||
if (!disabled && onLongPress && storedEvent.current && isPressIntent.current) {
|
||||
onLongPress(storedEvent.current);
|
||||
setPressed(false);
|
||||
}
|
||||
clearTimer();
|
||||
}, delay);
|
||||
},
|
||||
[clearTimer, delay, disabled, onLongPress, setPressed],
|
||||
);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
userOnPointerDown?.(event);
|
||||
if (disabled || !onLongPress || event.button !== 0) return;
|
||||
if (event.pointerType !== 'touch') return;
|
||||
startLongPressTimer(event, event.clientX, event.clientY, event.pointerId, true);
|
||||
},
|
||||
[disabled, onLongPress, startLongPressTimer, userOnPointerDown],
|
||||
);
|
||||
|
||||
const handlePointerMove = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
userOnPointerMove?.(event);
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
const startPos = pressStartPos.current;
|
||||
if (!startPos) return;
|
||||
|
||||
velocitySamples.current.push({x: event.clientX, y: event.clientY, timestamp: performance.now()});
|
||||
if (velocitySamples.current.length > 10) {
|
||||
velocitySamples.current = velocitySamples.current.slice(-10);
|
||||
}
|
||||
|
||||
const deltaX = Math.abs(event.clientX - startPos.x);
|
||||
const deltaY = Math.abs(event.clientY - startPos.y);
|
||||
|
||||
if (deltaX > LONG_PRESS_MOVEMENT_THRESHOLD || deltaY > LONG_PRESS_MOVEMENT_THRESHOLD) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const velocity = calculateVelocity();
|
||||
if (velocity > SWIPE_VELOCITY_THRESHOLD) {
|
||||
clearTimer();
|
||||
}
|
||||
},
|
||||
[clearTimer, calculateVelocity, userOnPointerMove],
|
||||
);
|
||||
|
||||
const handlePointerUp = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (pointerIdRef.current === event.pointerId) {
|
||||
clearTimer();
|
||||
}
|
||||
userOnPointerUp?.(event);
|
||||
},
|
||||
[clearTimer, userOnPointerUp],
|
||||
);
|
||||
|
||||
const handlePointerCancel = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (pointerIdRef.current === event.pointerId) {
|
||||
clearTimer();
|
||||
}
|
||||
userOnPointerCancel?.(event);
|
||||
},
|
||||
[clearTimer, userOnPointerCancel],
|
||||
);
|
||||
|
||||
const handleTouchStart = React.useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
userOnTouchStart?.(event);
|
||||
if (disabled || !onLongPress) return;
|
||||
const touch = event.touches[0];
|
||||
if (!touch) return;
|
||||
startLongPressTimer(event, touch.clientX, touch.clientY);
|
||||
},
|
||||
[disabled, onLongPress, startLongPressTimer, userOnTouchStart],
|
||||
);
|
||||
|
||||
const handleTouchMove = React.useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
userOnTouchMove?.(event);
|
||||
if (!pressStartPos.current) return;
|
||||
const touch = event.touches[0];
|
||||
if (!touch) return;
|
||||
|
||||
velocitySamples.current.push({x: touch.clientX, y: touch.clientY, timestamp: performance.now()});
|
||||
if (velocitySamples.current.length > 10) {
|
||||
velocitySamples.current = velocitySamples.current.slice(-10);
|
||||
}
|
||||
|
||||
const deltaX = Math.abs(touch.clientX - pressStartPos.current.x);
|
||||
const deltaY = Math.abs(touch.clientY - pressStartPos.current.y);
|
||||
|
||||
if (deltaX > LONG_PRESS_MOVEMENT_THRESHOLD || deltaY > LONG_PRESS_MOVEMENT_THRESHOLD) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const velocity = calculateVelocity();
|
||||
if (velocity > SWIPE_VELOCITY_THRESHOLD) {
|
||||
clearTimer();
|
||||
}
|
||||
},
|
||||
[clearTimer, calculateVelocity, userOnTouchMove],
|
||||
);
|
||||
|
||||
const handleTouchEnd = React.useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
clearTimer();
|
||||
userOnTouchEnd?.(event);
|
||||
},
|
||||
[clearTimer, userOnTouchEnd],
|
||||
);
|
||||
|
||||
const handleTouchCancel = React.useCallback(
|
||||
(event: React.TouchEvent<HTMLDivElement>) => {
|
||||
clearTimer();
|
||||
userOnTouchCancel?.(event);
|
||||
},
|
||||
[clearTimer, userOnTouchCancel],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (isPressIntent.current) {
|
||||
clearTimer();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, {capture: true, passive: true});
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, {capture: true});
|
||||
if (longPressTimer.current) {
|
||||
clearTimeout(longPressTimer.current);
|
||||
}
|
||||
if (highlightTimer.current) {
|
||||
clearTimeout(highlightTimer.current);
|
||||
}
|
||||
};
|
||||
}, [clearTimer]);
|
||||
|
||||
const finalClassName = isPressed && pressedClassName ? `${className ?? ''} ${pressedClassName}`.trim() : className;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
className={finalClassName}
|
||||
onPointerDown={HAS_POINTER_EVENTS ? handlePointerDown : undefined}
|
||||
onPointerMove={HAS_POINTER_EVENTS ? handlePointerMove : undefined}
|
||||
onPointerUp={HAS_POINTER_EVENTS ? handlePointerUp : undefined}
|
||||
onPointerCancel={HAS_POINTER_EVENTS ? handlePointerCancel : undefined}
|
||||
onTouchStart={!HAS_POINTER_EVENTS ? handleTouchStart : undefined}
|
||||
onTouchMove={!HAS_POINTER_EVENTS ? handleTouchMove : undefined}
|
||||
onTouchEnd={!HAS_POINTER_EVENTS ? handleTouchEnd : undefined}
|
||||
onTouchCancel={!HAS_POINTER_EVENTS ? handleTouchCancel : undefined}
|
||||
{...restWithoutPointer}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
LongPressable.displayName = 'LongPressable';
|
||||
61
fluxer_app/src/components/NetworkErrorScreen.tsx
Normal file
61
fluxer_app/src/components/NetworkErrorScreen.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
export const NetworkErrorScreen: React.FC = () => {
|
||||
const handleRetry = React.useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.errorFallbackContainer}>
|
||||
<FluxerIcon className={styles.errorFallbackIcon} />
|
||||
<div className={styles.errorFallbackContent}>
|
||||
<h1 className={styles.errorFallbackTitle}>
|
||||
<Trans>Connection Issue</Trans>
|
||||
</h1>
|
||||
<p className={styles.errorFallbackDescription}>
|
||||
<Trans>
|
||||
We're having trouble connecting to Fluxer's servers. This could be a temporary network issue or scheduled
|
||||
maintenance.
|
||||
</Trans>
|
||||
</p>
|
||||
<p className={styles.errorFallbackDescription}>
|
||||
<Trans>
|
||||
Check our{' '}
|
||||
<a href="https://bsky.app/profile/fluxer.app" target="_blank" rel="noopener noreferrer">
|
||||
Bluesky (@fluxer.app)
|
||||
</a>{' '}
|
||||
for status updates.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.errorFallbackActions}>
|
||||
<Button onClick={handleRetry}>
|
||||
<Trans>Try Again</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
167
fluxer_app/src/components/accounts/AccountListItem.module.css
Normal file
167
fluxer_app/src/components/accounts/AccountListItem.module.css
Normal file
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.accountItem {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: none;
|
||||
padding: var(--spacing-3);
|
||||
text-align: left;
|
||||
background-color: var(--background-secondary);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.accountItem.compact {
|
||||
padding: 0.5rem 0.625rem;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.accountItem:hover:not(:disabled) {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.accountItem.compact:hover:not(:disabled) {
|
||||
background-color: var(--surface-interactive-hover-bg);
|
||||
}
|
||||
|
||||
.accountItem:active:not(:disabled) {
|
||||
background-color: var(--background-modifier-active);
|
||||
}
|
||||
|
||||
.accountItem.compact:active:not(:disabled) {
|
||||
background-color: var(--surface-interactive-active-bg);
|
||||
}
|
||||
|
||||
.accountItem:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.accountItem.compact:disabled {
|
||||
opacity: 1;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.accountItem.current {
|
||||
background-color: var(--surface-interactive-selected-bg);
|
||||
color: var(--surface-interactive-selected-color);
|
||||
}
|
||||
|
||||
.accountItem.current:hover:not(:disabled) {
|
||||
background-color: var(--surface-interactive-selected-bg);
|
||||
}
|
||||
|
||||
.accountItemContent {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.accountItem.compact .accountItemContent {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.accountInfo {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.accountItem.compact .accountInfo {
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.accountName {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.accountItem.compact .accountName {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.accountItem.current .accountName {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.accountMeta {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
.accountItem.compact .accountMeta {
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.accountItem.current .accountMeta {
|
||||
color: var(--surface-interactive-selected-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.instanceLabel {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-left: var(--spacing-2);
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
color: white;
|
||||
background-color: var(--status-online);
|
||||
}
|
||||
|
||||
.badge.expired {
|
||||
color: var(--text-danger);
|
||||
background-color: color-mix(in srgb, var(--status-danger) 15%, transparent);
|
||||
}
|
||||
108
fluxer_app/src/components/accounts/AccountListItem.tsx
Normal file
108
fluxer_app/src/components/accounts/AccountListItem.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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 {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;
|
||||
disabled?: boolean;
|
||||
isCurrent?: boolean;
|
||||
onClick?: () => void;
|
||||
variant?: 'default' | 'compact';
|
||||
showInstance?: boolean;
|
||||
badge?: ReactNode;
|
||||
meta?: ReactNode;
|
||||
}
|
||||
|
||||
export const getAccountAvatarUrl = (account: AccountSummary): string | undefined => {
|
||||
const avatar = account.userData?.avatar ?? null;
|
||||
try {
|
||||
const mediaEndpoint = account.instance?.mediaEndpoint ?? RuntimeConfigStore.getSnapshot().mediaEndpoint;
|
||||
if (mediaEndpoint) {
|
||||
return AvatarUtils.getUserAvatarURLWithProxy({id: account.userId, avatar}, mediaEndpoint, false) ?? undefined;
|
||||
}
|
||||
return AvatarUtils.getUserAvatarURL({id: account.userId, avatar}, false) ?? undefined;
|
||||
} catch {
|
||||
return 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,
|
||||
isCurrent = false,
|
||||
onClick,
|
||||
variant = 'default',
|
||||
showInstance = false,
|
||||
badge,
|
||||
meta,
|
||||
}: AccountListItemProps) => {
|
||||
const {t} = useLingui();
|
||||
const displayName = account.userData?.username ?? t`Unknown user`;
|
||||
const avatarUrl = getAccountAvatarUrl(account);
|
||||
const avatarSize = variant === 'compact' ? 32 : 40;
|
||||
|
||||
const defaultMeta =
|
||||
variant === 'compact' ? (
|
||||
isCurrent ? (
|
||||
(account.userData?.email ?? t`Email unavailable`)
|
||||
) : (
|
||||
<Trans>Last active {formatLastActive(account.lastActive)}</Trans>
|
||||
)
|
||||
) : (
|
||||
(account.userData?.email ?? t`Email unavailable`)
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(styles.accountItem, isCurrent && styles.current, variant === 'compact' && styles.compact)}
|
||||
onClick={isCurrent && !onClick ? undefined : onClick}
|
||||
disabled={disabled || (isCurrent && !onClick)}
|
||||
type="button"
|
||||
>
|
||||
<div className={styles.accountItemContent}>
|
||||
<MockAvatar size={avatarSize} avatarUrl={avatarUrl} userTag={account.userData?.username ?? account.userId} />
|
||||
<div className={styles.accountInfo}>
|
||||
<span className={styles.accountName}>{displayName}</span>
|
||||
<span className={styles.accountMeta}>{meta ?? defaultMeta}</span>
|
||||
{showInstance && account.instance && (
|
||||
<span className={styles.instanceLabel}>{describeApiEndpoint(account.instance.apiEndpoint)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{badge}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountListItemBadge = ({variant, children}: {variant: 'active' | 'expired'; children: ReactNode}) => {
|
||||
return <span className={clsx(styles.badge, styles[variant])}>{children}</span>;
|
||||
};
|
||||
195
fluxer_app/src/components/accounts/AccountRow.module.css
Normal file
195
fluxer_app/src/components/accounts/AccountRow.module.css
Normal file
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.mainButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--background-secondary);
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
button.clickable {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
button.clickable:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
button.clickable:active {
|
||||
background-color: var(--background-modifier-active);
|
||||
}
|
||||
|
||||
.avatarWrap {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.displayName {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.primaryLine {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.currentName {
|
||||
color: var(--text-success);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.discriminator {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.currentFlag {
|
||||
font-size: 0.75rem;
|
||||
color: var(--status-online);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.expired {
|
||||
font-size: 0.75rem;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.menuButton:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.compact .mainButton {
|
||||
padding: 0.4rem 0.6rem;
|
||||
background-color: var(--background-primary);
|
||||
}
|
||||
|
||||
.manage .mainButton {
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.manage .primaryLine {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.compactRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.globeButtonCompact {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
}
|
||||
|
||||
.globeButtonCompact:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.checkIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
background-color: var(--brand-primary);
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.caretIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
187
fluxer_app/src/components/accounts/AccountRow.tsx
Normal file
187
fluxer_app/src/components/accounts/AccountRow.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
const STANDARD_INSTANCES = new Set(['web.fluxer.app', 'web.canary.fluxer.app']);
|
||||
|
||||
function getInstanceHost(account: AccountSummary): string | null {
|
||||
const endpoint = account.instance?.apiEndpoint;
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(endpoint).hostname;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse instance host:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getInstanceEndpoint(account: AccountSummary): string | null {
|
||||
return account.instance?.apiEndpoint ?? null;
|
||||
}
|
||||
|
||||
type AccountRowVariant = 'default' | 'manage' | 'compact';
|
||||
|
||||
interface AccountRowProps {
|
||||
account: AccountSummary;
|
||||
variant?: AccountRowVariant;
|
||||
isCurrent?: boolean;
|
||||
isExpired?: boolean;
|
||||
showInstance?: boolean;
|
||||
onMenuClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onClick?: () => void;
|
||||
showCaretIndicator?: boolean;
|
||||
className?: string;
|
||||
meta?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AccountRow = observer(
|
||||
({
|
||||
account,
|
||||
variant = 'default',
|
||||
isCurrent = false,
|
||||
isExpired = false,
|
||||
showInstance = false,
|
||||
onMenuClick,
|
||||
onClick,
|
||||
showCaretIndicator = false,
|
||||
className,
|
||||
meta,
|
||||
}: AccountRowProps) => {
|
||||
const {t} = useLingui();
|
||||
const avatarUrl = getAccountAvatarUrl(account);
|
||||
const displayName = account.userData?.username ?? t`Unknown user`;
|
||||
const discriminator = account.userData?.discriminator ?? '0000';
|
||||
const instanceHost = showInstance ? getInstanceHost(account) : null;
|
||||
const instanceEndpoint = showInstance ? getInstanceEndpoint(account) : null;
|
||||
const shouldShowInstance = typeof instanceHost === 'string' && !STANDARD_INSTANCES.has(instanceHost);
|
||||
|
||||
const handleMenuClick = React.useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
onMenuClick?.(event);
|
||||
},
|
||||
[onMenuClick],
|
||||
);
|
||||
|
||||
const avatarSize = variant === 'compact' ? 32 : 40;
|
||||
const variantClassName = variant === 'manage' ? styles.manage : variant === 'compact' ? styles.compact : undefined;
|
||||
const isClickable = typeof onClick === 'function';
|
||||
const MainButtonComponent = isClickable ? 'button' : 'div';
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.row, variantClassName, className)}>
|
||||
<MainButtonComponent
|
||||
type={isClickable ? 'button' : undefined}
|
||||
className={clsx(styles.mainButton, isClickable && styles.clickable)}
|
||||
onClick={isClickable ? onClick : undefined}
|
||||
>
|
||||
<div className={styles.avatarWrap}>
|
||||
<MockAvatar size={avatarSize} avatarUrl={avatarUrl} userTag={displayName} />
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
{variant === 'compact' ? (
|
||||
<div className={styles.compactRow}>
|
||||
<span className={clsx('user-text', 'truncate', styles.primaryLine, isCurrent && styles.currentName)}>
|
||||
{displayName}
|
||||
<span className={styles.discriminator}>#{discriminator}</span>
|
||||
</span>
|
||||
{shouldShowInstance && instanceEndpoint ? (
|
||||
<Tooltip text={instanceEndpoint} position="right">
|
||||
<span className={styles.globeButtonCompact}>
|
||||
<GlobeIcon size={12} weight="bold" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.titleRow}>
|
||||
{variant === 'manage' ? (
|
||||
<span
|
||||
className={clsx('user-text', 'truncate', styles.primaryLine, isCurrent && styles.currentName)}
|
||||
>
|
||||
{displayName}
|
||||
<span className={styles.discriminator}>#{discriminator}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className={clsx('user-text', styles.displayName, isCurrent && styles.currentName)}>
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{shouldShowInstance && instanceEndpoint ? (
|
||||
<Tooltip text={instanceEndpoint} position="right">
|
||||
<span className={styles.globeButtonCompact}>
|
||||
<GlobeIcon size={12} weight="bold" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
{variant !== 'manage' ? (
|
||||
<span className={clsx('user-text', styles.tag)}>
|
||||
{displayName}
|
||||
<span className={styles.discriminator}>#{discriminator}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{variant === 'manage' && isCurrent ? (
|
||||
<span className={styles.currentFlag}>
|
||||
<Trans>Active account</Trans>
|
||||
</span>
|
||||
) : null}
|
||||
{meta && <span className={styles.meta}>{meta}</span>}
|
||||
{isExpired && <span className={styles.expired}>{t`Expired`}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isCurrent && variant !== 'manage' ? (
|
||||
<div className={styles.checkIndicator}>
|
||||
<CheckIcon size={10} weight="bold" />
|
||||
</div>
|
||||
) : null}
|
||||
{showCaretIndicator ? (
|
||||
<div className={styles.caretIndicator}>
|
||||
<CaretRightIcon size={18} weight="bold" />
|
||||
</div>
|
||||
) : null}
|
||||
{onMenuClick && variant !== 'compact' && !showCaretIndicator ? (
|
||||
<FocusRing offset={-2}>
|
||||
<button type="button" className={styles.menuButton} onClick={handleMenuClick} aria-label={t`More`}>
|
||||
<DotsThreeIcon size={20} weight="bold" className={styles.menuIcon} />
|
||||
</button>
|
||||
</FocusRing>
|
||||
) : null}
|
||||
</MainButtonComponent>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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: var(--spacing-2);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 0;
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.025em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: hsla(0, calc(100% * var(--saturation-factor)), 50%, 0.1);
|
||||
border: 1px solid hsla(0, calc(100% * var(--saturation-factor)), 50%, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--status-danger);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.accountListWrapper {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.scroller {
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.accountList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
padding: var(--spacing-1) 0;
|
||||
}
|
||||
|
||||
.noAccounts {
|
||||
display: flex;
|
||||
height: 120px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
184
fluxer_app/src/components/accounts/AccountSelector.tsx
Normal file
184
fluxer_app/src/components/accounts/AccountSelector.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
interface AccountSelectorProps {
|
||||
accounts: Array<AccountSummary>;
|
||||
currentAccountId?: string | null;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
error?: string | null;
|
||||
disabled?: boolean;
|
||||
showInstance?: boolean;
|
||||
clickableRows?: boolean;
|
||||
addButtonLabel?: React.ReactNode;
|
||||
onSelectAccount: (account: AccountSummary) => void;
|
||||
onAddAccount?: () => void;
|
||||
scrollerKey?: string;
|
||||
}
|
||||
|
||||
export const AccountSelector = observer(
|
||||
({
|
||||
accounts,
|
||||
currentAccountId,
|
||||
title,
|
||||
description,
|
||||
error,
|
||||
disabled = false,
|
||||
showInstance = false,
|
||||
clickableRows = false,
|
||||
addButtonLabel,
|
||||
onSelectAccount,
|
||||
onAddAccount,
|
||||
scrollerKey,
|
||||
}: AccountSelectorProps) => {
|
||||
const {t} = useLingui();
|
||||
const defaultTitle = <Trans>Choose an account</Trans>;
|
||||
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 displayName = account.userData?.username ?? account.userId;
|
||||
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<ConfirmModal
|
||||
title={<Trans>Remove {displayName}</Trans>}
|
||||
description={
|
||||
hasMultipleAccounts ? (
|
||||
<Trans>This will remove the saved session for this account.</Trans>
|
||||
) : (
|
||||
<Trans>This will remove the only saved account on this device.</Trans>
|
||||
)
|
||||
}
|
||||
primaryText={<Trans>Remove</Trans>}
|
||||
primaryVariant="danger-primary"
|
||||
onPrimary={async () => {
|
||||
try {
|
||||
await AccountManager.removeStoredAccount(account.userId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove account', error);
|
||||
ToastActionCreators.error(t`We couldn't remove that account. Please try again.`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
},
|
||||
[hasMultipleAccounts, t],
|
||||
);
|
||||
|
||||
const openMenu = React.useCallback(
|
||||
(account: AccountSummary) => (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
ContextMenuActionCreators.openFromEvent(event, (props) => (
|
||||
<MenuGroup>
|
||||
<MenuItem
|
||||
icon={<SignOutIcon size={18} />}
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
onSelectAccount(account);
|
||||
}}
|
||||
>
|
||||
<Trans>Select account</Trans>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
danger
|
||||
icon={<SignOutIcon size={18} />}
|
||||
onClick={() => {
|
||||
props.onClose();
|
||||
openSignOutConfirm(account);
|
||||
}}
|
||||
>
|
||||
<Trans>Remove</Trans>
|
||||
</MenuItem>
|
||||
</MenuGroup>
|
||||
));
|
||||
},
|
||||
[openSignOutConfirm, onSelectAccount],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.title}>{title ?? defaultTitle}</h1>
|
||||
<p className={styles.description}>{description ?? defaultDescription}</p>
|
||||
|
||||
{error && <div className={styles.error}>{error}</div>}
|
||||
|
||||
<div className={styles.accountListWrapper}>
|
||||
{accounts.length === 0 ? (
|
||||
<div className={styles.noAccounts}>
|
||||
<Trans>No accounts</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<Scroller className={styles.scroller} key={scrollerKey ?? 'account-selector-scroller'}>
|
||||
<div className={styles.accountList}>
|
||||
{accounts.map((account) => {
|
||||
const isCurrent = account.userId === currentAccountId;
|
||||
return (
|
||||
<AccountRow
|
||||
key={account.userId}
|
||||
account={account}
|
||||
variant="manage"
|
||||
isCurrent={isCurrent}
|
||||
isExpired={account.isValid === false}
|
||||
showInstance={showInstance}
|
||||
onClick={clickableRows && !disabled ? () => onSelectAccount(account) : undefined}
|
||||
showCaretIndicator={clickableRows}
|
||||
onMenuClick={!clickableRows && !disabled ? openMenu(account) : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Scroller>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onAddAccount && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<PlusIcon size={18} weight="bold" />}
|
||||
onClick={onAddAccount}
|
||||
fitContainer
|
||||
>
|
||||
{addButtonLabel ?? <Trans>Add an account</Trans>}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
height: 140px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.noAccounts {
|
||||
display: flex;
|
||||
height: 120px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.scroller {
|
||||
padding: 0.5rem;
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
.accountList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 0.75rem 0.75rem 0.5rem;
|
||||
}
|
||||
119
fluxer_app/src/components/accounts/AccountSwitcherModal.tsx
Normal file
119
fluxer_app/src/components/accounts/AccountSwitcherModal.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
const AccountSwitcherModal = observer(() => {
|
||||
const {
|
||||
accounts,
|
||||
currentAccount,
|
||||
isBusy,
|
||||
handleSwitchAccount,
|
||||
handleReLogin,
|
||||
handleAddAccount,
|
||||
handleLogout,
|
||||
handleRemoveAccount,
|
||||
} = useAccountSwitcherLogic();
|
||||
|
||||
const hasMultipleAccounts = accounts.length > 1;
|
||||
|
||||
const openMenu = React.useCallback(
|
||||
(account: (typeof accounts)[number]) => (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
openAccountContextMenu(event, {
|
||||
account,
|
||||
currentAccountId: currentAccount?.userId ?? null,
|
||||
hasMultipleAccounts,
|
||||
onSwitch: handleSwitchAccount,
|
||||
onReLogin: handleReLogin,
|
||||
onLogout: handleLogout,
|
||||
onRemoveAccount: handleRemoveAccount,
|
||||
});
|
||||
},
|
||||
[
|
||||
currentAccount?.userId,
|
||||
hasMultipleAccounts,
|
||||
handleSwitchAccount,
|
||||
handleReLogin,
|
||||
handleLogout,
|
||||
handleRemoveAccount,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal.Root size="small" centered>
|
||||
<Modal.Header title={<Trans>Manage Accounts</Trans>} />
|
||||
<Modal.Content className={styles.content}>
|
||||
{isBusy && accounts.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner />
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className={styles.noAccounts}>
|
||||
<Trans>No accounts</Trans>
|
||||
</div>
|
||||
) : (
|
||||
<Scroller className={styles.scroller} key="account-switcher-scroller">
|
||||
<div className={styles.accountList}>
|
||||
{accounts.map((account) => {
|
||||
const isCurrent = account.userId === currentAccount?.userId;
|
||||
return (
|
||||
<AccountRow
|
||||
key={account.userId}
|
||||
account={account}
|
||||
variant="manage"
|
||||
isCurrent={isCurrent}
|
||||
isExpired={account.isValid === false}
|
||||
showInstance
|
||||
onMenuClick={openMenu(account)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Scroller>
|
||||
)}
|
||||
</Modal.Content>
|
||||
<Modal.Footer className={styles.footer}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
leftIcon={<PlusIcon size={18} weight="bold" />}
|
||||
onClick={handleAddAccount}
|
||||
disabled={isBusy}
|
||||
fitContainer
|
||||
>
|
||||
<Trans>Add an account</Trans>
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export default AccountSwitcherModal;
|
||||
33
fluxer_app/src/components/alerts/CallNotRingableModal.tsx
Normal file
33
fluxer_app/src/components/alerts/CallNotRingableModal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const CallNotRingableModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Unable to Start Call`}
|
||||
message={t`This user is not available to receive calls right now. They may have calls disabled.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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 {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();
|
||||
if (isDesktop() && isNativeMacOS()) {
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Camera Permission Required`}
|
||||
description={t`Fluxer needs access to your camera. Open System Settings → Privacy & Security → Camera, allow Fluxer, and then restart the app.`}
|
||||
primaryText={t`Open Settings`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => openNativePermissionSettings('camera')}
|
||||
secondaryText={t`Close`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const message = isDesktop()
|
||||
? t`Fluxer needs access to your camera. Allow camera access in your operating system privacy settings and restart the app.`
|
||||
: t`Fluxer needs access to your camera to enable video chat. Please grant camera permission in your browser settings and try again.`;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Camera Permission Required`}
|
||||
description={message}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {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`}
|
||||
message={t`We couldn't save your channel permission changes at this time.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
30
fluxer_app/src/components/alerts/DMCloseFailedModal.tsx
Normal file
30
fluxer_app/src/components/alerts/DMCloseFailedModal.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 {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.`} />
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const FeatureTemporarilyDisabledModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Feature Temporarily Disabled`}
|
||||
description={t`This feature has been temporarily disabled. Please try again later.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
52
fluxer_app/src/components/alerts/FileSizeTooLargeModal.tsx
Normal file
52
fluxer_app/src/components/alerts/FileSizeTooLargeModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
export const FileSizeTooLargeModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const user = UserStore.currentUser;
|
||||
const hasPremium = user?.isPremium() ?? false;
|
||||
|
||||
if (hasPremium) {
|
||||
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.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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.`}
|
||||
primaryText={t`Get Plutonium`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => PremiumModalActionCreators.open()}
|
||||
secondaryText={t`Cancel`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
32
fluxer_app/src/components/alerts/GenericErrorModal.tsx
Normal file
32
fluxer_app/src/components/alerts/GenericErrorModal.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
interface GenericErrorModalProps {
|
||||
title: string;
|
||||
message: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GenericErrorModal: React.FC<GenericErrorModalProps> = observer(({title, message}) => {
|
||||
const {t} = useLingui();
|
||||
return <ConfirmModal title={title} description={message} primaryText={t`Understood`} onPrimary={() => {}} />;
|
||||
});
|
||||
33
fluxer_app/src/components/alerts/GroupLeaveFailedModal.tsx
Normal file
33
fluxer_app/src/components/alerts/GroupLeaveFailedModal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {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`}
|
||||
message={t`We couldn't remove you from the group at this time.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {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}) => {
|
||||
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} />;
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {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}) => {
|
||||
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} />;
|
||||
});
|
||||
35
fluxer_app/src/components/alerts/GuildAtCapacityModal.tsx
Normal file
35
fluxer_app/src/components/alerts/GuildAtCapacityModal.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const GuildAtCapacityModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Community at Capacity`}
|
||||
description={t`This community has reached its maximum member limit and is not accepting new members at this time.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
29
fluxer_app/src/components/alerts/InviteAcceptFailedModal.tsx
Normal file
29
fluxer_app/src/components/alerts/InviteAcceptFailedModal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 {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.`} />
|
||||
);
|
||||
});
|
||||
32
fluxer_app/src/components/alerts/InviteRevokeFailedModal.tsx
Normal file
32
fluxer_app/src/components/alerts/InviteRevokeFailedModal.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 {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`}
|
||||
message={t`We couldn't revoke the invite at this time. The invite link may still be active. Please try again in a moment.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
35
fluxer_app/src/components/alerts/InvitesDisabledModal.tsx
Normal file
35
fluxer_app/src/components/alerts/InvitesDisabledModal.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const InvitesDisabledModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Invites Paused`}
|
||||
description={t`The admins of the community have paused invites, so you can't join at this time.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
30
fluxer_app/src/components/alerts/InvitesLoadFailedModal.tsx
Normal file
30
fluxer_app/src/components/alerts/InvitesLoadFailedModal.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* 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 {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.`} />
|
||||
);
|
||||
});
|
||||
66
fluxer_app/src/components/alerts/MaxBookmarksModal.tsx
Normal file
66
fluxer_app/src/components/alerts/MaxBookmarksModal.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 {Plural, Trans, 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 maxBookmarks = currentUser.maxBookmarks;
|
||||
|
||||
if (isPremium) {
|
||||
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>
|
||||
}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
primaryText={t`Upgrade to Plutonium`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => push(modal(() => <PremiumModal />))}
|
||||
secondaryText={t`Dismiss`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
52
fluxer_app/src/components/alerts/MaxFavoriteMemesModal.tsx
Normal file
52
fluxer_app/src/components/alerts/MaxFavoriteMemesModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 {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() {
|
||||
const {t} = useLingui();
|
||||
const currentUser = UserStore.currentUser;
|
||||
const isPremium = currentUser?.isPremium() ?? false;
|
||||
|
||||
if (isPremium) {
|
||||
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`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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!`}
|
||||
primaryText={t`Upgrade to Plutonium`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => push(modal(() => <PremiumModal />))}
|
||||
secondaryText={t`Maybe Later`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
44
fluxer_app/src/components/alerts/MaxGuildsModal.tsx
Normal file
44
fluxer_app/src/components/alerts/MaxGuildsModal.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 {Plural, Trans, 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();
|
||||
const currentUser = UserStore.currentUser!;
|
||||
const maxGuilds = currentUser.maxGuilds;
|
||||
|
||||
return (
|
||||
<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>
|
||||
}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const MessageDeleteFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`That message didn't delete`}
|
||||
message={t`We hit a snag. Try deleting that message again.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const MessageDeleteTooQuickModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`You're deleting messages too quickly`}
|
||||
description={t`The problem with being faster than light is that you can only live in darkness. Take a breather and try again.`}
|
||||
primaryText={t`Gotcha`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
33
fluxer_app/src/components/alerts/MessageEditFailedModal.tsx
Normal file
33
fluxer_app/src/components/alerts/MessageEditFailedModal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const MessageEditFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Your message didn't update`}
|
||||
message={t`We hit a snag. Try editing your message again.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {RateLimitedConfirmModal} from '~/components/alerts/RateLimitedConfirmModal';
|
||||
|
||||
interface MessageEditTooQuickModalProps {
|
||||
retryAfter?: number;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export const MessageEditTooQuickModal = observer(({retryAfter, onRetry}: MessageEditTooQuickModalProps) => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<RateLimitedConfirmModal title={t`You're editing messages too quickly`} retryAfter={retryAfter} onRetry={onRetry} />
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {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`}
|
||||
message={t`We couldn't forward the message at this time.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
32
fluxer_app/src/components/alerts/MessageSendFailedModal.tsx
Normal file
32
fluxer_app/src/components/alerts/MessageSendFailedModal.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const MessageSendFailedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Your message didn't go through`}
|
||||
message={t`We hit a snag. Try sending your message again.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {RateLimitedConfirmModal} from '~/components/alerts/RateLimitedConfirmModal';
|
||||
|
||||
interface MessageSendTooQuickModalProps {
|
||||
retryAfter?: number;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export const MessageSendTooQuickModal = observer(({retryAfter, onRetry}: MessageSendTooQuickModalProps) => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<RateLimitedConfirmModal title={t`You're sending messages too quickly`} retryAfter={retryAfter} onRetry={onRetry} />
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 {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();
|
||||
|
||||
if (isDesktop() && isNativeMacOS()) {
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Microphone Permission Required`}
|
||||
description={t`Fluxer needs access to your microphone. Open System Settings → Privacy & Security → Microphone, allow Fluxer, and then restart the app.`}
|
||||
primaryText={t`Open Settings`}
|
||||
primaryVariant="primary"
|
||||
onPrimary={() => openNativePermissionSettings('microphone')}
|
||||
secondaryText={t`Close`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const message = isDesktop()
|
||||
? t`Fluxer needs access to your microphone. Allow microphone access in your operating system privacy settings and restart the app.`
|
||||
: t`Fluxer needs access to your microphone to enable voice chat. Please grant microphone permission in your browser settings and try again.`;
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Microphone Permission Required`}
|
||||
description={message}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const NSFWContentRejectedModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`NSFW content not allowed`}
|
||||
description={t`This channel is not marked as NSFW. Explicit content can only be sent in NSFW channels. Ask a moderator to mark this channel as NSFW if appropriate.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
45
fluxer_app/src/components/alerts/PinFailedModal.tsx
Normal file
45
fluxer_app/src/components/alerts/PinFailedModal.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export type PinFailureReason = 'dm_restricted' | 'generic';
|
||||
|
||||
interface PinFailedModalProps {
|
||||
isUnpin?: boolean;
|
||||
reason?: PinFailureReason;
|
||||
}
|
||||
|
||||
export const PinFailedModal: React.FC<PinFailedModalProps> = observer(({isUnpin, reason = 'generic'}) => {
|
||||
const {t} = useLingui();
|
||||
const title = isUnpin ? t`Failed to unpin message` : t`Failed to pin message`;
|
||||
|
||||
let message: string;
|
||||
switch (reason) {
|
||||
case 'dm_restricted':
|
||||
message = t`You cannot interact with this user right now.`;
|
||||
break;
|
||||
default:
|
||||
message = t`Something went wrong. Please try again later.`;
|
||||
}
|
||||
|
||||
return <GenericErrorModal title={title} message={message} />;
|
||||
});
|
||||
77
fluxer_app/src/components/alerts/RateLimitedConfirmModal.tsx
Normal file
77
fluxer_app/src/components/alerts/RateLimitedConfirmModal.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
interface RateLimitedConfirmModalProps {
|
||||
title: string;
|
||||
retryAfter?: number;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export const RateLimitedConfirmModal = observer(({title, retryAfter, onRetry}: RateLimitedConfirmModalProps) => {
|
||||
const {t} = useLingui();
|
||||
const hasRetryAfter = retryAfter != null;
|
||||
|
||||
const formatRateLimitTime = React.useCallback(
|
||||
(totalSeconds: number): string => {
|
||||
if (totalSeconds < 60) {
|
||||
return totalSeconds === 1 ? t`${totalSeconds} second` : t`${totalSeconds} seconds`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (seconds === 0) {
|
||||
return minutes === 1 ? t`${minutes} minute` : t`${minutes} minutes`;
|
||||
}
|
||||
|
||||
if (minutes === 1 && seconds === 1) {
|
||||
return t`1 minute and 1 second`;
|
||||
}
|
||||
|
||||
if (minutes === 1) {
|
||||
return t`1 minute and ${seconds} seconds`;
|
||||
}
|
||||
|
||||
if (seconds === 1) {
|
||||
return t`${minutes} minutes and 1 second`;
|
||||
}
|
||||
|
||||
return t`${minutes} minutes and ${seconds} seconds`;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={title}
|
||||
description={
|
||||
hasRetryAfter
|
||||
? t`You're being rate limited. Please wait ${formatRateLimitTime(retryAfter)} before trying again.`
|
||||
: t`The problem with being faster than light is that you can only live in darkness. Take a breather and try again.`
|
||||
}
|
||||
secondaryText={hasRetryAfter ? t`Retry` : t`Gotcha`}
|
||||
onSecondary={onRetry}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const ReactionInteractionDisabledModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Reaction Interaction Disabled`}
|
||||
description={t`You can't interact with reactions in search results as it might disrupt the space-time continuum.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
29
fluxer_app/src/components/alerts/RoleCreateFailedModal.tsx
Normal file
29
fluxer_app/src/components/alerts/RoleCreateFailedModal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 {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.`} />
|
||||
);
|
||||
});
|
||||
37
fluxer_app/src/components/alerts/RoleDeleteFailedModal.tsx
Normal file
37
fluxer_app/src/components/alerts/RoleDeleteFailedModal.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* 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 {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}) => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Failed to delete role`}
|
||||
message={
|
||||
<Trans>
|
||||
The role <strong>"{roleName}"</strong> could not be deleted at this time. Please try again.
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
34
fluxer_app/src/components/alerts/RoleNameBlankModal.tsx
Normal file
34
fluxer_app/src/components/alerts/RoleNameBlankModal.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const RoleNameBlankModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Role name cannot be blank`}
|
||||
description={t`You cannot save a role with a blank name. Please provide a valid name before saving.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
33
fluxer_app/src/components/alerts/RoleUpdateFailedModal.tsx
Normal file
33
fluxer_app/src/components/alerts/RoleUpdateFailedModal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {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`}
|
||||
message={t`We couldn't save your role changes at this time.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 {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`}
|
||||
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"
|
||||
onPrimary={() => openNativePermissionSettings('screen')}
|
||||
secondaryText={t`Close`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {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`}
|
||||
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={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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 {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;
|
||||
}
|
||||
|
||||
export const SlowmodeRateLimitedModal = observer(({retryAfter}: SlowmodeRateLimitedModalProps) => {
|
||||
const {t} = useLingui();
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (seconds < 60) {
|
||||
return seconds === 1 ? t`${seconds} second` : t`${seconds} seconds`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
if (remainingSeconds === 0) {
|
||||
return minutes === 1 ? t`${minutes} minute` : t`${minutes} minutes`;
|
||||
}
|
||||
|
||||
if (minutes === 1 && remainingSeconds === 1) {
|
||||
return t`1 minute and 1 second`;
|
||||
}
|
||||
|
||||
if (minutes === 1) {
|
||||
return t`1 minute and ${remainingSeconds} seconds`;
|
||||
}
|
||||
|
||||
if (remainingSeconds === 1) {
|
||||
return t`${minutes} minutes and 1 second`;
|
||||
}
|
||||
|
||||
return t`${minutes} minutes and ${remainingSeconds} seconds`;
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Slowmode Active`}
|
||||
description={t(
|
||||
msg`This channel has slowmode enabled. You need to wait ${formatTime(retryAfter)} before sending another message.`,
|
||||
)}
|
||||
primaryText={t`Okay`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const TemporaryInviteRequiresPresenceModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Gateway Connection Required`}
|
||||
description={t`You must be connected to the gateway to accept this temporary invite. Please check your connection and try again.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
35
fluxer_app/src/components/alerts/TooManyAttachmentsModal.tsx
Normal file
35
fluxer_app/src/components/alerts/TooManyAttachmentsModal.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {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();
|
||||
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.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
35
fluxer_app/src/components/alerts/TooManyReactionsModal.tsx
Normal file
35
fluxer_app/src/components/alerts/TooManyReactionsModal.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const TooManyReactionsModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Whoa, this is heavy`}
|
||||
description={t`This is one heavy message. Some reactions need to be removed before you can add more.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const UserBannedFromGuildModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`You're Banned`}
|
||||
description={t`You are banned from this community and cannot join.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
|
||||
export const UserIpBannedFromGuildModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t`Your IP is Banned`}
|
||||
description={t`Your IP address is banned from this community and you cannot join.`}
|
||||
primaryText={t`Understood`}
|
||||
onPrimary={() => {}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
33
fluxer_app/src/components/alerts/VoiceChannelFullModal.tsx
Normal file
33
fluxer_app/src/components/alerts/VoiceChannelFullModal.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GenericErrorModal} from './GenericErrorModal';
|
||||
|
||||
export const VoiceChannelFullModal = observer(() => {
|
||||
const {t} = useLingui();
|
||||
|
||||
return (
|
||||
<GenericErrorModal
|
||||
title={t`Voice Channel Full`}
|
||||
message={t`This voice channel has reached its user limit. Please try again later or join a different channel.`}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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 {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 {
|
||||
useVoiceConnectionConfirmModalLogic,
|
||||
type VoiceConnectionConfirmModalProps,
|
||||
} from '~/utils/alerts/VoiceConnectionConfirmModalUtils';
|
||||
import styles from './VoiceConnectionConfirmModal.module.css';
|
||||
|
||||
export const VoiceConnectionConfirmModal: React.FC<VoiceConnectionConfirmModalProps> = observer(
|
||||
({guildId: _guildId, channelId: _channelId, onSwitchDevice, onJustJoin, onCancel}) => {
|
||||
const {t} = useLingui();
|
||||
const {existingConnectionsCount, handleSwitchDevice, handleJustJoin, handleCancel} =
|
||||
useVoiceConnectionConfirmModalLogic({
|
||||
onSwitchDevice,
|
||||
onJustJoin,
|
||||
onCancel,
|
||||
});
|
||||
|
||||
return (
|
||||
<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>
|
||||
</Modal.Content>
|
||||
<Modal.Footer>
|
||||
<div className={styles.footer}>
|
||||
<Button variant="primary" onClick={handleSwitchDevice} className={styles.fullWidth}>
|
||||
<Trans>Switch to This Device</Trans>
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" onClick={handleJustJoin} className={styles.fullWidth}>
|
||||
<Trans>Just Join (Keep Other Connections)</Trans>
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" onClick={handleCancel} className={styles.fullWidth}>
|
||||
<Trans>Do nothing, I don't want to join</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
138
fluxer_app/src/components/auth/AuthBackground.tsx
Normal file
138
fluxer_app/src/components/auth/AuthBackground.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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 {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],
|
||||
) => {
|
||||
switch (alignment) {
|
||||
case GuildSplashCardAlignment.LEFT:
|
||||
return {transformOrigin: 'bottom left', objectPosition: 'left bottom'};
|
||||
case GuildSplashCardAlignment.RIGHT:
|
||||
return {transformOrigin: 'bottom right', objectPosition: 'right bottom'};
|
||||
default:
|
||||
return {transformOrigin: 'bottom center', objectPosition: 'center bottom'};
|
||||
}
|
||||
};
|
||||
|
||||
export interface AuthBackgroundProps {
|
||||
splashUrl: string | null;
|
||||
splashLoaded: boolean;
|
||||
splashDimensions?: {width: number; height: number} | null;
|
||||
splashScale?: number | null;
|
||||
patternReady: boolean;
|
||||
patternImageUrl: string;
|
||||
className?: string;
|
||||
useFullCover?: boolean;
|
||||
splashAlignment?: (typeof GuildSplashCardAlignment)[keyof typeof GuildSplashCardAlignment];
|
||||
}
|
||||
|
||||
export const AuthBackground: React.FC<AuthBackgroundProps> = ({
|
||||
splashUrl,
|
||||
splashLoaded,
|
||||
splashDimensions,
|
||||
splashScale,
|
||||
patternReady,
|
||||
patternImageUrl,
|
||||
className,
|
||||
useFullCover = false,
|
||||
splashAlignment = GuildSplashCardAlignment.CENTER,
|
||||
}) => {
|
||||
const shouldShowSplash = splashUrl && splashDimensions && (useFullCover || splashScale);
|
||||
const {transformOrigin, objectPosition} = getSplashAlignmentStyles(splashAlignment);
|
||||
|
||||
if (shouldShowSplash) {
|
||||
if (useFullCover) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: splashLoaded ? 1 : 0}}
|
||||
transition={{duration: 0.5, ease: 'easeInOut'}}
|
||||
style={{position: 'absolute', inset: 0}}
|
||||
>
|
||||
<img
|
||||
src={splashUrl}
|
||||
alt=""
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition,
|
||||
}}
|
||||
/>
|
||||
<div className={styles.splashOverlay} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.rightSplit}>
|
||||
<motion.div
|
||||
className={styles.splashImage}
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: splashLoaded ? 1 : 0}}
|
||||
transition={{duration: 0.5, ease: 'easeInOut'}}
|
||||
style={{
|
||||
width: splashDimensions.width,
|
||||
height: splashDimensions.height,
|
||||
transform: `scale(${splashScale})`,
|
||||
transformOrigin,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={splashUrl}
|
||||
alt=""
|
||||
width={splashDimensions.width}
|
||||
height={splashDimensions.height}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition,
|
||||
}}
|
||||
/>
|
||||
<div className={styles.splashOverlay} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (patternReady) {
|
||||
return (
|
||||
<div
|
||||
className={className || styles.patternHost}
|
||||
style={{backgroundImage: `url(${patternImageUrl})`}}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
48
fluxer_app/src/components/auth/AuthBottomLink.tsx
Normal file
48
fluxer_app/src/components/auth/AuthBottomLink.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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 {Trans} from '@lingui/react/macro';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
import {AuthRouterLink} from './AuthRouterLink';
|
||||
|
||||
interface AuthBottomLinkProps {
|
||||
variant: 'login' | 'register';
|
||||
to: string;
|
||||
}
|
||||
|
||||
export function AuthBottomLink({variant, to}: AuthBottomLinkProps) {
|
||||
return (
|
||||
<div className={styles.bottomLink}>
|
||||
<span className={styles.bottomLinkText}>
|
||||
{variant === 'login' ? <Trans>Already have an account?</Trans> : <Trans>Need an account?</Trans>}{' '}
|
||||
</span>
|
||||
<AuthRouterLink to={to} className={styles.bottomLinkAnchor}>
|
||||
{variant === 'login' ? <Trans>Log in</Trans> : <Trans>Register</Trans>}
|
||||
</AuthRouterLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuthBottomLinksProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthBottomLinks({children}: AuthBottomLinksProps) {
|
||||
return <div className={styles.bottomLinks}>{children}</div>;
|
||||
}
|
||||
36
fluxer_app/src/components/auth/AuthCardContainer.module.css
Normal file
36
fluxer_app/src/components/auth/AuthCardContainer.module.css
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.inertOverlay {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inertOverlay * {
|
||||
pointer-events: none !important;
|
||||
cursor: default !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.inertOverlay input,
|
||||
.inertOverlay button,
|
||||
.inertOverlay select,
|
||||
.inertOverlay textarea,
|
||||
.inertOverlay a {
|
||||
opacity: 0.75;
|
||||
}
|
||||
50
fluxer_app/src/components/auth/AuthCardContainer.tsx
Normal file
50
fluxer_app/src/components/auth/AuthCardContainer.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* 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 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 {
|
||||
showLogoSide?: boolean;
|
||||
children: ReactNode;
|
||||
isInert?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AuthCardContainer({showLogoSide = true, children, isInert = false, className}: AuthCardContainerProps) {
|
||||
return (
|
||||
<div className={clsx(authLayoutStyles.cardContainer, className)}>
|
||||
<div className={clsx(authLayoutStyles.card, !showLogoSide && authLayoutStyles.cardSingle)}>
|
||||
{showLogoSide && (
|
||||
<div className={authLayoutStyles.logoSide}>
|
||||
<FluxerLogo className={authLayoutStyles.logo} />
|
||||
<FluxerWordmark className={authLayoutStyles.wordmark} />
|
||||
</div>
|
||||
)}
|
||||
<div className={clsx(authLayoutStyles.formSide, !showLogoSide && authLayoutStyles.formSideSingle)}>
|
||||
{isInert ? <div className={styles.inertOverlay}>{children}</div> : children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
fluxer_app/src/components/auth/AuthErrorState.tsx
Normal file
40
fluxer_app/src/components/auth/AuthErrorState.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 type {Icon} from '@phosphor-icons/react';
|
||||
import {QuestionIcon} from '@phosphor-icons/react';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
|
||||
interface AuthErrorStateProps {
|
||||
icon?: Icon;
|
||||
title: React.ReactNode;
|
||||
text: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthErrorState({icon: IconComponent = QuestionIcon, title, text}: AuthErrorStateProps) {
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<div className={styles.errorIcon}>
|
||||
<IconComponent className={styles.errorIconSvg} />
|
||||
</div>
|
||||
<h1 className={styles.errorTitle}>{title}</h1>
|
||||
<p className={styles.errorText}>{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
fluxer_app/src/components/auth/AuthLoadingState.tsx
Normal file
29
fluxer_app/src/components/auth/AuthLoadingState.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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 {Spinner} from '~/components/uikit/Spinner';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
|
||||
export function AuthLoadingState() {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 {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 = {
|
||||
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 = {
|
||||
form: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
form: AuthFormControllerLike;
|
||||
isLoading: boolean;
|
||||
fieldErrors?: FieldErrors;
|
||||
submitLabel: React.ReactNode;
|
||||
classes: AuthEmailPasswordFormClasses;
|
||||
extraFields?: React.ReactNode;
|
||||
links?: React.ReactNode;
|
||||
linksWrapperClassName?: string;
|
||||
disableSubmit?: boolean;
|
||||
};
|
||||
|
||||
export default function AuthLoginEmailPasswordForm({
|
||||
form,
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
submitLabel,
|
||||
classes,
|
||||
extraFields,
|
||||
links,
|
||||
linksWrapperClassName,
|
||||
disableSubmit,
|
||||
}: Props) {
|
||||
const {t} = useLingui();
|
||||
const emailId = useId();
|
||||
const passwordId = useId();
|
||||
|
||||
const isSubmitting = Boolean(form.isSubmitting);
|
||||
const submitDisabled = isLoading || isSubmitting || Boolean(disableSubmit);
|
||||
|
||||
return (
|
||||
<form className={classes.form} onSubmit={form.handleSubmit}>
|
||||
<FormField
|
||||
id={emailId}
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
label={t`Email`}
|
||||
value={form.getValue('email')}
|
||||
onChange={(value) => form.setValue('email', value)}
|
||||
error={form.getError('email') || fieldErrors?.email}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
id={passwordId}
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
label={t`Password`}
|
||||
value={form.getValue('password')}
|
||||
onChange={(value) => form.setValue('password', value)}
|
||||
error={form.getError('password') || fieldErrors?.password}
|
||||
/>
|
||||
|
||||
{extraFields}
|
||||
|
||||
{links ? <div className={linksWrapperClassName}>{links}</div> : null}
|
||||
|
||||
<Button type="submit" fitContainer disabled={submitDisabled}>
|
||||
{typeof submitLabel === 'string' ? <Trans>{submitLabel}</Trans> : submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* 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 {Trans} from '@lingui/react/macro';
|
||||
import {BrowserIcon, KeyIcon} from '@phosphor-icons/react';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
|
||||
export type AuthLoginDividerClasses = {
|
||||
divider: string;
|
||||
dividerLine: string;
|
||||
dividerText: string;
|
||||
};
|
||||
|
||||
export function AuthLoginDivider({
|
||||
classes,
|
||||
label = <Trans>OR</Trans>,
|
||||
}: {
|
||||
classes: AuthLoginDividerClasses;
|
||||
label?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={classes.divider}>
|
||||
<div className={classes.dividerLine} />
|
||||
<span className={classes.dividerText}>{label}</span>
|
||||
<div className={classes.dividerLine} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type AuthPasskeyClasses = {
|
||||
wrapper?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
classes?: AuthPasskeyClasses;
|
||||
|
||||
disabled: boolean;
|
||||
|
||||
onPasskeyLogin: () => void;
|
||||
showBrowserOption: boolean;
|
||||
onBrowserLogin?: () => void;
|
||||
|
||||
primaryLabel?: React.ReactNode;
|
||||
browserLabel?: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function AuthLoginPasskeyActions({
|
||||
classes,
|
||||
disabled,
|
||||
onPasskeyLogin,
|
||||
showBrowserOption,
|
||||
onBrowserLogin,
|
||||
primaryLabel = <Trans>Log in with a passkey</Trans>,
|
||||
browserLabel = <Trans>Log in via browser</Trans>,
|
||||
}: Props) {
|
||||
return (
|
||||
<div className={classes?.wrapper}>
|
||||
<Button
|
||||
type="button"
|
||||
fitContainer
|
||||
variant="secondary"
|
||||
onClick={onPasskeyLogin}
|
||||
disabled={disabled}
|
||||
leftIcon={<KeyIcon size={16} />}
|
||||
>
|
||||
{primaryLabel}
|
||||
</Button>
|
||||
|
||||
{showBrowserOption && onBrowserLogin ? (
|
||||
<Button
|
||||
type="button"
|
||||
fitContainer
|
||||
variant="secondary"
|
||||
onClick={onBrowserLogin}
|
||||
disabled={disabled}
|
||||
leftIcon={<BrowserIcon size={16} />}
|
||||
>
|
||||
{browserLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 {useCallback, useMemo, useState} from 'react';
|
||||
import * as AuthenticationActionCreators from '~/actions/AuthenticationActionCreators';
|
||||
|
||||
export type DesktopHandoffMode = 'idle' | 'selecting' | 'login' | 'generating' | 'displaying' | 'error';
|
||||
|
||||
type Options = {
|
||||
enabled: boolean;
|
||||
hasStoredAccounts: boolean;
|
||||
|
||||
initialMode?: DesktopHandoffMode;
|
||||
};
|
||||
|
||||
export function useDesktopHandoffFlow({enabled, hasStoredAccounts, initialMode}: Options) {
|
||||
const derivedInitial = useMemo<DesktopHandoffMode>(() => {
|
||||
if (!enabled) return 'idle';
|
||||
if (initialMode) return initialMode;
|
||||
return hasStoredAccounts ? 'selecting' : 'login';
|
||||
}, [enabled, hasStoredAccounts, initialMode]);
|
||||
|
||||
const [mode, setMode] = useState<DesktopHandoffMode>(derivedInitial);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const start = useCallback(
|
||||
async ({token, userId}: {token: string; userId: string}) => {
|
||||
if (!enabled) return;
|
||||
|
||||
setMode('generating');
|
||||
setError(null);
|
||||
setCode(null);
|
||||
|
||||
try {
|
||||
const result = await AuthenticationActionCreators.initiateDesktopHandoff();
|
||||
await AuthenticationActionCreators.completeDesktopHandoff({
|
||||
code: result.code,
|
||||
token,
|
||||
userId,
|
||||
});
|
||||
|
||||
setCode(result.code);
|
||||
setMode('displaying');
|
||||
} catch (e) {
|
||||
setMode('error');
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const switchToLogin = useCallback(() => {
|
||||
setMode('login');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
setError(null);
|
||||
setCode(null);
|
||||
setMode(hasStoredAccounts ? 'selecting' : 'login');
|
||||
}, [hasStoredAccounts]);
|
||||
|
||||
return {
|
||||
mode,
|
||||
code,
|
||||
error,
|
||||
|
||||
setMode,
|
||||
|
||||
start,
|
||||
switchToLogin,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
285
fluxer_app/src/components/auth/AuthLoginLayout.tsx
Normal file
285
fluxer_app/src/components/auth/AuthLoginLayout.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* 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 {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;
|
||||
inviteCode?: string;
|
||||
desktopHandoff?: boolean;
|
||||
excludeCurrentUser?: boolean;
|
||||
extraTopContent?: ReactNode;
|
||||
showTitle?: boolean;
|
||||
title?: ReactNode;
|
||||
registerLink: ReactElement<Record<string, unknown>>;
|
||||
onLoginComplete?: (payload: LoginSuccessPayload) => Promise<void> | void;
|
||||
initialEmail?: string;
|
||||
}
|
||||
|
||||
const AuthLoginLayout = observer(function AuthLoginLayout({
|
||||
redirectPath,
|
||||
inviteCode,
|
||||
desktopHandoff = false,
|
||||
excludeCurrentUser = false,
|
||||
extraTopContent,
|
||||
showTitle = true,
|
||||
title,
|
||||
registerLink,
|
||||
onLoginComplete,
|
||||
initialEmail,
|
||||
}: AuthLoginLayoutProps) {
|
||||
const {t} = useLingui();
|
||||
const currentUserId = AccountManager.currentUserId;
|
||||
const accounts = AccountManager.orderedAccounts;
|
||||
const hasStoredAccounts = accounts.length > 0;
|
||||
|
||||
const handoffAccounts =
|
||||
desktopHandoff && excludeCurrentUser ? accounts.filter((a) => a.userId !== currentUserId) : accounts;
|
||||
const hasHandoffAccounts = handoffAccounts.length > 0;
|
||||
|
||||
const handoff = useDesktopHandoffFlow({
|
||||
enabled: desktopHandoff,
|
||||
hasStoredAccounts: hasHandoffAccounts,
|
||||
initialMode: desktopHandoff && hasHandoffAccounts ? 'selecting' : 'login',
|
||||
});
|
||||
|
||||
const [ipAuthChallenge, setIpAuthChallenge] = useState<IpAuthorizationChallenge | null>(null);
|
||||
const [showAccountSelector, setShowAccountSelector] = useState(!desktopHandoff && hasStoredAccounts && !initialEmail);
|
||||
const [isSwitching, setIsSwitching] = useState(false);
|
||||
const [switchError, setSwitchError] = useState<string | null>(null);
|
||||
const [prefillEmail, setPrefillEmail] = useState<string | null>(() => initialEmail ?? null);
|
||||
|
||||
const showLoginFormForAccount = useCallback((account: AccountSummary, message?: string | null) => {
|
||||
setShowAccountSelector(false);
|
||||
setSwitchError(message ?? null);
|
||||
setPrefillEmail(account.userData?.email ?? null);
|
||||
}, []);
|
||||
|
||||
const handleLoginSuccess = useCallback(
|
||||
async ({token, userId}: LoginSuccessPayload) => {
|
||||
if (desktopHandoff) {
|
||||
await handoff.start({token, userId});
|
||||
return;
|
||||
}
|
||||
await AuthenticationActionCreators.completeLogin({token, userId});
|
||||
await onLoginComplete?.({token, userId});
|
||||
},
|
||||
[desktopHandoff, handoff, onLoginComplete],
|
||||
);
|
||||
|
||||
const {form, isLoading, fieldErrors, handlePasskeyLogin, handlePasskeyBrowserLogin, isPasskeyLoading} =
|
||||
useLoginFormController({
|
||||
redirectPath,
|
||||
inviteCode,
|
||||
onLoginSuccess: handleLoginSuccess,
|
||||
onRequireMfa: (challenge) => {
|
||||
AuthenticationActionCreators.setMfaTicket(challenge);
|
||||
},
|
||||
onRequireIpAuthorization: (challenge) => {
|
||||
setIpAuthChallenge(challenge);
|
||||
},
|
||||
});
|
||||
|
||||
const showBrowserPasskey = IS_DEV || isDesktop();
|
||||
const passkeyControlsDisabled = isLoading || Boolean(form.isSubmitting) || isPasskeyLoading;
|
||||
|
||||
const handleIpAuthorizationComplete = useCallback(
|
||||
async ({token, userId}: LoginSuccessPayload) => {
|
||||
await handleLoginSuccess({token, userId});
|
||||
if (redirectPath) {
|
||||
RouterUtils.replaceWith(redirectPath);
|
||||
}
|
||||
setIpAuthChallenge(null);
|
||||
},
|
||||
[handleLoginSuccess, redirectPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPrefillEmail(initialEmail ?? null);
|
||||
if (initialEmail) {
|
||||
setShowAccountSelector(false);
|
||||
}
|
||||
}, [initialEmail]);
|
||||
|
||||
useEffect(() => {
|
||||
if (prefillEmail !== null) {
|
||||
form.setValue('email', prefillEmail);
|
||||
}
|
||||
}, [form, prefillEmail]);
|
||||
|
||||
const handleSelectExistingAccount = useCallback(
|
||||
async (account: AccountSummary) => {
|
||||
const identifier = account.userData?.email ?? account.userData?.username ?? account.userId;
|
||||
const expiredMessage = t`Session expired for ${identifier}. Please log in again.`;
|
||||
|
||||
if (account.isValid === false || !AccountManager.canSwitchAccounts) {
|
||||
showLoginFormForAccount(account, expiredMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSwitching(true);
|
||||
setSwitchError(null);
|
||||
try {
|
||||
await AccountManager.switchToAccount(account.userId);
|
||||
} catch (error) {
|
||||
const updatedAccount = AccountManager.accounts.get(account.userId);
|
||||
if (error instanceof SessionExpiredError || updatedAccount?.isValid === false) {
|
||||
showLoginFormForAccount(updatedAccount ?? account, expiredMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
setSwitchError(error instanceof Error ? error.message : t`Failed to switch account`);
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
},
|
||||
[showLoginFormForAccount],
|
||||
);
|
||||
|
||||
const handleAddAnotherAccount = useCallback(() => {
|
||||
setShowAccountSelector(false);
|
||||
setSwitchError(null);
|
||||
setPrefillEmail(null);
|
||||
}, []);
|
||||
|
||||
const styledRegisterLink = useMemo(() => {
|
||||
const {className: linkClassName} = registerLink.props as {className?: string};
|
||||
return cloneElement(registerLink, {
|
||||
className: clsx(styles.footerLink, linkClassName),
|
||||
});
|
||||
}, [registerLink]);
|
||||
|
||||
if (desktopHandoff && handoff.mode === 'selecting') {
|
||||
return (
|
||||
<DesktopHandoffAccountSelector
|
||||
excludeCurrentUser={excludeCurrentUser}
|
||||
onSelectNewAccount={handoff.switchToLogin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showAccountSelector && hasStoredAccounts && !desktopHandoff) {
|
||||
return (
|
||||
<AccountSelector
|
||||
accounts={accounts}
|
||||
currentAccountId={currentUserId}
|
||||
error={switchError}
|
||||
disabled={isSwitching}
|
||||
showInstance
|
||||
onSelectAccount={handleSelectExistingAccount}
|
||||
onAddAccount={handleAddAnotherAccount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (desktopHandoff && (handoff.mode === 'generating' || handoff.mode === 'displaying' || handoff.mode === 'error')) {
|
||||
return (
|
||||
<HandoffCodeDisplay
|
||||
code={handoff.code}
|
||||
isGenerating={handoff.mode === 'generating'}
|
||||
error={handoff.mode === 'error' ? handoff.error : null}
|
||||
onRetry={handoff.retry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (ipAuthChallenge) {
|
||||
return (
|
||||
<IpAuthorizationScreen
|
||||
challenge={ipAuthChallenge}
|
||||
onAuthorized={handleIpAuthorizationComplete}
|
||||
onBack={() => setIpAuthChallenge(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{extraTopContent}
|
||||
|
||||
{showTitle ? <h1 className={styles.title}>{title ?? <Trans>Welcome back</Trans>}</h1> : null}
|
||||
|
||||
{!showAccountSelector && switchError ? <div className={styles.loginNotice}>{switchError}</div> : null}
|
||||
|
||||
<AuthLoginEmailPasswordForm
|
||||
form={form}
|
||||
isLoading={isLoading}
|
||||
fieldErrors={fieldErrors}
|
||||
submitLabel={<Trans>Log in</Trans>}
|
||||
classes={{form: styles.form}}
|
||||
linksWrapperClassName={styles.formLinks}
|
||||
links={
|
||||
<AuthRouterLink to="/forgot" className={styles.link}>
|
||||
<Trans>Forgot your password?</Trans>
|
||||
</AuthRouterLink>
|
||||
}
|
||||
disableSubmit={isPasskeyLoading}
|
||||
/>
|
||||
|
||||
<AuthLoginDivider
|
||||
classes={{
|
||||
divider: styles.divider,
|
||||
dividerLine: styles.dividerLine,
|
||||
dividerText: styles.dividerText,
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthLoginPasskeyActions
|
||||
classes={{
|
||||
wrapper: styles.passkeyActions,
|
||||
}}
|
||||
disabled={passkeyControlsDisabled}
|
||||
onPasskeyLogin={handlePasskeyLogin}
|
||||
showBrowserOption={showBrowserPasskey}
|
||||
onBrowserLogin={handlePasskeyBrowserLogin}
|
||||
browserLabel={<Trans>Log in via browser or custom instance</Trans>}
|
||||
/>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.footerText}>
|
||||
<span className={styles.footerLabel}>
|
||||
<Trans>Need an account?</Trans>{' '}
|
||||
</span>
|
||||
{styledRegisterLink}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export {AuthLoginLayout};
|
||||
151
fluxer_app/src/components/auth/AuthMinimalRegisterFormCore.tsx
Normal file
151
fluxer_app/src/components/auth/AuthMinimalRegisterFormCore.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
interface AuthMinimalRegisterFormCoreProps {
|
||||
submitLabel: React.ReactNode;
|
||||
redirectPath: string;
|
||||
onRegister?: (response: {token: string; user_id: string}) => Promise<void>;
|
||||
inviteCode?: string;
|
||||
extraContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthMinimalRegisterFormCore({
|
||||
submitLabel,
|
||||
redirectPath,
|
||||
onRegister,
|
||||
inviteCode,
|
||||
extraContent,
|
||||
}: AuthMinimalRegisterFormCoreProps) {
|
||||
const {t} = useLingui();
|
||||
const globalNameId = useId();
|
||||
|
||||
const [selectedMonth, setSelectedMonth] = useState('');
|
||||
const [selectedDay, setSelectedDay] = useState('');
|
||||
const [selectedYear, setSelectedYear] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
|
||||
const initialValues: Record<string, string> = {
|
||||
global_name: '',
|
||||
};
|
||||
|
||||
const handleRegisterSubmit = async (values: Record<string, string>) => {
|
||||
const dateOfBirth =
|
||||
selectedYear && selectedMonth && selectedDay
|
||||
? `${selectedYear}-${selectedMonth.padStart(2, '0')}-${selectedDay.padStart(2, '0')}`
|
||||
: '';
|
||||
|
||||
const response = await AuthenticationActionCreators.register({
|
||||
global_name: values.global_name || undefined,
|
||||
beta_code: '',
|
||||
date_of_birth: dateOfBirth,
|
||||
consent,
|
||||
invite_code: inviteCode,
|
||||
});
|
||||
|
||||
if (onRegister) {
|
||||
await onRegister(response);
|
||||
} else {
|
||||
await AuthenticationActionCreators.completeLogin({
|
||||
token: response.token,
|
||||
userId: response.user_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
initialValues,
|
||||
onSubmit: handleRegisterSubmit,
|
||||
redirectPath,
|
||||
firstFieldName: 'global_name',
|
||||
});
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: Array<MissingField> = [];
|
||||
if (!selectedMonth || !selectedDay || !selectedYear) {
|
||||
missing.push({key: 'date_of_birth', label: t`Date of birth`});
|
||||
}
|
||||
return missing;
|
||||
}, [selectedMonth, selectedDay, selectedYear]);
|
||||
|
||||
const globalNameValue = form.getValue('global_name');
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={form.handleSubmit}>
|
||||
<FormField
|
||||
id={globalNameId}
|
||||
name="global_name"
|
||||
type="text"
|
||||
label={t`Display name (optional)`}
|
||||
placeholder={t`What should people call you?`}
|
||||
value={globalNameValue}
|
||||
onChange={(value) => form.setValue('global_name', value)}
|
||||
error={form.getError('global_name') || fieldErrors?.global_name}
|
||||
/>
|
||||
|
||||
<DateOfBirthField
|
||||
selectedMonth={selectedMonth}
|
||||
selectedDay={selectedDay}
|
||||
selectedYear={selectedYear}
|
||||
onMonthChange={setSelectedMonth}
|
||||
onDayChange={setSelectedDay}
|
||||
onYearChange={setSelectedYear}
|
||||
error={fieldErrors?.date_of_birth}
|
||||
/>
|
||||
|
||||
{extraContent}
|
||||
|
||||
<div className={styles.consentRow}>
|
||||
<Checkbox checked={consent} onChange={setConsent}>
|
||||
<span className={styles.consentLabel}>
|
||||
<Trans>I agree to the</Trans>{' '}
|
||||
<ExternalLink href={Routes.terms()} className={styles.policyLink}>
|
||||
<Trans>Terms of Service</Trans>
|
||||
</ExternalLink>{' '}
|
||||
<Trans>and</Trans>{' '}
|
||||
<ExternalLink href={Routes.privacy()} className={styles.policyLink}>
|
||||
<Trans>Privacy Policy</Trans>
|
||||
</ExternalLink>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<SubmitTooltip consent={consent} missingFields={missingFields}>
|
||||
<Button
|
||||
type="submit"
|
||||
fitContainer
|
||||
disabled={isLoading || form.isSubmitting || shouldDisableSubmit(consent, missingFields)}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</SubmitTooltip>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
68
fluxer_app/src/components/auth/AuthPageHeader.tsx
Normal file
68
fluxer_app/src/components/auth/AuthPageHeader.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 {useLingui} from '@lingui/react/macro';
|
||||
import {SealCheckIcon} from '@phosphor-icons/react';
|
||||
import type {ReactNode} from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import styles from './AuthPageStyles.module.css';
|
||||
|
||||
interface AuthPageHeaderStatProps {
|
||||
value: string | number;
|
||||
dot?: 'online' | 'offline';
|
||||
}
|
||||
|
||||
interface AuthPageHeaderProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
verified?: boolean;
|
||||
stats?: Array<AuthPageHeaderStatProps>;
|
||||
}
|
||||
|
||||
export function AuthPageHeader({icon, title, subtitle, verified, stats}: AuthPageHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
{icon}
|
||||
<div className={styles.entityDetails}>
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{stats && stats.length > 0 && (
|
||||
<div className={styles.entityStats}>
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className={styles.entityStat}>
|
||||
{stat.dot === 'online' && <div className={styles.onlineDot} />}
|
||||
{stat.dot === 'offline' && <div className={styles.offlineDot} />}
|
||||
<span className={styles.statText}>{stat.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
429
fluxer_app/src/components/auth/AuthPageStyles.module.css
Normal file
429
fluxer_app/src/components/auth/AuthPageStyles.module.css
Normal file
@@ -0,0 +1,429 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
display: flex;
|
||||
height: 5rem;
|
||||
width: 5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--background-tertiary);
|
||||
}
|
||||
|
||||
.errorIconSvg {
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.errorTitle {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.errorText {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1 1 0%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.entityHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.entityDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.entityText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.entityTitleWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.packBadge {
|
||||
background: var(--background-modifier-accent);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.entityTitle {
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.verifiedIcon {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.entityStats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.packDescription {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.packMeta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.packMetaText {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.entityStat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.onlineDot {
|
||||
margin-right: 0.375rem;
|
||||
height: 0.625rem;
|
||||
width: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--status-online);
|
||||
}
|
||||
|
||||
.offlineDot {
|
||||
margin-right: 0.375rem;
|
||||
height: 0.625rem;
|
||||
width: 0.625rem;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--text-tertiary-secondary);
|
||||
}
|
||||
|
||||
.statText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.entityIconWrapper {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
min-width: 5rem;
|
||||
min-height: 5rem;
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.entityIcon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--background-primary);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.themeIconSpot {
|
||||
display: flex;
|
||||
height: 5rem;
|
||||
width: 5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-primary-dark, #4752c4) 100%);
|
||||
}
|
||||
|
||||
.themeIcon {
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.form {
|
||||
margin-top: 1.5rem;
|
||||
flex: 1 1 0%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.loginForm {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.bottomLink {
|
||||
margin-top: 1rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bottomLinks {
|
||||
margin-top: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.bottomLinkText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.bottomLinkAnchor {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-link);
|
||||
transition-property: color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bottomLinkAnchor:hover {
|
||||
color: var(--text-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dividerLine {
|
||||
flex: 1 1 0%;
|
||||
border-top: 1px solid var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.dividerText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.forgotPasswordLink {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.forgotPasswordLinkText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
transition-property: color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.forgotPasswordLinkText:hover {
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.usernameHint {
|
||||
margin-top: 0.25rem;
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.consentRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.consentLabel {
|
||||
padding-top: 2px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.policyLink {
|
||||
color: var(--text-link);
|
||||
text-decoration: none;
|
||||
transition-property: color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.policyLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.submitSpacer {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.disabledContainer {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.disabledText {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.disabledSubtext {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.disabledActions {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.disabledActionLink {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.025em;
|
||||
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;
|
||||
width: 5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(to bottom right, rgb(168, 85, 247), rgb(236, 72, 153));
|
||||
}
|
||||
|
||||
.giftIcon {
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.entitySubtext {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.subtext {
|
||||
margin-top: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.secondaryInlineAction {
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-link);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryInlineAction:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
285
fluxer_app/src/components/auth/AuthRegisterFormCore.tsx
Normal file
285
fluxer_app/src/components/auth/AuthRegisterFormCore.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
interface FieldConfig {
|
||||
showEmail?: boolean;
|
||||
showPassword?: boolean;
|
||||
showUsernameValidation?: boolean;
|
||||
showBetaCodeHint?: boolean;
|
||||
requireBetaCode?: boolean;
|
||||
}
|
||||
|
||||
interface AuthRegisterFormCoreProps {
|
||||
fields?: FieldConfig;
|
||||
submitLabel: React.ReactNode;
|
||||
redirectPath: string;
|
||||
onRegister?: (response: {token: string; user_id: string}) => Promise<void>;
|
||||
inviteCode?: string;
|
||||
extraContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthRegisterFormCore({
|
||||
fields = {},
|
||||
submitLabel,
|
||||
redirectPath,
|
||||
onRegister,
|
||||
inviteCode,
|
||||
extraContent,
|
||||
}: AuthRegisterFormCoreProps) {
|
||||
const {t} = useLingui();
|
||||
const {
|
||||
showEmail = false,
|
||||
showPassword = false,
|
||||
showUsernameValidation = false,
|
||||
requireBetaCode = MODE !== 'development',
|
||||
} = fields;
|
||||
|
||||
const emailId = useId();
|
||||
const globalNameId = useId();
|
||||
const usernameId = useId();
|
||||
const passwordId = useId();
|
||||
const betaCodeId = useId();
|
||||
|
||||
const [selectedMonth, setSelectedMonth] = useState('');
|
||||
const [selectedDay, setSelectedDay] = useState('');
|
||||
const [selectedYear, setSelectedYear] = useState('');
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [usernameFocused, setUsernameFocused] = useState(false);
|
||||
|
||||
const initialValues: Record<string, string> = {
|
||||
global_name: '',
|
||||
username: '',
|
||||
betaCode: '',
|
||||
};
|
||||
if (showEmail) initialValues.email = '';
|
||||
if (showPassword) initialValues.password = '';
|
||||
|
||||
const handleRegisterSubmit = async (values: Record<string, string>) => {
|
||||
const dateOfBirth =
|
||||
selectedYear && selectedMonth && selectedDay
|
||||
? `${selectedYear}-${selectedMonth.padStart(2, '0')}-${selectedDay.padStart(2, '0')}`
|
||||
: '';
|
||||
|
||||
const response = await AuthenticationActionCreators.register({
|
||||
global_name: values.global_name || undefined,
|
||||
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,
|
||||
});
|
||||
|
||||
if (onRegister) {
|
||||
await onRegister(response);
|
||||
} else {
|
||||
await AuthenticationActionCreators.completeLogin({
|
||||
token: response.token,
|
||||
userId: response.user_id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const {form, isLoading, fieldErrors} = useAuthForm({
|
||||
initialValues,
|
||||
onSubmit: handleRegisterSubmit,
|
||||
redirectPath,
|
||||
firstFieldName: showEmail ? 'email' : 'global_name',
|
||||
});
|
||||
|
||||
const {suggestions} = useUsernameSuggestions({
|
||||
globalName: form.getValue('global_name'),
|
||||
username: form.getValue('username'),
|
||||
});
|
||||
|
||||
const missingFields = useMemo(() => {
|
||||
const missing: Array<MissingField> = [];
|
||||
if (showEmail && !form.getValue('email')) {
|
||||
missing.push({key: 'email', label: t`Email`});
|
||||
}
|
||||
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 (requireBetaCode && !form.getValue('betaCode')) {
|
||||
missing.push({key: 'betaCode', label: t`Beta code`});
|
||||
}
|
||||
return missing;
|
||||
}, [form, selectedMonth, selectedDay, selectedYear, showEmail, showPassword, requireBetaCode]);
|
||||
|
||||
const usernameValue = form.getValue('username');
|
||||
const showValidationRules = showUsernameValidation && usernameValue && (usernameFocused || usernameValue.length > 0);
|
||||
|
||||
return (
|
||||
<form className={styles.form} onSubmit={form.handleSubmit}>
|
||||
{showEmail && (
|
||||
<FormField
|
||||
id={emailId}
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
label={t`Email`}
|
||||
value={form.getValue('email')}
|
||||
onChange={(value) => form.setValue('email', value)}
|
||||
error={form.getError('email') || fieldErrors?.email}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
id={globalNameId}
|
||||
name="global_name"
|
||||
type="text"
|
||||
label={t`Display name (optional)`}
|
||||
placeholder={t`What should people call you?`}
|
||||
value={form.getValue('global_name')}
|
||||
onChange={(value) => form.setValue('global_name', value)}
|
||||
error={form.getError('global_name') || fieldErrors?.global_name}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<FormField
|
||||
id={usernameId}
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
label={t`Username (optional)`}
|
||||
placeholder={t`Leave blank for a random username`}
|
||||
value={usernameValue}
|
||||
onChange={(value) => form.setValue('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>
|
||||
)}
|
||||
|
||||
{!usernameValue && (
|
||||
<UsernameSuggestions suggestions={suggestions} onSelect={(username) => form.setValue('username', username)} />
|
||||
)}
|
||||
|
||||
{showPassword && (
|
||||
<FormField
|
||||
id={passwordId}
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
label={t`Password`}
|
||||
value={form.getValue('password')}
|
||||
onChange={(value) => form.setValue('password', value)}
|
||||
error={form.getError('password') || fieldErrors?.password}
|
||||
/>
|
||||
)}
|
||||
|
||||
{requireBetaCode ? (
|
||||
<FormField
|
||||
id={betaCodeId}
|
||||
name="betaCode"
|
||||
type="text"
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DateOfBirthField
|
||||
selectedMonth={selectedMonth}
|
||||
selectedDay={selectedDay}
|
||||
selectedYear={selectedYear}
|
||||
onMonthChange={setSelectedMonth}
|
||||
onDayChange={setSelectedDay}
|
||||
onYearChange={setSelectedYear}
|
||||
error={fieldErrors?.date_of_birth}
|
||||
/>
|
||||
|
||||
{extraContent}
|
||||
|
||||
<div className={styles.consentRow}>
|
||||
<Checkbox checked={consent} onChange={setConsent}>
|
||||
<span className={styles.consentLabel}>
|
||||
<Trans>I agree to the</Trans>{' '}
|
||||
<ExternalLink href={Routes.terms()} className={styles.policyLink}>
|
||||
<Trans>Terms of Service</Trans>
|
||||
</ExternalLink>{' '}
|
||||
<Trans>and</Trans>{' '}
|
||||
<ExternalLink href={Routes.privacy()} className={styles.policyLink}>
|
||||
<Trans>Privacy Policy</Trans>
|
||||
</ExternalLink>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<SubmitTooltip consent={consent} missingFields={missingFields}>
|
||||
<Button
|
||||
type="submit"
|
||||
fitContainer
|
||||
disabled={isLoading || form.isSubmitting || shouldDisableSubmit(consent, missingFields)}
|
||||
>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</SubmitTooltip>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
40
fluxer_app/src/components/auth/AuthRouterLink.tsx
Normal file
40
fluxer_app/src/components/auth/AuthRouterLink.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* 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 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>;
|
||||
}
|
||||
|
||||
export function AuthRouterLink({ringOffset = -2, children, className, to, search}: AuthRouterLinkProps) {
|
||||
return (
|
||||
<FocusRing offset={ringOffset}>
|
||||
<RouterLink tabIndex={0} className={className} to={to} search={search}>
|
||||
{children}
|
||||
</RouterLink>
|
||||
</FocusRing>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.codeInputSection {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.inputHelper {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin: 8px 0 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.instanceLink {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.instanceLink:hover {
|
||||
color: var(--text-muted);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prefillHint {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.instanceBadge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.instanceBadgeIcon {
|
||||
color: var(--status-online);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.instanceBadgeText {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--status-online);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.instanceBadgeClear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
margin-left: 4px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.instanceBadgeClear:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
352
fluxer_app/src/components/auth/BrowserLoginHandoffModal.tsx
Normal file
352
fluxer_app/src/components/auth/BrowserLoginHandoffModal.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* 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 {msg} from '@lingui/core/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {ArrowSquareOutIcon, CheckCircleIcon} 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';
|
||||
|
||||
interface LoginSuccessPayload {
|
||||
token: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
interface BrowserLoginHandoffModalProps {
|
||||
onSuccess: (payload: LoginSuccessPayload) => Promise<void>;
|
||||
targetWebAppUrl?: string;
|
||||
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, '')
|
||||
.toUpperCase()
|
||||
.slice(0, CODE_LENGTH);
|
||||
|
||||
if (cleaned.length <= 4) {
|
||||
return cleaned;
|
||||
}
|
||||
return `${cleaned.slice(0, 4)}-${cleaned.slice(4)}`;
|
||||
};
|
||||
|
||||
const extractRawCode = (formatted: string): string => {
|
||||
return formatted
|
||||
.replace(/[^A-Za-z0-9]/g, '')
|
||||
.toUpperCase()
|
||||
.slice(0, CODE_LENGTH);
|
||||
};
|
||||
|
||||
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 [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 showInstanceOption = IS_DEV || isDesktop();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (rawCode: string) => {
|
||||
if (!VALID_CODE_PATTERN.test(rawCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const customApiEndpoint = validatedInstance?.apiEndpoint;
|
||||
const result = await AuthenticationActionCreators.pollDesktopHandoffStatus(rawCode, customApiEndpoint);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
if (result.status === 'pending') {
|
||||
setError(i18n._(msg`This code hasn't been used yet. Please complete login in your browser first.`));
|
||||
} else {
|
||||
setError(i18n._(msg`Invalid or expired code. Please try again.`));
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[i18n, onSuccess, validatedInstance],
|
||||
);
|
||||
|
||||
const handleCodeChange = React.useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawCode = extractRawCode(e.target.value);
|
||||
setCode(rawCode);
|
||||
setError(null);
|
||||
|
||||
if (VALID_CODE_PATTERN.test(rawCode)) {
|
||||
void handleSubmit(rawCode);
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
const handleOpenBrowser = React.useCallback(async () => {
|
||||
const currentWebAppUrl = RuntimeConfigStore.webAppBaseUrl;
|
||||
const baseUrl = validatedInstance?.webAppUrl || targetWebAppUrl || currentWebAppUrl;
|
||||
|
||||
const params = new URLSearchParams({desktop_handoff: '1'});
|
||||
if (prefillEmail) {
|
||||
params.set('email', prefillEmail);
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/login?${params.toString()}`;
|
||||
await openExternalUrl(url);
|
||||
}, [prefillEmail, targetWebAppUrl, validatedInstance]);
|
||||
|
||||
const handleShowInstanceView = React.useCallback(() => {
|
||||
setView('instance');
|
||||
}, []);
|
||||
|
||||
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}>
|
||||
<p className={styles.description}>
|
||||
<Trans>Log in using your browser, then enter the code shown to add the account.</Trans>
|
||||
</p>
|
||||
|
||||
<div className={styles.codeInputSection}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
label={i18n._(msg`Login code`)}
|
||||
value={formatCodeForDisplay(code)}
|
||||
onChange={handleCodeChange}
|
||||
error={error ?? undefined}
|
||||
disabled={isSubmitting}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</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>
|
||||
</p>
|
||||
) : null}
|
||||
</Modal.Content>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={ModalActionCreators.pop} disabled={isSubmitting}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleOpenBrowser} submitting={isSubmitting}>
|
||||
<ArrowSquareOutIcon size={16} weight="bold" />
|
||||
<Trans>Open browser</Trans>
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function showBrowserLoginHandoffModal(
|
||||
onSuccess: (payload: LoginSuccessPayload) => Promise<void>,
|
||||
targetWebAppUrl?: string,
|
||||
prefillEmail?: string,
|
||||
): void {
|
||||
ModalActionCreators.push(
|
||||
modal(() => (
|
||||
<BrowserLoginHandoffModal
|
||||
onSuccess={async (payload) => {
|
||||
await onSuccess(payload);
|
||||
}}
|
||||
targetWebAppUrl={targetWebAppUrl}
|
||||
prefillEmail={prefillEmail}
|
||||
/>
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
export default BrowserLoginHandoffModal;
|
||||
138
fluxer_app/src/components/auth/DateOfBirthField.module.css
Normal file
138
fluxer_app/src/components/auth/DateOfBirthField.module.css
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.labelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.legend {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.inputsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.fieldsRow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.monthField {
|
||||
flex: 2 1 0%;
|
||||
}
|
||||
|
||||
.dayField {
|
||||
flex: 1.5 1 0%;
|
||||
}
|
||||
|
||||
.yearField {
|
||||
flex: 1.5 1 0%;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--status-danger);
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fieldsRow {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.monthField,
|
||||
.dayField,
|
||||
.yearField {
|
||||
flex: 1 1 calc(50% - 0.5rem);
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.yearField {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.monthField,
|
||||
.dayField,
|
||||
.yearField {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.nativeDateInput {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--background-modifier-accent);
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--form-surface-background);
|
||||
min-height: 44px;
|
||||
transition-property: color, background-color, border-color;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
:global(.theme-light) .nativeDateInput {
|
||||
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);
|
||||
}
|
||||
|
||||
.nativeDateInput::-webkit-date-and-time-value {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.nativeDateInput::-webkit-calendar-picker-indicator {
|
||||
opacity: 0.6;
|
||||
cursor: pointer;
|
||||
filter: var(--calendar-picker-filter, none);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.nativeDateInput::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
301
fluxer_app/src/components/auth/DateOfBirthField.tsx
Normal file
301
fluxer_app/src/components/auth/DateOfBirthField.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
/*
|
||||
* 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 {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;
|
||||
selectedYear: string;
|
||||
onMonthChange: (month: string) => void;
|
||||
onDayChange: (day: string) => void;
|
||||
onYearChange: (year: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface NativeDatePickerProps {
|
||||
selectedMonth: string;
|
||||
selectedDay: string;
|
||||
selectedYear: string;
|
||||
onMonthChange: (month: string) => void;
|
||||
onDayChange: (day: string) => void;
|
||||
onYearChange: (year: string) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function NativeDatePicker({
|
||||
selectedMonth,
|
||||
selectedDay,
|
||||
selectedYear,
|
||||
onMonthChange,
|
||||
onDayChange,
|
||||
onYearChange,
|
||||
error,
|
||||
}: NativeDatePickerProps) {
|
||||
const {t} = useLingui();
|
||||
const _monthPlaceholder = t`Month`;
|
||||
const _dayPlaceholder = t`Day`;
|
||||
const _yearPlaceholder = t`Year`;
|
||||
const dateOfBirthPlaceholder = t`Date of birth`;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const minDate = `${currentYear - 150}-01-01`;
|
||||
const maxDate = `${currentYear}-12-31`;
|
||||
|
||||
const dateValue = useMemo(() => {
|
||||
if (!selectedYear || !selectedMonth || !selectedDay) {
|
||||
return '';
|
||||
}
|
||||
const year = selectedYear.padStart(4, '0');
|
||||
const month = selectedMonth.padStart(2, '0');
|
||||
const day = selectedDay.padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}, [selectedYear, selectedMonth, selectedDay]);
|
||||
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
if (!value) {
|
||||
onYearChange('');
|
||||
onMonthChange('');
|
||||
onDayChange('');
|
||||
return;
|
||||
}
|
||||
const [year, month, day] = value.split('-');
|
||||
onYearChange(String(parseInt(year, 10)));
|
||||
onMonthChange(String(parseInt(month, 10)));
|
||||
onDayChange(String(parseInt(day, 10)));
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className={styles.fieldset}>
|
||||
<div className={styles.labelContainer}>
|
||||
<legend className={styles.legend}>
|
||||
<Trans>Date of birth</Trans>
|
||||
</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}
|
||||
/>
|
||||
{error && <span className={styles.errorText}>{error}</span>}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export const DateOfBirthField = observer(function DateOfBirthField({
|
||||
selectedMonth,
|
||||
selectedDay,
|
||||
selectedYear,
|
||||
onMonthChange,
|
||||
onDayChange,
|
||||
onYearChange,
|
||||
error,
|
||||
}: DateOfBirthFieldProps) {
|
||||
const {t} = useLingui();
|
||||
const monthPlaceholder = t`Month`;
|
||||
const dayPlaceholder = t`Day`;
|
||||
const yearPlaceholder = t`Year`;
|
||||
|
||||
const locale = getCurrentLocale();
|
||||
const fieldOrder = useMemo(() => getDateFieldOrder(locale), [locale]);
|
||||
|
||||
const dateOptions = useMemo(() => {
|
||||
const currentDate = new Date();
|
||||
const currentYear = currentDate.getFullYear();
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const years = [];
|
||||
for (let year = currentYear; year >= currentYear - 150; year--) {
|
||||
years.push({
|
||||
value: String(year),
|
||||
label: String(year),
|
||||
});
|
||||
}
|
||||
|
||||
let availableDays = Array.from({length: 31}, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: String(i + 1),
|
||||
}));
|
||||
|
||||
if (selectedYear && selectedMonth) {
|
||||
const year = Number(selectedYear);
|
||||
const month = Number(selectedMonth);
|
||||
const daysInMonth = new Date(year, month, 0).getDate();
|
||||
availableDays = availableDays.filter((day) => Number(day.value) <= daysInMonth);
|
||||
}
|
||||
|
||||
return {
|
||||
months: allMonths,
|
||||
days: availableDays,
|
||||
years,
|
||||
};
|
||||
}, [selectedYear, selectedMonth, locale]);
|
||||
|
||||
if (isMobileWebBrowser()) {
|
||||
return (
|
||||
<NativeDatePicker
|
||||
selectedMonth={selectedMonth}
|
||||
selectedDay={selectedDay}
|
||||
selectedYear={selectedYear}
|
||||
onMonthChange={onMonthChange}
|
||||
onDayChange={onDayChange}
|
||||
onYearChange={onYearChange}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleYearChange = (year: string) => {
|
||||
onYearChange(year);
|
||||
if (selectedDay && selectedYear && selectedMonth) {
|
||||
const daysInMonth = new Date(Number(year), Number(selectedMonth), 0).getDate();
|
||||
if (Number(selectedDay) > daysInMonth) {
|
||||
onDayChange('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMonthChange = (month: string) => {
|
||||
onMonthChange(month);
|
||||
if (selectedDay && selectedYear && month) {
|
||||
const daysInMonth = new Date(Number(selectedYear), Number(month), 0).getDate();
|
||||
if (Number(selectedDay) > daysInMonth) {
|
||||
onDayChange('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fieldComponents: Record<DateFieldType, React.ReactElement> = {
|
||||
month: (
|
||||
<div key="month" className={styles.monthField}>
|
||||
<Select
|
||||
placeholder={monthPlaceholder}
|
||||
options={dateOptions.months}
|
||||
value={selectedMonth}
|
||||
onChange={handleMonthChange}
|
||||
tabIndex={0}
|
||||
tabSelectsValue={false}
|
||||
blurInputOnSelect={false}
|
||||
openMenuOnFocus={true}
|
||||
closeMenuOnSelect={true}
|
||||
autoSelectExactMatch={true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
day: (
|
||||
<div key="day" className={styles.dayField}>
|
||||
<Select
|
||||
placeholder={dayPlaceholder}
|
||||
options={dateOptions.days}
|
||||
value={selectedDay}
|
||||
onChange={onDayChange}
|
||||
tabIndex={0}
|
||||
tabSelectsValue={false}
|
||||
blurInputOnSelect={false}
|
||||
openMenuOnFocus={true}
|
||||
closeMenuOnSelect={true}
|
||||
autoSelectExactMatch={true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
year: (
|
||||
<div key="year" className={styles.yearField}>
|
||||
<Select
|
||||
placeholder={yearPlaceholder}
|
||||
options={dateOptions.years}
|
||||
value={selectedYear}
|
||||
onChange={handleYearChange}
|
||||
tabIndex={0}
|
||||
tabSelectsValue={false}
|
||||
blurInputOnSelect={false}
|
||||
openMenuOnFocus={true}
|
||||
closeMenuOnSelect={true}
|
||||
autoSelectExactMatch={true}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const orderedFields = fieldOrder.map((fieldType) => fieldComponents[fieldType]);
|
||||
|
||||
return (
|
||||
<fieldset className={styles.fieldset}>
|
||||
<div className={styles.labelContainer}>
|
||||
<legend className={styles.legend}>
|
||||
<Trans>Date of birth</Trans>
|
||||
</legend>
|
||||
</div>
|
||||
<div className={styles.inputsContainer}>
|
||||
<div className={styles.fieldsRow}>{orderedFields}</div>
|
||||
{error && <span className={styles.errorText}>{error}</span>}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.9rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--background-secondary-alt);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.copy {
|
||||
flex: 1 1 0%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.cta {
|
||||
display: inline-flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notInstalled {
|
||||
color: var(--text-warning);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
107
fluxer_app/src/components/auth/DesktopDeepLinkPrompt.tsx
Normal file
107
fluxer_app/src/components/auth/DesktopDeepLinkPrompt.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* 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 {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;
|
||||
kind: 'invite' | 'gift' | 'theme';
|
||||
preferLogin?: boolean;
|
||||
}
|
||||
|
||||
export const DesktopDeepLinkPrompt: React.FC<DesktopDeepLinkPromptProps> = ({code, kind, preferLogin = false}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [desktopAvailable, setDesktopAvailable] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesktop()) return;
|
||||
|
||||
let cancelled = false;
|
||||
checkDesktopAvailable().then(({available}) => {
|
||||
if (!cancelled) {
|
||||
setDesktopAvailable(available);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isDesktop()) return null;
|
||||
|
||||
if (desktopAvailable !== true) return null;
|
||||
|
||||
const getPath = (): string => {
|
||||
switch (kind) {
|
||||
case 'invite':
|
||||
return preferLogin ? Routes.inviteLogin(code) : Routes.inviteRegister(code);
|
||||
case 'gift':
|
||||
return preferLogin ? Routes.giftLogin(code) : Routes.giftRegister(code);
|
||||
case 'theme':
|
||||
return preferLogin ? Routes.themeLogin(code) : Routes.themeRegister(code);
|
||||
}
|
||||
};
|
||||
|
||||
const path = getPath();
|
||||
|
||||
const handleOpen = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await navigateInDesktop(path);
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error ?? 'Failed to open in desktop app');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.banner}>
|
||||
<div className={styles.copy}>
|
||||
<p className={styles.title}>
|
||||
<Trans>Open in Fluxer for desktop</Trans>
|
||||
</p>
|
||||
{error ? (
|
||||
<p className={styles.notInstalled}>{error}</p>
|
||||
) : (
|
||||
<p className={styles.body}>
|
||||
<Trans>Jump straight to the app to continue.</Trans>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleOpen} className={styles.cta} submitting={isLoading}>
|
||||
<ArrowSquareOutIcon size={18} weight="fill" />
|
||||
<span>
|
||||
<Trans>Open Fluxer</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
121
fluxer_app/src/components/auth/DesktopHandoffAccountSelector.tsx
Normal file
121
fluxer_app/src/components/auth/DesktopHandoffAccountSelector.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
interface DesktopHandoffAccountSelectorProps {
|
||||
excludeCurrentUser?: boolean;
|
||||
onSelectNewAccount: () => void;
|
||||
}
|
||||
|
||||
const DesktopHandoffAccountSelector = observer(function DesktopHandoffAccountSelector({
|
||||
excludeCurrentUser = false,
|
||||
onSelectNewAccount,
|
||||
}: DesktopHandoffAccountSelectorProps) {
|
||||
const {t} = useLingui();
|
||||
const [handoffState, setHandoffState] = useState<HandoffState>('selecting');
|
||||
const [handoffCode, setHandoffCode] = useState<string | null>(null);
|
||||
const [handoffError, setHandoffError] = useState<string | null>(null);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null);
|
||||
|
||||
const currentUserId = AccountManager.currentUserId;
|
||||
const allAccounts = AccountManager.orderedAccounts;
|
||||
const accounts = excludeCurrentUser ? allAccounts.filter((account) => account.userId !== currentUserId) : allAccounts;
|
||||
const isGenerating = handoffState === 'generating';
|
||||
|
||||
const handleSelectAccount = useCallback(async (account: AccountSummary) => {
|
||||
setSelectedAccountId(account.userId);
|
||||
setHandoffState('generating');
|
||||
setHandoffError(null);
|
||||
|
||||
try {
|
||||
const {token, userId} = await AccountManager.generateTokenForAccount(account.userId);
|
||||
if (!token) {
|
||||
throw new Error('Failed to generate token');
|
||||
}
|
||||
|
||||
const result = await AuthenticationActionCreators.initiateDesktopHandoff();
|
||||
await AuthenticationActionCreators.completeDesktopHandoff({
|
||||
code: result.code,
|
||||
token,
|
||||
userId,
|
||||
});
|
||||
|
||||
setHandoffCode(result.code);
|
||||
setHandoffState('displaying');
|
||||
} catch (error) {
|
||||
setHandoffState('error');
|
||||
if (error instanceof SessionExpiredError) {
|
||||
setHandoffError(t`Session expired. Please log in again.`);
|
||||
} else {
|
||||
setHandoffError(error instanceof Error ? error.message : t`Failed to generate handoff code`);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (selectedAccountId) {
|
||||
const account = allAccounts.find((a) => a.userId === selectedAccountId);
|
||||
if (account) {
|
||||
void handleSelectAccount(account);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setHandoffState('selecting');
|
||||
setSelectedAccountId(null);
|
||||
setHandoffError(null);
|
||||
}, [selectedAccountId, allAccounts, handleSelectAccount]);
|
||||
|
||||
if (handoffState === 'generating' || handoffState === 'displaying' || handoffState === 'error') {
|
||||
return (
|
||||
<HandoffCodeDisplay
|
||||
code={handoffCode}
|
||||
isGenerating={handoffState === 'generating'}
|
||||
error={handoffState === 'error' ? handoffError : null}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountSelector
|
||||
accounts={accounts}
|
||||
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
|
||||
clickableRows
|
||||
onSelectAccount={handleSelectAccount}
|
||||
onAddAccount={onSelectNewAccount}
|
||||
addButtonLabel={<Trans>Add a different account</Trans>}
|
||||
scrollerKey="desktop-handoff-scroller"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default DesktopHandoffAccountSelector;
|
||||
45
fluxer_app/src/components/auth/FormField.tsx
Normal file
45
fluxer_app/src/components/auth/FormField.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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 {observer} from 'mobx-react-lite';
|
||||
import {Input} from '~/components/form/Input';
|
||||
|
||||
interface FormFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> {
|
||||
name: string;
|
||||
label?: React.ReactNode;
|
||||
value: string;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const FormField = observer(function FormField({name, label, value, error, onChange, ...props}: FormFieldProps) {
|
||||
return (
|
||||
<Input
|
||||
name={name}
|
||||
label={typeof label === 'string' ? label : undefined}
|
||||
value={value}
|
||||
error={error}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default FormField;
|
||||
60
fluxer_app/src/components/auth/GiftHeader.tsx
Normal file
60
fluxer_app/src/components/auth/GiftHeader.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 {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;
|
||||
variant: 'login' | 'register';
|
||||
}
|
||||
|
||||
export function GiftHeader({gift, variant}: GiftHeaderProps) {
|
||||
const {i18n} = useLingui();
|
||||
const durationText = getPremiumGiftDurationText(i18n, gift);
|
||||
|
||||
const sender =
|
||||
gift.created_by?.username && gift.created_by.discriminator
|
||||
? `${gift.created_by.username}#${gift.created_by.discriminator}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
<div className={styles.giftIconContainer}>
|
||||
<GiftIcon className={styles.giftIcon} />
|
||||
</div>
|
||||
<div className={styles.entityDetails}>
|
||||
<p className={styles.entityText}>
|
||||
{sender ? <Trans>{sender} sent you a gift!</Trans> : <Trans>You've received a gift!</Trans>}
|
||||
</p>
|
||||
<h2 className={styles.entityTitle}>{durationText}</h2>
|
||||
<p className={styles.entitySubtext}>
|
||||
{variant === 'login' ? (
|
||||
<Trans>Log in to claim your gift</Trans>
|
||||
) : (
|
||||
<Trans>Create an account to claim your gift</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
fluxer_app/src/components/auth/HandoffCodeDisplay.module.css
Normal file
118
fluxer_app/src/components/auth/HandoffCodeDisplay.module.css
Normal file
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* 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 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.025em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.codeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.codeLabel {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.codeDisplay {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 24px;
|
||||
background-color: var(--background-tertiary);
|
||||
border: 2px solid var(--background-modifier-accent);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.codeChar {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.codeSeparator {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 1.5rem 0;
|
||||
}
|
||||
|
||||
.spinnerIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 3px solid var(--background-modifier-accent);
|
||||
border-top-color: var(--brand-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: hsla(0, calc(100% * var(--saturation-factor)), 50%, 0.1);
|
||||
border: 1px solid hsla(0, calc(100% * var(--saturation-factor)), 50%, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--status-danger);
|
||||
margin: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
111
fluxer_app/src/components/auth/HandoffCodeDisplay.tsx
Normal file
111
fluxer_app/src/components/auth/HandoffCodeDisplay.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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 {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;
|
||||
isGenerating: boolean;
|
||||
error: string | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export function HandoffCodeDisplay({code, isGenerating, error, onRetry}: HandoffCodeDisplayProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopyCode = useCallback(async () => {
|
||||
if (!code) return;
|
||||
await TextCopyActionCreators.copy(i18n, code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [code]);
|
||||
|
||||
if (isGenerating) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.title}>
|
||||
<Trans>Generating code...</Trans>
|
||||
</h1>
|
||||
<div className={styles.spinner}>
|
||||
<span className={styles.spinnerIcon} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.title}>
|
||||
<Trans>Something went wrong</Trans>
|
||||
</h1>
|
||||
<p className={styles.error}>{error}</p>
|
||||
{onRetry && (
|
||||
<Button onClick={onRetry} fitContainer>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codeWithoutHyphen = code.replace(/-/g, '');
|
||||
const codePart1 = codeWithoutHyphen.slice(0, 4);
|
||||
const codePart2 = codeWithoutHyphen.slice(4, 8);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.title}>
|
||||
<Trans>Your code is ready!</Trans>
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
<Trans>Paste it where you came from to complete sign-in.</Trans>
|
||||
</p>
|
||||
|
||||
<div className={styles.codeSection}>
|
||||
<p className={styles.codeLabel}>
|
||||
<Trans>Your code</Trans>
|
||||
</p>
|
||||
<div className={styles.codeDisplay}>
|
||||
<span className={styles.codeChar}>{codePart1}</span>
|
||||
<span className={styles.codeSeparator}>-</span>
|
||||
<span className={styles.codeChar}>{codePart2}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCopyCode}
|
||||
leftIcon={copied ? <CheckCircleIcon size={16} weight="bold" /> : <ClipboardIcon size={16} />}
|
||||
variant="secondary"
|
||||
>
|
||||
{copied ? <Trans>Copied!</Trans> : <Trans>Copy code</Trans>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
248
fluxer_app/src/components/auth/InviteHeader.tsx
Normal file
248
fluxer_app/src/components/auth/InviteHeader.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
interface InviteHeaderProps {
|
||||
invite: Invite;
|
||||
}
|
||||
|
||||
interface GuildInviteHeaderProps {
|
||||
invite: GuildInvite;
|
||||
}
|
||||
|
||||
interface GroupDMInviteHeaderProps {
|
||||
invite: GroupDmInvite;
|
||||
}
|
||||
|
||||
interface PackInviteHeaderProps {
|
||||
invite: PackInvite;
|
||||
}
|
||||
|
||||
interface PreviewGuildInviteHeaderProps {
|
||||
guildId: string;
|
||||
guildName: string;
|
||||
guildIcon: string | null;
|
||||
isVerified: boolean;
|
||||
presenceCount: number;
|
||||
memberCount: number;
|
||||
previewIconUrl?: string | null;
|
||||
previewName?: string | null;
|
||||
}
|
||||
|
||||
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 memberCount = invite.member_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
<div className={styles.entityIconWrapper}>
|
||||
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} className={styles.entityIcon} sizePx={80} />
|
||||
</div>
|
||||
<div className={styles.entityDetails}>
|
||||
<p className={styles.entityText}>
|
||||
<Trans>You've been invited to join</Trans>
|
||||
</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}
|
||||
</div>
|
||||
<div className={styles.entityStats}>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.onlineDot} />
|
||||
<span className={styles.statText}>
|
||||
<Trans>{invite.presence_count} Online</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.offlineDot} />
|
||||
<span className={styles.statText}>
|
||||
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const GroupDMInviteHeader = observer(function GroupDMInviteHeader({invite}: GroupDMInviteHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
const inviter = invite.inviter;
|
||||
const avatarUrl = inviter ? AvatarUtils.getUserAvatarURL(inviter, false) : null;
|
||||
const memberCount = invite.member_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
{inviter && avatarUrl ? <BaseAvatar size={80} avatarUrl={avatarUrl} shouldPlayAnimated={false} /> : null}
|
||||
<div className={styles.entityDetails}>
|
||||
<p className={styles.entityText}>
|
||||
<Trans>You've been invited to join a group DM by</Trans>
|
||||
</p>
|
||||
{inviter ? (
|
||||
<h2 className={styles.entityTitle}>
|
||||
{inviter.username}#{inviter.discriminator}
|
||||
</h2>
|
||||
) : null}
|
||||
<div className={styles.entityStats}>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.offlineDot} />
|
||||
<span className={styles.statText}>
|
||||
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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 packKindLabel = pack.type === 'emoji' ? t`Emoji pack` : t`Sticker pack`;
|
||||
const inviterTag = invite.inviter ? `${invite.inviter.username}#${invite.inviter.discriminator}` : null;
|
||||
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
<div className={styles.entityIconWrapper}>
|
||||
<Avatar user={creatorRecord} size={80} className={styles.entityIcon} />
|
||||
</div>
|
||||
<div className={styles.entityDetails}>
|
||||
<p className={styles.entityText}>
|
||||
<Trans>You've been invited to install</Trans>
|
||||
</p>
|
||||
<div className={styles.entityTitleWrapper}>
|
||||
<h2 className={styles.entityTitle}>{pack.name}</h2>
|
||||
<span className={styles.packBadge}>{packKindLabel}</span>
|
||||
</div>
|
||||
<p className={styles.packDescription}>{pack.description || t`No description provided.`}</p>
|
||||
<div className={styles.packMeta}>
|
||||
<span className={styles.packMetaText}>{t`Created by ${pack.creator.username}`}</span>
|
||||
{inviterTag ? <span className={styles.packMetaText}>{t`Invited by ${inviterTag}`}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function InviteHeader({invite}: InviteHeaderProps) {
|
||||
if (isGroupDmInvite(invite)) {
|
||||
return <GroupDMInviteHeader invite={invite} />;
|
||||
}
|
||||
|
||||
if (isPackInvite(invite)) {
|
||||
return <PackInviteHeader invite={invite} />;
|
||||
}
|
||||
|
||||
if (isGuildInvite(invite)) {
|
||||
return <GuildInviteHeader invite={invite} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const PreviewGuildInviteHeader = observer(function PreviewGuildInviteHeader({
|
||||
guildId,
|
||||
guildName,
|
||||
guildIcon,
|
||||
isVerified,
|
||||
presenceCount,
|
||||
memberCount,
|
||||
previewIconUrl,
|
||||
previewName,
|
||||
}: PreviewGuildInviteHeaderProps) {
|
||||
const {t} = useLingui();
|
||||
const displayName = previewName ?? guildName;
|
||||
const [hasPreviewIconError, setPreviewIconError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setPreviewIconError(false);
|
||||
}, [previewIconUrl]);
|
||||
|
||||
const shouldShowPreviewIcon = Boolean(previewIconUrl && !hasPreviewIconError);
|
||||
|
||||
return (
|
||||
<div className={styles.entityHeader}>
|
||||
<div className={styles.entityIconWrapper}>
|
||||
{shouldShowPreviewIcon ? (
|
||||
<img
|
||||
src={previewIconUrl as string}
|
||||
alt=""
|
||||
className={styles.entityIcon}
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
setPreviewIconError(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<GuildIcon id={guildId} name={displayName} icon={guildIcon} className={styles.entityIcon} sizePx={80} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.entityDetails}>
|
||||
<p className={styles.entityText}>
|
||||
<Trans>You've been invited to join</Trans>
|
||||
</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}
|
||||
</div>
|
||||
<div className={styles.entityStats}>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.onlineDot} />
|
||||
<span className={styles.statText}>
|
||||
<Trans>{presenceCount} Online</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.entityStat}>
|
||||
<div className={styles.offlineDot} />
|
||||
<span className={styles.statText}>
|
||||
{memberCount === 1 ? t`${memberCount} Member` : t`${memberCount} Members`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: var(--background-modifier-accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.025em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.retryingText {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
179
fluxer_app/src/components/auth/IpAuthorizationScreen.tsx
Normal file
179
fluxer_app/src/components/auth/IpAuthorizationScreen.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* 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 {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';
|
||||
|
||||
interface IpAuthorizationScreenProps {
|
||||
challenge: IpAuthorizationChallenge;
|
||||
onAuthorized: (payload: {token: string; userId: string}) => Promise<void> | void;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
|
||||
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 onAuthorizedRef = useRef(onAuthorized);
|
||||
onAuthorizedRef.current = onAuthorized;
|
||||
|
||||
useEffect(() => {
|
||||
setResendUsed(false);
|
||||
setResendIn(challenge.resendAvailableIn);
|
||||
setConnectionState('connecting');
|
||||
setRetryCount(0);
|
||||
}, [challenge]);
|
||||
|
||||
useEffect(() => {
|
||||
let es: EventSource | null = null;
|
||||
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let isMounted = true;
|
||||
|
||||
const connect = () => {
|
||||
if (!isMounted) return;
|
||||
|
||||
es = AuthenticationActionCreators.subscribeToIpAuthorization(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;
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
es?.close();
|
||||
if (retryTimeout) {
|
||||
clearTimeout(retryTimeout);
|
||||
}
|
||||
};
|
||||
}, [challenge.ticket]);
|
||||
|
||||
useEffect(() => {
|
||||
if (resendIn <= 0) return;
|
||||
const interval = setInterval(() => {
|
||||
setResendIn((prev) => (prev > 0 ? prev - 1 : 0));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [resendIn]);
|
||||
|
||||
const handleResend = useCallback(async () => {
|
||||
if (resendIn > 0 || resendUsed) return;
|
||||
try {
|
||||
await AuthenticationActionCreators.resendIpAuthorization(challenge.ticket);
|
||||
setResendUsed(true);
|
||||
setResendIn(30);
|
||||
} catch (error) {
|
||||
console.error('Failed to resend IP authorization email', error);
|
||||
}
|
||||
}, [challenge.ticket, resendIn, resendUsed]);
|
||||
|
||||
const handleRetryConnection = useCallback(() => {
|
||||
setRetryCount(0);
|
||||
setConnectionState('connecting');
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.icon}>
|
||||
{connectionState === '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>}
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
{connectionState === '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}>
|
||||
<Trans>Retry</Trans>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" onClick={handleResend} disabled={resendIn > 0 || resendUsed}>
|
||||
{resendUsed ? <Trans>Resent</Trans> : <Trans>Resend email</Trans>}
|
||||
{resendIn > 0 ? ` (${resendIn}s)` : ''}
|
||||
</Button>
|
||||
)}
|
||||
{onBack ? (
|
||||
<Button variant="secondary" onClick={onBack}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IpAuthorizationScreen;
|
||||
88
fluxer_app/src/components/auth/MfaScreen.module.css
Normal file
88
fluxer_app/src/components/auth/MfaScreen.module.css
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 0.5rem;
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.025em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.smsSection {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.webauthnSection {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footerButtons {
|
||||
margin-top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footerButton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.footerButton:hover,
|
||||
.footerButton:focus {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
158
fluxer_app/src/components/auth/MfaScreen.tsx
Normal file
158
fluxer_app/src/components/auth/MfaScreen.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* 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 {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;
|
||||
inviteCode?: string;
|
||||
onSuccess: (payload: LoginSuccessPayload) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const MfaScreen = ({challenge, inviteCode, onSuccess, onCancel}: MfaScreenProps) => {
|
||||
const {t} = useLingui();
|
||||
const codeId = useId();
|
||||
|
||||
const {
|
||||
form,
|
||||
isLoading,
|
||||
fieldErrors,
|
||||
selectedMethod,
|
||||
setSelectedMethod,
|
||||
smsSent,
|
||||
handleSendSms,
|
||||
handleWebAuthn,
|
||||
isWebAuthnLoading,
|
||||
supports,
|
||||
} = useMfaController({
|
||||
ticket: challenge.ticket,
|
||||
methods: {sms: challenge.sms, totp: challenge.totp, webauthn: challenge.webauthn},
|
||||
inviteCode,
|
||||
onLoginSuccess: onSuccess,
|
||||
});
|
||||
|
||||
if (!selectedMethod && (supports.sms || supports.webauthn || supports.totp)) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.title}>
|
||||
<Trans>Two-factor authentication</Trans>
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
<Trans>Choose a verification method</Trans>
|
||||
</p>
|
||||
<div className={styles.buttons}>
|
||||
{supports.totp && (
|
||||
<Button type="button" fitContainer onClick={() => setSelectedMethod('totp')}>
|
||||
<Trans>Authenticator App</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{supports.sms && (
|
||||
<Button type="button" fitContainer variant="secondary" onClick={() => setSelectedMethod('sms')}>
|
||||
<Trans>SMS Code</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{supports.webauthn && (
|
||||
<Button
|
||||
type="button"
|
||||
fitContainer
|
||||
variant="secondary"
|
||||
onClick={handleWebAuthn}
|
||||
disabled={isWebAuthnLoading}
|
||||
>
|
||||
<Trans>Security Key / Passkey</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Button type="button" variant="secondary" onClick={onCancel} className={styles.footerButton}>
|
||||
<Trans>Back to login</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h1 className={styles.title}>
|
||||
<Trans>Two-factor authentication</Trans>
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
{selectedMethod === 'sms' ? (
|
||||
<Trans>Enter the 6-digit code sent to your phone.</Trans>
|
||||
) : (
|
||||
<Trans>Enter the 6-digit code from your authenticator app or one of your backup codes.</Trans>
|
||||
)}
|
||||
</p>
|
||||
{selectedMethod === 'sms' && !smsSent && supports.sms && (
|
||||
<div className={styles.smsSection}>
|
||||
<Button type="button" fitContainer onClick={handleSendSms}>
|
||||
<Trans>Send SMS Code</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{supports.webauthn && (
|
||||
<div className={styles.webauthnSection}>
|
||||
<Button type="button" fitContainer variant="secondary" onClick={handleWebAuthn} disabled={isWebAuthnLoading}>
|
||||
<Trans>Try security key / passkey instead</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<form className={styles.form} onSubmit={form.handleSubmit}>
|
||||
<FormField
|
||||
id={codeId}
|
||||
name="code"
|
||||
type="text"
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
label={t`Code`}
|
||||
value={form.getValue('code')}
|
||||
onChange={(value) => form.setValue('code', value)}
|
||||
error={form.getError('code') || fieldErrors?.code}
|
||||
/>
|
||||
<Button type="submit" fitContainer disabled={isLoading || form.isSubmitting}>
|
||||
<Trans>Log in</Trans>
|
||||
</Button>
|
||||
</form>
|
||||
<div className={styles.footerButtons}>
|
||||
{(supports.sms || supports.webauthn || supports.totp) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setSelectedMethod(null)}
|
||||
className={styles.footerButton}
|
||||
>
|
||||
<Trans>Try another method</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="secondary" onClick={onCancel} className={styles.footerButton}>
|
||||
<Trans>Back to login</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MfaScreen;
|
||||
135
fluxer_app/src/components/auth/MockMinimalRegisterForm.tsx
Normal file
135
fluxer_app/src/components/auth/MockMinimalRegisterForm.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* 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 {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;
|
||||
}
|
||||
|
||||
export function MockMinimalRegisterForm({submitLabel}: MockMinimalRegisterFormProps) {
|
||||
const {t} = useLingui();
|
||||
const locale = getCurrentLocale();
|
||||
const fieldOrder = useMemo(() => getDateFieldOrder(locale), [locale]);
|
||||
|
||||
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} />
|
||||
</div>
|
||||
),
|
||||
day: (
|
||||
<div key="day" className={dobStyles.dayField}>
|
||||
<input type="text" 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} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const orderedFields = fieldOrder.map((fieldType) => dateFields[fieldType]);
|
||||
|
||||
return (
|
||||
<div className={authStyles.form}>
|
||||
<div className={inputStyles.fieldset}>
|
||||
<div className={inputStyles.labelContainer}>
|
||||
<span className={inputStyles.label}>
|
||||
<Trans>Display name (optional)</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={inputStyles.inputGroup}>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
placeholder={t`What should people call you?`}
|
||||
className={inputStyles.input}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={dobStyles.fieldset}>
|
||||
<div className={dobStyles.labelContainer}>
|
||||
<span className={dobStyles.legend}>
|
||||
<Trans>Date of birth</Trans>
|
||||
</span>
|
||||
</div>
|
||||
<div className={dobStyles.inputsContainer}>
|
||||
<div className={dobStyles.fieldsRow}>{orderedFields}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={authStyles.consentRow}>
|
||||
<Checkbox checked={false} onChange={() => {}} disabled>
|
||||
<span className={authStyles.consentLabel}>
|
||||
<Trans>I agree to the</Trans>{' '}
|
||||
<ExternalLink href={Routes.terms()} className={authStyles.policyLink}>
|
||||
<Trans>Terms of Service</Trans>
|
||||
</ExternalLink>{' '}
|
||||
<Trans>and</Trans>{' '}
|
||||
<ExternalLink href={Routes.privacy()} className={authStyles.policyLink}>
|
||||
<Trans>Privacy Policy</Trans>
|
||||
</ExternalLink>
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<Button type="button" fitContainer disabled>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
fluxer_app/src/components/auth/SubmitTooltip.module.css
Normal file
22
fluxer_app/src/components/auth/SubmitTooltip.module.css
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.buttonWrapper {
|
||||
width: 100%;
|
||||
}
|
||||
73
fluxer_app/src/components/auth/SubmitTooltip.tsx
Normal file
73
fluxer_app/src/components/auth/SubmitTooltip.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 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;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SubmitTooltipProps {
|
||||
children: ReactNode;
|
||||
consent: boolean;
|
||||
missingFields?: Array<MissingField>;
|
||||
}
|
||||
|
||||
const CONSENT_REQUIRED_DESCRIPTOR = msg`You must agree to the Terms of Service and Privacy Policy to create an account`;
|
||||
const getMissingFieldsDescriptor = (fieldList: string): MessageDescriptor =>
|
||||
msg`Please fill out the following fields: ${fieldList}`;
|
||||
|
||||
function getTooltipContentDescriptor(consent: boolean, missingFields: Array<MissingField>): MessageDescriptor | null {
|
||||
if (!consent) {
|
||||
return CONSENT_REQUIRED_DESCRIPTOR;
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
const fieldList = missingFields.map((f) => f.label).join(', ');
|
||||
return getMissingFieldsDescriptor(fieldList);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function shouldDisableSubmit(consent: boolean, missingFields: Array<MissingField>): boolean {
|
||||
return !consent || missingFields.length > 0;
|
||||
}
|
||||
|
||||
export function SubmitTooltip({children, consent, missingFields = []}: SubmitTooltipProps) {
|
||||
const {t} = useLingui();
|
||||
const tooltipContentDescriptor = getTooltipContentDescriptor(consent, missingFields);
|
||||
const tooltipContent = tooltipContentDescriptor ? t(tooltipContentDescriptor) : null;
|
||||
|
||||
if (!tooltipContent) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip text={tooltipContent} position="top">
|
||||
<div className={styles.buttonWrapper}>{children}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
59
fluxer_app/src/components/auth/UsernameSuggestions.tsx
Normal file
59
fluxer_app/src/components/auth/UsernameSuggestions.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 {Trans} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import styles from './UsernameSuggestions.module.css';
|
||||
|
||||
interface UsernameSuggestionsProps {
|
||||
suggestions: Array<string>;
|
||||
onSelect: (username: string) => void;
|
||||
}
|
||||
|
||||
export const UsernameSuggestions = observer(function UsernameSuggestions({
|
||||
suggestions,
|
||||
onSelect,
|
||||
}: UsernameSuggestionsProps) {
|
||||
if (suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<p className={styles.label}>
|
||||
<Trans>Suggested usernames:</Trans>
|
||||
</p>
|
||||
<div className={styles.suggestionsList}>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => onSelect(suggestion)}
|
||||
className={styles.suggestionButton}
|
||||
style={{
|
||||
animationDelay: `${index * 50}ms`,
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
502
fluxer_app/src/components/bottomsheets/ChannelBottomSheet.tsx
Normal file
502
fluxer_app/src/components/bottomsheets/ChannelBottomSheet.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
/*
|
||||
* 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 {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 {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';
|
||||
|
||||
interface ChannelBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
channel: ChannelRecord;
|
||||
guild?: GuildRecord;
|
||||
}
|
||||
|
||||
export const ChannelBottomSheet: React.FC<ChannelBottomSheetProps> = observer(({isOpen, onClose, channel, guild}) => {
|
||||
const {t, i18n} = 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, {
|
||||
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuBottomSheet
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
groups={menuGroups}
|
||||
title={channel.name ?? t`Channel Options`}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,924 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
.memberListItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
background-color: var(--background-secondary-alt);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.memberListItem:active {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.memberListItemOffline {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.memberContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.memberNameRow {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.memberName {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.memberCustomStatus {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.memberListItem:hover .memberCustomStatus,
|
||||
.memberListItem:active .memberCustomStatus {
|
||||
--emoji-show-animated: 1;
|
||||
}
|
||||
|
||||
.crownContainer {
|
||||
margin-top: 0.2em;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.crownIcon {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
color: hsl(39, 57%, 64%);
|
||||
}
|
||||
|
||||
.memberTag {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.memberGroupContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.memberGroupHeader {
|
||||
margin-bottom: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.memberGroupList {
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.memberDivider {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
height: 1px;
|
||||
background-color: var(--background-header-secondary);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.memberListContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.mainScroller {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
display: flex;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--background-tertiary);
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.actionButton:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.actionIcon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.channelInfo {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.channelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.channelIcon {
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
color: var(--text-primary-muted);
|
||||
}
|
||||
|
||||
.channelName {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.channelType {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.topicSection {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.topicLabel {
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: var(--text-primary-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topicContent {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.topicContentCollapsed {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.topicToggle {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
color: var(--text-link);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.topicToggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0 1rem 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
transition: all 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
background-color: var(--background-modifier-selected);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tabContent {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.recipientInfo {
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.recipientAvatarContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.recipientDetails {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.recipientName {
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.recipientDiscriminator {
|
||||
font-weight: 500;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.recipientStatus {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.muteOption {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.muteOption:active {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.muteOptionSelected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.muteCheckIcon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.topicMarkup {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.topicMarkup:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.topicMarkupCollapsed {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tabBarContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tabButton {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.tabButtonActive {
|
||||
color: var(--brand-primary-light);
|
||||
}
|
||||
|
||||
:global(.theme-light) .tabButtonActive {
|
||||
color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.tabButtonInactive {
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.tabButtonInactive:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.tabIcon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.dmMemberList {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dmMemberList:active {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.dmMemberName {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.iconSmall {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.iconMedium {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.iconLarge {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.channelInfoSection {
|
||||
position: relative;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
}
|
||||
|
||||
.channelInfoContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.channelAvatar {
|
||||
display: flex;
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--background-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channelInfoContent {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channelInfoUserContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channelInfoUsername {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channelInfoDiscriminator {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.channelInfoTitle {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.75rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channelInfoSubtitle {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channelInfoTag {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.channelNameWithIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.channelNameIcon {
|
||||
height: 1.125rem;
|
||||
width: 1.125rem;
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topicSectionContainer {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.topicWrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.topicExpandButton {
|
||||
margin-top: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.topicExpandButton:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.membersTabContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pinsTabContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dmMembersContainer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.newGroupButton {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--background-secondary-alt);
|
||||
padding: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newGroupIconContainer {
|
||||
display: flex;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.newGroupIconWhite {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.newGroupContent {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.newGroupTitle {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.newGroupSubtitle {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
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%;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.memberItemButton:active {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
:global(.theme-light) .memberListItem,
|
||||
:global(.theme-light) .memberItemButton {
|
||||
background-color: var(--background-modifier-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.memberItemContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.memberItemName {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.memberItemYou {
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.memberItemTags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.ownerCrown {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
color: hsl(39, 57%, 64%);
|
||||
}
|
||||
|
||||
.memberItemDivider {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
height: 1px;
|
||||
background-color: var(--background-header-secondary);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.muteSheetContainer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.muteSheetContent {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.muteStatusBanner {
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--background-secondary-alt);
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.muteStatusText {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.muteOptionsContainer {
|
||||
overflow: hidden;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.muteOptionButton {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
transition: background-color 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.muteOptionButton:active {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.muteOptionLabel {
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.muteOptionDivider {
|
||||
margin-left: 1rem;
|
||||
margin-right: 1rem;
|
||||
height: 1px;
|
||||
background-color: var(--background-header-secondary);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.quickActionsRow {
|
||||
padding: 0 1rem 0.75rem;
|
||||
}
|
||||
|
||||
.quickActionsScroll {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.quickActionButton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
padding: 0.625rem 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: var(--background-secondary-alt);
|
||||
color: var(--text-primary);
|
||||
transition:
|
||||
background-color 0.15s,
|
||||
transform 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.quickActionButton:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.quickActionButtonPressed {
|
||||
background-color: var(--background-modifier-active);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.quickActionButtonActive {
|
||||
background-color: var(--brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.quickActionButtonActive:hover {
|
||||
background-color: var(--brand-primary-light);
|
||||
}
|
||||
}
|
||||
|
||||
.quickActionButtonDanger {
|
||||
color: hsl(350, calc(90% * var(--saturation-factor)), 65%);
|
||||
}
|
||||
|
||||
.quickActionButtonDisabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.quickActionIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.quickActionLabel {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.theme-light) .quickActionButton {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
:global(.theme-light) .quickActionButton:hover {
|
||||
background-color: var(--background-modifier-active);
|
||||
}
|
||||
}
|
||||
|
||||
.addFriendsContainer {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.addFriendsDescription {
|
||||
padding: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
}
|
||||
|
||||
.addFriendsSelectorContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 400px;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.addFriendsFooter {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.skeletonItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.skeletonAvatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeletonInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.skeletonName {
|
||||
height: 1rem;
|
||||
width: 120px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeletonStatus {
|
||||
height: 0.75rem;
|
||||
width: 80px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeletonHeader {
|
||||
width: 100px;
|
||||
height: 0.875rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--background-secondary) 25%,
|
||||
var(--background-tertiary) 50%,
|
||||
var(--background-secondary) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: skeletonPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeletonPulse {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
1431
fluxer_app/src/components/bottomsheets/ChannelDetailsBottomSheet.tsx
Normal file
1431
fluxer_app/src/components/bottomsheets/ChannelDetailsBottomSheet.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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 {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}) => {
|
||||
const {t} = useLingui();
|
||||
return (
|
||||
<BottomSheet isOpen={isOpen} onClose={onClose} title={t`Pinned Messages`} snapPoints={[0, 1]} initialSnap={1}>
|
||||
<ChannelPinsContent channel={channel} onJump={onClose} />
|
||||
</BottomSheet>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,511 @@
|
||||
/*
|
||||
* 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 {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
CaretDownIcon,
|
||||
CircleNotchIcon,
|
||||
FunnelIcon,
|
||||
MagnifyingGlassIcon,
|
||||
SortAscendingIcon,
|
||||
UserIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
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';
|
||||
|
||||
interface ChannelSearchBottomSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
channel: ChannelRecord;
|
||||
}
|
||||
|
||||
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 [hasFilters, setHasFilters] = React.useState<Array<HasFilterType>>([]);
|
||||
const [fromUserIds, setFromUserIds] = React.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 {
|
||||
machineState,
|
||||
sortMode,
|
||||
scope,
|
||||
scopeOptions,
|
||||
hasSearched,
|
||||
performFilterSearch,
|
||||
goToPage,
|
||||
setSortMode,
|
||||
setScope,
|
||||
reset,
|
||||
} = useChannelSearch({channel});
|
||||
|
||||
const buildFilters = React.useCallback((): ChannelSearchFilters => {
|
||||
return {
|
||||
content: contentQuery.trim() || undefined,
|
||||
has: hasFilters.length > 0 ? hasFilters : undefined,
|
||||
authorIds: fromUserIds.length > 0 ? fromUserIds : undefined,
|
||||
};
|
||||
}, [contentQuery, hasFilters, fromUserIds]);
|
||||
|
||||
const handleSearch = React.useCallback(() => {
|
||||
const filters = buildFilters();
|
||||
if (!filters.content && !filters.has?.length && !filters.authorIds?.length) {
|
||||
return;
|
||||
}
|
||||
performFilterSearch(filters);
|
||||
}, [buildFilters, performFilterSearch]);
|
||||
|
||||
const handleClear = React.useCallback(() => {
|
||||
setContentQuery('');
|
||||
setHasFilters([]);
|
||||
setFromUserIds([]);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const handleNextPage = React.useCallback(() => {
|
||||
if (machineState.status !== 'success') return;
|
||||
const totalPages = Math.max(1, Math.ceil(machineState.total / machineState.hitsPerPage));
|
||||
if (machineState.page < totalPages) {
|
||||
goToPage(machineState.page + 1);
|
||||
}
|
||||
}, [machineState, goToPage]);
|
||||
|
||||
const handlePrevPage = React.useCallback(() => {
|
||||
if (machineState.status !== 'success' || machineState.page === 1) return;
|
||||
goToPage(machineState.page - 1);
|
||||
}, [machineState, goToPage]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (contentQuery) {
|
||||
setContentQuery('');
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleJump = (channelId: string, messageId: string) => {
|
||||
goToMessage(channelId, messageId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleTap = (message: MessageRecord) => {
|
||||
handleJump(message.channelId, message.id);
|
||||
};
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
(bypassConfirm = false) => {
|
||||
if (!selectedMessage) return;
|
||||
if (bypassConfirm) {
|
||||
MessageActionCreators.remove(selectedMessage.channelId, selectedMessage.id);
|
||||
} else {
|
||||
MessageActionCreators.showDeleteConfirmation(i18n, {message: selectedMessage});
|
||||
}
|
||||
},
|
||||
[t, selectedMessage],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasSearched) {
|
||||
const filters = buildFilters();
|
||||
if (filters.content || filters.has?.length || filters.authorIds?.length) {
|
||||
performFilterSearch(filters);
|
||||
}
|
||||
}
|
||||
}, [hasFilters, fromUserIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (machineState.status !== 'success' || machineState.results.length === 0) {
|
||||
clearChannelSearchHighlight();
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedQuery = contentQuery.trim();
|
||||
if (!trimmedQuery) {
|
||||
clearChannelSearchHighlight();
|
||||
return;
|
||||
}
|
||||
|
||||
const container = scrollerRef.current?.getScrollerNode();
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchTerms = trimmedQuery.split(/\s+/).filter((term) => term.length > 0);
|
||||
applyChannelSearchHighlight(container, searchTerms);
|
||||
|
||||
return () => {
|
||||
clearChannelSearchHighlight();
|
||||
};
|
||||
}, [machineState, contentQuery]);
|
||||
|
||||
const hasActiveFilters = hasFilters.length > 0 || fromUserIds.length > 0;
|
||||
const canSearch = contentQuery.trim() || hasActiveFilters;
|
||||
|
||||
const getFromUserLabel = (): string => {
|
||||
if (fromUserIds.length === 0) return '';
|
||||
if (fromUserIds.length === 1) {
|
||||
const user = UserStore.getUser(fromUserIds[0]);
|
||||
return user?.username ?? t`1 user`;
|
||||
}
|
||||
return t`${fromUserIds.length} users`;
|
||||
};
|
||||
|
||||
const getHasFilterLabel = (): string => {
|
||||
if (hasFilters.length === 0) return '';
|
||||
if (hasFilters.length === 1) return hasFilters[0];
|
||||
return t`${hasFilters.length} types`;
|
||||
};
|
||||
|
||||
const activeScopeOption = scopeOptions.find((opt) => opt.value === scope) ?? scopeOptions[0];
|
||||
|
||||
const SearchResultItem: React.FC<{message: MessageRecord; messageChannel: ChannelRecord}> = observer(
|
||||
({message, messageChannel}) => {
|
||||
return (
|
||||
<LongPressable
|
||||
className={styles.searchResultItem}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleTap(message)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleTap(message);
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
setSelectedMessage(message);
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<Message message={message} channel={messageChannel!} previewContext={MessagePreviewContext.LIST_POPOUT} />
|
||||
</LongPressable>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!hasSearched) {
|
||||
return (
|
||||
<div className={styles.emptyStateContainer}>
|
||||
<div className={styles.emptyStateContent}>
|
||||
<MagnifyingGlassIcon className={styles.emptyStateIcon} />
|
||||
<h3 className={styles.emptyStateTitle}>
|
||||
<Trans>Search Messages</Trans>
|
||||
</h3>
|
||||
<p className={styles.emptyStateDescription}>
|
||||
<Trans>Use filters or enter keywords to find messages</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
switch (machineState.status) {
|
||||
case 'idle':
|
||||
case 'loading':
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<CircleNotchIcon className={styles.loadingIcon} />
|
||||
</div>
|
||||
);
|
||||
case 'indexing':
|
||||
return (
|
||||
<div className={styles.indexingContainer}>
|
||||
<CircleNotchIcon className={styles.indexingIcon} />
|
||||
<div className={styles.indexingContent}>
|
||||
<h3 className={styles.indexingTitle}>
|
||||
<Trans>Indexing Channel</Trans>
|
||||
</h3>
|
||||
<p className={styles.indexingDescription}>
|
||||
<Trans>We're indexing this channel for the first time. This might take a little while...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<div className={styles.errorContainer}>
|
||||
<div className={styles.errorContent}>
|
||||
<h3 className={styles.errorTitle}>
|
||||
<Trans>Error</Trans>
|
||||
</h3>
|
||||
<p className={styles.errorMessage}>{machineState.error}</p>
|
||||
<Button variant="secondary" onClick={handleSearch}>
|
||||
<Trans>Try Again</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'success': {
|
||||
const {results, total, hitsPerPage, page: currentPage} = machineState;
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<div className={styles.emptyStateContainer}>
|
||||
<div className={styles.emptyStateContent}>
|
||||
<MagnifyingGlassIcon className={styles.emptyStateIcon} />
|
||||
<div className={styles.emptyStateContent}>
|
||||
<h3 className={styles.emptyStateTitle}>
|
||||
<Trans>No Results</Trans>
|
||||
</h3>
|
||||
<p className={styles.emptyStateDescription}>
|
||||
<Trans>Try different filters or search terms</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const totalPages = Math.max(1, Math.ceil(total / hitsPerPage));
|
||||
const messagesByChannel = new Map<string, Array<MessageRecord>>();
|
||||
for (const message of results) {
|
||||
if (!messagesByChannel.has(message.channelId)) {
|
||||
messagesByChannel.set(message.channelId, []);
|
||||
}
|
||||
messagesByChannel.get(message.channelId)!.push(message);
|
||||
}
|
||||
const hasMultipleChannels = messagesByChannel.size > 1;
|
||||
return (
|
||||
<>
|
||||
<Scroller ref={scrollerRef} className={styles.resultsScroller} key="channel-search-results-scroller">
|
||||
{Array.from(messagesByChannel.entries()).map(([channelId, messages]) => {
|
||||
const messageChannel = ChannelStore.getChannel(channelId);
|
||||
return (
|
||||
<React.Fragment key={channelId}>
|
||||
{hasMultipleChannels && messageChannel && (
|
||||
<div className={styles.channelSection}>
|
||||
{ChannelUtils.getIcon(messageChannel, {
|
||||
className: styles.channelIcon,
|
||||
})}
|
||||
<span className={styles.channelName}>{messageChannel.name || 'Unnamed Channel'}</span>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<SearchResultItem key={message.id} message={message} messageChannel={messageChannel!} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Scroller>
|
||||
{totalPages > 1 && (
|
||||
<div className={styles.paginationContainer}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className={styles.paginationButton}
|
||||
>
|
||||
<ArrowLeftIcon className={sharedStyles.iconSmall} />
|
||||
<Trans>Previous</Trans>
|
||||
</button>
|
||||
<span className={styles.paginationText}>
|
||||
<Trans>
|
||||
Page {currentPage} of {totalPages}
|
||||
</Trans>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className={styles.paginationButton}
|
||||
>
|
||||
<Trans>Next</Trans>
|
||||
<ArrowRightIcon className={sharedStyles.iconSmall} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const headerSubtitle =
|
||||
machineState.status === 'success' && machineState.total > 0 ? <Trans>{machineState.total} Results</Trans> : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomSheet
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
snapPoints={[0, 1]}
|
||||
initialSnap={1}
|
||||
disablePadding={true}
|
||||
title={t`Search`}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.searchContainer}>
|
||||
<div className={styles.searchInputWrapper}>
|
||||
<MagnifyingGlassIcon className={styles.searchIcon} weight="bold" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={contentQuery}
|
||||
onChange={(e) => setContentQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t`Search messages`}
|
||||
className={styles.searchInput}
|
||||
/>
|
||||
{(contentQuery || hasActiveFilters) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className={styles.clearButton}
|
||||
aria-label={t`Clear search`}
|
||||
>
|
||||
<XIcon className={sharedStyles.icon} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.filterChipsRow}>
|
||||
<SearchFilterChip
|
||||
label={t`From`}
|
||||
value={getFromUserLabel()}
|
||||
icon={<UserIcon size={14} weight="bold" />}
|
||||
onPress={() => setUserSheetOpen(true)}
|
||||
onRemove={fromUserIds.length > 0 ? () => setFromUserIds([]) : undefined}
|
||||
isActive={fromUserIds.length > 0}
|
||||
/>
|
||||
<SearchFilterChip
|
||||
label={t`Has`}
|
||||
value={getHasFilterLabel()}
|
||||
icon={<FunnelIcon size={14} weight="bold" />}
|
||||
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" />}
|
||||
onPress={() => setSortSheetOpen(true)}
|
||||
isActive={false}
|
||||
/>
|
||||
<SearchFilterChip
|
||||
label={activeScopeOption?.label ?? t`Scope`}
|
||||
icon={<CaretDownIcon size={14} weight="bold" />}
|
||||
onPress={() => setScopeSheetOpen(true)}
|
||||
isActive={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onClick={handleSearch} disabled={!canSearch} className={styles.searchButton}>
|
||||
<Trans>Search</Trans>
|
||||
</Button>
|
||||
|
||||
{headerSubtitle && <p className={styles.searchResults}>{headerSubtitle}</p>}
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
<HasFilterSheet
|
||||
isOpen={hasSheetOpen}
|
||||
onClose={() => setHasSheetOpen(false)}
|
||||
selectedFilters={hasFilters}
|
||||
onFiltersChange={setHasFilters}
|
||||
/>
|
||||
<UserFilterSheet
|
||||
isOpen={userSheetOpen}
|
||||
onClose={() => setUserSheetOpen(false)}
|
||||
channel={channel}
|
||||
selectedUserIds={fromUserIds}
|
||||
onUsersChange={setFromUserIds}
|
||||
title={t`From user`}
|
||||
/>
|
||||
<SortModeSheet
|
||||
isOpen={sortSheetOpen}
|
||||
onClose={() => setSortSheetOpen(false)}
|
||||
selectedMode={sortMode}
|
||||
onModeChange={setSortMode}
|
||||
/>
|
||||
<ScopeSheet
|
||||
isOpen={scopeSheetOpen}
|
||||
onClose={() => setScopeSheetOpen(false)}
|
||||
selectedScope={scope}
|
||||
scopeOptions={scopeOptions}
|
||||
onScopeChange={setScope}
|
||||
/>
|
||||
|
||||
{selectedMessage && (
|
||||
<MessageActionBottomSheet
|
||||
isOpen={menuOpen}
|
||||
onClose={() => {
|
||||
setMenuOpen(false);
|
||||
setSelectedMessage(null);
|
||||
}}
|
||||
message={selectedMessage}
|
||||
handleDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user