initial commit

This commit is contained in:
Hampus Kraft
2026-01-01 20:42:59 +00:00
commit 2f557eda8c
9029 changed files with 1490197 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

View File

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

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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 MessageForwardFailedModal = observer(() => {
const {t} = useLingui();
return (
<GenericErrorModal
title={t`Failed to forward message`}
message={t`We couldn't forward the message at this time.`}
/>
);
});

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

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

View File

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

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

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

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

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

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

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

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

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

View File

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

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

View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {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={() => {}}
/>
);
});

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

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

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

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

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

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

View 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/>.
*/
.footer {
display: flex;
width: 100%;
flex-direction: column;
gap: 0.5rem;
}
.fullWidth {
width: 100%;
}

View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {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>
);
},
);

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

View 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/>.
*/
.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;
}

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

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

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

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

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

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

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

View 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/>.
*/
.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;
}

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

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

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

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

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

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

View File

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

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

View File

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