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,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/>.
*/
.previewWrapper {
background: var(--background-secondary-lighter);
}
.previewContainer {
padding: 16px;
border-radius: 8px;
border: 1px solid var(--background-modifier-accent);
}
.previewActionsRow {
position: relative;
display: flex;
align-items: center;
gap: 0.75rem;
}
.previewAvatarsRow {
display: flex;
gap: 0.75rem;
}
.previewMessageContainer {
margin-top: 0.75rem;
}

View File

@@ -0,0 +1,164 @@
/*
* 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 {MessagePreviewContext, MessageStates, MessageTypes, StatusTypes} from '~/Constants';
import {Message} from '~/components/channel/Message';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
import {Button} from '~/components/uikit/Button/Button';
import {MockAvatar} from '~/components/uikit/MockAvatar';
import {ChannelRecord} from '~/records/ChannelRecord';
import {MessageRecord} from '~/records/MessageRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import UserStore from '~/stores/UserStore';
import {AnimationTabContent} from './AccessibilityTab/AnimationTab';
import {KeyboardTabContent} from './AccessibilityTab/KeyboardTab';
import {MotionTabContent} from './AccessibilityTab/MotionTab';
import {VisualTabContent} from './AccessibilityTab/VisualTab';
import styles from './AccessibilityTab.module.css';
export const AccessibilityTabPreview = observer(() => {
const {t} = useLingui();
const alwaysUnderlineLinks = AccessibilityStore.alwaysUnderlineLinks;
const fakeData = React.useMemo(() => {
const tabOpenedAt = new Date();
const currentUser = UserStore.getCurrentUser();
const author = currentUser?.toJSON() || {
id: 'preview-user',
username: 'PreviewUser',
discriminator: '0000',
global_name: 'Preview User',
avatar: null,
bot: false,
system: false,
flags: 0,
};
const fakeChannel = new ChannelRecord({
id: 'fake-accessibility-channel',
type: 0,
name: 'accessibility-preview',
position: 0,
parent_id: null,
topic: null,
url: null,
nsfw: false,
last_message_id: null,
last_pin_timestamp: null,
bitrate: null,
user_limit: null,
permission_overwrites: [],
});
const fakeMessage = new MessageRecord(
{
id: 'accessibility-preview-1',
channel_id: 'fake-accessibility-channel',
author,
type: MessageTypes.DEFAULT,
flags: 0,
pinned: false,
mention_everyone: false,
content: t`This shows how links appear: https://fluxer.app`,
timestamp: tabOpenedAt.toISOString(),
state: MessageStates.SENT,
},
{skipUserCache: true},
);
return {fakeChannel, fakeMessage};
}, []);
return (
<div className={styles.previewWrapper}>
<div className={styles.previewContainer}>
<div className={styles.previewActionsRow}>
<Button small={true} onClick={() => {}}>
{t`Preview Button`}
</Button>
<div className={styles.previewAvatarsRow}>
<MockAvatar size={32} status={StatusTypes.ONLINE} />
<MockAvatar size={32} status={StatusTypes.DND} />
<MockAvatar size={32} status={StatusTypes.IDLE} />
</div>
</div>
<div className={styles.previewMessageContainer}>
<Message
channel={fakeData.fakeChannel}
message={fakeData.fakeMessage}
previewContext={MessagePreviewContext.SETTINGS}
previewOverrides={{
usernameColor: '#e91e63',
...(alwaysUnderlineLinks
? {
linkStyle: 'always-underline',
}
: {}),
}}
/>
</div>
</div>
</div>
);
});
const AccessibilityTabComponent: React.FC = observer(() => {
const {t} = useLingui();
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsSection
id="visual"
title={t`Visual`}
description={t`Customize visual elements to improve visibility and readability.`}
>
<VisualTabContent />
</SettingsSection>
<SettingsSection id="keyboard" title={t`Keyboard`} description={t`Customize keyboard navigation behavior.`}>
<KeyboardTabContent />
</SettingsSection>
<SettingsSection
id="animation"
title={t`Animation`}
description={t`Control animated content throughout the app.`}
>
<AnimationTabContent />
</SettingsSection>
<SettingsSection
id="motion"
title={t`Motion`}
description={t`Control animations and transitions throughout the app.`}
isAdvanced
defaultExpanded={false}
>
<MotionTabContent />
</SettingsSection>
</SettingsTabContent>
</SettingsTabContainer>
);
});
export default AccessibilityTabComponent;

View File

@@ -0,0 +1,42 @@
/*
* 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/>.
*/
.radioSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.radioHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.radioLabel {
display: block;
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.radioDescription {
color: var(--text-primary-muted);
font-size: 0.875rem;
}

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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import {StickerAnimationOptions} from '~/Constants';
import {Switch} from '~/components/form/Switch';
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
import AccessibilityOverrideStore, {type AnimationOverrides} from '~/stores/AccessibilityOverrideStore';
import AccessibilityStore from '~/stores/AccessibilityStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import styles from './AnimationTab.module.css';
export const AnimationTabContent: React.FC = observer(() => {
const {t} = useLingui();
const mobileLayout = MobileLayoutStore;
const mobileStickerAnimationOverridden = AccessibilityStore.mobileStickerAnimationOverridden;
const mobileGifAutoPlayOverridden = AccessibilityStore.mobileGifAutoPlayOverridden;
const stickerAnimationOptions = React.useMemo(
() =>
[
{value: StickerAnimationOptions.ALWAYS_ANIMATE, name: t`Always animate`, desc: t`Stickers will always animate`},
{
value: StickerAnimationOptions.ANIMATE_ON_INTERACTION,
name: t`Animate on interaction`,
desc: mobileLayout.enabled
? t`Stickers will animate when you press them`
: t`Stickers will animate when you hover or interact with them`,
},
{value: StickerAnimationOptions.NEVER_ANIMATE, name: t`Never animate`, desc: t`Stickers will never animate`},
] as ReadonlyArray<RadioOption<number>>,
[mobileLayout.enabled, t],
);
const getOverrideDescription = (
setting: 'gif_auto_play' | 'animate_emoji' | 'animate_stickers',
): string | undefined => {
if (AccessibilityOverrideStore.isOverriddenByReducedMotion(setting)) {
return t`This setting is currently overridden by your reduced motion preferences`;
}
return;
};
const handleAnimationSettingChange = (
setting: 'gif_auto_play' | 'animate_emoji' | 'animate_stickers',
_value: unknown,
updateAction: () => void,
) => {
const dirtyKey: keyof AnimationOverrides =
setting === 'gif_auto_play'
? 'gifAutoPlayDirty'
: setting === 'animate_emoji'
? 'animateEmojiDirty'
: 'animateStickersDirty';
AccessibilityOverrideStore.markDirty(dirtyKey);
updateAction();
};
return (
<>
<Switch
label={t`Play animated emojis`}
description={getOverrideDescription('animate_emoji')}
value={UserSettingsStore.getAnimateEmoji()}
onChange={(value) => {
if (mobileLayout.enabled) {
AccessibilityActionCreators.update({
mobileAnimateEmojiOverridden: true,
mobileAnimateEmojiValue: value,
});
} else {
handleAnimationSettingChange('animate_emoji', value, () =>
UserSettingsActionCreators.update({animateEmoji: value}),
);
}
}}
/>
<Switch
label={mobileLayout.enabled ? t`Automatically play GIFs` : t`Automatically play GIFs when Fluxer is focused`}
description={
mobileLayout.enabled && !mobileGifAutoPlayOverridden
? t`Defaults to off on mobile to preserve battery life and data usage.`
: getOverrideDescription('gif_auto_play')
}
value={UserSettingsStore.getGifAutoPlay()}
onChange={(value) => {
if (mobileLayout.enabled) {
AccessibilityActionCreators.update({
mobileGifAutoPlayOverridden: true,
mobileGifAutoPlayValue: value,
});
} else {
handleAnimationSettingChange('gif_auto_play', value, () =>
UserSettingsActionCreators.update({gifAutoPlay: value}),
);
}
}}
/>
<div className={styles.radioSection}>
<div className={styles.radioHeader}>
<div className={styles.radioLabel}>
<Trans>Sticker animations</Trans>
</div>
{mobileLayout.enabled && !mobileStickerAnimationOverridden ? (
<p className={styles.radioDescription}>
{t`Defaults to animate on interaction on mobile to preserve battery life.`}
</p>
) : (
getOverrideDescription('animate_stickers') && (
<p className={styles.radioDescription}>{getOverrideDescription('animate_stickers')}</p>
)
)}
</div>
<RadioGroup
aria-label={t`Sticker animation preference`}
options={stickerAnimationOptions}
value={UserSettingsStore.getAnimateStickers()}
onChange={(value) => {
if (mobileLayout.enabled) {
AccessibilityActionCreators.update({
mobileStickerAnimationOverridden: true,
mobileStickerAnimationValue: value,
});
} else {
handleAnimationSettingChange('animate_stickers', value, () =>
UserSettingsActionCreators.update({animateStickers: value}),
);
}
}}
/>
</div>
</>
);
});

View File

@@ -0,0 +1,23 @@
/*
* 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;
}

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 {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import {AnimationTabContent} from './AnimationTab';
import styles from './Inline.module.css';
import {MotionTabContent} from './MotionTab';
import {VisualTabContent} from './VisualTab';
export const AccessibilityInlineTab: React.FC = observer(() => {
const {t} = useLingui();
return (
<div className={styles.container}>
<SettingsSection id="accessibility-visual" title={t`Visual`}>
<VisualTabContent />
</SettingsSection>
<SettingsSection id="accessibility-animation" title={t`Animation`}>
<AnimationTabContent />
</SettingsSection>
<SettingsSection id="accessibility-motion" title={t`Motion`}>
<MotionTabContent />
</SettingsSection>
</div>
);
});

View File

@@ -0,0 +1,49 @@
/*
* 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 type React from 'react';
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import {Switch} from '~/components/form/Switch';
import AccessibilityStore from '~/stores/AccessibilityStore';
export const KeyboardTabContent: React.FC = observer(() => {
const {t} = useLingui();
const showTextareaFocusRing = AccessibilityStore.showTextareaFocusRing;
const escapeExitsKeyboardMode = AccessibilityStore.escapeExitsKeyboardMode;
return (
<>
<Switch
label={t`Show focus ring on chat textarea`}
description={t`Display a visible focus indicator around the message input when focused. Disable for a more subtle appearance.`}
value={showTextareaFocusRing}
onChange={(value) => AccessibilityActionCreators.update({showTextareaFocusRing: value})}
/>
<Switch
label={t`Escape key exits keyboard mode`}
description={t`Allow pressing Escape to exit keyboard navigation mode. Note: This may conflict with other uses of Escape.`}
value={escapeExitsKeyboardMode}
onChange={(value) => AccessibilityActionCreators.update({escapeExitsKeyboardMode: value})}
/>
</>
);
});

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 type React from 'react';
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import {Switch} from '~/components/form/Switch';
import AccessibilityStore from '~/stores/AccessibilityStore';
export const MotionTabContent: React.FC = observer(() => {
const {t} = useLingui();
const syncReducedMotionWithSystem = AccessibilityStore.syncReducedMotionWithSystem;
const reducedMotionOverride = AccessibilityStore.reducedMotionOverride;
return (
<>
<Switch
label={t`Sync reduced motion setting with system`}
description={t`Automatically use your system's reduced motion preference, or customize it below.`}
value={syncReducedMotionWithSystem}
onChange={(value) => AccessibilityActionCreators.update({syncReducedMotionWithSystem: value})}
/>
<Switch
label={t`Reduce motion`}
description={
syncReducedMotionWithSystem
? t`Disable animations and transitions. Currently controlled by your system setting.`
: t`Disable animations and transitions throughout the app.`
}
value={syncReducedMotionWithSystem ? AccessibilityStore.useReducedMotion : (reducedMotionOverride ?? false)}
disabled={syncReducedMotionWithSystem}
onChange={(value) => AccessibilityActionCreators.update({reducedMotionOverride: value})}
/>
</>
);
});

View File

@@ -0,0 +1,43 @@
/*
* 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/>.
*/
.sliderSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sliderHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sliderLabel {
display: block;
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.sliderDescription {
margin-bottom: 0.5rem;
color: var(--text-primary-muted);
font-size: 0.875rem;
}

View File

@@ -0,0 +1,126 @@
/*
* 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 {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import {Switch} from '~/components/form/Switch';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
import {Slider} from '~/components/uikit/Slider';
import AccessibilityStore, {DMMessagePreviewMode} from '~/stores/AccessibilityStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import styles from './VisualTab.module.css';
const dmMessagePreviewOptions = (
t: (msg: MessageDescriptor) => string,
): ReadonlyArray<RadioOption<DMMessagePreviewMode>> => [
{
value: DMMessagePreviewMode.ALL,
name: t(msg`All messages`),
desc: t(msg`Show message previews for all DM conversations`),
},
{
value: DMMessagePreviewMode.UNREAD_ONLY,
name: t(msg`Unread DMs only`),
desc: t(msg`Only show message previews for DMs with unread messages`),
},
{
value: DMMessagePreviewMode.NONE,
name: t(msg`None`),
desc: t(msg`Don't show message previews in the DM list`),
},
];
export const VisualTabContent: React.FC = observer(() => {
const {t} = useLingui();
const saturationFactor = AccessibilityStore.saturationFactor;
const alwaysUnderlineLinks = AccessibilityStore.alwaysUnderlineLinks;
const enableTextSelection = AccessibilityStore.enableTextSelection;
const mobileLayout = MobileLayoutStore;
return (
<>
<div className={styles.sliderSection}>
<div className={styles.sliderHeader}>
<label htmlFor="saturation" className={styles.sliderLabel}>
<Trans>Saturation</Trans>
</label>
<p className={styles.sliderDescription}>
<Trans>Adjust the saturation of all theme colors.</Trans>
</p>
</div>
<Slider
defaultValue={saturationFactor * 100}
factoryDefaultValue={100}
minValue={0}
maxValue={100}
step={1}
markers={[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]}
stickToMarkers={false}
onMarkerRender={(value) => `${value}%`}
onValueRender={(value) => <Trans>{value}%</Trans>}
onValueChange={(value) => AccessibilityActionCreators.update({saturationFactor: value / 100})}
/>
</div>
<Switch
label={t(msg`Always underline links`)}
description={t(msg`Make links to websites stand out more by always underlining them.`)}
value={alwaysUnderlineLinks}
onChange={(value) => AccessibilityActionCreators.update({alwaysUnderlineLinks: value})}
/>
<SettingsTabSection
title={<Trans>Interaction</Trans>}
description={<Trans>Adjust how you interact with the app</Trans>}
>
<Switch
label={t(msg`Enable text selection`)}
description={
mobileLayout.enabled
? t(
msg`Allow selecting all text content in the app. This setting is disabled on mobile to prevent interference with touch interactions.`,
)
: t(msg`Allow selecting all text content in the app.`)
}
value={enableTextSelection}
disabled={mobileLayout.enabled}
onChange={(value) => AccessibilityActionCreators.update({enableTextSelection: value})}
/>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>DM Message Previews</Trans>}
description={<Trans>Control when message previews are shown in the DM list</Trans>}
>
<RadioGroup
options={dmMessagePreviewOptions(t)}
value={AccessibilityStore.dmMessagePreviewMode}
onChange={(value) => AccessibilityActionCreators.update({dmMessagePreviewMode: value})}
aria-label={t(msg`DM message preview mode`)}
/>
</SettingsTabSection>
</>
);
});

View File

@@ -0,0 +1,24 @@
/*
* 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-6);
}

View File

@@ -0,0 +1,114 @@
/*
* 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 type React from 'react';
import {useCallback, useEffect, useState} from 'react';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {UserAuthenticatorTypes, UserFlags} from '~/Constants';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
import UserStore from '~/stores/UserStore';
import {AccountTabContent} from './AccountSecurityTab/AccountTab';
import {DangerZoneTabContent} from './AccountSecurityTab/DangerZoneTab';
import {SecurityTabContent} from './AccountSecurityTab/SecurityTab';
const AccountSecurityTab: React.FC = observer(() => {
const {t} = useLingui();
const user = UserStore.currentUser;
const [showMaskedEmail, setShowMaskedEmail] = useState(false);
const [passkeys, setPasskeys] = useState<Array<UserActionCreators.WebAuthnCredential>>([]);
const [loadingPasskeys, setLoadingPasskeys] = useState(false);
const [enablingSmsMfa, setEnablingSmsMfa] = useState(false);
const [disablingSmsMfa, setDisablingSmsMfa] = useState(false);
const loadPasskeys = useCallback(async () => {
setLoadingPasskeys(true);
try {
const credentials = await UserActionCreators.listWebAuthnCredentials();
setPasskeys(credentials);
} catch (error) {
console.error('Failed to load passkeys', error);
} finally {
setLoadingPasskeys(false);
}
}, []);
useEffect(() => {
loadPasskeys();
}, [loadPasskeys]);
if (!user) return null;
const hasSmsMfa = user.authenticatorTypes?.includes(UserAuthenticatorTypes.SMS) ?? false;
const hasTotpMfa = user.authenticatorTypes?.includes(UserAuthenticatorTypes.TOTP) ?? false;
const isSmsMfaDisabledForUser =
(user.flags & UserFlags.STAFF) !== 0 ||
(user.flags & UserFlags.CTP_MEMBER) !== 0 ||
(user.flags & UserFlags.PARTNER) !== 0;
const isClaimed = user.isClaimed();
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsSection
id="account"
title={t`Account`}
description={t`Manage your email, password, and account settings`}
>
<AccountTabContent
user={user}
isClaimed={isClaimed}
showMaskedEmail={showMaskedEmail}
setShowMaskedEmail={setShowMaskedEmail}
/>
</SettingsSection>
<SettingsSection
id="security"
title={t`Security`}
description={t`Protect your account with two-factor authentication and passkeys`}
>
<SecurityTabContent
user={user}
isClaimed={isClaimed}
hasSmsMfa={hasSmsMfa}
hasTotpMfa={hasTotpMfa}
isSmsMfaDisabledForUser={isSmsMfaDisabledForUser}
passkeys={passkeys}
loadingPasskeys={loadingPasskeys}
enablingSmsMfa={enablingSmsMfa}
disablingSmsMfa={disablingSmsMfa}
loadPasskeys={loadPasskeys}
setEnablingSmsMfa={setEnablingSmsMfa}
setDisablingSmsMfa={setDisablingSmsMfa}
/>
</SettingsSection>
<SettingsSection id="danger_zone" title={t`Danger Zone`} description={t`Irreversible and destructive actions`}>
<DangerZoneTabContent user={user} isClaimed={isClaimed} />
</SettingsSection>
</SettingsTabContent>
</SettingsTabContainer>
);
});
export default AccountSecurityTab;

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/>.
*/
.row {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@media (min-width: 640px) {
.row {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
}
.rowContent {
flex: 1;
}
.label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.description {
color: var(--text-primary-muted);
font-size: 0.875rem;
}
.emailRow {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (min-width: 640px) {
.emailRow {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
}
.emailText {
color: var(--text-primary-muted);
font-size: 0.875rem;
}
.emailTextSelectable {
user-select: text;
-webkit-user-select: text;
}
.toggleButton {
margin-top: 0.1em;
text-align: left;
color: var(--text-link);
font-size: 0.875rem;
cursor: pointer;
}
.toggleButton:hover {
text-decoration: underline;
}
@media (min-width: 640px) {
.toggleButton {
text-align: center;
}
}
.warningText {
color: var(--alert-warning-color);
font-size: 0.875rem;
}
.divider {
border-top: 1px solid var(--background-header-secondary);
padding-top: 1rem;
}

View File

@@ -0,0 +1,147 @@
/*
* 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 * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {EmailChangeModal} from '~/components/modals/EmailChangeModal';
import {PasswordChangeModal} from '~/components/modals/PasswordChangeModal';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import {Button} from '~/components/uikit/Button/Button';
import type {UserRecord} from '~/records/UserRecord';
import * as DateUtils from '~/utils/DateUtils';
import {EmailVerificationAlert} from '../../components/EmailVerificationAlert';
import {UnclaimedAccountAlert} from '../../components/UnclaimedAccountAlert';
import styles from './AccountTab.module.css';
const maskEmail = (email: string): string => {
const [username, domain] = email.split('@');
const maskedUsername = username.replace(/./g, '*');
return `${maskedUsername}@${domain}`;
};
interface AccountTabProps {
user: UserRecord;
isClaimed: boolean;
showMaskedEmail: boolean;
setShowMaskedEmail: (show: boolean) => void;
}
export const AccountTabContent: React.FC<AccountTabProps> = observer(
({user, isClaimed, showMaskedEmail, setShowMaskedEmail}) => {
const {t, i18n} = useLingui();
return (
<>
{!isClaimed && <UnclaimedAccountAlert />}
<SettingsTabSection
title={<Trans>Email Settings</Trans>}
description={<Trans>Manage the email address you use to sign in to Fluxer</Trans>}
>
{isClaimed ? (
<>
<div className={styles.row}>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>Email Address</Trans>
</div>
<div className={styles.emailRow}>
<span className={`${styles.emailText} ${showMaskedEmail ? styles.emailTextSelectable : ''}`}>
{showMaskedEmail ? user.email : maskEmail(user.email!)}
</span>
<button
type="button"
className={styles.toggleButton}
onClick={() => setShowMaskedEmail(!showMaskedEmail)}
>
{showMaskedEmail ? t`Hide` : t`Reveal`}
</button>
</div>
</div>
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <EmailChangeModal />))}>
<Trans>Change Email</Trans>
</Button>
</div>
{user.email && !user.verified && <EmailVerificationAlert />}
</>
) : (
<div className={styles.row}>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>Email Address</Trans>
</div>
<div className={styles.warningText}>
<Trans>No email address set</Trans>
</div>
</div>
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Trans>Add Email</Trans>
</Button>
</div>
)}
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Password</Trans>}
description={<Trans>Change your password to keep your account secure</Trans>}
>
<div className={styles.row}>
{isClaimed ? (
<>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>Current Password</Trans>
</div>
<div className={styles.description}>
{user.passwordLastChangedAt ? (
<Trans>Last changed: {DateUtils.getRelativeDateString(user.passwordLastChangedAt, i18n)}</Trans>
) : (
<Trans>Last changed: Never</Trans>
)}
</div>
</div>
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <PasswordChangeModal />))}>
<Trans>Change Password</Trans>
</Button>
</>
) : (
<>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>Password</Trans>
</div>
<div className={styles.warningText}>
<Trans>No password set</Trans>
</div>
</div>
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Trans>Set Password</Trans>
</Button>
</>
)}
</div>
</SettingsTabSection>
</>
);
},
);

View File

@@ -0,0 +1,101 @@
/*
* 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 type React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {AccountDeleteModal} from '~/components/modals/AccountDeleteModal';
import {AccountDisableModal} from '~/components/modals/AccountDisableModal';
import {GuildOwnershipWarningModal} from '~/components/modals/GuildOwnershipWarningModal';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import {Button} from '~/components/uikit/Button/Button';
import type {UserRecord} from '~/records/UserRecord';
import GuildStore from '~/stores/GuildStore';
import styles from './AccountTab.module.css';
interface DangerZoneTabProps {
user: UserRecord;
isClaimed: boolean;
}
export const DangerZoneTabContent: React.FC<DangerZoneTabProps> = observer(({user, isClaimed}) => {
const handleDisableAccount = () => {
const ownedGuilds = GuildStore.getOwnedGuilds(user.id);
if (ownedGuilds.length > 0) {
ModalActionCreators.push(modal(() => <GuildOwnershipWarningModal ownedGuilds={ownedGuilds} action="disable" />));
} else {
ModalActionCreators.push(modal(() => <AccountDisableModal />));
}
};
const handleDeleteAccount = () => {
const ownedGuilds = GuildStore.getOwnedGuilds(user.id);
if (ownedGuilds.length > 0) {
ModalActionCreators.push(modal(() => <GuildOwnershipWarningModal ownedGuilds={ownedGuilds} action="delete" />));
} else {
ModalActionCreators.push(modal(() => <AccountDeleteModal />));
}
};
return (
<>
{isClaimed && (
<SettingsTabSection
title={<Trans>Disable Account</Trans>}
description={<Trans>Temporarily disable your account. You can reactivate it later.</Trans>}
>
<div className={styles.row}>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>Disable Account</Trans>
</div>
<div className={styles.description}>
<Trans>Temporarily disable your account. You can reactivate it later by signing back in.</Trans>
</div>
</div>
<Button variant="danger-secondary" small={true} onClick={handleDisableAccount}>
<Trans>Disable Account</Trans>
</Button>
</div>
</SettingsTabSection>
)}
<SettingsTabSection
title={<Trans>Delete Account</Trans>}
description={<Trans>Permanently delete your account and all associated data. This cannot be undone.</Trans>}
>
<div className={styles.row}>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>Delete Account</Trans>
</div>
<div className={styles.description}>
<Trans>Permanently delete your account and all associated data. This action cannot be undone.</Trans>
</div>
</div>
<Button variant="danger-primary" small={true} onClick={handleDeleteAccount}>
<Trans>Delete Account</Trans>
</Button>
</div>
</SettingsTabSection>
</>
);
});

View File

@@ -0,0 +1,23 @@
/*
* 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;
}

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 {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useState} from 'react';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {UserAuthenticatorTypes, UserFlags} from '~/Constants';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import UserStore from '~/stores/UserStore';
import {AccountTabContent as AccountTab} from './AccountTab';
import {DangerZoneTabContent as DangerZoneTab} from './DangerZoneTab';
import styles from './Inline.module.css';
import {SecurityTabContent as SecurityTab} from './SecurityTab';
export const AccountSecurityInlineTab = observer(() => {
const {t} = useLingui();
const user = UserStore.currentUser;
const [showMaskedEmail, setShowMaskedEmail] = useState(false);
const [passkeys, setPasskeys] = useState<Array<UserActionCreators.WebAuthnCredential>>([]);
const [loadingPasskeys, setLoadingPasskeys] = useState(false);
const [enablingSmsMfa, setEnablingSmsMfa] = useState(false);
const [disablingSmsMfa, setDisablingSmsMfa] = useState(false);
const loadPasskeys = useCallback(async () => {
setLoadingPasskeys(true);
try {
const credentials = await UserActionCreators.listWebAuthnCredentials();
setPasskeys(credentials);
} catch (error) {
console.error('Failed to load passkeys', error);
} finally {
setLoadingPasskeys(false);
}
}, []);
useEffect(() => {
loadPasskeys();
}, [loadPasskeys]);
if (!user) return null;
const hasSmsMfa = user.authenticatorTypes?.includes(UserAuthenticatorTypes.SMS) ?? false;
const hasTotpMfa = user.authenticatorTypes?.includes(UserAuthenticatorTypes.TOTP) ?? false;
const isSmsMfaDisabledForUser =
(user.flags & UserFlags.STAFF) !== 0 ||
(user.flags & UserFlags.CTP_MEMBER) !== 0 ||
(user.flags & UserFlags.PARTNER) !== 0;
const isClaimed = user.isClaimed();
return (
<div className={styles.container}>
<SettingsSection id="account" title={t`Account`}>
<AccountTab
user={user}
isClaimed={isClaimed}
showMaskedEmail={showMaskedEmail}
setShowMaskedEmail={setShowMaskedEmail}
/>
</SettingsSection>
<SettingsSection id="security" title={t`Security`}>
<SecurityTab
user={user}
isClaimed={isClaimed}
hasSmsMfa={hasSmsMfa}
hasTotpMfa={hasTotpMfa}
isSmsMfaDisabledForUser={isSmsMfaDisabledForUser}
passkeys={passkeys}
loadingPasskeys={loadingPasskeys}
enablingSmsMfa={enablingSmsMfa}
disablingSmsMfa={disablingSmsMfa}
loadPasskeys={loadPasskeys}
setEnablingSmsMfa={setEnablingSmsMfa}
setDisablingSmsMfa={setDisablingSmsMfa}
/>
</SettingsSection>
<SettingsSection id="danger_zone" title={t`Danger Zone`}>
<DangerZoneTab user={user} isClaimed={isClaimed} />
</SettingsSection>
</div>
);
});

View File

@@ -0,0 +1,98 @@
/*
* 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;
flex-direction: column;
gap: 0.75rem;
}
@media (min-width: 640px) {
.row {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
}
.rowContent {
flex: 1;
}
.label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.description {
color: var(--text-primary-muted);
font-size: 0.875rem;
}
.divider {
border-top: 1px solid var(--background-modifier-accent);
padding-top: 1rem;
}
.passkeyList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.passkeyItem {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
@media (min-width: 640px) {
.passkeyItem {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
}
.passkeyInfo {
flex: 1;
}
.passkeyName {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.passkeyDetails {
color: var(--text-primary-muted);
font-size: 0.75rem;
}
.passkeyActions {
display: flex;
gap: 0.5rem;
}

View File

@@ -0,0 +1,447 @@
/*
* 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 ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {BackupCodesViewModal} from '~/components/modals/BackupCodesViewModal';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {MfaTotpDisableModal} from '~/components/modals/MfaTotpDisableModal';
import {MfaTotpEnableModal} from '~/components/modals/MfaTotpEnableModal';
import {PasskeyNameModal} from '~/components/modals/PasskeyNameModal';
import {PhoneAddModal} from '~/components/modals/PhoneAddModal';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import {Button} from '~/components/uikit/Button/Button';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {UserRecord} from '~/records/UserRecord';
import * as DateUtils from '~/utils/DateUtils';
import * as WebAuthnUtils from '~/utils/WebAuthnUtils';
import styles from './SecurityTab.module.css';
interface SecurityTabProps {
user: UserRecord;
isClaimed: boolean;
hasSmsMfa: boolean;
hasTotpMfa: boolean;
isSmsMfaDisabledForUser: boolean;
passkeys: Array<UserActionCreators.WebAuthnCredential>;
loadingPasskeys: boolean;
enablingSmsMfa: boolean;
disablingSmsMfa: boolean;
loadPasskeys: () => Promise<void>;
setEnablingSmsMfa: React.Dispatch<React.SetStateAction<boolean>>;
setDisablingSmsMfa: React.Dispatch<React.SetStateAction<boolean>>;
}
export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
({
user,
isClaimed,
hasSmsMfa,
hasTotpMfa,
isSmsMfaDisabledForUser,
passkeys,
loadingPasskeys,
enablingSmsMfa,
disablingSmsMfa,
loadPasskeys,
setEnablingSmsMfa,
setDisablingSmsMfa,
}) => {
const {t, i18n} = useLingui();
const handleAddPasskey = async () => {
try {
const options = await UserActionCreators.getWebAuthnRegistrationOptions();
const credential = await WebAuthnUtils.performRegistration(options);
ModalActionCreators.push(
modal(() => (
<PasskeyNameModal
onSubmit={async (name: string) => {
await UserActionCreators.registerWebAuthnCredential(credential, options.challenge, name);
await loadPasskeys();
}}
/>
)),
);
} catch (error) {
console.error('Failed to add passkey', error);
}
};
const handleRenamePasskey = async (credentialId: string) => {
ModalActionCreators.push(
modal(() => (
<PasskeyNameModal
onSubmit={async (name: string) => {
try {
await UserActionCreators.renameWebAuthnCredential(credentialId, name);
await loadPasskeys();
} catch (error) {
console.error('Failed to rename passkey', error);
}
}}
/>
)),
);
};
const handleDeletePasskey = (credentialId: string) => {
const passkey = passkeys.find((p) => p.id === credentialId);
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Delete Passkey`}
description={
passkey ? (
<div>
<Trans>
Are you sure you want to delete the passkey <strong>{passkey.name}</strong>?
</Trans>
</div>
) : (
<Trans>Are you sure you want to delete this passkey?</Trans>
)
}
primaryText={t`Delete Passkey`}
primaryVariant="danger-primary"
onPrimary={async () => {
try {
await UserActionCreators.deleteWebAuthnCredential(credentialId);
await loadPasskeys();
} catch (error) {
console.error('Failed to delete passkey', error);
}
}}
/>
)),
);
};
const handleEnableSmsMfa = () => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Enable SMS Two-Factor Authentication`}
description={
<Trans>
SMS two-factor authentication adds an additional layer of security to your account by requiring a
verification code sent to your phone number when signing in.
</Trans>
}
primaryText={t`Enable SMS 2FA`}
primaryVariant="primary"
onPrimary={async () => {
setEnablingSmsMfa(true);
try {
await UserActionCreators.enableSmsMfa();
} catch (error) {
console.error('Failed to enable SMS MFA', error);
} finally {
setEnablingSmsMfa(false);
}
}}
/>
)),
);
};
const handleDisableSmsMfa = () => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Disable SMS Two-Factor Authentication`}
description={
<Trans>
Are you sure you want to disable SMS two-factor authentication? This will make your account less secure.
</Trans>
}
primaryText={t`Disable SMS 2FA`}
primaryVariant="danger-primary"
onPrimary={async () => {
setDisablingSmsMfa(true);
try {
await UserActionCreators.disableSmsMfa();
} catch (error) {
console.error('Failed to disable SMS MFA', error);
} finally {
setDisablingSmsMfa(false);
}
}}
/>
)),
);
};
if (!isClaimed) {
return (
<SettingsTabSection
title={<Trans>Security Features</Trans>}
description={
<Trans>Claim your account to access security features like two-factor authentication and passkeys.</Trans>
}
>
<Button onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
<Trans>Claim Account</Trans>
</Button>
</SettingsTabSection>
);
}
return (
<>
<SettingsTabSection
title={<Trans>Two-Factor Authentication</Trans>}
description={<Trans>Add an extra layer of security to your account</Trans>}
>
<div className={styles.row}>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>Authenticator App</Trans>
</div>
<div className={styles.description}>
{hasTotpMfa ? (
<Trans>Two-factor authentication is enabled</Trans>
) : (
<Trans>Use an authenticator app to generate codes for two-factor authentication</Trans>
)}
</div>
</div>
{hasTotpMfa ? (
<Button
variant="danger-secondary"
small={true}
onClick={() => ModalActionCreators.push(modal(() => <MfaTotpDisableModal />))}
>
<Trans>Disable</Trans>
</Button>
) : (
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <MfaTotpEnableModal />))}>
<Trans>Enable</Trans>
</Button>
)}
</div>
{hasTotpMfa && (
<div className={styles.divider}>
<div className={styles.row}>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>Backup Codes</Trans>
</div>
<div className={styles.description}>
<Trans>View and manage your backup codes for account recovery</Trans>
</div>
</div>
<Button
variant="secondary"
small={true}
onClick={() => ModalActionCreators.push(modal(() => <BackupCodesViewModal />))}
>
<Trans>View Codes</Trans>
</Button>
</div>
</div>
)}
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Passkeys</Trans>}
description={<Trans>Use passkeys for passwordless sign-in and two-factor authentication</Trans>}
>
<div className={styles.row}>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>Registered Passkeys</Trans>
</div>
<div className={styles.description}>
<Plural
value={passkeys.length}
_0="No passkeys registered"
one="# passkey registered (max 10)"
other="# passkeys registered (max 10)"
/>
</div>
</div>
<Button small={true} disabled={loadingPasskeys || passkeys.length >= 10} onClick={handleAddPasskey}>
<Trans>Add Passkey</Trans>
</Button>
</div>
{passkeys.length > 0 && (
<div className={styles.divider}>
<div className={styles.passkeyList}>
{passkeys.map((passkey) => {
const createdDate = DateUtils.getRelativeDateString(new Date(passkey.created_at), i18n);
const lastUsedDate = passkey.last_used_at
? DateUtils.getRelativeDateString(new Date(passkey.last_used_at), i18n)
: null;
return (
<div key={passkey.id} className={styles.passkeyItem}>
<div className={styles.passkeyInfo}>
<div className={styles.passkeyName}>{passkey.name}</div>
<div className={styles.passkeyDetails}>
{lastUsedDate ? (
<Trans>
Added: {createdDate} Last used: {lastUsedDate}
</Trans>
) : (
<Trans>Added: {createdDate}</Trans>
)}
</div>
</div>
<div className={styles.passkeyActions}>
<Button variant="secondary" small={true} onClick={() => handleRenamePasskey(passkey.id)}>
<Trans>Rename</Trans>
</Button>
<Button variant="danger-secondary" small={true} onClick={() => handleDeletePasskey(passkey.id)}>
<Trans>Delete</Trans>
</Button>
</div>
</div>
);
})}
</div>
</div>
)}
</SettingsTabSection>
{user.mfaEnabled && (
<>
<SettingsTabSection
title={<Trans>Phone Number</Trans>}
description={<Trans>Manage your phone number for SMS two-factor authentication</Trans>}
>
<div className={styles.row}>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>Phone Number</Trans>
</div>
<div className={styles.description}>
{user.phone ? (
<Trans>Phone number added: {user.phone}</Trans>
) : (
<Trans>Add a phone number to enable SMS two-factor authentication</Trans>
)}
</div>
</div>
{user.phone ? (
<Button
variant="danger-secondary"
small={true}
onClick={() => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Remove Phone Number`}
description={
<>
<div>
<Trans>
Are you sure you want to remove your phone number <strong>{user.phone}</strong>?
</Trans>
</div>
{hasSmsMfa && (
<div>
<Trans>
<strong>Warning:</strong> This will also disable SMS two-factor authentication.
</Trans>
</div>
)}
</>
}
primaryText={t`Remove Phone`}
primaryVariant="danger-primary"
onPrimary={async () => {
try {
await UserActionCreators.removePhone();
} catch (error) {
console.error('Failed to remove phone', error);
}
}}
/>
)),
);
}}
>
<Trans>Remove</Trans>
</Button>
) : (
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <PhoneAddModal />))}>
<Trans>Add Phone</Trans>
</Button>
)}
</div>
</SettingsTabSection>
{user.phone && (
<SettingsTabSection
title={<Trans>SMS Two-Factor Authentication</Trans>}
description={<Trans>Receive verification codes via SMS as a backup authentication method</Trans>}
>
<div className={styles.row}>
<div className={styles.rowContent}>
<div className={styles.label}>
<Trans>SMS Backup</Trans>
</div>
<div className={styles.description}>
{hasSmsMfa ? (
<Trans>SMS two-factor authentication is enabled</Trans>
) : (
<Trans>Enable SMS codes as a backup for your authenticator app</Trans>
)}
</div>
</div>
{hasSmsMfa ? (
<Button
variant="danger-secondary"
small={true}
disabled={disablingSmsMfa}
onClick={handleDisableSmsMfa}
>
<Trans>Disable</Trans>
</Button>
) : isSmsMfaDisabledForUser ? (
<Tooltip text={t`SMS backup is disabled for partners`}>
<div>
<Button small={true} disabled={true}>
<Trans>Enable</Trans>
</Button>
</div>
</Tooltip>
) : (
<Button small={true} disabled={enablingSmsMfa} onClick={handleEnableSmsMfa}>
<Trans>Enable</Trans>
</Button>
)}
</div>
</SettingsTabSection>
)}
</>
)}
</>
);
},
);

View File

@@ -0,0 +1,154 @@
/*
* 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 * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import {Switch} from '~/components/form/Switch';
import {SettingsTabContainer, SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import {WarningAlert} from '~/components/uikit/WarningAlert/WarningAlert';
import NativeWindowStateStore from '~/stores/NativeWindowStateStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import {getAutostartStatus, setAutostartEnabled} from '~/utils/AutostartUtils';
import {getNativePlatform, isDesktop, type NativePlatform} from '~/utils/NativeUtils';
const AdvancedTab: React.FC = observer(() => {
const {developerMode} = UserSettingsStore;
const [autostartEnabled, setAutostartEnabledState] = React.useState(false);
const [autostartBusy, setAutostartBusy] = React.useState(false);
const [platform, setPlatform] = React.useState<NativePlatform>('unknown');
React.useLayoutEffect(() => {
let mounted = true;
const initAutostart = async () => {
if (!isDesktop()) return;
const detectedPlatform = await getNativePlatform();
if (!mounted) return;
setPlatform(detectedPlatform);
if (detectedPlatform !== 'macos') return;
setAutostartBusy(true);
const enabled = await getAutostartStatus();
if (!mounted) return;
if (enabled !== null) {
setAutostartEnabledState(enabled);
}
setAutostartBusy(false);
};
void initAutostart();
return () => {
mounted = false;
};
}, []);
const handleAutostartChange = async (value: boolean) => {
if (platform !== 'macos') return;
setAutostartBusy(true);
const nextState = await setAutostartEnabled(value);
if (nextState !== null) {
setAutostartEnabledState(nextState);
}
setAutostartBusy(false);
};
const showAutostartWarning = platform !== 'unknown' && platform !== 'macos';
return (
<SettingsTabContainer>
{isDesktop() && (
<SettingsTabSection
title={<Trans>Desktop Startup</Trans>}
description={<Trans>Run Fluxer automatically when your computer starts. Or don't. Your choice!</Trans>}
>
<Switch
label={<Trans>Launch Fluxer at login</Trans>}
description={<Trans>Applies only to the desktop app on this device.</Trans>}
value={platform === 'macos' ? autostartEnabled : false}
disabled={platform !== 'macos' || autostartBusy}
onChange={handleAutostartChange}
/>
{showAutostartWarning && (
<WarningAlert>
<Trans>Autostart is coming soon for Windows and Linux. For now, it is only available on macOS.</Trans>
</WarningAlert>
)}
</SettingsTabSection>
)}
{isDesktop() && (
<SettingsTabSection
title={<Trans>Desktop Window</Trans>}
description={
<Trans>Choose what Fluxer remembers about your window between restarts and reloads on this device.</Trans>
}
>
<Switch
label={<Trans>Remember size &amp; position</Trans>}
description={<Trans>Keep your window dimensions and placement even when you reload the app.</Trans>}
value={NativeWindowStateStore.rememberSizeAndPosition}
onChange={NativeWindowStateStore.setRememberSizeAndPosition}
/>
<Switch
label={<Trans>Restore maximized</Trans>}
description={<Trans>Reopen in maximized mode if that&rsquo;s how you last used Fluxer.</Trans>}
value={NativeWindowStateStore.rememberMaximized}
onChange={NativeWindowStateStore.setRememberMaximized}
/>
<Switch
label={<Trans>Restore fullscreen</Trans>}
description={<Trans>Return to fullscreen automatically when you had it enabled last time.</Trans>}
value={NativeWindowStateStore.rememberFullscreen}
onChange={NativeWindowStateStore.setRememberFullscreen}
/>
</SettingsTabSection>
)}
<SettingsTabSection
title={<Trans>Developer Options</Trans>}
description={
<Trans>
Enable advanced features for debugging and development. Note that copying snowflake IDs for entities is
always available to all users without needing developer mode.
</Trans>
}
>
<Switch
label={<Trans>Developer Mode</Trans>}
description={
<Trans>
When enabled, reveals debugging menus throughout the app to inspect and copy raw JSON objects of internal
data structures like messages, channels, users, and communities. Also includes tools to debug the Fluxer
Markdown parser performance and AST for any given message.
</Trans>
}
value={developerMode}
onChange={(value) => UserSettingsActionCreators.update({developerMode: value})}
/>
</SettingsTabSection>
</SettingsTabContainer>
);
});
export default AdvancedTab;

View File

@@ -0,0 +1,113 @@
/*
* 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/>.
*/
.previewWrapper {
background: var(--background-secondary-lighter);
}
.previewContainer {
display: flex;
flex-direction: column;
height: 176px;
overflow: hidden;
position: relative;
padding: 2px 12px 6px;
padding-bottom: 0;
border-radius: 8px;
border: 1px solid var(--background-modifier-accent);
}
.previewMessagesContainer {
flex: 1;
min-height: 0;
padding: 0;
pointer-events: none;
display: flex;
flex-direction: column;
--chat-horizontal-padding: 16px;
box-sizing: border-box;
overflow: hidden;
}
.previewContainerCompact .previewMessagesContainer,
.previewContainerCozy .previewMessagesContainer {
justify-content: flex-start;
}
.previewMessagesContainer :global(.message),
.previewMessagesContainer :global(.messageCompact) {
margin-left: 0 !important;
margin-right: 0 !important;
padding-right: var(--chat-horizontal-padding, 16px) !important;
pointer-events: none;
}
.previewMessagesContainer > :first-child {
margin-top: 0 !important;
}
.previewOverlay {
position: absolute;
right: 0;
bottom: 0;
left: 0;
cursor: default;
content: '';
pointer-events: none;
height: 32px;
background: linear-gradient(transparent, var(--background-secondary-lighter));
}
.buttonRowsContainer {
display: flex;
flex-direction: column;
margin: -1rem 0;
}
.buttonRow {
display: flex;
height: 68px;
align-items: center;
border-bottom: 1px solid var(--background-modifier-accent);
}
.buttonRowsContainer > .buttonRow:last-child {
border-bottom: 0;
}
.buttonRowContent {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.buttonRowLabel {
display: flex;
flex: 1;
align-items: center;
gap: 0.5rem;
}
.buttonRowShortcut {
display: flex;
align-items: center;
gap: 0.25rem;
}

View File

@@ -0,0 +1,96 @@
/*
* 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 type React from 'react';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
import {FavoritesTabContent} from './AppearanceTab/FavoritesTab';
import {InterfaceTabContent} from './AppearanceTab/InterfaceTab';
import {AppearanceTabPreview, MessagesTabContent} from './AppearanceTab/MessagesTab';
import {
AppZoomLevelTabContent,
ChatFontScalingTabContent,
getAppZoomLevelDescription,
shouldShowAppZoomLevel,
} from './AppearanceTab/ScalingTab';
import {ThemeTabContent} from './AppearanceTab/ThemeTab';
const AppearanceTab: React.FC = observer(() => {
const {t} = useLingui();
const showZoomLevel = shouldShowAppZoomLevel();
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsSection
id="theme"
title={t`Theme`}
description={t`Choose between dark, coal, or light appearance. You can still add custom CSS overrides below for limitless control.`}
>
<ThemeTabContent />
</SettingsSection>
<SettingsSection
id="chat-font-scaling"
title={t`Chat font scaling`}
description={t`Adjust the font size in the chat area.`}
>
<ChatFontScalingTabContent />
</SettingsSection>
{showZoomLevel ? (
<SettingsSection id="app-zoom-level" title={t`App zoom level`} description={getAppZoomLevelDescription(t)}>
<AppZoomLevelTabContent />
</SettingsSection>
) : null}
<SettingsSection
id="messages"
title={t`Messages`}
description={t`Choose how messages are displayed in chat channels.`}
>
<MessagesTabContent />
</SettingsSection>
<SettingsSection
id="interface"
title={t`Interface`}
description={t`Customize interface elements and behaviors.`}
>
<InterfaceTabContent />
</SettingsSection>
<SettingsSection
id="favorites"
title={t`Favorites`}
description={t`Control the visibility of favorites throughout the app.`}
isAdvanced
defaultExpanded={false}
>
<FavoritesTabContent />
</SettingsSection>
</SettingsTabContent>
</SettingsTabContainer>
);
});
export {AppearanceTabPreview};
export default AppearanceTab;

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 {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import {Switch} from '~/components/form/Switch';
import AccessibilityStore from '~/stores/AccessibilityStore';
export const FavoritesTabContent: React.FC = observer(() => {
const {t} = useLingui();
return (
<Switch
label={t`Enable Favorites`}
description={t`When enabled, you can favorite channels and they'll appear in the Favorites section. When disabled, all favorite-related UI elements (buttons, menu items) will be hidden. Your existing favorites will be preserved.`}
value={AccessibilityStore.showFavorites}
onChange={(value) => AccessibilityActionCreators.update({showFavorites: value})}
/>
);
});

View File

@@ -0,0 +1,23 @@
/*
* 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;
}

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 {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import styles from './Inline.module.css';
import {InterfaceTabContent} from './InterfaceTab';
import {MessagesTabContent} from './MessagesTab';
import {ThemeTabContent} from './ThemeTab';
export const AppearanceInlineTab: React.FC = observer(() => {
const {t} = useLingui();
return (
<div className={styles.container}>
<SettingsSection id="appearance-theme" title={t`Theme`}>
<ThemeTabContent />
</SettingsSection>
<SettingsSection id="appearance-messages" title={t`Messages`}>
<MessagesTabContent />
</SettingsSection>
<SettingsSection id="appearance-interface" title={t`Interface`}>
<InterfaceTabContent />
</SettingsSection>
</div>
);
});

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/>.
*/
.switchWrapper {
margin-top: 0.5rem;
}
.previewContainer {
margin-bottom: 1.5rem;
display: flex;
justify-content: center;
}
.previewContent {
width: 100%;
max-width: 16.5rem;
}
.tooltipContent {
max-width: 32rem;
white-space: break-spaces;
overflow-wrap: break-word;
}
.typingContainer {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--surface-interactive-selected-color);
}
.typingAnimationWrapper {
margin-right: 0.25rem;
}
.typingAvatars {
gap: 0;
}

View File

@@ -0,0 +1,156 @@
/*
* 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 {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import {Typing} from '~/components/channel/Typing';
import {Switch} from '~/components/form/Switch';
import {ChannelItemCore} from '~/components/layout/ChannelItem';
import channelItemSurfaceStyles from '~/components/layout/ChannelItemSurface.module.css';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import {AvatarStack} from '~/components/uikit/avatars/AvatarStack';
import {MockAvatar} from '~/components/uikit/MockAvatar';
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import AccessibilityStore, {ChannelTypingIndicatorMode} from '~/stores/AccessibilityStore';
import {cdnUrl} from '~/utils/UrlUtils';
import styles from './InterfaceTab.module.css';
const TYPING_PREVIEW_AVATAR_URLS = [1, 2, 3].map((index) => cdnUrl(`avatars/${index}.png`));
const ChannelListPreview = observer(({mode}: {mode: ChannelTypingIndicatorMode}) => {
const typingIndicator =
mode !== ChannelTypingIndicatorMode.HIDDEN ? (
<Tooltip
text={() => (
<span className={styles.tooltipContent}>
<strong>Kenji</strong>, <strong>Amara</strong> and <strong>Mateo</strong> are typing...
</span>
)}
>
<div className={styles.typingContainer}>
<Typing className={styles.typingAnimationWrapper} color="var(--surface-interactive-selected-color)" />
{mode === ChannelTypingIndicatorMode.AVATARS && (
<AvatarStack size={12} maxVisible={5} className={styles.typingAvatars}>
{TYPING_PREVIEW_AVATAR_URLS.map((avatarUrl, index) => (
<MockAvatar key={avatarUrl} size={12} userTag={`User ${index + 1}`} avatarUrl={avatarUrl} />
))}
</AvatarStack>
)}
</div>
</Tooltip>
) : undefined;
return (
<div className={styles.previewContainer}>
<div className={styles.previewContent}>
<ChannelItemCore
channel={{name: 'general', type: 0}}
isSelected={true}
typingIndicator={typingIndicator}
className={clsx(
'cursor-default',
channelItemSurfaceStyles.channelItemSurface,
channelItemSurfaceStyles.channelItemSurfaceSelected,
)}
/>
</div>
</div>
);
});
export const InterfaceTabContent: React.FC = observer(() => {
const {t} = useLingui();
const channelTypingIndicatorOptions: ReadonlyArray<RadioOption<ChannelTypingIndicatorMode>> = [
{
value: ChannelTypingIndicatorMode.AVATARS,
name: t`Typing indicator + avatars`,
desc: t`Show typing indicator with user avatars in the channel list`,
},
{
value: ChannelTypingIndicatorMode.INDICATOR_ONLY,
name: t`Typing indicator only`,
desc: t`Show just the typing indicator without avatars`,
},
{
value: ChannelTypingIndicatorMode.HIDDEN,
name: t`Hidden`,
desc: t`Don't show typing indicators in the channel list`,
},
];
return (
<>
<SettingsTabSection
title={t`Channel List Typing Indicators`}
description={t`Choose how typing indicators appear in the channel list when someone is typing in a channel.`}
>
<ChannelListPreview mode={AccessibilityStore.channelTypingIndicatorMode} />
<RadioGroup
options={channelTypingIndicatorOptions}
value={AccessibilityStore.channelTypingIndicatorMode}
onChange={(value) => AccessibilityActionCreators.update({channelTypingIndicatorMode: value})}
aria-label={t`Channel list typing indicator mode`}
/>
<div className={styles.switchWrapper}>
<Switch
label={t`Show typing on selected channel`}
description={t`When disabled (default), typing indicators won't appear on the channel you're currently viewing.`}
value={AccessibilityStore.showSelectedChannelTypingIndicator}
onChange={(value) => AccessibilityActionCreators.update({showSelectedChannelTypingIndicator: value})}
/>
</div>
</SettingsTabSection>
<SettingsTabSection
title={t`Keyboard Hints`}
description={t`Control whether keyboard shortcut hints appear inside tooltips.`}
>
<div className={styles.switchWrapper}>
<Switch
label={t`Hide keyboard hints in tooltips`}
description={t`When enabled, shortcut badges are hidden in tooltip popups.`}
value={AccessibilityStore.hideKeyboardHints}
onChange={(value) => AccessibilityActionCreators.update({hideKeyboardHints: value})}
/>
</div>
</SettingsTabSection>
<SettingsTabSection
title={t`Voice Channel Join Behavior`}
description={t`Control how you join voice channels in communities.`}
>
<Switch
label={t`Require double-click to join voice channels`}
description={t`When enabled, you'll need to double-click on voice channels to join them. When disabled (default), single-clicking will join the channel immediately.`}
value={AccessibilityStore.voiceChannelJoinRequiresDoubleClick}
onChange={(value) =>
AccessibilityActionCreators.update({
voiceChannelJoinRequiresDoubleClick: value,
})
}
/>
</SettingsTabSection>
</>
);
});

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/>.
*/
.switchWrapper {
margin-top: 0.5rem;
}

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/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import {MessagePreviewContext, MessageStates, MessageTypes} from '~/Constants';
import {Message} from '~/components/channel/Message';
import {Switch} from '~/components/form/Switch';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
import {Slider} from '~/components/uikit/Slider';
import {ChannelRecord} from '~/records/ChannelRecord';
import {MessageRecord} from '~/records/MessageRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import UserStore from '~/stores/UserStore';
import {isNewMessageGroup} from '~/utils/MessageGroupingUtils';
import appearanceTabStyles from '../AppearanceTab.module.css';
import styles from './MessagesTab.module.css';
export const AppearanceTabPreview = observer(() => {
const {t} = useLingui();
const {messageDisplayCompact} = UserSettingsStore;
const currentUser = UserStore.getCurrentUser();
const author = currentUser?.toJSON() || {
id: 'preview-user',
username: 'PreviewUser',
discriminator: '0000',
global_name: 'Preview User',
avatar: null,
bot: false,
system: false,
flags: 0,
};
const fakeChannel = new ChannelRecord({
id: 'fake-channel',
type: 0,
name: 'fake-channel',
position: 0,
parent_id: null,
topic: null,
url: null,
nsfw: false,
last_message_id: null,
last_pin_timestamp: null,
bitrate: null,
user_limit: null,
permission_overwrites: [],
});
const baseTime = new Date();
const messageContents = [
{content: t`This is how messages appear`, offsetMinutes: 0},
{content: t`With different display modes available`, offsetMinutes: 1},
{content: t`Customize the spacing and size`, offsetMinutes: 2},
{content: t`Waiting for you to...`, offsetMinutes: 10},
{content: t`... turn dense mode on. Nice!`, offsetMinutes: 11},
];
const fakeMessages = messageContents.map(({content, offsetMinutes}, index) => {
const timestamp = new Date(baseTime.getTime() + offsetMinutes * 60 * 1000);
return new MessageRecord(
{
id: `preview-${index + 1}`,
channel_id: 'fake-channel',
author,
type: MessageTypes.DEFAULT,
flags: 0,
pinned: false,
mention_everyone: false,
content,
timestamp: timestamp.toISOString(),
state: MessageStates.SENT,
},
{skipUserCache: true},
);
});
return (
<div className={appearanceTabStyles.previewWrapper}>
<div
className={clsx(
appearanceTabStyles.previewContainer,
messageDisplayCompact
? appearanceTabStyles.previewContainerCompact
: appearanceTabStyles.previewContainerCozy,
)}
>
<div className={appearanceTabStyles.previewMessagesContainer} key="appearance-messages-preview-scroller">
{fakeMessages.map((message, index) => {
const prevMessage = index > 0 ? fakeMessages[index - 1] : undefined;
const isNewGroup = isNewMessageGroup(fakeChannel, prevMessage, message);
const shouldGroup = !messageDisplayCompact && !isNewGroup;
return (
<Message
key={message.id}
channel={fakeChannel}
message={message}
prevMessage={prevMessage}
previewContext={MessagePreviewContext.SETTINGS}
shouldGroup={shouldGroup}
/>
);
})}
</div>
<div className={appearanceTabStyles.previewOverlay} />
</div>
</div>
);
});
export const MessagesTabContent: React.FC = observer(() => {
const {t} = useLingui();
const {messageDisplayCompact} = UserSettingsStore;
const messageGroupSpacing = AccessibilityStore.messageGroupSpacing;
const showUserAvatarsInCompactMode = AccessibilityStore.showUserAvatarsInCompactMode;
const showMessageDividers = AccessibilityStore.showMessageDividers;
const mobileLayout = MobileLayoutStore;
const messageDisplayOptions: ReadonlyArray<RadioOption<boolean>> = [
{value: false, name: t`Comfy`, desc: t`Spacious layout with clear visual separation between messages.`},
{value: true, name: t`Dense`, desc: t`Maximizes visible messages with minimal spacing.`},
];
if (mobileLayout.enabled) {
return <Trans>Message display settings are only available on desktop.</Trans>;
}
return (
<>
<RadioGroup
options={messageDisplayOptions}
value={messageDisplayCompact}
onChange={(value) => {
UserSettingsActionCreators.update({messageDisplayCompact: value});
}}
aria-label={t`Message display mode`}
/>
{messageDisplayCompact ? (
<div className={styles.switchWrapper}>
<Switch
label={t`Hide user avatars`}
value={!showUserAvatarsInCompactMode}
onChange={(value) => AccessibilityActionCreators.update({showUserAvatarsInCompactMode: !value})}
/>
</div>
) : null}
<SettingsTabSection
title={t`Space between message groups`}
description={t`Adjust the spacing between groups of messages.`}
>
<Slider
defaultValue={messageGroupSpacing}
factoryDefaultValue={messageDisplayCompact ? 0 : 16}
markers={[0, 4, 8, 16, 24]}
stickToMarkers={true}
onValueChange={(value) => AccessibilityActionCreators.update({messageGroupSpacing: value})}
onMarkerRender={(value) => `${value}px`}
/>
<div className={styles.switchWrapper}>
<Switch
label={t`Show divider lines between message groups`}
description={t`Display separator lines between different groups of messages for better visual distinction.`}
value={showMessageDividers}
onChange={(value) => AccessibilityActionCreators.update({showMessageDividers: value})}
/>
</div>
</SettingsTabSection>
</>
);
});

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/>.
*/
import {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import {Slider} from '~/components/uikit/Slider';
import AccessibilityStore from '~/stores/AccessibilityStore';
import KeybindStore from '~/stores/KeybindStore';
import {formatKeyCombo} from '~/utils/KeybindUtils';
import {isDesktop} from '~/utils/NativeUtils';
export const ChatFontScalingTabContent: React.FC = observer(() => {
const fontSize = AccessibilityStore.fontSize;
return (
<Slider
defaultValue={fontSize}
factoryDefaultValue={16}
markers={[12, 14, 15, 16, 18, 20, 24]}
stickToMarkers={true}
onValueChange={(value) => AccessibilityActionCreators.update({fontSize: value})}
onMarkerRender={(value) => `${value}px`}
/>
);
});
export const AppZoomLevelTabContent: React.FC = observer(() => {
const zoomLevel = AccessibilityStore.zoomLevel;
return (
<Slider
defaultValue={Math.round(zoomLevel * 100)}
factoryDefaultValue={100}
minValue={50}
maxValue={200}
step={10}
markers={[50, 75, 100, 125, 150, 175, 200]}
stickToMarkers={true}
onValueChange={(value) => AccessibilityActionCreators.update({zoomLevel: value / 100})}
onMarkerRender={(value) => `${value}%`}
onValueRender={(value) => <Trans>{value}%</Trans>}
/>
);
});
export type LinguiT = (literals: TemplateStringsArray, ...placeholders: Array<unknown>) => string;
export function getAppZoomLevelDescription(t: LinguiT): string {
const zoomIn = formatKeyCombo(KeybindStore.keybinds.zoom_in);
const zoomOut = formatKeyCombo(KeybindStore.keybinds.zoom_out);
return t`Adjust the overall zoom level of the app. Use ${zoomIn} / ${zoomOut} to adjust quickly.`;
}
export function shouldShowAppZoomLevel(): boolean {
return isDesktop();
}

View File

@@ -0,0 +1,127 @@
/*
* 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/>.
*/
.themeButtonGroup {
display: flex;
gap: 0.75rem;
}
.themeButton {
position: relative;
height: 3.5rem;
width: 3.5rem;
border-radius: 9999px;
border: 2px solid;
outline: none;
cursor: pointer;
}
.themeButton:focus {
outline: none;
}
.themeButton:active {
transform: none;
}
.themeButtonSelected {
border-color: var(--brand-primary);
}
.themeButtonLight {
border-color: var(--border-color);
}
.themeButtonDark {
border-color: white;
}
.themeButtonIcon {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.themeButtonCheckmark {
position: absolute;
top: -0.25rem;
right: -0.25rem;
display: flex;
height: 1.25rem;
width: 1.25rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: var(--brand-primary);
}
.themeButtonCheckmarkIcon {
color: white;
}
.colorGrid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 1rem;
}
@media (min-width: 768px) {
.colorGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.colorSection {
border-top: 1px solid var(--background-header-secondary);
padding-top: 1rem;
}
.colorSectionHeading {
margin-bottom: 0.75rem;
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
}
.cssSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
border-top: 1px solid var(--background-header-secondary);
padding-top: 1rem;
}
.buttonGroup {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.loadingContainer {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
min-height: 16rem;
padding-top: 2.5rem;
padding-bottom: 2.5rem;
}

View File

@@ -0,0 +1,563 @@
/*
* 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 {ArrowsCounterClockwiseIcon, CheckIcon, ShareNetworkIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import {ThemeTypes} from '~/Constants';
import {ColorPickerField} from '~/components/form/ColorPickerField';
import {Input, Textarea} from '~/components/form/Input';
import {Switch} from '~/components/form/Switch';
import {ShareThemeModal} from '~/components/modals/ShareThemeModal';
import {Accordion} from '~/components/uikit/Accordion/Accordion';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Spinner} from '~/components/uikit/Spinner';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import AccessibilityStore from '~/stores/AccessibilityStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import styles from './ThemeTab.module.css';
interface ThemeButtonProps {
themeType: string;
currentTheme: string;
label: string;
backgroundColor: string;
onKeyDown: (event: React.KeyboardEvent, themeType: string) => void;
onClick: (themeType: string) => void;
icon?: React.ReactElement<Record<string, unknown>>;
}
const ThemeButton = observer(
React.forwardRef<HTMLButtonElement, ThemeButtonProps>(
({themeType, currentTheme, label, backgroundColor, onKeyDown, onClick, icon}, ref) => {
const isSelected = currentTheme === themeType;
const getButtonClassName = () => {
const classes = [styles.themeButton];
if (isSelected) {
classes.push(styles.themeButtonSelected);
} else if (backgroundColor === 'hsl(210, 20%, 98%)') {
classes.push(styles.themeButtonLight);
} else {
classes.push(styles.themeButtonDark);
}
return clsx(classes);
};
return (
<FocusRing offset={-2}>
<button
ref={ref}
type="button"
onClick={() => onClick(themeType)}
onKeyDown={(e) => onKeyDown(e, themeType)}
className={getButtonClassName()}
style={{backgroundColor}}
role="radio"
aria-checked={isSelected}
aria-label={label}
tabIndex={isSelected ? 0 : -1}
>
{icon && (
<div className={styles.themeButtonIcon} aria-hidden="true">
{React.cloneElement(icon, {
size: 24,
weight: 'bold',
style: {color: backgroundColor === 'hsl(220, 13%, 5%)' ? '#ffffff' : '#000000'},
})}
</div>
)}
{isSelected && (
<div className={styles.themeButtonCheckmark} aria-hidden="true">
<CheckIcon weight="bold" className={styles.themeButtonCheckmarkIcon} size={12} />
</div>
)}
</button>
</FocusRing>
);
},
),
);
const THEME_COLOR_VARIABLES: ReadonlyArray<string> = [
'--background-primary',
'--background-secondary',
'--background-secondary-alt',
'--background-tertiary',
'--background-textarea',
'--background-header-primary',
'--background-header-primary-hover',
'--background-header-secondary',
'--background-modifier-hover',
'--guild-list-foreground',
'--background-modifier-selected',
'--background-modifier-accent',
'--background-modifier-accent-focus',
'--brand-primary',
'--brand-secondary',
'--brand-primary-light',
'--brand-primary-fill',
'--status-online',
'--status-idle',
'--status-dnd',
'--status-offline',
'--status-danger',
'--text-primary',
'--text-secondary',
'--text-tertiary',
'--text-primary-muted',
'--text-chat',
'--text-chat-muted',
'--text-link',
'--text-on-brand-primary',
'--text-tertiary-muted',
'--text-tertiary-secondary',
'--border-color',
'--border-color-hover',
'--border-color-focus',
'--accent-primary',
'--accent-success',
'--accent-warning',
'--accent-danger',
'--accent-info',
'--accent-purple',
'--alert-note-color',
'--alert-tip-color',
'--alert-important-color',
'--alert-warning-color',
'--alert-caution-color',
'--markup-mention-text',
'--markup-mention-fill',
'--markup-interactive-hover-text',
'--markup-interactive-hover-fill',
'--button-primary-fill',
'--button-primary-active-fill',
'--button-primary-text',
'--button-secondary-fill',
'--button-secondary-active-fill',
'--button-secondary-text',
'--button-secondary-active-text',
'--button-danger-fill',
'--button-danger-active-fill',
'--button-danger-text',
'--button-danger-outline-border',
'--button-danger-outline-text',
'--button-danger-outline-active-fill',
'--button-danger-outline-active-border',
'--button-ghost-text',
'--button-inverted-fill',
'--button-inverted-text',
'--button-outline-border',
'--button-outline-text',
'--button-outline-active-fill',
'--button-outline-active-border',
'--bg-primary',
'--bg-secondary',
'--bg-tertiary',
'--bg-hover',
'--bg-active',
'--bg-code',
'--bg-code-block',
'--bg-blockquote',
'--bg-table-header',
'--bg-table-row-odd',
'--bg-table-row-even',
];
const THEME_FONT_VARIABLES: ReadonlyArray<string> = ['--font-sans', '--font-mono'];
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractThemeVariableOverrides(css: string): Record<string, string> {
const overrides: Record<string, string> = {};
const variablePattern = /--([a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = variablePattern.exec(css)) !== null) {
const variableName = `--${match[1] as string}`;
const value = match[2] as string;
overrides[variableName] = value.trim();
}
return overrides;
}
function updateCssForVariable(css: string, variableName: string, newValue: string | null): string {
const variableNamePattern = escapeRegExp(variableName);
const propertyPattern = new RegExp(`(--${variableNamePattern.replace(/^--/, '')}\\s*:[^;]*;)`);
if (newValue === null) {
return css.replace(propertyPattern, '');
}
if (propertyPattern.test(css)) {
return css.replace(propertyPattern, `${variableName}: ${newValue};`);
}
const trimmedCss = css.trim();
const prefix = trimmedCss.length > 0 && !trimmedCss.endsWith('\n') ? '\n' : '';
return `${trimmedCss}${prefix}:root { ${variableName}: ${newValue}; }\n`;
}
function clampByte(value: number): number {
return Math.max(0, Math.min(255, Math.round(value)));
}
function numberToHex(value: number): string {
return `#${(value >>> 0).toString(16).padStart(6, '0').slice(-6)}`.toUpperCase();
}
function cssColorStringToNumber(color: string): number | null {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) return null;
try {
context.fillStyle = '#000';
context.fillStyle = color;
const parsed = String(context.fillStyle);
const match = /^rgba?\((\d+),\s*(\d+),\s*(\d+)/i.exec(parsed);
if (match) {
const red = parseInt(match[1] ?? '0', 10);
const green = parseInt(match[2] ?? '0', 10);
const blue = parseInt(match[3] ?? '0', 10);
const hex = `#${clampByte(red).toString(16).padStart(2, '0')}${clampByte(green)
.toString(16)
.padStart(2, '0')}${clampByte(blue).toString(16).padStart(2, '0')}`.toUpperCase();
return Number.parseInt(hex.slice(1), 16) >>> 0;
}
if (/^#[0-9A-Fa-f]{6}$/.test(parsed)) {
return Number.parseInt(parsed.slice(1), 16) >>> 0;
}
} catch {
return null;
}
return null;
}
export const ThemeTabContent: React.FC = observer(() => {
const {t} = useLingui();
const {theme} = UserSettingsStore;
const syncThemeAcrossDevices = AccessibilityStore.syncThemeAcrossDevices;
const localThemeOverride = AccessibilityStore.localThemeOverride;
const customThemeCss = AccessibilityStore.customThemeCss ?? '';
const currentSelectedTheme = syncThemeAcrossDevices ? theme : localThemeOverride || theme;
const [systemPrefersDark, setSystemPrefersDark] = React.useState(() => {
return window.matchMedia?.('(prefers-color-scheme: dark)').matches;
});
const buttonRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
const themeToFocusRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!window.matchMedia) return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event: MediaQueryListEvent) => {
setSystemPrefersDark(event.matches);
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
const [defaultVariableValues, setDefaultVariableValues] = React.useState<Record<string, string> | null>(null);
const beginThemeHydration = React.useCallback(() => {
setDefaultVariableValues(null);
}, []);
const handleThemeChange = React.useCallback(
(newTheme: string) => {
if (newTheme === currentSelectedTheme) return;
themeToFocusRef.current = newTheme;
beginThemeHydration();
if (newTheme === ThemeTypes.SYSTEM) {
AccessibilityActionCreators.update({syncThemeAcrossDevices: false, localThemeOverride: newTheme});
} else if (syncThemeAcrossDevices) {
UserSettingsActionCreators.update({theme: newTheme});
} else {
AccessibilityActionCreators.update({localThemeOverride: newTheme});
}
},
[currentSelectedTheme, beginThemeHydration, syncThemeAcrossDevices],
);
const themeOptions = [
{
type: ThemeTypes.DARK,
label: t`Dark Theme`,
backgroundColor: 'hsl(220, calc(13% * var(--saturation-factor)), 13.22%)',
icon: null,
tooltip: t`Use dark theme`,
},
{
type: ThemeTypes.COAL,
label: t`Coal Theme`,
backgroundColor: 'hsl(220, 13%, 2%)',
icon: null,
tooltip: t`Use coal theme (pitch-black surfaces)`,
},
{
type: ThemeTypes.LIGHT,
label: t`Light Theme`,
backgroundColor: 'hsl(210, 20%, 98%)',
icon: null,
tooltip: t`Use light theme`,
},
{
type: ThemeTypes.SYSTEM,
label: t`System Theme`,
backgroundColor: systemPrefersDark ? 'hsl(220, 13%, 5%)' : 'hsl(210, 20%, 98%)',
icon: <ArrowsCounterClockwiseIcon size={12} />,
tooltip: systemPrefersDark
? t`System: Dark theme (automatically sync with your system's dark/light preference)`
: t`System: Light theme (automatically sync with your system's dark/light preference)`,
},
];
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent, targetTheme: string) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
handleThemeChange(targetTheme);
} else if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
event.preventDefault();
const order = themeOptions.map((option) => option.type);
const direction = event.key === 'ArrowRight' ? 1 : -1;
const currentIndex = Math.max(order.indexOf(currentSelectedTheme as (typeof order)[number]), 0);
const nextIndex = (currentIndex + direction + order.length) % order.length;
const nextTheme = order[nextIndex];
if (nextTheme) {
handleThemeChange(nextTheme);
}
}
},
[themeOptions, currentSelectedTheme, handleThemeChange],
);
const overrides = React.useMemo(() => extractThemeVariableOverrides(customThemeCss), [customThemeCss]);
const hydrationKey =
currentSelectedTheme === ThemeTypes.SYSTEM
? `${currentSelectedTheme}-${systemPrefersDark ? 'dark' : 'light'}`
: currentSelectedTheme;
const lastHydrationKey = React.useRef(hydrationKey);
React.useEffect(() => {
if (hydrationKey !== lastHydrationKey.current) {
beginThemeHydration();
lastHydrationKey.current = hydrationKey;
}
const frameId = requestAnimationFrame(() => {
const computed = getComputedStyle(document.documentElement);
const nextDefaults: Record<string, string> = {};
for (const variableName of [...THEME_COLOR_VARIABLES, ...THEME_FONT_VARIABLES]) {
const raw = computed.getPropertyValue(variableName);
if (raw && raw.trim().length > 0) {
nextDefaults[variableName] = raw.trim();
}
}
setDefaultVariableValues(nextDefaults);
});
return () => cancelAnimationFrame(frameId);
}, [hydrationKey, customThemeCss, beginThemeHydration]);
const handleShareTheme = React.useCallback(() => {
const css = AccessibilityStore.customThemeCss ?? '';
if (!css.trim()) {
ToastActionCreators.error(t`You don't have any custom theme overrides to share yet.`);
return;
}
ModalActionCreators.push(ModalActionCreators.modal(() => <ShareThemeModal themeCss={css} />));
}, []);
const handleResetAllOverrides = React.useCallback(() => {
AccessibilityActionCreators.update({customThemeCss: null});
}, []);
React.useEffect(() => {
if (defaultVariableValues !== null && themeToFocusRef.current) {
const node = buttonRefs.current[themeToFocusRef.current];
if (node) {
node.focus();
}
themeToFocusRef.current = null;
}
}, [defaultVariableValues]);
if (defaultVariableValues === null) {
return (
<div className={styles.loadingContainer}>
<Spinner size="large" />
</div>
);
}
return (
<>
<div className={styles.themeButtonGroup} role="radiogroup" aria-labelledby="theme-label">
{themeOptions.map((option) => (
<Tooltip key={option.type} text={option.tooltip} position="top" delay={200}>
<div>
<ThemeButton
ref={(el) => {
buttonRefs.current[option.type] = el;
}}
themeType={option.type}
currentTheme={currentSelectedTheme}
label={option.label}
backgroundColor={option.backgroundColor}
icon={option.icon ?? undefined}
onClick={handleThemeChange}
onKeyDown={handleKeyDown}
/>
</div>
</Tooltip>
))}
</div>
<Switch
label={t`Sync theme across devices`}
description={
(syncThemeAcrossDevices ? theme : localThemeOverride) === ThemeTypes.SYSTEM
? t`System theme automatically disables sync to track your system's preference on this device.`
: t`When enabled, theme changes will sync to all your devices. When disabled, this device will use its own theme setting.`
}
value={syncThemeAcrossDevices}
disabled={(syncThemeAcrossDevices ? theme : localThemeOverride) === ThemeTypes.SYSTEM}
onChange={(value) => {
if (!value) {
const currentTheme = syncThemeAcrossDevices ? theme : UserSettingsStore.getTheme();
AccessibilityActionCreators.update({syncThemeAcrossDevices: false, localThemeOverride: currentTheme});
} else {
AccessibilityActionCreators.update({syncThemeAcrossDevices: true, localThemeOverride: null});
}
}}
/>
<Accordion
id="custom-theme-tokens"
title={t`Custom theme tokens`}
description={t`Fine-tune core colors and fonts for this app. Changes here are stored as custom CSS overrides and sync with the editor below.`}
defaultExpanded={false}
>
<div className={styles.colorGrid}>
{THEME_FONT_VARIABLES.map((variableName) => {
const overrideValue = overrides[variableName];
const defaultValue = defaultVariableValues[variableName];
const currentValue = overrideValue ?? defaultValue ?? '';
return (
<Input
key={variableName}
label={
variableName === '--font-sans'
? t`Body font family`
: variableName === '--font-mono'
? t`Monospace font family`
: variableName
}
placeholder={defaultValue || undefined}
value={currentValue}
onChange={(event) => {
const next = event.target.value.trim();
const updatedCss =
next.length === 0
? updateCssForVariable(customThemeCss, variableName, null)
: updateCssForVariable(customThemeCss, variableName, next);
AccessibilityActionCreators.update({customThemeCss: updatedCss});
}}
/>
);
})}
</div>
<div className={styles.colorSection}>
<div className={styles.colorSectionHeading}>{t`Colors`}</div>
<div className={styles.colorGrid}>
{THEME_COLOR_VARIABLES.map((variableName) => {
const overrideCss = overrides[variableName];
const defaultCss = defaultVariableValues[variableName];
const defaultNumber = defaultCss !== undefined ? (cssColorStringToNumber(defaultCss) ?? 0) : undefined;
const valueNumber =
overrideCss !== undefined ? (cssColorStringToNumber(overrideCss) ?? defaultNumber ?? 0) : 0;
const label = variableName.replace(/^--/, '').replace(/-/g, ' ');
return (
<ColorPickerField
key={variableName}
label={label}
value={valueNumber ?? 0}
defaultValue={defaultNumber}
hideHelperText
onChange={(nextValue) => {
const updatedCss =
nextValue === 0
? updateCssForVariable(customThemeCss, variableName, null)
: updateCssForVariable(customThemeCss, variableName, numberToHex(nextValue));
AccessibilityActionCreators.update({customThemeCss: updatedCss});
}}
/>
);
})}
</div>
</div>
<div className={styles.cssSection}>
<Textarea
label={t`Custom CSS overrides`}
placeholder={t`Write custom CSS here to override any theme tokens. For example:\n:root { --background-primary: #1E1E2F; }`}
minRows={4}
maxRows={12}
value={customThemeCss}
onChange={(event) => {
AccessibilityActionCreators.update({customThemeCss: event.target.value});
}}
/>
<div className={styles.buttonGroup}>
<Button variant="secondary" fitContent onClick={handleResetAllOverrides}>
{t`Reset all overrides to theme default`}
</Button>
<Button variant="primary" fitContent leftIcon={<ShareNetworkIcon size={18} />} onClick={handleShareTheme}>
{t`Share this theme`}
</Button>
</div>
</div>
</Accordion>
</>
);
});

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/>.
*/
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {Input} from '~/components/form/Input';
import * as Modal from '~/components/modals/Modal';
import styles from '~/components/modals/tabs/ApplicationsTab/ApplicationsTab.module.css';
import {Button} from '~/components/uikit/Button/Button';
import {Endpoints} from '~/Endpoints';
import HttpClient from '~/lib/HttpClient';
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
interface ApplicationCreateModalProps {
onCreated: (application: DeveloperApplication) => void;
}
interface CreateFormValues {
name: string;
}
export const ApplicationCreateModal: React.FC<ApplicationCreateModalProps> = observer(({onCreated}) => {
const {t} = useLingui();
const {
register,
handleSubmit,
formState: {errors},
reset,
} = useForm<CreateFormValues>({
defaultValues: {
name: '',
},
});
const nameField = register('name', {required: true, maxLength: 100});
const nameInputRef = React.useRef<HTMLInputElement | null>(null);
const [creating, setCreating] = React.useState(false);
const [createError, setCreateError] = React.useState<string | null>(null);
const handleCancel = () => {
reset();
setCreateError(null);
ModalActionCreators.pop();
};
const onSubmit = handleSubmit(async (data) => {
setCreateError(null);
setCreating(true);
try {
const response = await HttpClient.post<DeveloperApplication>(Endpoints.OAUTH_APPLICATIONS, {
name: data.name.trim(),
redirect_uris: [],
});
onCreated(response.body);
reset();
ModalActionCreators.pop();
} catch (err) {
console.error('[ApplicationCreateModal] Failed to create application:', err);
setCreateError(t`Failed to create application. Please check your inputs and try again.`);
} finally {
setCreating(false);
}
});
return (
<Modal.Root size="small" centered initialFocusRef={nameInputRef}>
<form onSubmit={onSubmit}>
<Modal.Header title={t`Create Application`} />
<Modal.Content className={styles.createForm}>
<Input
type="text"
label={t`Application Name`}
{...nameField}
ref={(el) => {
nameField.ref(el);
nameInputRef.current = el;
}}
placeholder={t`My Application`}
maxLength={100}
required
disabled={creating}
autoFocus
/>
{errors.name && <div className={styles.error}>{t`Application name is required`}</div>}
{createError && <div className={styles.error}>{createError}</div>}
</Modal.Content>
<Modal.Footer>
<Button type="button" variant="secondary" onClick={handleCancel} disabled={creating}>
{t`Cancel`}
</Button>
<Button type="submit" variant="primary" submitting={creating}>
{t`Create`}
</Button>
</Modal.Footer>
</form>
</Modal.Root>
);
});

View File

@@ -0,0 +1,691 @@
/*
* 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 {TrashIcon, WarningCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useForm, useWatch} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UnsavedChangesActionCreators from '~/actions/UnsavedChangesActionCreators';
import {OAuth2Scopes, UserPremiumTypes} from '~/Constants';
import {Form} from '~/components/form/Form';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import ApplicationsTabStore from '~/components/modals/tabs/ApplicationsTab/ApplicationsTabStore';
import {Button} from '~/components/uikit/Button/Button';
import {Endpoints} from '~/Endpoints';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import {useSudo} from '~/hooks/useSudo';
import HttpClient from '~/lib/HttpClient';
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
import UserStore from '~/stores/UserStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {formatBotPermissionsQuery, getAllBotPermissions} from '~/utils/PermissionUtils';
import styles from './application-detail/ApplicationDetail.module.css';
import {ApplicationHeader} from './application-detail/ApplicationHeader';
import {ApplicationInfoSection} from './application-detail/ApplicationInfoSection';
import {BotProfileSection} from './application-detail/BotProfileSection';
import {OAuthBuilderSection} from './application-detail/OAuthBuilderSection';
import {SecretsSection} from './application-detail/SecretsSection';
import {SectionCard} from './application-detail/SectionCard';
import type {ApplicationDetailFormValues} from './application-detail/types';
interface ApplicationDetailProps {
applicationId: string;
onBack: () => void;
initialApplication?: DeveloperApplication | null;
}
const APPLICATIONS_TAB_ID = 'applications';
const AVAILABLE_SCOPES = OAuth2Scopes.filter((scope) => scope !== 'applications.commands');
export const ApplicationDetail: React.FC<ApplicationDetailProps> = observer(
({applicationId, onBack, initialApplication}) => {
const {t, i18n} = useLingui();
const store = ApplicationsTabStore;
const application = store.selectedApplication;
const loading = store.isLoading && store.isDetailView;
const error = store.error;
const [idCopied, setIdCopied] = React.useState(false);
const [previewAvatarUrl, setPreviewAvatarUrl] = React.useState<string | null>(null);
const [hasClearedAvatar, setHasClearedAvatar] = React.useState(false);
const [previewBannerUrl, setPreviewBannerUrl] = React.useState<string | null>(null);
const [hasClearedBanner, setHasClearedBanner] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const [initialValues, setInitialValues] = React.useState<ApplicationDetailFormValues | null>(null);
const [clientSecret, setClientSecret] = React.useState<string | null>(null);
const [botToken, setBotToken] = React.useState<string | null>(null);
const [isRotating, setIsRotating] = React.useState<'client' | 'bot' | null>(null);
const clientSecretInputId = React.useId();
const botTokenInputId = React.useId();
const currentUser = UserStore.currentUser;
const isLifetimePremium = currentUser?.premiumType === UserPremiumTypes.LIFETIME;
const sudo = useSudo();
const form = useForm<ApplicationDetailFormValues>({
defaultValues: {
name: '',
botPublic: true,
redirectUris: [],
redirectUriInputs: [''],
builderScopes: {} as Record<string, boolean>,
builderPermissions: {} as Record<string, boolean>,
username: '',
discriminator: '',
avatar: null,
bio: '',
banner: null,
},
});
const buildFormDefaults = React.useCallback((app: DeveloperApplication): ApplicationDetailFormValues => {
const builderScopeMap = AVAILABLE_SCOPES.reduce<Record<string, boolean>>((acc, scope) => {
acc[scope] = false;
return acc;
}, {});
const redirectList = (app.redirect_uris ?? []).length > 0 ? app.redirect_uris : [''];
return {
name: app.name,
redirectUris: app.redirect_uris ?? [],
redirectUriInputs: redirectList,
botPublic: app.bot_public,
builderScopes: builderScopeMap,
builderPermissions: {},
username: app.bot?.username || '',
discriminator: app.bot?.discriminator || '',
avatar: null,
bio: app.bot?.bio ?? '',
banner: null,
};
}, []);
React.useEffect(() => {
if (application && application.id === applicationId) {
const fetchedClientSecret = application.client_secret ?? null;
const fetchedBotToken = application.bot?.token ?? null;
setClientSecret(fetchedClientSecret);
setBotToken(fetchedBotToken);
const defaults = buildFormDefaults(application);
form.reset(defaults);
setInitialValues(defaults);
setPreviewAvatarUrl(null);
setHasClearedAvatar(false);
setPreviewBannerUrl(null);
setHasClearedBanner(false);
}
}, [application, applicationId, buildFormDefaults, form]);
React.useEffect(() => {
if (initialApplication && initialApplication.id === applicationId) {
void store.navigateToDetail(applicationId, initialApplication);
} else if (!store.selectedApplication || store.selectedAppId !== applicationId) {
void store.navigateToDetail(applicationId);
}
}, [applicationId, initialApplication, store]);
const formIsSubmitting = form.formState.isSubmitting;
const watchedValues = useWatch<ApplicationDetailFormValues>({control: form.control});
const hasFormChanges = React.useMemo(() => {
if (!initialValues) return false;
const currentValues =
(watchedValues as ApplicationDetailFormValues | undefined) ?? ({} as ApplicationDetailFormValues);
return (
(currentValues.name ?? '') !== (initialValues.name ?? '') ||
(currentValues.redirectUris ?? []).join(',') !== (initialValues.redirectUris ?? []).join(',') ||
(currentValues.redirectUriInputs ?? []).join(',') !== (initialValues.redirectUriInputs ?? []).join(',') ||
(currentValues.botPublic ?? true) !== (initialValues.botPublic ?? true) ||
(currentValues.username ?? '') !== (initialValues.username ?? '') ||
(currentValues.discriminator ?? '') !== (initialValues.discriminator ?? '') ||
(currentValues.bio ?? '') !== (initialValues.bio ?? '') ||
(currentValues.banner ?? '') !== (initialValues.banner ?? '')
);
}, [initialValues, watchedValues]);
const hasUnsavedChanges = React.useMemo(() => {
return Boolean(hasFormChanges || previewAvatarUrl || hasClearedAvatar || previewBannerUrl || hasClearedBanner);
}, [hasFormChanges, previewAvatarUrl, hasClearedAvatar, previewBannerUrl, hasClearedBanner]);
const onSubmit = React.useCallback(
async (data: ApplicationDetailFormValues) => {
if (!application) return;
const normalizedName = data.name.trim();
const redirectUris = (data.redirectUriInputs ?? []).map((u) => u.trim()).filter(Boolean);
const dirtyFields = form.formState.dirtyFields;
const buildApplicationPatch = () => {
const changes: Record<string, unknown> = {};
if (normalizedName !== application.name) {
changes.name = normalizedName;
}
const initialRedirects = application.redirect_uris ?? [];
if ((redirectUris ?? []).join(',') !== initialRedirects.join(',')) {
changes.redirect_uris = redirectUris;
}
if ((data.botPublic ?? true) !== (application.bot_public ?? true)) {
changes.bot_public = data.botPublic;
}
return changes;
};
const buildBotPatch = () => {
if (!application.bot) return null;
const botBody: Record<string, unknown> = {};
const currentBot = application.bot;
const avatarCleared = hasClearedAvatar;
const bannerCleared = hasClearedBanner;
if (dirtyFields.username && data.username && data.username !== currentBot.username) {
botBody.username = data.username;
}
const currentDiscriminator = currentBot.discriminator || '';
if (
isLifetimePremium &&
dirtyFields.discriminator &&
data.discriminator &&
data.discriminator !== currentDiscriminator
) {
botBody.discriminator = data.discriminator;
}
const shouldSendAvatar = dirtyFields.avatar || avatarCleared;
if (shouldSendAvatar) {
if (avatarCleared) {
botBody.avatar = null;
} else if (data.avatar) {
botBody.avatar = data.avatar;
}
}
const shouldSendBanner = dirtyFields.banner || bannerCleared;
if (shouldSendBanner) {
if (bannerCleared) {
botBody.banner = null;
} else if (data.banner) {
botBody.banner = data.banner;
}
}
if (dirtyFields.bio) {
const trimmedBio = data.bio?.trim() ?? '';
const currentBio = currentBot.bio ?? '';
if (trimmedBio !== currentBio) {
botBody.bio = trimmedBio.length > 0 ? trimmedBio : null;
}
}
return Object.keys(botBody).length > 0 ? botBody : null;
};
const appPatch = buildApplicationPatch();
const botPatch = buildBotPatch();
if (Object.keys(appPatch).length === 0 && !botPatch) {
ToastActionCreators.createToast({type: 'info', children: t`No changes to save`});
return;
}
try {
if (Object.keys(appPatch).length > 0) {
await HttpClient.patch(Endpoints.OAUTH_APPLICATION(applicationId), appPatch);
}
if (botPatch) {
await HttpClient.patch(Endpoints.OAUTH_APPLICATION_BOT_PROFILE(applicationId), botPatch);
}
ToastActionCreators.createToast({type: 'success', children: t`Application updated successfully`});
setPreviewAvatarUrl(null);
setHasClearedAvatar(false);
await store.fetchApplication(applicationId);
} catch (err) {
console.error('[ApplicationDetail] Failed to update application:', err);
throw err;
}
},
[
application,
applicationId,
store,
form.formState.dirtyFields,
hasClearedAvatar,
hasClearedBanner,
isLifetimePremium,
],
);
const {handleSubmit: handleSave} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
const handleReset = React.useCallback(() => {
if (!application) return;
const defaults = buildFormDefaults(application);
form.reset(defaults, {keepDirty: false});
setInitialValues(defaults);
setPreviewAvatarUrl(null);
setHasClearedAvatar(false);
setPreviewBannerUrl(null);
setHasClearedBanner(false);
}, [application, buildFormDefaults, form]);
React.useEffect(() => {
UnsavedChangesActionCreators.setUnsavedChanges(APPLICATIONS_TAB_ID, hasUnsavedChanges);
}, [hasUnsavedChanges]);
React.useEffect(() => {
UnsavedChangesActionCreators.setTabData(APPLICATIONS_TAB_ID, {
onReset: handleReset,
onSave: handleSave,
isSubmitting: formIsSubmitting,
});
}, [handleReset, handleSave, formIsSubmitting]);
React.useEffect(() => {
return () => {
UnsavedChangesActionCreators.clearUnsavedChanges(APPLICATIONS_TAB_ID);
};
}, []);
const handleAvatarChange = React.useCallback(
(base64: string) => {
form.setValue('avatar', base64, {shouldDirty: true});
setPreviewAvatarUrl(base64);
setHasClearedAvatar(false);
form.clearErrors('avatar');
},
[form],
);
const handleBannerChange = React.useCallback(
(base64: string) => {
form.setValue('banner', base64, {shouldDirty: true});
setPreviewBannerUrl(base64);
setHasClearedBanner(false);
form.clearErrors('banner');
},
[form],
);
const handleBannerClear = React.useCallback(() => {
form.setValue('banner', null, {shouldDirty: true});
setPreviewBannerUrl(null);
setHasClearedBanner(true);
}, [form]);
const handleAvatarClear = React.useCallback(() => {
form.setValue('avatar', null, {shouldDirty: true});
setPreviewAvatarUrl(null);
setHasClearedAvatar(true);
}, [form]);
const handleCopyId = async () => {
if (!application) return;
try {
await navigator.clipboard.writeText(application.id);
setIdCopied(true);
setTimeout(() => setIdCopied(false), 2000);
} catch (err) {
console.error('[ApplicationDetail] Failed to copy ID:', err);
}
};
const handleDelete = () => {
if (!application) return;
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Delete Application`}
description={
<div>
<Trans>
Are you sure you want to delete <strong>{application.name}</strong>?
</Trans>
<br />
<br />
<Trans>
This action cannot be undone. All associated data, including the bot user, will be permanently
deleted.
</Trans>
</div>
}
primaryText={t`Delete Application`}
primaryVariant="danger-primary"
onPrimary={async () => {
try {
setIsDeleting(true);
await HttpClient.delete({url: Endpoints.OAUTH_APPLICATION(application.id), body: {}});
onBack();
} catch (err) {
console.error('[ApplicationDetail] Failed to delete application:', err);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to delete application. Please try again.`,
});
setIsDeleting(false);
}
}}
/>
)),
);
};
const rotateSecret = async (type: 'client' | 'bot') => {
if (!application) return;
setIsRotating(type);
try {
const sudoPayload = await sudo.require();
const endpoint =
type === 'client'
? Endpoints.OAUTH_APPLICATION_CLIENT_SECRET_RESET(application.id)
: Endpoints.OAUTH_APPLICATION_BOT_TOKEN_RESET(application.id);
const res = await HttpClient.post<{client_secret?: string; token?: string}>(endpoint, sudoPayload);
if (type === 'client') {
setClientSecret(res.body.client_secret ?? null);
} else {
setBotToken(res.body.token ?? null);
}
ToastActionCreators.createToast({
type: 'success',
children:
type === 'client'
? t`Client secret regenerated. Update any code that uses the old secret.`
: t`Bot token regenerated. Update any code that uses the old token.`,
});
} catch (err) {
console.error('Failed to rotate secret', err);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to rotate. Please try again.`,
});
} finally {
setIsRotating(null);
}
};
const addRedirectInput = () => {
const current = form.getValues('redirectUriInputs') ?? [];
form.setValue('redirectUriInputs', [...current, ''], {shouldDirty: true});
};
const removeRedirectInput = (index: number) => {
const current = form.getValues('redirectUriInputs') ?? [];
const next = current.filter((_, i) => i !== index);
form.setValue('redirectUriInputs', next.length > 0 ? next : [''], {shouldDirty: true});
};
const updateRedirectInput = (index: number, value: string) => {
const current = form.getValues('redirectUriInputs') ?? [];
const next = [...current];
next[index] = value;
form.setValue('redirectUriInputs', next, {shouldDirty: true});
};
const builderScopes = useWatch({control: form.control, name: 'builderScopes'}) || {};
const builderPermissions = useWatch({control: form.control, name: 'builderPermissions'}) || {};
const builderRedirectUri = useWatch({control: form.control, name: 'builderRedirectUri'});
const redirectInputs = useWatch({control: form.control, name: 'redirectUriInputs'}) ?? [];
const bannerValue = useWatch({control: form.control, name: 'banner'});
const builderScopeList = React.useMemo(
() =>
Object.entries(builderScopes)
.filter(([, enabled]) => enabled)
.map(([scope]) => scope),
[builderScopes],
);
const botPermissionsList = React.useMemo(() => getAllBotPermissions(i18n), [i18n]);
const builderUrl = React.useMemo(() => {
if (!application) return '';
const params = new URLSearchParams();
params.set('client_id', application.id);
if (builderScopeList.length > 0) {
params.set('scope', builderScopeList.join(' '));
}
const isBot = builderScopeList.includes('bot');
const botPerms = Object.entries(builderPermissions)
.filter(([, enabled]) => enabled)
.map(([perm]) => perm);
if (isBot && botPerms.length > 0) {
params.set('permissions', formatBotPermissionsQuery(botPerms));
}
const redirect = builderRedirectUri?.trim();
if (redirect) {
params.set('redirect_uri', redirect);
params.set('response_type', 'code');
} else {
if (!isBot) {
params.set('response_type', 'code');
}
}
if (builderScopeList.length === 0) {
return '';
}
return `${window.location.origin}${Endpoints.OAUTH_AUTHORIZE}?${params.toString()}`;
}, [application, builderScopeList, builderPermissions, builderRedirectUri]);
const redirectOptions = React.useMemo(() => {
const normalized = Array.from(new Set((redirectInputs ?? []).map((u) => u.trim()).filter(Boolean)));
const current = builderRedirectUri?.trim();
if (current && !normalized.includes(current)) {
normalized.push(current);
}
return normalized.map((url) => ({value: url, label: url}));
}, [builderRedirectUri, redirectInputs]);
const confirmRotate = (type: 'client' | 'bot') => {
if (!application) return;
const isClient = type === 'client';
const description = isClient ? (
<Trans>Regenerating will invalidate the current secret. Update any code that uses the old value.</Trans>
) : (
<Trans>Regenerating will invalidate the current token. Update any code that uses the old value.</Trans>
);
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={isClient ? t`Regenerate client secret?` : t`Regenerate bot token?`}
description={description}
primaryText={t`Regenerate`}
primaryVariant="danger-primary"
onPrimary={() => rotateSecret(type)}
/>
)),
);
};
const handleCopyBuilderUrl = React.useCallback(async () => {
if (!builderUrl) return;
await navigator.clipboard.writeText(builderUrl);
ToastActionCreators.createToast({
type: 'success',
children: t`Copied URL to clipboard`,
});
}, [builderUrl]);
if (loading) {
return <div className={styles.loadingState} />;
}
if (error || !application) {
return (
<div className={styles.page}>
<StatusSlate
Icon={WarningCircleIcon}
title={<Trans>We couldn't load this application</Trans>}
description={<Trans>Please retry or go back to the applications list.</Trans>}
fullHeight={true}
actions={[
{
text: <Trans>Retry</Trans>,
onClick: () => store.fetchApplication(applicationId),
},
{
text: <Trans>Back to list</Trans>,
onClick: onBack,
variant: 'secondary',
},
]}
/>
</div>
);
}
const avatarUrl = application.bot
? AvatarUtils.getUserAvatarURL({id: application.bot.id, avatar: application.bot.avatar}, false)
: null;
const defaultAvatarUrl = application.bot
? AvatarUtils.getUserAvatarURL({id: application.bot.id, avatar: null}, false)
: null;
const displayAvatarUrl = hasClearedAvatar ? defaultAvatarUrl : previewAvatarUrl || avatarUrl || defaultAvatarUrl;
const hasAvatar = (!hasClearedAvatar && Boolean(application.bot?.avatar)) || Boolean(previewAvatarUrl);
const displayBannerUrl =
previewBannerUrl ||
(hasClearedBanner
? null
: application.bot
? AvatarUtils.getUserBannerURL({id: application.bot.id, banner: application.bot.banner}, true)
: null);
const hasBanner = Boolean(displayBannerUrl || bannerValue);
return (
<Form form={form} onSubmit={onSubmit}>
<div className={styles.page}>
<ApplicationHeader
name={application.name}
applicationId={application.id}
onBack={onBack}
onCopyId={handleCopyId}
idCopied={idCopied}
/>
<div className={styles.detailGrid}>
<div className={styles.columnStack}>
<SecretsSection
clientSecret={clientSecret}
botToken={botToken}
onRegenerateClientSecret={() => confirmRotate('client')}
onRegenerateBotToken={() => confirmRotate('bot')}
isRotatingClient={isRotating === 'client'}
isRotatingBot={isRotating === 'bot'}
hasBot={Boolean(application.bot)}
clientSecretInputId={clientSecretInputId}
botTokenInputId={botTokenInputId}
/>
<div className={styles.sectionSpacer} aria-hidden="true" />
<ApplicationInfoSection
form={form}
redirectInputs={form.watch('redirectUriInputs') ?? []}
onAddRedirect={addRedirectInput}
onRemoveRedirect={removeRedirectInput}
onUpdateRedirect={updateRedirectInput}
/>
{application.bot && (
<>
<div className={styles.sectionSpacer} aria-hidden="true" />
<BotProfileSection
application={application}
form={form}
displayAvatarUrl={displayAvatarUrl}
hasAvatar={hasAvatar}
hasClearedAvatar={hasClearedAvatar}
isLifetimePremium={Boolean(isLifetimePremium)}
onAvatarChange={handleAvatarChange}
onAvatarClear={handleAvatarClear}
onBannerChange={handleBannerChange}
onBannerClear={handleBannerClear}
displayBannerUrl={displayBannerUrl}
hasBanner={hasBanner}
hasClearedBanner={hasClearedBanner}
/>
</>
)}
</div>
<div className={styles.columnStack}>
<div className={styles.sectionSpacer} aria-hidden="true" />
<div>
<OAuthBuilderSection
form={form}
availableScopes={AVAILABLE_SCOPES}
builderScopeList={builderScopeList}
botPermissionsList={botPermissionsList}
builderUrl={builderUrl}
redirectOptions={redirectOptions}
onCopyBuilderUrl={handleCopyBuilderUrl}
/>
</div>
</div>
</div>
<div className={styles.sectionSpacer} aria-hidden="true" />
<SectionCard
tone="danger"
title={<Trans>Danger zone</Trans>}
subtitle={<Trans>This cannot be undone. Removing the application also deletes its bot.</Trans>}
>
<div className={styles.dangerContent}>
<p className={styles.helperText}>
<Trans>Once deleted, the application and its credentials are permanently removed.</Trans>
</p>
<div className={styles.dangerActions}>
<Button
variant="danger-primary"
onClick={handleDelete}
submitting={isDeleting}
leftIcon={<TrashIcon size={16} weight="fill" />}
fitContent
>
<Trans>Delete Application</Trans>
</Button>
</div>
</div>
</SectionCard>
</div>
</Form>
);
},
);

View File

@@ -0,0 +1,86 @@
/*
* 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 {AppWindowIcon, CaretRightIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import styles from '~/components/modals/tabs/ApplicationsTab/ApplicationsTab.module.css';
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
import * as AvatarUtils from '~/utils/AvatarUtils';
import * as DateUtils from '~/utils/DateUtils';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
interface ApplicationsListProps {
applications: ReadonlyArray<DeveloperApplication>;
onSelectApplication: (appId: string) => void;
}
export const ApplicationsList: React.FC<ApplicationsListProps> = observer(({applications, onSelectApplication}) => {
if (applications.length === 0) {
return (
<div className={styles.emptyState}>
<StatusSlate
Icon={AppWindowIcon}
title={<Trans>No applications yet</Trans>}
description={<Trans>Create your first application to get started with the Fluxer API.</Trans>}
/>
</div>
);
}
return (
<div className={styles.listContainer}>
{applications.map((app) => {
const avatarUrl = app.bot
? AvatarUtils.getUserAvatarURL({id: app.bot.id, avatar: app.bot.avatar}, false)
: null;
const createdAt = DateUtils.getFormattedShortDate(SnowflakeUtils.extractTimestamp(app.id));
return (
<div key={app.id} className={styles.itemContainer}>
<button type="button" className={styles.itemButton} onClick={() => onSelectApplication(app.id)}>
<div className={styles.itemLeft}>
{avatarUrl ? (
<div className={styles.itemAvatar} style={{backgroundImage: `url(${avatarUrl})`}} aria-hidden />
) : (
<div className={styles.itemAvatarPlaceholder} aria-hidden>
{app.name.charAt(0).toUpperCase()}
</div>
)}
<div className={styles.itemTextBlock}>
<div className={styles.itemTitleRow}>
<span className={styles.itemName}>{app.name}</span>
</div>
<div className={styles.itemMetaRow}>
<span>
<Trans>Created {createdAt}</Trans>
</span>
</div>
</div>
</div>
<CaretRightIcon className={styles.itemChevron} weight="bold" />
</button>
</div>
);
})}
</div>
);
});

View File

@@ -0,0 +1,727 @@
/*
* 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/>.
*/
.buttonContainer {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--spacing-3);
margin-bottom: var(--spacing-3);
flex-wrap: wrap;
}
.devControls {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
margin-bottom: var(--spacing-3);
}
.devControlButton {
min-width: 200px;
}
.devControlButton[data-active='true'] {
box-shadow: 0 0 0 2px var(--border-muted, var(--background-modifier-accent));
}
.documentationLink {
color: var(--text-link);
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
font-weight: 600;
text-decoration: none;
line-height: 1.25;
}
.documentationLink:hover {
text-decoration: underline;
}
.documentationIcon {
color: var(--text-link);
display: block;
}
.createForm {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.modalButtons {
display: flex;
gap: var(--spacing-2);
justify-content: flex-end;
flex-wrap: wrap;
}
.errorCard {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding: 1.5rem;
border: 1px solid var(--background-modifier-accent);
border-radius: 8px;
background-color: var(--background-secondary);
align-items: flex-start;
}
.errorHeader {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.errorTitle {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
color: var(--text-primary);
}
.errorSubtitle {
margin: 0;
color: var(--text-primary-muted);
}
.errorActions {
display: flex;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.errorState {
padding: var(--spacing-3) 0;
border: none;
background: transparent;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-3);
text-align: center;
}
.statusActions {
display: flex;
gap: var(--spacing-2);
justify-content: center;
}
.listContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.itemContainer {
border-radius: var(--radius-xl);
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-textarea);
overflow: hidden;
}
.itemButton {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: var(--input-container-min-height);
padding: 0 var(--input-container-padding);
background: transparent;
border: none;
text-align: left;
cursor: pointer;
transition: background-color var(--transition-fast);
}
.itemButton:hover {
background-color: var(--background-modifier-hover);
}
.itemButton:focus-visible {
background-color: var(--background-modifier-hover);
outline: 2px solid var(--brand-primary);
outline-offset: -2px;
}
.itemLeft {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
flex: 1;
}
.itemAvatar {
height: 32px;
width: 32px;
flex-shrink: 0;
border-radius: 9999px;
background-position: center;
background-size: cover;
}
.itemAvatarPlaceholder {
height: 32px;
width: 32px;
flex-shrink: 0;
border-radius: 9999px;
background-color: var(--brand-experiment);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.875rem;
}
.itemTextBlock {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.itemTitleRow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.itemName {
font-weight: 500;
font-size: 0.875rem;
line-height: 1.125rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.itemMetaRow {
display: flex;
align-items: center;
gap: var(--spacing-2);
color: var(--text-primary-muted);
font-size: 0.75rem;
line-height: 0.8125rem;
min-width: 0;
}
.itemId {
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.itemChevron {
height: 20px;
width: 20px;
flex-shrink: 0;
color: var(--text-tertiary);
transition: transform var(--transition-fast);
}
@media (min-width: 768px) {
.itemChevron {
height: 24px;
width: 24px;
}
}
.emptyState {
padding: 3rem 2rem;
text-align: center;
color: var(--text-muted);
}
.emptyStateTitle {
margin: 0 0 0.5rem 0;
font-size: 1.1rem;
font-weight: 600;
}
.emptyStateDescription {
margin: 0;
font-size: 0.9rem;
}
.detailContainer {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.headerRow {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-2);
}
.headerPlaceholder {
display: flex;
align-items: center;
}
.headerPlaceholder button {
visibility: hidden;
pointer-events: none;
}
.section {
padding: 1.5rem;
border: 1px solid var(--background-modifier-accent);
border-radius: 8px;
background-color: var(--background-secondary);
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--background-modifier-accent);
}
.sectionTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.sectionDescription {
margin: 0.5rem 0 0 0;
font-size: 0.875rem;
color: var(--text-muted);
}
.sectionContent {
display: flex;
flex-direction: column;
gap: 1rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fieldLabel {
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.fieldValue {
padding: 0.75rem;
background-color: var(--background-tertiary);
border-radius: 4px;
font-family: monospace;
font-size: 0.875rem;
color: var(--text-primary);
word-break: break-all;
}
.fieldRow {
display: flex;
gap: 0.5rem;
align-items: center;
}
.uriList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.uriItem {
padding: 0.5rem;
background-color: var(--background-tertiary);
border-radius: 4px;
font-size: 0.875rem;
color: var(--text-primary);
word-break: break-all;
}
.scopeList {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.scopeBadge {
padding: 0.375rem 0.75rem;
background-color: var(--background-primary);
border-radius: 4px;
font-size: 0.875rem;
color: var(--text-primary);
font-family: monospace;
}
.checkboxGroup {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tokenBanner {
padding: 1rem;
background-color: var(--background-modifier-accent);
border: 2px solid var(--status-warning);
border-radius: 8px;
margin-bottom: 1rem;
}
.tokenBannerHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.tokenBannerTitle {
margin: 0;
color: var(--status-warning);
font-weight: 600;
font-size: 0.95rem;
}
.tokenBannerClose {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.tokenBannerClose:hover {
background-color: var(--background-modifier-hover);
}
.tokenBannerText {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
color: var(--text-primary);
}
.tokenDisplay {
display: flex;
gap: 0.5rem;
align-items: center;
}
.tokenInput {
flex: 1;
padding: 0.75rem;
background-color: var(--background-tertiary);
border: 1px solid var(--background-modifier-accent);
border-radius: 4px;
font-family: monospace;
font-size: 0.85rem;
color: var(--text-primary);
word-break: break-all;
}
.botProfileSection {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: var(--spacing-3);
}
.avatarSection {
display: flex;
gap: var(--spacing-3);
align-items: center;
margin-bottom: var(--spacing-3);
}
.avatarDisplay {
flex-shrink: 0;
}
.avatarControls {
flex: 1;
min-width: 0;
}
.botAvatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--background-modifier-accent);
flex-shrink: 0;
}
.botAvatarPlaceholder {
width: 80px;
height: 80px;
border-radius: 50%;
background-color: var(--brand-experiment);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.botInfo {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.avatarUploadContainer {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fileInput {
padding: 0.5rem;
border: 1px solid var(--background-modifier-accent);
border-radius: 4px;
background-color: var(--background-tertiary);
font-size: 0.875rem;
color: var(--text-primary);
cursor: pointer;
}
.fileInput:hover {
background-color: var(--background-secondary);
}
.dangerZone {
border-color: var(--status-danger);
background-color: rgba(var(--status-danger-rgb), 0.05);
}
.dangerZone .sectionHeader {
border-bottom-color: var(--status-danger);
}
.dangerZone .sectionTitle {
color: var(--status-danger);
}
.buttonGroup {
display: flex;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.inputFooter {
font-size: 0.75rem;
color: var(--text-primary-muted);
margin-top: var(--spacing-1);
}
.fluxerTagContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.sectionSubtitle {
margin: 0;
color: var(--text-primary-muted);
font-size: 0.9rem;
}
.secretRow {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.secretLabel {
font-weight: 600;
color: var(--text-primary);
}
.secretInputRow {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.secretActions {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.redirectList {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.redirectRow {
display: flex;
gap: var(--spacing-2);
align-items: center;
}
.bannerSection {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.bannerHeader {
display: flex;
align-items: center;
justify-content: space-between;
}
.permissionsGrid {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.permissionsList {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.permissionItem {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.builderResult {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.fluxerTagLabel {
margin: 0;
display: block;
padding: 0;
font-weight: 500;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
}
.fluxerTagInputRow {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.usernameInput {
flex: 1;
}
.separator {
display: flex;
align-items: center;
font-family: var(--font-mono);
font-size: 1.25rem;
line-height: 1;
color: var(--text-primary);
padding: 0 var(--spacing-1);
}
.discriminatorInput {
width: 5rem;
}
.validationBox {
margin-top: var(--spacing-1);
}
.error {
color: var(--text-danger);
font-size: 0.875rem;
padding: 0.75rem;
background-color: rgba(var(--status-danger-rgb), 0.1);
border-radius: 4px;
margin-top: 0.5rem;
}
.spinnerContainer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 2rem;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.scopeGrid {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.scopeList {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--spacing-2);
}
.scopeItem {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: 0.5rem 0.75rem;
border: 1px solid var(--background-modifier-accent);
border-radius: var(--radius-lg);
background-color: var(--background-secondary);
}

View File

@@ -0,0 +1,270 @@
/*
* 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 {action, makeAutoObservable, runInAction} from 'mobx';
import {Endpoints} from '~/Endpoints';
import HttpClient from '~/lib/HttpClient';
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
import {DeveloperApplicationRecord} from '~/records/DeveloperApplicationRecord';
enum NavigationState {
LOADING_LIST = 'LOADING_LIST',
LIST = 'LIST',
LOADING_DETAIL = 'LOADING_DETAIL',
DETAIL = 'DETAIL',
ERROR = 'ERROR',
}
class ApplicationsTabStore {
navigationState: NavigationState = NavigationState.LOADING_LIST;
applicationOrder: Array<string> = [];
applicationsById: Record<string, DeveloperApplicationRecord> = {};
selectedAppId: string | null = null;
error: string | null = null;
isLoading: boolean = false;
private listAbortController: AbortController | null = null;
private detailAbortController: AbortController | null = null;
constructor() {
makeAutoObservable(this, {}, {autoBind: true});
}
get contentKey(): string {
if (this.navigationState === NavigationState.DETAIL && this.selectedAppId) {
return `applications-detail-${this.selectedAppId}`;
}
return 'applications-main';
}
get isDetailView(): boolean {
return this.navigationState === NavigationState.DETAIL || this.navigationState === NavigationState.LOADING_DETAIL;
}
get isListView(): boolean {
return this.navigationState === NavigationState.LIST || this.navigationState === NavigationState.LOADING_LIST;
}
get applications(): ReadonlyArray<DeveloperApplicationRecord> {
const records: Array<DeveloperApplicationRecord> = [];
for (const id of this.applicationOrder) {
const record = this.applicationsById[id];
if (record) {
records.push(record);
}
}
return records;
}
get selectedApplication(): DeveloperApplicationRecord | null {
if (!this.selectedAppId) {
return null;
}
return this.applicationsById[this.selectedAppId] ?? null;
}
get hasApplications(): boolean {
return this.applicationOrder.length > 0;
}
async fetchApplications(options?: {showLoading?: boolean}): Promise<void> {
if (this.listAbortController) {
this.listAbortController.abort();
}
this.listAbortController = new AbortController();
const shouldShowLoading = options?.showLoading ?? (!this.hasApplications && !this.isDetailView);
runInAction(() => {
if (shouldShowLoading) {
this.navigationState = NavigationState.LOADING_LIST;
}
this.isLoading = shouldShowLoading;
this.error = null;
});
try {
const response = await HttpClient.get<Array<DeveloperApplication>>({
url: Endpoints.OAUTH_APPLICATIONS_LIST,
signal: this.listAbortController.signal,
});
runInAction(() => {
this.mergeApplications(response.body);
if (!this.isDetailView) {
this.navigationState = NavigationState.LIST;
}
});
} catch (err) {
if ((err as DOMException).name === 'AbortError') {
return;
}
console.error('[ApplicationsTabStore] Failed to fetch applications:', err);
runInAction(() => {
this.error = 'Failed to load applications';
if (!this.isDetailView) {
this.navigationState = NavigationState.ERROR;
}
});
} finally {
runInAction(() => {
this.isLoading = false;
this.listAbortController = null;
});
}
}
async fetchApplication(appId: string, options?: {showLoading?: boolean}): Promise<void> {
if (this.detailAbortController) {
this.detailAbortController.abort();
}
this.detailAbortController = new AbortController();
const shouldShowLoading = Boolean(options?.showLoading);
runInAction(() => {
this.isLoading = shouldShowLoading;
if (shouldShowLoading) {
this.navigationState = NavigationState.LOADING_DETAIL;
}
this.error = null;
});
try {
const response = await HttpClient.get<DeveloperApplication>({
url: Endpoints.OAUTH_APPLICATION(appId),
signal: this.detailAbortController.signal,
});
runInAction(() => {
this.cacheApplication(response.body);
this.navigationState = NavigationState.DETAIL;
});
} catch (err) {
if ((err as DOMException).name === 'AbortError') {
return;
}
console.error('[ApplicationsTabStore] Failed to fetch application:', err);
runInAction(() => {
this.error = 'Failed to load application details';
this.navigationState = NavigationState.ERROR;
});
} finally {
runInAction(() => {
this.isLoading = false;
this.detailAbortController = null;
});
}
}
async navigateToDetail(appId: string, initialApplication?: DeveloperApplication | null): Promise<void> {
if (
this.selectedAppId === appId &&
(this.navigationState === NavigationState.DETAIL || this.navigationState === NavigationState.LOADING_DETAIL)
) {
return;
}
let cacheHit = Boolean(this.applicationsById[appId]);
if (initialApplication) {
this.cacheApplication(initialApplication);
cacheHit = true;
}
runInAction(() => {
this.selectedAppId = appId;
this.error = null;
this.navigationState = cacheHit ? NavigationState.DETAIL : NavigationState.LOADING_DETAIL;
});
await this.fetchApplication(appId, {showLoading: !cacheHit});
}
async navigateToList(): Promise<void> {
if (this.detailAbortController) {
this.detailAbortController.abort();
this.detailAbortController = null;
}
runInAction(() => {
this.selectedAppId = null;
this.error = null;
if (this.hasApplications) {
this.navigationState = NavigationState.LIST;
} else {
this.navigationState = NavigationState.LOADING_LIST;
this.isLoading = true;
}
});
if (!this.hasApplications) {
await this.fetchApplications({showLoading: true});
}
}
@action
clearError(): void {
this.error = null;
if (this.navigationState === NavigationState.ERROR) {
if (this.isDetailView) {
this.navigationState = NavigationState.LOADING_DETAIL;
} else if (this.hasApplications) {
this.navigationState = NavigationState.LIST;
} else {
this.navigationState = NavigationState.LOADING_LIST;
}
}
}
private mergeApplications(applications: Array<DeveloperApplication>): void {
const nextById: Record<string, DeveloperApplicationRecord> = {...this.applicationsById};
const nextOrder: Array<string> = [];
for (const application of applications) {
const record = DeveloperApplicationRecord.from(application);
nextById[record.id] = record;
nextOrder.push(record.id);
}
this.applicationOrder = nextOrder;
this.applicationsById = nextById;
}
private cacheApplication(application: DeveloperApplication): DeveloperApplicationRecord {
const record = DeveloperApplicationRecord.from(application);
const nextById = {...this.applicationsById, [record.id]: record};
let nextOrder: Array<string> = this.applicationOrder;
if (!nextOrder.includes(record.id)) {
nextOrder = [...nextOrder, record.id];
}
this.applicationsById = nextById;
this.applicationOrder = nextOrder;
return record;
}
}
export default new ApplicationsTabStore();

View File

@@ -0,0 +1,572 @@
/*
* 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/>.
*/
.page {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-6) var(--spacing-5) var(--spacing-4);
width: 100%;
max-width: 1200px;
margin: 0 auto;
min-height: 100%;
background: var(--background-secondary);
}
.pageHeader {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
padding-bottom: var(--spacing-1);
}
.breadcrumbRow {
display: flex;
margin-bottom: var(--spacing-4);
}
.heroCard {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: 0;
border: none;
border-radius: 0;
background: transparent;
}
.heroTop {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-3);
flex-wrap: wrap;
}
.heroTop > div {
flex: 1;
min-width: 320px;
}
.eyebrow {
margin: 0;
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.78rem;
color: var(--text-primary-muted);
font-weight: 700;
}
.heroTitle {
margin: 0 0 var(--spacing-2) 0;
font-size: 1.35rem;
font-weight: 750;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.heroMeta {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
color: var(--text-primary-muted);
}
.metaValue {
padding: 0.55rem 0.9rem;
border-radius: var(--radius-lg);
background: var(--background-primary);
border: 1px solid var(--background-modifier-accent);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.9rem;
word-break: break-all;
min-width: 240px;
}
.metaInput {
width: 100%;
max-width: none;
font-family: var(--font-mono);
}
.pill {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
padding: 0.35rem 0.75rem;
border-radius: 999px;
background: var(--background-tertiary);
color: var(--text-primary);
font-weight: 700;
font-size: 0.85rem;
}
.actions {
display: flex;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.actions > * {
flex: 1;
min-width: fit-content;
}
.detailGrid {
display: grid;
gap: var(--spacing-6);
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
align-items: start;
}
.columnStack {
display: flex;
flex-direction: column;
align-self: stretch;
gap: var(--spacing-5);
}
.builderSection {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.sectionSpacer {
height: var(--spacing-5);
flex: 0 0 auto;
}
.card {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding: 0;
border: none;
border-radius: 0;
background: transparent;
}
.cardDanger {
border-color: transparent;
background: transparent;
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-2);
flex-wrap: wrap;
padding-bottom: var(--spacing-2);
border-bottom: 1px solid var(--background-modifier-accent);
}
.cardTitle {
margin: 0;
font-size: 1.05rem;
font-weight: 750;
color: var(--text-primary);
}
.cardSubtitle {
margin: 0.35rem 0 0 0;
color: var(--text-primary-muted);
font-size: 0.95rem;
line-height: 1.5;
}
.cardActions {
display: flex;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.cardBody {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding-top: var(--spacing-2);
}
.secretRow {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.secretLabel {
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.secretInputRow {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.secretActions {
display: flex;
align-items: center;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.fieldStack {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.toggleRow {
display: flex;
align-items: flex-start;
gap: var(--spacing-2);
}
.toggleSwitch {
width: 100%;
}
.toggleLabel {
display: flex;
flex-direction: column;
gap: 2px;
}
.toggleTitle {
font-weight: 700;
color: var(--text-primary);
}
.toggleDescription {
font-size: 0.85rem;
color: var(--text-primary-muted);
}
.redirectList {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.redirectRow {
display: grid;
grid-template-columns: 1fr auto;
gap: var(--spacing-2);
align-items: center;
}
.redirectActions {
display: flex;
gap: var(--spacing-1);
align-self: center;
align-items: center;
justify-content: flex-end;
}
.redirectRow[data-first='true'] .redirectActions {
flex-direction: column;
justify-content: center;
}
.redirectRow[data-first='true'] .redirectActions::before {
content: '';
display: block;
height: 1.25rem;
flex: 0 0 auto;
}
.redirectRemoveButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: var(--radius-md);
background: transparent;
color: var(--text-primary-muted);
cursor: pointer;
transition:
color 0.1s ease,
background-color 0.1s ease;
}
.redirectRemoveButton:hover:not(:disabled) {
color: var(--text-primary);
background: var(--background-modifier-hover);
}
.redirectRemoveButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.addRedirectButton {
align-self: flex-start;
}
.scopeGrid {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.fieldLabel {
margin: 0;
font-weight: 500;
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-primary);
}
.scopeList {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--spacing-2);
}
.botPermissionList {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@media (max-width: 1080px) {
.botPermissionList {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.botPermissionList {
grid-template-columns: 1fr;
}
}
.scopeItem {
display: flex;
align-items: center;
gap: var(--spacing-2);
padding: 0.35rem 0;
min-width: 0;
}
.scopeLabel {
display: inline-flex;
align-items: center;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 500;
color: var(--text-primary);
}
.avatarRow {
display: flex;
gap: var(--spacing-3);
align-items: center;
flex-wrap: wrap;
}
.avatarPreview {
width: 96px;
height: 96px;
border-radius: var(--radius-full);
object-fit: cover;
border: 1px solid var(--background-modifier-accent);
background: var(--background-tertiary);
}
.avatarPlaceholder {
width: 96px;
height: 96px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
rgba(var(--brand-primary-rgb), 0.9),
rgba(var(--brand-secondary-rgb, 95, 125, 255), 0.85)
);
color: white;
font-weight: 800;
font-size: 1.85rem;
}
.tagRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: var(--spacing-2);
row-gap: var(--spacing-2);
align-items: end;
width: 100%;
}
.discriminatorInput {
width: 6rem;
display: flex;
align-items: center;
align-self: end;
}
.metaRow {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
align-items: center;
color: var(--text-primary-muted);
}
.validationBox {
margin-top: -0.25rem;
}
.bannerRow {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
align-items: flex-start;
}
.builderResult {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.loadingState {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
padding: var(--spacing-5);
}
.loadingCard,
.errorCard {
padding: var(--spacing-4);
border: 1px solid var(--background-modifier-accent);
border-radius: var(--radius-xl);
background-color: var(--background-primary);
display: flex;
flex-direction: column;
gap: var(--spacing-2);
align-items: flex-start;
justify-content: center;
min-height: 320px;
}
.errorState {
width: 100%;
min-height: 320px;
display: flex;
flex-direction: column;
gap: var(--spacing-3);
align-items: center;
justify-content: center;
text-align: center;
}
.statusActions {
display: flex;
gap: var(--spacing-2);
flex-wrap: wrap;
justify-content: center;
width: 100%;
}
.spinnerRow {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.errorTitle {
margin: 0;
font-size: 1.05rem;
font-weight: 700;
color: var(--text-primary);
}
.errorSubtitle {
margin: 0;
color: var(--text-primary-muted);
}
.errorActions {
display: flex;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.helperText {
color: var(--text-primary-muted);
font-size: 0.9rem;
}
.dangerContent {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
align-items: flex-start;
justify-content: flex-start;
}
.dangerActions {
display: flex;
align-items: center;
gap: var(--spacing-2);
align-self: flex-start;
width: 100%;
}
.dangerActions > * {
flex: 0 0 auto;
}
.error {
color: var(--text-danger);
font-size: 0.95rem;
padding: var(--spacing-2);
border-radius: var(--radius-lg);
background: rgba(var(--status-danger-rgb), 0.12);
border: 1px solid rgba(var(--status-danger-rgb), 0.35);
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}

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 {ArrowLeftIcon, CheckIcon, CopyIcon} from '@phosphor-icons/react';
import type React from 'react';
import {Input} from '~/components/form/Input';
import {Button} from '~/components/uikit/Button/Button';
import styles from './ApplicationDetail.module.css';
interface ApplicationHeaderProps {
name: string;
applicationId: string;
onBack: () => void;
onCopyId: () => void;
idCopied: boolean;
}
export const ApplicationHeader: React.FC<ApplicationHeaderProps> = ({
name,
applicationId,
onBack,
onCopyId,
idCopied,
}) => {
const {t} = useLingui();
return (
<div className={styles.pageHeader}>
<div className={styles.breadcrumbRow}>
<Button variant="secondary" onClick={onBack} leftIcon={<ArrowLeftIcon size={16} weight="bold" />} fitContent>
{t`Back to Applications`}
</Button>
</div>
<div className={styles.heroCard}>
<div className={styles.heroTop}>
<div>
<h2 className={styles.heroTitle}>{name}</h2>
<Input
label={t`Application ID`}
value={applicationId}
readOnly
className={styles.metaInput}
rightElement={
<Button
variant="secondary"
compact
fitContent
onClick={onCopyId}
leftIcon={idCopied ? <CheckIcon size={14} weight="bold" /> : <CopyIcon size={14} />}
>
{idCopied ? t`Copied` : t`Copy ID`}
</Button>
}
/>
</div>
</div>
</div>
</div>
);
};

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 {useLingui} from '@lingui/react/macro';
import {XIcon} from '@phosphor-icons/react';
import type React from 'react';
import {Input} from '~/components/form/Input';
import {Switch} from '~/components/form/Switch';
import {Button} from '~/components/uikit/Button/Button';
import styles from './ApplicationDetail.module.css';
import {SectionCard} from './SectionCard';
import type {ApplicationDetailForm} from './types';
interface ApplicationInfoSectionProps {
form: ApplicationDetailForm;
redirectInputs: Array<string>;
onAddRedirect: () => void;
onRemoveRedirect: (index: number) => void;
onUpdateRedirect: (index: number, value: string) => void;
}
export const ApplicationInfoSection: React.FC<ApplicationInfoSectionProps> = ({
form,
redirectInputs,
onAddRedirect,
onRemoveRedirect,
onUpdateRedirect,
}) => {
const {t} = useLingui();
const redirectList = redirectInputs ?? [];
return (
<SectionCard title={t`Application information`} subtitle={t`Basic settings and allowed redirect URIs.`}>
<div className={styles.fieldStack}>
<Input
{...form.register('name', {required: t`Application name is required`})}
label={t`Application Name`}
value={form.watch('name')}
placeholder={t`My Application`}
maxLength={100}
error={form.formState.errors.name?.message}
/>
<div className={styles.toggleRow}>
<Switch
label={t`Public bot`}
description={t`Allow anyone to invite this bot to their communities.`}
value={form.watch('botPublic')}
onChange={(checked) => form.setValue('botPublic', checked, {shouldDirty: true})}
className={styles.toggleSwitch}
/>
</div>
<div className={styles.redirectList}>
{redirectList.map((value, idx) => (
<div key={idx} className={styles.redirectRow} data-first={idx === 0 ? 'true' : undefined}>
<Input
label={idx === 0 ? t`Redirect URIs` : undefined}
value={value}
onChange={(e) => onUpdateRedirect(idx, e.target.value)}
placeholder={t`https://example.com/callback`}
/>
<div className={styles.redirectActions}>
<button
type="button"
className={styles.redirectRemoveButton}
onClick={() => onRemoveRedirect(idx)}
disabled={idx === 0}
aria-label={t`Remove redirect URI`}
>
<XIcon size={18} weight="bold" />
</button>
</div>
</div>
))}
<Button variant="primary" fitContent className={styles.addRedirectButton} onClick={onAddRedirect}>
{t`Add redirect`}
</Button>
</div>
</div>
</SectionCard>
);
};

View File

@@ -0,0 +1,185 @@
/*
* 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 type React from 'react';
import {Controller} from 'react-hook-form';
import {Input, Textarea} from '~/components/form/Input';
import {UsernameValidationRules} from '~/components/form/UsernameValidationRules';
import {AvatarUploader} from '~/components/modals/tabs/MyProfileTab/AvatarUploader';
import {BannerUploader} from '~/components/modals/tabs/MyProfileTab/BannerUploader';
import {ImagePreviewField} from '~/components/shared/ImagePreviewField';
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
import styles from './ApplicationDetail.module.css';
import {SectionCard} from './SectionCard';
import type {ApplicationDetailForm} from './types';
interface BotProfileSectionProps {
application: DeveloperApplication;
form: ApplicationDetailForm;
displayAvatarUrl: string | null;
hasAvatar: boolean;
hasClearedAvatar: boolean;
displayBannerUrl: string | null;
hasBanner: boolean;
hasClearedBanner: boolean;
isLifetimePremium: boolean;
onAvatarChange: (value: string) => void;
onAvatarClear: () => void;
onBannerChange: (value: string) => void;
onBannerClear: () => void;
}
export const BotProfileSection: React.FC<BotProfileSectionProps> = ({
application,
form,
displayAvatarUrl,
hasAvatar,
hasClearedAvatar,
displayBannerUrl,
hasBanner,
hasClearedBanner,
isLifetimePremium,
onAvatarChange,
onAvatarClear,
onBannerChange,
onBannerClear,
}) => {
const {t} = useLingui();
return (
<SectionCard title={t`Bot profile`} subtitle={t`Avatar, tag, and rich profile details for your bot.`}>
<div className={styles.fieldStack}>
<div className={styles.avatarRow}>
{displayAvatarUrl ? (
<img src={displayAvatarUrl} alt="Bot avatar" className={styles.avatarPreview} />
) : (
<div className={styles.avatarPlaceholder}>{application.bot?.username.charAt(0).toUpperCase()}</div>
)}
<AvatarUploader
hasAvatar={hasAvatar && !hasClearedAvatar}
onAvatarChange={onAvatarChange}
onAvatarClear={onAvatarClear}
hasPremium={true}
isPerGuildProfile={false}
errorMessage={form.formState.errors.avatar?.message}
/>
</div>
<div className={styles.tagRow}>
<Controller
name="username"
control={form.control}
rules={{
required: t`Username is required`,
minLength: {value: 1, message: t`Username must be at least 1 character`},
maxLength: {value: 32, message: t`Username must be at most 32 characters`},
pattern: {
value: /^[a-zA-Z0-9_]+$/,
message: t`Username can only contain letters, numbers, and underscores`,
},
}}
render={({field}) => (
<Input
{...field}
aria-label={t`Bot Username`}
placeholder={t`BotName`}
maxLength={32}
required
label={t`FluxerTag`}
/>
)}
/>
<div className={styles.discriminatorInput}>
{isLifetimePremium ? (
<Controller
name="discriminator"
control={form.control}
rules={{
pattern: {
value: /^\d{1,4}$/,
message: t`Discriminator must be 1-4 digits`,
},
validate: (value) => {
if (!value) return true;
const num = parseInt(value, 10);
if (num < 0 || num > 9999) {
return t`Discriminator must be between 0 and 9999`;
}
return true;
},
}}
render={({field}) => (
<Input {...field} aria-label={t`Discriminator`} placeholder="0000" maxLength={4} />
)}
/>
) : (
<Input
value={application.bot?.discriminator}
readOnly
disabled
maxLength={4}
aria-label={t`Discriminator`}
/>
)}
</div>
</div>
{(form.formState.errors.username || form.formState.errors.discriminator) && (
<div className={styles.error}>
{form.formState.errors.username?.message || form.formState.errors.discriminator?.message}
</div>
)}
<div className={styles.validationBox}>
<UsernameValidationRules username={form.watch('username') || ''} />
</div>
<Textarea
{...form.register('bio')}
label={t`Bot Bio`}
value={form.watch('bio') || ''}
placeholder={t`A helpful bot that does amazing things!`}
minRows={3}
maxRows={6}
maxLength={1024}
error={form.formState.errors.bio?.message}
/>
<div className={styles.bannerRow}>
<BannerUploader
hasBanner={hasBanner}
onBannerChange={onBannerChange}
onBannerClear={onBannerClear}
hasPremium={true}
isPerGuildProfile={false}
errorMessage={form.formState.errors.banner?.message as string | undefined}
/>
</div>
<ImagePreviewField
imageUrl={hasBanner && !hasClearedBanner ? displayBannerUrl : null}
showPlaceholder={!hasBanner || hasClearedBanner}
placeholderText={t`No bot banner`}
altText={t`Bot banner preview`}
objectFit="contain"
/>
</div>
</SectionCard>
);
};

View File

@@ -0,0 +1,156 @@
/*
* 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 {t} from '@lingui/core/macro';
import {Trans} from '@lingui/react/macro';
import {CopyIcon} from '@phosphor-icons/react';
import type React from 'react';
import {Controller} from 'react-hook-form';
import {Input} from '~/components/form/Input';
import {Select, type SelectOption} from '~/components/form/Select';
import {Button} from '~/components/uikit/Button/Button';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import styles from './ApplicationDetail.module.css';
import {SectionCard} from './SectionCard';
import type {ApplicationDetailForm} from './types';
interface OAuthBuilderSectionProps {
form: ApplicationDetailForm;
availableScopes: ReadonlyArray<string>;
builderScopeList: Array<string>;
botPermissionsList: Array<{id: string; label: string}>;
builderUrl: string;
redirectOptions: Array<SelectOption<string>>;
onCopyBuilderUrl: () => Promise<void>;
}
export const OAuthBuilderSection: React.FC<OAuthBuilderSectionProps> = ({
form,
availableScopes,
builderScopeList,
botPermissionsList,
builderUrl,
redirectOptions,
onCopyBuilderUrl,
}) => {
const builderRedirectUri = form.watch('builderRedirectUri');
const redirectError =
!builderScopeList.includes('bot') && !builderRedirectUri
? t`Redirect URI is required when not using only the bot scope.`
: undefined;
return (
<SectionCard
title={<Trans>OAuth2 URL Builder</Trans>}
subtitle={<Trans>Construct an authorize URL with scopes and permissions.</Trans>}
>
<div className={styles.fieldStack}>
<div className={styles.scopeGrid}>
<div className={styles.fieldLabel}>
<Trans>Scopes</Trans>
</div>
<div className={styles.scopeList}>
{availableScopes.map((scope) => (
<div key={scope} className={styles.scopeItem}>
<Controller
name={`builderScopes.${scope}` as const}
control={form.control}
render={({field}) => (
<Checkbox checked={!!field.value} onChange={(checked) => field.onChange(checked)} size="small">
<span className={styles.scopeLabel}>{scope}</span>
</Checkbox>
)}
/>
</div>
))}
</div>
</div>
<Controller
name="builderRedirectUri"
control={form.control}
render={({field}) => (
<Select
label={t`Redirect URI (required unless only bot scope)`}
placeholder={t`Select a redirect URI`}
value={field.value ?? ''}
options={redirectOptions}
onChange={(val) => field.onChange(val || '')}
isClearable
error={redirectError}
/>
)}
/>
{builderScopeList.includes('bot') && (
<div className={styles.scopeGrid}>
<div className={styles.fieldLabel}>
<Trans>Bot permissions</Trans>
</div>
<div className={`${styles.scopeList} ${styles.botPermissionList}`}>
{botPermissionsList.map((perm) => (
<div key={perm.id} className={styles.scopeItem}>
<Controller
name={`builderPermissions.${perm.id}` as const}
control={form.control}
render={({field}) => (
<Checkbox checked={!!field.value} onChange={(checked) => field.onChange(checked)} size="small">
<span className={styles.scopeLabel}>{perm.label}</span>
</Checkbox>
)}
/>
</div>
))}
</div>
</div>
)}
<div className={styles.builderResult}>
<Input
label={t`Authorize URL`}
value={builderUrl}
readOnly
placeholder={t`Select scopes (and redirect URI if required)`}
rightElement={
<Button
variant="primary"
compact
fitContent
aria-label={t`Copy authorize URL`}
leftIcon={<CopyIcon size={16} />}
disabled={!builderUrl}
onClick={onCopyBuilderUrl}
>
<span className={styles.srOnly}>
<Trans>Copy</Trans>
</span>
</Button>
}
/>
{!builderUrl && (
<div className={styles.error}>
<Trans>Select scopes and a redirect URI (unless bot-only) to generate a URL.</Trans>
</div>
)}
</div>
</div>
</SectionCard>
);
};

View File

@@ -0,0 +1,93 @@
/*
* 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 type React from 'react';
import {Input} from '~/components/form/Input';
import {Button} from '~/components/uikit/Button/Button';
import styles from './ApplicationDetail.module.css';
import {SectionCard} from './SectionCard';
interface SecretsSectionProps {
clientSecret: string | null;
botToken: string | null;
onRegenerateClientSecret: () => void;
onRegenerateBotToken: () => void;
isRotatingClient: boolean;
isRotatingBot: boolean;
hasBot: boolean;
clientSecretInputId: string;
botTokenInputId: string;
}
export const SecretsSection: React.FC<SecretsSectionProps> = ({
clientSecret,
botToken,
onRegenerateClientSecret,
onRegenerateBotToken,
isRotatingClient,
isRotatingBot,
hasBot,
clientSecretInputId,
botTokenInputId,
}) => {
const {t} = useLingui();
return (
<SectionCard
title={t`Secrets & tokens`}
subtitle={t`Keep these safe. Regenerating will break existing integrations.`}
>
<div className={styles.fieldStack}>
<div className={styles.secretRow}>
<Input
id={clientSecretInputId}
label={t`Client secret`}
type="text"
value={clientSecret ?? ''}
readOnly
placeholder={clientSecret ? '•'.repeat(64) : '•'.repeat(64)}
/>
<div className={styles.secretActions}>
<Button variant="primary" compact submitting={isRotatingClient} onClick={onRegenerateClientSecret}>
{t`Regenerate`}
</Button>
</div>
</div>
{hasBot && (
<div className={styles.secretRow}>
<Input
id={botTokenInputId}
label={t`Bot token`}
type="text"
value={botToken ?? ''}
readOnly
placeholder={botToken ? '•'.repeat(64) : '•'.repeat(64)}
/>
<div className={styles.secretActions}>
<Button variant="primary" compact submitting={isRotatingBot} onClick={onRegenerateBotToken}>
{t`Regenerate`}
</Button>
</div>
</div>
)}
</div>
</SectionCard>
);
};

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 clsx from 'clsx';
import type React from 'react';
import styles from './ApplicationDetail.module.css';
interface SectionCardProps {
title: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
tone?: 'default' | 'danger';
children: React.ReactNode;
}
export const SectionCard: React.FC<SectionCardProps> = ({title, subtitle, actions, children, tone = 'default'}) => {
return (
<section className={clsx(styles.card, tone === 'danger' && styles.cardDanger)}>
<div className={styles.cardHeader}>
<div>
<h3 className={styles.cardTitle}>{title}</h3>
{subtitle && <p className={styles.cardSubtitle}>{subtitle}</p>}
</div>
{actions && <div className={styles.cardActions}>{actions}</div>}
</div>
<div className={styles.cardBody}>{children}</div>
</section>
);
};

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 type {UseFormReturn} from 'react-hook-form';
export interface ApplicationDetailFormValues {
name: string;
redirectUris: Array<string>;
botPublic: boolean;
username?: string;
discriminator?: string;
avatar?: string | null;
bio?: string | null;
banner?: string | null;
redirectUriInputs: Array<string>;
builderScopes: Record<string, boolean>;
builderRedirectUri?: string;
builderPermissions: Record<string, boolean>;
}
export type ApplicationDetailForm = UseFormReturn<ApplicationDetailFormValues>;

View File

@@ -0,0 +1,156 @@
/*
* 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 {BookOpenIcon, WarningCircleIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {useSettingsContentKey} from '~/components/modals/hooks/useSettingsContentKey';
import {useUnsavedChangesFlash} from '~/components/modals/hooks/useUnsavedChangesFlash';
import {
SettingsTabContainer,
SettingsTabContent,
SettingsTabSection,
} from '~/components/modals/shared/SettingsTabLayout';
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import {ApplicationCreateModal} from '~/components/modals/tabs/ApplicationsTab/ApplicationCreateModal';
import {ApplicationDetail} from '~/components/modals/tabs/ApplicationsTab/ApplicationDetail';
import {ApplicationsList} from '~/components/modals/tabs/ApplicationsTab/ApplicationsList';
import styles from '~/components/modals/tabs/ApplicationsTab/ApplicationsTab.module.css';
import ApplicationsTabStore from '~/components/modals/tabs/ApplicationsTab/ApplicationsTabStore';
import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner';
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
const ApplicationsTab: React.FC = observer(() => {
const {checkUnsavedChanges} = useUnsavedChangesFlash('applications');
const {setContentKey} = useSettingsContentKey();
const store = ApplicationsTabStore;
React.useLayoutEffect(() => {
setContentKey(store.contentKey);
}, [store.contentKey, setContentKey]);
React.useEffect(() => {
void store.fetchApplications({showLoading: store.applications.length === 0});
}, [store]);
const handleSelectApplication = React.useCallback(
(appId: string) => {
if (checkUnsavedChanges()) return;
void store.navigateToDetail(appId);
},
[store, checkUnsavedChanges],
);
const openCreateModal = React.useCallback(() => {
ModalActionCreators.push(
modal(() => (
<ApplicationCreateModal
onCreated={async (app: DeveloperApplication) => {
await store.navigateToDetail(app.id, app);
void store.fetchApplications({showLoading: false});
}}
/>
)),
);
}, [store]);
const handleBackToList = React.useCallback(() => {
if (checkUnsavedChanges()) return;
void store.navigateToList();
}, [store, checkUnsavedChanges]);
if (store.navigationState === 'LOADING_LIST' || (store.isLoading && store.isListView)) {
return (
<SettingsTabContainer>
<SettingsTabContent>
<div className={styles.spinnerContainer}>
<Spinner />
</div>
</SettingsTabContent>
</SettingsTabContainer>
);
}
if (store.navigationState === 'ERROR' && store.isListView) {
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsTabSection
title={<Trans>Applications &amp; Bots</Trans>}
description={<Trans>Manage your applications and bots.</Trans>}
>
<StatusSlate
Icon={WarningCircleIcon}
title={<Trans>Unable to load applications</Trans>}
description={<Trans>Check your connection and try again.</Trans>}
actions={[
{
text: <Trans>Retry</Trans>,
onClick: () => store.fetchApplications({showLoading: true}),
},
]}
/>
</SettingsTabSection>
</SettingsTabContent>
</SettingsTabContainer>
);
}
if (store.isDetailView && store.selectedAppId) {
return (
<SettingsTabContainer>
<SettingsTabContent>
<ApplicationDetail
applicationId={store.selectedAppId}
onBack={handleBackToList}
initialApplication={store.selectedApplication}
/>
</SettingsTabContent>
</SettingsTabContainer>
);
}
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsTabSection
title={<Trans>Applications &amp; Bots</Trans>}
description={<Trans>Create and manage applications and bots for your account.</Trans>}
>
<div className={styles.buttonContainer}>
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal}>
<Trans>Create Application</Trans>
</Button>
<a className={styles.documentationLink} href="https://fluxer.dev" target="_blank" rel="noreferrer">
<BookOpenIcon weight="fill" size={18} className={styles.documentationIcon} />
<Trans>Read the Documentation (fluxer.dev)</Trans>
</a>
</div>
<ApplicationsList applications={store.applications} onSelectApplication={handleSelectApplication} />
</SettingsTabSection>
</SettingsTabContent>
</SettingsTabContainer>
);
});
export default ApplicationsTab;

View File

@@ -0,0 +1,276 @@
/*
* 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;
height: 100%;
flex-direction: column;
}
.header {
padding: 1rem 2rem;
}
.title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.description {
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.scrollContainer {
flex: 1;
overflow: hidden;
}
.scrollerPadding {
padding-left: 2rem;
padding-right: 2rem;
}
.appList {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-bottom: 1rem;
}
.appCard {
display: flex;
flex-direction: column;
border-radius: var(--radius-xl);
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-textarea);
overflow: hidden;
}
.headerButton {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: var(--input-container-min-height);
padding: 0 var(--input-container-padding);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background-color var(--transition-fast);
}
.headerButton:hover {
background-color: var(--background-modifier-hover);
}
.headerButton:focus-visible {
background-color: var(--background-modifier-hover);
outline: 2px solid var(--brand-primary);
outline-offset: -2px;
}
.left {
display: flex;
align-items: center;
gap: var(--spacing-2);
min-width: 0;
flex: 1;
}
.appAvatar {
height: 32px;
width: 32px;
flex-shrink: 0;
border-radius: 9999px;
background-color: var(--background-tertiary);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.appAvatarImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.appAvatarPlaceholder {
width: 18px;
height: 18px;
color: var(--text-muted);
}
.textBlock {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing-1);
}
.appName {
font-weight: 600;
font-size: 0.875rem;
line-height: 1.125rem;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.metaRow {
display: flex;
align-items: center;
gap: var(--spacing-2);
color: var(--text-primary-muted);
font-size: 0.75rem;
line-height: 0.875rem;
min-width: 0;
}
.metaText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
width: 20px;
height: 20px;
color: var(--text-tertiary);
flex-shrink: 0;
transition: transform var(--transition-fast);
}
.chevronExpanded {
transform: rotate(180deg);
}
.loadingContainer {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
}
.errorContainer {
display: flex;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
text-align: center;
}
.errorText {
color: var(--text-secondary);
}
.details {
border-top: 1px solid var(--background-modifier-accent);
padding: var(--spacing-3) var(--input-container-padding);
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.detailsRow {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
@media (min-width: 768px) {
.detailsRow {
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing-4);
}
}
.scopeColumn {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
flex: 1;
}
.sectionLabel {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.scopeList {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.scopeTag {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: var(--spacing-2);
border-radius: var(--radius-lg);
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-tertiary);
}
.scopeName {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-mono);
}
.scopeDescription {
font-size: 0.82rem;
color: var(--text-secondary);
line-height: 1.4;
}
.actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-2);
align-self: flex-end;
}
.actions > :global(button) {
white-space: nowrap;
}

View File

@@ -0,0 +1,239 @@
/*
* 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 {t} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {AppWindowIcon, CaretDownIcon, NetworkSlashIcon} from '@phosphor-icons/react';
import clsx from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import type {OAuth2Authorization} from '~/actions/OAuth2AuthorizationActionCreators';
import * as OAuth2AuthorizationActionCreators from '~/actions/OAuth2AuthorizationActionCreators';
import {getOAuth2ScopeDescription, type OAuth2Scope} from '~/Constants';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import {Button} from '~/components/uikit/Button/Button';
import {Scroller} from '~/components/uikit/Scroller';
import {Spinner} from '~/components/uikit/Spinner';
import {getUserAvatarURL} from '~/utils/AvatarUtils';
import styles from './AuthorizedAppsTab.module.css';
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const AuthorizedAppsTab = observer(function AuthorizedAppsTab() {
const {i18n} = useLingui();
const [authorizations, setAuthorizations] = React.useState<Array<OAuth2Authorization>>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set());
const toggleExpanded = React.useCallback((appId: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(appId)) {
next.delete(appId);
} else {
next.add(appId);
}
return next;
});
}, []);
const loadAuthorizations = React.useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await OAuth2AuthorizationActionCreators.listAuthorizations();
setAuthorizations(data);
} catch (_err) {
setError(t`Failed to load authorized applications`);
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
loadAuthorizations();
}, [loadAuthorizations]);
const handleDeauthorize = React.useCallback((authorization: OAuth2Authorization) => {
const appName = authorization.application.name;
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Deauthorize Application`}
description={t`Are you sure you want to deauthorize ${appName}? This application will no longer have access to your account.`}
primaryText={t`Deauthorize`}
primaryVariant="danger-primary"
onPrimary={async () => {
await OAuth2AuthorizationActionCreators.deauthorize(authorization.application.id);
setAuthorizations((prev) => prev.filter((a) => a.application.id !== authorization.application.id));
}}
/>
)),
);
}, []);
if (loading) {
return (
<div className={styles.loadingContainer}>
<Spinner size="large" />
</div>
);
}
if (error) {
return (
<StatusSlate
Icon={NetworkSlashIcon}
title={t`Failed to load authorized applications`}
description={error}
actions={[
{
text: t`Retry`,
onClick: loadAuthorizations,
variant: 'primary',
},
]}
/>
);
}
if (authorizations.length === 0) {
return (
<StatusSlate
Icon={AppWindowIcon}
title={<Trans>No Authorized Applications</Trans>}
description={<Trans>You haven&apos;t authorized any applications to access your account.</Trans>}
fullHeight
/>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.title}>
<Trans>Authorized Applications</Trans>
</h2>
<p className={styles.description}>
<Trans>These applications have been granted access to your Fluxer account.</Trans>
</p>
</div>
<div className={styles.scrollContainer}>
<Scroller className={styles.scrollerPadding}>
<div className={styles.appList}>
{authorizations.map((authorization) => {
const iconUrl = authorization.application.icon
? getUserAvatarURL({
id: authorization.application.id,
avatar: authorization.application.icon,
})
: null;
const isExpanded = expandedIds.has(authorization.application.id);
const authorizedOn = formatDate(authorization.authorized_at);
return (
<div key={authorization.application.id} className={styles.appCard}>
<button
type="button"
className={styles.headerButton}
onClick={() => toggleExpanded(authorization.application.id)}
aria-expanded={isExpanded}
>
<div className={styles.left}>
<div className={styles.appAvatar} aria-hidden>
{iconUrl ? (
<img src={iconUrl} alt={authorization.application.name} className={styles.appAvatarImage} />
) : (
<AppWindowIcon className={styles.appAvatarPlaceholder} />
)}
</div>
<div className={styles.textBlock}>
<div className={styles.titleRow}>
<span className={styles.appName}>{authorization.application.name}</span>
</div>
<div className={styles.metaRow}>
<span className={styles.metaText}>
<Trans>Authorized on {authorizedOn}</Trans>
</span>
</div>
</div>
</div>
<CaretDownIcon
weight="bold"
className={clsx(styles.chevron, isExpanded && styles.chevronExpanded)}
/>
</button>
{isExpanded && (
<div className={styles.details}>
<div className={styles.detailsRow}>
<div className={styles.scopeColumn}>
<div className={styles.sectionLabel}>
<Trans>Permissions granted</Trans>
</div>
<div className={styles.scopeList}>
{authorization.scopes.map((scope) => (
<div key={scope} className={styles.scopeTag}>
<span className={styles.scopeName}>{scope}</span>
<span className={styles.scopeDescription}>
{getOAuth2ScopeDescription(i18n, scope as OAuth2Scope) || scope}
</span>
</div>
))}
</div>
</div>
<div className={styles.actions}>
<Button variant="danger-primary" small onClick={() => handleDeauthorize(authorization)}>
<Trans>Deauthorize</Trans>
</Button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</Scroller>
</div>
</div>
);
});
export default AuthorizedAppsTab;

View File

@@ -0,0 +1,325 @@
/*
* 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/>.
*/
.spinnerContainer {
display: flex;
justify-content: center;
padding: 4rem 0;
}
.actionRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.allowanceText {
font-size: 0.875rem;
color: var(--text-primary-muted);
}
.subsection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.subsectionTitle {
font-weight: 600;
font-size: 0.875rem;
}
.listContainer {
display: flex;
flex-direction: column;
}
.listItems {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.emptyState {
padding: 1.5rem 0;
text-align: center;
color: var(--text-primary-muted);
}
.listHeader {
display: none;
grid-template-columns: 200px minmax(150px, 1fr) 130px;
gap: 0.75rem;
padding: 0 0.75rem 0.5rem;
}
@media (min-width: 768px) {
.listHeader {
display: grid;
}
}
.listHeaderColumn {
font-weight: 600;
color: var(--text-primary-muted);
font-size: 0.75rem;
text-transform: uppercase;
}
.listItemMobile {
position: relative;
display: flex;
width: 100%;
cursor: pointer;
flex-direction: column;
gap: 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary);
padding: 0.75rem;
text-align: left;
}
.listItemMobile:active {
opacity: 0.8;
}
.codeRow {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
overflow: hidden;
}
.labelMobile {
font-weight: 600;
color: var(--text-primary-muted);
font-size: 0.75rem;
text-transform: uppercase;
}
@media (min-width: 768px) {
.labelMobile {
display: none;
}
}
.code {
user-select: text;
-webkit-user-select: text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: monospace;
font-size: 0.875rem;
}
.redeemerRow {
display: flex;
align-items: center;
gap: 0.5rem;
overflow: hidden;
}
.redeemerInfo {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.redeemerName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.875rem;
}
.redeemerDate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-primary-muted);
font-size: 0.75rem;
}
.unclaimedIcon {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: var(--background-tertiary);
width: 32px;
height: 32px;
}
.unclaimedIconSvg {
height: 1.25rem;
width: 1.25rem;
color: var(--text-primary-muted);
}
.unclaimedText {
font-size: 0.875rem;
color: var(--text-primary-muted);
}
.createdRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.createdLabel {
flex-shrink: 0;
font-weight: 600;
color: var(--text-primary-muted);
font-size: 0.75rem;
text-transform: uppercase;
}
@media (min-width: 768px) {
.createdLabel {
display: none;
}
}
.createdDate {
font-size: 0.875rem;
}
.revokeButton {
position: absolute;
top: -0.5rem;
right: -0.5rem;
border-radius: 9999px;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-primary);
padding: 0.5rem;
color: var(--text-primary-muted);
opacity: 1;
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
transition-property: border-color, background-color, color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
cursor: pointer;
}
.revokeButton:hover {
border-color: var(--status-danger);
background-color: var(--status-danger);
color: white;
}
.revokeButtonIcon {
height: 0.75rem;
width: 0.75rem;
}
.listItemDesktop {
position: relative;
display: grid;
grid-template-columns: 200px minmax(150px, 1fr) 130px;
align-items: center;
gap: 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary);
padding: 0.75rem;
}
.copyButton {
flex-shrink: 0;
border-radius: 0.25rem;
padding: 0.25rem;
color: var(--text-primary-muted);
transition-property: background-color, color, opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
cursor: pointer;
}
.copyButton:hover {
background-color: var(--background-header-secondary);
color: var(--text-primary);
}
.copyButtonHidden {
opacity: 0;
}
.copyButtonVisible {
opacity: 1;
}
.copyButtonIcon {
height: 1rem;
width: 1rem;
}
.revokeButtonDesktop {
position: absolute;
top: -0.5rem;
right: -0.5rem;
border-radius: 9999px;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-primary);
padding: 0.5rem;
color: var(--text-primary-muted);
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.1),
0 2px 4px -2px rgb(0 0 0 / 0.1);
transition-property: border-color, background-color, color, opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
cursor: pointer;
}
.revokeButtonDesktop:hover {
border-color: var(--status-danger);
background-color: var(--status-danger);
color: white;
}
.revokeButtonDesktopHidden {
opacity: 0;
}
@media (min-width: 768px) {
.revokeButtonDesktopHidden {
opacity: 0;
}
.listItemDesktop:hover .revokeButtonDesktopHidden {
opacity: 1;
}
}
.revokeButtonDesktopVisible {
opacity: 1;
}
.avatarNoShrink {
flex-shrink: 0;
}

View File

@@ -0,0 +1,382 @@
/*
* 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 {ClipboardIcon, QuestionMarkIcon, TicketIcon, XIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as BetaCodeActionCreators from '~/actions/BetaCodeActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import {
SettingsTabContainer,
SettingsTabContent,
SettingsTabHeader,
} from '~/components/modals/shared/SettingsTabLayout';
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import {Avatar} from '~/components/uikit/Avatar';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Spinner} from '~/components/uikit/Spinner';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {BetaCodeRecord} from '~/records/BetaCodeRecord';
import BetaCodeStore from '~/stores/BetaCodeStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import * as DateUtils from '~/utils/DateUtils';
import styles from './BetaCodesTab.module.css';
const MAX_UNCLAIMED_BETA_CODES = 6;
const BetaCodeListHeader: React.FC = observer(() => {
return (
<div className={styles.listHeader}>
<div className={styles.listHeaderColumn}>
<Trans>Code</Trans>
</div>
<div className={styles.listHeaderColumn}>
<Trans>Redeemer</Trans>
</div>
<div className={styles.listHeaderColumn}>
<Trans>Created</Trans>
</div>
</div>
);
});
const BetaCodeListItem: React.FC<{
betaCode: BetaCodeRecord;
onRevoke: (code: string) => void;
}> = observer(({betaCode, onRevoke}) => {
const {i18n} = useLingui();
const {enabled: isMobile} = MobileLayoutStore;
const [isHovered, setIsHovered] = React.useState(false);
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation();
TextCopyActionCreators.copy(i18n, betaCode.code);
};
const handleRowClick = () => {
if (isMobile) {
TextCopyActionCreators.copy(i18n, betaCode.code);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (isMobile && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleRowClick();
}
};
const createdDate = DateUtils.getFormattedShortDate(betaCode.createdAt);
const createdTooltip = DateUtils.getFormattedDateTimeWithSeconds(betaCode.createdAt);
return isMobile ? (
<div
role="button"
tabIndex={0}
onClick={handleRowClick}
onKeyDown={handleKeyDown}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={styles.listItemMobile}
>
<div className={styles.codeRow}>
<span className={styles.labelMobile}>
<Trans>Code:</Trans>
</span>
<code className={styles.code}>{betaCode.code}</code>
</div>
<div className={styles.redeemerRow}>
<span className={styles.labelMobile}>
<Trans>Redeemer:</Trans>
</span>
{betaCode.redeemer ? (
<>
<Avatar user={betaCode.redeemer} size={32} className={styles.avatarNoShrink} />
<div className={styles.redeemerInfo}>
<span className={styles.redeemerName}>{betaCode.redeemer.tag}</span>
<span className={styles.redeemerDate}>{DateUtils.getFormattedShortDate(betaCode.redeemedAt!)}</span>
</div>
</>
) : (
<>
<div className={styles.unclaimedIcon} style={{width: 32, height: 32}}>
<QuestionMarkIcon className={styles.unclaimedIconSvg} weight="bold" />
</div>
<span className={styles.unclaimedText}>
<Trans>Unclaimed</Trans>
</span>
</>
)}
</div>
<div className={styles.createdRow}>
<span className={styles.createdLabel}>
<Trans>Created:</Trans>
</span>
<Tooltip text={createdTooltip}>
<span className={styles.createdDate}>{createdDate}</span>
</Tooltip>
</div>
{!betaCode.redeemer && (
<Tooltip text={i18n._(msg`Revoke`)}>
<FocusRing offset={-2}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRevoke(betaCode.code);
}}
className={styles.revokeButton}
>
<XIcon className={styles.revokeButtonIcon} weight="bold" />
</button>
</FocusRing>
</Tooltip>
)}
</div>
) : (
// biome-ignore lint/a11y/noStaticElementInteractions: Hover state is for visual feedback only, not for interaction
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
className={styles.listItemDesktop}
>
<div className={styles.codeRow}>
<span className={styles.labelMobile}>
<Trans>Code:</Trans>
</span>
<code className={styles.code}>{betaCode.code}</code>
<Tooltip text={i18n._(msg`Click to copy`)}>
<FocusRing offset={-2}>
<button
type="button"
onClick={handleCopy}
className={clsx(styles.copyButton, isHovered ? styles.copyButtonVisible : styles.copyButtonHidden)}
aria-label={i18n._(msg`Copy beta code`)}
>
<ClipboardIcon className={styles.copyButtonIcon} />
</button>
</FocusRing>
</Tooltip>
</div>
<div className={styles.redeemerRow}>
<span className={styles.labelMobile}>
<Trans>Redeemer:</Trans>
</span>
{betaCode.redeemer ? (
<>
<Avatar user={betaCode.redeemer} size={32} className={styles.avatarNoShrink} />
<div className={styles.redeemerInfo}>
<span className={styles.redeemerName}>{betaCode.redeemer.tag}</span>
<span className={styles.redeemerDate}>{DateUtils.getFormattedShortDate(betaCode.redeemedAt!)}</span>
</div>
</>
) : (
<>
<div className={styles.unclaimedIcon} style={{width: 32, height: 32}}>
<QuestionMarkIcon className={styles.unclaimedIconSvg} weight="bold" />
</div>
<span className={styles.unclaimedText}>
<Trans>Unclaimed</Trans>
</span>
</>
)}
</div>
<div className={styles.createdRow}>
<span className={styles.createdLabel}>
<Trans>Created:</Trans>
</span>
<Tooltip text={createdTooltip}>
<span className={styles.createdDate}>{createdDate}</span>
</Tooltip>
</div>
{!betaCode.redeemer && (
<Tooltip text={i18n._(msg`Revoke`)}>
<FocusRing offset={-2}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRevoke(betaCode.code);
}}
className={clsx(
styles.revokeButtonDesktop,
isHovered ? styles.revokeButtonDesktopVisible : styles.revokeButtonDesktopHidden,
)}
>
<XIcon className={styles.revokeButtonIcon} weight="bold" />
</button>
</FocusRing>
</Tooltip>
)}
</div>
);
});
BetaCodeListItem.displayName = 'BetaCodeListItem';
const BetaCodesTab: React.FC = observer(() => {
const {i18n} = useLingui();
const betaCodes = BetaCodeStore.betaCodes;
const fetchStatus = BetaCodeStore.fetchStatus;
const allowance = BetaCodeStore.allowance;
const nextResetAt = BetaCodeStore.nextResetAt;
React.useEffect(() => {
BetaCodeActionCreators.fetch();
}, []);
const sortedBetaCodes = React.useMemo(() => {
return [...betaCodes].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}, [betaCodes]);
const unclaimedBetaCodes = sortedBetaCodes.filter((code) => !code.redeemer);
const redeemedBetaCodes = sortedBetaCodes.filter((code) => code.redeemer);
const canGenerateMore = unclaimedBetaCodes.length < MAX_UNCLAIMED_BETA_CODES && allowance > 0;
const handleRevoke = React.useCallback((code: string) => {
BetaCodeActionCreators.remove(code);
}, []);
const handleCreate = React.useCallback(() => {
BetaCodeActionCreators.create();
}, []);
const allowanceText = React.useMemo(() => {
if (allowance === 0 && nextResetAt !== null) {
const resetString = DateUtils.getFormattedShortDate(nextResetAt);
return i18n._(msg`No codes remaining. Resets ${resetString}`);
}
if (allowance === 1) {
return i18n._(msg`1 code remaining this week`);
}
return i18n._(msg`${allowance} codes remaining this week`);
}, [allowance, nextResetAt, i18n]);
if (fetchStatus === 'pending' || fetchStatus === 'idle') {
return (
<div className={styles.spinnerContainer}>
<Spinner />
</div>
);
}
if (fetchStatus === 'error') {
return (
<StatusSlate
Icon={TicketIcon}
title={i18n._(msg`Network error`)}
description={i18n._(
msg`We're having trouble connecting to the space-time continuum. Please check your connection and try again.`,
)}
actions={[
{
text: i18n._(msg`Retry`),
onClick: () => BetaCodeActionCreators.fetch(),
variant: 'primary',
},
]}
/>
);
}
return (
<SettingsTabContainer>
<SettingsTabHeader
title={<Trans>Beta Codes</Trans>}
description={
<Trans>
Generate up to {MAX_UNCLAIMED_BETA_CODES} unclaimed beta codes to invite friends to Fluxer. You can create 3
codes per week.
</Trans>
}
/>
<SettingsTabContent>
<div className={styles.actionRow}>
<div className={styles.allowanceText}>{allowanceText}</div>
<Button small={true} disabled={!canGenerateMore} onClick={handleCreate}>
<Trans>Create Code</Trans>
</Button>
</div>
{unclaimedBetaCodes.length > 0 && (
<div className={styles.subsection}>
<div className={styles.subsectionTitle}>
<Trans>Unclaimed ({unclaimedBetaCodes.length})</Trans>
</div>
<div className={styles.listContainer}>
<BetaCodeListHeader />
<div className={styles.listItems}>
{unclaimedBetaCodes.map((betaCode) => (
<BetaCodeListItem key={betaCode.code} betaCode={betaCode} onRevoke={handleRevoke} />
))}
</div>
</div>
</div>
)}
{redeemedBetaCodes.length > 0 && (
<div className={styles.subsection}>
<div className={styles.subsectionTitle}>
<Trans>Redeemed ({redeemedBetaCodes.length})</Trans>
</div>
<div className={styles.listContainer}>
<BetaCodeListHeader />
<div className={styles.listItems}>
{redeemedBetaCodes.map((betaCode) => (
<BetaCodeListItem key={betaCode.code} betaCode={betaCode} onRevoke={handleRevoke} />
))}
</div>
</div>
</div>
)}
{betaCodes.length === 0 && (
<StatusSlate
Icon={TicketIcon}
title={i18n._(msg`No beta codes yet`)}
description={i18n._(msg`Click "Create Code" to generate your first code.`)}
actions={[
{
text: i18n._(msg`Create Code`),
onClick: handleCreate,
variant: canGenerateMore ? 'primary' : 'secondary',
fitContent: true,
},
]}
/>
)}
</SettingsTabContent>
</SettingsTabContainer>
);
});
export default BetaCodesTab;

View File

@@ -0,0 +1,126 @@
/*
* 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;
height: 100%;
flex-direction: column;
}
.header {
padding: 1rem 2rem;
}
.title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.description {
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
.scrollContainer {
flex: 1;
overflow: hidden;
}
.scrollerPadding {
padding-left: 2rem;
padding-right: 2rem;
}
.userList {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-bottom: 1rem;
}
.userCard {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary);
}
.userInfo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatarButton {
padding: 0;
border: 0;
border-radius: 9999px;
background-color: transparent;
cursor: pointer;
}
.usernameButton {
padding: 0;
border: 0;
background-color: transparent;
text-align: left;
cursor: pointer;
}
.usernameContainer {
display: flex;
align-items: center;
}
.username {
display: inline;
align-items: baseline;
white-space: normal;
word-break: break-all;
font-weight: 600;
color: var(--text-primary);
line-height: 1.25;
}
.discriminator {
display: inline;
align-items: baseline;
white-space: normal;
word-break: break-all;
font-weight: 600;
color: var(--text-primary);
line-height: 1.25;
opacity: 0.5;
}
.actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.moreIcon {
width: 1.25rem;
height: 1.25rem;
}

View File

@@ -0,0 +1,192 @@
/*
* 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 {CopyIcon, DotsThreeVerticalIcon, IdentificationCardIcon, ProhibitIcon, UserIcon} 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 RelationshipActionCreators from '~/actions/RelationshipActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import * as UserProfileActionCreators from '~/actions/UserProfileActionCreators';
import {RelationshipTypes} from '~/Constants';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import styles from '~/components/modals/tabs/BlockedUsersTab.module.css';
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 {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
import RelationshipStore from '~/stores/RelationshipStore';
import UserStore from '~/stores/UserStore';
const BlockedUsersTab: React.FC = observer(() => {
const {t, i18n} = useLingui();
const relationships = RelationshipStore.getRelationships();
const blockedUsers = React.useMemo(() => {
return relationships.filter((rel) => rel.type === RelationshipTypes.BLOCKED);
}, [relationships]);
const handleUnblockUser = (userId: string) => {
const user = UserStore.getUser(userId);
if (!user) return;
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Unblock User`}
description={t`Are you sure you want to unblock ${user.username}?`}
primaryText={t`Unblock`}
primaryVariant="primary"
onPrimary={async () => {
RelationshipActionCreators.removeRelationship(userId);
}}
/>
)),
);
};
const handleViewProfile = React.useCallback((userId: string) => {
UserProfileActionCreators.openUserProfile(userId);
}, []);
const handleMoreOptionsClick = React.useCallback(
(userId: string, event: React.MouseEvent<HTMLButtonElement>) => {
const user = UserStore.getUser(userId);
if (!user) return;
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<>
<MenuGroup>
<MenuItem
icon={<UserIcon size={16} />}
onClick={() => {
onClose();
handleViewProfile(userId);
}}
>
{t`View Profile`}
</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItem
icon={<CopyIcon size={16} />}
onClick={() => {
onClose();
TextCopyActionCreators.copy(i18n, user.tag, true);
}}
>
{t`Copy FluxerTag`}
</MenuItem>
<MenuItem
icon={<IdentificationCardIcon size={16} />}
onClick={() => {
onClose();
TextCopyActionCreators.copy(i18n, user.id, true);
}}
>
{t`Copy User ID`}
</MenuItem>
</MenuGroup>
</>
));
},
[handleViewProfile],
);
if (blockedUsers.length === 0) {
return (
<StatusSlate
Icon={ProhibitIcon}
title={<Trans>No Blocked Users</Trans>}
description={<Trans>You haven't blocked anyone yet.</Trans>}
fullHeight={true}
/>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.title}>
<Trans>Blocked Users</Trans>
</h2>
<p className={styles.description}>
<Trans>Blocked users can't send you friend requests or message you directly.</Trans>
</p>
</div>
<div className={styles.scrollContainer}>
<Scroller className={styles.scrollerPadding} key="blocked-users-scroller">
<div className={styles.userList}>
{blockedUsers.map((relationship) => {
const user = UserStore.getUser(relationship.id);
if (!user) return null;
const moreOptionsButtonRef = React.createRef<HTMLButtonElement>();
return (
<div key={user.id} className={styles.userCard}>
<div className={styles.userInfo}>
<button
type="button"
className={styles.avatarButton}
onClick={() => handleViewProfile(user.id)}
aria-label={`View ${user.username}'s profile`}
>
<StatusAwareAvatar user={user} size={40} disablePresence={true} />
</button>
<button
type="button"
className={styles.usernameButton}
onClick={() => handleViewProfile(user.id)}
aria-label={`View ${user.username}'s profile`}
>
<div className={styles.usernameContainer}>
<span className={styles.username}>{user.username}</span>
<span className={styles.discriminator}>#{user.discriminator}</span>
</div>
</button>
</div>
<div className={styles.actions}>
<Button variant="secondary" small={true} onClick={() => handleUnblockUser(user.id)}>
<Trans>Unblock</Trans>
</Button>
<Button
ref={moreOptionsButtonRef}
variant="secondary"
small={true}
square={true}
icon={<DotsThreeVerticalIcon weight="bold" className={styles.moreIcon} />}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => handleMoreOptionsClick(user.id, event)}
/>
</div>
</div>
);
})}
</div>
</Scroller>
</div>
</div>
);
});
export default BlockedUsersTab;

View File

@@ -0,0 +1,24 @@
/*
* 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-6);
}

View File

@@ -0,0 +1,65 @@
/*
* 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 type React from 'react';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
import {DisplayTabContent} from './ChatSettingsTab/DisplayTab';
import {InputTabContent} from './ChatSettingsTab/InputTab';
import {InteractionTabContent} from './ChatSettingsTab/InteractionTab';
import {MediaTabContent} from './ChatSettingsTab/MediaTab';
const ChatSettingsTab: React.FC = observer(() => {
const {t} = useLingui();
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsSection
id="display"
title={t`Display`}
description={t`Control how messages, media, and other content are displayed.`}
>
<DisplayTabContent />
</SettingsSection>
<SettingsSection id="media" title={t`Media`} description={t`Customize media size preferences and buttons.`}>
<MediaTabContent />
</SettingsSection>
<SettingsSection id="input" title={t`Input`} description={t`Customize message input settings.`}>
<InputTabContent />
</SettingsSection>
<SettingsSection
id="interaction"
title={t`Interaction`}
description={t`Configure message interaction settings.`}
isAdvanced
defaultExpanded={false}
>
<InteractionTabContent />
</SettingsSection>
</SettingsTabContent>
</SettingsTabContainer>
);
});
export default ChatSettingsTab;

View File

@@ -0,0 +1,42 @@
/*
* 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/>.
*/
.sectionContent {
margin-top: 0.5rem;
}
.radioSection {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.radioLabel {
display: block;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.radioLabelContainer {
display: flex;
flex-direction: column;
gap: 0.25rem;
}

View File

@@ -0,0 +1,124 @@
/*
* 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 {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import {RenderSpoilers} from '~/Constants';
import {Switch} from '~/components/form/Switch';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
import {SwitchGroup, SwitchGroupItem} from '~/components/uikit/SwitchGroup';
import UserSettingsStore from '~/stores/UserSettingsStore';
import styles from './DisplayTab.module.css';
const spoilerOptions = (t: (m: MessageDescriptor) => string): ReadonlyArray<RadioOption<number>> => [
{
value: RenderSpoilers.ON_CLICK,
name: t(msg`On click`),
desc: t(msg`Show spoiler content when clicked`),
},
{
value: RenderSpoilers.IF_MODERATOR,
name: t(msg`In channels I moderate`),
desc: t(msg`Always show spoiler content in channels where you have the "Manage Messages" permission`),
},
{
value: RenderSpoilers.ALWAYS,
name: t(msg`Always`),
desc: t(msg`Always show spoiler content`),
},
];
export const DisplayTabContent: React.FC = observer(() => {
const {t} = useLingui();
const userSettings = UserSettingsStore;
return (
<>
<SettingsTabSection
title={t(msg`Media Display`)}
description={t(
msg`Control how images, videos and other media are shown. All media is resized and converted. Extremely large files that cannot be compressed into a preview will not embed regardless of these settings.`,
)}
>
<div className={styles.sectionContent}>
<SwitchGroup>
<SwitchGroupItem
label={t(msg`When posted as links to chat`)}
value={userSettings.inlineEmbedMedia}
onChange={(value) => UserSettingsActionCreators.update({inlineEmbedMedia: value})}
/>
<SwitchGroupItem
label={t(msg`When uploaded directly to Fluxer`)}
value={userSettings.inlineAttachmentMedia}
onChange={(value) => UserSettingsActionCreators.update({inlineAttachmentMedia: value})}
/>
</SwitchGroup>
</div>
</SettingsTabSection>
<SettingsTabSection
title={t(msg`Link Previews`)}
description={t(msg`Control how website links are previewed in chat`)}
>
<div className={styles.sectionContent}>
<Switch
label={t(msg`Show embeds and preview website links`)}
value={userSettings.renderEmbeds}
onChange={(value) => UserSettingsActionCreators.update({renderEmbeds: value})}
/>
</div>
</SettingsTabSection>
<SettingsTabSection title={t(msg`Reactions`)} description={t(msg`Configure emoji reactions on messages`)}>
<div className={styles.sectionContent}>
<Switch
label={t(msg`Show emoji reactions on messages`)}
value={userSettings.renderReactions}
onChange={(value) => UserSettingsActionCreators.update({renderReactions: value})}
/>
</div>
</SettingsTabSection>
<SettingsTabSection
title={t(msg`Spoiler Content`)}
description={t(msg`Control how spoiler content is displayed`)}
>
<div className={styles.radioSection}>
<div className={styles.radioLabelContainer}>
<div className={styles.radioLabel}>
<Trans>Show spoiler content</Trans>
</div>
</div>
<RadioGroup
options={spoilerOptions(t)}
value={userSettings.renderSpoilers}
onChange={(value) => UserSettingsActionCreators.update({renderSpoilers: value})}
aria-label={t(msg`Show spoiler content`)}
/>
</div>
</SettingsTabSection>
</>
);
});

View File

@@ -0,0 +1,23 @@
/*
* 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;
}

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 {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import {DisplayTabContent} from './DisplayTab';
import styles from './Inline.module.css';
import {InputTabContent} from './InputTab';
import {InteractionTabContent} from './InteractionTab';
import {MediaTabContent} from './MediaTab';
export const ChatSettingsInlineTab: React.FC = observer(() => {
const {t} = useLingui();
return (
<div className={styles.container}>
<SettingsSection id="chat-display" title={t`Display`}>
<DisplayTabContent />
</SettingsSection>
<SettingsSection id="chat-media" title={t`Media`}>
<MediaTabContent />
</SettingsSection>
<SettingsSection id="chat-input" title={t`Input`}>
<InputTabContent />
</SettingsSection>
<SettingsSection id="chat-interaction" title={t`Interaction`}>
<InteractionTabContent />
</SettingsSection>
</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/>.
*/
.sectionContent {
margin-top: 0.5rem;
}

View File

@@ -0,0 +1,164 @@
/*
* 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 * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import {KeyboardKey} from '~/components/uikit/KeyboardKey';
import {SwitchGroup, SwitchGroupItem} from '~/components/uikit/SwitchGroup';
import AccessibilityStore from '~/stores/AccessibilityStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import styles from './InputTab.module.css';
export const InputTabContent: React.FC = observer(() => {
const {t} = useLingui();
const mobileLayout = MobileLayoutStore;
const isMac = React.useMemo(() => {
return /Mac|iPhone|iPad|iPod/.test(navigator.platform) || /Macintosh/.test(navigator.userAgent);
}, []);
const modifierKey = isMac ? '⌘' : 'Ctrl';
const {
showGiftButton,
showGifButton,
showMemesButton,
showStickersButton,
showEmojiButton,
showUploadButton,
showMessageSendButton,
showDefaultEmojisInExpressionAutocomplete,
showCustomEmojisInExpressionAutocomplete,
showStickersInExpressionAutocomplete,
showMemesInExpressionAutocomplete,
} = AccessibilityStore;
return (
<>
<SettingsTabSection
title="Expression Autocomplete (Colon Autocomplete)"
description="Control what appears in the expression autocomplete when you type colon. Customize what suggestions show up to match your preferences."
>
<div className={styles.sectionContent}>
<SwitchGroup>
<SwitchGroupItem
label={t`Show default emojis in expression autocomplete`}
value={showDefaultEmojisInExpressionAutocomplete}
onChange={(value) =>
AccessibilityActionCreators.update({showDefaultEmojisInExpressionAutocomplete: value})
}
/>
<SwitchGroupItem
label={t`Show custom emojis in expression autocomplete`}
value={showCustomEmojisInExpressionAutocomplete}
onChange={(value) =>
AccessibilityActionCreators.update({showCustomEmojisInExpressionAutocomplete: value})
}
/>
<SwitchGroupItem
label={t`Show stickers in expression autocomplete`}
value={showStickersInExpressionAutocomplete}
onChange={(value) => AccessibilityActionCreators.update({showStickersInExpressionAutocomplete: value})}
/>
<SwitchGroupItem
label={t`Show saved media in expression autocomplete`}
value={showMemesInExpressionAutocomplete}
onChange={(value) => AccessibilityActionCreators.update({showMemesInExpressionAutocomplete: value})}
/>
</SwitchGroup>
</div>
</SettingsTabSection>
{!mobileLayout.enabled && (
<SettingsTabSection
title="Message Input Buttons"
description="Customize which buttons are visible in the message input area. Keyboard shortcuts will continue to work even if buttons are hidden."
>
<div className={styles.sectionContent}>
<SwitchGroup>
<SwitchGroupItem
label={t`Show Upload Button`}
value={showUploadButton}
onChange={(value) => AccessibilityActionCreators.update({showUploadButton: value})}
/>
<SwitchGroupItem
label={t`Show Gift Button`}
value={showGiftButton}
onChange={(value) => AccessibilityActionCreators.update({showGiftButton: value})}
/>
<SwitchGroupItem
label={t`Show GIFs Button`}
value={showGifButton}
onChange={(value) => AccessibilityActionCreators.update({showGifButton: value})}
shortcut={
<>
<KeyboardKey>{modifierKey}</KeyboardKey>
<KeyboardKey>G</KeyboardKey>
</>
}
/>
<SwitchGroupItem
label={t`Show Media Button`}
value={showMemesButton}
onChange={(value) => AccessibilityActionCreators.update({showMemesButton: value})}
shortcut={
<>
<KeyboardKey>{modifierKey}</KeyboardKey>
<KeyboardKey>M</KeyboardKey>
</>
}
/>
<SwitchGroupItem
label={t`Show Stickers Button`}
value={showStickersButton}
onChange={(value) => AccessibilityActionCreators.update({showStickersButton: value})}
shortcut={
<>
<KeyboardKey>{modifierKey}</KeyboardKey>
<KeyboardKey>S</KeyboardKey>
</>
}
/>
<SwitchGroupItem
label={t`Show Emoji Button`}
value={showEmojiButton}
onChange={(value) => AccessibilityActionCreators.update({showEmojiButton: value})}
shortcut={
<>
<KeyboardKey>{modifierKey}</KeyboardKey>
<KeyboardKey>E</KeyboardKey>
</>
}
/>
<SwitchGroupItem
label={t`Show Send Button`}
value={showMessageSendButton}
onChange={(value) => AccessibilityActionCreators.update({showMessageSendButton: value})}
shortcut={<KeyboardKey></KeyboardKey>}
/>
</SwitchGroup>
</div>
</SettingsTabSection>
)}
</>
);
});

View File

@@ -0,0 +1,55 @@
/*
* 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/>.
*/
.sectionContent {
margin-top: 0.5rem;
}
.previewContainer {
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.previewBox {
border-radius: 0.5rem;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-secondary-lighter);
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: 2rem;
padding-bottom: 2rem;
}
.shiftHint {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.shiftHintDisabled {
opacity: 0.4;
}
.shiftHintText {
font-size: 0.875rem;
color: var(--text-primary-muted);
}

View File

@@ -0,0 +1,201 @@
/*
* 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 * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import {MessageStates, MessageTypes} from '~/Constants';
import {Message} from '~/components/channel/Message';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import {KeyboardKey} from '~/components/uikit/KeyboardKey';
import {SwitchGroup, SwitchGroupItem} from '~/components/uikit/SwitchGroup';
import {ChannelRecord} from '~/records/ChannelRecord';
import {MessageRecord} from '~/records/MessageRecord';
import AccessibilityStore from '~/stores/AccessibilityStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserStore from '~/stores/UserStore';
import {SHIFT_KEY_SYMBOL} from '~/utils/KeyboardUtils';
import styles from './InteractionTab.module.css';
const MessageActionBarPreview = observer(
({
showActionBar,
showShiftExpand,
onlyMoreButton,
}: {
showActionBar: boolean;
showQuickReactions: boolean;
showShiftExpand: boolean;
onlyMoreButton: boolean;
}) => {
const {t} = useLingui();
const fakeData = React.useMemo(() => {
const currentUser = UserStore.getCurrentUser();
const author = currentUser?.toJSON() || {
id: 'preview-user',
username: 'PreviewUser',
discriminator: '0000',
global_name: 'Preview User',
avatar: null,
bot: false,
system: false,
flags: 0,
};
const fakeChannel = new ChannelRecord({
id: 'action-bar-fake-channel',
type: 0,
name: 'action-bar-fake-channel',
position: 0,
parent_id: null,
topic: null,
url: null,
nsfw: false,
last_message_id: null,
last_pin_timestamp: null,
bitrate: null,
user_limit: null,
permission_overwrites: [],
});
const fakeMessage = new MessageRecord(
{
id: 'action-bar-preview-message',
channel_id: 'action-bar-fake-channel',
author,
type: MessageTypes.DEFAULT,
flags: 0,
pinned: false,
mention_everyone: false,
content: showActionBar ? t`This message shows the action bar` : t`This message doesn't show the action bar`,
timestamp: new Date().toISOString(),
state: MessageStates.SENT,
},
{skipUserCache: true},
);
return {fakeChannel, fakeMessage};
}, [showActionBar]);
return (
<div className={styles.previewContainer}>
<div
className={styles.previewBox}
data-force-show-action-bar={showActionBar ? 'true' : 'false'}
style={{
pointerEvents: 'none',
}}
>
{showActionBar && (
<style>
{`
[data-force-show-action-bar="true"] [class*="buttons"] {
opacity: 1 !important;
pointer-events: none !important;
}
[data-force-show-action-bar="true"] [class*="hoverAction"] {
opacity: 1 !important;
pointer-events: none !important;
}
`}
</style>
)}
<Message
channel={fakeData.fakeChannel}
message={fakeData.fakeMessage}
removeTopSpacing={true}
previewMode={true}
/>
</div>
<div
className={`${styles.shiftHint} ${!showActionBar || onlyMoreButton || !showShiftExpand ? styles.shiftHintDisabled : ''}`}
>
<span className={styles.shiftHintText}>{t`Hold`}</span>
<KeyboardKey>{SHIFT_KEY_SYMBOL}</KeyboardKey>
<span className={styles.shiftHintText}>{t`to expand action bar`}</span>
</div>
</div>
);
},
);
export const InteractionTabContent: React.FC = observer(() => {
const {t} = useLingui();
const mobileLayout = MobileLayoutStore;
const {
showMessageActionBar,
showMessageActionBarQuickReactions,
showMessageActionBarShiftExpand,
showMessageActionBarOnlyMoreButton,
} = AccessibilityStore;
if (mobileLayout.enabled) {
return (
<SettingsTabSection
title="Message Action Bar"
description="Message action bar settings are only available on desktop."
>
{null}
</SettingsTabSection>
);
}
return (
<SettingsTabSection
title="Message Action Bar"
description="Customize the action bar that appears when hovering over messages."
>
<div className={styles.sectionContent}>
<MessageActionBarPreview
showActionBar={showMessageActionBar}
showQuickReactions={showMessageActionBarQuickReactions}
showShiftExpand={showMessageActionBarShiftExpand}
onlyMoreButton={showMessageActionBarOnlyMoreButton}
/>
<SwitchGroup>
<SwitchGroupItem
label={t`Show message action bar`}
value={showMessageActionBar}
onChange={(value) => AccessibilityActionCreators.update({showMessageActionBar: value})}
/>
<SwitchGroupItem
label={t`Show only More button`}
value={showMessageActionBarOnlyMoreButton}
onChange={(value) => AccessibilityActionCreators.update({showMessageActionBarOnlyMoreButton: value})}
disabled={!showMessageActionBar}
/>
<SwitchGroupItem
label={t`Show quick reactions`}
value={showMessageActionBarQuickReactions}
onChange={(value) => AccessibilityActionCreators.update({showMessageActionBarQuickReactions: value})}
disabled={!showMessageActionBar || showMessageActionBarOnlyMoreButton}
/>
<SwitchGroupItem
label={t`Enable Shift to expand`}
value={showMessageActionBarShiftExpand}
onChange={(value) => AccessibilityActionCreators.update({showMessageActionBarShiftExpand: value})}
disabled={!showMessageActionBar || showMessageActionBarOnlyMoreButton}
/>
</SwitchGroup>
</div>
</SettingsTabSection>
);
});

View File

@@ -0,0 +1,154 @@
/*
* 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/>.
*/
.sectionContent {
margin-top: 0.5rem;
}
.radioSections {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.radioSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.radioLabelContainer {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.radioLabel {
display: block;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary);
}
.previewContainer {
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.previewWrapper {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: stretch;
gap: 0.35rem;
max-width: 28rem;
width: 20rem;
}
.previewBox {
position: relative;
display: flex;
aspect-ratio: 16 / 9;
width: 100%;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 0.25rem;
background-color: var(--background-tertiary);
}
.previewIcon {
height: 4rem;
width: 4rem;
color: var(--text-tertiary);
}
.gifIndicator {
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 10;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.6);
padding: 0.125rem 0.375rem;
font-weight: 600;
font-size: 0.8125rem;
line-height: 1.1;
color: white;
}
.actionButtons {
position: absolute;
top: 0.5rem;
right: 0.5rem;
z-index: 10;
display: flex;
gap: 0.25rem;
}
.actionButton {
display: flex;
height: 2rem;
width: 2rem;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-primary);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
cursor: pointer;
}
.actionButtonIcon {
color: var(--text-primary);
}
.suppressButton {
position: absolute;
top: 0.25rem;
right: -2rem;
display: flex;
height: 1.5rem;
width: 1.5rem;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
background: transparent;
border: none;
color: var(--text-tertiary);
transition:
background-color 0.15s,
color 0.15s;
cursor: pointer;
padding: 0;
}
.suppressButton:hover {
color: var(--status-danger);
}
.expiryFootnotePreview {
width: 100%;
}

View File

@@ -0,0 +1,220 @@
/*
* 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 {DownloadSimpleIcon, ImageSquareIcon, StarIcon, TrashIcon, XIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as AccessibilityActionCreators from '~/actions/AccessibilityActionCreators';
import {ExpiryFootnote} from '~/components/common/ExpiryFootnote';
import {Switch} from '~/components/form/Switch';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
import {SwitchGroup, SwitchGroupItem} from '~/components/uikit/SwitchGroup';
import AccessibilityStore, {MediaDimensionSize} from '~/stores/AccessibilityStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import styles from './MediaTab.module.css';
const MediaPreview = observer(
({
showFavorite,
showDownload,
showDelete,
showSuppress,
showGifIndicator,
showExpiryIndicator,
}: {
showFavorite: boolean;
showDownload: boolean;
showDelete: boolean;
showSuppress: boolean;
showGifIndicator: boolean;
showExpiryIndicator: boolean;
}) => {
const previewExpiry = React.useMemo(() => new Date(Date.now() + 12 * 24 * 60 * 60 * 1000), []);
return (
<div className={styles.previewContainer}>
<div className={styles.previewWrapper}>
<div className={styles.previewBox}>
<ImageSquareIcon className={styles.previewIcon} />
{showGifIndicator && <div className={styles.gifIndicator}>GIF</div>}
<div className={styles.actionButtons}>
{showDelete && (
<button type="button" className={styles.actionButton}>
<TrashIcon size={18} weight="bold" className={styles.actionButtonIcon} />
</button>
)}
{showDownload && (
<button type="button" className={styles.actionButton}>
<DownloadSimpleIcon size={18} weight="bold" className={styles.actionButtonIcon} />
</button>
)}
{showFavorite && (
<button type="button" className={styles.actionButton}>
<StarIcon size={18} weight="bold" className={styles.actionButtonIcon} />
</button>
)}
</div>
</div>
{showSuppress && (
<button type="button" className={styles.suppressButton}>
<XIcon size={16} weight="bold" />
</button>
)}
{showExpiryIndicator && (
<ExpiryFootnote expiresAt={previewExpiry} isExpired={false} className={styles.expiryFootnotePreview} />
)}
</div>
</div>
);
},
);
export const MediaTabContent: React.FC = observer(() => {
const {t} = useLingui();
const mobileLayout = MobileLayoutStore;
const {
showMediaFavoriteButton,
showMediaDownloadButton,
showMediaDeleteButton,
showSuppressEmbedsButton,
showGifIndicator,
showAttachmentExpiryIndicator,
autoSendTenorGifs,
embedMediaDimensionSize,
attachmentMediaDimensionSize,
} = AccessibilityStore;
const mediaSizeOptions = React.useMemo(
(): ReadonlyArray<RadioOption<MediaDimensionSize>> => [
{
value: MediaDimensionSize.SMALL,
name: t`Compact (400x300)`,
desc: t`Smaller media size`,
},
{
value: MediaDimensionSize.LARGE,
name: t`Comfortable (550x400)`,
desc: t`Larger media size with more detail`,
},
],
[t],
);
return (
<>
<SettingsTabSection
title="Media Size Preferences"
description="Customize the maximum display size for embedded and attached media. Smaller sizes use less screen space, while larger sizes show more detail."
>
<div className={styles.radioSections}>
<div className={styles.radioSection}>
<div className={styles.radioLabelContainer}>
<div className={styles.radioLabel}>
<Trans>Media from links (embeds)</Trans>
</div>
</div>
<RadioGroup
options={mediaSizeOptions}
value={embedMediaDimensionSize}
onChange={(value) => AccessibilityActionCreators.update({embedMediaDimensionSize: value})}
aria-label={t`Select media size for embedded content from links`}
/>
</div>
<div className={styles.radioSection}>
<div className={styles.radioLabelContainer}>
<div className={styles.radioLabel}>
<Trans>Uploaded attachments</Trans>
</div>
</div>
<RadioGroup
options={mediaSizeOptions}
value={attachmentMediaDimensionSize}
onChange={(value) => AccessibilityActionCreators.update({attachmentMediaDimensionSize: value})}
aria-label={t`Select media size for uploaded attachments`}
/>
</div>
</div>
</SettingsTabSection>
<SettingsTabSection title="GIF Behavior" description="Control how GIFs are inserted into chat">
<div className={styles.sectionContent}>
<Switch
label={t`Automatically send Tenor GIFs when selected`}
value={autoSendTenorGifs}
onChange={(value) => AccessibilityActionCreators.update({autoSendTenorGifs: value})}
/>
</div>
</SettingsTabSection>
{!mobileLayout.enabled && (
<SettingsTabSection
title="Media Buttons"
description="Customize which buttons appear on media attachments and embeds when hovering over messages."
>
<div className={styles.sectionContent}>
<MediaPreview
showFavorite={showMediaFavoriteButton}
showDownload={showMediaDownloadButton}
showDelete={showMediaDeleteButton}
showSuppress={showSuppressEmbedsButton}
showGifIndicator={showGifIndicator}
showExpiryIndicator={showAttachmentExpiryIndicator}
/>
<SwitchGroup>
<SwitchGroupItem
label={t`Show GIF Indicator`}
value={showGifIndicator}
onChange={(value) => AccessibilityActionCreators.update({showGifIndicator: value})}
/>
<SwitchGroupItem
label={t`Show Attachment Expiry Indicator`}
value={showAttachmentExpiryIndicator}
onChange={(value) => AccessibilityActionCreators.update({showAttachmentExpiryIndicator: value})}
/>
<SwitchGroupItem
label={t`Show Delete Button`}
value={showMediaDeleteButton}
onChange={(value) => AccessibilityActionCreators.update({showMediaDeleteButton: value})}
/>
<SwitchGroupItem
label={t`Show Download Button`}
value={showMediaDownloadButton}
onChange={(value) => AccessibilityActionCreators.update({showMediaDownloadButton: value})}
/>
<SwitchGroupItem
label={t`Show Favorite Button`}
value={showMediaFavoriteButton}
onChange={(value) => AccessibilityActionCreators.update({showMediaFavoriteButton: value})}
/>
<SwitchGroupItem
label={t`Show Suppress Embeds Button`}
value={showSuppressEmbedsButton}
onChange={(value) => AccessibilityActionCreators.update({showSuppressEmbedsButton: value})}
/>
</SwitchGroup>
</div>
</SettingsTabSection>
)}
</>
);
});

View File

@@ -0,0 +1,20 @@
/*
* 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/>.
*/
export {default} from './ComponentGalleryTab/index';

View File

@@ -0,0 +1,25 @@
/*
* 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/>.
*/
.buttonsWrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}

View File

@@ -0,0 +1,293 @@
/*
* 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 {
BookmarkIcon,
CheckIcon,
DotsThreeOutlineIcon,
GearIcon,
HeartIcon,
LinkSimpleIcon,
MegaphoneIcon,
PaperPlaneRightIcon,
PlayIcon,
PlusIcon,
ShareFatIcon,
TrashIcon,
} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {
SettingsTabContainer,
SettingsTabContent,
SettingsTabSection,
} from '~/components/modals/shared/SettingsTabLayout';
import {Button} from '~/components/uikit/Button/Button';
import styles from './ButtonsTab.module.css';
import {SubsectionTitle} from './shared';
interface ButtonsTabProps {
openContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
}
export const ButtonsTab: React.FC<ButtonsTabProps> = observer(({openContextMenu}) => {
const {t} = useLingui();
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsTabSection
title={<Trans>Button Variants</Trans>}
description={<Trans>Click any button to see toast notifications with feedback.</Trans>}
>
<div className={styles.buttonsWrapper}>
<Button
leftIcon={<PlusIcon size={16} weight="bold" />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Primary button clicked!`})}
>
<Trans>Primary</Trans>
</Button>
<Button
variant="secondary"
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Secondary button clicked!`})}
>
<Trans>Secondary</Trans>
</Button>
<Button
variant="danger-primary"
onClick={() => ToastActionCreators.createToast({type: 'error', children: t`Danger primary clicked!`})}
>
<Trans>Danger Primary</Trans>
</Button>
<Button
variant="danger-secondary"
onClick={() => ToastActionCreators.createToast({type: 'error', children: t`Danger secondary clicked!`})}
>
<Trans>Danger Secondary</Trans>
</Button>
<Button
variant="inverted"
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Inverted button clicked!`})}
>
<Trans>Inverted</Trans>
</Button>
</div>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Disabled States</Trans>}>
<div className={styles.buttonsWrapper}>
<Button disabled>
<Trans>Primary (Disabled)</Trans>
</Button>
<Button variant="secondary" disabled>
<Trans>Secondary (Disabled)</Trans>
</Button>
<Button variant="danger-primary" disabled>
<Trans>Danger (Disabled)</Trans>
</Button>
</div>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Button Sizes</Trans>}>
<div className={styles.buttonsWrapper}>
<Button
small
leftIcon={<MegaphoneIcon size={14} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Small button clicked!`})}
>
<Trans>Small Button</Trans>
</Button>
<Button
leftIcon={<MegaphoneIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Regular button clicked!`})}
>
<Trans>Regular Button</Trans>
</Button>
<Button
small
variant="secondary"
leftIcon={<GearIcon size={14} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Small secondary clicked!`})}
>
<Trans>Small Secondary</Trans>
</Button>
</div>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Buttons with Icons</Trans>}>
<SubsectionTitle>
<Trans>Left Icon</Trans>
</SubsectionTitle>
<div className={styles.buttonsWrapper}>
<Button
leftIcon={<PlusIcon size={16} weight="bold" />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Add action!`})}
>
<Trans>Add Item</Trans>
</Button>
<Button
variant="secondary"
leftIcon={<GearIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Settings opened!`})}
>
<Trans>Settings</Trans>
</Button>
<Button
variant="danger-primary"
leftIcon={<TrashIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'error', children: t`Delete action!`})}
>
<Trans>Delete</Trans>
</Button>
<Button
leftIcon={<ShareFatIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Share action!`})}
>
<Trans>Share</Trans>
</Button>
</div>
<SubsectionTitle>
<Trans>Right Icon</Trans>
</SubsectionTitle>
<div className={styles.buttonsWrapper}>
<Button
rightIcon={<PaperPlaneRightIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Message sent!`})}
>
<Trans>Send</Trans>
</Button>
<Button
variant="secondary"
rightIcon={<LinkSimpleIcon size={16} weight="bold" />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Link copied!`})}
>
<Trans>Copy Link</Trans>
</Button>
</div>
<SubsectionTitle>
<Trans>Both Sides</Trans>
</SubsectionTitle>
<div className={styles.buttonsWrapper}>
<Button
leftIcon={<PlusIcon size={16} weight="bold" />}
rightIcon={<ShareFatIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Action with both icons!`})}
>
<Trans>Create & Share</Trans>
</Button>
<Button
variant="secondary"
leftIcon={<HeartIcon size={16} />}
rightIcon={<CheckIcon size={16} weight="bold" />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Saved!`})}
>
<Trans>Save Favorite</Trans>
</Button>
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Square Icon Buttons</Trans>}
description={<Trans>Compact buttons with just an icon, perfect for toolbars and action bars.</Trans>}
>
<div className={styles.buttonsWrapper}>
<Button
square
aria-label={t`Play`}
icon={<PlayIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Play!`})}
/>
<Button
square
aria-label={t`Settings`}
icon={<GearIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Settings!`})}
/>
<Button
square
variant="secondary"
aria-label={t`Bookmark`}
icon={<BookmarkIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Bookmarked!`})}
/>
<Button
square
variant="secondary"
aria-label={t`Heart`}
icon={<HeartIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Liked!`})}
/>
<Button
square
variant="danger-primary"
aria-label={t`Delete`}
icon={<TrashIcon size={16} />}
onClick={() => ToastActionCreators.createToast({type: 'error', children: t`Deleted!`})}
/>
<Button square aria-label={t`More`} icon={<DotsThreeOutlineIcon size={16} />} onClick={openContextMenu} />
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Loading States</Trans>}
description={<Trans>Buttons show a loading indicator when submitting is true.</Trans>}
>
<div className={styles.buttonsWrapper}>
<Button submitting>
<Trans>Submitting</Trans>
</Button>
<Button variant="secondary" submitting>
<Trans>Loading</Trans>
</Button>
<Button small submitting leftIcon={<MegaphoneIcon size={14} />}>
<Trans>Small Submitting</Trans>
</Button>
<Button variant="danger-primary" submitting>
<Trans>Processing</Trans>
</Button>
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Button with Context Menu</Trans>}
description={
<Trans>
Buttons can trigger context menus on click by passing the onClick event directly to openContextMenu.
</Trans>
}
>
<div className={styles.buttonsWrapper}>
<Button leftIcon={<DotsThreeOutlineIcon size={16} />} onClick={openContextMenu}>
<Trans>Open Menu</Trans>
</Button>
<Button
square
icon={<DotsThreeOutlineIcon size={16} />}
aria-label={t`Open Menu (icon)`}
onClick={openContextMenu}
/>
</div>
</SettingsTabSection>
</SettingsTabContent>
</SettingsTabContainer>
);
});

View File

@@ -0,0 +1,83 @@
/*
* 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/>.
*/
.itemsWrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1.5rem;
}
.itemWithLabel {
display: flex;
align-items: center;
gap: 0.5rem;
}
.itemText {
font-size: 0.875rem;
color: var(--text-primary);
}
.itemTextSmall {
font-size: 0.75rem;
color: var(--text-primary);
}
.itemTextBase {
font-size: 1rem;
color: var(--text-primary);
}
.itemTextTertiary {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.avatarGroup {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.avatarShapes {
display: flex;
align-items: center;
gap: 0.5rem;
}
.stacksWrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
.stackItem {
display: flex;
align-items: center;
gap: 1rem;
}
.badgesWrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}

View File

@@ -0,0 +1,320 @@
/*
* 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 {ChannelTypes, getStatusTypeLabel, StatusTypes} from '~/Constants';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {
SettingsTabContainer,
SettingsTabContent,
SettingsTabSection,
} from '~/components/modals/shared/SettingsTabLayout';
import {AvatarStack} from '~/components/uikit/avatars/AvatarStack';
import {MentionBadge} from '~/components/uikit/MentionBadge';
import {MockAvatar} from '~/components/uikit/MockAvatar';
import {ChannelRecord} from '~/records/ChannelRecord';
import type {UserPartial} from '~/records/UserRecord';
import styles from './IndicatorsTab.module.css';
import {SubsectionTitle} from './shared';
const AVATAR_SIZES_WITH_STATUS: Array<16 | 24 | 32 | 36 | 40 | 48 | 80> = [16, 24, 32, 36, 40, 48, 80];
const AVATAR_STATUSES: Array<string> = [
StatusTypes.ONLINE,
StatusTypes.IDLE,
StatusTypes.DND,
StatusTypes.INVISIBLE,
StatusTypes.OFFLINE,
];
const createMockRecipient = (id: string): UserPartial => ({
id,
username: id,
discriminator: '0000',
avatar: null,
flags: 0,
});
const createMockGroupDMChannel = (id: string, recipientIds: Array<string>): ChannelRecord =>
new ChannelRecord({
id,
type: ChannelTypes.GROUP_DM,
recipients: recipientIds.map(createMockRecipient),
});
const getMockGroupDMChannels = (): Array<ChannelRecord> => [
createMockGroupDMChannel('1000000000000000001', ['1000000000000000002', '1000000000000000003']),
createMockGroupDMChannel('1000000000000000004', [
'1000000000000000005',
'1000000000000000006',
'1000000000000000007',
]),
];
export const IndicatorsTab: React.FC = observer(() => {
const {i18n} = useLingui();
const mockGroupDMChannels = getMockGroupDMChannels();
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsTabSection
title={<Trans>Status Indicators</Trans>}
description={
<Trans>
Visual indicators showing user status, rendered using the same masked status badges as avatars: online,
idle, do not disturb, invisible, and offline.
</Trans>
}
>
<SubsectionTitle>
<Trans>Single User (All Statuses)</Trans>
</SubsectionTitle>
<div className={styles.itemsWrapper}>
{AVATAR_STATUSES.map((status) => (
<div key={status} className={styles.avatarGroup}>
<MockAvatar size={40} status={status} />
<span className={styles.itemTextTertiary}>{getStatusTypeLabel(i18n, status) ?? status}</span>
</div>
))}
</div>
<SubsectionTitle>
<Trans>Mobile Online Status on Avatars</Trans>
</SubsectionTitle>
<div className={styles.itemsWrapper}>
{AVATAR_SIZES_WITH_STATUS.map((size) => (
<div key={`mobile-avatar-size-${size}`} className={styles.avatarGroup}>
<MockAvatar size={size} status={StatusTypes.ONLINE} isMobileStatus />
<span className={styles.itemTextTertiary}>{size}px</span>
</div>
))}
</div>
<SubsectionTitle>
<Trans>Different Sizes (Status Supported)</Trans>
</SubsectionTitle>
<div className={styles.itemsWrapper}>
{AVATAR_SIZES_WITH_STATUS.map((size) => (
<div key={size} className={styles.avatarGroup}>
<MockAvatar size={size} status={StatusTypes.ONLINE} />
<span className={styles.itemTextTertiary}>{size}px</span>
</div>
))}
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Mention Badges</Trans>}
description={<Trans>Notification badges showing unread mention counts in different sizes.</Trans>}
>
<SubsectionTitle>
<Trans>Medium Size (Default)</Trans>
</SubsectionTitle>
<div className={styles.badgesWrapper}>
<MentionBadge mentionCount={1} />
<MentionBadge mentionCount={5} />
<MentionBadge mentionCount={12} />
<MentionBadge mentionCount={99} />
<MentionBadge mentionCount={150} />
<MentionBadge mentionCount={1000} />
<MentionBadge mentionCount={9999} />
</div>
<SubsectionTitle>
<Trans>Small Size</Trans>
</SubsectionTitle>
<div className={styles.badgesWrapper}>
<MentionBadge mentionCount={1} size="small" />
<MentionBadge mentionCount={5} size="small" />
<MentionBadge mentionCount={12} size="small" />
<MentionBadge mentionCount={99} size="small" />
<MentionBadge mentionCount={150} size="small" />
<MentionBadge mentionCount={1000} size="small" />
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Mock Avatars</Trans>}
description={<Trans>Mock user avatars in various sizes and all status permutations.</Trans>}
>
<SubsectionTitle>
<Trans>Different Sizes (Online)</Trans>
</SubsectionTitle>
<div className={styles.itemsWrapper}>
{AVATAR_SIZES_WITH_STATUS.map((size) => (
<div key={size} className={styles.avatarGroup}>
<MockAvatar size={size} status={StatusTypes.ONLINE} />
<span className={styles.itemTextTertiary}>{size}px</span>
</div>
))}
</div>
<SubsectionTitle>
<Trans>All Status Types</Trans>
</SubsectionTitle>
<div className={styles.itemsWrapper}>
{AVATAR_STATUSES.map((status) => (
<div key={status} className={styles.avatarGroup}>
<MockAvatar size={48} status={status} />
<span className={styles.itemTextTertiary}>{getStatusTypeLabel(i18n, status) ?? status}</span>
</div>
))}
</div>
<SubsectionTitle>
<Trans>Typing State</Trans>
</SubsectionTitle>
<div className={styles.itemsWrapper}>
{AVATAR_STATUSES.map((status) => (
<div key={status} className={styles.avatarGroup}>
<MockAvatar size={48} status={status} isTyping />
<span className={styles.itemTextTertiary}>{getStatusTypeLabel(i18n, status) ?? status}</span>
</div>
))}
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Group DM Avatars</Trans>}
description={
<Trans>
Group DM avatars using the same status masks as regular avatars, including stacked layouts and typing
states.
</Trans>
}
>
<SubsectionTitle>
<Trans>Different Sizes & Member Counts</Trans>
</SubsectionTitle>
<div className={styles.itemsWrapper}>
<div className={styles.avatarGroup}>
<GroupDMAvatar channel={mockGroupDMChannels[0]} size={32} />
<span className={styles.itemTextTertiary}>32px · 2 members</span>
</div>
<div className={styles.avatarGroup}>
<GroupDMAvatar channel={mockGroupDMChannels[1]} size={40} />
<span className={styles.itemTextTertiary}>40px · 3 members</span>
</div>
</div>
<SubsectionTitle>
<Trans>Group Online Status</Trans>
</SubsectionTitle>
<div className={styles.itemsWrapper}>
<div className={styles.avatarGroup}>
<GroupDMAvatar channel={mockGroupDMChannels[0]} size={32} statusOverride={StatusTypes.ONLINE} />
<span className={styles.itemTextTertiary}>
<Trans>2 members (online)</Trans>
</span>
</div>
<div className={styles.avatarGroup}>
<GroupDMAvatar channel={mockGroupDMChannels[1]} size={40} statusOverride={StatusTypes.ONLINE} />
<span className={styles.itemTextTertiary}>
<Trans>3 members (online)</Trans>
</span>
</div>
</div>
<SubsectionTitle>
<Trans>Group Typing States</Trans>
</SubsectionTitle>
<div className={styles.itemsWrapper}>
<div className={styles.avatarGroup}>
<GroupDMAvatar channel={mockGroupDMChannels[0]} size={32} isTyping />
<span className={styles.itemTextTertiary}>
<Trans>2 members (typing)</Trans>
</span>
</div>
<div className={styles.avatarGroup}>
<GroupDMAvatar channel={mockGroupDMChannels[1]} size={40} isTyping />
<span className={styles.itemTextTertiary}>
<Trans>3 members (typing)</Trans>
</span>
</div>
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Avatar Stacks</Trans>}
description={<Trans>Overlapping avatar groups showing multiple users with automatic overflow counts.</Trans>}
>
<SubsectionTitle>
<Trans>Different Sizes</Trans>
</SubsectionTitle>
<div className={styles.stacksWrapper}>
<div className={styles.stackItem}>
<AvatarStack size={24}>
{[1, 2, 3, 4].map((i) => (
<MockAvatar key={i} size={24} userTag={`User ${i}`} />
))}
</AvatarStack>
<span className={styles.itemTextTertiary}>24px</span>
</div>
<div className={styles.stackItem}>
<AvatarStack size={32}>
{[1, 2, 3, 4].map((i) => (
<MockAvatar key={i} size={32} userTag={`User ${i}`} />
))}
</AvatarStack>
<span className={styles.itemTextTertiary}>32px</span>
</div>
<div className={styles.stackItem}>
<AvatarStack size={40}>
{[1, 2, 3, 4].map((i) => (
<MockAvatar key={i} size={40} userTag={`User ${i}`} />
))}
</AvatarStack>
<span className={styles.itemTextTertiary}>40px</span>
</div>
</div>
<SubsectionTitle>
<Trans>Max Visible Count</Trans>
</SubsectionTitle>
<div className={styles.stacksWrapper}>
<div className={styles.stackItem}>
<AvatarStack size={32} maxVisible={3}>
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
<MockAvatar key={i} size={32} userTag={`User ${i}`} />
))}
</AvatarStack>
<span className={styles.itemTextTertiary}>
<Trans>Show max 3 (+5 badge)</Trans>
</span>
</div>
<div className={styles.stackItem}>
<AvatarStack size={32} maxVisible={5}>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((i) => (
<MockAvatar key={i} size={32} userTag={`User ${i}`} />
))}
</AvatarStack>
<span className={styles.itemTextTertiary}>
<Trans>Show max 5 (+5 badge)</Trans>
</span>
</div>
</div>
</SettingsTabSection>
</SettingsTabContent>
</SettingsTabContainer>
);
});

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/>.
*/
.container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.section {
border-bottom: 1px solid var(--background-modifier-accent);
padding-bottom: 2rem;
}
.sectionTitle {
margin-bottom: 1rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
}

View File

@@ -0,0 +1,241 @@
/*
* 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 {
DotsThreeOutlineIcon,
GearIcon,
LinkSimpleIcon,
PlayIcon,
PlusIcon,
ShareFatIcon,
TrashIcon,
} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {openFromEvent} from '~/actions/ContextMenuActionCreators';
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
import {MenuGroups} from '~/components/uikit/ContextMenu/MenuGroups';
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
import {MenuItemCheckbox} from '~/components/uikit/ContextMenu/MenuItemCheckbox';
import {MenuItemRadio} from '~/components/uikit/ContextMenu/MenuItemRadio';
import {MenuItemSlider} from '~/components/uikit/ContextMenu/MenuItemSlider';
import {MenuItemSubmenu} from '~/components/uikit/ContextMenu/MenuItemSubmenu';
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {ButtonsTab} from './ButtonsTab';
import {IndicatorsTab} from './IndicatorsTab';
import styles from './Inline.module.css';
import {InputsTab} from './InputsTab';
import {OverlaysTab} from './OverlaysTab';
import {SelectionsTab} from './SelectionsTab';
export const ComponentGalleryInlineTab: React.FC = observer(() => {
const {t} = useLingui();
const [primarySwitch, setPrimarySwitch] = React.useState(true);
const [dangerSwitch, setDangerSwitch] = React.useState(false);
const [selectValue, setSelectValue] = React.useState('opt1');
const [selectValue2, setSelectValue2] = React.useState('size-md');
const [sliderValue, setSliderValue] = React.useState(42);
const [sliderValue2, setSliderValue2] = React.useState(75);
const [sliderValue3, setSliderValue3] = React.useState(50);
const [sliderValue4, setSliderValue4] = React.useState(75);
const [sliderValue5, setSliderValue5] = React.useState(60);
const [color, setColor] = React.useState(0x3b82f6);
const [color2, setColor2] = React.useState(0xff5733);
const [radioValue, setRadioValue] = React.useState<'a' | 'b' | 'c'>('a');
const [checkOne, setCheckOne] = React.useState(true);
const [checkTwo, setCheckTwo] = React.useState(false);
const [checkboxChecked, setCheckboxChecked] = React.useState(false);
const [checkboxChecked2, setCheckboxChecked2] = React.useState(true);
const [radioGroupValue, setRadioGroupValue] = React.useState<string>('option1');
const [inputValue1, setInputValue1] = React.useState('');
const [inputValue2, setInputValue2] = React.useState('');
const [inputValue3, setInputValue3] = React.useState('');
const [searchValue, setSearchValue] = React.useState('');
const [emailValue, setEmailValue] = React.useState('');
const [passwordValue, setPasswordValue] = React.useState('');
const [textareaValue1, setTextareaValue1] = React.useState('');
const [textareaValue2, setTextareaValue2] = React.useState('This is some example text in the textarea.');
const [inlineEditValue, setInlineEditValue] = React.useState('EditableText');
const radioOptions: Array<RadioOption<string>> = [
{value: 'option1', name: t`First Option`, desc: t`This is the first option description`},
{value: 'option2', name: t`Second Option`, desc: t`This is the second option description`},
{value: 'option3', name: t`Third Option`, desc: t`This is the third option description`},
];
const handleOpenContextMenu = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
openFromEvent(event, ({onClose}) => (
<MenuGroups>
<MenuGroup>
<MenuItem icon={<GearIcon size={16} />} onClick={() => onClose()}>
<Trans>Settings</Trans>
</MenuItem>
<MenuItem icon={<ShareFatIcon size={16} />} onClick={() => onClose()}>
<Trans>Share</Trans>
</MenuItem>
<MenuItem icon={<LinkSimpleIcon size={16} weight="bold" />} onClick={() => onClose()}>
<Trans>Copy Link</Trans>
</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItemCheckbox icon={<PlusIcon size={16} weight="bold" />} checked={checkOne} onChange={setCheckOne}>
<Trans>Enable Extra Option</Trans>
</MenuItemCheckbox>
<MenuItemCheckbox icon={<PlusIcon size={16} weight="bold" />} checked={checkTwo} onChange={setCheckTwo}>
<Trans>Enable Beta Feature</Trans>
</MenuItemCheckbox>
</MenuGroup>
<MenuGroup>
<MenuItemRadio
icon={<PlayIcon size={16} />}
selected={radioValue === 'a'}
onSelect={() => setRadioValue('a')}
>
<Trans>Mode A</Trans>
</MenuItemRadio>
<MenuItemRadio
icon={<PlayIcon size={16} />}
selected={radioValue === 'b'}
onSelect={() => setRadioValue('b')}
>
<Trans>Mode B</Trans>
</MenuItemRadio>
<MenuItemRadio
icon={<PlayIcon size={16} />}
selected={radioValue === 'c'}
onSelect={() => setRadioValue('c')}
>
<Trans>Mode C</Trans>
</MenuItemRadio>
</MenuGroup>
<MenuGroup>
<MenuItemSlider
label={t`Opacity`}
value={sliderValue}
minValue={0}
maxValue={100}
onChange={(v: number) => setSliderValue(Math.round(v))}
onFormat={(v: number) => `${Math.round(v)}%`}
/>
</MenuGroup>
<MenuGroup>
<MenuItemSubmenu
label={t`More Actions`}
icon={<DotsThreeOutlineIcon size={16} />}
render={() => (
<>
<MenuItem onClick={() => onClose()}>
<Trans>Duplicate</Trans>
</MenuItem>
<MenuItem onClick={() => onClose()}>
<Trans>Archive</Trans>
</MenuItem>
</>
)}
/>
<MenuItem icon={<TrashIcon size={16} />} danger onClick={() => onClose()}>
<Trans>Delete</Trans>
</MenuItem>
</MenuGroup>
</MenuGroups>
));
},
[checkOne, checkTwo, radioValue, sliderValue],
);
return (
<div className={styles.container}>
<div className={styles.section}>
<h3 className={styles.sectionTitle}>{t`Buttons`}</h3>
<ButtonsTab openContextMenu={handleOpenContextMenu} />
</div>
<div className={styles.section}>
<h3 className={styles.sectionTitle}>{t`Inputs & Text`}</h3>
<InputsTab
inputValue1={inputValue1}
setInputValue1={setInputValue1}
inputValue2={inputValue2}
setInputValue2={setInputValue2}
inputValue3={inputValue3}
setInputValue3={setInputValue3}
searchValue={searchValue}
setSearchValue={setSearchValue}
emailValue={emailValue}
setEmailValue={setEmailValue}
passwordValue={passwordValue}
setPasswordValue={setPasswordValue}
textareaValue1={textareaValue1}
setTextareaValue1={setTextareaValue1}
textareaValue2={textareaValue2}
setTextareaValue2={setTextareaValue2}
inlineEditValue={inlineEditValue}
setInlineEditValue={setInlineEditValue}
color={color}
setColor={setColor}
color2={color2}
setColor2={setColor2}
/>
</div>
<div className={styles.section}>
<h3 className={styles.sectionTitle}>{t`Selections`}</h3>
<SelectionsTab
selectValue={selectValue}
setSelectValue={setSelectValue}
selectValue2={selectValue2}
setSelectValue2={setSelectValue2}
primarySwitch={primarySwitch}
setPrimarySwitch={setPrimarySwitch}
dangerSwitch={dangerSwitch}
setDangerSwitch={setDangerSwitch}
checkboxChecked={checkboxChecked}
setCheckboxChecked={setCheckboxChecked}
checkboxChecked2={checkboxChecked2}
setCheckboxChecked2={setCheckboxChecked2}
radioGroupValue={radioGroupValue}
setRadioGroupValue={setRadioGroupValue}
radioOptions={radioOptions}
sliderValue={sliderValue}
setSliderValue={setSliderValue}
sliderValue2={sliderValue2}
setSliderValue2={setSliderValue2}
sliderValue3={sliderValue3}
setSliderValue3={setSliderValue3}
sliderValue4={sliderValue4}
setSliderValue4={setSliderValue4}
sliderValue5={sliderValue5}
setSliderValue5={setSliderValue5}
/>
</div>
<div className={styles.section}>
<h3 className={styles.sectionTitle}>{t`Overlays & Menus`}</h3>
<OverlaysTab openContextMenu={handleOpenContextMenu} />
</div>
<div>
<h3 className={styles.sectionTitle}>{t`Indicators & Status`}</h3>
<IndicatorsTab />
</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/>.
*/
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
.gridSingle {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.inlineEditWrapper {
display: flex;
align-items: center;
gap: 1rem;
}
.inlineEditLabel {
font-size: 0.875rem;
color: var(--text-tertiary);
}
.inlineEditWrapper {
display: flex;
align-items: center;
gap: 1rem;
}
.inlineEditCaption {
font-size: 0.875rem;
color: var(--text-tertiary);
}
.colorPickersGrid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.colorPickersGrid {
grid-template-columns: repeat(2, 1fr);
}
}

View File

@@ -0,0 +1,223 @@
/*
* 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 {MagnifyingGlassIcon, UserIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ColorPickerField} from '~/components/form/ColorPickerField';
import {Input, Textarea} from '~/components/form/Input';
import {
SettingsTabContainer,
SettingsTabContent,
SettingsTabSection,
} from '~/components/modals/shared/SettingsTabLayout';
import {InlineEdit} from '~/components/uikit/InlineEdit';
import styles from './InputsTab.module.css';
interface InputsTabProps {
inputValue1: string;
setInputValue1: (value: string) => void;
inputValue2: string;
setInputValue2: (value: string) => void;
inputValue3: string;
setInputValue3: (value: string) => void;
searchValue: string;
setSearchValue: (value: string) => void;
emailValue: string;
setEmailValue: (value: string) => void;
passwordValue: string;
setPasswordValue: (value: string) => void;
textareaValue1: string;
setTextareaValue1: (value: string) => void;
textareaValue2: string;
setTextareaValue2: (value: string) => void;
inlineEditValue: string;
setInlineEditValue: (value: string) => void;
color: number;
setColor: (value: number) => void;
color2: number;
setColor2: (value: number) => void;
}
export const InputsTab: React.FC<InputsTabProps> = observer(
({
inputValue1,
setInputValue1,
inputValue2,
setInputValue2,
inputValue3,
setInputValue3,
searchValue,
setSearchValue,
emailValue,
setEmailValue,
passwordValue,
setPasswordValue,
textareaValue1,
setTextareaValue1,
textareaValue2,
setTextareaValue2,
inlineEditValue,
setInlineEditValue,
color,
setColor,
color2,
setColor2,
}) => {
const {t} = useLingui();
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsTabSection
title={<Trans>Basic Text Inputs</Trans>}
description={<Trans>All inputs are fully interactive - type to test them out!</Trans>}
>
<div className={styles.grid}>
<Input
label={t`Display Name`}
placeholder={t`Enter your name`}
value={inputValue1}
onChange={(e) => setInputValue1(e.target.value)}
/>
<Input
label={t`Username`}
placeholder={t`@username`}
value={inputValue2}
onChange={(e) => setInputValue2(e.target.value)}
/>
<Input
label={t`Email Address`}
type="email"
placeholder={t`your@email.com`}
value={emailValue}
onChange={(e) => setEmailValue(e.target.value)}
/>
<Input
label={t`Password`}
type="password"
placeholder={t`Enter a secure password`}
value={passwordValue}
onChange={(e) => setPasswordValue(e.target.value)}
/>
</div>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Inputs with Icons</Trans>}>
<div className={styles.grid}>
<Input
label={t`Search`}
placeholder={t`Search for anything...`}
leftIcon={<MagnifyingGlassIcon size={16} weight="bold" />}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
/>
<Input
label={t`User Profile`}
placeholder={t`Enter username`}
leftIcon={<UserIcon size={16} />}
value={inputValue3}
onChange={(e) => setInputValue3(e.target.value)}
/>
</div>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Input States</Trans>}>
<div className={styles.grid}>
<Input
label={t`With Error`}
placeholder={t`This field has an error`}
error={t`This is an error message`}
/>
<Input label={t`Disabled Input`} placeholder={t`Cannot be edited`} disabled value="Disabled value" />
</div>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Textarea</Trans>}>
<div className={styles.grid}>
<Textarea
label={t`About You`}
placeholder={t`Write a short bio (max 280 characters)`}
maxLength={280}
showCharacterCount
value={textareaValue1}
onChange={(e) => setTextareaValue1(e.target.value)}
/>
<Textarea
label={t`Message`}
placeholder={t`Type your message here...`}
minRows={3}
value={textareaValue2}
onChange={(e) => setTextareaValue2(e.target.value)}
/>
</div>
<div className={styles.gridSingle}>
<Textarea
label={t`Long Form Content`}
placeholder={t`Write your content here... This textarea expands as you type.`}
minRows={4}
maxRows={12}
value={textareaValue1}
onChange={(e) => setTextareaValue1(e.target.value)}
footer={
<p className={styles.inlineEditLabel}>
<Trans>This textarea auto-expands between 4-12 rows as you type.</Trans>
</p>
}
/>
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Inline Edit</Trans>}
description={
<Trans>Click the text below to edit it inline. Press Enter to save or Escape to cancel.</Trans>
}
>
<div className={styles.inlineEditWrapper}>
<span className={styles.inlineEditCaption}>
<Trans>Editable Text:</Trans>
</span>
<InlineEdit
value={inlineEditValue}
onSave={(newValue) => {
setInlineEditValue(newValue);
ToastActionCreators.createToast({type: 'success', children: t`Value saved: ${newValue}`});
}}
placeholder={t`Enter text`}
maxLength={50}
/>
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Color Pickers</Trans>}
description={<Trans>Click to open the color picker and choose a new color.</Trans>}
>
<div className={styles.colorPickersGrid}>
<ColorPickerField label={t`Primary Accent Color`} value={color} onChange={setColor} />
<ColorPickerField label={t`Secondary Accent Color`} value={color2} onChange={setColor2} />
</div>
</SettingsTabSection>
</SettingsTabContent>
</SettingsTabContainer>
);
},
);

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/>.
*/
.sectionsContainer {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.sectionHeader {
display: flex;
flex-direction: column;
gap: 0.25rem;
border-top: 1px solid var(--background-header-secondary);
padding-top: 1rem;
}
.item {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.itemHeader {
display: flex;
align-items: center;
gap: 0.5rem;
}
.itemLabel {
display: inline-block;
white-space: nowrap;
font-family: monospace;
font-size: 0.875rem;
color: var(--text-tertiary);
}

View File

@@ -0,0 +1,275 @@
/*
* 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 React from 'react';
import {MessagePreviewContext, MessageStates, MessageTypes} from '~/Constants';
import {Message} from '~/components/channel/Message';
import {
SettingsTabContainer,
SettingsTabContent,
SettingsTabHeader,
} from '~/components/modals/shared/SettingsTabLayout';
import {Scroller} from '~/components/uikit/Scroller';
import {ChannelRecord} from '~/records/ChannelRecord';
import {MessageRecord} from '~/records/MessageRecord';
import UserStore from '~/stores/UserStore';
import appearanceStyles from '../AppearanceTab.module.css';
import styles from './MarkdownTab.module.css';
import {SubsectionTitle} from './shared';
export const MarkdownTab: React.FC = observer(() => {
const {t} = useLingui();
const createMarkdownPreviewMessages = React.useCallback(() => {
const currentUser = UserStore.getCurrentUser();
const author = currentUser?.toJSON() || {
id: 'markdown-preview-user',
username: 'MarkdownUser',
discriminator: '0000',
global_name: 'Markdown Preview User',
avatar: null,
bot: false,
system: false,
flags: 0,
};
const fakeChannel = new ChannelRecord({
id: 'markdown-preview-channel',
type: 0,
name: 'markdown-preview',
position: 0,
parent_id: null,
topic: null,
url: null,
nsfw: false,
last_message_id: null,
last_pin_timestamp: null,
bitrate: null,
user_limit: null,
permission_overwrites: [],
});
const tabOpenedAt = new Date();
const markdownSections = [
{
title: t`Text Formatting`,
items: [
{label: '**bold**', content: '**bold text**'},
{label: '*italic*', content: '*italic text*'},
{label: '***bold italic***', content: '***bold italic***'},
{label: '__underline__', content: '__underline text__'},
{label: '~~strikethrough~~', content: '~~strikethrough text~~'},
{label: '`code`', content: '`inline code`'},
{label: '||spoiler||', content: '||spoiler text||'},
{label: '\\*escaped\\*', content: '\\*escaped asterisks\\*'},
],
},
{
title: t`Headings`,
items: [
{label: '#', content: '# Heading 1'},
{label: '##', content: '## Heading 2'},
{label: '###', content: '### Heading 3'},
{label: '####', content: '#### Heading 4'},
],
},
{
title: t`Links`,
items: [
{label: '[text](url)', content: '[Masked Link](https://fluxer.app)'},
{label: '<url>', content: '<https://fluxer.app>'},
{label: 'url', content: 'https://fluxer.app'},
{label: '<email>', content: '<contact@fluxer.app>'},
],
},
{
title: t`Lists`,
items: [
{label: 'Unordered', content: '- First item\n- Second item\n- Third item'},
{label: 'Ordered', content: '1. First item\n2. Second item\n3. Third item'},
{
label: 'Nested',
content: '- Parent item\n - Nested item\n - Another nested\n- Another parent',
},
],
},
{
title: t`Blockquotes`,
items: [
{label: 'Single line', content: '> Single line quote'},
{label: 'Multi-line', content: '> Multi-line quote\n> Spans multiple lines\n> Continues here'},
{
label: 'Alternative',
content: '>>> Multi-line quote\nContinues without > on each line\nUntil the message ends',
},
],
},
{
title: t`Code Blocks`,
items: [
{label: 'Plain', content: '```\nfunction example() {\n return "Hello";\n}\n```'},
{
label: 'JavaScript',
// biome-ignore lint/suspicious/noTemplateCurlyInString: this is intended
content: '```js\nfunction greet(name) {\n console.log(`Hello, ${name}!`);\n}\n```',
},
{
label: 'Python',
content: '```py\ndef factorial(n):\n return 1 if n <= 1 else n * factorial(n-1)\n```',
},
],
},
{
title: t`Tables`,
items: [
{
label: 'Basic',
content: '| Header 1 | Header 2 |\n|----------|----------|\n| Cell 1 | Cell 2 |',
},
{
label: 'Aligned',
content: '| Left | Center | Right |\n|:-----|:------:|------:|\n| L1 | C1 | R1 |',
},
],
},
{
title: t`Alerts / Callouts`,
items: [
{label: '[!NOTE]', content: '> [!NOTE]\n> Helpful information here'},
{label: '[!TIP]', content: '> [!TIP]\n> Useful suggestion here'},
{label: '[!IMPORTANT]', content: '> [!IMPORTANT]\n> Critical information here'},
{label: '[!WARNING]', content: '> [!WARNING]\n> Exercise caution here'},
{label: '[!CAUTION]', content: '> [!CAUTION]\n> Potential risks here'},
],
},
{
title: t`Special Features`,
items: [
{label: 'Subtext', content: '-# This is subtext that appears smaller and dimmed'},
{label: 'Block Spoiler', content: '||\nBlock spoiler content\nClick to reveal!\n||'},
{label: 'Unicode Emojis', content: '🎉 🚀 ❤️ 👍 😀'},
{label: 'Shortcodes', content: ':tm: :copyright: :registered:'},
],
},
{
title: t`Mentions & Timestamps`,
items: [
{label: '@everyone', content: '@everyone'},
{label: '@here', content: '@here'},
{label: 'Short Time', content: '<t:1618936830:t>'},
{label: 'Long Time', content: '<t:1618936830:T>'},
{label: 'Short Date', content: '<t:1618936830:d>'},
{label: 'Long Date', content: '<t:1618936830:D>'},
{label: 'Default', content: '<t:1618936830:f>'},
{label: 'Full', content: '<t:1618936830:F>'},
{label: 'Short Date/Time', content: '<t:1618936830:s>'},
{label: 'Relative', content: '<t:1618936830:R>'},
],
},
];
return {
fakeChannel,
createMessage: (content: string, id: string) => {
return new MessageRecord(
{
id: `markdown-preview-${id}`,
channel_id: 'markdown-preview-channel',
author,
type: MessageTypes.DEFAULT,
flags: 0,
pinned: false,
mention_everyone: false,
content,
timestamp: tabOpenedAt.toISOString(),
state: MessageStates.SENT,
},
{skipUserCache: true},
);
},
markdownSections,
};
}, [t]);
const {fakeChannel, createMessage, markdownSections} = createMarkdownPreviewMessages();
return (
<SettingsTabContainer>
<SettingsTabHeader
title={<Trans>Markdown Preview</Trans>}
description={<Trans>Each message below demonstrates a single markdown feature with live preview.</Trans>}
/>
<SettingsTabContent>
<div className={styles.sectionsContainer}>
{markdownSections.map((section, sectionIndex) => (
<div key={sectionIndex} className={styles.section}>
<div className={styles.sectionHeader}>
<SubsectionTitle>{section.title}</SubsectionTitle>
</div>
{section.items.map((item, itemIndex) => {
const message = createMessage(item.content, `${sectionIndex}-${itemIndex}`);
return (
<div key={itemIndex} className={styles.item}>
<div className={styles.itemHeader}>
<code className={styles.itemLabel}>{item.label}</code>
</div>
<div className={appearanceStyles.previewWrapper}>
<div
className={clsx(appearanceStyles.previewContainer, appearanceStyles.previewContainerCozy)}
style={{
height: 'auto',
minHeight: '60px',
maxHeight: '300px',
}}
>
<Scroller
className={appearanceStyles.previewMessagesContainer}
style={{
height: 'auto',
minHeight: '60px',
maxHeight: '280px',
pointerEvents: 'auto',
paddingTop: '16px',
paddingBottom: '16px',
}}
>
<Message
channel={fakeChannel}
message={message}
previewContext={MessagePreviewContext.SETTINGS}
shouldGroup={false}
/>
</Scroller>
<div className={appearanceStyles.previewOverlay} style={{pointerEvents: 'none'}} />
</div>
</div>
</div>
);
})}
</div>
))}
</div>
</SettingsTabContent>
</SettingsTabContainer>
);
});

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/>.
*/
.buttonsWrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.demoArea {
border-radius: 0.5rem;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-secondary);
padding: 1rem;
font-size: 0.875rem;
color: var(--text-primary);
cursor: pointer;
}

View File

@@ -0,0 +1,120 @@
/*
* 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 {DotsThreeOutlineIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {
SettingsTabContainer,
SettingsTabContent,
SettingsTabSection,
} from '~/components/modals/shared/SettingsTabLayout';
import {Button} from '~/components/uikit/Button/Button';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import styles from './OverlaysTab.module.css';
interface OverlaysTabProps {
openContextMenu: (event: React.MouseEvent<HTMLElement>) => void;
}
export const OverlaysTab: React.FC<OverlaysTabProps> = observer(({openContextMenu}) => {
const {t} = useLingui();
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsTabSection
title={<Trans>Tooltips</Trans>}
description={<Trans>Hover over buttons to see tooltips in different positions.</Trans>}
>
<div className={styles.buttonsWrapper}>
<Tooltip text={t`I am a tooltip`}>
<Button>
<Trans>Hover Me</Trans>
</Button>
</Tooltip>
<Tooltip text={t`Top Tooltip`} position="top">
<Button variant="secondary">
<Trans>Top</Trans>
</Button>
</Tooltip>
<Tooltip text={t`Right Tooltip`} position="right">
<Button variant="secondary">
<Trans>Right</Trans>
</Button>
</Tooltip>
<Tooltip text={t`Bottom Tooltip`} position="bottom">
<Button variant="secondary">
<Trans>Bottom</Trans>
</Button>
</Tooltip>
<Tooltip text={t`Left Tooltip`} position="left">
<Button variant="secondary">
<Trans>Left</Trans>
</Button>
</Tooltip>
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Toasts</Trans>}
description={<Trans>Toasts appear in the top-center of the screen.</Trans>}
>
<div className={styles.buttonsWrapper}>
<Button onClick={() => ToastActionCreators.createToast({type: 'success', children: t`Great Success!`})}>
<Trans>Success</Trans>
</Button>
<Button
variant="danger-primary"
onClick={() => ToastActionCreators.createToast({type: 'error', children: t`Something went wrong.`})}
>
<Trans>Error</Trans>
</Button>
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Context Menus</Trans>}
description={
<Trans>
Context menus can be opened with left-click (on buttons) or right-click (on other elements). This
demonstrates various menu items including checkboxes, radio buttons, sliders, and submenus.
</Trans>
}
>
<div className={styles.buttonsWrapper}>
<Button leftIcon={<DotsThreeOutlineIcon size={16} />} onClick={openContextMenu}>
<Trans>Open Menu</Trans>
</Button>
<Button
square
icon={<DotsThreeOutlineIcon size={16} />}
aria-label={t`Open Menu (icon)`}
onClick={openContextMenu}
/>
<div role="button" tabIndex={0} onContextMenu={openContextMenu} className={styles.demoArea}>
<Trans>Right-click here to open the context menu</Trans>
</div>
</div>
</SettingsTabSection>
</SettingsTabContent>
</SettingsTabContainer>
);
});

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/>.
*/
.descriptionSmall {
font-size: 0.75rem;
color: var(--text-secondary);
}
.gridDouble {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
@media (min-width: 768px) {
.gridDouble {
grid-template-columns: 1fr 1fr;
}
}
.gridSingle {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
}
.contentList {
display: flex;
flex-direction: column;
gap: 1rem;
}
.sliderRow {
display: flex;
align-items: center;
gap: 1rem;
}
.sliderContainer {
width: 100%;
}
.sliderValue {
min-width: 56px;
text-align: right;
font-family: monospace;
font-size: 0.875rem;
color: var(--text-primary);
}
.sliderValueDisabled {
min-width: 56px;
text-align: right;
font-family: monospace;
font-size: 0.875rem;
color: var(--text-tertiary);
}

View File

@@ -0,0 +1,351 @@
/*
* 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 {t} from '@lingui/core/macro';
import {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {Select} from '~/components/form/Select';
import {Switch} from '~/components/form/Switch';
import {
SettingsTabContainer,
SettingsTabContent,
SettingsTabSection,
} from '~/components/modals/shared/SettingsTabLayout';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import {RadioGroup, type RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {Slider} from '~/components/uikit/Slider';
import styles from './SelectionsTab.module.css';
import {SubsectionTitle} from './shared';
interface SelectionsTabProps {
selectValue: string;
setSelectValue: (value: string) => void;
selectValue2: string;
setSelectValue2: (value: string) => void;
primarySwitch: boolean;
setPrimarySwitch: (value: boolean) => void;
dangerSwitch: boolean;
setDangerSwitch: (value: boolean) => void;
checkboxChecked: boolean;
setCheckboxChecked: (value: boolean) => void;
checkboxChecked2: boolean;
setCheckboxChecked2: (value: boolean) => void;
radioGroupValue: string;
setRadioGroupValue: (value: string) => void;
radioOptions: Array<RadioOption<string>>;
sliderValue: number;
setSliderValue: (value: number) => void;
sliderValue2: number;
setSliderValue2: (value: number) => void;
sliderValue3: number;
setSliderValue3: (value: number) => void;
sliderValue4: number;
setSliderValue4: (value: number) => void;
sliderValue5: number;
setSliderValue5: (value: number) => void;
}
export const SelectionsTab: React.FC<SelectionsTabProps> = observer(
({
selectValue,
setSelectValue,
selectValue2,
setSelectValue2,
primarySwitch,
setPrimarySwitch,
dangerSwitch,
setDangerSwitch,
checkboxChecked,
setCheckboxChecked,
checkboxChecked2,
setCheckboxChecked2,
radioGroupValue,
setRadioGroupValue,
radioOptions,
sliderValue,
setSliderValue,
sliderValue2,
setSliderValue2,
sliderValue3,
setSliderValue3,
sliderValue4,
setSliderValue4,
sliderValue5,
}) => {
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsTabSection
title={<Trans>Select Dropdown</Trans>}
description={<Trans>Click to open the dropdown menu and select different options.</Trans>}
>
<div className={styles.gridDouble}>
<Select<string>
label={t`Choose an option`}
value={selectValue}
onChange={(value) => {
setSelectValue(value);
}}
options={[
{value: 'opt1', label: t`Option One`},
{value: 'opt2', label: t`Option Two`},
{value: 'opt3', label: t`Option Three`},
{value: 'opt4', label: t`Option Four`},
]}
/>
<Select<string>
label={t`Size Selection`}
value={selectValue2}
onChange={(value) => {
setSelectValue2(value);
}}
options={[
{value: 'size-sm', label: t`Small`},
{value: 'size-md', label: t`Medium`},
{value: 'size-lg', label: t`Large`},
{value: 'size-xl', label: t`Extra Large`},
]}
/>
</div>
<div className={styles.gridSingle}>
<Select
label={t`Disabled Select`}
value="disabled-opt"
onChange={() => {}}
disabled
options={[{value: 'disabled-opt', label: t`This is disabled`}]}
/>
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Switches</Trans>}
description={<Trans>Toggle switches on and off to see state changes.</Trans>}
>
<div className={styles.contentList}>
<Switch
label={t`Enable Notifications`}
description={t`Receive notifications when someone mentions you`}
value={primarySwitch}
onChange={(value) => {
setPrimarySwitch(value);
}}
/>
<Switch
label={t`Dark Mode`}
description={t`Use dark theme across the application`}
value={dangerSwitch}
onChange={(value) => {
setDangerSwitch(value);
}}
/>
<Switch
label={t`Disabled Switch`}
description={t`This switch is disabled and cannot be toggled`}
value={false}
onChange={() => {}}
disabled
/>
<Switch
label={t`Disabled (Checked)`}
description={t`This switch is disabled in the checked state`}
value={true}
onChange={() => {}}
disabled
/>
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Checkboxes</Trans>}
description={<Trans>Click to check and uncheck. Available in square and round styles.</Trans>}
>
<SubsectionTitle>
<Trans>Square Checkboxes</Trans>
</SubsectionTitle>
<div className={styles.contentList}>
<Checkbox
checked={checkboxChecked}
onChange={(checked) => {
setCheckboxChecked(checked);
}}
>
<Trans>Interactive Checkbox</Trans>
</Checkbox>
<Checkbox
checked={checkboxChecked2}
onChange={(checked) => {
setCheckboxChecked2(checked);
}}
>
<Trans>Another Checkbox</Trans>
</Checkbox>
<Checkbox checked={true} disabled>
<Trans>Disabled (Checked)</Trans>
</Checkbox>
<Checkbox checked={false} disabled>
<Trans>Disabled (Unchecked)</Trans>
</Checkbox>
</div>
<SubsectionTitle>
<Trans>Round Checkboxes</Trans>
</SubsectionTitle>
<div className={styles.contentList}>
<Checkbox checked={checkboxChecked} onChange={(checked) => setCheckboxChecked(checked)} type="round">
<Trans>Round Style Checkbox</Trans>
</Checkbox>
<Checkbox checked={checkboxChecked2} onChange={(checked) => setCheckboxChecked2(checked)} type="round">
<Trans>Another Round Checkbox</Trans>
</Checkbox>
<Checkbox checked={true} disabled type="round">
<Trans>Disabled Round (Checked)</Trans>
</Checkbox>
</div>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Radio Group</Trans>}
description={<Trans>Radio buttons allow selecting one option from a group.</Trans>}
>
<RadioGroup
aria-label={t`Select an option from the radio group`}
options={radioOptions}
value={radioGroupValue}
onChange={(value) => {
setRadioGroupValue(value);
}}
/>
</SettingsTabSection>
<SettingsTabSection
title={<Trans>Sliders</Trans>}
description={
<Trans>Drag the slider handles to adjust values. Click markers to jump to specific values.</Trans>
}
>
<SubsectionTitle>
<Trans>Standard Slider with Markers</Trans>
</SubsectionTitle>
<div className={styles.sliderRow}>
<div className={styles.sliderContainer}>
<Slider
defaultValue={sliderValue}
factoryDefaultValue={42}
minValue={0}
maxValue={100}
onValueChange={(v) => setSliderValue(Math.round(v))}
onValueRender={(v) => `${Math.round(v)}%`}
markers={[0, 25, 50, 75, 100]}
onMarkerRender={(m) => `${m}%`}
/>
</div>
<div className={styles.sliderValue}>{sliderValue}%</div>
</div>
<SubsectionTitle>
<Trans>Slider with Fewer Markers</Trans>
</SubsectionTitle>
<div className={styles.sliderRow}>
<div className={styles.sliderContainer}>
<Slider
defaultValue={sliderValue2}
factoryDefaultValue={75}
minValue={0}
maxValue={100}
onValueChange={(v) => setSliderValue2(Math.round(v))}
onValueRender={(v) => `${Math.round(v)}%`}
markers={[0, 50, 100]}
onMarkerRender={(m) => `${m}%`}
/>
</div>
<div className={styles.sliderValue}>{sliderValue2}%</div>
</div>
<SubsectionTitle>
<Trans>Slider with Step Values</Trans>
</SubsectionTitle>
<p className={styles.descriptionSmall}>
<Trans>Snaps to increments of 5.</Trans>
</p>
<div className={styles.sliderRow}>
<div className={styles.sliderContainer}>
<Slider
defaultValue={sliderValue3}
factoryDefaultValue={50}
minValue={0}
maxValue={100}
step={5}
onValueChange={(v) => setSliderValue3(Math.round(v))}
onValueRender={(v) => `${Math.round(v)}%`}
markers={[0, 25, 50, 75, 100]}
onMarkerRender={(m) => `${m}%`}
/>
</div>
<div className={styles.sliderValue}>{sliderValue3}%</div>
</div>
<SubsectionTitle>
<Trans>Markers Below Slider</Trans>
</SubsectionTitle>
<p className={styles.descriptionSmall}>
<Trans>Alternative marker positioning.</Trans>
</p>
<div className={styles.sliderRow}>
<div className={styles.sliderContainer}>
<Slider
defaultValue={sliderValue4}
factoryDefaultValue={75}
minValue={0}
maxValue={100}
markerPosition="below"
onValueChange={(v) => setSliderValue4(Math.round(v))}
onValueRender={(v) => `${Math.round(v)}%`}
markers={[0, 25, 50, 75, 100]}
onMarkerRender={(m) => `${m}%`}
/>
</div>
<div className={styles.sliderValue}>{sliderValue4}%</div>
</div>
<SubsectionTitle>
<Trans>Disabled Slider</Trans>
</SubsectionTitle>
<div className={styles.sliderRow}>
<div className={styles.sliderContainer}>
<Slider
defaultValue={sliderValue5}
factoryDefaultValue={60}
minValue={0}
maxValue={100}
disabled
onValueRender={(v) => `${Math.round(v)}%`}
markers={[0, 25, 50, 75, 100]}
onMarkerRender={(m) => `${m}%`}
/>
</div>
<div className={styles.sliderValueDisabled}>{sliderValue5}%</div>
</div>
</SettingsTabSection>
</SettingsTabContent>
</SettingsTabContainer>
);
},
);

View File

@@ -0,0 +1,281 @@
/*
* 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 {
DotsThreeOutlineIcon,
GearIcon,
LinkSimpleIcon,
PlayIcon,
PlusIcon,
ShareFatIcon,
TrashIcon,
WarningCircleIcon,
} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
import {MenuGroups} from '~/components/uikit/ContextMenu/MenuGroups';
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
import {MenuItemCheckbox} from '~/components/uikit/ContextMenu/MenuItemCheckbox';
import {MenuItemRadio} from '~/components/uikit/ContextMenu/MenuItemRadio';
import {MenuItemSlider} from '~/components/uikit/ContextMenu/MenuItemSlider';
import {MenuItemSubmenu} from '~/components/uikit/ContextMenu/MenuItemSubmenu';
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import {ButtonsTab} from './ButtonsTab';
import {IndicatorsTab} from './IndicatorsTab';
import {InputsTab} from './InputsTab';
import {MarkdownTab} from './MarkdownTab';
import {OverlaysTab} from './OverlaysTab';
import {SelectionsTab} from './SelectionsTab';
const ComponentGalleryTab: React.FC = observer(() => {
const {t} = useLingui();
const [primarySwitch, setPrimarySwitch] = React.useState(true);
const [dangerSwitch, setDangerSwitch] = React.useState(false);
const [selectValue, setSelectValue] = React.useState('opt1');
const [selectValue2, setSelectValue2] = React.useState('size-md');
const [sliderValue, setSliderValue] = React.useState(42);
const [sliderValue2, setSliderValue2] = React.useState(75);
const [sliderValue3, setSliderValue3] = React.useState(50);
const [sliderValue4, setSliderValue4] = React.useState(75);
const [sliderValue5, setSliderValue5] = React.useState(60);
const [color, setColor] = React.useState(0x3b82f6);
const [color2, setColor2] = React.useState(0xff5733);
const [radioValue, setRadioValue] = React.useState<'a' | 'b' | 'c'>('a');
const [checkOne, setCheckOne] = React.useState(true);
const [checkTwo, setCheckTwo] = React.useState(false);
const [checkboxChecked, setCheckboxChecked] = React.useState(false);
const [checkboxChecked2, setCheckboxChecked2] = React.useState(true);
const [radioGroupValue, setRadioGroupValue] = React.useState<string>('option1');
const [inputValue1, setInputValue1] = React.useState('');
const [inputValue2, setInputValue2] = React.useState('');
const [inputValue3, setInputValue3] = React.useState('');
const [searchValue, setSearchValue] = React.useState('');
const [emailValue, setEmailValue] = React.useState('');
const [passwordValue, setPasswordValue] = React.useState('');
const [textareaValue1, setTextareaValue1] = React.useState('');
const [textareaValue2, setTextareaValue2] = React.useState('This is some example text in the textarea.');
const [inlineEditValue, setInlineEditValue] = React.useState('EditableText');
const radioOptions: Array<RadioOption<string>> = [
{value: 'option1', name: t`First Option`, desc: t`This is the first option description`},
{value: 'option2', name: t`Second Option`, desc: t`This is the second option description`},
{value: 'option3', name: t`Third Option`, desc: t`This is the third option description`},
];
const openContextMenu = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<MenuGroups>
<MenuGroup>
<MenuItem icon={<GearIcon size={16} />} onClick={() => onClose()}>
<Trans>Settings</Trans>
</MenuItem>
<MenuItem icon={<ShareFatIcon size={16} />} onClick={() => onClose()}>
<Trans>Share</Trans>
</MenuItem>
<MenuItem icon={<LinkSimpleIcon size={16} weight="bold" />} onClick={() => onClose()}>
<Trans>Copy Link</Trans>
</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItemCheckbox icon={<PlusIcon size={16} weight="bold" />} checked={checkOne} onChange={setCheckOne}>
<Trans>Enable Extra Option</Trans>
</MenuItemCheckbox>
<MenuItemCheckbox icon={<PlusIcon size={16} weight="bold" />} checked={checkTwo} onChange={setCheckTwo}>
<Trans>Enable Beta Feature</Trans>
</MenuItemCheckbox>
</MenuGroup>
<MenuGroup>
<MenuItemRadio
icon={<PlayIcon size={16} />}
selected={radioValue === 'a'}
onSelect={() => setRadioValue('a')}
>
<Trans>Mode A</Trans>
</MenuItemRadio>
<MenuItemRadio
icon={<PlayIcon size={16} />}
selected={radioValue === 'b'}
onSelect={() => setRadioValue('b')}
>
<Trans>Mode B</Trans>
</MenuItemRadio>
<MenuItemRadio
icon={<PlayIcon size={16} />}
selected={radioValue === 'c'}
onSelect={() => setRadioValue('c')}
>
<Trans>Mode C</Trans>
</MenuItemRadio>
</MenuGroup>
<MenuGroup>
<MenuItemSlider
label={t`Opacity`}
value={sliderValue}
minValue={0}
maxValue={100}
onChange={(v: number) => setSliderValue(Math.round(v))}
onFormat={(v: number) => `${Math.round(v)}%`}
/>
</MenuGroup>
<MenuGroup>
<MenuItemSubmenu
label={t`More Actions`}
icon={<DotsThreeOutlineIcon size={16} />}
render={() => (
<>
<MenuItem onClick={() => onClose()}>
<Trans>Duplicate</Trans>
</MenuItem>
<MenuItem onClick={() => onClose()}>
<Trans>Archive</Trans>
</MenuItem>
</>
)}
/>
<MenuItem icon={<TrashIcon size={16} />} danger onClick={() => onClose()}>
<Trans>Delete</Trans>
</MenuItem>
</MenuGroup>
</MenuGroups>
));
},
[checkOne, checkTwo, radioValue, sliderValue],
);
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsSection id="buttons" title={t`Buttons`}>
<ButtonsTab openContextMenu={openContextMenu} />
</SettingsSection>
<SettingsSection id="inputs" title={t`Inputs & Text`}>
<InputsTab
inputValue1={inputValue1}
setInputValue1={setInputValue1}
inputValue2={inputValue2}
setInputValue2={setInputValue2}
inputValue3={inputValue3}
setInputValue3={setInputValue3}
searchValue={searchValue}
setSearchValue={setSearchValue}
emailValue={emailValue}
setEmailValue={setEmailValue}
passwordValue={passwordValue}
setPasswordValue={setPasswordValue}
textareaValue1={textareaValue1}
setTextareaValue1={setTextareaValue1}
textareaValue2={textareaValue2}
setTextareaValue2={setTextareaValue2}
inlineEditValue={inlineEditValue}
setInlineEditValue={setInlineEditValue}
color={color}
setColor={setColor}
color2={color2}
setColor2={setColor2}
/>
</SettingsSection>
<SettingsSection id="selections" title={t`Selections`}>
<SelectionsTab
selectValue={selectValue}
setSelectValue={setSelectValue}
selectValue2={selectValue2}
setSelectValue2={setSelectValue2}
primarySwitch={primarySwitch}
setPrimarySwitch={setPrimarySwitch}
dangerSwitch={dangerSwitch}
setDangerSwitch={setDangerSwitch}
checkboxChecked={checkboxChecked}
setCheckboxChecked={setCheckboxChecked}
checkboxChecked2={checkboxChecked2}
setCheckboxChecked2={setCheckboxChecked2}
radioGroupValue={radioGroupValue}
setRadioGroupValue={setRadioGroupValue}
radioOptions={radioOptions}
sliderValue={sliderValue}
setSliderValue={setSliderValue}
sliderValue2={sliderValue2}
setSliderValue2={setSliderValue2}
sliderValue3={sliderValue3}
setSliderValue3={setSliderValue3}
sliderValue4={sliderValue4}
setSliderValue4={setSliderValue4}
sliderValue5={sliderValue5}
setSliderValue5={setSliderValue5}
/>
</SettingsSection>
<SettingsSection id="overlays" title={t`Overlays & Menus`}>
<OverlaysTab openContextMenu={openContextMenu} />
</SettingsSection>
<SettingsSection id="indicators" title={t`Indicators & Status`}>
<IndicatorsTab />
</SettingsSection>
<SettingsSection
id="status"
title={t`Status Slate`}
description={t`A reusable component for empty states, errors, and status messages.`}
>
<StatusSlate
Icon={WarningCircleIcon}
title={<Trans>Lorem ipsum dolor sit amet</Trans>}
description={
<Trans>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua.
</Trans>
}
actions={[
{
text: <Trans>Primary Action</Trans>,
onClick: () => {},
},
{
text: <Trans>Secondary Action</Trans>,
onClick: () => {},
variant: 'secondary',
},
]}
/>
</SettingsSection>
<SettingsSection id="markdown" title={t`Markdown`}>
<MarkdownTab />
</SettingsSection>
</SettingsTabContent>
</SettingsTabContainer>
);
});
export default ComponentGalleryTab;

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/>.
*/
.sectionTitle {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
}
.subsectionTitle {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}

View File

@@ -0,0 +1,26 @@
/*
* 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 type React from 'react';
import styles from './shared.module.css';
export const SubsectionTitle = observer(({children}: {children: React.ReactNode}) => (
<h4 className={styles.subsectionTitle}>{children}</h4>
));

View File

@@ -0,0 +1,20 @@
/*
* 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/>.
*/
export {default} from './DeveloperOptionsTab/index';

View File

@@ -0,0 +1,43 @@
/*
* 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/>.
*/
.sliderContainer {
margin-left: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sliderLabel {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.labelText {
display: block;
font-weight: 500;
font-size: 0.875rem;
}
.labelDescription {
margin-bottom: 0.5rem;
color: var(--text-primary-muted);
font-size: 0.875rem;
}

View File

@@ -0,0 +1,147 @@
/*
* 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 DeveloperOptionsActionCreators from '~/actions/DeveloperOptionsActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {UserPremiumTypes} from '~/Constants';
import {Select} from '~/components/form/Select';
import {Switch} from '~/components/form/Switch';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import {Slider} from '~/components/uikit/Slider';
import type {UserRecord} from '~/records/UserRecord';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import styles from './AccountPremiumTab.module.css';
import {
applyPremiumScenarioOption,
PREMIUM_SCENARIO_OPTIONS,
type PremiumScenarioOption,
} from './premiumScenarioOptions';
interface AccountPremiumTabContentProps {
user: UserRecord;
}
export const AccountPremiumTabContent: React.FC<AccountPremiumTabContentProps> = observer(({user}) => {
const {t} = useLingui();
const premiumTypeOptions = [
{value: '', label: t`Use Actual Premium Type`},
{value: UserPremiumTypes.NONE.toString(), label: t`None`},
{value: UserPremiumTypes.SUBSCRIPTION.toString(), label: t`Subscription`},
{value: UserPremiumTypes.LIFETIME.toString(), label: t`Lifetime`},
];
return (
<>
<SettingsTabSection title={<Trans>Account State Overrides</Trans>}>
<Switch
label={t`Email Verified Override`}
value={DeveloperOptionsStore.emailVerifiedOverride ?? false}
description={t`Override email verification status`}
onChange={(value) =>
DeveloperOptionsActionCreators.updateOption('emailVerifiedOverride', value ? true : null)
}
/>
<Switch
label={t`Unclaimed Account Override`}
value={DeveloperOptionsStore.unclaimedAccountOverride ?? false}
description={t`Override unclaimed account status`}
onChange={(value) =>
DeveloperOptionsActionCreators.updateOption('unclaimedAccountOverride', value ? true : null)
}
/>
<Switch
label={t`Unread Gift Inventory Override`}
value={DeveloperOptionsStore.hasUnreadGiftInventoryOverride ?? false}
description={t`Override unread gift inventory status`}
onChange={(value) => {
DeveloperOptionsActionCreators.updateOption('hasUnreadGiftInventoryOverride', value ? true : null);
if (!value) DeveloperOptionsActionCreators.updateOption('unreadGiftInventoryCountOverride', null);
}}
/>
{DeveloperOptionsStore.hasUnreadGiftInventoryOverride && (
<div className={styles.sliderContainer}>
<div className={styles.sliderLabel}>
<span className={styles.labelText}>
<Trans>Unread Gift Count</Trans>
</span>
<p className={styles.labelDescription}>
<Trans>Set the number of unread gifts in inventory.</Trans>
</p>
</div>
<Slider
defaultValue={DeveloperOptionsStore.unreadGiftInventoryCountOverride ?? 1}
factoryDefaultValue={1}
minValue={0}
maxValue={99}
step={1}
markers={[0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 99]}
stickToMarkers={false}
onMarkerRender={(v) => `${v}`}
onValueRender={(v) => <Plural value={v} one="# gift" other="# gifts" />}
onValueChange={(v) => DeveloperOptionsActionCreators.updateOption('unreadGiftInventoryCountOverride', v)}
/>
</div>
)}
</SettingsTabSection>
<SettingsTabSection title={<Trans>Premium Type Override</Trans>}>
<Select
label={t`Override Premium Type`}
value={DeveloperOptionsStore.premiumTypeOverride?.toString() ?? ''}
options={premiumTypeOptions}
onChange={(value) => {
const premiumTypeOverride = value === '' ? null : Number.parseInt(value, 10);
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', premiumTypeOverride);
}}
/>
<Switch
label={t`Backend Premium Override`}
value={user.premiumEnabledOverride ?? false}
description={t`Toggle premium_enabled_override on the backend`}
onChange={async (value) => {
await UserActionCreators.update({premium_enabled_override: value});
}}
/>
<Switch
label={t`Has Ever Purchased Override`}
value={DeveloperOptionsStore.hasEverPurchasedOverride ?? false}
description={t`Simulates having a Stripe customer ID (for testing purchase history access)`}
onChange={(value) =>
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', value ? true : null)
}
/>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Premium Subscription Scenarios</Trans>}>
<Select<PremiumScenarioOption>
label={t`Test Subscription State`}
value="none"
options={PREMIUM_SCENARIO_OPTIONS.map(({value, label}) => ({
value,
label: t(label),
}))}
onChange={applyPremiumScenarioOption}
/>
</SettingsTabSection>
</>
);
});

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/>.
*/
.toggleGroup {
border-top: 1px solid var(--background-header-secondary);
padding-top: 1rem;
}
.toggleGroupFirst {
padding-top: 0;
border-top: none;
}
.groupTitle {
margin-top: 0.25rem;
margin-bottom: 0.75rem;
font-weight: 600;
font-size: 0.75rem;
color: var(--text-tertiary-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.toggleList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.buttonGroup {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}

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 {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as DeveloperOptionsActionCreators from '~/actions/DeveloperOptionsActionCreators';
import {Switch} from '~/components/form/Switch';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import styles from './GeneralTab.module.css';
import {getToggleGroups} from './shared';
export const GeneralTabContent: React.FC = observer(() => {
const {t} = useLingui();
const toggleGroups = getToggleGroups();
return (
<>
{toggleGroups.map((group, gi) => (
<div
key={group.title.id ?? `toggle-group-${gi}`}
className={gi > 0 ? styles.toggleGroup : styles.toggleGroupFirst}
>
<div className={styles.groupTitle}>{t(group.title)}</div>
<div className={styles.toggleList}>
{group.items.map(({key, label, description}) => (
<Switch
key={String(key)}
label={t(label)}
description={description ? t(description) : undefined}
value={Boolean(DeveloperOptionsStore[key])}
onChange={(value) => {
DeveloperOptionsActionCreators.updateOption(key, value);
if (key === 'selfHostedModeOverride') {
window.location.reload();
}
}}
/>
))}
</div>
</div>
))}
</>
);
});

View File

@@ -0,0 +1,23 @@
/*
* 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;
}

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 {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import ConnectionStore from '~/stores/ConnectionStore';
import NagbarStore from '~/stores/NagbarStore';
import UserStore from '~/stores/UserStore';
import {AccountPremiumTabContent} from './AccountPremiumTab';
import {GeneralTabContent} from './GeneralTab';
import styles from './Inline.module.css';
import {MockingTabContent} from './MockingTab';
import {NagbarsTabContent} from './NagbarsTab';
import {ToolsTabContent} from './ToolsTab';
export const DeveloperOptionsInlineTab: React.FC = observer(() => {
const {t} = useLingui();
const socket = ConnectionStore.socket;
const nagbarState = NagbarStore;
const user = UserStore.currentUser;
if (!(user && socket)) return null;
return (
<div className={styles.container}>
<SettingsSection id="dev-general" title={t`General`}>
<GeneralTabContent />
</SettingsSection>
<SettingsSection id="dev-account-premium" title={t`Account & Premium`}>
<AccountPremiumTabContent user={user} />
</SettingsSection>
<SettingsSection id="dev-mocking" title={t`Mocking`}>
<MockingTabContent />
</SettingsSection>
<SettingsSection id="dev-nagbars" title={t`Nagbars`}>
<NagbarsTabContent nagbarState={nagbarState} />
</SettingsSection>
<SettingsSection id="dev-tools" title={t`Tools`}>
<ToolsTabContent socket={socket} />
</SettingsSection>
</div>
);
});

View File

@@ -0,0 +1,65 @@
/*
* 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/>.
*/
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.headerTitle {
margin-bottom: 0;
font-weight: 600;
font-size: 1rem;
}
.sliderContainer {
margin-left: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sliderLabel {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.labelText {
display: block;
font-weight: 500;
font-size: 0.875rem;
}
.labelDescription {
margin-bottom: 0.5rem;
color: var(--text-primary-muted);
font-size: 0.875rem;
}
.note {
color: var(--text-primary-muted);
font-size: 0.75rem;
}
.buttonRow {
display: flex;
align-items: flex-start;
}

View File

@@ -0,0 +1,388 @@
/*
* 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 * as DeveloperOptionsActionCreators from '~/actions/DeveloperOptionsActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {Select} from '~/components/form/Select';
import {Switch} from '~/components/form/Switch';
import RequiredActionModal from '~/components/modals/RequiredActionModal';
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
import {Button} from '~/components/uikit/Button/Button';
import {Slider} from '~/components/uikit/Slider';
import type {DeveloperOptionsState} from '~/stores/DeveloperOptionsStore';
import DeveloperOptionsStore from '~/stores/DeveloperOptionsStore';
import UserStore from '~/stores/UserStore';
import styles from './MockingTab.module.css';
export const MockingTabContent: React.FC = observer(() => {
const {t} = useLingui();
const handleClearAttachmentMocks = () => {
DeveloperOptionsActionCreators.clearAllAttachmentMocks();
};
return (
<>
<SettingsTabSection title={<Trans>Verification Barriers</Trans>}>
<Select
label={t`Mock Verification Barrier`}
value={DeveloperOptionsStore.mockVerificationBarrier}
options={[
{value: 'none', label: t`None (Normal Behavior)`},
{value: 'unclaimed_account', label: t`Unclaimed Account`},
{value: 'unverified_email', label: t`Unverified Email`},
{value: 'account_too_new', label: t`Account Too New`},
{value: 'not_member_long', label: t`Not Member Long Enough`},
{value: 'no_phone', label: t`No Phone Number`},
{value: 'send_message_disabled', label: t`Send Message Disabled`},
]}
onChange={(value) => {
DeveloperOptionsActionCreators.updateOption(
'mockVerificationBarrier',
value as DeveloperOptionsState['mockVerificationBarrier'],
);
}}
/>
{(DeveloperOptionsStore.mockVerificationBarrier === 'account_too_new' ||
DeveloperOptionsStore.mockVerificationBarrier === 'not_member_long') && (
<Select<string>
label={t`Countdown Timer`}
value={DeveloperOptionsStore.mockBarrierTimeRemaining?.toString() ?? '300000'}
options={[
{value: '0', label: t`No Timer`},
{value: '10000', label: t`10 seconds`},
{value: '30000', label: t`30 seconds`},
{value: '60000', label: t`1 minute`},
{value: '120000', label: t`2 minutes`},
{value: '300000', label: t`5 minutes`},
{value: '600000', label: t`10 minutes`},
]}
onChange={(v) =>
DeveloperOptionsActionCreators.updateOption('mockBarrierTimeRemaining', Number.parseInt(v, 10))
}
/>
)}
</SettingsTabSection>
<SettingsTabSection title={<Trans>Channel Permissions</Trans>}>
<Switch
label={t`Force No SEND_MESSAGES Permission`}
value={DeveloperOptionsStore.forceNoSendMessages}
description={t`Removes SEND_MESSAGES permission in all channels (disables textarea and all buttons)`}
onChange={(value) => DeveloperOptionsActionCreators.updateOption('forceNoSendMessages', value)}
/>
<Switch
label={t`Force No ATTACH_FILES Permission`}
value={DeveloperOptionsStore.forceNoAttachFiles}
description={t`Removes ATTACH_FILES permission in all channels (hides upload button)`}
onChange={(value) => DeveloperOptionsActionCreators.updateOption('forceNoAttachFiles', value)}
/>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Attachments</Trans>}>
<Button variant="secondary" onClick={handleClearAttachmentMocks}>
<Trans>Clear attachment mocks</Trans>
</Button>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Voice Calls</Trans>}>
<Button
variant="secondary"
onClick={() => DeveloperOptionsActionCreators.triggerMockIncomingCall()}
disabled={!UserStore.currentUser}
>
<Trans>Trigger Mock Incoming Call</Trans>
</Button>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Slowmode</Trans>}>
<Switch
label={t`Force Slowmode Active`}
value={DeveloperOptionsStore.mockSlowmodeActive}
description={t`Forces slowmode to be active in all channels`}
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockSlowmodeActive', value)}
/>
{DeveloperOptionsStore.mockSlowmodeActive && (
<div className={styles.sliderContainer}>
<div className={styles.sliderLabel}>
<span className={styles.labelText}>
<Trans>Slowmode Time Remaining (ms)</Trans>
</span>
<p className={styles.labelDescription}>
<Trans>Set how much time remains before the user can send another message.</Trans>
</p>
</div>
<Slider
defaultValue={DeveloperOptionsStore.mockSlowmodeRemaining}
factoryDefaultValue={10000}
minValue={0}
maxValue={60000}
step={1000}
markers={[0, 5000, 10000, 15000, 20000, 30000, 45000, 60000]}
stickToMarkers={false}
onMarkerRender={(v) => `${v / 1000}s`}
onValueRender={(v) => <Trans>{Math.floor(v / 1000)} seconds</Trans>}
onValueChange={(v) => DeveloperOptionsActionCreators.updateOption('mockSlowmodeRemaining', v)}
/>
</div>
)}
</SettingsTabSection>
<SettingsTabSection
title={
<div className={styles.header}>
<h3 className={styles.headerTitle}>
<Trans>Visionary</Trans>
</h3>
<Button
variant="secondary"
small={true}
onClick={() => {
DeveloperOptionsActionCreators.updateOption('mockVisionarySoldOut', false);
DeveloperOptionsActionCreators.updateOption('mockVisionaryRemaining', null);
}}
>
<Trans>Reset to Real Data</Trans>
</Button>
</div>
}
>
<Switch
label={t`Mock Visionary Sold Out`}
value={DeveloperOptionsStore.mockVisionarySoldOut}
description={t`Force Visionary to appear sold out`}
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockVisionarySoldOut', value)}
/>
{!DeveloperOptionsStore.mockVisionarySoldOut && (
<div className={styles.sliderContainer}>
<div className={styles.sliderLabel}>
<span className={styles.labelText}>
<Trans>Mock Visionary Remaining</Trans>
</span>
<p className={styles.labelDescription}>
<Trans>Set a fixed remaining count for Visionary availability.</Trans>
</p>
</div>
<Slider
defaultValue={DeveloperOptionsStore.mockVisionaryRemaining ?? 100}
factoryDefaultValue={100}
minValue={0}
maxValue={1000}
step={10}
markers={[0, 50, 100, 250, 500, 750, 1000]}
stickToMarkers={false}
onMarkerRender={(v) => `${v}`}
onValueRender={(v) => <Trans>{v} remaining</Trans>}
onValueChange={(v) => DeveloperOptionsActionCreators.updateOption('mockVisionaryRemaining', v)}
/>
<div className={styles.note}>
<Trans>Set to 0 to simulate sold out.</Trans>
</div>
</div>
)}
</SettingsTabSection>
<SettingsTabSection title={<Trans>Required Actions</Trans>}>
<Select
label={t`Mock Variant`}
value={DeveloperOptionsStore.mockRequiredActionsMode}
description={t`Choose which variant to preview`}
options={[
{value: 'email', label: t`Email`},
{value: 'phone', label: t`Phone`},
{value: 'email_or_phone', label: t`Email or Phone`},
]}
onChange={(value) =>
DeveloperOptionsActionCreators.updateOption(
'mockRequiredActionsMode',
value as DeveloperOptionsState['mockRequiredActionsMode'],
)
}
/>
<div className={styles.buttonRow}>
<Button
onClick={() =>
ModalActionCreators.pushWithKey(
modal(() => <RequiredActionModal mock={true} />),
'required-actions-mock',
)
}
disabled={!UserStore.currentUser}
>
<Trans>Open Overlay</Trans>
</Button>
</div>
{DeveloperOptionsStore.mockRequiredActionsMode === 'email_or_phone' && (
<Select
label={t`Default Tab`}
value={DeveloperOptionsStore.mockRequiredActionsSelectedTab}
description={t`Which tab is selected when the overlay opens`}
options={[
{value: 'email', label: t`Email`},
{value: 'phone', label: t`Phone`},
]}
onChange={(value) =>
DeveloperOptionsActionCreators.updateOption(
'mockRequiredActionsSelectedTab',
value as DeveloperOptionsState['mockRequiredActionsSelectedTab'],
)
}
/>
)}
{(DeveloperOptionsStore.mockRequiredActionsMode === 'phone' ||
(DeveloperOptionsStore.mockRequiredActionsMode === 'email_or_phone' &&
DeveloperOptionsStore.mockRequiredActionsSelectedTab === 'phone')) && (
<Select
label={t`Phone Step`}
value={DeveloperOptionsStore.mockRequiredActionsPhoneStep}
description={t`Pick which phone step to show`}
options={[
{value: 'phone', label: t`Enter Phone`},
{value: 'code', label: t`Enter Code`},
]}
onChange={(value) =>
DeveloperOptionsActionCreators.updateOption(
'mockRequiredActionsPhoneStep',
value as DeveloperOptionsState['mockRequiredActionsPhoneStep'],
)
}
/>
)}
<Switch
label={t`Use Reverification Text`}
value={DeveloperOptionsStore.mockRequiredActionsReverify}
description={t`Swap text to reverify variants`}
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockRequiredActionsReverify', value)}
/>
<Switch
label={t`Resend Button Loading`}
value={DeveloperOptionsStore.mockRequiredActionsResending}
description={t`Force the email resend button into a loading state`}
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockRequiredActionsResending', value)}
/>
<Select
label={t`Resend Outcome`}
value={DeveloperOptionsStore.mockRequiredActionsResendOutcome}
description={t`Toast shown when clicking resend in mock mode`}
options={[
{value: 'success', label: t`Success`},
{value: 'rate_limited', label: t`Rate Limited`},
{value: 'server_error', label: t`Server Error`},
]}
onChange={(value) =>
DeveloperOptionsActionCreators.updateOption(
'mockRequiredActionsResendOutcome',
value as DeveloperOptionsState['mockRequiredActionsResendOutcome'],
)
}
/>
</SettingsTabSection>
<SettingsTabSection title={<Trans>NSFW Gates</Trans>}>
<Select
label={t`Mock NSFW Channel Gate Reason`}
value={DeveloperOptionsStore.mockNSFWGateReason}
description={t`Mock gate reason for blocking entire NSFW channels`}
options={[
{value: 'none', label: t`None (Normal Behavior)`},
{value: 'geo_restricted', label: t`Geo Restricted`},
{value: 'age_restricted', label: t`Age Restricted`},
{value: 'consent_required', label: t`Consent Required`},
]}
onChange={(value) => {
DeveloperOptionsActionCreators.updateOption(
'mockNSFWGateReason',
value as DeveloperOptionsState['mockNSFWGateReason'],
);
}}
/>
<Select
label={t`Mock NSFW Media Gate Reason`}
value={DeveloperOptionsStore.mockNSFWMediaGateReason}
description={t`Forces all media to NSFW and shows the selected blur overlay error state`}
options={[
{value: 'none', label: t`None (Normal Behavior)`},
{value: 'geo_restricted', label: t`Geo Restricted`},
{value: 'age_restricted', label: t`Age Restricted`},
]}
onChange={(value) => {
DeveloperOptionsActionCreators.updateOption(
'mockNSFWMediaGateReason',
value as DeveloperOptionsState['mockNSFWMediaGateReason'],
);
}}
/>
<Switch
label={t`Mock Geo Block Overlay`}
value={DeveloperOptionsStore.mockGeoBlocked}
description={t`Show the geo-blocked overlay (dismissible)`}
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockGeoBlocked', value)}
/>
</SettingsTabSection>
<SettingsTabSection title={<Trans>Gift Inventory</Trans>}>
<Switch
label={t`Mock Gift Inventory`}
value={DeveloperOptionsStore.mockGiftInventory ?? false}
description={t`Show a mock gift code in your gift inventory for testing`}
onChange={(value) => {
DeveloperOptionsActionCreators.updateOption('mockGiftInventory', value ? true : null);
if (!value) {
DeveloperOptionsActionCreators.updateOption('mockGiftDurationMonths', 12);
DeveloperOptionsActionCreators.updateOption('mockGiftRedeemed', null);
}
}}
/>
{DeveloperOptionsStore.mockGiftInventory && (
<>
<Select<string>
label={t`Gift Duration`}
value={DeveloperOptionsStore.mockGiftDurationMonths?.toString() ?? '12'}
description={t`How many months of Plutonium the mock gift provides`}
options={[
{value: '1', label: t`1 Month`},
{value: '3', label: t`3 Months`},
{value: '6', label: t`6 Months`},
{value: '12', label: t`12 Months (1 Year)`},
{value: '0', label: t`Lifetime`},
]}
onChange={(value) =>
DeveloperOptionsActionCreators.updateOption('mockGiftDurationMonths', Number.parseInt(value, 10))
}
/>
<Switch
label={t`Mark as Redeemed`}
value={DeveloperOptionsStore.mockGiftRedeemed ?? false}
description={t`Show the gift as already redeemed`}
onChange={(value) => DeveloperOptionsActionCreators.updateOption('mockGiftRedeemed', value ? true : null)}
/>
</>
)}
</SettingsTabSection>
</>
);
});

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/>.
*/
.nagbarList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.nagbarItem {
display: flex;
align-items: center;
justify-content: space-between;
}
.nagbarInfo {
display: flex;
flex-direction: column;
}
.nagbarLabel {
font-size: 0.875rem;
}
.nagbarStatus {
color: var(--text-tertiary);
font-size: 0.75rem;
}
.buttonGroup {
display: flex;
gap: 0.5rem;
}
.footer {
display: flex;
flex-wrap: wrap;
}
.footer > * {
flex: 1;
min-width: fit-content;
}

View File

@@ -0,0 +1,97 @@
/*
* 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 type React from 'react';
import * as NagbarActionCreators from '~/actions/NagbarActionCreators';
import {Button} from '~/components/uikit/Button/Button';
import type {NagbarStore, NagbarToggleKey} from '~/stores/NagbarStore';
import styles from './NagbarsTab.module.css';
import {getNagbarControls, type NagbarControlDefinition} from './nagbarControls';
interface NagbarsTabContentProps {
nagbarState: NagbarStore;
}
const NagbarRow: React.FC<{
control: NagbarControlDefinition;
nagbarState: NagbarStore;
}> = observer(({control, nagbarState}) => {
const {t} = useLingui();
const getFlag = (key: NagbarToggleKey): boolean => Boolean(nagbarState[key]);
const useActualDisabled = control.useActualDisabled?.(nagbarState) ?? control.resetKeys.every((key) => !getFlag(key));
const forceShowDisabled = control.forceShowDisabled?.(nagbarState) ?? getFlag(control.forceKey);
const forceHideDisabled = control.forceHideDisabled?.(nagbarState) ?? getFlag(control.forceHideKey);
const handleUseActual = () => {
control.resetKeys.forEach((key) => NagbarActionCreators.resetNagbar(key));
NagbarActionCreators.setForceHideNagbar(control.forceHideKey, false);
};
const handleForceShow = () => {
NagbarActionCreators.dismissNagbar(control.forceKey);
NagbarActionCreators.setForceHideNagbar(control.forceHideKey, false);
};
const handleForceHide = () => {
NagbarActionCreators.setForceHideNagbar(control.forceHideKey, true);
NagbarActionCreators.resetNagbar(control.forceKey);
};
return (
<div className={styles.nagbarItem}>
<div className={styles.nagbarInfo}>
<span className={styles.nagbarLabel}>{control.label}</span>
<span className={styles.nagbarStatus}>{control.status(nagbarState)}</span>
</div>
<div className={styles.buttonGroup}>
<Button onClick={handleUseActual} disabled={useActualDisabled}>
{t`Use Actual`}
</Button>
<Button onClick={handleForceShow} disabled={forceShowDisabled}>
{t`Force Show`}
</Button>
<Button onClick={handleForceHide} disabled={forceHideDisabled}>
{t`Force Hide`}
</Button>
</div>
</div>
);
});
export const NagbarsTabContent: React.FC<NagbarsTabContentProps> = observer(({nagbarState}) => {
const {t} = useLingui();
const nagbarControls = getNagbarControls(t);
return (
<div className={styles.nagbarList}>
{nagbarControls.map((control) => (
<NagbarRow key={control.key} control={control} nagbarState={nagbarState} />
))}
<div className={styles.footer}>
<Button
onClick={() => NagbarActionCreators.resetAllNagbars()}
variant="secondary"
>{t`Reset All Nagbars`}</Button>
</div>
</div>
);
});

View File

@@ -0,0 +1,24 @@
/*
* 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/>.
*/
.buttonGroup {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}

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 {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useState} from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {testBulkDeleteAllMessages} from '~/actions/UserActionCreators';
import {CaptchaModal} from '~/components/modals/CaptchaModal';
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
import {KeyboardModeIntroModal} from '~/components/modals/KeyboardModeIntroModal';
import {Button} from '~/components/uikit/Button/Button';
import type {GatewaySocket} from '~/lib/GatewaySocket';
import NewDeviceMonitoringStore from '~/stores/NewDeviceMonitoringStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import styles from './ToolsTab.module.css';
interface ToolsTabContentProps {
socket: GatewaySocket;
}
export const ToolsTabContent: React.FC<ToolsTabContentProps> = observer(({socket}) => {
const {t} = useLingui();
const [isTestingBulkDelete, setIsTestingBulkDelete] = useState(false);
const [shouldCrash, setShouldCrash] = useState(false);
const handleTestBulkDelete = useCallback(async () => {
setIsTestingBulkDelete(true);
try {
await testBulkDeleteAllMessages();
} finally {
setIsTestingBulkDelete(false);
}
}, []);
const handleOpenCaptchaModal = useCallback(() => {
ModalActionCreators.push(
ModalActionCreators.modal(() => (
<CaptchaModal
closeOnVerify={false}
onVerify={(token, captchaType) => {
console.debug('Captcha solved in Developer Options', {token, captchaType});
}}
onCancel={() => {
console.debug('Captcha cancelled in Developer Options');
}}
/>
)),
);
}, []);
const handleOpenClaimAccountModal = useCallback(() => {
ModalActionCreators.push(ModalActionCreators.modal(() => <ClaimAccountModal />));
}, []);
if (shouldCrash) {
return {} as any;
}
return (
<div className={styles.buttonGroup}>
<Button onClick={() => socket.reset()}>{t`Reset Socket`}</Button>
<Button onClick={() => socket.simulateNetworkDisconnect()}>{t`Disconnect Socket`}</Button>
<Button
onClick={() => {
void MediaEngineStore.moveToAfkChannel();
}}
>
{t`Force Move to AFK Channel`}
</Button>
<Button onClick={() => NewDeviceMonitoringStore.showTestModal()}>{t`Show New Device Modal`}</Button>
<Button onClick={handleOpenCaptchaModal}>{t`Open Captcha Modal`}</Button>
<Button
onClick={() => {
ModalActionCreators.push(ModalActionCreators.modal(() => <KeyboardModeIntroModal />));
}}
>
{t`Show Keyboard Mode Intro`}
</Button>
<Button onClick={handleOpenClaimAccountModal}>{t`Open Claim Account Modal`}</Button>
<Button onClick={() => void handleTestBulkDelete()} submitting={isTestingBulkDelete} variant="danger-primary">
{t`Test Bulk Delete (60s)`}
</Button>
<Button onClick={() => setShouldCrash(true)} variant="danger-primary">
{t`Trigger React Crash`}
</Button>
</div>
);
});

View File

@@ -0,0 +1,258 @@
/*
* 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: 2rem;
}
.section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.heading {
margin-bottom: 0;
font-weight: 600;
font-size: 1.25rem;
}
.subheading {
margin-bottom: 1rem;
font-weight: 600;
font-size: 1.125rem;
}
.description {
color: var(--text-secondary);
}
.grid {
display: grid;
gap: 0.75rem;
}
@media (min-width: 768px) {
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.card {
position: relative;
overflow: hidden;
border-radius: 0.5rem;
background-color: var(--background-secondary);
padding: 1rem;
transition: background-color 0.15s;
}
.card:hover {
background-color: var(--background-tertiary);
}
.cardHeader {
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.cardInfo {
display: flex;
flex-direction: column;
}
.fontName {
font-weight: 500;
font-size: 0.875rem;
}
.langCode {
color: var(--text-tertiary);
font-size: 0.75rem;
}
.fontFamily {
font-family: monospace;
color: var(--text-tertiary);
font-size: 0.75rem;
}
.sampleText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weightCard {
border-radius: 0.5rem;
background-color: var(--background-secondary);
padding: 1rem;
}
.weightHeader {
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.weightLabel {
font-weight: 500;
}
.weightValue {
font-family: monospace;
font-size: 0.875rem;
color: var(--text-tertiary);
}
.weightItalic {
margin-top: 0.25rem;
font-style: italic;
}
.scaleList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.scaleItem {
display: flex;
align-items: center;
gap: 1rem;
border-radius: 0.5rem;
background-color: var(--background-secondary);
padding: 0.75rem 1rem;
}
.scaleSize {
width: 5rem;
text-align: right;
}
.scaleSizeText {
font-family: monospace;
color: var(--text-tertiary);
font-size: 0.75rem;
}
.scaleLabel {
width: 4rem;
text-align: right;
}
.scaleLabelText {
font-weight: 500;
font-size: 0.75rem;
color: var(--text-secondary);
}
.scaleSample {
flex: 1;
}
.styleGrid {
display: grid;
gap: 0.75rem;
}
@media (min-width: 768px) {
.styleGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.styleGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.styleCard {
border-radius: 0.5rem;
background-color: var(--background-secondary);
padding: 1rem;
}
.styleLabel {
margin-bottom: 0.5rem;
color: var(--text-tertiary);
font-size: 0.75rem;
}
.codeGrid {
display: grid;
gap: 1rem;
}
@media (min-width: 768px) {
.codeGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.codeCard {
border-radius: 0.5rem;
background-color: var(--background-secondary);
padding: 1rem;
}
.codeTitle {
margin-bottom: 0.75rem;
font-weight: 500;
}
.codeLines {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-family: monospace;
font-size: 0.875rem;
}
.multilingualCard {
border-radius: 0.5rem;
background-color: var(--background-secondary);
padding: 1.5rem;
}
.multilingualList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.multilingualItem {
font-size: 1rem;
}
.italic {
font-style: italic;
}

View File

@@ -0,0 +1,366 @@
/*
* 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 type React from 'react';
import styles from './TypographyTab.module.css';
const fontSamples = [
{
fontFamily: 'IBM Plex Sans',
name: 'IBM Plex Sans',
sample: 'The quick brown fox jumps over the lazy dog',
lang: 'en',
},
{
fontFamily: 'IBM Plex Sans JP',
name: 'IBM Plex Sans Japanese',
sample: 'これは日本語のサンプルテキストです',
lang: 'ja',
},
{
fontFamily: 'IBM Plex Sans KR',
name: 'IBM Plex Sans Korean',
sample: '이것은 한국어 샘플 텍스트입니다',
lang: 'ko',
},
{
fontFamily: 'IBM Plex Sans SC',
name: 'IBM Plex Sans Simplified Chinese',
sample: '这是简体中文的示例文本',
lang: 'zh-CN',
},
{
fontFamily: 'IBM Plex Sans TC',
name: 'IBM Plex Sans Traditional Chinese',
sample: '這是繁體中文的示例文本',
lang: 'zh-TW',
},
{
fontFamily: 'IBM Plex Sans Arabic',
name: 'IBM Plex Sans Arabic',
sample: 'هذه عينة نصية باللغة العربية',
lang: 'ar',
rtl: true,
},
{
fontFamily: 'IBM Plex Sans Hebrew',
name: 'IBM Plex Sans Hebrew',
sample: 'זוהי דוגמה לטקסט בעברית',
lang: 'he',
},
{
fontFamily: 'IBM Plex Sans Devanagari',
name: 'IBM Plex Sans Devanagari',
sample: 'यह हिंदी का नमूना पाठ है',
lang: 'hi',
},
{
fontFamily: 'IBM Plex Sans Thai',
name: 'IBM Plex Sans Thai',
sample: 'นี่คือข้อความตัวอย่างภาษาไทย',
lang: 'th',
},
{
fontFamily: 'IBM Plex Sans Thai Looped',
name: 'IBM Plex Sans Thai Looped',
sample: 'นี่คือข้อความตัวอย่างภาษาไทยแบบ Loop',
lang: 'th',
},
{
fontFamily: 'IBM Plex Mono',
name: 'IBM Plex Mono',
sample: 'const hello = "World"; function example() { return true; }',
lang: 'en',
mono: true,
},
];
const weightExamples = [
{weight: 100, label: 'Thin', text: 'Delicate and light typography'},
{weight: 200, label: 'Extra Light', text: 'Gentle and airy text display'},
{weight: 300, label: 'Light', text: 'Soft and easy reading experience'},
{weight: 400, label: 'Regular', text: 'Perfect for everyday content'},
{weight: 450, label: 'Text', text: 'Optimized for longer reading passages'},
{weight: 500, label: 'Medium', text: 'Slightly bolder emphasis'},
{weight: 600, label: 'Semi Bold', text: 'Strong visual hierarchy'},
{weight: 700, label: 'Bold', text: 'Powerful and attention-grabbing'},
];
const scaleExamples = [
{size: '12px', label: 'Caption', weight: 400},
{size: '14px', label: 'Small', weight: 400},
{size: '16px', label: 'Body', weight: 400},
{size: '18px', label: 'Large', weight: 500},
{size: '20px', label: 'Subtitle', weight: 500},
{size: '24px', label: 'Heading', weight: 600},
{size: '30px', label: 'Title', weight: 700},
{size: '36px', label: 'Display', weight: 700},
];
const contrastExamples = [
{weight: 400, size: '16px', style: 'Normal'},
{weight: 400, size: '16px', style: 'Italic'},
{weight: 600, size: '16px', style: 'Semi Bold'},
{weight: 600, size: '18px', style: 'Semi Bold Large'},
{weight: 700, size: '20px', style: 'Bold Heading'},
{weight: 700, size: '24px', style: 'Bold Title'},
];
export const TypographyTabContent: React.FC = observer(() => {
return (
<div className={styles.container}>
<div className={styles.section}>
<h2 className={styles.heading}>
<Trans>Typography Showcase</Trans>
</h2>
<p className={styles.description}>
<Trans>
Preview all available fonts, weights, and styles across different languages supported by Fluxer.
</Trans>
</p>
</div>
<div>
<h3 className={styles.subheading}>
<Trans>Language Support</Trans>
</h3>
<div className={styles.grid}>
{fontSamples.map((font) => (
<div key={font.fontFamily} className={styles.card}>
<div className={styles.cardHeader}>
<div className={styles.cardInfo}>
<span className={styles.fontName}>{font.name}</span>
<span className={styles.langCode}>{font.lang.toUpperCase()}</span>
</div>
<span className={styles.fontFamily}>{font.fontFamily}</span>
</div>
<div
className={styles.sampleText}
style={{
fontFamily: font.mono ? 'var(--font-mono)' : `"${font.fontFamily}", var(--font-sans)`,
fontSize: '16px',
textAlign: font.rtl ? 'right' : 'left',
}}
lang={font.lang}
dir={font.rtl ? 'rtl' : 'ltr'}
>
{font.sample}
</div>
</div>
))}
</div>
</div>
<div>
<h3 className={styles.subheading}>
<Trans>Font Weights</Trans>
</h3>
<div className={styles.codeGrid}>
{weightExamples.map((example) => (
<div key={example.weight} className={styles.weightCard}>
<div className={styles.cardHeader}>
<span className={styles.weightLabel}>{example.label}</span>
<span className={styles.weightValue}>{example.weight}</span>
</div>
<div
style={{
fontWeight: example.weight,
fontFamily: 'var(--font-sans)',
lineHeight: 1.4,
}}
>
{example.text}
</div>
<div
className={styles.weightItalic}
style={{
fontWeight: example.weight,
fontFamily: 'var(--font-sans)',
fontSize: '14px',
}}
>
Italic style demonstration
</div>
</div>
))}
</div>
</div>
<div>
<h3 className={styles.subheading}>
<Trans>Type Scale</Trans>
</h3>
<div className={styles.scaleList}>
{scaleExamples.map((example) => (
<div key={example.size} className={styles.scaleItem}>
<div className={styles.scaleSize}>
<span className={styles.fontFamily}>{example.size}</span>
</div>
<div className={styles.scaleLabel}>
<span className={styles.scaleLabelText}>{example.label}</span>
</div>
<div
className={styles.scaleSample}
style={{
fontSize: example.size,
fontWeight: example.weight,
fontFamily: 'var(--font-sans)',
lineHeight: 1.3,
}}
>
Typography scale demonstration
</div>
</div>
))}
</div>
</div>
<div>
<h3 className={styles.subheading}>
<Trans>Style Variations</Trans>
</h3>
<div className={styles.grid}>
{contrastExamples.map((example, index) => (
<div key={index} className={styles.weightCard}>
<div className={styles.styleLabel}>{example.style}</div>
<div
className={example.style.includes('Italic') ? styles.italic : ''}
style={{
fontSize: example.size,
fontWeight: example.weight,
fontFamily: 'var(--font-sans)',
lineHeight: 1.3,
}}
>
This text demonstrates {example.style.toLowerCase()} styling
</div>
</div>
))}
</div>
</div>
<div>
<h3 className={styles.subheading}>
<Trans>Code & Monospace</Trans>
</h3>
<div className={styles.codeGrid}>
<div className={styles.weightCard}>
<div className={styles.codeTitle}>Light Code</div>
<div
className={styles.codeLines}
style={{
fontFamily: 'var(--font-mono)',
fontWeight: 300,
}}
>
<div>const example = "light";</div>
<div>function demo() {'{'}</div>
<div> return true;</div>
<div>{'}'}</div>
</div>
</div>
<div className={styles.weightCard}>
<div className={styles.codeTitle}>Regular Code</div>
<div
className={styles.codeLines}
style={{
fontFamily: 'var(--font-mono)',
fontWeight: 400,
}}
>
<div>const example = "regular";</div>
<div>function demo() {'{'}</div>
<div> return true;</div>
<div>{'}'}</div>
</div>
</div>
<div className={styles.weightCard}>
<div className={styles.codeTitle}>Medium Code</div>
<div
className={styles.codeLines}
style={{
fontFamily: 'var(--font-mono)',
fontWeight: 500,
}}
>
<div>const example = "medium";</div>
<div>function demo() {'{'}</div>
<div> return true;</div>
<div>{'}'}</div>
</div>
</div>
<div className={styles.weightCard}>
<div className={styles.codeTitle}>Bold Code</div>
<div
className={styles.codeLines}
style={{
fontFamily: 'var(--font-mono)',
fontWeight: 600,
}}
>
<div>const example = "bold";</div>
<div>function demo() {'{'}</div>
<div> return true;</div>
<div>{'}'}</div>
</div>
</div>
</div>
</div>
<div>
<h3 className={styles.subheading}>
<Trans>Multilingual Content</Trans>
</h3>
<div className={styles.multilingualCard}>
<div className={styles.multilingualList} style={{fontFamily: 'var(--font-sans)', lineHeight: 1.6}}>
<div className={styles.multilingualItem}>
<strong>English:</strong> Welcome to Fluxer's typography showcase
</div>
<div className={styles.multilingualItem} lang="ja">
<strong>:</strong>
</div>
<div className={styles.multilingualItem} lang="ko">
<strong>:</strong> Fluxer의
</div>
<div className={styles.multilingualItem} lang="zh-CN">
<strong>:</strong> Fluxer
</div>
<div className={styles.multilingualItem} lang="zh-TW">
<strong>:</strong> Fluxer
</div>
<div className={styles.multilingualItem} lang="ar" dir="rtl">
<strong>العربية:</strong> مرحباً بك في عرض طباعة Fluxer
</div>
<div className={styles.multilingualItem} lang="he">
<strong>עברית:</strong> ברוכים הבאים לתצוגת הטיפוגרפיה של Fluxer
</div>
<div className={styles.multilingualItem} lang="hi">
<strong>ि:</strong> Fluxer
</div>
<div className={styles.multilingualItem} lang="th">
<strong>:</strong> Fluxer
</div>
</div>
</div>
</div>
</div>
);
});

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 {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {SettingsSection} from '~/components/modals/shared/SettingsSection';
import {SettingsTabContainer, SettingsTabContent} from '~/components/modals/shared/SettingsTabLayout';
import ConnectionStore from '~/stores/ConnectionStore';
import NagbarStore from '~/stores/NagbarStore';
import UserStore from '~/stores/UserStore';
import {AccountPremiumTabContent} from './AccountPremiumTab';
import {GeneralTabContent} from './GeneralTab';
import {MockingTabContent} from './MockingTab';
import {NagbarsTabContent} from './NagbarsTab';
import {ToolsTabContent} from './ToolsTab';
import {TypographyTabContent} from './TypographyTab';
const DeveloperOptionsTab: React.FC = observer(() => {
const socket = ConnectionStore.socket;
const nagbarState = NagbarStore;
const user = UserStore.currentUser;
if (!(user && socket)) return null;
return (
<SettingsTabContainer>
<SettingsTabContent>
<SettingsSection id="general" title={<Trans>General</Trans>}>
<GeneralTabContent />
</SettingsSection>
<SettingsSection id="account_premium" title={<Trans>Account & Premium</Trans>}>
<AccountPremiumTabContent user={user} />
</SettingsSection>
<SettingsSection id="mocking" title={<Trans>Mocking</Trans>}>
<MockingTabContent />
</SettingsSection>
<SettingsSection id="nagbars" title={<Trans>Nagbars</Trans>}>
<NagbarsTabContent nagbarState={nagbarState} />
</SettingsSection>
<SettingsSection id="tools" title={<Trans>Tools</Trans>}>
<ToolsTabContent socket={socket} />
</SettingsSection>
<SettingsSection id="typography" title={<Trans>Typography</Trans>}>
<TypographyTabContent />
</SettingsSection>
</SettingsTabContent>
</SettingsTabContainer>
);
});
export default DeveloperOptionsTab;

View File

@@ -0,0 +1,188 @@
/*
* 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 {Trans} from '@lingui/react/macro';
import type React from 'react';
import type {NagbarStore, NagbarToggleKey} from '~/stores/NagbarStore';
export type DismissibleNagbarKey = NagbarToggleKey;
export interface NagbarControlDefinition {
key: string;
label: React.ReactNode;
forceKey: DismissibleNagbarKey;
forceHideKey: NagbarToggleKey;
resetKeys: Array<DismissibleNagbarKey>;
status: (state: NagbarStore) => string;
useActualDisabled?: (state: NagbarStore) => boolean;
forceShowDisabled?: (state: NagbarStore) => boolean;
forceHideDisabled?: (state: NagbarStore) => boolean;
}
const FORCE_ENABLED_DESCRIPTOR = msg`Force enabled`;
const FORCE_DISABLED_DESCRIPTOR = msg`Force disabled`;
const USING_ACTUAL_ACCOUNT_STATE_DESCRIPTOR = msg`Using actual account state`;
const USING_ACTUAL_VERIFICATION_STATE_DESCRIPTOR = msg`Using actual verification state`;
const CURRENTLY_DISMISSED_DESCRIPTOR = msg`Currently dismissed`;
const CURRENTLY_SHOWING_DESCRIPTOR = msg`Currently showing`;
const USING_ACTUAL_PREMIUM_STATE_DESCRIPTOR = msg`Using actual premium state`;
const USING_ACTUAL_GIFT_INVENTORY_STATE_DESCRIPTOR = msg`Using actual gift inventory state`;
const USING_ACTUAL_STATE_DESCRIPTOR = msg`Using actual state`;
export const getNagbarControls = (t: (message: MessageDescriptor) => string): Array<NagbarControlDefinition> => [
{
key: 'forceUnclaimedAccount',
label: <Trans>Unclaimed Account Nagbar</Trans>,
forceKey: 'forceUnclaimedAccount',
forceHideKey: 'forceHideUnclaimedAccount',
resetKeys: ['forceUnclaimedAccount'],
status: (state) =>
state.forceUnclaimedAccount
? t(FORCE_ENABLED_DESCRIPTOR)
: state.forceHideUnclaimedAccount
? t(FORCE_DISABLED_DESCRIPTOR)
: t(USING_ACTUAL_ACCOUNT_STATE_DESCRIPTOR),
useActualDisabled: (state) => !state.forceUnclaimedAccount && !state.forceHideUnclaimedAccount,
forceShowDisabled: (state) => state.forceUnclaimedAccount,
forceHideDisabled: (state) => state.forceHideUnclaimedAccount,
},
{
key: 'forceEmailVerification',
label: <Trans>Email Verification Nagbar</Trans>,
forceKey: 'forceEmailVerification',
forceHideKey: 'forceHideEmailVerification',
resetKeys: ['forceEmailVerification'],
status: (state) =>
state.forceEmailVerification
? t(FORCE_ENABLED_DESCRIPTOR)
: state.forceHideEmailVerification
? t(FORCE_DISABLED_DESCRIPTOR)
: t(USING_ACTUAL_VERIFICATION_STATE_DESCRIPTOR),
useActualDisabled: (state) => !state.forceEmailVerification && !state.forceHideEmailVerification,
forceShowDisabled: (state) => state.forceEmailVerification,
forceHideDisabled: (state) => state.forceHideEmailVerification,
},
{
key: 'forceDesktopNotification',
label: <Trans>Desktop Notification Nagbar</Trans>,
forceKey: 'forceDesktopNotification',
forceHideKey: 'forceHideDesktopNotification',
resetKeys: ['forceDesktopNotification', 'desktopNotificationDismissed'],
status: (state) =>
state.forceDesktopNotification
? t(FORCE_ENABLED_DESCRIPTOR)
: state.forceHideDesktopNotification
? t(FORCE_DISABLED_DESCRIPTOR)
: state.desktopNotificationDismissed
? t(CURRENTLY_DISMISSED_DESCRIPTOR)
: t(CURRENTLY_SHOWING_DESCRIPTOR),
useActualDisabled: (state) =>
!state.forceDesktopNotification && !state.desktopNotificationDismissed && !state.forceHideDesktopNotification,
forceShowDisabled: (state) => state.forceDesktopNotification,
forceHideDisabled: (state) => state.forceHideDesktopNotification,
},
{
key: 'forcePremiumGracePeriod',
label: <Trans>Premium Grace Period Nagbar</Trans>,
forceKey: 'forcePremiumGracePeriod',
forceHideKey: 'forceHidePremiumGracePeriod',
resetKeys: ['forcePremiumGracePeriod'],
status: (state) =>
state.forcePremiumGracePeriod
? t(FORCE_ENABLED_DESCRIPTOR)
: state.forceHidePremiumGracePeriod
? t(FORCE_DISABLED_DESCRIPTOR)
: t(USING_ACTUAL_PREMIUM_STATE_DESCRIPTOR),
useActualDisabled: (state) => !state.forcePremiumGracePeriod && !state.forceHidePremiumGracePeriod,
forceShowDisabled: (state) => state.forcePremiumGracePeriod,
forceHideDisabled: (state) => state.forceHidePremiumGracePeriod,
},
{
key: 'forcePremiumExpired',
label: <Trans>Premium Expired Nagbar</Trans>,
forceKey: 'forcePremiumExpired',
forceHideKey: 'forceHidePremiumExpired',
resetKeys: ['forcePremiumExpired'],
status: (state) =>
state.forcePremiumExpired
? t(FORCE_ENABLED_DESCRIPTOR)
: state.forceHidePremiumExpired
? t(FORCE_DISABLED_DESCRIPTOR)
: t(USING_ACTUAL_PREMIUM_STATE_DESCRIPTOR),
useActualDisabled: (state) => !state.forcePremiumExpired && !state.forceHidePremiumExpired,
forceShowDisabled: (state) => state.forcePremiumExpired,
forceHideDisabled: (state) => state.forceHidePremiumExpired,
},
{
key: 'forcePremiumOnboarding',
label: <Trans>Premium Onboarding Nagbar</Trans>,
forceKey: 'forcePremiumOnboarding',
forceHideKey: 'forceHidePremiumOnboarding',
resetKeys: ['forcePremiumOnboarding', 'premiumOnboardingDismissed'],
status: (state) =>
state.forcePremiumOnboarding
? t(FORCE_ENABLED_DESCRIPTOR)
: state.forceHidePremiumOnboarding
? t(FORCE_DISABLED_DESCRIPTOR)
: state.premiumOnboardingDismissed
? t(CURRENTLY_DISMISSED_DESCRIPTOR)
: t(USING_ACTUAL_PREMIUM_STATE_DESCRIPTOR),
useActualDisabled: (state) =>
!state.forcePremiumOnboarding && !state.premiumOnboardingDismissed && !state.forceHidePremiumOnboarding,
forceShowDisabled: (state) => state.forcePremiumOnboarding,
forceHideDisabled: (state) => state.forceHidePremiumOnboarding,
},
{
key: 'forceGiftInventory',
label: <Trans>Gift Inventory Nagbar</Trans>,
forceKey: 'forceGiftInventory',
forceHideKey: 'forceHideGiftInventory',
resetKeys: ['forceGiftInventory', 'giftInventoryDismissed'],
status: (state) =>
state.forceGiftInventory
? t(FORCE_ENABLED_DESCRIPTOR)
: state.forceHideGiftInventory
? t(FORCE_DISABLED_DESCRIPTOR)
: state.giftInventoryDismissed
? t(CURRENTLY_DISMISSED_DESCRIPTOR)
: t(USING_ACTUAL_GIFT_INVENTORY_STATE_DESCRIPTOR),
useActualDisabled: (state) =>
!state.forceGiftInventory && !state.giftInventoryDismissed && !state.forceHideGiftInventory,
forceShowDisabled: (state) => state.forceGiftInventory,
forceHideDisabled: (state) => state.forceHideGiftInventory,
},
{
key: 'forceInvitesDisabled',
label: <Trans>Invites Disabled Nagbar</Trans>,
forceKey: 'forceInvitesDisabled',
forceHideKey: 'forceHideInvitesDisabled',
resetKeys: ['forceInvitesDisabled'],
status: (state) =>
state.forceInvitesDisabled
? t(FORCE_ENABLED_DESCRIPTOR)
: state.forceHideInvitesDisabled
? t(FORCE_DISABLED_DESCRIPTOR)
: t(USING_ACTUAL_STATE_DESCRIPTOR),
useActualDisabled: (state) => !state.forceInvitesDisabled && !state.forceHideInvitesDisabled,
forceShowDisabled: (state) => state.forceInvitesDisabled,
forceHideDisabled: (state) => state.forceHideInvitesDisabled,
},
];

View File

@@ -0,0 +1,204 @@
/*
* 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 * as DeveloperOptionsActionCreators from '~/actions/DeveloperOptionsActionCreators';
import {UserPremiumTypes} from '~/Constants';
import type {SelectOption} from '~/components/form/Select';
interface PremiumScenarioSelectOption extends Omit<SelectOption<PremiumScenarioOption>, 'label'> {
label: MessageDescriptor;
}
export type PremiumScenarioOption =
| 'none'
| 'free_no_purchases'
| 'free_with_history'
| 'active_monthly'
| 'active_monthly_cancelled'
| 'active_yearly'
| 'active_yearly_cancelled'
| 'gift_active_no_history'
| 'gift_active_with_history'
| 'gift_expiring_soon'
| 'gift_grace_period_active'
| 'gift_expired_recent'
| 'grace_period_active'
| 'expired_recent'
| 'expired_old'
| 'visionary'
| 'reset';
export const PREMIUM_SCENARIO_OPTIONS: ReadonlyArray<PremiumScenarioSelectOption> = [
{value: 'none', label: msg`Select a scenario to apply...`},
{value: 'free_no_purchases', label: msg`Free User (No Purchases)`},
{value: 'free_with_history', label: msg`Free User (With Purchase History)`},
{value: 'active_monthly', label: msg`Active Monthly Subscriber`},
{value: 'active_monthly_cancelled', label: msg`Active Monthly Subscriber (Cancellation Scheduled)`},
{value: 'active_yearly', label: msg`Active Yearly Subscriber`},
{value: 'active_yearly_cancelled', label: msg`Active Yearly Subscriber (Cancellation Scheduled)`},
{value: 'gift_active_no_history', label: msg`Gift Active (No Purchase History)`},
{value: 'gift_active_with_history', label: msg`Gift Active (Has Purchase History)`},
{value: 'gift_expiring_soon', label: msg`Gift Expiring Soon`},
{value: 'gift_grace_period_active', label: msg`Gift Grace Period (Still Have Access)`},
{value: 'gift_expired_recent', label: msg`Gift Expired (Within 30 Days)`},
{value: 'grace_period_active', label: msg`Grace Period (Still Have Access)`},
{value: 'expired_recent', label: msg`Expired (Within 30 Days)`},
{value: 'expired_old', label: msg`Expired (Over 30 Days Ago)`},
{value: 'visionary', label: msg`Visionary (Lifetime #42)`},
{value: 'reset', label: msg`Reset to Actual Values`},
];
export const applyPremiumScenarioOption = (scenario: PremiumScenarioOption) => {
if (scenario === 'none') return;
const now = Date.now();
switch (scenario) {
case 'free_no_purchases':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.NONE);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', false);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'free_with_history':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.NONE);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'active_monthly':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 15 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 15 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'monthly');
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'active_monthly_cancelled':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 20 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 10 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'monthly');
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', true);
break;
case 'active_yearly':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 180 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 185 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'yearly');
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'active_yearly_cancelled':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 250 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 60 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'yearly');
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', true);
break;
case 'gift_active_no_history':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', false);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 10 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 20 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'gift_active_with_history':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 5 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 60 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'gift_expiring_soon':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', false);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 25 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now + 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'gift_grace_period_active':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', false);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 31 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now - 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'gift_expired_recent':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.NONE);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', false);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 35 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now - 5 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'grace_period_active':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.SUBSCRIPTION);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 31 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now - 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'yearly');
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'expired_recent':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.NONE);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 35 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now - 5 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'yearly');
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'expired_old':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.NONE);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 65 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', new Date(now - 35 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', 'yearly');
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'visionary':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', UserPremiumTypes.LIFETIME);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', true);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', new Date(now - 365 * 86400000));
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', false);
break;
case 'reset':
DeveloperOptionsActionCreators.updateOption('premiumTypeOverride', null);
DeveloperOptionsActionCreators.updateOption('hasEverPurchasedOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumSinceOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumUntilOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumBillingCycleOverride', null);
DeveloperOptionsActionCreators.updateOption('premiumWillCancelOverride', null);
break;
}
};

Some files were not shown because too many files have changed in this diff Show More