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,72 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 16px;
}
.footer {
align-items: center;
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}
.infoSection {
display: flex;
flex-direction: column;
gap: 12px;
color: var(--text-primary);
}
.infoBox {
border-radius: 6px;
border: 1px solid var(--background-header-secondary);
background: var(--background-secondary);
padding: 12px;
}
.infoBoxTitle {
margin-bottom: 8px;
font-weight: 600;
color: var(--text-primary);
}
.infoList {
list-style-position: inside;
list-style-type: disc;
display: flex;
flex-direction: column;
gap: 4px;
color: var(--text-primary-muted);
font-size: 14px;
}
.disclaimer {
color: var(--text-primary-muted);
}

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/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {Form} from '~/components/form/Form';
import styles from '~/components/modals/AccountDeleteModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import * as RouterUtils from '~/utils/RouterUtils';
export const AccountDeleteModal = observer(() => {
const {t} = useLingui();
const form = useForm();
const onSubmit = async () => {
await UserActionCreators.deleteAccount();
ModalActionCreators.pop();
RouterUtils.transitionTo('/login');
};
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'form',
});
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit} aria-label={t`Delete account form`}>
<Modal.Header title={t`Delete Account`} />
<Modal.Content className={styles.content}>
<div className={styles.infoSection}>
<p>
<Trans>
Are you sure you want to delete your account? This action will schedule your account for permanent
deletion.
</Trans>
</p>
<div className={styles.infoBox}>
<p className={styles.infoBoxTitle}>
<Trans>Important information:</Trans>
</p>
<ul className={styles.infoList}>
<li>
<Trans>You can cancel the deletion process within 14 days</Trans>
</li>
<li>
<Trans>After 14 days, your account will be permanently deleted</Trans>
</li>
<li>
<Trans>Once deletion is processed, you cannot recover access to your account</Trans>
</li>
<li>
<Trans>You will not be able to delete your sent messages after your account is deleted</Trans>
</li>
</ul>
</div>
<p className={styles.disclaimer}>
<Trans>
If you want to export your data or delete your messages first, please visit the Privacy Dashboard
section in User Settings before proceeding.
</Trans>
</p>
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" submitting={isSubmitting} variant="danger-primary">
<Trans>Delete Account</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

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/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 16px;
}
.footer {
align-items: center;
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}
.description {
color: var(--text-primary);
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {Form} from '~/components/form/Form';
import styles from '~/components/modals/AccountDisableModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import {Routes} from '~/Routes';
import * as RouterUtils from '~/utils/RouterUtils';
export const AccountDisableModal = observer(() => {
const {t} = useLingui();
const form = useForm();
const onSubmit = async () => {
await UserActionCreators.disableAccount();
ModalActionCreators.pop();
RouterUtils.transitionTo(Routes.LOGIN);
};
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'form',
});
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit}>
<Modal.Header title={t`Disable Account`} />
<Modal.Content className={styles.content}>
<div className={styles.description}>
<Trans>
Disabling your account will log you out of all sessions. You can re-enable your account at any time by
logging in again.
</Trans>
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" submitting={isSubmitting} variant="danger-primary">
<Trans>Disable Account</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

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/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 16px;
}
.footer {
align-items: center;
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}
.selectContainer {
margin-bottom: 16px;
}
.checkboxLabel {
margin-bottom: 12px;
display: flex;
cursor: pointer;
align-items: center;
gap: 8px;
}
.checkboxRow {
margin-bottom: 12px;
}
.checkboxText {
font-size: 14px;
color: var(--text-secondary);
}
.scrollerContainer {
flex: 1;
min-height: 0;
}
.channelList {
display: flex;
flex-direction: column;
gap: 2px;
}
.emptyState {
padding: 16px 0;
text-align: center;
font-size: 14px;
color: var(--text-tertiary);
}
.categoryHeader {
margin-top: 8px;
padding: 4px 8px;
font-weight: 600;
color: var(--text-tertiary);
font-size: 12px;
text-transform: uppercase;
}
.channelRow {
display: flex;
align-items: center;
gap: 8px;
border-radius: 6px;
padding: 8px;
}
.channelIconContainer {
flex-shrink: 0;
}
.channelIcon {
height: 20px;
width: 20px;
color: var(--text-primary-muted);
}
.channelName {
flex: 1;
color: var(--text-primary);
}
.channelActions {
flex-shrink: 0;
}

View File

@@ -0,0 +1,198 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {MagnifyingGlassIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {ChannelTypes} from '~/Constants';
import {Input} from '~/components/form/Input';
import {Select, type SelectOption} from '~/components/form/Select';
import styles from '~/components/modals/AddFavoriteChannelModal.module.css';
import * as Modal from '~/components/modals/Modal';
import selectorStyles from '~/components/modals/shared/SelectorModalStyles.module.css';
import {Button} from '~/components/uikit/Button/Button';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import {Scroller} from '~/components/uikit/Scroller';
import type {ChannelRecord} from '~/records/ChannelRecord';
import ChannelStore from '~/stores/ChannelStore';
import FavoritesStore from '~/stores/FavoritesStore';
import GuildStore from '~/stores/GuildStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
interface ChannelWithCategory {
channel: ChannelRecord;
categoryName: string | null;
}
export const AddFavoriteChannelModal = observer(({categoryId}: {categoryId?: string | null} = {}) => {
const {t} = useLingui();
const guilds = GuildStore.getGuilds();
const firstGuildId = guilds.length > 0 ? guilds[0].id : null;
const [selectedGuildId, setSelectedGuildId] = React.useState<string | null>(firstGuildId);
const [hideMutedChannels, setHideMutedChannels] = React.useState(false);
const [searchQuery, setSearchQuery] = React.useState('');
const guildOptions: Array<SelectOption<string>> = React.useMemo(
() =>
guilds.map((guild) => ({
value: guild.id,
label: guild.name ?? 'Unknown Guild',
})),
[guilds],
);
const selectedGuild = selectedGuildId ? GuildStore.getGuild(selectedGuildId) : null;
const channels = React.useMemo(() => {
if (!selectedGuild) return [];
const guildChannels = ChannelStore.getGuildChannels(selectedGuild.id);
const result: Array<ChannelWithCategory> = [];
const query = searchQuery.toLowerCase().trim();
for (const channel of guildChannels) {
if (channel.type !== ChannelTypes.GUILD_TEXT && channel.type !== ChannelTypes.GUILD_VOICE) {
continue;
}
if (hideMutedChannels && UserGuildSettingsStore.isGuildOrChannelMuted(selectedGuild.id, channel.id)) {
continue;
}
if (query && !channel.name?.toLowerCase().includes(query)) {
continue;
}
let categoryName: string | null = null;
if (channel.parentId) {
const category = ChannelStore.getChannel(channel.parentId);
if (category) {
categoryName = category.name ?? null;
}
}
result.push({channel, categoryName});
}
return result.sort((a, b) => {
if (a.categoryName === b.categoryName) {
return (a.channel.position ?? 0) - (b.channel.position ?? 0);
}
if (!a.categoryName) return -1;
if (!b.categoryName) return 1;
return a.categoryName.localeCompare(b.categoryName);
});
}, [selectedGuild, hideMutedChannels, searchQuery]);
const handleToggleChannel = (channelId: string) => {
if (!selectedGuild) return;
const isAlreadyFavorite = !!FavoritesStore.getChannel(channelId);
if (isAlreadyFavorite) {
FavoritesStore.removeChannel(channelId);
} else {
FavoritesStore.addChannel(channelId, selectedGuild.id, categoryId ?? null);
}
};
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Add Favorite Channels`}>
<div className={selectorStyles.headerSearch}>
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search channels`}
leftIcon={<MagnifyingGlassIcon weight="bold" className={selectorStyles.searchIcon} />}
className={selectorStyles.headerSearchInput}
/>
</div>
</Modal.Header>
<Modal.Content className={styles.content}>
<div className={styles.selectContainer}>
<Select
label={t`Select a Community`}
value={selectedGuildId ?? ''}
options={guildOptions}
onChange={(value) => setSelectedGuildId(value || null)}
placeholder={t`Choose a community...`}
/>
</div>
{selectedGuild && (
<>
<Checkbox
className={styles.checkboxRow}
checked={hideMutedChannels}
onChange={(checked) => setHideMutedChannels(checked)}
>
<span className={styles.checkboxText}>{t`Hide muted channels`}</span>
</Checkbox>
<Scroller className={styles.scrollerContainer} key="add-favorite-channel-scroller">
<div className={styles.channelList}>
{channels.length === 0 ? (
<div className={styles.emptyState}>{t`No channels available`}</div>
) : (
channels.map(({channel, categoryName}, index) => {
const prevCategoryName = index > 0 ? channels[index - 1].categoryName : null;
const showCategoryHeader = categoryName !== prevCategoryName;
const isAlreadyFavorite = !!FavoritesStore.getChannel(channel.id);
return (
<React.Fragment key={channel.id}>
{showCategoryHeader && (
<div className={styles.categoryHeader}>{categoryName || t`Uncategorized`}</div>
)}
<div className={styles.channelRow}>
<div className={styles.channelIconContainer}>
{ChannelUtils.getIcon(channel, {
className: styles.channelIcon,
})}
</div>
<span className={styles.channelName}>{channel.name}</span>
<div className={styles.channelActions}>
<Button
variant={isAlreadyFavorite ? 'secondary' : 'primary'}
small={true}
onClick={() => handleToggleChannel(channel.id)}
>
{isAlreadyFavorite ? t`Remove` : t`Add`}
</Button>
</div>
</div>
</React.Fragment>
);
})
)}
</div>
</Scroller>
</>
)}
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop}>{t`Close`}</Button>
</Modal.Footer>
</Modal.Root>
);
});

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/>.
*/
.formContainer {
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@@ -0,0 +1,107 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useForm} from 'react-hook-form';
import * as FavoriteMemeActionCreators from '~/actions/FavoriteMemeActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {Form} from '~/components/form/Form';
import styles from '~/components/modals/AddFavoriteMemeModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {MemeFormFields} from '~/components/modals/meme-form/MemeFormFields';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
interface AddFavoriteMemeModalProps {
channelId: string;
messageId: string;
attachmentId?: string;
embedIndex?: number;
defaultName?: string;
defaultAltText?: string;
}
interface FormInputs {
name: string;
altText?: string;
tags: Array<string>;
}
export const AddFavoriteMemeModal = observer(function AddFavoriteMemeModal({
channelId,
messageId,
attachmentId,
embedIndex,
defaultName = '',
defaultAltText = '',
}: AddFavoriteMemeModalProps) {
const {t, i18n} = useLingui();
const form = useForm<FormInputs>({
defaultValues: {
name: defaultName,
altText: defaultAltText,
tags: [],
},
});
const onSubmit = React.useCallback(
async (data: FormInputs) => {
await FavoriteMemeActionCreators.createFavoriteMeme(i18n, {
channelId,
messageId,
attachmentId,
embedIndex,
name: data.name.trim(),
altText: data.altText?.trim() || undefined,
tags: data.tags.length > 0 ? data.tags : undefined,
});
ModalActionCreators.pop();
},
[channelId, messageId, attachmentId, embedIndex],
);
const {handleSubmit: handleSave} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Add to Saved Media`} />
<Modal.Content>
<Form form={form} onSubmit={handleSave}>
<div className={styles.formContainer}>
<MemeFormFields form={form} />
</div>
</Form>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleSave} disabled={!form.watch('name')?.trim() || form.formState.isSubmitting}>
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,71 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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;
overflow: hidden;
}
.scroller {
flex: 1;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
.description {
color: var(--text-primary-muted);
font-size: 14px;
line-height: 20px;
}
.requestsSection {
margin-top: 16px;
}
.requestsGroup {
margin-bottom: 24px;
}
.requestsHeader {
margin-bottom: 12px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary-muted);
}
.requestsList {
overflow: hidden;
border-radius: 12px;
background: var(--background-tertiary);
}
.requestDivider {
margin: 0 16px;
height: 1px;
background: var(--background-header-secondary);
opacity: 0.3;
}

View File

@@ -0,0 +1,104 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {RelationshipTypes} from '~/Constants';
import {AddFriendForm} from '~/components/channel/dm/AddFriendForm';
import {MobileFriendRequestItem} from '~/components/channel/friends/MobileFriendRequestItem';
import styles from '~/components/modals/AddFriendSheet.module.css';
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
import {Scroller} from '~/components/uikit/Scroller';
import RelationshipStore from '~/stores/RelationshipStore';
interface AddFriendSheetProps {
isOpen: boolean;
onClose: () => void;
}
export const AddFriendSheet: React.FC<AddFriendSheetProps> = observer(({isOpen, onClose}) => {
const {t} = useLingui();
const relationships = RelationshipStore.getRelationships();
const incomingRequests = relationships.filter((relation) => relation.type === RelationshipTypes.INCOMING_REQUEST);
const outgoingRequests = relationships.filter((relation) => relation.type === RelationshipTypes.OUTGOING_REQUEST);
const hasPendingRequests = incomingRequests.length > 0 || outgoingRequests.length > 0;
return (
<BottomSheet
isOpen={isOpen}
onClose={onClose}
snapPoints={[0, 1]}
initialSnap={1}
title={t`Add Friend`}
disablePadding
>
<div className={styles.container}>
<Scroller className={styles.scroller} key="add-friend-sheet-scroller">
<div className={styles.content}>
<AddFriendForm />
{hasPendingRequests && (
<div className={styles.requestsSection}>
{incomingRequests.length > 0 && (
<div className={styles.requestsGroup}>
<div className={styles.requestsHeader}>
{t`Incoming friend requests`} {incomingRequests.length}
</div>
<div className={styles.requestsList}>
{incomingRequests.map((request, index) => (
<React.Fragment key={request.id}>
<MobileFriendRequestItem
userId={request.id}
relationshipType={RelationshipTypes.INCOMING_REQUEST}
/>
{index < incomingRequests.length - 1 && <div className={styles.requestDivider} />}
</React.Fragment>
))}
</div>
</div>
)}
{outgoingRequests.length > 0 && (
<div className={styles.requestsGroup}>
<div className={styles.requestsHeader}>
{t`Outgoing friend requests`} {outgoingRequests.length}
</div>
<div className={styles.requestsList}>
{outgoingRequests.map((request, index) => (
<React.Fragment key={request.id}>
<MobileFriendRequestItem
userId={request.id}
relationshipType={RelationshipTypes.OUTGOING_REQUEST}
/>
{index < outgoingRequests.length - 1 && <div className={styles.requestDivider} />}
</React.Fragment>
))}
</div>
</div>
)}
</div>
)}
</div>
</Scroller>
</div>
</BottomSheet>
);
});

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/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {MagnifyingGlassIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {FriendSelector} from '~/components/common/FriendSelector';
import {Input} from '~/components/form/Input';
import inviteStyles from '~/components/modals/InviteModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {CopyLinkSection} from '~/components/modals/shared/CopyLinkSection';
import selectorStyles from '~/components/modals/shared/SelectorModalStyles.module.css';
import {Button} from '~/components/uikit/Button/Button';
import {useAddFriendsToGroupModalLogic} from '~/utils/modals/AddFriendsToGroupModalUtils';
interface AddFriendsToGroupModalProps {
channelId: string;
}
export const AddFriendsToGroupModal = observer((props: AddFriendsToGroupModalProps) => {
const {t} = useLingui();
const modalLogic = useAddFriendsToGroupModalLogic(props.channelId);
const hasSelection = modalLogic.selectedUserIds.length > 0;
const canAddFriends = hasSelection && !modalLogic.isAdding;
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Select Friends`}>
<p className={selectorStyles.subtitle}>
<Trans>You can add {modalLogic.remainingSlotsCount} more friends</Trans>
</p>
<div className={selectorStyles.headerSearch}>
<Input
value={modalLogic.searchQuery}
onChange={(e) => modalLogic.setSearchQuery(e.target.value)}
placeholder={t`Search friends`}
leftIcon={<MagnifyingGlassIcon size={20} weight="bold" className={selectorStyles.searchIcon} />}
className={selectorStyles.headerSearchInput}
rightElement={
<Button
onClick={modalLogic.handleAddFriends}
disabled={!canAddFriends}
submitting={modalLogic.isAdding}
compact
fitContent
>
<Trans>Add</Trans>
</Button>
}
/>
</div>
</Modal.Header>
<Modal.Content className={selectorStyles.selectorContent}>
<FriendSelector
selectedUserIds={modalLogic.selectedUserIds}
onToggle={modalLogic.handleToggle}
maxSelections={modalLogic.remainingSlotsCount}
excludeUserIds={modalLogic.currentMemberIds}
searchQuery={modalLogic.searchQuery}
onSearchQueryChange={modalLogic.setSearchQuery}
showSearchInput={false}
/>
</Modal.Content>
<Modal.Footer>
<CopyLinkSection
label={<Trans>or send an invite to a friend:</Trans>}
value={modalLogic.inviteLink ?? ''}
onCopy={modalLogic.inviteLink ? modalLogic.handleGenerateOrCopyInvite : undefined}
copied={modalLogic.inviteLinkCopied}
copyDisabled={modalLogic.isGeneratingInvite}
inputProps={{placeholder: t`Generate invite link`}}
rightElement={
!modalLogic.inviteLink ? (
<Button
onClick={modalLogic.handleGenerateOrCopyInvite}
submitting={modalLogic.isGeneratingInvite}
compact
fitContent
>
<Trans>Create</Trans>
</Button>
) : undefined
}
>
<p className={inviteStyles.expirationText}>
<Trans>Your invite expires in 24 hours</Trans>
</p>
</CopyLinkSection>
</Modal.Footer>
</Modal.Root>
);
});
AddFriendsToGroupModal.displayName = 'AddFriendsToGroupModal';

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/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 16px;
}
.landingContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.actionButtons {
display: flex;
width: 100%;
flex-direction: row;
gap: 12px;
}
.actionButton {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px 16px;
border: 1px solid var(--background-modifier-accent);
border-radius: 8px;
background: var(--background-secondary);
color: var(--text-primary);
cursor: pointer;
}
.actionButton:hover {
background: var(--background-secondary-alt);
border-color: var(--brand-primary-light);
}
:global(.theme-light) .actionButton:hover {
border-color: var(--brand-primary);
}
.actionIcon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--brand-primary);
color: var(--brand-primary-fill);
}
.actionIcon > svg {
width: 24px;
height: 24px;
}
.actionLabel {
font-weight: 600;
font-size: 14px;
text-align: center;
}
.formContainer {
display: flex;
flex-direction: column;
gap: 12px;
}
.formContainer > p {
margin: 0;
}
.iconSection {
display: flex;
flex-direction: column;
gap: 16px;
}
.iconSectionInner {
display: block;
}
.iconLabel {
margin-bottom: 8px;
font-weight: 500;
color: var(--text-primary);
font-size: 14px;
}
.iconPreview {
display: flex;
align-items: center;
gap: 16px;
}
.iconImage {
height: 80px;
width: 80px;
flex-shrink: 0;
border-radius: 50%;
background-position: center;
background-size: cover;
}
.iconPlaceholder {
display: flex;
height: 80px;
width: 80px;
flex-shrink: 0;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 50%;
border: 1px solid var(--background-modifier-accent);
background: var(--background-tertiary);
container-type: size;
}
.iconInitials {
user-select: none;
-webkit-user-select: none;
font-weight: 600;
color: var(--text-primary);
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
width: 100%;
text-align: center;
font-size: clamp(0.85rem, 45cqi, 1.35rem);
letter-spacing: 0.06em;
}
.iconPlaceholder[data-initials-length='medium'] .iconInitials {
font-size: clamp(0.85rem, 38cqi, 1.11rem);
letter-spacing: 0.02em;
}
.iconPlaceholder[data-initials-length='long'] .iconInitials {
font-size: clamp(0.85rem, 32cqi, 0.87rem);
letter-spacing: -0.02em;
}
.iconActions {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}
.iconButtons {
display: flex;
flex-direction: column;
gap: 8px;
}
@media (min-width: 640px) {
.iconButtons {
flex-direction: row;
}
}
.iconHint {
color: var(--text-primary-muted);
font-size: 14px;
}
.iconError {
margin-top: 8px;
color: var(--status-danger);
font-size: 14px;
}
.guidelines {
color: var(--text-primary-muted);
font-size: 12px;
}

View File

@@ -0,0 +1,442 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {HouseIcon, LinkIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React, {useState} from 'react';
import {useForm} from 'react-hook-form';
import * as GuildActionCreators from '~/actions/GuildActionCreators';
import * as InviteActionCreators from '~/actions/InviteActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ExternalLink} from '~/components/common/ExternalLink';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import styles from '~/components/modals/AddGuildModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import {Routes} from '~/Routes';
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {openFilePicker} from '~/utils/FilePickerUtils';
import {getInitialsLength} from '~/utils/GuildInitialsUtils';
import * as InviteUtils from '~/utils/InviteUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import * as StringUtils from '~/utils/StringUtils';
import {AssetCropModal, AssetType} from './AssetCropModal';
export type AddGuildModalView = 'landing' | 'create_guild' | 'join_guild';
interface GuildCreateFormInputs {
icon?: string | null;
name: string;
}
interface GuildJoinFormInputs {
code: string;
}
interface ModalFooterContextValue {
setFooterContent: (content: React.ReactNode) => void;
}
const ModalFooterContext = React.createContext<ModalFooterContextValue | null>(null);
const ActionButton = ({onClick, icon, label}: {onClick: () => void; icon: React.ReactNode; label: string}) => (
<button type="button" onClick={onClick} className={styles.actionButton}>
<span className={styles.actionIcon}>{icon}</span>
<span className={styles.actionLabel}>{label}</span>
</button>
);
export const AddGuildModal = observer(({initialView = 'landing'}: {initialView?: AddGuildModalView} = {}) => {
const {t} = useLingui();
const [view, setView] = useState<AddGuildModalView>(initialView);
const [footerContent, setFooterContent] = useState<React.ReactNode>(null);
const getTitle = () => {
switch (view) {
case 'landing':
return t`Add a Community`;
case 'create_guild':
return t`Create a Community`;
case 'join_guild':
return t`Join a Community`;
}
};
const contextValue = React.useMemo(
() => ({
setFooterContent,
}),
[],
);
return (
<ModalFooterContext.Provider value={contextValue}>
<Modal.Root size="small" centered>
<Modal.Header title={getTitle()} />
<Modal.Content className={styles.content}>
{view === 'landing' && <LandingView onViewChange={setView} />}
{view === 'create_guild' && <GuildCreateForm />}
{view === 'join_guild' && <GuildJoinForm />}
</Modal.Content>
{footerContent && <Modal.Footer>{footerContent}</Modal.Footer>}
</Modal.Root>
</ModalFooterContext.Provider>
);
});
const LandingView = observer(({onViewChange}: {onViewChange: (view: AddGuildModalView) => void}) => {
const {t} = useLingui();
return (
<div className={styles.landingContainer}>
<p>
<Trans>Create a new community or join an existing one.</Trans>
</p>
<div className={styles.actionButtons}>
<ActionButton
onClick={() => onViewChange('create_guild')}
icon={<HouseIcon size={24} />}
label={t`Create Community`}
/>
<ActionButton
onClick={() => onViewChange('join_guild')}
icon={<LinkIcon size={24} weight="regular" />}
label={t`Join Community`}
/>
</div>
</div>
);
});
const GuildCreateForm = observer(() => {
const {t} = useLingui();
const [previewIconUrl, setPreviewIconUrl] = React.useState<string | null>(null);
const form = useForm<GuildCreateFormInputs>({defaultValues: {name: ''}});
const modalFooterContext = React.useContext(ModalFooterContext);
const formId = React.useId();
const guildNamePlaceholders = React.useMemo(
() => [
t`The Midnight Gamers`,
t`Study Buddies United`,
t`Creative Minds Collective`,
t`Bookworms Anonymous`,
t`Artists' Corner`,
t`Dev Den`,
t`Band Practice Room`,
t`Volunteer Heroes`,
t`Hobby Haven`,
t`Class of '24`,
t`Team Alpha`,
t`Family Reunion`,
t`Project X`,
t`Weekend Warriors`,
t`Movie Night Crew`,
t`Neighborhood Watch`,
t`Professional Peers`,
t`Support Circle`,
t`Coffee Chat`,
t`Game Night`,
t`Study Hall`,
t`Creative Writing Club`,
t`Photography Club`,
t`Music Lovers`,
t`Fitness Friends`,
t`Foodie Friends`,
t`Travel Buddies`,
t`Movie Club`,
t`Board Game Night`,
t`Coding Crew`,
t`Art Club`,
t`Book Club`,
t`Sports Fans`,
t`Gaming Guild`,
t`Study Group`,
t`Work Friends`,
t`Family Chat`,
t`Friends Forever`,
t`The Squad`,
t`Our Hangout`,
],
[],
);
const randomPlaceholder = React.useMemo(() => {
const randomIndex = Math.floor(Math.random() * guildNamePlaceholders.length);
return guildNamePlaceholders[randomIndex];
}, [guildNamePlaceholders]);
const nameValue = form.watch('name');
const initials = React.useMemo(() => {
const raw = (nameValue || '').trim();
if (!raw) return '';
return StringUtils.getInitialsFromName(raw);
}, [nameValue]);
const initialsLength = React.useMemo(() => (initials ? getInitialsLength(initials) : null), [initials]);
const handleIconUpload = React.useCallback(async () => {
try {
const [file] = await openFilePicker({accept: 'image/*'});
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
ToastActionCreators.createToast({
type: 'error',
children: t`Icon file is too large. Please choose a file smaller than 10MB.`,
});
return;
}
if (file.type === 'image/gif') {
ToastActionCreators.createToast({
type: 'error',
children: t`Animated icons are not supported when creating a new community. Please use JPEG, PNG, or WebP.`,
});
return;
}
const base64 = await AvatarUtils.fileToBase64(file);
ModalActionCreators.push(
modal(() => (
<AssetCropModal
assetType={AssetType.GUILD_ICON}
imageUrl={base64}
sourceMimeType={file.type}
onCropComplete={(croppedBlob) => {
const reader = new FileReader();
reader.onload = () => {
const croppedBase64 = reader.result as string;
form.setValue('icon', croppedBase64);
setPreviewIconUrl(croppedBase64);
form.clearErrors('icon');
};
reader.onerror = () => {
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to process the cropped image. Please try again.`,
});
};
reader.readAsDataURL(croppedBlob);
}}
onSkip={() => {
form.setValue('icon', base64);
setPreviewIconUrl(base64);
form.clearErrors('icon');
}}
/>
)),
);
} catch {
ToastActionCreators.createToast({
type: 'error',
children: <Trans>That image is invalid. Please try another one.</Trans>,
});
}
}, [form]);
const onSubmit = React.useCallback(async (data: GuildCreateFormInputs) => {
const guild = await GuildActionCreators.create({
icon: data.icon,
name: data.name,
});
ModalActionCreators.pop();
RouterUtils.transitionTo(Routes.guildChannel(guild.id, guild.system_channel_id || undefined));
}, []);
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
React.useEffect(() => {
const isNameEmpty = !nameValue?.trim();
modalFooterContext?.setFooterContent(
<>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleSubmit} submitting={isSubmitting} disabled={isNameEmpty}>
<Trans>Create Community</Trans>
</Button>
</>,
);
return () => modalFooterContext?.setFooterContent(null);
}, [handleSubmit, isSubmitting, modalFooterContext, nameValue]);
const handleClearIcon = React.useCallback(() => {
form.setValue('icon', null);
setPreviewIconUrl(null);
}, [form]);
return (
<div className={styles.formContainer}>
<p>
<Trans>Create a community for you and your friends to chat.</Trans>
</p>
<Form form={form} onSubmit={handleSubmit} id={formId} aria-label={t`Create community form`}>
<div className={styles.iconSection}>
<div className={styles.iconSectionInner}>
<div className={styles.iconLabel}>
<Trans>Community Icon</Trans>
</div>
<div className={styles.iconPreview}>
{previewIconUrl ? (
<div className={styles.iconImage} style={{backgroundImage: `url(${previewIconUrl})`}} />
) : (
<div className={styles.iconPlaceholder} data-initials-length={initialsLength}>
{initials ? <span className={styles.iconInitials}>{initials}</span> : null}
</div>
)}
<div className={styles.iconActions}>
<div className={styles.iconButtons}>
<Button variant="secondary" small={true} onClick={handleIconUpload}>
{previewIconUrl ? <Trans>Change Icon</Trans> : <Trans>Upload Icon</Trans>}
</Button>
{previewIconUrl && (
<Button variant="secondary" small={true} onClick={handleClearIcon}>
<Trans>Remove Icon</Trans>
</Button>
)}
</div>
<div className={styles.iconHint}>
<Trans>JPEG, PNG, WebP. Max 10MB. Recommended: 512×512px</Trans>
</div>
</div>
</div>
{form.formState.errors.icon?.message && (
<p className={styles.iconError}>{form.formState.errors.icon.message}</p>
)}
</div>
<Input
{...form.register('name')}
autoFocus={true}
error={form.formState.errors.name?.message}
label={t`Community Name`}
minLength={1}
maxLength={100}
name="name"
placeholder={randomPlaceholder}
required={true}
type="text"
/>
<p className={styles.guidelines}>
<Trans>
By creating a community, you agree to follow and uphold the{' '}
<ExternalLink href={Routes.guidelines()}>Fluxer Community Guidelines</ExternalLink>.
</Trans>
</p>
</div>
</Form>
</div>
);
});
const GuildJoinForm = observer(() => {
const {t, i18n} = useLingui();
const form = useForm<GuildJoinFormInputs>({defaultValues: {code: ''}});
const modalFooterContext = React.useContext(ModalFooterContext);
const formId = React.useId();
const randomInviteCode = React.useMemo(() => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
const length = Math.floor(Math.random() * 7) + 6;
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}, []);
const onSubmit = React.useCallback(
async (data: GuildJoinFormInputs) => {
const parsedCode = InviteUtils.findInvite(data.code) ?? data.code;
const invite = await InviteActionCreators.fetch(parsedCode);
await InviteActionCreators.acceptAndTransitionToChannel(invite.code, i18n);
ModalActionCreators.pop();
},
[i18n],
);
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'code',
});
const codeValue = form.watch('code');
React.useEffect(() => {
const isCodeEmpty = !codeValue?.trim();
modalFooterContext?.setFooterContent(
<>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleSubmit} submitting={isSubmitting} disabled={isCodeEmpty}>
<Trans>Join Community</Trans>
</Button>
</>,
);
return () => modalFooterContext?.setFooterContent(null);
}, [handleSubmit, isSubmitting, modalFooterContext, codeValue]);
return (
<div className={styles.formContainer}>
<p>
<Trans>Enter the invite link to join a community.</Trans>
</p>
<Form form={form} onSubmit={handleSubmit} id={formId} aria-label={t`Join community form`}>
<div className={styles.iconSection}>
<Input
{...form.register('code')}
autoFocus={true}
error={form.formState.errors.code?.message}
label={t`Invite Link`}
minLength={1}
maxLength={100}
name="code"
placeholder={`${RuntimeConfigStore.inviteEndpoint}/${randomInviteCode}`}
required={true}
type="text"
/>
</div>
</Form>
</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/>.
*/
.formContainer {
display: flex;
flex-direction: column;
gap: 16px;
}

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 {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useForm} from 'react-hook-form';
import * as GuildStickerActionCreators from '~/actions/GuildStickerActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {STICKER_MAX_SIZE} from '~/Constants';
import {Form} from '~/components/form/Form';
import styles from '~/components/modals/AddGuildStickerModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {StickerFormFields} from '~/components/modals/sticker-form/StickerFormFields';
import {StickerPreview} from '~/components/modals/sticker-form/StickerPreview';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import * as ImageCropUtils from '~/utils/ImageCropUtils';
interface AddGuildStickerModalProps {
guildId: string;
file: File;
onSuccess: () => void;
}
interface FormInputs {
name: string;
description: string;
tags: Array<string>;
}
export const AddGuildStickerModal = observer(function AddGuildStickerModal({
guildId,
file,
onSuccess,
}: AddGuildStickerModalProps) {
const {t} = useLingui();
const [isProcessing, setIsProcessing] = React.useState(false);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const form = useForm<FormInputs>({
defaultValues: {
name: GuildStickerActionCreators.sanitizeStickerName(file.name),
description: '',
tags: [],
},
});
React.useEffect(() => {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
const onSubmit = React.useCallback(
async (data: FormInputs) => {
setIsProcessing(true);
try {
const base64Image = await ImageCropUtils.optimizeStickerImage(file, STICKER_MAX_SIZE, 320);
await GuildStickerActionCreators.create(guildId, {
name: data.name.trim(),
description: data.description.trim(),
tags: data.tags.length > 0 ? data.tags : [],
image: base64Image,
});
onSuccess();
ModalActionCreators.pop();
} catch (error: any) {
console.error('Failed to create sticker:', error);
form.setError('name', {
message: error.message || t`Failed to create sticker`,
});
setIsProcessing(false);
}
},
[guildId, file, onSuccess, form],
);
const {handleSubmit: handleSave} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Add Sticker`} />
<Modal.Content>
<Form form={form} onSubmit={handleSave} aria-label={t`Add sticker form`}>
<div className={styles.formContainer}>
{previewUrl && <StickerPreview imageUrl={previewUrl} altText={form.watch('name') || file.name} />}
<StickerFormFields form={form} disabled={isProcessing} />
</div>
</Form>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()} disabled={isProcessing}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleSave} disabled={!form.watch('name')?.trim() || isProcessing} submitting={isProcessing}>
<Trans>Create</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,255 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {ImageCropModal} from '~/components/modals/ImageCropModal';
export enum AssetType {
AVATAR = 'avatar',
GUILD_ICON = 'guild_icon',
CHANNEL_ICON = 'channel_icon',
GUILD_BANNER = 'guild_banner',
PROFILE_BANNER = 'profile_banner',
SPLASH = 'splash',
EMBED_SPLASH = 'embed_splash',
}
interface AssetConfig {
aspectRatio: number;
cropShape: 'rect' | 'round';
maxWidth: number;
maxHeight: number;
minWidth: number;
minHeight: number;
sizeLimitBytes: number;
}
const ASSET_CONFIGS: Record<AssetType, AssetConfig> = {
[AssetType.AVATAR]: {
aspectRatio: 1,
cropShape: 'round',
maxWidth: 1024,
maxHeight: 1024,
minWidth: 256,
minHeight: 256,
sizeLimitBytes: 10 * 1024 * 1024,
},
[AssetType.GUILD_ICON]: {
aspectRatio: 1,
cropShape: 'round',
maxWidth: 1024,
maxHeight: 1024,
minWidth: 256,
minHeight: 256,
sizeLimitBytes: 10 * 1024 * 1024,
},
[AssetType.CHANNEL_ICON]: {
aspectRatio: 1,
cropShape: 'round',
maxWidth: 1024,
maxHeight: 1024,
minWidth: 256,
minHeight: 256,
sizeLimitBytes: 10 * 1024 * 1024,
},
[AssetType.GUILD_BANNER]: {
aspectRatio: 16 / 9,
cropShape: 'rect',
maxWidth: 2048,
maxHeight: 1152,
minWidth: 960,
minHeight: 540,
sizeLimitBytes: 10 * 1024 * 1024,
},
[AssetType.PROFILE_BANNER]: {
aspectRatio: 17 / 6,
cropShape: 'rect',
maxWidth: 2048,
maxHeight: 723,
minWidth: 680,
minHeight: 240,
sizeLimitBytes: 10 * 1024 * 1024,
},
[AssetType.SPLASH]: {
aspectRatio: 16 / 9,
cropShape: 'rect',
maxWidth: 2048,
maxHeight: 1152,
minWidth: 960,
minHeight: 540,
sizeLimitBytes: 10 * 1024 * 1024,
},
[AssetType.EMBED_SPLASH]: {
aspectRatio: 16 / 9,
cropShape: 'rect',
maxWidth: 2048,
maxHeight: 1152,
minWidth: 960,
minHeight: 540,
sizeLimitBytes: 10 * 1024 * 1024,
},
};
export const getAssetConfig = (type: AssetType): AssetConfig => ASSET_CONFIGS[type];
const getTitle = (assetType: AssetType): React.ReactNode => {
switch (assetType) {
case AssetType.AVATAR:
return <Trans>Crop Avatar</Trans>;
case AssetType.GUILD_ICON:
return <Trans>Crop Community Icon</Trans>;
case AssetType.CHANNEL_ICON:
return <Trans>Crop Group Icon</Trans>;
case AssetType.GUILD_BANNER:
return <Trans>Crop Banner</Trans>;
case AssetType.PROFILE_BANNER:
return <Trans>Crop Profile Banner</Trans>;
case AssetType.SPLASH:
return <Trans>Crop Invite Background</Trans>;
case AssetType.EMBED_SPLASH:
return <Trans>Crop Chat Embed Background</Trans>;
}
};
const getDescription = (assetType: AssetType): React.ReactNode => {
const config = getAssetConfig(assetType);
switch (assetType) {
case AssetType.AVATAR:
return (
<Trans>
Drag to reposition your avatar and use the scroll wheel or pinch to zoom. The recommended minimum size is{' '}
256×256 pixels.
</Trans>
);
case AssetType.GUILD_ICON:
return (
<Trans>
Drag to reposition your community icon and use the scroll wheel or pinch to zoom. The recommended minimum size
is 256×256 pixels.
</Trans>
);
case AssetType.CHANNEL_ICON:
return (
<Trans>
Drag to reposition your group icon and use the scroll wheel or pinch to zoom. The recommended minimum size is
256×256 pixels.
</Trans>
);
case AssetType.GUILD_BANNER:
return (
<Trans>
Drag to reposition your banner and use the scroll wheel or pinch to zoom. The recommended minimum size is{' '}
{config.minWidth}×{config.minHeight} pixels (16:9).
</Trans>
);
case AssetType.PROFILE_BANNER:
return (
<Trans>
Drag to reposition your banner and use the scroll wheel or pinch to zoom. The recommended minimum size is{' '}
{config.minWidth}×{config.minHeight} pixels (17:6).
</Trans>
);
case AssetType.SPLASH:
return (
<Trans>
Drag to reposition your invite background and use the scroll wheel or pinch to zoom. The recommended minimum{' '}
size is {config.minWidth}×{config.minHeight} pixels (16:9).
</Trans>
);
case AssetType.EMBED_SPLASH:
return (
<Trans>
Drag to reposition your chat embed background and use the scroll wheel or pinch to zoom. The recommended
minimum size is {config.minWidth}×{config.minHeight} pixels (16:9).
</Trans>
);
}
};
const getSaveButtonLabel = (assetType: AssetType): React.ReactNode => {
switch (assetType) {
case AssetType.AVATAR:
return <Trans>Save Avatar</Trans>;
case AssetType.GUILD_ICON:
case AssetType.CHANNEL_ICON:
return <Trans>Save Icon</Trans>;
case AssetType.GUILD_BANNER:
case AssetType.PROFILE_BANNER:
return <Trans>Save Banner</Trans>;
case AssetType.SPLASH:
case AssetType.EMBED_SPLASH:
return <Trans>Save Background</Trans>;
}
};
const getErrorMessage = (assetType: AssetType): string => {
const {t} = useLingui();
switch (assetType) {
case AssetType.AVATAR:
return t`Failed to crop avatar. Please try again.`;
case AssetType.GUILD_ICON:
case AssetType.CHANNEL_ICON:
return t`Failed to crop icon. Please try again.`;
case AssetType.GUILD_BANNER:
case AssetType.PROFILE_BANNER:
return t`Failed to crop banner. Please try again.`;
case AssetType.SPLASH:
case AssetType.EMBED_SPLASH:
return t`Failed to crop background. Please try again.`;
}
};
interface AssetCropModalProps {
imageUrl: string;
sourceMimeType: string;
assetType: AssetType;
onCropComplete: (croppedImageBlob: Blob) => void;
onSkip?: () => void;
}
export const AssetCropModal: React.FC<AssetCropModalProps> = observer(
({imageUrl, sourceMimeType, assetType, onCropComplete, onSkip}) => {
const config = getAssetConfig(assetType);
const hasFlexibleHeight =
assetType === AssetType.GUILD_BANNER || assetType === AssetType.SPLASH || assetType === AssetType.EMBED_SPLASH;
return (
<ImageCropModal
imageUrl={imageUrl}
sourceMimeType={sourceMimeType}
onCropComplete={onCropComplete}
onSkip={onSkip}
title={getTitle(assetType)}
description={getDescription(assetType)}
saveButtonLabel={getSaveButtonLabel(assetType)}
errorMessage={getErrorMessage(assetType)}
aspectRatio={config.aspectRatio}
cropShape={config.cropShape}
maxWidth={config.maxWidth}
maxHeight={config.maxHeight}
sizeLimitBytes={config.sizeLimitBytes}
minHeightRatio={hasFlexibleHeight ? 0.5 : undefined}
maxHeightRatio={hasFlexibleHeight ? 1 : undefined}
/>
);
},
);

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/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 12px;
}

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 {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {MessageAttachmentFlags} from '~/Constants';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import {Switch} from '~/components/form/Switch';
import styles from '~/components/modals/AttachmentEditModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {type CloudAttachment, CloudUpload} from '~/lib/CloudUpload';
interface FormInputs {
filename: string;
spoiler: boolean;
}
export const AttachmentEditModal = observer(
({channelId, attachment}: {channelId: string; attachment: CloudAttachment}) => {
const {t} = useLingui();
const defaultSpoiler = (attachment.flags & MessageAttachmentFlags.IS_SPOILER) !== 0;
const form = useForm<FormInputs>({
defaultValues: {
filename: attachment.filename,
spoiler: defaultSpoiler,
},
});
const onSubmit = async (data: FormInputs) => {
const nextFlags = data.spoiler
? attachment.flags | MessageAttachmentFlags.IS_SPOILER
: attachment.flags & ~MessageAttachmentFlags.IS_SPOILER;
CloudUpload.updateAttachment(channelId, attachment.id, {
filename: data.filename,
flags: nextFlags,
spoiler: data.spoiler,
});
ModalActionCreators.pop();
};
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={onSubmit} aria-label={t`Edit attachment form`}>
<Modal.Header title={attachment.filename} />
<Modal.Content className={styles.content}>
<Input
{...form.register('filename')}
autoFocus={true}
label={t`Filename`}
minLength={1}
maxLength={512}
required={true}
type="text"
spellCheck={false}
/>
<Switch
label={t`Mark as spoiler`}
value={form.watch('spoiler')}
onChange={(value) => form.setValue('spoiler', value)}
/>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
},
);

View File

@@ -0,0 +1,40 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
interface AudioPlaybackPermissionModalProps {
onStartAudio: () => Promise<void>;
}
export const AudioPlaybackPermissionModal = observer(({onStartAudio}: AudioPlaybackPermissionModalProps) => {
const {t} = useLingui();
return (
<ConfirmModal
title={t`Browser Audio Required`}
description={t`Your browser requires user interaction before audio can be played. Click the button below to enable voice chat.`}
primaryText={t`Enable Audio`}
primaryVariant="primary"
secondaryText={false}
onPrimary={onStartAudio}
/>
);
});

View File

@@ -0,0 +1,378 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.selectionSection {
position: relative;
display: flex;
flex-direction: column;
gap: 16px;
}
.dragOverlay {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: 2px dashed var(--brand-primary);
background: color-mix(in srgb, var(--brand-primary) 10%, transparent);
}
.dragContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.dragIcon {
color: var(--brand-primary);
}
.dragText {
font-weight: 500;
font-size: 18px;
color: var(--brand-primary);
}
.freeUserContainer {
display: flex;
flex-direction: column;
gap: 12px;
}
.customBackgroundWrapper {
position: relative;
}
.actionButtons {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 8px;
opacity: 0;
transition: opacity 0.2s;
}
.customBackgroundWrapper:hover .actionButtons {
opacity: 1;
}
.actionButton {
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
padding: 8px;
transition: background-color 0.2s;
cursor: pointer;
}
.actionButton:hover {
background: rgba(0, 0, 0, 0.8);
}
.actionButtonIcon {
color: white;
}
.builtInGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.uploadPlaceholder {
position: relative;
aspect-ratio: 16 / 9;
cursor: pointer;
overflow: hidden;
border-radius: 8px;
border: 2px dashed;
border-color: var(--background-modifier-accent);
transition:
opacity 0.2s,
border-color 0.2s;
}
.uploadPlaceholder:hover {
opacity: 0.75;
}
.uploadPlaceholderContent {
display: flex;
height: 100%;
width: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: var(--background-secondary);
}
.uploadIcon {
color: var(--text-primary-muted);
}
.uploadTextContainer {
text-align: center;
}
.uploadTitle {
font-weight: 500;
font-size: 14px;
color: var(--text-primary);
}
.uploadHint {
font-size: 12px;
color: var(--text-primary-muted);
}
.premiumGrid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 12px;
}
@media (min-width: 640px) {
.premiumGrid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.premiumGrid {
grid-template-columns: repeat(3, 1fr);
}
}
.backgroundItem {
position: relative;
aspect-ratio: 16 / 9;
cursor: pointer;
overflow: hidden;
border-radius: 8px;
border: 2px solid;
transition:
opacity 0.2s,
border-color 0.2s;
}
.backgroundItem:hover {
opacity: 0.75;
}
.backgroundItemContent {
display: flex;
height: 100%;
width: 100%;
align-items: center;
justify-content: center;
background: var(--background-secondary);
}
.backgroundItemInner {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.backgroundItemIcon {
color: var(--text-primary-muted);
}
.backgroundItemText {
text-align: center;
}
.backgroundItemName {
font-weight: 500;
font-size: 14px;
color: var(--text-primary);
}
.backgroundItemDesc {
font-size: 12px;
color: var(--text-primary-muted);
}
.loadingContainer {
display: flex;
height: 100%;
width: 100%;
align-items: center;
justify-content: center;
background: var(--background-secondary);
}
.spinner {
height: 32px;
width: 32px;
animation: spin 1s linear infinite;
border-radius: 50%;
border: 2px solid var(--background-modifier-accent);
border-top-color: var(--brand-primary);
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.errorContainer {
display: flex;
height: 100%;
width: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--background-secondary);
padding: 16px;
}
.errorIcon {
color: var(--status-danger);
}
.errorText {
text-align: center;
font-size: 12px;
color: var(--text-primary-muted);
}
.errorButton {
border-radius: 4px;
background: var(--background-modifier-accent);
padding: 4px 8px;
font-size: 12px;
color: var(--text-primary);
cursor: pointer;
}
.errorButton:hover {
background: var(--background-modifier-hover);
}
.backgroundImage {
height: 100%;
width: 100%;
object-fit: cover;
}
.imageOverlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0);
transition: background-color 0.2s;
}
.backgroundItem:hover .imageOverlay {
background: rgba(0, 0, 0, 0.2);
}
.deleteButton {
position: absolute;
top: 8px;
right: 8px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
padding: 8px;
opacity: 0;
transition:
opacity 0.2s,
background-color 0.2s;
cursor: pointer;
}
.backgroundItem:hover .deleteButton {
opacity: 1;
}
.deleteButton:hover {
background: rgba(0, 0, 0, 0.8);
}
.deleteButtonIcon {
color: white;
}
.selectedBadge {
position: absolute;
top: 8px;
left: 8px;
border-radius: 50%;
background: var(--brand-primary);
padding: 6px;
}
.selectedIcon {
color: white;
}
.fileInput {
display: none;
}
.statsText {
text-align: center;
font-size: 14px;
color: var(--text-primary-muted);
}
.infoText {
text-align: center;
font-size: 12px;
color: var(--text-primary-muted);
}
.premiumUpsell {
border-radius: 8px;
border: 1px solid var(--background-modifier-accent);
background: var(--background-secondary);
padding: 16px;
}
.premiumHeader {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.premiumIcon {
flex-shrink: 0;
}
.premiumTitle {
font-weight: 500;
font-size: 14px;
color: var(--text-primary);
}
.premiumDesc {
margin-bottom: 12px;
font-size: 14px;
color: var(--text-primary-muted);
}

View File

@@ -0,0 +1,687 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {
ArrowsClockwiseIcon,
CheckIcon,
CrownIcon,
EyeSlashIcon,
PlusIcon,
SparkleIcon,
TrashIcon,
WarningCircleIcon,
} 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 PremiumModalActionCreators from '~/actions/PremiumModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as VoiceSettingsActionCreators from '~/actions/VoiceSettingsActionCreators';
import styles from '~/components/modals/BackgroundImageGalleryModal.module.css';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {MenuItem} from '~/components/uikit/ContextMenu/MenuItem';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import UserStore from '~/stores/UserStore';
import VoiceSettingsStore, {BLUR_BACKGROUND_ID, NONE_BACKGROUND_ID} from '~/stores/VoiceSettingsStore';
import * as BackgroundImageDB from '~/utils/BackgroundImageDB';
import {openFilePicker} from '~/utils/FilePickerUtils';
interface BackgroundImage {
id: string;
createdAt: number;
}
interface BuiltInBackground {
id: string;
type: 'none' | 'blur' | 'upload';
name: string;
icon: React.ComponentType<any>;
description: string;
}
type BackgroundItemType = BuiltInBackground | BackgroundImage;
const getBuiltInBackgrounds = (isReplace: boolean): ReadonlyArray<BuiltInBackground> => {
const {t} = useLingui();
return [
{
id: NONE_BACKGROUND_ID,
type: 'none',
name: t`No Background`,
icon: EyeSlashIcon,
description: t`Show your actual background`,
},
{
id: BLUR_BACKGROUND_ID,
type: 'blur',
name: t`Blur`,
icon: SparkleIcon,
description: t`Blur your background`,
},
{
id: 'upload',
type: 'upload',
name: isReplace ? t`Replace` : t`Upload`,
icon: PlusIcon,
description: isReplace ? t`Replace your custom background` : t`Add a custom background`,
},
];
};
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'video/mp4'];
interface BackgroundItemProps {
background: BackgroundItemType;
isSelected: boolean;
onSelect: (background: BackgroundItemType) => void;
onContextMenu?: (event: React.MouseEvent, background: BackgroundImage) => void;
onDelete?: (background: BackgroundImage) => void;
}
const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
({background, isSelected, onSelect, onContextMenu, onDelete}) => {
const {t} = useLingui();
const isBuiltIn = 'type' in background;
const Icon = isBuiltIn ? background.icon : undefined;
const [imageUrl, setImageUrl] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(!isBuiltIn);
const [hasError, setHasError] = React.useState(false);
React.useEffect(() => {
if (isBuiltIn) return;
let objectUrl: string | null = null;
setIsLoading(true);
setHasError(false);
BackgroundImageDB.getBackgroundImageURL(background.id)
.then((url) => {
objectUrl = url;
setImageUrl(url);
setIsLoading(false);
})
.catch((error) => {
console.error('Failed to load background image:', error);
setHasError(true);
setIsLoading(false);
});
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [isBuiltIn, background.id]);
const handleClick = React.useCallback(() => {
onSelect(background);
}, [background, onSelect]);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(background);
}
},
[background, onSelect],
);
const handleContextMenu = React.useCallback(
(e: React.MouseEvent) => {
if (!isBuiltIn) {
onContextMenu?.(e, background as BackgroundImage);
}
},
[isBuiltIn, background, onContextMenu],
);
const handleDelete = React.useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isBuiltIn) {
onDelete?.(background as BackgroundImage);
}
},
[isBuiltIn, background, onDelete],
);
const handleRetry = React.useCallback(() => {
setHasError(false);
setIsLoading(true);
BackgroundImageDB.getBackgroundImageURL(background.id)
.then((url) => {
setImageUrl(url);
setIsLoading(false);
})
.catch((error) => {
console.error('Failed to load background image:', error);
setHasError(true);
setIsLoading(false);
});
}, [background.id]);
return (
<div
className={styles.backgroundItem}
style={{
borderColor: isSelected ? 'var(--brand-primary)' : 'var(--background-modifier-accent)',
}}
onClick={handleClick}
onKeyDown={handleKeyDown}
onContextMenu={handleContextMenu}
role="button"
tabIndex={0}
aria-pressed={isSelected}
>
{isBuiltIn ? (
<div className={styles.backgroundItemContent}>
<div className={styles.backgroundItemInner}>
{Icon && (
<Icon size={24} weight={isSelected ? 'fill' : 'regular'} className={styles.backgroundItemIcon} />
)}
<div className={styles.backgroundItemText}>
<div className={styles.backgroundItemName}>{background.name}</div>
<div className={styles.backgroundItemDesc}>{background.description}</div>
</div>
</div>
</div>
) : (
<>
{isLoading ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
</div>
) : hasError ? (
<div className={styles.errorContainer}>
<WarningCircleIcon size={24} weight="fill" className={styles.errorIcon} />
<div className={styles.errorText}>
<Trans>Failed to load</Trans>
</div>
<FocusRing offset={-2}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRetry();
}}
className={styles.errorButton}
>
<Trans>Retry</Trans>
</button>
</FocusRing>
</div>
) : imageUrl ? (
<img src={imageUrl} alt="Background" className={styles.backgroundImage} />
) : null}
<div className={styles.imageOverlay} />
{!isBuiltIn && onDelete && !isLoading && !hasError && (
<Tooltip text={t`Remove background`}>
<FocusRing offset={-2}>
<button
type="button"
onClick={handleDelete}
className={styles.deleteButton}
aria-label={t`Remove background`}
>
<TrashIcon size={16} weight="bold" className={styles.deleteButtonIcon} />
</button>
</FocusRing>
</Tooltip>
)}
</>
)}
{isSelected && (
<div className={styles.selectedBadge}>
<CheckIcon size={16} weight="bold" className={styles.selectedIcon} />
</div>
)}
</div>
);
},
);
BackgroundItem.displayName = 'BackgroundItem';
const BackgroundImageGalleryModal: React.FC = observer(() => {
const {t} = useLingui();
const user = UserStore.currentUser;
const voiceSettings = VoiceSettingsStore;
const {backgroundImageId, backgroundImages = []} = voiceSettings;
const isMountedRef = React.useRef(true);
const [isDragging, setIsDragging] = React.useState(false);
const dragCounterRef = React.useRef(0);
const hasPremium = React.useMemo(() => user?.isPremium?.() ?? false, [user]);
const maxBackgroundImages = hasPremium ? 15 : 1;
const canAddMoreImages = backgroundImages.length < maxBackgroundImages;
const backgroundCount = backgroundImages.length;
const shouldShowReplace = !hasPremium && backgroundImages.length >= 1;
const builtInBackgrounds = React.useMemo(() => getBuiltInBackgrounds(shouldShowReplace), [shouldShowReplace]);
const sortedImages = React.useMemo(
() => [...backgroundImages].sort((a, b) => b.createdAt - a.createdAt),
[backgroundImages],
);
React.useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const processFileUpload = React.useCallback(
async (file: File | null) => {
if (!file) return;
try {
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
ToastActionCreators.createToast({
type: 'error',
children: t`Unsupported file format. Please use JPG, PNG, GIF, WebP, or MP4.`,
});
return;
}
if (file.size > MAX_FILE_SIZE) {
ToastActionCreators.createToast({
type: 'error',
children: t`Background image is too large. Please choose a file smaller than 10MB.`,
});
return;
}
const newImage: BackgroundImage = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
createdAt: Date.now(),
};
await BackgroundImageDB.saveBackgroundImage(newImage.id, file);
if (isMountedRef.current) {
let updatedImages = [...backgroundImages];
let oldImageToDelete: string | null = null;
if (!hasPremium && backgroundImages.length >= 1) {
const oldImage = backgroundImages[0];
oldImageToDelete = oldImage.id;
updatedImages = [];
}
updatedImages.push(newImage);
VoiceSettingsActionCreators.update({
backgroundImages: updatedImages,
backgroundImageId: newImage.id,
});
if (oldImageToDelete) {
BackgroundImageDB.deleteBackgroundImage(oldImageToDelete).catch((error) => {
console.error('Failed to delete old background image:', error);
});
}
ToastActionCreators.createToast({
type: 'success',
children: oldImageToDelete
? t`Background image replaced successfully.`
: t`Background image uploaded successfully.`,
});
ModalActionCreators.pop();
}
} catch (error) {
console.error('File upload failed:', error);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to upload background image. Please try again.`,
});
}
},
[backgroundImages, hasPremium],
);
const handleUploadClick = React.useCallback(
(showReplaceWarning: boolean = false) => {
if (!canAddMoreImages && hasPremium) {
ToastActionCreators.createToast({
type: 'error',
children: t`You've reached the maximum of ${maxBackgroundImages} backgrounds. Remove one to add a new background.`,
});
return;
}
const pickAndProcess = async () => {
const [file] = await openFilePicker({accept: ALLOWED_MIME_TYPES.join(',')});
await processFileUpload(file ?? null);
};
if (showReplaceWarning && !hasPremium && backgroundImages.length >= 1) {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Replace Background?`}
description={
<Trans>
You can only have one custom background on the free tier. Uploading a new one will replace your
existing background.
</Trans>
}
primaryText={t`Replace`}
primaryVariant="primary"
onPrimary={pickAndProcess}
/>
)),
);
return;
}
void pickAndProcess();
},
[canAddMoreImages, hasPremium, maxBackgroundImages, backgroundImages.length, processFileUpload],
);
const handleBackgroundSelect = React.useCallback(
(background: BackgroundItemType) => {
if ('type' in background) {
if (background.type === 'upload') {
handleUploadClick(true);
return;
}
VoiceSettingsActionCreators.update({
backgroundImageId: background.id,
});
} else {
VoiceSettingsActionCreators.update({
backgroundImageId: background.id,
});
}
ModalActionCreators.pop();
},
[handleUploadClick],
);
const handleRemoveImage = React.useCallback(
async (image: BackgroundImage) => {
try {
await BackgroundImageDB.deleteBackgroundImage(image.id);
const updatedImages = backgroundImages.filter((img) => img.id !== image.id);
const updates: any = {
backgroundImages: updatedImages,
};
if (backgroundImageId === image.id) {
updates.backgroundImageId = NONE_BACKGROUND_ID;
}
VoiceSettingsActionCreators.update(updates);
ToastActionCreators.createToast({
type: 'success',
children: t`Background image removed.`,
});
} catch (error) {
console.error('Failed to delete background image:', error);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to remove background image. Please try again.`,
});
}
},
[backgroundImageId, backgroundImages],
);
const handleBackgroundContextMenu = React.useCallback(
(event: React.MouseEvent, image: BackgroundImage) => {
event.preventDefault();
event.stopPropagation();
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<div>
<MenuItem
danger
onClick={() => {
handleRemoveImage(image);
onClose();
}}
>
{t`Remove Background`}
</MenuItem>
</div>
));
},
[handleRemoveImage],
);
const handleDrop = React.useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
dragCounterRef.current = 0;
const file = e.dataTransfer.files?.[0];
if (!file) return;
await processFileUpload(file);
},
[processFileUpload],
);
const handleDragEnter = React.useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
}, []);
const handleDragLeave = React.useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDragging(false);
}
}, []);
const handleDragOver = React.useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
return (
<Modal.Root size="medium">
<Modal.Header title={t`Choose Background`} />
<Modal.Content>
<section
className={styles.selectionSection}
onDrop={handleDrop}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
aria-label={t`Background selection area with drag and drop support`}
>
{isDragging && (
<div className={styles.dragOverlay}>
<div className={styles.dragContent}>
<PlusIcon size={48} weight="bold" className={styles.dragIcon} />
<div className={styles.dragText}>
<Trans>Drop to upload background</Trans>
</div>
</div>
</div>
)}
{!hasPremium ? (
<div className={styles.freeUserContainer}>
{sortedImages.length > 0 ? (
<div className={styles.customBackgroundWrapper}>
<BackgroundItem
key={sortedImages[0].id}
background={sortedImages[0]}
isSelected={backgroundImageId === sortedImages[0].id}
onSelect={handleBackgroundSelect}
onDelete={undefined}
/>
<div className={styles.actionButtons}>
<Tooltip text={t`Replace background`}>
<FocusRing offset={-2}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleUploadClick(true);
}}
className={styles.actionButton}
aria-label={t`Replace background`}
>
<ArrowsClockwiseIcon size={16} weight="bold" className={styles.actionButtonIcon} />
</button>
</FocusRing>
</Tooltip>
<Tooltip text={t`Remove background`}>
<FocusRing offset={-2}>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveImage(sortedImages[0]);
}}
className={styles.actionButton}
aria-label={t`Remove background`}
>
<TrashIcon size={16} weight="bold" className={styles.actionButtonIcon} />
</button>
</FocusRing>
</Tooltip>
</div>
</div>
) : (
<div
className={styles.uploadPlaceholder}
onClick={() => handleUploadClick(false)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleUploadClick(false);
}
}}
role="button"
tabIndex={0}
aria-label={t`Upload custom background`}
>
<div className={styles.uploadPlaceholderContent}>
<PlusIcon size={48} weight="regular" className={styles.uploadIcon} />
<div className={styles.uploadTextContainer}>
<div className={styles.uploadTitle}>
<Trans>Upload Custom Background</Trans>
</div>
<div className={styles.uploadHint}>
<Trans>Click or drag and drop</Trans>
</div>
</div>
</div>
</div>
)}
<div className={styles.builtInGrid}>
{builtInBackgrounds
.filter((bg) => bg.type !== 'upload')
.map((background) => (
<BackgroundItem
key={background.id}
background={background}
isSelected={backgroundImageId === background.id}
onSelect={handleBackgroundSelect}
/>
))}
</div>
</div>
) : (
<div className={styles.premiumGrid}>
{builtInBackgrounds.map((background) => (
<BackgroundItem
key={background.id}
background={background}
isSelected={backgroundImageId === background.id}
onSelect={handleBackgroundSelect}
/>
))}
{sortedImages.map((image) => (
<BackgroundItem
key={image.id}
background={image}
isSelected={backgroundImageId === image.id}
onSelect={handleBackgroundSelect}
onContextMenu={handleBackgroundContextMenu}
onDelete={handleRemoveImage}
/>
))}
</div>
)}
<div className={styles.statsText}>
{backgroundCount === 1
? t`${backgroundCount} / ${maxBackgroundImages} custom background`
: t`${backgroundCount} / ${maxBackgroundImages} custom backgrounds`}
</div>
<div className={styles.infoText}>
<Trans>Supported: JPG, PNG, GIF, WebP, MP4. Max size: 10MB.</Trans>
</div>
{!hasPremium && (
<div className={styles.premiumUpsell}>
<div className={styles.premiumHeader}>
<CrownIcon weight="fill" size={18} className={styles.premiumIcon} />
<span className={styles.premiumTitle}>
<Trans>Unlock More Backgrounds with Plutonium</Trans>
</span>
</div>
<p className={styles.premiumDesc}>
<Trans>
Upgrade to store up to 15 custom backgrounds and unlock HD video quality, higher frame rates, and
more.
</Trans>
</p>
<Button variant="secondary" small={true} onClick={() => PremiumModalActionCreators.open()}>
<Trans>Get Plutonium</Trans>
</Button>
</div>
)}
</section>
</Modal.Content>
</Modal.Root>
);
});
export default BackgroundImageGalleryModal;

View File

@@ -0,0 +1,125 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 16px;
}
.description {
font-size: 14px;
color: var(--text-primary-muted);
}
.codesGrid {
margin-top: 16px;
margin-bottom: 16px;
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 8px;
}
@media (min-width: 640px) {
.codesGrid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
}
.codeItem {
display: flex;
align-items: center;
gap: 10px;
border-radius: 6px;
border: 1px solid var(--background-modifier-accent);
padding: 8px 12px;
}
.codeItemConsumed {
opacity: 0.5;
}
.checkbox {
display: flex;
height: 16px;
width: 16px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.checkboxUnchecked {
border: 1px solid var(--background-modifier-accent);
}
.checkboxChecked {
background: var(--brand-primary);
}
.checkIcon {
height: 10px;
width: 10px;
color: white;
}
.code {
user-select: text;
-webkit-user-select: text;
font-family: monospace;
font-size: 14px;
color: var(--text-primary);
letter-spacing: 0.05em;
}
.codeConsumed {
text-decoration: line-through;
}
.buttonRow {
display: flex;
flex-direction: column;
gap: 8px;
}
@media (min-width: 640px) {
.buttonRow {
flex-direction: row;
}
}
.footer {
align-items: center;
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}
.buttonIcon {
height: 1.25rem;
width: 1.25rem;
}

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, useLingui} from '@lingui/react/macro';
import {CheckIcon, ClipboardIcon, DownloadIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import styles from '~/components/modals/BackupCodesModal.module.css';
import {BackupCodesRegenerateModal} from '~/components/modals/BackupCodesRegenerateModal';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import type {BackupCode} from '~/records/UserRecord';
import UserStore from '~/stores/UserStore';
export const BackupCodesModal = observer(({backupCodes}: {backupCodes: Array<BackupCode>}) => {
const {t, i18n} = useLingui();
const user = UserStore.getCurrentUser()!;
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Backup codes`} />
<Modal.Content className={styles.content}>
<p className={styles.description}>
<Trans>Use these codes to access your account if you lose your authenticator app.</Trans>
</p>
<p className={styles.description}>
<Trans>We recommend saving these codes now so that you don't get locked out of your account.</Trans>
</p>
<div className={styles.codesGrid}>
{backupCodes.map(({code, consumed}) => (
<div key={code} className={`${styles.codeItem} ${consumed ? styles.codeItemConsumed : ''}`}>
<div className={`${styles.checkbox} ${consumed ? styles.checkboxChecked : styles.checkboxUnchecked}`}>
{consumed && <CheckIcon weight="bold" className={styles.checkIcon} />}
</div>
<code className={`${styles.code} ${consumed ? styles.codeConsumed : ''}`}>{code}</code>
</div>
))}
</div>
<div className={styles.buttonRow}>
<Button
leftIcon={<DownloadIcon className={styles.buttonIcon} />}
small={true}
onClick={() => {
const blob = new Blob([backupCodes.map(({code}) => code).join('\n')], {type: 'text/plain'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fluxer_${user.email}_backup_codes.txt`;
a.click();
URL.revokeObjectURL(url);
}}
>
<Trans>Download</Trans>
</Button>
<Button
variant="secondary"
small={true}
leftIcon={<ClipboardIcon className={styles.buttonIcon} />}
onClick={() => TextCopyActionCreators.copy(i18n, backupCodes.map(({code}) => code).join('\n'))}
>
<Trans>Copy</Trans>
</Button>
<Button
variant="danger-secondary"
small={true}
onClick={() => ModalActionCreators.push(modal(() => <BackupCodesRegenerateModal />))}
>
<Trans>Regenerate</Trans>
</Button>
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop}>
<Trans>I have saved the codes</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useForm} from 'react-hook-form';
import * as MfaActionCreators from '~/actions/MfaActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {Form} from '~/components/form/Form';
import {BackupCodesModal} from '~/components/modals/BackupCodesModal';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
export const BackupCodesRegenerateModal = observer(() => {
const {t} = useLingui();
const form = useForm();
const onSubmit = async () => {
const backupCodes = await MfaActionCreators.getBackupCodes(true);
ModalActionCreators.pop();
ModalActionCreators.update('backup-codes', () => modal(() => <BackupCodesModal backupCodes={backupCodes} />));
ToastActionCreators.createToast({
type: 'success',
children: t`Backup codes regenerated`,
});
};
const {handleSubmit} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'form',
});
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit}>
<Modal.Header title={t`Regenerate backup codes`} />
<Modal.Content>This will invalidate your existing backup codes and generate new ones.</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" submitting={form.formState.isSubmitting}>
<Trans>Continue</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

View File

@@ -0,0 +1,72 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {useForm} from 'react-hook-form';
import * as MfaActionCreators from '~/actions/MfaActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {Form} from '~/components/form/Form';
import {BackupCodesModal} from '~/components/modals/BackupCodesModal';
import styles from '~/components/modals/ConfirmModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
export const BackupCodesViewModal = observer(() => {
const {t} = useLingui();
const form = useForm();
const onSubmit = async () => {
const backupCodes = await MfaActionCreators.getBackupCodes();
ModalActionCreators.pop();
ModalActionCreators.pushWithKey(
modal(() => <BackupCodesModal backupCodes={backupCodes} />),
'backup-codes',
);
};
const {handleSubmit} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'form',
});
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit} aria-label={t`View backup codes form`}>
<Modal.Header title={t`View backup codes`} />
<Modal.Content className={styles.content}>
<p>
<Trans>Verification may be required before viewing your backup codes.</Trans>
</p>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" submitting={form.formState.isSubmitting}>
<Trans>Continue</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

View File

@@ -0,0 +1,117 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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: 1.25rem;
}
.userSection {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0 0.25rem;
}
.avatar {
width: 3rem;
height: 3rem;
border-radius: 9999px;
object-fit: cover;
flex-shrink: 0;
}
.avatarPlaceholder {
display: flex;
width: 3rem;
height: 3rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background-color: var(--background-header-secondary);
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
flex-shrink: 0;
}
.userInfo {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.username {
font-weight: 600;
font-size: 1rem;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
font-size: 0.8125rem;
color: var(--text-tertiary);
display: block;
}
.details {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detailRow {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.detailLabel {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--text-tertiary);
}
.detailValue {
font-size: 0.9375rem;
color: var(--text-primary);
word-break: break-word;
}
.noReason {
font-style: italic;
color: var(--text-tertiary);
}
.moderator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.unknownModerator {
font-style: italic;
color: var(--text-tertiary);
}

View File

@@ -0,0 +1,135 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import type {GuildBan} from '~/actions/GuildActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as Modal from '~/components/modals/Modal';
import {Avatar} from '~/components/uikit/Avatar';
import {Button} from '~/components/uikit/Button/Button';
import UserStore from '~/stores/UserStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import * as DateUtils from '~/utils/DateUtils';
import styles from './BanDetailsModal.module.css';
interface BanDetailsModalProps {
ban: GuildBan;
onRevoke?: () => void;
}
export const BanDetailsModal: React.FC<BanDetailsModalProps> = observer(({ban, onRevoke}) => {
const {t} = useLingui();
const moderator = UserStore.getUser(ban.moderator_id);
const avatarUrl = AvatarUtils.getUserAvatarURL(ban.user, false);
const [isRevoking, setIsRevoking] = React.useState(false);
const userTag = ban.user.tag ?? `${ban.user.username}#${(ban.user.discriminator ?? '').padStart(4, '0')}`;
const handleRevoke = React.useCallback(async () => {
if (!onRevoke) return;
setIsRevoking(true);
try {
await onRevoke();
ModalActionCreators.pop();
} finally {
setIsRevoking(false);
}
}, [onRevoke]);
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Ban Details`} />
<Modal.Content>
<div className={styles.container}>
<div className={styles.userSection}>
{avatarUrl ? (
<img src={avatarUrl} alt="" className={styles.avatar} />
) : (
<div className={styles.avatarPlaceholder}>{ban.user.username[0].toUpperCase()}</div>
)}
<div className={styles.userInfo}>
<span className={styles.username}>{ban.user.username}</span>
<span className={styles.tag}>{userTag}</span>
</div>
</div>
<div className={styles.details}>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>
<Trans>Reason</Trans>
</span>
<span className={styles.detailValue}>
{ban.reason || (
<span className={styles.noReason}>
<Trans>No reason provided</Trans>
</span>
)}
</span>
</div>
<div className={styles.detailRow}>
<span className={styles.detailLabel}>
<Trans>Banned on</Trans>
</span>
<span className={styles.detailValue}>{DateUtils.getFormattedShortDate(new Date(ban.banned_at))}</span>
</div>
{ban.expires_at && (
<div className={styles.detailRow}>
<span className={styles.detailLabel}>
<Trans>Expires</Trans>
</span>
<span className={styles.detailValue}>{DateUtils.getFormattedShortDate(new Date(ban.expires_at))}</span>
</div>
)}
<div className={styles.detailRow}>
<span className={styles.detailLabel}>
<Trans>Banned by</Trans>
</span>
<span className={styles.detailValue}>
{moderator ? (
<span className={styles.moderator}>
<Avatar user={moderator} size={20} />
<span>{moderator.username}</span>
</span>
) : (
<span className={styles.unknownModerator}>
<Trans>Unknown</Trans>
</span>
)}
</span>
</div>
</div>
</div>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()}>
<Trans>Close</Trans>
</Button>
{onRevoke && (
<Button variant="danger-primary" submitting={isRevoking} onClick={handleRevoke}>
<Trans>Revoke Ban</Trans>
</Button>
)}
</Modal.Footer>
</Modal.Root>
);
});

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/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.video {
width: 100%;
max-width: 400px;
margin-bottom: 1rem;
}
.sectionTitle {
font-weight: 600;
margin-bottom: 0.5rem;
}

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 React from 'react';
import * as GuildActionCreators from '~/actions/GuildActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {Input} from '~/components/form/Input';
import {Select as FormSelect} from '~/components/form/Select';
import styles from '~/components/modals/BanMemberModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
import type {UserRecord} from '~/records/UserRecord';
import bannedMp4 from '~/videos/banned.mp4';
import bannedPng from '~/videos/banned.png';
import bannedWebm from '~/videos/banned.webm';
interface SelectOption {
value: number;
label: string;
}
export const BanMemberModal: React.FC<{guildId: string; targetUser: UserRecord}> = observer(({guildId, targetUser}) => {
const {t} = useLingui();
const [reason, setReason] = React.useState('');
const [deleteMessageDays, setDeleteMessageDays] = React.useState<number>(1);
const [banDuration, setBanDuration] = React.useState<number>(0);
const [isBanning, setIsBanning] = React.useState(false);
const getBanDurationOptions = React.useCallback(
(): ReadonlyArray<SelectOption> => [
{value: 0, label: t`Permanent`},
{value: 60 * 60, label: t`1 hour`},
{value: 60 * 60 * 12, label: t`12 hours`},
{value: 60 * 60 * 24, label: t`1 day`},
{value: 60 * 60 * 24 * 3, label: t`3 days`},
{value: 60 * 60 * 24 * 5, label: t`5 days`},
{value: 60 * 60 * 24 * 7, label: t`1 week`},
{value: 60 * 60 * 24 * 14, label: t`2 weeks`},
{value: 60 * 60 * 24 * 30, label: t`1 month`},
],
[t],
);
const BAN_DURATION_OPTIONS = getBanDurationOptions();
const handleBan = async () => {
setIsBanning(true);
try {
await GuildActionCreators.banMember(guildId, targetUser.id, deleteMessageDays, reason || undefined, banDuration);
ToastActionCreators.createToast({
type: 'success',
children: <Trans>Successfully banned {targetUser.tag} from the community</Trans>,
});
ModalActionCreators.pop();
} catch (error) {
console.error('Failed to ban member:', error);
ToastActionCreators.createToast({
type: 'error',
children: <Trans>Failed to ban member. Please try again.</Trans>,
});
} finally {
setIsBanning(false);
}
};
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Ban ${targetUser.tag}`} />
<Modal.Content>
<div className={styles.content}>
{/* biome-ignore lint/a11y/useMediaCaption: this is fine s*/}
<video autoPlay loop className={styles.video}>
<source src={bannedWebm} type="video/webm" />
<source src={bannedMp4} type="video/mp4" />
<img src={bannedPng} alt="Banned" />
</video>
<div>
<FormSelect<number>
label={t`Ban Duration`}
description={t`How long this user should be banned for.`}
value={banDuration}
onChange={(v) => setBanDuration(v)}
options={BAN_DURATION_OPTIONS}
disabled={isBanning}
/>
</div>
<div>
<div className={styles.sectionTitle}>
<Trans>Delete Message History</Trans>
</div>
<RadioGroup
aria-label={t`Delete Message History`}
options={[
{value: 0, name: t`Don't Delete Any`, desc: t`Keep all messages`},
{value: 1, name: t`Previous 24 Hours`, desc: t`Delete messages from the last day`},
{value: 7, name: t`Previous 7 Days`, desc: t`Delete messages from the last week`},
]}
value={deleteMessageDays}
onChange={setDeleteMessageDays}
disabled={isBanning}
/>
</div>
<Input
type="text"
label={t`Reason (optional)`}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t`Enter a reason for the ban...`}
maxLength={512}
disabled={isBanning}
/>
</div>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()} disabled={isBanning}>
<Trans>Cancel</Trans>
</Button>
<Button variant="danger-primary" onClick={handleBan} disabled={isBanning}>
<Trans>Ban Member</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,38 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.clearButton {
position: absolute;
top: 50%;
right: 0.5rem;
transform: translateY(-50%);
padding: 0.25rem;
color: var(--text-tertiary);
transition: color 0.2s ease;
cursor: pointer;
}
.clearButton:hover {
color: var(--text-primary);
}
.helperText {
font-size: 0.875rem;
color: var(--text-primary-muted);
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {XIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import styles from '~/components/modals/BaseChangeNicknameModal.module.css';
import confirmStyles from '~/components/modals/ConfirmModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {useFormSubmit} from '~/hooks/useFormSubmit';
interface FormInputs {
nick: string;
}
interface BaseChangeNicknameModalProps {
currentNick: string;
displayName: string;
onSave: (nick: string | null) => Promise<void>;
}
export const BaseChangeNicknameModal: React.FC<BaseChangeNicknameModalProps> = observer(
({currentNick, displayName, onSave}) => {
const {t} = useLingui();
const form = useForm<FormInputs>({
defaultValues: {
nick: currentNick,
},
});
const onSubmit = React.useCallback(
async (data: FormInputs) => {
const nick = data.nick.trim() || null;
await onSave(nick);
ToastActionCreators.createToast({
type: 'success',
children: <Trans>Nickname updated</Trans>,
});
ModalActionCreators.pop();
},
[onSave],
);
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'nick',
});
const nickValue = form.watch('nick');
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit} aria-label={t`Change nickname form`}>
<Modal.Header title={t`Change Nickname`} />
<Modal.Content className={confirmStyles.content}>
<Input
{...form.register('nick', {
maxLength: {
value: 32,
message: t`Nickname must not exceed 32 characters`,
},
})}
autoFocus={true}
type="text"
label={t`Nickname`}
placeholder={displayName}
maxLength={32}
error={form.formState.errors.nick?.message}
rightElement={
nickValue ? (
<FocusRing offset={-2}>
<button
type="button"
className={styles.clearButton}
onClick={() => form.setValue('nick', '')}
aria-label={t`Clear nickname`}
>
<XIcon size={16} weight="bold" />
</button>
</FocusRing>
) : undefined
}
/>
</Modal.Content>
<Modal.Footer>
<Button type="submit" submitting={isSubmitting}>
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
},
);

View File

@@ -0,0 +1,84 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.emptyState {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
}
.emptyContent {
display: flex;
flex-direction: column;
gap: 8px;
}
.emptyTitle {
font-weight: 500;
color: var(--text-primary);
}
.emptyDescription {
font-size: 14px;
color: var(--text-primary-muted);
}
.messageList {
flex: 1;
}
.topSpacer {
height: 8px;
flex-shrink: 0;
}
.messagesContainer {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 16px 16px;
}
.messagePreviewCard {
position: relative;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
overflow: hidden;
background-color: var(--background-modifier-hover);
border-radius: 14px;
padding: 10px 12px;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.menuIcon {
height: 1.25rem;
width: 1.25rem;
}
.missingList {
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 8px;
}

View File

@@ -0,0 +1,167 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowSquareOutIcon, TrashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as SavedMessageActionCreators from '~/actions/SavedMessageActionCreators';
import {MessagePreviewContext} from '~/Constants';
import {Message} from '~/components/channel/Message';
import {LongPressable} from '~/components/LongPressable';
import styles from '~/components/modals/BookmarksBottomSheet.module.css';
import {SavedMessageMissingCard} from '~/components/shared/SavedMessageMissingCard';
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
import type {MenuGroupType} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
import {MenuBottomSheet} from '~/components/uikit/MenuBottomSheet/MenuBottomSheet';
import {Scroller} from '~/components/uikit/Scroller';
import type {MessageRecord} from '~/records/MessageRecord';
import ChannelStore from '~/stores/ChannelStore';
import SavedMessagesStore from '~/stores/SavedMessagesStore';
import {goToMessage} from '~/utils/MessageNavigator';
interface BookmarksBottomSheetProps {
isOpen: boolean;
onClose: () => void;
}
export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottomSheetProps) => {
const {t, i18n} = useLingui();
const {savedMessages, missingSavedMessages, fetched} = SavedMessagesStore;
const hasBookmarks = savedMessages.length > 0 || missingSavedMessages.length > 0;
const [selectedMessage, setSelectedMessage] = React.useState<MessageRecord | null>(null);
const [menuOpen, setMenuOpen] = React.useState(false);
React.useEffect(() => {
if (!fetched && isOpen) {
SavedMessageActionCreators.fetch();
}
}, [fetched, isOpen]);
const handleLongPress = (message: MessageRecord) => {
setSelectedMessage(message);
setMenuOpen(true);
};
const handleJumpToMessage = (message: MessageRecord) => {
goToMessage(message.channelId, message.id);
onClose();
};
const handleMenuJump = () => {
if (selectedMessage) {
handleJumpToMessage(selectedMessage);
}
setMenuOpen(false);
setSelectedMessage(null);
};
const handleRemove = () => {
if (selectedMessage) {
SavedMessageActionCreators.remove(i18n, selectedMessage.id);
}
setMenuOpen(false);
setSelectedMessage(null);
};
const menuGroups: Array<MenuGroupType> = [
{
items: [
{
icon: <ArrowSquareOutIcon weight="fill" className={styles.menuIcon} />,
label: t`Jump to Message`,
onClick: handleMenuJump,
},
{
icon: <TrashIcon weight="fill" className={styles.menuIcon} />,
label: t`Remove Bookmark`,
onClick: handleRemove,
danger: true,
},
],
},
];
return (
<>
<BottomSheet isOpen={isOpen} onClose={onClose} snapPoints={[0, 1]} initialSnap={1} title={t`Bookmarks`}>
{hasBookmarks ? (
<Scroller className={styles.messageList} key="bookmarks-bottom-sheet-scroller">
{missingSavedMessages.length > 0 && (
<div className={styles.missingList}>
{missingSavedMessages.map((entry) => (
<SavedMessageMissingCard
key={entry.id}
entryId={entry.id}
onRemove={() => SavedMessageActionCreators.remove(i18n, entry.id)}
/>
))}
</div>
)}
<div className={styles.topSpacer} />
<div className={styles.messagesContainer}>
{savedMessages.map((message) => (
<MessageWithLongPress
key={message.id}
message={message}
onLongPress={handleLongPress}
onClick={handleJumpToMessage}
/>
))}
</div>
</Scroller>
) : (
<div className={styles.emptyState}>
<div className={styles.emptyContent}>
<p className={styles.emptyTitle}>
<Trans>No Bookmarks</Trans>
</p>
<p className={styles.emptyDescription}>
<Trans>Bookmark messages to save them for later.</Trans>
</p>
</div>
</div>
)}
</BottomSheet>
<MenuBottomSheet isOpen={menuOpen} onClose={() => setMenuOpen(false)} groups={menuGroups} />
</>
);
});
interface MessageWithLongPressProps {
message: MessageRecord;
onLongPress: (message: MessageRecord) => void;
onClick: (message: MessageRecord) => void;
}
const MessageWithLongPress = observer(({message, onLongPress, onClick}: MessageWithLongPressProps) => {
const channel = ChannelStore.getChannel(message.channelId);
if (!channel) return null;
return (
<LongPressable
className={styles.messagePreviewCard}
onLongPress={() => onLongPress(message)}
onClick={() => onClick(message)}
>
<Message message={message} channel={channel} previewContext={MessagePreviewContext.LIST_POPOUT} />
</LongPressable>
);
});

View File

@@ -0,0 +1,144 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 1rem;
}
.backgroundSection {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.backgroundLabel {
font-weight: 500;
color: var(--text-primary);
font-size: 0.875rem;
}
.iconMargin {
margin-right: 0.5rem;
}
.videoContainer {
position: relative;
aspect-ratio: 16 / 9;
max-height: 24rem;
overflow: hidden;
border-radius: 0.5rem;
background-color: black;
}
.video {
height: 100%;
width: 100%;
object-fit: contain;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.7);
}
.overlayText {
margin-top: 0.75rem;
text-align: center;
color: white;
}
.overlayTextMedium {
font-weight: 500;
font-size: 0.875rem;
}
.errorOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.7);
}
.errorText {
text-align: center;
color: rgb(248 113 113);
}
.errorTitle {
font-weight: 500;
font-size: 0.875rem;
}
.errorDetail {
font-size: 0.75rem;
opacity: 0.75;
}
.liveLabel {
position: absolute;
top: 0.5rem;
left: 0.5rem;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.7);
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
font-size: 0.875rem;
color: white;
}
.resolutionInfo {
position: absolute;
right: 0.5rem;
bottom: 0.5rem;
border-radius: 0.25rem;
background-color: rgba(0, 0, 0, 0.7);
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-top: 0.25rem;
padding-bottom: 0.25rem;
color: white;
font-size: 0.75rem;
}
.resolutionDetails {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.resolutionRow {
display: flex;
align-items: center;
gap: 0.25rem;
}
.warningIcon {
color: rgb(253 224 71);
}

View File

@@ -0,0 +1,573 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {useLocalParticipant} from '@livekit/components-react';
import {BackgroundProcessor} from '@livekit/track-processors';
import {CameraIcon, ImageIcon} from '@phosphor-icons/react';
import type {LocalParticipant, LocalVideoTrack} from 'livekit-client';
import {createLocalVideoTrack} from 'livekit-client';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as VoiceSettingsActionCreators from '~/actions/VoiceSettingsActionCreators';
import {Select} from '~/components/form/Select';
import BackgroundImageGalleryModal from '~/components/modals/BackgroundImageGalleryModal';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import LocalVoiceStateStore from '~/stores/LocalVoiceStateStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import VoiceSettingsStore, {BLUR_BACKGROUND_ID, NONE_BACKGROUND_ID} from '~/stores/VoiceSettingsStore';
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
import VoiceDevicePermissionStore, {type VoiceDeviceState} from '~/stores/voice/VoiceDevicePermissionStore';
import * as BackgroundImageDB from '~/utils/BackgroundImageDB';
import styles from './CameraPreviewModal.module.css';
interface CameraPreviewModalProps {
onEnabled?: () => void;
onEnableCamera?: () => void;
showEnableCameraButton?: boolean;
localParticipant?: LocalParticipant;
isCameraEnabled?: boolean;
}
interface VideoResolutionPreset {
width: number;
height: number;
frameRate: number;
}
const TARGET_ASPECT_RATIO = 16 / 9;
const ASPECT_RATIO_TOLERANCE = 0.1;
const RESOLUTION_WAIT_TIMEOUT = 2000;
const RESOLUTION_CHECK_INTERVAL = 100;
const VIDEO_ELEMENT_WAIT_TIMEOUT = 5000;
const VIDEO_ELEMENT_CHECK_INTERVAL = 10;
const MEDIAPIPE_TASKS_VISION_WASM_BASE = `https://fluxerstatic.com/libs/mediapipe/tasks-vision/0.10.14/wasm`;
const MEDIAPIPE_SEGMENTER_MODEL_PATH =
'https://fluxerstatic.com/libs/mediapipe/image_segmenter/selfie_segmenter/float16/latest/selfie_segmenter.tflite';
const CAMERA_RESOLUTION_PRESETS: Record<'low' | 'medium' | 'high', VideoResolutionPreset> = {
low: {width: 640, height: 360, frameRate: 24},
medium: {width: 1280, height: 720, frameRate: 30},
high: {width: 1920, height: 1080, frameRate: 30},
};
const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
const {t} = useLingui();
const {localParticipant, onEnabled, onEnableCamera, isCameraEnabled, showEnableCameraButton = true} = props;
const [videoDevices, setVideoDevices] = useState<Array<MediaDeviceInfo>>([]);
const [status, setStatus] = useState<
'idle' | 'initializing' | 'ready' | 'error' | 'fixing' | 'fix-settling' | 'fix-switching-back'
>('initializing');
const [resolution, setResolution] = useState<{width: number; height: number} | null>(null);
const [error, setError] = useState<string | null>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<LocalVideoTrack | null>(null);
const processorRef = useRef<ReturnType<typeof BackgroundProcessor> | null>(null);
const isMountedRef = useRef(true);
const isIOSRef = useRef(/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream);
const prevConfigRef = useRef<{
videoDeviceId: string;
backgroundImageId: string;
cameraResolution: 'low' | 'medium' | 'high';
videoFrameRate: number;
} | null>(null);
const originalBackgroundIdRef = useRef<string | null>(VoiceSettingsStore.backgroundImageId);
const needsResolutionFixRef = useRef(false);
const isApplyingFixRef = useRef(false);
const initializationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const fixTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const settleTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const switchBackTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleDeviceUpdate = useCallback((state: VoiceDeviceState) => {
if (!isMountedRef.current) return;
const videoInputs = state.videoDevices.filter((device) => device.deviceId !== 'default');
setVideoDevices(videoInputs);
const voiceSettings = VoiceSettingsStore;
if (voiceSettings.videoDeviceId === 'default' && videoInputs.length > 0) {
VoiceSettingsActionCreators.update({videoDeviceId: videoInputs[0].deviceId});
}
}, []);
const applyResolutionFix = useCallback(() => {
if (!isMountedRef.current || isApplyingFixRef.current) {
return;
}
isApplyingFixRef.current = true;
needsResolutionFixRef.current = false;
const voiceSettings = VoiceSettingsStore;
const currentBg = voiceSettings.backgroundImageId;
const tempBg = currentBg === NONE_BACKGROUND_ID ? BLUR_BACKGROUND_ID : NONE_BACKGROUND_ID;
setStatus('fixing');
VoiceSettingsActionCreators.update({backgroundImageId: tempBg});
settleTimeoutRef.current = setTimeout(() => {
setStatus('fix-switching-back');
VoiceSettingsActionCreators.update({backgroundImageId: originalBackgroundIdRef.current!});
switchBackTimeoutRef.current = setTimeout(() => {
if (isMountedRef.current) {
isApplyingFixRef.current = false;
setStatus('ready');
}
}, 500);
}, 1200);
}, []);
const initializeCamera = useCallback(async () => {
const voiceSettings = VoiceSettingsStore;
const isMobile = MobileLayoutStore.isMobileLayout() || isIOSRef.current;
if (isMobile) {
if (isMountedRef.current) {
setStatus('ready');
}
return;
}
if (!isMountedRef.current) {
return;
}
let videoElement = videoRef.current;
let attempts = 0;
const maxAttempts = VIDEO_ELEMENT_WAIT_TIMEOUT / VIDEO_ELEMENT_CHECK_INTERVAL;
while (!videoElement && attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, VIDEO_ELEMENT_CHECK_INTERVAL));
videoElement = videoRef.current;
attempts++;
}
if (!videoElement) {
if (isMountedRef.current) {
setStatus('error');
setError('Video element not available');
}
return;
}
try {
const currentConfig = {
videoDeviceId: voiceSettings.videoDeviceId,
backgroundImageId: voiceSettings.backgroundImageId,
cameraResolution: voiceSettings.cameraResolution,
videoFrameRate: voiceSettings.videoFrameRate,
};
if (prevConfigRef.current && JSON.stringify(prevConfigRef.current) === JSON.stringify(currentConfig)) {
return;
}
prevConfigRef.current = currentConfig;
if (!originalBackgroundIdRef.current) {
originalBackgroundIdRef.current = voiceSettings.backgroundImageId;
}
if (isMountedRef.current) {
setStatus(isApplyingFixRef.current ? 'fixing' : 'initializing');
setError(null);
}
videoElement.muted = true;
videoElement.autoplay = true;
videoElement.playsInline = true;
if (trackRef.current) {
trackRef.current.stop();
trackRef.current = null;
}
if (processorRef.current) {
await processorRef.current.destroy();
processorRef.current = null;
}
const resolutionPreset = CAMERA_RESOLUTION_PRESETS[voiceSettings.cameraResolution];
const track = await createLocalVideoTrack({
deviceId:
voiceSettings.videoDeviceId && voiceSettings.videoDeviceId !== 'default'
? voiceSettings.videoDeviceId
: undefined,
resolution: {
width: resolutionPreset.width,
height: resolutionPreset.height,
frameRate: voiceSettings.videoFrameRate,
aspectRatio: TARGET_ASPECT_RATIO,
},
});
if (!isMountedRef.current) {
track.stop();
return;
}
trackRef.current = track;
track.attach(videoElement);
await new Promise<void>((resolve) => {
let playbackAttempts = 0;
const checkPlayback = () => {
const hasData = videoElement!.srcObject && videoElement!.readyState >= 2;
if (hasData) {
resolve();
} else if (++playbackAttempts < 100) {
setTimeout(checkPlayback, 50);
} else {
resolve();
}
};
checkPlayback();
});
if (!isMountedRef.current) {
track.stop();
return;
}
let negotiatedResolution: {width: number; height: number} | null = null;
await new Promise<void>((resolve) => {
let resolutionAttempts = 0;
const checkResolution = () => {
const settings = track.mediaStreamTrack.getSettings();
if (settings.width && settings.height) {
negotiatedResolution = {width: settings.width, height: settings.height};
if (isMountedRef.current) {
setResolution(negotiatedResolution);
}
resolve();
} else if (++resolutionAttempts < RESOLUTION_WAIT_TIMEOUT / RESOLUTION_CHECK_INTERVAL) {
setTimeout(checkResolution, RESOLUTION_CHECK_INTERVAL);
} else {
resolve();
}
};
checkResolution();
});
if (!isMountedRef.current) {
track.stop();
return;
}
if (negotiatedResolution && !isApplyingFixRef.current) {
const {width, height} = negotiatedResolution;
const aspectRatio = width / height;
const isValid16x9 = Math.abs(aspectRatio - TARGET_ASPECT_RATIO) < ASPECT_RATIO_TOLERANCE;
needsResolutionFixRef.current = !isValid16x9;
}
const isNone = voiceSettings.backgroundImageId === NONE_BACKGROUND_ID;
const isBlur = voiceSettings.backgroundImageId === BLUR_BACKGROUND_ID;
try {
if (isBlur) {
processorRef.current = BackgroundProcessor({
mode: 'background-blur',
blurRadius: 20,
assetPaths: {
tasksVisionFileSet: MEDIAPIPE_TASKS_VISION_WASM_BASE,
modelAssetPath: MEDIAPIPE_SEGMENTER_MODEL_PATH,
},
});
await track.setProcessor(processorRef.current);
} else if (!isNone) {
const backgroundImage = voiceSettings.backgroundImages?.find(
(img) => img.id === voiceSettings.backgroundImageId,
);
if (backgroundImage) {
const imageUrl = await BackgroundImageDB.getBackgroundImageURL(backgroundImage.id);
if (imageUrl) {
processorRef.current = BackgroundProcessor({
mode: 'virtual-background',
imagePath: imageUrl,
assetPaths: {
tasksVisionFileSet: MEDIAPIPE_TASKS_VISION_WASM_BASE,
modelAssetPath: MEDIAPIPE_SEGMENTER_MODEL_PATH,
},
});
await track.setProcessor(processorRef.current);
}
}
}
} catch (_webglError) {
console.warn('WebGL not supported for background processing, falling back to basic camera');
}
if (!isMountedRef.current) {
track.stop();
return;
}
if (isMountedRef.current) {
setStatus('ready');
if (needsResolutionFixRef.current && !isApplyingFixRef.current) {
initializationTimeoutRef.current = setTimeout(() => applyResolutionFix(), 800);
}
}
} catch (err) {
if (isMountedRef.current) {
const message = err instanceof Error ? err.message : 'Unknown error';
setStatus('error');
setError(message);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to start camera preview. Please check your camera permissions.`,
});
}
}
}, [applyResolutionFix]);
const handleDeviceChange = useCallback((deviceId: string) => {
VoiceSettingsActionCreators.update({videoDeviceId: deviceId});
}, []);
const handleOpenBackgroundGallery = useCallback(() => {
ModalActionCreators.push(modal(() => <BackgroundImageGalleryModal />));
}, []);
const handleEnableCamera = useCallback(async () => {
if (!localParticipant) {
onEnabled?.();
onEnableCamera?.();
ModalActionCreators.pop();
return;
}
try {
const voiceSettings = VoiceSettingsStore;
await localParticipant.setCameraEnabled(true, {
deviceId: voiceSettings.videoDeviceId !== 'default' ? voiceSettings.videoDeviceId : undefined,
});
LocalVoiceStateStore.updateSelfVideo(true);
MediaEngineStore.syncLocalVoiceStateWithServer({self_video: true});
onEnabled?.();
onEnableCamera?.();
ModalActionCreators.pop();
} catch (_err) {
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to enable camera.`,
});
}
}, [localParticipant, onEnabled, onEnableCamera]);
useEffect(() => {
isMountedRef.current = true;
const unsubscribeDevices = VoiceDevicePermissionStore.subscribe(handleDeviceUpdate);
void VoiceDevicePermissionStore.ensureDevices({requestPermissions: true}).catch(() => {});
initializeCamera();
return () => {
isMountedRef.current = false;
if (initializationTimeoutRef.current) clearTimeout(initializationTimeoutRef.current);
if (fixTimeoutRef.current) clearTimeout(fixTimeoutRef.current);
if (settleTimeoutRef.current) clearTimeout(settleTimeoutRef.current);
if (switchBackTimeoutRef.current) clearTimeout(switchBackTimeoutRef.current);
if (trackRef.current) {
trackRef.current.stop();
trackRef.current = null;
}
if (processorRef.current) {
processorRef.current.destroy().catch(() => {});
processorRef.current = null;
}
if (videoRef.current) {
try {
if (videoRef.current.srcObject) {
videoRef.current.srcObject = null;
}
} catch {}
}
unsubscribeDevices?.();
};
}, [handleDeviceUpdate, initializeCamera]);
useEffect(() => {
const voiceSettings = VoiceSettingsStore;
const currentConfig = {
videoDeviceId: voiceSettings.videoDeviceId,
backgroundImageId: voiceSettings.backgroundImageId,
cameraResolution: voiceSettings.cameraResolution,
videoFrameRate: voiceSettings.videoFrameRate,
};
const configChanged =
!prevConfigRef.current || JSON.stringify(prevConfigRef.current) !== JSON.stringify(currentConfig);
if (configChanged) {
initializeCamera();
}
}, [
initializeCamera,
VoiceSettingsStore.videoDeviceId,
VoiceSettingsStore.backgroundImageId,
VoiceSettingsStore.cameraResolution,
VoiceSettingsStore.videoFrameRate,
]);
const voiceSettings = VoiceSettingsStore;
const videoDeviceOptions = videoDevices.map((device) => ({
value: device.deviceId,
label: device.label || t`Camera ${device.deviceId.slice(0, 8)}`,
}));
const isValidAspectRatio = resolution
? Math.abs(resolution.width / resolution.height - TARGET_ASPECT_RATIO) < ASPECT_RATIO_TOLERANCE
: null;
const resolutionDisplay = resolution
? {
display: `${resolution.width}×${resolution.height}`,
aspectRatio: (resolution.width / resolution.height).toFixed(3),
frameRate: voiceSettings.videoFrameRate,
}
: null;
return (
<Modal.Root size="medium">
<Modal.Header title={t`Camera Preview`} />
<Modal.Content>
<div className={styles.content}>
<div>
<Select
label={t`Camera`}
value={voiceSettings.videoDeviceId}
options={videoDeviceOptions}
onChange={handleDeviceChange}
/>
</div>
<div className={styles.backgroundSection}>
<div className={styles.backgroundLabel}>
<Trans>Background</Trans>
</div>
<Button variant="primary" onClick={handleOpenBackgroundGallery} leftIcon={<ImageIcon size={16} />}>
<Trans>Change Background</Trans>
</Button>
</div>
<div className={styles.videoContainer}>
<video ref={videoRef} autoPlay playsInline muted className={styles.video} aria-label={t`Camera preview`} />
{(status === 'initializing' || status === 'fixing' || status === 'fix-switching-back') && (
<div className={styles.overlay}>
<Spinner />
<div className={styles.overlayText}>
<div className={styles.overlayTextMedium}>
{status === 'fixing' ? (
<Trans>Optimizing camera...</Trans>
) : status === 'fix-switching-back' ? (
<Trans>Finalizing camera...</Trans>
) : (
<Trans>Initializing camera...</Trans>
)}
</div>
</div>
</div>
)}
{status === 'error' && (
<div className={styles.errorOverlay}>
<div className={styles.errorText}>
<div className={styles.errorTitle}>
<Trans>Camera error</Trans>
</div>
<div className={styles.errorDetail}>{error}</div>
</div>
</div>
)}
<div className={styles.liveLabel}>
<Trans>Live Preview</Trans>
</div>
{status === 'ready' && resolutionDisplay && (
<div className={styles.resolutionInfo}>
<div className={styles.resolutionDetails}>
<div>{resolutionDisplay.display}</div>
<div className={styles.resolutionRow}>
<span>AR: {resolutionDisplay.aspectRatio}</span>
<span>{resolutionDisplay.frameRate}fps</span>
{isValidAspectRatio === false && (
<Tooltip text={t`Not 16:9 aspect ratio`}>
<span className={styles.warningIcon}></span>
</Tooltip>
)}
</div>
</div>
</div>
)}
</div>
</div>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()}>
<Trans>Cancel</Trans>
</Button>
{showEnableCameraButton && !isCameraEnabled && (
<Button onClick={handleEnableCamera} leftIcon={<CameraIcon size={16} />}>
<Trans>Turn On Camera</Trans>
</Button>
)}
</Modal.Footer>
</Modal.Root>
);
});
const CameraPreviewModalInRoom: React.FC<Omit<CameraPreviewModalProps, 'localParticipant' | 'isCameraEnabled'>> =
observer((props) => {
const {localParticipant, isCameraEnabled} = useLocalParticipant();
return (
<CameraPreviewModalContent localParticipant={localParticipant} isCameraEnabled={isCameraEnabled} {...props} />
);
});
const CameraPreviewModalStandalone: React.FC<CameraPreviewModalProps> = observer((props) => {
return <CameraPreviewModalContent localParticipant={undefined} isCameraEnabled={false} {...props} />;
});
export {CameraPreviewModalInRoom, CameraPreviewModalStandalone};

View File

@@ -0,0 +1,76 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--text-primary);
}
.description {
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
color: var(--text-primary-muted);
}
.errorBox {
width: 100%;
padding: 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--status-danger);
background-color: rgb(from var(--status-danger) r g b / 0.1);
}
.errorText {
font-size: 0.875rem;
line-height: 1.25rem;
text-align: center;
color: var(--status-danger);
}
.captchaContainer {
display: flex;
width: 100%;
justify-content: center;
margin-bottom: 1rem;
}
.switchContainer {
text-align: center;
}
.switchButton {
font-size: 0.875rem;
line-height: 1.25rem;
color: var(--text-link);
text-decoration: underline;
cursor: pointer;
}
.switchButton:hover {
color: var(--text-link);
}
.switchButton:disabled {
cursor: not-allowed;
opacity: 0.5;
}

View File

@@ -0,0 +1,179 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import HCaptcha from '@hcaptcha/react-hcaptcha';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useRef, useState} from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {TurnstileWidget} from '~/components/captcha/TurnstileWidget';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import styles from './CaptchaModal.module.css';
export type CaptchaType = 'turnstile' | 'hcaptcha';
interface CaptchaModalProps {
onVerify: (token: string, captchaType: CaptchaType) => void;
onCancel?: () => void;
preferredType?: CaptchaType;
error?: string | null;
isVerifying?: boolean;
closeOnVerify?: boolean;
}
export const CaptchaModal = observer(
({onVerify, onCancel, preferredType, error, isVerifying, closeOnVerify = true}: CaptchaModalProps) => {
const {t} = useLingui();
const hcaptchaRef = useRef<HCaptcha>(null);
const [captchaType, setCaptchaType] = useState<CaptchaType>(() => {
if (preferredType) return preferredType;
if (RuntimeConfigStore.captchaProvider === 'turnstile' && RuntimeConfigStore.turnstileSiteKey) {
return 'turnstile';
}
if (RuntimeConfigStore.captchaProvider === 'hcaptcha' && RuntimeConfigStore.hcaptchaSiteKey) {
return 'hcaptcha';
}
return RuntimeConfigStore.turnstileSiteKey ? 'turnstile' : 'hcaptcha';
});
useEffect(() => {
if (captchaType === 'hcaptcha') {
const timer = setTimeout(() => {
hcaptchaRef.current?.resetCaptcha();
}, 100);
return () => clearTimeout(timer);
}
return;
}, [captchaType]);
useEffect(() => {
if (error) {
if (captchaType === 'hcaptcha') {
hcaptchaRef.current?.resetCaptcha();
}
}
}, [error, captchaType]);
const handleVerify = useCallback(
(token: string) => {
onVerify(token, captchaType);
if (closeOnVerify) {
ModalActionCreators.pop();
}
},
[onVerify, captchaType, closeOnVerify],
);
const handleCancel = useCallback(() => {
onCancel?.();
ModalActionCreators.pop();
}, [onCancel]);
const handleExpire = useCallback(() => {
if (captchaType === 'hcaptcha') {
hcaptchaRef.current?.resetCaptcha();
}
}, [captchaType]);
const handleError = useCallback(
(error: string) => {
console.error(`${captchaType} error:`, error);
},
[captchaType],
);
const handleSwitchToHCaptcha = useCallback(() => {
setCaptchaType('hcaptcha');
}, []);
const handleSwitchToTurnstile = useCallback(() => {
setCaptchaType('turnstile');
}, []);
const showSwitchButton =
(captchaType === 'turnstile' && RuntimeConfigStore.hcaptchaSiteKey) ||
(captchaType === 'hcaptcha' && RuntimeConfigStore.turnstileSiteKey);
return (
<Modal.Root size="small" centered onClose={handleCancel}>
<Modal.Header title={t`Verify You're Human`} onClose={handleCancel} />
<Modal.Content>
<div className={styles.container}>
<p className={styles.description}>
<Trans>We need to make sure you're not a bot. Please complete the verification below.</Trans>
</p>
{error && (
<div className={styles.errorBox}>
<p className={styles.errorText}>{error}</p>
</div>
)}
<div className={styles.captchaContainer}>
{captchaType === 'turnstile' ? (
<TurnstileWidget
sitekey={RuntimeConfigStore.turnstileSiteKey ?? ''}
onVerify={handleVerify}
onExpire={handleExpire}
onError={handleError}
theme="dark"
/>
) : (
<HCaptcha
ref={hcaptchaRef}
sitekey={RuntimeConfigStore.hcaptchaSiteKey ?? ''}
onVerify={handleVerify}
onExpire={handleExpire}
onError={handleError}
theme="dark"
/>
)}
</div>
{showSwitchButton && (
<div className={styles.switchContainer}>
<button
type="button"
onClick={captchaType === 'turnstile' ? handleSwitchToHCaptcha : handleSwitchToTurnstile}
className={styles.switchButton}
disabled={isVerifying}
>
{captchaType === 'turnstile' ? (
<Trans>Having issues? Try hCaptcha instead</Trans>
) : (
<Trans>Try Turnstile instead</Trans>
)}
</button>
</div>
)}
</div>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={handleCancel} disabled={isVerifying}>
<Trans>Cancel</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
},
);

View File

@@ -0,0 +1,88 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useForm} from 'react-hook-form';
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {ChannelTypes} from '~/Constants';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import styles from '~/components/modals/ConfirmModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
interface FormInputs {
name: string;
}
export const CategoryCreateModal = observer(({guildId}: {guildId: string}) => {
const {t} = useLingui();
const form = useForm<FormInputs>();
const onSubmit = async (data: FormInputs) => {
await ChannelActionCreators.create(guildId, {
name: data.name,
url: null,
type: ChannelTypes.GUILD_CATEGORY,
parent_id: null,
bitrate: null,
user_limit: null,
});
ModalActionCreators.pop();
};
const {handleSubmit} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit}>
<Modal.Header title={t`Create Category`} />
<Modal.Content className={styles.content}>
<Input
{...form.register('name')}
autoFocus={true}
autoComplete="off"
error={form.formState.errors.name?.message}
label={t`Name`}
maxLength={100}
minLength={1}
placeholder={t`New Category`}
required={true}
/>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
{t`Cancel`}
</Button>
<Button type="submit" submitting={form.formState.isSubmitting}>
{t`Create Category`}
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

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/>.
*/
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators';
import {BaseChangeNicknameModal} from '~/components/modals/BaseChangeNicknameModal';
import type {UserRecord} from '~/records/UserRecord';
import RelationshipStore from '~/stores/RelationshipStore';
interface ChangeFriendNicknameModalProps {
user: UserRecord;
}
export const ChangeFriendNicknameModal: React.FC<ChangeFriendNicknameModalProps> = observer(({user}) => {
const relationship = RelationshipStore.getRelationship(user.id);
const currentNick = relationship?.nickname ?? '';
const handleSave = React.useCallback(
async (nick: string | null) => {
await RelationshipActionCreators.updateFriendNickname(user.id, nick);
},
[user.id],
);
return <BaseChangeNicknameModal currentNick={currentNick} displayName={user.displayName} onSave={handleSave} />;
});

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 {observer} from 'mobx-react-lite';
import React from 'react';
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
import {BaseChangeNicknameModal} from '~/components/modals/BaseChangeNicknameModal';
import type {UserRecord} from '~/records/UserRecord';
import ChannelStore from '~/stores/ChannelStore';
interface ChangeGroupDMNicknameModalProps {
channelId: string;
user: UserRecord;
}
export const ChangeGroupDMNicknameModal: React.FC<ChangeGroupDMNicknameModalProps> = observer(({channelId, user}) => {
const channel = ChannelStore.getChannel(channelId);
const currentNick = channel?.nicks?.[user.id] || '';
const handleSave = React.useCallback(
async (nick: string | null) => {
await ChannelActionCreators.updateGroupDMNickname(channelId, user.id, nick);
},
[channelId, user.id],
);
return <BaseChangeNicknameModal currentNick={currentNick} displayName={user.displayName} onSave={handleSave} />;
});

View File

@@ -0,0 +1,50 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators';
import {BaseChangeNicknameModal} from '~/components/modals/BaseChangeNicknameModal';
import type {GuildMemberRecord} from '~/records/GuildMemberRecord';
import type {UserRecord} from '~/records/UserRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
interface ChangeNicknameModalProps {
guildId: string;
user: UserRecord;
member: GuildMemberRecord;
}
export const ChangeNicknameModal: React.FC<ChangeNicknameModalProps> = observer(({guildId, user, member}) => {
const currentUserId = AuthenticationStore.currentUserId;
const isCurrentUser = user.id === currentUserId;
const handleSave = React.useCallback(
async (nick: string | null) => {
if (isCurrentUser) {
await GuildMemberActionCreators.updateProfile(guildId, {nick});
} else {
await GuildMemberActionCreators.update(guildId, user.id, {nick});
}
},
[guildId, user.id, isCurrentUser],
);
return <BaseChangeNicknameModal currentNick={member.nick || ''} displayName={user.displayName} onSave={handleSave} />;
});

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/>.
*/
.content {
display: flex;
flex-direction: column;
gap: var(--spacing-5);
padding-bottom: var(--spacing-4);
}
.channelTypeSection {
display: block;
margin-bottom: var(--spacing-4);
}
.channelTypeLabel {
margin-bottom: 8px;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
}
.footer {
align-items: center;
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}

View File

@@ -0,0 +1,109 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {Controller, useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {ChannelTypes} from '~/Constants';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import styles from '~/components/modals/ChannelCreateModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import {
channelTypeOptions,
createChannel,
type FormInputs,
getDefaultValues,
} from '~/utils/modals/ChannelCreateModalUtils';
export const ChannelCreateModal = observer(({guildId, parentId}: {guildId: string; parentId?: string}) => {
const {t} = useLingui();
const form = useForm<FormInputs>({
defaultValues: getDefaultValues(),
});
const onSubmit = async (data: FormInputs) => {
await createChannel(guildId, data, parentId);
};
const {handleSubmit} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit}>
<Modal.Header title={t`Create Channel`} />
<Modal.Content className={styles.content}>
<div className={styles.channelTypeSection}>
<div className={styles.channelTypeLabel}>{t`Channel Type`}</div>
<Controller
name="type"
control={form.control}
render={({field}) => (
<RadioGroup
aria-label={t`Channel type selection`}
value={Number(field.value)}
onChange={(value) => field.onChange(value.toString())}
options={channelTypeOptions}
/>
)}
/>
</div>
<Input
{...form.register('name')}
autoFocus={true}
autoComplete="off"
error={form.formState.errors.name?.message}
label={t`Name`}
maxLength={100}
minLength={1}
placeholder={t`new-channel`}
required={true}
/>
{Number(form.watch('type') || '0') === ChannelTypes.GUILD_LINK && (
<Input
{...form.register('url')}
error={form.formState.errors.url?.message}
label={t`URL`}
maxLength={1024}
placeholder={t`https://example.com`}
required={true}
type="url"
/>
)}
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
{t`Cancel`}
</Button>
<Button type="submit" submitting={form.formState.isSubmitting}>
{t`Create Channel`}
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

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/>.
*/
.message {
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--text-primary);
}
.footer {
align-items: center;
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}

View File

@@ -0,0 +1,80 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import {useState} from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import styles from '~/components/modals/ChannelDeleteModal.module.css';
import confirmStyles from '~/components/modals/ConfirmModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {
type ChannelDeleteModalProps,
deleteChannel,
getChannelDeleteInfo,
} from '~/utils/modals/ChannelDeleteModalUtils';
export const ChannelDeleteModal = observer(({channelId}: ChannelDeleteModalProps) => {
const deleteInfo = getChannelDeleteInfo(channelId);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async () => {
if (!deleteInfo) return;
setIsSubmitting(true);
try {
await deleteChannel(channelId);
} finally {
setIsSubmitting(false);
}
};
if (!deleteInfo) return null;
const {channel, isCategory, title, confirmText} = deleteInfo;
return (
<Modal.Root size="small" centered>
<Modal.Header title={title} />
<Modal.Content className={confirmStyles.content}>
<p className={clsx(styles.message, confirmStyles.descriptionText)}>
{isCategory ? (
<Trans>
Are you sure you want to delete <strong>{channel.name}</strong>? This cannot be undone.
</Trans>
) : (
<Trans>
Are you sure you want to delete <strong>{channel.name}</strong>? This cannot be undone.
</Trans>
)}
</p>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button onClick={onSubmit} submitting={isSubmitting} variant="danger-primary">
<Trans>{confirmText}</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,128 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 ModalActionCreators from '~/actions/ModalActionCreators';
import {ChannelTypes} from '~/Constants';
import * as Modal from '~/components/modals/Modal';
import ChannelStore from '~/stores/ChannelStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
import {
type ChannelSettingsModalProps,
createHandleClose,
getAvailableTabs,
getGroupedSettingsTabs,
} from '~/utils/modals/ChannelSettingsModalUtils';
import {DesktopChannelSettingsView} from './components/DesktopChannelSettingsView';
import {MobileChannelSettingsView} from './components/MobileChannelSettingsView';
import {useMobileNavigation} from './hooks/useMobileNavigation';
import {SettingsModalContainer} from './shared/SettingsModalLayout';
import type {ChannelSettingsTabType} from './utils/channelSettingsConstants';
export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observer(({channelId, initialMobileTab}) => {
const {t} = useLingui();
const channel = ChannelStore.getChannel(channelId);
const [selectedTab, setSelectedTab] = React.useState<ChannelSettingsTabType>('overview');
const availableTabs = React.useMemo(() => {
return getAvailableTabs(t, channelId);
}, [t, channelId]);
const isMobileExperience = isMobileExperienceEnabled();
const initialTab = React.useMemo(() => {
if (!isMobileExperience || !initialMobileTab) return;
const targetTab = availableTabs.find((tab) => tab.type === initialMobileTab);
if (!targetTab) return;
return {tab: initialMobileTab, title: targetTab.label};
}, [initialMobileTab, availableTabs, isMobileExperience]);
const mobileNav = useMobileNavigation<ChannelSettingsTabType>(initialTab);
const {enabled: isMobile} = MobileLayoutStore;
React.useEffect(() => {
if (!channel) {
ModalActionCreators.pop();
}
}, [channel]);
const groupedSettingsTabs = React.useMemo(() => {
return getGroupedSettingsTabs(availableTabs);
}, [availableTabs]);
const currentTab = React.useMemo(() => {
if (!isMobile) {
return availableTabs.find((tab) => tab.type === selectedTab);
}
if (mobileNav.isRootView) return;
return availableTabs.find((tab) => tab.type === mobileNav.currentView?.tab);
}, [isMobile, selectedTab, mobileNav.isRootView, mobileNav.currentView, availableTabs]);
const handleMobileBack = React.useCallback(() => {
if (mobileNav.isRootView) {
ModalActionCreators.pop();
} else {
mobileNav.navigateBack();
}
}, [mobileNav]);
const handleTabSelect = React.useCallback(
(tabType: string, title: string) => {
mobileNav.navigateTo(tabType as ChannelSettingsTabType, title);
},
[mobileNav],
);
const handleClose = React.useCallback(createHandleClose(selectedTab), [selectedTab]);
if (!channel) {
return null;
}
const isCategory = channel.type === ChannelTypes.GUILD_CATEGORY;
return (
<Modal.Root size="fullscreen" onClose={handleClose}>
<Modal.ScreenReaderLabel text={isCategory ? t`Category Settings` : t`Channel Settings`} />
<SettingsModalContainer fullscreen={true}>
{isMobile ? (
<MobileChannelSettingsView
channel={channel}
groupedSettingsTabs={groupedSettingsTabs}
currentTab={currentTab}
mobileNav={mobileNav}
onBack={handleMobileBack}
onTabSelect={handleTabSelect}
/>
) : (
<DesktopChannelSettingsView
channel={channel}
groupedSettingsTabs={groupedSettingsTabs}
currentTab={currentTab}
selectedTab={selectedTab}
onTabSelect={setSelectedTab}
/>
)}
</SettingsModalContainer>
</Modal.Root>
);
});

View File

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

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/>.
*/
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import confirmStyles from '~/components/modals/ConfirmModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {SafeMarkdown} from '~/lib/markdown';
import {MarkdownContext} from '~/lib/markdown/renderers';
import markupStyles from '~/styles/Markup.module.css';
import {type ChannelTopicModalProps, getChannelTopicInfo} from '~/utils/modals/ChannelTopicModalUtils';
import styles from './ChannelTopicModal.module.css';
export const ChannelTopicModal = observer(({channelId}: ChannelTopicModalProps) => {
const topicInfo = getChannelTopicInfo(channelId);
if (!topicInfo) {
return null;
}
const {topic, title} = topicInfo;
return (
<Modal.Root size="small" centered>
<Modal.Header title={title} />
<Modal.Content className={clsx(confirmStyles.content, styles.selectable)}>
<div className={clsx(markupStyles.markup, styles.topic)}>
<SafeMarkdown
content={topic}
options={{
context: MarkdownContext.STANDARD_WITHOUT_JUMBO,
channelId,
}}
/>
</div>
</Modal.Content>
</Modal.Root>
);
});

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/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 16px;
}
.footer {
align-items: center;
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}
.error {
color: var(--warn-text, #f36);
margin-top: 8px;
}

View File

@@ -0,0 +1,232 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import styles from '~/components/modals/ClaimAccountModal.module.css';
import confirmStyles from '~/components/modals/ConfirmModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
interface FormInputs {
email: string;
newPassword: string;
}
type Stage = 'collect' | 'verify';
export const ClaimAccountModal = observer(() => {
const {t} = useLingui();
const form = useForm<FormInputs>({defaultValues: {email: '', newPassword: ''}});
const [stage, setStage] = useState<Stage>('collect');
const [ticket, setTicket] = useState<string | null>(null);
const [originalProof, setOriginalProof] = useState<string | null>(null);
const [verificationCode, setVerificationCode] = useState<string>('');
const [resendNewAt, setResendNewAt] = useState<Date | null>(null);
const [verificationError, setVerificationError] = useState<string | null>(null);
const [submittingAction, setSubmittingAction] = useState<boolean>(false);
const [now, setNow] = useState<number>(Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
const canResendNew = useMemo(() => !resendNewAt || resendNewAt.getTime() <= now, [resendNewAt, now]);
const resendSecondsRemaining = useMemo(() => {
if (!resendNewAt) return 0;
return Math.max(0, Math.ceil((resendNewAt.getTime() - now) / 1000));
}, [resendNewAt, now]);
const startEmailTokenFlow = async (data: FormInputs) => {
setVerificationError(null);
let activeTicket = ticket;
let activeProof = originalProof;
if (!activeTicket || !activeProof) {
const startResult = await UserActionCreators.startEmailChange();
activeTicket = startResult.ticket;
activeProof = startResult.original_proof ?? null;
setTicket(startResult.ticket);
setOriginalProof(activeProof);
if (startResult.resend_available_at) {
setResendNewAt(new Date(startResult.resend_available_at));
}
}
if (!activeProof) {
throw new Error('Missing original proof token');
}
const result = await UserActionCreators.requestEmailChangeNew(activeTicket!, data.email, activeProof);
setResendNewAt(result.resend_available_at ? new Date(result.resend_available_at) : null);
setVerificationCode('');
setStage('verify');
ToastActionCreators.createToast({type: 'success', children: t`Verification code sent`});
};
const handleVerifyNew = async () => {
if (!ticket || !originalProof) return;
const passwordValid = await form.trigger('newPassword');
if (!passwordValid) {
return;
}
setSubmittingAction(true);
setVerificationError(null);
try {
const {email_token} = await UserActionCreators.verifyEmailChangeNew(ticket, verificationCode, originalProof);
await UserActionCreators.update({
email_token,
new_password: form.getValues('newPassword'),
});
ToastActionCreators.createToast({type: 'success', children: t`Account claimed successfully`});
ModalActionCreators.pop();
} catch (error: any) {
const message = error?.message ?? t`Invalid or expired code`;
setVerificationError(message);
ToastActionCreators.createToast({type: 'error', children: message});
} finally {
setSubmittingAction(false);
}
};
const handleResendNew = async () => {
if (!ticket || !canResendNew) return;
setSubmittingAction(true);
setVerificationError(null);
try {
await UserActionCreators.resendEmailChangeNew(ticket);
setResendNewAt(new Date(Date.now() + 30 * 1000));
ToastActionCreators.createToast({type: 'success', children: t`Code resent`});
} catch (error: any) {
const message = error?.message ?? t`Unable to resend code right now`;
setVerificationError(message);
ToastActionCreators.createToast({type: 'error', children: message});
} finally {
setSubmittingAction(false);
}
};
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit: startEmailTokenFlow,
defaultErrorField: 'email',
});
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Claim your account`} />
{stage === 'collect' ? (
<Form form={form} onSubmit={handleSubmit}>
<Modal.Content className={styles.content}>
<p className={confirmStyles.descriptionText}>
<Trans>
Claim your account by adding an email and password. We will send a verification code to confirm your
email before finishing.
</Trans>
</p>
<div className={confirmStyles.inputContainer}>
<Input
{...form.register('email')}
autoComplete="email"
autoFocus={true}
error={form.formState.errors.email?.message}
label={t`Email`}
maxLength={256}
minLength={1}
placeholder={t`marty@example.com`}
required={true}
type="email"
/>
<Input
{...form.register('newPassword')}
autoComplete="new-password"
error={form.formState.errors.newPassword?.message}
label={t`Password`}
maxLength={128}
minLength={8}
placeholder={'•'.repeat(32)}
required={true}
type="password"
/>
</div>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" submitting={isSubmitting}>
<Trans>Send code</Trans>
</Button>
</Modal.Footer>
</Form>
) : (
<>
<Modal.Content className={styles.content}>
<p className={confirmStyles.descriptionText}>
<Trans>
Enter the code we sent to your email to verify it. Your password will be set once the code is confirmed.
</Trans>
</p>
<div className={confirmStyles.inputContainer}>
<Input
value={verificationCode}
onChange={(event) => setVerificationCode(event.target.value)}
autoFocus={true}
label={t`Verification code`}
placeholder="XXXX-XXXX"
required={true}
error={verificationError ?? undefined}
/>
<Input
{...form.register('newPassword')}
autoComplete="new-password"
error={form.formState.errors.newPassword?.message}
label={t`Password`}
maxLength={128}
minLength={8}
placeholder={'•'.repeat(32)}
required={true}
type="password"
/>
</div>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleResendNew} disabled={!canResendNew || submittingAction}>
{canResendNew ? <Trans>Resend</Trans> : <Trans>Resend ({resendSecondsRemaining}s)</Trans>}
</Button>
<Button onClick={handleVerifyNew} submitting={submittingAction}>
<Trans>Claim Account</Trans>
</Button>
</Modal.Footer>
</>
)}
</Modal.Root>
);
});

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/>.
*/
.footer {
align-items: center;
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}
.messagePreview {
pointer-events: none;
position: relative;
overflow: hidden;
border-radius: 6px;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary);
padding: 8px 0;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 16px;
}
.descriptionText {
display: block;
margin-bottom: var(--spacing-4);
}
.inputContainer {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}

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 {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {MessagePreviewContext} from '~/Constants';
import {Message} from '~/components/channel/Message';
import styles from '~/components/modals/ConfirmModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {MessageRecord} from '~/records/MessageRecord';
import ChannelStore from '~/stores/ChannelStore';
type ConfirmModalCheckboxProps = {
checked?: boolean;
onChange?: (checked: boolean) => void;
};
type ConfirmModalPrimaryVariant = 'primary' | 'danger-primary';
type ConfirmModalProps =
| {
title: React.ReactNode;
description: React.ReactNode;
message?: MessageRecord;
primaryText: React.ReactNode;
primaryVariant?: ConfirmModalPrimaryVariant;
secondaryText?: React.ReactNode | false;
size?: Modal.ModalProps['size'];
onPrimary: (checkboxChecked?: boolean) => Promise<void> | void;
onSecondary?: (checkboxChecked?: boolean) => void;
checkboxContent?: React.ReactElement<ConfirmModalCheckboxProps>;
}
| {
title: React.ReactNode;
description: React.ReactNode;
message?: MessageRecord;
primaryText?: never;
primaryVariant?: never;
secondaryText?: React.ReactNode | false;
size?: Modal.ModalProps['size'];
onPrimary?: never;
onSecondary?: (checkboxChecked?: boolean) => void;
checkboxContent?: React.ReactElement<ConfirmModalCheckboxProps>;
};
export const ConfirmModal = observer(
({
title,
description,
message,
primaryText,
primaryVariant = 'danger-primary',
secondaryText,
size = 'small',
onPrimary,
onSecondary,
checkboxContent,
}: ConfirmModalProps) => {
const {t} = useLingui();
const [submitting, setSubmitting] = React.useState(false);
const [checkboxChecked, setCheckboxChecked] = React.useState(false);
const initialFocusRef = React.useRef<HTMLButtonElement | null>(null);
const previewBehaviorOverrides = React.useMemo(
() => ({
isEditing: false,
isHighlight: false,
disableContextMenu: true,
disableContextMenuTracking: true,
contextMenuOpen: false,
}),
[],
);
const messageSnapshot = React.useMemo(() => {
if (!message) return undefined;
return new MessageRecord(message.toJSON());
}, [message?.id]);
const handlePrimaryClick = React.useCallback(async () => {
if (!onPrimary) {
return;
}
setSubmitting(true);
try {
await onPrimary(checkboxChecked);
ModalActionCreators.pop();
} finally {
setSubmitting(false);
}
}, [onPrimary, checkboxChecked]);
const handleSecondaryClick = React.useCallback(() => {
if (onSecondary) {
onSecondary(checkboxChecked);
}
ModalActionCreators.pop();
}, [onSecondary, checkboxChecked]);
return (
<Modal.Root size={size} initialFocusRef={initialFocusRef} centered>
<Modal.Header title={title} />
<Modal.Content className={styles.content}>
<div className={styles.descriptionText}>{description}</div>
{React.isValidElement(checkboxContent) && (
<div style={{marginTop: '16px'}}>
{React.cloneElement(checkboxContent, {
checked: checkboxChecked,
onChange: (value: boolean) => setCheckboxChecked(value),
})}
</div>
)}
{messageSnapshot && (
<div className={styles.messagePreview}>
<Message
channel={ChannelStore.getChannel(messageSnapshot.channelId)!}
message={messageSnapshot}
previewContext={MessagePreviewContext.LIST_POPOUT}
removeTopSpacing={true}
behaviorOverrides={previewBehaviorOverrides}
/>
</div>
)}
</Modal.Content>
<Modal.Footer className={styles.footer}>
{secondaryText !== false && (
<Button onClick={handleSecondaryClick} variant="secondary">
{secondaryText ?? t`Cancel`}
</Button>
)}
{onPrimary && primaryText && (
<Button onClick={handlePrimaryClick} submitting={submitting} variant={primaryVariant} ref={initialFocusRef}>
{primaryText}
</Button>
)}
</Modal.Footer>
</Modal.Root>
);
},
);

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/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {MagnifyingGlassIcon} 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 {FriendSelector} from '~/components/common/FriendSelector';
import {Input} from '~/components/form/Input';
import {DuplicateGroupConfirmModal} from '~/components/modals/DuplicateGroupConfirmModal';
import * as Modal from '~/components/modals/Modal';
import selectorStyles from '~/components/modals/shared/SelectorModalStyles.module.css';
import {Button} from '~/components/uikit/Button/Button';
import {type CreateDMModalProps, useCreateDMModalLogic} from '~/utils/modals/CreateDMModalUtils';
export const CreateDMModal = observer((props: CreateDMModalProps) => {
const {t} = useLingui();
const modalLogic = useCreateDMModalLogic(props);
const handleCreate = React.useCallback(async () => {
const result = await modalLogic.handleCreate();
if (result && result.duplicates.length > 0) {
ModalActionCreators.push(
modal(() => (
<DuplicateGroupConfirmModal
channels={result.duplicates}
onConfirm={() => modalLogic.handleCreateChannel(result.selectionSnapshot)}
/>
)),
);
}
}, [modalLogic]);
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Select Friends`}>
<p className={selectorStyles.subtitle}>
{modalLogic.selectedUserIds.length === 0 ? (
<Trans>You can add up to {modalLogic.maxSelections} friends</Trans>
) : (
<Trans>
You can add {Math.max(0, modalLogic.maxSelections - modalLogic.selectedUserIds.length)} more friends
</Trans>
)}
</p>
<div className={selectorStyles.headerSearch}>
<Input
value={modalLogic.searchQuery}
onChange={(e) => modalLogic.setSearchQuery(e.target.value)}
placeholder={t`Search friends`}
leftIcon={<MagnifyingGlassIcon weight="bold" className={selectorStyles.searchIcon} />}
className={selectorStyles.headerSearchInput}
/>
</div>
</Modal.Header>
<Modal.Content className={selectorStyles.selectorContent}>
<FriendSelector
selectedUserIds={modalLogic.selectedUserIds}
onToggle={modalLogic.handleToggle}
maxSelections={modalLogic.maxSelections}
searchQuery={modalLogic.searchQuery}
onSearchQueryChange={modalLogic.setSearchQuery}
showSearchInput={false}
stickyUserIds={props.initialSelectedUserIds}
/>
</Modal.Content>
<Modal.Footer className={selectorStyles.footer}>
<div className={selectorStyles.actionRow}>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()} className={selectorStyles.actionButton}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={handleCreate}
disabled={modalLogic.isCreating}
submitting={modalLogic.isCreating}
className={selectorStyles.actionButton}
>
{modalLogic.buttonText}
</Button>
</div>
</Modal.Footer>
</Modal.Root>
);
});

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/>.
*/
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import styles from '~/components/modals/ConfirmModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import FavoritesStore from '~/stores/FavoritesStore';
interface FormInputs {
name: string;
}
export const CreateFavoriteCategoryModal = observer(() => {
const {t} = useLingui();
const form = useForm<FormInputs>({
defaultValues: {
name: '',
},
});
const onSubmit = async (data: FormInputs) => {
FavoritesStore.createCategory(data.name);
ModalActionCreators.pop();
};
const {handleSubmit} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit} aria-label={t`Create favorite category form`}>
<Modal.Header title={t`Create Category`} />
<Modal.Content className={styles.content}>
<Input
{...form.register('name')}
autoFocus={true}
autoComplete="off"
error={form.formState.errors.name?.message}
label={t`Category Name`}
maxLength={100}
minLength={1}
placeholder={t`New Category`}
required={true}
/>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
{t`Cancel`}
</Button>
<Button type="submit" submitting={form.formState.isSubmitting}>
{t`Create Category`}
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

View File

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

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 {Trans, 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 {Form} from '~/components/form/Form';
import {Input, Textarea} from '~/components/form/Input';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import PackStore from '~/stores/PackStore';
import styles from './CreatePackModal.module.css';
interface FormInputs {
name: string;
description: string;
}
interface CreatePackModalProps {
type: 'emoji' | 'sticker';
onSuccess?: () => void;
}
export const CreatePackModal = observer(({type, onSuccess}: CreatePackModalProps) => {
const {t} = useLingui();
const form = useForm<FormInputs>({
defaultValues: {
name: '',
description: '',
},
});
const title = type === 'emoji' ? t`Create Emoji Pack` : t`Create Sticker Pack`;
const submitHandler = React.useCallback(
async (data: FormInputs) => {
await PackStore.createPack(type, data.name.trim(), data.description.trim() || null);
onSuccess?.();
ModalActionCreators.pop();
},
[type, onSuccess],
);
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit: submitHandler,
defaultErrorField: 'name',
});
return (
<Modal.Root size="small" onClose={() => ModalActionCreators.pop()}>
<Modal.Header title={title} />
<Modal.Content>
<p className={styles.description}>
{type === 'emoji' ? (
<Trans>Start curating a custom emoji pack that you can share and install.</Trans>
) : (
<Trans>Bundle your favorite stickers into a pack you can distribute.</Trans>
)}
</p>
<Form className={styles.form} form={form} onSubmit={handleSubmit}>
<div className={styles.formFields}>
<Input
id="pack-name"
label={t`Pack name`}
error={form.formState.errors.name?.message}
{...form.register('name', {
required: t`Pack name is required`,
minLength: {value: 2, message: t`Pack name must be at least 2 characters`},
maxLength: {value: 64, message: t`Pack name must be at most 64 characters`},
})}
placeholder={t`My super pack`}
/>
<Textarea
id="pack-description"
label={t`Description`}
error={form.formState.errors.description?.message}
{...form.register('description', {maxLength: {value: 256, message: t`Maximum 256 characters`}})}
placeholder={t`Describe what expressions are inside this pack.`}
minRows={3}
/>
</div>
</Form>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleSubmit} submitting={isSubmitting}>
<Trans>Create</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,134 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 1rem;
padding-bottom: 1rem;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.expirySelector {
display: flex;
align-items: center;
gap: 0.5rem;
}
.expirySelectorLabel {
font-size: 0.75rem;
color: var(--text-tertiary);
white-space: nowrap;
}
.expirySelect {
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-textarea);
color: var(--text-primary);
font-size: 0.8125rem;
cursor: pointer;
}
.expirySelect:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.saveButton {
flex-shrink: 0;
}
.emojiTriggerButton {
width: 32px;
height: 32px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--text-primary-muted);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
background-color var(--transition-normal),
color var(--transition-normal);
}
.emojiTriggerButton:active {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.emojiTriggerButtonActive {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.emojiTriggerButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.emojiPreviewImage {
width: 22px;
height: 22px;
object-fit: contain;
}
.emojiPreviewNative {
font-size: 22px;
line-height: 1;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.clearButtonIcon {
border: none;
background: transparent;
color: var(--text-primary-muted);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 999px;
cursor: pointer;
transition:
background-color var(--transition-normal),
color var(--transition-normal);
}
.clearButtonIcon:active {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.clearButtonIcon:disabled {
opacity: 0.4;
cursor: not-allowed;
}

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 {Trans, useLingui} from '@lingui/react/macro';
import {SmileyIcon, XIcon} from '@phosphor-icons/react';
import clsx from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import {Input} from '~/components/form/Input';
import {ExpressionPickerSheet} from '~/components/modals/ExpressionPickerSheet';
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {type CustomStatus, normalizeCustomStatus} from '~/lib/customStatus';
import type {Emoji} from '~/stores/EmojiStore';
import EmojiStore from '~/stores/EmojiStore';
import PresenceStore from '~/stores/PresenceStore';
import UserStore from '~/stores/UserStore';
import {getEmojiURL, shouldUseNativeEmoji} from '~/utils/EmojiUtils';
import styles from './CustomStatusBottomSheet.module.css';
const CUSTOM_STATUS_SNAP_POINTS: Array<number> = [0, 1];
const EXPIRY_OPTIONS = [
{id: 'never', label: <Trans>Don&apos;t clear</Trans>, minutes: null},
{id: '30m', label: <Trans>30 minutes</Trans>, minutes: 30},
{id: '1h', label: <Trans>1 hour</Trans>, minutes: 60},
{id: '4h', label: <Trans>4 hours</Trans>, minutes: 4 * 60},
{id: '24h', label: <Trans>24 hours</Trans>, minutes: 24 * 60},
];
interface CustomStatusBottomSheetProps {
isOpen: boolean;
onClose: () => void;
}
const buildDraftStatus = (params: {
text: string;
emojiId: string | null;
emojiName: string | null;
expiresAt: string | null;
}): CustomStatus | null => {
return normalizeCustomStatus({
text: params.text || null,
emojiId: params.emojiId,
emojiName: params.emojiName,
expiresAt: params.expiresAt,
});
};
export const CustomStatusBottomSheet = observer(({isOpen, onClose}: CustomStatusBottomSheetProps) => {
const {t} = useLingui();
const currentUser = UserStore.getCurrentUser();
const currentUserId = currentUser?.id ?? null;
const existingCustomStatus = currentUserId ? PresenceStore.getCustomStatus(currentUserId) : null;
const normalizedExisting = normalizeCustomStatus(existingCustomStatus);
const [statusText, setStatusText] = React.useState('');
const [emojiId, setEmojiId] = React.useState<string | null>(null);
const [emojiName, setEmojiName] = React.useState<string | null>(null);
const [selectedExpiry, setSelectedExpiry] = React.useState<string>('never');
const [isSaving, setIsSaving] = React.useState(false);
const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false);
const mountedAt = React.useMemo(() => new Date(), []);
React.useEffect(() => {
if (isOpen) {
setStatusText(normalizedExisting?.text ?? '');
setEmojiId(normalizedExisting?.emojiId ?? null);
setEmojiName(normalizedExisting?.emojiName ?? null);
setSelectedExpiry('never');
}
}, [isOpen, normalizedExisting?.text, normalizedExisting?.emojiId, normalizedExisting?.emojiName]);
const getExpiresAt = React.useCallback(
(expiryId: string): string | null => {
const option = EXPIRY_OPTIONS.find((o) => o.id === expiryId);
if (!option?.minutes) return null;
return new Date(mountedAt.getTime() + option.minutes * 60 * 1000).toISOString();
},
[mountedAt],
);
const draftStatus = React.useMemo(
() => buildDraftStatus({text: statusText.trim(), emojiId, emojiName, expiresAt: getExpiresAt(selectedExpiry)}),
[statusText, emojiId, emojiName, selectedExpiry, getExpiresAt],
);
const handleEmojiSelect = React.useCallback((emoji: Emoji) => {
if (emoji.id) {
setEmojiId(emoji.id);
setEmojiName(emoji.name);
} else {
setEmojiId(null);
setEmojiName(emoji.surrogates ?? emoji.name);
}
}, []);
const handleClearDraft = () => {
setStatusText('');
setEmojiId(null);
setEmojiName(null);
};
const handleSave = async () => {
if (isSaving) return;
setIsSaving(true);
try {
await UserSettingsActionCreators.update({customStatus: draftStatus});
onClose();
} finally {
setIsSaving(false);
}
};
const renderEmojiPreview = (): React.ReactNode => {
if (!draftStatus) return null;
if (draftStatus.emojiId) {
const emoji = EmojiStore.getEmojiById(draftStatus.emojiId);
if (emoji?.url) {
return <img src={emoji.url} alt={emoji.name} className={styles.emojiPreviewImage} />;
}
}
if (draftStatus.emojiName) {
if (!shouldUseNativeEmoji) {
const twemojiUrl = getEmojiURL(draftStatus.emojiName);
if (twemojiUrl) {
return <img src={twemojiUrl} alt={draftStatus.emojiName} className={styles.emojiPreviewImage} />;
}
}
return <span className={styles.emojiPreviewNative}>{draftStatus.emojiName}</span>;
}
return null;
};
const emojiPreview = renderEmojiPreview();
return (
<BottomSheet
isOpen={isOpen}
onClose={onClose}
snapPoints={CUSTOM_STATUS_SNAP_POINTS}
initialSnap={CUSTOM_STATUS_SNAP_POINTS.length - 1}
title={t`Set Custom Status`}
zIndex={10001}
>
<div className={styles.content}>
<Input
id="custom-status-text"
value={statusText}
onChange={(event) => setStatusText(event.target.value.slice(0, 128))}
maxLength={128}
placeholder={t`What's happening?`}
leftElement={
<FocusRing offset={-2} enabled={!isSaving}>
<button
type="button"
className={clsx(styles.emojiTriggerButton, emojiPickerOpen && styles.emojiTriggerButtonActive)}
aria-label={emojiPreview ? t`Change emoji` : t`Choose an emoji`}
disabled={isSaving}
onClick={() => setEmojiPickerOpen(true)}
>
{emojiPreview ?? <SmileyIcon size={22} weight="fill" aria-hidden="true" />}
</button>
</FocusRing>
}
rightElement={
draftStatus ? (
<FocusRing offset={-2} enabled={!isSaving}>
<button
type="button"
className={styles.clearButtonIcon}
onClick={handleClearDraft}
disabled={isSaving}
aria-label={t`Clear custom status`}
>
<XIcon size={16} weight="bold" />
</button>
</FocusRing>
) : null
}
/>
<ExpressionPickerSheet
isOpen={emojiPickerOpen}
onClose={() => setEmojiPickerOpen(false)}
onEmojiSelect={(emoji) => {
handleEmojiSelect(emoji);
setEmojiPickerOpen(false);
}}
visibleTabs={['emojis']}
zIndex={10002}
/>
<div className={styles.footer}>
<div className={styles.expirySelector}>
<span className={styles.expirySelectorLabel}>
<Trans>Clear after</Trans>
</span>
<select
className={styles.expirySelect}
value={selectedExpiry}
onChange={(e) => setSelectedExpiry(e.target.value)}
disabled={isSaving}
>
{EXPIRY_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{typeof option.label === 'string' ? option.label : option.id}
</option>
))}
</select>
</div>
<Button variant="primary" onClick={handleSave} submitting={isSaving} className={styles.saveButton}>
<Trans>Save</Trans>
</Button>
</div>
</div>
</BottomSheet>
);
});
CustomStatusBottomSheet.displayName = 'CustomStatusBottomSheet';

View File

@@ -0,0 +1,146 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.modalRoot {
width: min(520px, 90vw);
max-width: 520px;
}
.previewSection {
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.previewStatus {
word-break: break-word;
}
.previewStatusPlaceholder {
min-height: 1rem;
}
.statusInputWrapper {
width: 100%;
max-width: 520px;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.characterCount {
font-size: 0.75rem;
color: var(--text-tertiary);
text-align: right;
}
.inputRight {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.emojiTriggerButton {
width: 32px;
height: 32px;
border-radius: 999px;
border: none;
background: transparent;
color: var(--text-primary-muted);
display: inline-flex;
align-items: center;
justify-content: center;
transition:
background-color var(--transition-normal),
color var(--transition-normal);
}
.emojiPreviewImage {
width: 22px;
height: 22px;
object-fit: contain;
}
.emojiPreviewNative {
font-size: 22px;
line-height: 1;
font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif;
}
.emojiTriggerButton:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.emojiTriggerButtonActive {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.clearButtonIcon {
border: none;
background: transparent;
color: var(--text-primary-muted);
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 999px;
}
.clearButtonIcon:hover {
background-color: var(--background-modifier-hover);
color: var(--text-primary);
}
.clearButtonIcon:disabled {
opacity: 0.4;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.footer > button {
flex: 0 0 auto !important;
}
.expirationSelectWrapper {
position: relative;
min-width: fit-content;
}
.expirationLabel {
position: absolute;
top: -1.25rem;
left: 0;
font-size: 0.75rem;
color: var(--text-tertiary);
pointer-events: none;
}
.expirationSelect {
width: 100%;
}

View File

@@ -0,0 +1,369 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {I18n} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {Trans, useLingui} from '@lingui/react/macro';
import {SmileyIcon, XIcon} 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 * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import {Input} from '~/components/form/Input';
import {Select, type SelectOption} from '~/components/form/Select';
import * as Modal from '~/components/modals/Modal';
import {ExpressionPickerPopout} from '~/components/popouts/ExpressionPickerPopout';
import {ProfilePreview} from '~/components/profile/ProfilePreview';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Popout} from '~/components/uikit/Popout/Popout';
import {type CustomStatus, normalizeCustomStatus} from '~/lib/customStatus';
import type {Emoji} from '~/stores/EmojiStore';
import EmojiStore from '~/stores/EmojiStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import UserStore from '~/stores/UserStore';
import {getEmojiURL, shouldUseNativeEmoji} from '~/utils/EmojiUtils';
import styles from './CustomStatusModal.module.css';
const MS_PER_MINUTE = 60 * 1000;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
interface TimeLabel {
dayLabel: string;
timeString: string;
}
type ExpirationKey = '24h' | '4h' | '1h' | '30m' | 'never';
interface ExpirationOption {
key: ExpirationKey;
minutes: number | null;
expiresAt: string | null;
relativeLabel: TimeLabel | null;
label: string;
}
const DEFAULT_EXPIRATION_KEY: ExpirationKey = '24h';
const getPopoutClose = (renderProps: unknown): (() => void) => {
const props = renderProps as {
close?: unknown;
requestClose?: unknown;
onClose?: unknown;
};
if (typeof props.close === 'function') return props.close as () => void;
if (typeof props.requestClose === 'function') return props.requestClose as () => void;
if (typeof props.onClose === 'function') return props.onClose as () => void;
return () => {};
};
const formatLabelWithRelative = (label: string, relative: TimeLabel | null): React.ReactNode => {
if (!relative) return label;
return (
<>
{label} (
<Trans>
{relative.dayLabel} at {relative.timeString}
</Trans>
)
</>
);
};
const getDayDifference = (reference: Date, target: Date): number => {
const referenceDayStart = new Date(reference.getFullYear(), reference.getMonth(), reference.getDate());
const targetDayStart = new Date(target.getFullYear(), target.getMonth(), target.getDate());
return Math.round((targetDayStart.getTime() - referenceDayStart.getTime()) / MS_PER_DAY);
};
const formatTimeString = (date: Date): string =>
date.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit', hourCycle: 'h23'});
const formatRelativeDayTimeLabel = (i18n: I18n, reference: Date, target: Date): TimeLabel => {
const dayOffset = getDayDifference(reference, target);
const timeString = formatTimeString(target);
if (dayOffset === 0) return {dayLabel: i18n._(msg`today`), timeString};
if (dayOffset === 1) return {dayLabel: i18n._(msg`tomorrow`), timeString};
const dayLabel = target.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
});
return {dayLabel, timeString};
};
const buildDraftStatus = (params: {
text: string;
emojiId: string | null;
emojiName: string | null;
expiresAt: string | null;
}): CustomStatus | null => {
return normalizeCustomStatus({
text: params.text || null,
emojiId: params.emojiId,
emojiName: params.emojiName,
expiresAt: params.expiresAt,
});
};
export const CustomStatusModal = observer(() => {
const {i18n} = useLingui();
const initialStatus = normalizeCustomStatus(UserSettingsStore.customStatus);
const currentUser = UserStore.getCurrentUser();
const [statusText, setStatusText] = React.useState(initialStatus?.text ?? '');
const [emojiId, setEmojiId] = React.useState<string | null>(initialStatus?.emojiId ?? null);
const [emojiName, setEmojiName] = React.useState<string | null>(initialStatus?.emojiName ?? null);
const mountedAt = React.useMemo(() => new Date(), []);
const [emojiPickerOpen, setEmojiPickerOpen] = React.useState(false);
const emojiButtonRef = React.useRef<HTMLButtonElement | null>(null);
const expirationPresets = React.useMemo(
() => [
{key: '24h' as const, label: i18n._(msg`24 hours`), minutes: 24 * 60},
{key: '4h' as const, label: i18n._(msg`4 hours`), minutes: 4 * 60},
{key: '1h' as const, label: i18n._(msg`1 hour`), minutes: 60},
{key: '30m' as const, label: i18n._(msg`30 minutes`), minutes: 30},
{key: 'never' as const, label: i18n._(msg`Don't clear`), minutes: null},
],
[i18n],
);
const expirationOptions = React.useMemo<Array<ExpirationOption>>(
() =>
expirationPresets.map((preset) => {
if (preset.minutes == null) {
return {...preset, expiresAt: null, relativeLabel: null};
}
const target = new Date(mountedAt.getTime() + preset.minutes * MS_PER_MINUTE);
return {
...preset,
expiresAt: target.toISOString(),
relativeLabel: formatRelativeDayTimeLabel(i18n, mountedAt, target),
};
}),
[mountedAt, i18n, expirationPresets],
);
const expirationLabelMap = React.useMemo<Record<ExpirationKey, TimeLabel | null>>(() => {
return expirationOptions.reduce<Record<ExpirationKey, TimeLabel | null>>(
(acc, option) => {
acc[option.key] = option.relativeLabel;
return acc;
},
{} as Record<ExpirationKey, TimeLabel | null>,
);
}, [expirationOptions]);
const selectOptions = React.useMemo<Array<SelectOption<ExpirationKey>>>(() => {
return expirationOptions.map((option) => ({value: option.key, label: option.label}));
}, [expirationOptions]);
const [selectedExpiration, setSelectedExpiration] = React.useState<ExpirationKey>(DEFAULT_EXPIRATION_KEY);
const [expiresAt, setExpiresAt] = React.useState<string | null>(() => {
return expirationOptions.find((option) => option.key === DEFAULT_EXPIRATION_KEY)?.expiresAt ?? null;
});
const [isSaving, setIsSaving] = React.useState(false);
const draftStatus = React.useMemo(
() => buildDraftStatus({text: statusText.trim(), emojiId, emojiName, expiresAt}),
[statusText, emojiId, emojiName, expiresAt],
);
const handleExpirationChange = (value: ExpirationKey) => {
const option = expirationOptions.find((entry) => entry.key === value);
setSelectedExpiration(value);
setExpiresAt(option?.expiresAt ?? null);
};
const handleEmojiSelect = React.useCallback((emoji: Emoji) => {
if (emoji.id) {
setEmojiId(emoji.id);
setEmojiName(emoji.name);
} else {
setEmojiId(null);
setEmojiName(emoji.surrogates ?? emoji.name);
}
}, []);
const handleSave = async () => {
if (isSaving) return;
setIsSaving(true);
try {
await UserSettingsActionCreators.update({customStatus: draftStatus});
ModalActionCreators.pop();
} finally {
setIsSaving(false);
}
};
const handleClearDraft = () => {
setStatusText('');
setEmojiId(null);
setEmojiName(null);
};
const renderEmojiPreview = (): React.ReactNode => {
if (!draftStatus) return null;
if (draftStatus.emojiId) {
const emoji = EmojiStore.getEmojiById(draftStatus.emojiId);
if (emoji?.url) {
return <img src={emoji.url} alt={emoji.name} className={styles.emojiPreviewImage} />;
}
}
if (draftStatus.emojiName) {
if (!shouldUseNativeEmoji) {
const twemojiUrl = getEmojiURL(draftStatus.emojiName);
if (twemojiUrl) {
return <img src={twemojiUrl} alt={draftStatus.emojiName} className={styles.emojiPreviewImage} />;
}
}
return <span className={styles.emojiPreviewNative}>{draftStatus.emojiName}</span>;
}
return null;
};
const emojiPreview = renderEmojiPreview();
return (
<Modal.Root onClose={() => ModalActionCreators.pop()} size="medium" className={styles.modalRoot}>
<Modal.ScreenReaderLabel text={i18n._(msg`Set your status`)} />
<Modal.Header title={i18n._(msg`Set your status`)} />
<Modal.Content>
<div className={styles.previewSection}>
{currentUser && (
<ProfilePreview
user={currentUser}
showMembershipInfo={false}
showMessageButton={false}
showPreviewLabel={false}
previewCustomStatus={draftStatus}
/>
)}
</div>
<div className={styles.statusInputWrapper}>
<Input
id="custom-status-text"
value={statusText}
onChange={(event) => setStatusText(event.target.value.slice(0, 128))}
maxLength={128}
placeholder={i18n._(msg`What's happening?`)}
leftElement={
<Popout
position="bottom-start"
animationType="none"
offsetMainAxis={8}
offsetCrossAxis={0}
onOpen={() => setEmojiPickerOpen(true)}
onClose={() => setEmojiPickerOpen(false)}
returnFocusRef={emojiButtonRef}
render={(renderProps) => {
const closePopout = getPopoutClose(renderProps);
return (
<ExpressionPickerPopout
onEmojiSelect={(emoji) => {
handleEmojiSelect(emoji);
setEmojiPickerOpen(false);
closePopout();
}}
onClose={() => {
setEmojiPickerOpen(false);
closePopout();
}}
visibleTabs={['emojis']}
/>
);
}}
>
<FocusRing offset={-2} enabled={!isSaving}>
<button
ref={emojiButtonRef}
type="button"
className={clsx(styles.emojiTriggerButton, emojiPickerOpen && styles.emojiTriggerButtonActive)}
aria-label={emojiPreview ? i18n._(msg`Change emoji`) : i18n._(msg`Choose an emoji`)}
disabled={isSaving}
>
{emojiPreview ?? <SmileyIcon size={22} weight="fill" aria-hidden="true" />}
</button>
</FocusRing>
</Popout>
}
rightElement={
draftStatus ? (
<FocusRing offset={-2} enabled={!isSaving}>
<button
type="button"
className={styles.clearButtonIcon}
onClick={handleClearDraft}
disabled={isSaving}
aria-label={i18n._(msg`Clear custom status`)}
>
<XIcon size={16} weight="bold" />
</button>
</FocusRing>
) : null
}
/>
<div className={styles.characterCount}>{statusText.length}/128</div>
</div>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<div className={styles.expirationSelectWrapper}>
<label className={styles.expirationLabel} htmlFor="custom-status-expiration">
<Trans>Clear after</Trans>
</label>
<Select
id="custom-status-expiration"
className={styles.expirationSelect}
options={selectOptions}
value={selectedExpiration}
onChange={handleExpirationChange}
disabled={isSaving}
renderOption={(option) => formatLabelWithRelative(option.label, expirationLabelMap[option.value])}
renderValue={(option) =>
option ? formatLabelWithRelative(option.label, expirationLabelMap[option.value]) : null
}
/>
</div>
<Button variant="primary" onClick={handleSave} submitting={isSaving}>
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});
CustomStatusModal.displayName = 'CustomStatusModal';

View File

@@ -0,0 +1,74 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {useForm} from 'react-hook-form';
import * as AuthSessionActionCreators from '~/actions/AuthSessionActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {Form} from '~/components/form/Form';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
export const DeviceRevokeModal = observer(({sessionIdHashes}: {sessionIdHashes: Array<string>}) => {
const {t} = useLingui();
const form = useForm();
const sessionCount = sessionIdHashes.length;
const title =
sessionCount === 0
? t`Log out all other devices`
: sessionCount === 1
? t`Log out 1 device`
: t`Log out ${sessionCount} devices`;
const onSubmit = async () => {
await AuthSessionActionCreators.logout(sessionIdHashes);
ModalActionCreators.pop();
ToastActionCreators.createToast({type: 'success', children: t`Device revoked`});
};
const {handleSubmit} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'form',
});
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit}>
<Modal.Header title={title} />
<Modal.Content>
This will log out the selected {sessionCount === 1 ? t`device` : t`devices`} from your account. You will need
to log in again on those {sessionCount === 1 ? t`device` : t`devices`}.
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" submitting={form.formState.isSubmitting}>
<Trans>Continue</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.description {
margin: 0;
color: var(--text-secondary);
line-height: 1.5;
font-size: 0.9rem;
}
.channelList {
margin-top: var(--spacing-4);
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.channelItem {
display: flex;
align-items: center;
justify-content: flex-start;
gap: var(--spacing-2);
width: 100%;
background: var(--surface-primary);
border: 1px solid var(--border-secondary);
border-radius: var(--border-radius-4);
padding: var(--spacing-2);
cursor: pointer;
text-align: left;
}
.channelItem:hover {
background: var(--surface-tertiary);
}
.channelItem:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.avatarWrapper {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
}
.channelDetails {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-0-5);
flex: 1;
}
.channelName {
font-weight: 600;
color: var(--text-primary);
}
.lastActive {
font-size: 0.75rem;
color: var(--text-tertiary);
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 ModalActionCreators from '~/actions/ModalActionCreators';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Routes} from '~/Routes';
import type {ChannelRecord} from '~/records/ChannelRecord';
import * as ChannelUtils from '~/utils/ChannelUtils';
import * as DateUtils from '~/utils/DateUtils';
import * as RouterUtils from '~/utils/RouterUtils';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
import styles from './DuplicateGroupConfirmModal.module.css';
interface DuplicateGroupConfirmModalProps {
channels: Array<ChannelRecord>;
onConfirm: () => Promise<void> | void;
}
export const DuplicateGroupConfirmModal = observer(({channels, onConfirm}: DuplicateGroupConfirmModalProps) => {
const {t} = useLingui();
const handleChannelClick = React.useCallback((channelId: string) => {
ModalActionCreators.pop();
RouterUtils.transitionTo(Routes.dmChannel(channelId));
}, []);
const description = React.useMemo(() => {
return (
<>
<p className={styles.description}>
<Trans>
You already have a group with these users. Do you really want to create a new one? That&apos;s fine too!
</Trans>
</p>
{channels.length > 0 && (
<div className={styles.channelList}>
{channels.map((channel) => {
const lastActivitySnowflake = channel.lastMessageId ?? channel.id;
const lastActiveText = DateUtils.getShortRelativeDateString(
SnowflakeUtils.extractTimestamp(lastActivitySnowflake),
);
const lastActiveLabel = lastActiveText || t`No activity yet`;
return (
<FocusRing key={channel.id} offset={-2}>
<button type="button" className={styles.channelItem} onClick={() => handleChannelClick(channel.id)}>
<div className={styles.avatarWrapper}>
<GroupDMAvatar channel={channel} size={40} />
</div>
<div className={styles.channelDetails}>
<span className={styles.channelName}>{ChannelUtils.getDMDisplayName(channel)}</span>
<span className={styles.lastActive}>{lastActiveLabel}</span>
</div>
</button>
</FocusRing>
);
})}
</div>
)}
</>
);
}, [channels, handleChannelClick]);
return (
<ConfirmModal
title={t`Confirm New Group`}
description={description}
primaryText={t`Create new group`}
primaryVariant="primary"
secondaryText={t`Cancel`}
size="small"
onPrimary={onConfirm}
/>
);
});

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/>.
*/
.formContainer {
display: flex;
flex-direction: column;
gap: 16px;
}

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 {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useForm} from 'react-hook-form';
import * as FavoriteMemeActionCreators from '~/actions/FavoriteMemeActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {Form} from '~/components/form/Form';
import styles from '~/components/modals/EditFavoriteMemeModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {MemeFormFields} from '~/components/modals/meme-form/MemeFormFields';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import type {FavoriteMemeRecord} from '~/records/FavoriteMemeRecord';
interface EditFavoriteMemeModalProps {
meme: FavoriteMemeRecord;
}
interface FormInputs {
name: string;
altText?: string;
tags: Array<string>;
}
export const EditFavoriteMemeModal = observer(function EditFavoriteMemeModal({meme}: EditFavoriteMemeModalProps) {
const {t, i18n} = useLingui();
const form = useForm<FormInputs>({
defaultValues: {
name: meme.name,
altText: meme.altText || '',
tags: meme.tags,
},
});
const onSubmit = React.useCallback(
async (data: FormInputs) => {
await FavoriteMemeActionCreators.updateFavoriteMeme(i18n, {
memeId: meme.id,
name: data.name !== meme.name ? data.name.trim() : undefined,
altText: data.altText !== (meme.altText || '') ? data.altText?.trim() || null : undefined,
tags: JSON.stringify(data.tags) !== JSON.stringify(meme.tags) ? data.tags : undefined,
});
ModalActionCreators.pop();
},
[meme],
);
const {handleSubmit: handleSave} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Edit Saved Media`} />
<Modal.Content>
<Form form={form} onSubmit={handleSave} aria-label={t`Edit saved media form`}>
<div className={styles.formContainer}>
<MemeFormFields form={form} />
</div>
</Form>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleSave} disabled={!form.watch('name')?.trim() || form.formState.isSubmitting}>
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,138 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
height: 100%;
flex-direction: column;
overflow: hidden;
}
.backButton {
display: flex;
align-items: center;
color: var(--text-primary);
}
.backIcon {
width: 20px;
height: 20px;
}
.scrollContent {
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
.form {
display: flex;
flex-direction: column;
gap: 24px;
}
.iconSection {
display: flex;
flex-direction: column;
}
.iconLabel {
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.iconContainer {
display: flex;
align-items: center;
gap: 16px;
}
.iconPreview {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: 50%;
background-size: cover;
background-position: center;
}
.iconPlaceholder {
display: flex;
width: 80px;
height: 80px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 4px dashed var(--text-tertiary);
}
.iconPlaceholderIcon {
width: 32px;
height: 32px;
color: var(--text-tertiary);
}
.iconActions {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}
.iconButtonGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
.iconHint {
font-size: 14px;
color: var(--text-primary-muted);
}
.iconError {
margin-top: 8px;
font-size: 14px;
color: var(--status-danger);
}
.footer {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}
.fullWidth {
width: 100%;
}
.hiddenInput {
display: none;
}
.scroller {
flex: 1;
}

View File

@@ -0,0 +1,249 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowLeftIcon, PlusIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useForm} from 'react-hook-form';
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import styles from '~/components/modals/EditGroupBottomSheet.module.css';
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
import {Button} from '~/components/uikit/Button/Button';
import {Scroller} from '~/components/uikit/Scroller';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import ChannelStore from '~/stores/ChannelStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {openFilePicker} from '~/utils/FilePickerUtils';
import {AssetCropModal, AssetType} from './AssetCropModal';
interface FormInputs {
icon?: string | null;
name: string;
}
interface EditGroupBottomSheetProps {
isOpen: boolean;
onClose: () => void;
channelId: string;
}
export const EditGroupBottomSheet: React.FC<EditGroupBottomSheetProps> = observer(({isOpen, onClose, channelId}) => {
const {t} = useLingui();
const channel = ChannelStore.getChannel(channelId);
const [hasClearedIcon, setHasClearedIcon] = React.useState(false);
const [previewIconUrl, setPreviewIconUrl] = React.useState<string | null>(null);
const form = useForm<FormInputs>({
defaultValues: React.useMemo(() => ({name: channel?.name || ''}), [channel]),
});
const handleIconUpload = React.useCallback(
async (file: File | null) => {
try {
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
ToastActionCreators.createToast({
type: 'error',
children: t`Icon file is too large. Please choose a file smaller than 10MB.`,
});
return;
}
if (file.type === 'image/gif') {
ToastActionCreators.createToast({
type: 'error',
children: t`Animated icons are not supported. Please use JPEG, PNG, or WebP.`,
});
return;
}
const base64 = await AvatarUtils.fileToBase64(file);
ModalActionCreators.push(
modal(() => (
<AssetCropModal
assetType={AssetType.CHANNEL_ICON}
imageUrl={base64}
sourceMimeType={file.type}
onCropComplete={(croppedBlob) => {
const reader = new FileReader();
reader.onload = () => {
const croppedBase64 = reader.result as string;
form.setValue('icon', croppedBase64);
setPreviewIconUrl(croppedBase64);
setHasClearedIcon(false);
form.clearErrors('icon');
};
reader.onerror = () => {
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to process the cropped image. Please try again.`,
});
};
reader.readAsDataURL(croppedBlob);
}}
onSkip={() => {
form.setValue('icon', base64);
setPreviewIconUrl(base64);
setHasClearedIcon(false);
form.clearErrors('icon');
}}
/>
)),
);
} catch {
ToastActionCreators.createToast({
type: 'error',
children: <Trans>That image is invalid. Please try another one.</Trans>,
});
}
},
[form],
);
const handleIconUploadClick = React.useCallback(async () => {
const [file] = await openFilePicker({accept: 'image/jpeg,image/png,image/webp,image/gif'});
await handleIconUpload(file ?? null);
}, [handleIconUpload]);
const handleClearIcon = React.useCallback(() => {
form.setValue('icon', null);
setPreviewIconUrl(null);
setHasClearedIcon(true);
}, [form]);
const onSubmit = React.useCallback(
async (data: FormInputs) => {
const newChannel = await ChannelActionCreators.update(channelId, {
icon: data.icon,
name: data.name,
});
form.reset({name: newChannel.name});
ToastActionCreators.createToast({type: 'success', children: <Trans>Group updated</Trans>});
onClose();
},
[channelId, form, onClose],
);
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
if (!channel) {
return null;
}
const iconPresentable = hasClearedIcon
? null
: (previewIconUrl ?? AvatarUtils.getChannelIconURL({id: channel.id, icon: channel.icon}, 256));
return (
<BottomSheet
isOpen={isOpen}
onClose={onClose}
snapPoints={[0, 1]}
initialSnap={1}
disablePadding={true}
surface="primary"
leadingAction={
<button type="button" onClick={onClose} className={styles.backButton}>
<ArrowLeftIcon className={styles.backIcon} weight="bold" />
</button>
}
title={t`Edit Group`}
>
<div className={styles.container}>
<Scroller className={styles.scroller} key="edit-group-bottom-sheet-scroller">
<div className={styles.scrollContent}>
<Form form={form} onSubmit={handleSubmit} className={styles.form} aria-label={t`Edit group form`}>
<div className={styles.iconSection}>
<div className={styles.iconLabel}>
<Trans>Group Icon</Trans>
</div>
<div className={styles.iconContainer}>
{previewIconUrl ? (
<div
className={styles.iconPreview}
style={{
backgroundImage: `url(${previewIconUrl})`,
}}
/>
) : iconPresentable ? (
<div
className={styles.iconPreview}
style={{
backgroundImage: `url(${iconPresentable})`,
}}
/>
) : (
<div className={styles.iconPlaceholder}>
<PlusIcon weight="regular" className={styles.iconPlaceholderIcon} />
</div>
)}
<div className={styles.iconActions}>
<div className={styles.iconButtonGroup}>
<Button variant="secondary" small={true} onClick={handleIconUploadClick}>
{previewIconUrl || iconPresentable ? <Trans>Change Icon</Trans> : <Trans>Upload Icon</Trans>}
</Button>
{(previewIconUrl || iconPresentable) && (
<Button variant="secondary" small={true} onClick={handleClearIcon}>
<Trans>Remove Icon</Trans>
</Button>
)}
</div>
<div className={styles.iconHint}>
<Trans>JPEG, PNG, WebP. Max 10MB. Recommended: 512×512px</Trans>
</div>
</div>
</div>
{form.formState.errors.icon?.message && (
<p className={styles.iconError}>{form.formState.errors.icon.message}</p>
)}
</div>
<Input
{...form.register('name')}
type="text"
label={t`Group Name`}
placeholder={t`My Group`}
minLength={1}
maxLength={100}
error={form.formState.errors.name?.message}
/>
<div className={styles.footer}>
<Button type="submit" submitting={isSubmitting} className={styles.fullWidth}>
<Trans>Save</Trans>
</Button>
</div>
</Form>
</div>
</Scroller>
</div>
</BottomSheet>
);
});

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/>.
*/
.iconSection {
display: flex;
flex-direction: column;
margin-bottom: 24px;
}
.iconLabel {
margin-bottom: 8px;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.iconContainer {
display: flex;
align-items: center;
gap: 16px;
}
.iconPreview {
width: 80px;
height: 80px;
flex-shrink: 0;
border-radius: 50%;
background-size: cover;
background-position: center;
}
.iconPlaceholder {
display: flex;
width: 80px;
height: 80px;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 4px dashed var(--text-tertiary);
}
.iconPlaceholderIcon {
width: 32px;
height: 32px;
color: var(--text-tertiary);
}
.iconActions {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}
.iconButtonGroup {
display: flex;
flex-direction: column;
gap: 8px;
}
@media (min-width: 640px) {
.iconButtonGroup {
flex-direction: row;
}
}
.iconHint {
font-size: 14px;
color: var(--text-primary-muted);
}
.iconError {
margin-top: 8px;
font-size: 14px;
color: var(--status-danger);
}
.hiddenInput {
display: none;
}

View File

@@ -0,0 +1,232 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {PlusIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useForm} from 'react-hook-form';
import * as ChannelActionCreators from '~/actions/ChannelActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import confirmStyles from '~/components/modals/ConfirmModal.module.css';
import styles from '~/components/modals/EditGroupModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import ChannelStore from '~/stores/ChannelStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import * as ChannelUtils from '~/utils/ChannelUtils';
import {openFilePicker} from '~/utils/FilePickerUtils';
import {AssetCropModal, AssetType} from './AssetCropModal';
interface FormInputs {
icon?: string | null;
name: string;
}
export const EditGroupModal = observer(({channelId}: {channelId: string}) => {
const {t} = useLingui();
const channel = ChannelStore.getChannel(channelId);
const [hasClearedIcon, setHasClearedIcon] = React.useState(false);
const [previewIconUrl, setPreviewIconUrl] = React.useState<string | null>(null);
const form = useForm<FormInputs>({
defaultValues: React.useMemo(() => ({name: channel?.name || ''}), [channel]),
});
const handleIconUpload = React.useCallback(
async (file: File | null) => {
try {
if (!file) return;
if (file.size > 10 * 1024 * 1024) {
ToastActionCreators.createToast({
type: 'error',
children: t`Icon file is too large. Please choose a file smaller than 10MB.`,
});
return;
}
if (file.type === 'image/gif') {
ToastActionCreators.createToast({
type: 'error',
children: t`Animated icons are not supported. Please use JPEG, PNG, or WebP.`,
});
return;
}
const base64 = await AvatarUtils.fileToBase64(file);
ModalActionCreators.push(
modal(() => (
<AssetCropModal
assetType={AssetType.CHANNEL_ICON}
imageUrl={base64}
sourceMimeType={file.type}
onCropComplete={(croppedBlob) => {
const reader = new FileReader();
reader.onload = () => {
const croppedBase64 = reader.result as string;
form.setValue('icon', croppedBase64);
setPreviewIconUrl(croppedBase64);
setHasClearedIcon(false);
form.clearErrors('icon');
};
reader.onerror = () => {
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to process the cropped image. Please try again.`,
});
};
reader.readAsDataURL(croppedBlob);
}}
onSkip={() => {
form.setValue('icon', base64);
setPreviewIconUrl(base64);
setHasClearedIcon(false);
form.clearErrors('icon');
}}
/>
)),
);
} catch {
ToastActionCreators.createToast({
type: 'error',
children: <Trans>That image is invalid. Please try another one.</Trans>,
});
}
},
[form],
);
const handleIconUploadClick = React.useCallback(async () => {
const [file] = await openFilePicker({accept: 'image/jpeg,image/png,image/webp,image/gif'});
await handleIconUpload(file ?? null);
}, [handleIconUpload]);
const handleClearIcon = React.useCallback(() => {
form.setValue('icon', null);
setPreviewIconUrl(null);
setHasClearedIcon(true);
}, [form]);
const onSubmit = React.useCallback(
async (data: FormInputs) => {
const newChannel = await ChannelActionCreators.update(channelId, {
icon: data.icon,
name: data.name,
});
form.reset({name: newChannel.name});
ToastActionCreators.createToast({type: 'success', children: <Trans>Group updated</Trans>});
ModalActionCreators.pop();
},
[channelId, form],
);
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
if (!channel) {
return null;
}
const iconPresentable = hasClearedIcon
? null
: (previewIconUrl ?? AvatarUtils.getChannelIconURL({id: channel.id, icon: channel.icon}, 256));
const placeholderName = channel ? ChannelUtils.getDMDisplayName(channel) : '';
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit}>
<Modal.Header title={t`Edit Group`} />
<Modal.Content className={confirmStyles.content}>
<div className={styles.iconSection}>
<div className={styles.iconLabel}>
<Trans>Group Icon</Trans>
</div>
<div className={styles.iconContainer}>
{previewIconUrl ? (
<div
className={styles.iconPreview}
style={{
backgroundImage: `url(${previewIconUrl})`,
}}
/>
) : iconPresentable ? (
<div
className={styles.iconPreview}
style={{
backgroundImage: `url(${iconPresentable})`,
}}
/>
) : (
<div className={styles.iconPlaceholder}>
<PlusIcon weight="regular" className={styles.iconPlaceholderIcon} />
</div>
)}
<div className={styles.iconActions}>
<div className={styles.iconButtonGroup}>
<Button variant="secondary" small={true} onClick={handleIconUploadClick}>
{previewIconUrl || iconPresentable ? <Trans>Change Icon</Trans> : <Trans>Upload Icon</Trans>}
</Button>
{(previewIconUrl || iconPresentable) && (
<Button variant="secondary" small={true} onClick={handleClearIcon}>
<Trans>Remove Icon</Trans>
</Button>
)}
</div>
<div className={styles.iconHint}>
<Trans>JPEG, PNG, WebP. Max 10MB. Recommended: 512×512px</Trans>
</div>
</div>
</div>
{form.formState.errors.icon?.message && (
<p className={styles.iconError}>{form.formState.errors.icon.message}</p>
)}
</div>
<Input
{...form.register('name', {
maxLength: {
value: 100,
message: t`Group name must not exceed 100 characters`,
},
})}
type="text"
label={t`Group Name`}
placeholder={placeholderName || t`My Group`}
maxLength={100}
error={form.formState.errors.name?.message}
/>
</Modal.Content>
<Modal.Footer>
<Button type="submit" submitting={isSubmitting}>
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

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/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {useForm} from 'react-hook-form';
import * as GuildStickerActionCreators from '~/actions/GuildStickerActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {Form} from '~/components/form/Form';
import styles from '~/components/modals/EditGuildStickerModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {StickerFormFields} from '~/components/modals/sticker-form/StickerFormFields';
import {StickerPreview} from '~/components/modals/sticker-form/StickerPreview';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import {type GuildStickerWithUser, isStickerAnimated} from '~/records/GuildStickerRecord';
import * as AvatarUtils from '~/utils/AvatarUtils';
interface EditGuildStickerModalProps {
guildId: string;
sticker: GuildStickerWithUser;
onUpdate: () => void;
}
interface FormInputs {
name: string;
description: string;
tags: Array<string>;
}
export const EditGuildStickerModal = observer(function EditGuildStickerModal({
guildId,
sticker,
onUpdate,
}: EditGuildStickerModalProps) {
const {t} = useLingui();
const form = useForm<FormInputs>({
defaultValues: {
name: sticker.name,
description: sticker.description,
tags: [...sticker.tags],
},
});
const onSubmit = React.useCallback(
async (data: FormInputs) => {
try {
await GuildStickerActionCreators.update(guildId, sticker.id, {
name: data.name.trim(),
description: data.description.trim(),
tags: data.tags.length > 0 ? data.tags : [],
});
onUpdate();
ModalActionCreators.pop();
} catch (error: any) {
console.error('Failed to update sticker:', error);
form.setError('name', {
message: error.message || t`Failed to update sticker`,
});
}
},
[guildId, sticker.id, onUpdate, form],
);
const {handleSubmit: handleSave} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'name',
});
const stickerUrl = AvatarUtils.getStickerURL({
id: sticker.id,
animated: isStickerAnimated(sticker),
size: 320,
});
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Edit Sticker`} />
<Modal.Content>
<Form form={form} onSubmit={handleSave}>
<div className={styles.content}>
<StickerPreview imageUrl={stickerUrl} altText={sticker.name} />
<StickerFormFields form={form} />
</div>
</Form>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleSave} disabled={!form.watch('name')?.trim() || form.formState.isSubmitting}>
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,110 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, 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 {Form} from '~/components/form/Form';
import {Input, Textarea} from '~/components/form/Input';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import PackStore from '~/stores/PackStore';
import styles from './CreatePackModal.module.css';
interface FormInputs {
name: string;
description: string;
}
interface EditPackModalProps {
packId: string;
type: 'emoji' | 'sticker';
name: string;
description: string | null;
onSuccess?: () => void;
}
export const EditPackModal = observer(({packId, type, name, description, onSuccess}: EditPackModalProps) => {
const {t} = useLingui();
const form = useForm<FormInputs>({
defaultValues: {
name,
description: description ?? '',
},
});
const title = type === 'emoji' ? t`Edit Emoji Pack` : t`Edit Sticker Pack`;
const submitHandler = React.useCallback(
async (data: FormInputs) => {
await PackStore.updatePack(packId, {name: data.name.trim(), description: data.description.trim() || null});
onSuccess?.();
ModalActionCreators.pop();
},
[packId, onSuccess],
);
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit: submitHandler,
defaultErrorField: 'name',
});
return (
<Modal.Root size="small" onClose={() => ModalActionCreators.pop()}>
<Modal.Header title={title} />
<Modal.Content>
<Form className={styles.form} form={form} onSubmit={handleSubmit}>
<div className={styles.formFields}>
<Input
id="pack-name"
label={t`Pack name`}
error={form.formState.errors.name?.message}
{...form.register('name', {
required: t`Pack name is required`,
minLength: {value: 2, message: t`Pack name must be at least 2 characters`},
maxLength: {value: 64, message: t`Pack name must be at most 64 characters`},
})}
/>
<Textarea
id="pack-description"
label={t`Description`}
error={form.formState.errors.description?.message}
{...form.register('description', {
maxLength: {value: 256, message: t`Maximum 256 characters`},
})}
minRows={3}
/>
</div>
</Form>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleSubmit} submitting={isSubmitting}>
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,41 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.inputContainer {
display: flex;
flex-direction: column;
}
.footer {
align-items: center;
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}
.error {
color: var(--warn-text, #f36);
margin-top: 8px;
}

View File

@@ -0,0 +1,304 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import confirmStyles from '~/components/modals/ConfirmModal.module.css';
import styles from '~/components/modals/EmailChangeModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import UserStore from '~/stores/UserStore';
type Stage = 'intro' | 'verifyOriginal' | 'newEmail' | 'verifyNew';
interface NewEmailForm {
email: string;
}
export const EmailChangeModal = observer(() => {
const {t} = useLingui();
const user = UserStore.getCurrentUser()!;
const newEmailForm = useForm<NewEmailForm>({defaultValues: {email: ''}});
const [stage, setStage] = useState<Stage>('intro');
const [ticket, setTicket] = useState<string | null>(null);
const [originalProof, setOriginalProof] = useState<string | null>(null);
const [originalCode, setOriginalCode] = useState<string>('');
const [newCode, setNewCode] = useState<string>('');
const [resendOriginalAt, setResendOriginalAt] = useState<Date | null>(null);
const [resendNewAt, setResendNewAt] = useState<Date | null>(null);
const [submitting, setSubmitting] = useState<boolean>(false);
const [originalCodeError, setOriginalCodeError] = useState<string | null>(null);
const [newCodeError, setNewCodeError] = useState<string | null>(null);
const isEmailVerified = user.verified === true;
const [now, setNow] = useState<number>(Date.now());
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, []);
const canResendOriginal = useMemo(
() => !resendOriginalAt || resendOriginalAt.getTime() <= now,
[resendOriginalAt, now],
);
const canResendNew = useMemo(() => !resendNewAt || resendNewAt.getTime() <= now, [resendNewAt, now]);
const originalSecondsRemaining = useMemo(
() => (resendOriginalAt ? Math.max(0, Math.ceil((resendOriginalAt.getTime() - now) / 1000)) : 0),
[resendOriginalAt, now],
);
const newSecondsRemaining = useMemo(
() => (resendNewAt ? Math.max(0, Math.ceil((resendNewAt.getTime() - now) / 1000)) : 0),
[resendNewAt, now],
);
const startFlow = async () => {
setSubmitting(true);
setOriginalCodeError(null);
try {
const result = await UserActionCreators.startEmailChange();
setTicket(result.ticket);
if (result.original_proof) {
setOriginalProof(result.original_proof);
}
if (result.resend_available_at) {
setResendOriginalAt(new Date(result.resend_available_at));
}
setStage(result.require_original ? 'verifyOriginal' : 'newEmail');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : t`Unable to start email change`;
ToastActionCreators.error(errorMessage);
} finally {
setSubmitting(false);
}
};
const handleVerifyOriginal = async () => {
if (!ticket) return;
setSubmitting(true);
setOriginalCodeError(null);
try {
const result = await UserActionCreators.verifyEmailChangeOriginal(ticket, originalCode);
setOriginalProof(result.original_proof);
setStage('newEmail');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : t`Invalid or expired code`;
setOriginalCodeError(errorMessage);
} finally {
setSubmitting(false);
}
};
const handleResendOriginal = async () => {
if (!ticket || !canResendOriginal) return;
setSubmitting(true);
try {
await UserActionCreators.resendEmailChangeOriginal(ticket);
setResendOriginalAt(new Date(Date.now() + 30 * 1000));
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : t`Unable to resend code right now`;
ToastActionCreators.error(errorMessage);
} finally {
setSubmitting(false);
}
};
const handleRequestNew = async (data: NewEmailForm) => {
if (!ticket || !originalProof) return;
setSubmitting(true);
try {
const result = await UserActionCreators.requestEmailChangeNew(ticket, data.email, originalProof);
setResendNewAt(result.resend_available_at ? new Date(result.resend_available_at) : null);
setStage('verifyNew');
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : t`Unable to send code to new email`;
ToastActionCreators.error(errorMessage);
throw error;
} finally {
setSubmitting(false);
}
};
const handleResendNew = async () => {
if (!ticket || !canResendNew) return;
setSubmitting(true);
try {
await UserActionCreators.resendEmailChangeNew(ticket);
setResendNewAt(new Date(Date.now() + 30 * 1000));
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : t`Unable to resend code right now`;
ToastActionCreators.error(errorMessage);
} finally {
setSubmitting(false);
}
};
const handleVerifyNew = async () => {
if (!ticket || !originalProof) return;
setSubmitting(true);
setNewCodeError(null);
try {
const result = await UserActionCreators.verifyEmailChangeNew(ticket, newCode, originalProof);
await UserActionCreators.update({email_token: result.email_token});
ToastActionCreators.createToast({type: 'success', children: t`Email changed`});
ModalActionCreators.pop();
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : t`Invalid or expired code`;
setNewCodeError(errorMessage);
} finally {
setSubmitting(false);
}
};
const {handleSubmit: handleNewEmailSubmit} = useFormSubmit({
form: newEmailForm,
onSubmit: handleRequestNew,
defaultErrorField: 'email',
});
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Change your email`} />
{stage === 'intro' && (
<>
<Modal.Content className={confirmStyles.content}>
<p className={confirmStyles.descriptionText}>
{isEmailVerified ? (
<Trans>We'll verify your current email and then your new email with one-time codes.</Trans>
) : (
<Trans>We'll verify your new email with a one-time code.</Trans>
)}
</p>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button onClick={startFlow} submitting={submitting}>
<Trans>Start</Trans>
</Button>
</Modal.Footer>
</>
)}
{stage === 'verifyOriginal' && (
<>
<Modal.Content className={confirmStyles.content}>
<p className={confirmStyles.descriptionText}>
<Trans>Enter the code sent to your current email.</Trans>
</p>
<div className={styles.inputContainer}>
<Input
value={originalCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setOriginalCode(event.target.value)}
autoFocus={true}
label={t`Verification code`}
placeholder="XXXX-XXXX"
required={true}
error={originalCodeError ?? undefined}
/>
</div>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleResendOriginal} disabled={!canResendOriginal || submitting}>
{canResendOriginal ? <Trans>Resend</Trans> : <Trans>Resend ({originalSecondsRemaining}s)</Trans>}
</Button>
<Button onClick={handleVerifyOriginal} submitting={submitting}>
<Trans>Verify</Trans>
</Button>
</Modal.Footer>
</>
)}
{stage === 'newEmail' && (
<Form form={newEmailForm} onSubmit={handleNewEmailSubmit} aria-label={t`New email form`}>
<Modal.Content className={confirmStyles.content}>
<p className={confirmStyles.descriptionText}>
<Trans>Enter the new email you want to use. We'll send a code there next.</Trans>
</p>
<div className={styles.inputContainer}>
<Input
{...newEmailForm.register('email')}
autoComplete="email"
autoFocus={true}
error={newEmailForm.formState.errors.email?.message}
label={t`New email`}
maxLength={256}
minLength={1}
placeholder={t`marty@example.com`}
required={true}
type="email"
/>
</div>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" submitting={submitting}>
<Trans>Send code</Trans>
</Button>
</Modal.Footer>
</Form>
)}
{stage === 'verifyNew' && (
<>
<Modal.Content className={confirmStyles.content}>
<p className={confirmStyles.descriptionText}>
<Trans>Enter the code we emailed to your new address.</Trans>
</p>
<div className={styles.inputContainer}>
<Input
value={newCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setNewCode(event.target.value)}
autoFocus={true}
label={t`Verification code`}
placeholder="XXXX-XXXX"
required={true}
error={newCodeError ?? undefined}
/>
</div>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleResendNew} disabled={!canResendNew || submitting}>
{canResendNew ? <Trans>Resend</Trans> : <Trans>Resend ({newSecondsRemaining}s)</Trans>}
</Button>
<Button onClick={handleVerifyNew} submitting={submitting}>
<Trans>Confirm</Trans>
</Button>
</Modal.Footer>
</>
)}
</Modal.Root>
);
});

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 32px;
}
.message {
text-align: center;
font-size: 14px;
color: var(--text-primary-muted);
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import styles from '~/components/modals/EmojiUploadModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Spinner} from '~/components/uikit/Spinner';
interface EmojiUploadModalProps {
count: number;
}
export const EmojiUploadModal: React.FC<EmojiUploadModalProps> = observer(({count}) => {
return (
<Modal.Root size="small" centered>
<Modal.Header title={<Trans>Uploading Emojis</Trans>} hideCloseButton />
<Modal.Content>
<div className={styles.container}>
<Spinner />
<p className={styles.message}>
<Trans>
Uploading <Plural value={count} one="# emoji" other="# emojis" />. This may take a little while.
</Trans>
</p>
</div>
</Modal.Content>
</Modal.Root>
);
});

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/>.
*/
.container {
display: flex;
height: 100%;
flex-direction: column;
overflow: hidden;
}
.contentContainer {
position: relative;
flex: 1;
overflow: hidden;
height: 100%;
}
.contentInner {
width: 100%;
height: 100%;
}
.pickerContent {
height: 100%;
}
.headerPortal {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
padding-block: var(--spacing-2);
padding-inline: var(--spacing-4);
&:empty {
display: none;
padding: 0;
}
& > * {
width: 100%;
}
}

View File

@@ -0,0 +1,257 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ExpressionPickerActionCreators from '~/actions/ExpressionPickerActionCreators';
import {MobileEmojiPicker} from '~/components/channel/MobileEmojiPicker';
import {MobileMemesPicker} from '~/components/channel/MobileMemesPicker';
import {MobileStickersPicker} from '~/components/channel/MobileStickersPicker';
import {GifPicker} from '~/components/channel/pickers/gif/GifPicker';
import styles from '~/components/modals/ExpressionPickerSheet.module.css';
import {ExpressionPickerHeaderContext, type ExpressionPickerTabType} from '~/components/popouts/ExpressionPickerPopout';
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
import {type SegmentedTab, SegmentedTabs} from '~/components/uikit/SegmentedTabs/SegmentedTabs';
import * as StickerSendUtils from '~/lib/StickerSendUtils';
import type {GuildStickerRecord} from '~/records/GuildStickerRecord';
import type {Emoji} from '~/stores/EmojiStore';
import ExpressionPickerStore from '~/stores/ExpressionPickerStore';
interface ExpressionPickerCategoryDescriptor {
type: ExpressionPickerTabType;
label: MessageDescriptor;
renderComponent: (props: {
channelId?: string;
onSelect: (emoji: Emoji, shiftKey?: boolean) => void;
onClose: () => void;
searchTerm?: string;
setSearchTerm?: (term: string) => void;
setHoveredEmoji?: (emoji: Emoji | null) => void;
}) => React.ReactNode;
}
const EXPRESSION_PICKER_CATEGORY_DESCRIPTORS: Array<ExpressionPickerCategoryDescriptor> = [
{
type: 'gifs' as const,
label: msg`GIFs`,
renderComponent: ({onClose}) => (
<div className={styles.pickerContent}>
<GifPicker onClose={onClose} />
</div>
),
},
{
type: 'memes' as const,
label: msg`Media`,
renderComponent: ({onClose}) => (
<div className={styles.pickerContent}>
<MobileMemesPicker onClose={onClose} />
</div>
),
},
{
type: 'stickers' as const,
label: msg`Stickers`,
renderComponent: ({channelId, onClose}) => {
const handleStickerSelect = (sticker: GuildStickerRecord, shiftKey?: boolean) => {
if (channelId) {
StickerSendUtils.handleStickerSelect(channelId, sticker);
if (!shiftKey) {
onClose?.();
}
}
};
return (
<div className={styles.pickerContent}>
<MobileStickersPicker channelId={channelId} handleSelect={handleStickerSelect} />
</div>
);
},
},
{
type: 'emojis' as const,
label: msg`Emojis`,
renderComponent: ({channelId, onSelect, searchTerm, setSearchTerm}) => (
<div className={styles.pickerContent}>
<MobileEmojiPicker
channelId={channelId}
handleSelect={onSelect}
externalSearchTerm={searchTerm}
externalSetSearchTerm={setSearchTerm}
/>
</div>
),
},
];
interface ExpressionPickerSheetProps {
isOpen: boolean;
onClose: () => void;
channelId?: string;
onEmojiSelect: (emoji: Emoji, shiftKey?: boolean) => void;
visibleTabs?: Array<ExpressionPickerTabType>;
selectedTab?: ExpressionPickerTabType;
onTabChange?: (tab: ExpressionPickerTabType) => void;
zIndex?: number;
}
export const ExpressionPickerSheet = observer(
({
isOpen,
onClose,
channelId,
onEmojiSelect,
visibleTabs = ['gifs', 'memes', 'stickers', 'emojis'],
selectedTab: controlledSelectedTab,
onTabChange,
zIndex,
}: ExpressionPickerSheetProps) => {
const {t} = useLingui();
const categories = React.useMemo(
() =>
EXPRESSION_PICKER_CATEGORY_DESCRIPTORS.filter((category) => visibleTabs.includes(category.type)).map(
(category) => ({
type: category.type,
label: t(category.label),
renderComponent: category.renderComponent,
}),
),
[visibleTabs, t],
);
const [internalSelectedTab, setInternalSelectedTab] = React.useState<ExpressionPickerTabType>(
() => categories[0]?.type || 'emojis',
);
const [emojiSearchTerm, setEmojiSearchTerm] = React.useState('');
const [_hoveredEmoji, setHoveredEmoji] = React.useState<Emoji | null>(null);
const storeSelectedTab = ExpressionPickerStore.selectedTab;
const selectedTab = storeSelectedTab ?? controlledSelectedTab ?? internalSelectedTab;
const setSelectedTab = React.useCallback(
(tab: ExpressionPickerTabType) => {
if (onTabChange) {
onTabChange(tab);
return;
}
const pickerChannelId = ExpressionPickerStore.channelId;
if (pickerChannelId) {
ExpressionPickerActionCreators.setTab(tab);
} else {
setInternalSelectedTab(tab);
}
},
[onTabChange],
);
const selectedCategory = categories.find((category) => category.type === selectedTab) || categories[0];
React.useEffect(() => {
if (!isOpen) return;
if (channelId && ExpressionPickerStore.channelId !== channelId) {
ExpressionPickerActionCreators.open(channelId, selectedTab);
}
const timer = setTimeout(() => {
const firstInput = document.querySelector('input[type="text"]') as HTMLInputElement | null;
if (firstInput) {
firstInput.focus();
}
}, 150);
return () => clearTimeout(timer);
}, [isOpen, channelId, selectedTab, t]);
const handleEmojiSelect = React.useCallback(
(emoji: Emoji, shiftKey?: boolean) => {
onEmojiSelect(emoji, shiftKey);
if (!shiftKey) {
onClose();
}
},
[onEmojiSelect, onClose],
);
const showTabs = categories.length > 1;
const segmentedTabs: Array<SegmentedTab<ExpressionPickerTabType>> = React.useMemo(
() => categories.map((category) => ({id: category.type, label: category.label})),
[categories],
);
const [headerPortalElement, setHeaderPortalElement] = React.useState<HTMLDivElement | null>(null);
const headerPortalCallback = React.useCallback((node: HTMLDivElement | null) => {
setHeaderPortalElement(node);
}, []);
const headerContextValue = React.useMemo(() => ({headerPortalElement}), [headerPortalElement]);
const headerContent = (
<>
{showTabs ? (
<SegmentedTabs
tabs={segmentedTabs}
selectedTab={selectedTab}
onTabChange={setSelectedTab}
ariaLabel={t`Expression picker categories`}
/>
) : null}
<div ref={headerPortalCallback} className={styles.headerPortal} />
</>
);
return (
<ExpressionPickerHeaderContext.Provider value={headerContextValue}>
<BottomSheet
isOpen={isOpen}
onClose={onClose}
snapPoints={[0, 1]}
initialSnap={1}
disablePadding={true}
disableDefaultHeader={true}
headerSlot={headerContent}
showCloseButton={false}
zIndex={zIndex}
>
<div className={styles.container}>
<div className={styles.contentContainer}>
<div className={styles.contentInner}>
{selectedCategory.renderComponent({
channelId,
onSelect: handleEmojiSelect,
onClose,
searchTerm: selectedTab === 'emojis' ? emojiSearchTerm : undefined,
setSearchTerm: selectedTab === 'emojis' ? setEmojiSearchTerm : undefined,
setHoveredEmoji: selectedTab === 'emojis' ? setHoveredEmoji : undefined,
})}
</div>
</div>
</div>
</BottomSheet>
</ExpressionPickerHeaderContext.Provider>
);
},
);

View File

@@ -0,0 +1,104 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 24px;
padding-bottom: 24px;
}
.iconContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.iconCircle {
display: flex;
width: 48px;
height: 48px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--background-modifier-accent);
}
.warningIcon {
color: #eab308;
}
.textContainer {
text-align: center;
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.description {
margin-top: 4px;
font-size: 14px;
color: var(--text-secondary);
}
.urlSection {
display: flex;
flex-direction: column;
gap: 8px;
}
.urlLabel {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
.urlBox {
overflow: hidden;
border-radius: 8px;
border: 1px solid var(--background-modifier-accent);
background: var(--background-tertiary);
padding: 12px 16px;
}
.urlText {
word-break: break-all;
font-family: monospace;
font-size: 14px;
color: var(--text-primary);
}
.checkboxLabel {
font-size: 14px;
color: var(--text-primary);
}
.button {
width: 100%;
}
@media (min-width: 640px) {
.button {
width: auto;
}
}

View File

@@ -0,0 +1,108 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowRightIcon, WarningIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as TrustedDomainActionCreators from '~/actions/TrustedDomainActionCreators';
import styles from '~/components/modals/ExternalLinkWarningModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import {openExternalUrl} from '~/utils/NativeUtils';
export const ExternalLinkWarningModal = observer(({url, hostname}: {url: string; hostname: string}) => {
const {t} = useLingui();
const [trustDomain, setTrustDomain] = React.useState(false);
const initialFocusRef = React.useRef<HTMLButtonElement | null>(null);
const handleContinue = React.useCallback(() => {
if (trustDomain) {
TrustedDomainActionCreators.addTrustedDomain(hostname);
}
void openExternalUrl(url);
ModalActionCreators.pop();
}, [url, hostname, trustDomain]);
const handleCancel = React.useCallback(() => {
ModalActionCreators.pop();
}, []);
const handleTrustChange = React.useCallback((checked: boolean) => {
setTrustDomain(checked);
}, []);
const title = t`External Link Warning`;
return (
<Modal.Root size="small" centered initialFocusRef={initialFocusRef}>
<Modal.Header title={title} />
<Modal.Content>
<div className={styles.content}>
<div className={styles.iconContainer}>
<div className={styles.iconCircle}>
<WarningIcon size={24} className={styles.warningIcon} weight="fill" />
</div>
<div className={styles.textContainer}>
<p className={styles.title}>
<Trans>You are about to leave Fluxer</Trans>
</p>
<p className={styles.description}>
<Trans>External links can be dangerous. Please be careful.</Trans>
</p>
</div>
</div>
<div className={styles.urlSection}>
<div className={styles.urlLabel}>
<Trans>Destination URL:</Trans>
</div>
<div className={styles.urlBox}>
<p className={styles.urlText}>{url}</p>
</div>
</div>
<Checkbox checked={trustDomain} onChange={handleTrustChange} size="small">
<span className={styles.checkboxLabel}>
<Trans>
Always trust <strong>{hostname}</strong> skip this warning next time
</Trans>
</span>
</Checkbox>
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={handleCancel} variant="secondary" className={styles.button}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={handleContinue}
ref={initialFocusRef}
variant="primary"
className={styles.button}
rightIcon={<ArrowRightIcon size={16} weight="bold" />}
>
<Trans>Visit Site</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,110 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.confirmDescription {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.875rem;
}
.confirmSecondary {
color: var(--text-secondary);
}
.fluxerTagContainer {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.description {
margin-bottom: var(--spacing-4);
}
.fluxerTagLabel {
margin-bottom: 0.25rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-tertiary);
}
.fluxerTagInputRow {
display: flex;
align-items: center;
gap: 0.5rem;
}
.usernameInput {
flex: 1;
}
.separator {
font-family: monospace;
font-size: 1.125rem;
color: var(--text-primary);
}
.discriminatorInput {
width: 5rem;
}
.discriminatorInputDisabled {
position: relative;
}
.discriminatorInputDisabled input:disabled {
cursor: pointer;
}
.discriminatorOverlay {
position: absolute;
inset: 0;
cursor: pointer;
}
.errorMessage {
font-size: 0.875rem;
color: var(--status-danger);
}
.validationBox {
margin-top: 0.5rem;
padding: 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--background-modifier-accent);
background-color: var(--background-secondary);
}
.premiumUpsell {
margin-top: 0.5rem;
}
.footer {
align-items: center;
display: flex;
flex-wrap: wrap;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}

View File

@@ -0,0 +1,262 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {Controller, useForm} from 'react-hook-form';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as PremiumModalActionCreators from '~/actions/PremiumModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import * as UserActionCreators from '~/actions/UserActionCreators';
import {Form} from '~/components/form/Form';
import {Input} from '~/components/form/Input';
import {UsernameValidationRules} from '~/components/form/UsernameValidationRules';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import confirmStyles from '~/components/modals/ConfirmModal.module.css';
import styles from '~/components/modals/FluxerTagChangeModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {PlutoniumUpsell} from '~/components/uikit/PlutoniumUpsell/PlutoniumUpsell';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useFormSubmit} from '~/hooks/useFormSubmit';
import UserStore from '~/stores/UserStore';
interface FormInputs {
username: string;
discriminator: string;
}
export const FluxerTagChangeModal = observer(() => {
const {t} = useLingui();
const user = UserStore.getCurrentUser()!;
const usernameRef = React.useRef<HTMLInputElement>(null);
const hasPremium = user.isPremium();
const skipAvailabilityCheckRef = React.useRef(false);
const resubmitHandlerRef = React.useRef<(() => Promise<void>) | null>(null);
const confirmedRerollRef = React.useRef(false);
const form = useForm<FormInputs>({
defaultValues: {
username: user.username,
discriminator: user.discriminator,
},
});
React.useEffect(() => {
const subscription = form.watch((_, info) => {
if (info?.name === 'username') {
confirmedRerollRef.current = false;
}
});
return () => {
subscription.unsubscribe();
};
}, [form]);
const onSubmit = React.useCallback(
async (data: FormInputs) => {
const usernameValue = data.username.trim();
const normalizedDiscriminator = data.discriminator;
const currentUsername = user.username.trim();
const currentDiscriminator = user.discriminator;
const isSameTag = usernameValue === currentUsername && normalizedDiscriminator === currentDiscriminator;
if (!hasPremium && !skipAvailabilityCheckRef.current && !confirmedRerollRef.current) {
const tagTaken = await UserActionCreators.checkFluxerTagAvailability({
username: usernameValue,
discriminator: normalizedDiscriminator,
});
if (tagTaken && !isSameTag) {
const fluxerTag = `${usernameValue}#${normalizedDiscriminator}`;
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`FluxerTag already taken`}
description={
<div className={styles.confirmDescription}>
<p>
<Trans>
The FluxerTag <strong>{fluxerTag}</strong> is already taken. Continuing will reroll your
discriminator automatically.
</Trans>
</p>
<p className={styles.confirmSecondary}>
<Trans>Cancel if you want to choose a different username instead.</Trans>
</p>
</div>
}
primaryText={t`Continue`}
secondaryText={t`Cancel`}
primaryVariant="primary"
onPrimary={async () => {
confirmedRerollRef.current = true;
skipAvailabilityCheckRef.current = true;
try {
await resubmitHandlerRef.current?.();
} finally {
skipAvailabilityCheckRef.current = false;
}
}}
/>
)),
);
return;
}
}
await UserActionCreators.update({
username: usernameValue,
discriminator: normalizedDiscriminator,
});
if (skipAvailabilityCheckRef.current) {
skipAvailabilityCheckRef.current = false;
}
ModalActionCreators.pop();
ToastActionCreators.createToast({type: 'success', children: t`FluxerTag updated`});
},
[hasPremium, user.username, user.discriminator],
);
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'username',
});
resubmitHandlerRef.current = handleSubmit;
return (
<Modal.Root size="small" centered initialFocusRef={usernameRef}>
<Form form={form} onSubmit={handleSubmit} aria-label={t`Change FluxerTag form`}>
<Modal.Header title={t`Change your FluxerTag`} />
<Modal.Content className={confirmStyles.content}>
<p className={clsx(styles.description, confirmStyles.descriptionText)}>
{hasPremium ? (
<Trans>
Usernames can only contain letters (a-z, A-Z), numbers (0-9), and underscores. Usernames are
case-insensitive. You can pick your own 4-digit tag if it's available.
</Trans>
) : (
<Trans>
Usernames can only contain letters (a-z, A-Z), numbers (0-9), and underscores. Usernames are
case-insensitive.
</Trans>
)}
</p>
<div className={styles.fluxerTagContainer}>
<span className={styles.fluxerTagLabel}>{t`FluxerTag`}</span>
<div className={styles.fluxerTagInputRow}>
<div className={styles.usernameInput}>
<Controller
name="username"
control={form.control}
render={({field}) => (
<Input
{...field}
ref={usernameRef}
autoComplete="username"
aria-label={t`Username`}
placeholder={t`Marty_McFly`}
required={true}
type="text"
/>
)}
/>
</div>
<span className={styles.separator}>#</span>
<div className={styles.discriminatorInput}>
{!hasPremium ? (
<Tooltip text={t`Get Plutonium to customize your tag or keep it when changing your username`}>
<div className={styles.discriminatorInputDisabled}>
<Input
{...form.register('discriminator')}
aria-label={t`4-digit tag`}
maxLength={4}
placeholder="0000"
required={true}
type="text"
disabled={true}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
form.setValue('discriminator', value);
}}
/>
<FocusRing offset={-2}>
<button
type="button"
onClick={() => {
PremiumModalActionCreators.open();
}}
className={styles.discriminatorOverlay}
aria-label={t`Get Plutonium`}
/>
</FocusRing>
</div>
</Tooltip>
) : (
<Input
{...form.register('discriminator')}
aria-label={t`4-digit tag`}
maxLength={4}
placeholder="0000"
required={true}
type="text"
disabled={false}
onChange={(e) => {
const value = e.target.value.replace(/\D/g, '');
form.setValue('discriminator', value);
}}
/>
)}
</div>
</div>
{(form.formState.errors.username || form.formState.errors.discriminator) && (
<span className={styles.errorMessage}>
{form.formState.errors.username?.message || form.formState.errors.discriminator?.message}
</span>
)}
<div className={styles.validationBox}>
<UsernameValidationRules username={form.watch('username')} />
</div>
{!hasPremium && (
<PlutoniumUpsell className={styles.premiumUpsell}>
<Trans>Customize your 4-digit tag or keep it when changing your username</Trans>
</PlutoniumUpsell>
)}
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" submitting={isSubmitting}>
<Trans>Continue</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

View File

@@ -0,0 +1,247 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.messageInput {
scrollbar-width: none;
}
.messageInput::-webkit-scrollbar {
display: none;
}
.channelIcon {
width: 28px;
height: 28px;
flex-shrink: 0;
color: var(--text-primary-muted);
}
.avatar {
width: 32px;
height: 32px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: visible;
}
.modalContent {
display: flex;
min-height: 0;
flex-direction: column;
gap: 0;
padding: 0;
}
.headerSearch {
margin-top: var(--spacing-3);
width: 100%;
}
.channelListContainer {
max-height: 320px;
flex-shrink: 0;
padding: 0 var(--spacing-4);
}
.scrollerFullHeight {
height: 100%;
padding: 0;
}
.noChannelsContainer {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.channelList {
display: flex;
flex-direction: column;
gap: 4px;
padding-bottom: 16px;
}
.channelButton {
display: flex;
width: 100%;
min-width: 0;
height: 48px;
align-items: center;
justify-content: space-between;
gap: 12px;
border-radius: var(--radius-md);
padding: 6px 10px;
text-align: left;
cursor: pointer;
}
.channelButton:hover:not(:disabled) {
background-color: var(--background-modifier-accent);
}
.channelButtonSelected {
background-color: var(--background-modifier-accent);
}
.channelButtonDisabled {
cursor: not-allowed;
opacity: 0.5;
}
.channelButtonContent {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 12px;
}
.channelInfo {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
justify-content: center;
}
.channelName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 500;
font-size: 14px;
color: var(--text-primary);
line-height: 1.4;
max-height: 1.4em;
}
.channelDetails {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-tertiary);
font-size: 12px;
line-height: 1.4;
max-height: 1.4em;
}
.checkboxContainer {
flex-shrink: 0;
pointer-events: none;
}
.channelButton:hover:not(:disabled) .checkboxContainer {
filter: brightness(1.3);
}
.inputAreaContainer {
position: relative;
width: 100%;
flex-shrink: 0;
padding: 12px 16px;
}
.messageInputContainer {
position: relative;
width: 100%;
border-radius: 6px;
background-color: var(--background-textarea);
}
.messageInputBase {
position: relative;
display: flex;
height: 100%;
max-height: 80px;
min-height: 44px;
width: 100%;
resize: none;
overflow-x: hidden;
overflow-y: scroll;
white-space: pre-wrap;
word-break: break-word;
border-radius: 6px;
background-color: transparent;
padding: 11px 52px 11px 11px;
color: var(--text-chat);
line-height: 1.375rem;
caret-color: var(--text-chat);
}
.messageInputActions {
position: absolute;
top: 0;
right: 0;
}
.emojiPickerButton {
display: flex;
height: 44px;
width: auto;
align-items: center;
justify-content: center;
padding: 0 12px;
transition: color 0.2s ease;
color: var(--text-chat-muted);
cursor: pointer;
}
.emojiPickerButton:hover {
color: var(--text-chat);
}
.emojiPickerButtonActive {
color: var(--text-primary);
}
.modalFooter {
display: flex;
flex-direction: row;
gap: 8px;
width: 100%;
}
.footerButton {
flex: 1;
}
.searchInput {
height: 44px;
background-color: var(--background-textarea);
width: 100%;
}
.headerSearchInput {
width: 100%;
}
.searchIcon {
height: 1.25rem;
width: 1.25rem;
}
.emojiIcon {
height: 1.5rem;
width: 1.5rem;
}

View File

@@ -0,0 +1,383 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 {HashIcon, MagnifyingGlassIcon, NotePencilIcon, SmileyIcon, SpeakerHighIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ChannelTypes} from '~/Constants';
import {MessageForwardFailedModal} from '~/components/alerts/MessageForwardFailedModal';
import {Autocomplete} from '~/components/channel/Autocomplete';
import {MessageCharacterCounter} from '~/components/channel/MessageCharacterCounter';
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
import {Input} from '~/components/form/Input';
import {ExpressionPickerSheet} from '~/components/modals/ExpressionPickerSheet';
import * as Modal from '~/components/modals/Modal';
import {
getForwardChannelCategoryName,
getForwardChannelDisplayName,
getForwardChannelGuildName,
useForwardChannelSelection,
} from '~/components/modals/shared/forwardChannelSelection';
import selectorStyles from '~/components/modals/shared/SelectorModalStyles.module.css';
import {ExpressionPickerPopout} from '~/components/popouts/ExpressionPickerPopout';
import {Button} from '~/components/uikit/Button/Button';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import {Popout} from '~/components/uikit/Popout/Popout';
import {Scroller} from '~/components/uikit/Scroller';
import {useTextareaAutocomplete} from '~/hooks/useTextareaAutocomplete';
import {useTextareaEmojiPicker} from '~/hooks/useTextareaEmojiPicker';
import {useTextareaPaste} from '~/hooks/useTextareaPaste';
import {useTextareaSegments} from '~/hooks/useTextareaSegments';
import {TextareaAutosize} from '~/lib/TextareaAutosize';
import {Routes} from '~/Routes';
import type {MessageRecord} from '~/records/MessageRecord';
import ChannelStore from '~/stores/ChannelStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import UserStore from '~/stores/UserStore';
import * as RouterUtils from '~/utils/RouterUtils';
import {FocusRing} from '../uikit/FocusRing';
import {StatusAwareAvatar} from '../uikit/StatusAwareAvatar';
import modalStyles from './ForwardModal.module.css';
export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
const {t} = useLingui();
const {filteredChannels, handleToggleChannel, isChannelDisabled, searchQuery, selectedChannelIds, setSearchQuery} =
useForwardChannelSelection({excludedChannelId: message.channelId});
const [optionalMessage, setOptionalMessage] = React.useState('');
const [isForwarding, setIsForwarding] = React.useState(false);
const [expressionPickerOpen, setExpressionPickerOpen] = React.useState(false);
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const currentUser = UserStore.currentUser!;
const mobileLayout = MobileLayoutStore;
const {segmentManagerRef, previousValueRef, displayToActual, insertSegment, handleTextChange} = useTextareaSegments();
const {handleEmojiSelect} = useTextareaEmojiPicker({
setValue: setOptionalMessage,
textareaRef,
insertSegment,
previousValueRef,
});
const channel = ChannelStore.getChannel(message.channelId)!;
const {
autocompleteQuery,
autocompleteOptions,
autocompleteType,
selectedIndex,
isAutocompleteAttached,
setSelectedIndex,
onCursorMove,
handleSelect,
} = useTextareaAutocomplete({
channel,
value: optionalMessage,
setValue: setOptionalMessage,
textareaRef,
segmentManagerRef,
previousValueRef,
});
useTextareaPaste({
channel,
textareaRef,
segmentManagerRef,
setValue: setOptionalMessage,
previousValueRef,
});
const handleForward = async () => {
if (selectedChannelIds.size === 0 || isForwarding) return;
setIsForwarding(true);
try {
const actualMessage = optionalMessage.trim() ? displayToActual(optionalMessage) : undefined;
await MessageActionCreators.forward(
Array.from(selectedChannelIds),
{
message_id: message.id,
channel_id: message.channelId,
guild_id: channel.guildId,
},
actualMessage,
);
ToastActionCreators.createToast({
type: 'success',
children: <Trans>Message forwarded</Trans>,
});
ModalActionCreators.pop();
if (selectedChannelIds.size === 1) {
const forwardedChannelId = Array.from(selectedChannelIds)[0];
const forwardedChannel = ChannelStore.getChannel(forwardedChannelId);
if (forwardedChannel) {
if (forwardedChannel.guildId) {
RouterUtils.transitionTo(Routes.guildChannel(forwardedChannel.guildId, forwardedChannelId));
} else {
RouterUtils.transitionTo(Routes.dmChannel(forwardedChannelId));
}
}
}
} catch (error) {
console.error('Failed to forward message:', error);
ModalActionCreators.push(modal(() => <MessageForwardFailedModal />));
} finally {
setIsForwarding(false);
}
};
const getChannelIcon = (ch: any) => {
const iconSize = 32;
if (ch.type === ChannelTypes.DM_PERSONAL_NOTES) {
return <NotePencilIcon className={selectorStyles.itemIcon} weight="fill" size={iconSize} />;
}
if (ch.type === ChannelTypes.DM) {
const recipientId = ch.recipientIds[0];
const user = UserStore.getUser(recipientId);
if (!user) return null;
return (
<div className={selectorStyles.avatar}>
<StatusAwareAvatar user={user} size={iconSize} />
</div>
);
}
if (ch.type === ChannelTypes.GROUP_DM) {
return (
<div className={selectorStyles.avatar}>
<GroupDMAvatar channel={ch} size={iconSize} />
</div>
);
}
if (ch.type === ChannelTypes.GUILD_VOICE) {
return <SpeakerHighIcon className={selectorStyles.itemIcon} weight="fill" size={iconSize} />;
}
return <HashIcon className={selectorStyles.itemIcon} weight="bold" size={iconSize} />;
};
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Forward Message`}>
<div className={selectorStyles.headerSearch}>
<Input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t`Search channels or DMs`}
maxLength={100}
leftIcon={<MagnifyingGlassIcon className={selectorStyles.searchIcon} weight="bold" />}
className={selectorStyles.headerSearchInput}
/>
</div>
</Modal.Header>
<Modal.Content className={selectorStyles.selectorContent}>
<div className={selectorStyles.listContainer}>
<Scroller
className={selectorStyles.scroller}
key="forward-modal-channel-list-scroller"
fade={false}
reserveScrollbarTrack={false}
>
{filteredChannels.length === 0 ? (
<div className={selectorStyles.emptyState}>
<Trans>No channels found</Trans>
</div>
) : (
<div className={selectorStyles.itemList}>
{filteredChannels.map((ch) => {
if (!ch) return null;
const isSelected = selectedChannelIds.has(ch.id);
const isDisabled = isChannelDisabled(ch.id);
const displayName = getForwardChannelDisplayName(ch);
const categoryName = getForwardChannelCategoryName(ch);
const guildName = getForwardChannelGuildName(ch);
return (
<FocusRing key={ch.id} offset={-2} enabled={!isDisabled}>
<button
type="button"
onClick={() => !isDisabled && handleToggleChannel(ch.id)}
disabled={isDisabled}
className={clsx(
selectorStyles.itemButton,
isSelected && selectorStyles.itemButtonSelected,
isDisabled && selectorStyles.itemButtonDisabled,
)}
>
<div className={selectorStyles.itemContent}>
{getChannelIcon(ch)}
<div className={selectorStyles.itemInfo}>
<span className={selectorStyles.itemName}>{displayName}</span>
{ch.type === ChannelTypes.GUILD_TEXT ? (
<span className={selectorStyles.itemSecondary}>
{categoryName ? categoryName : t`No Category`}
{guildName && `${guildName}`}
</span>
) : (
guildName && <span className={selectorStyles.itemSecondary}>{guildName}</span>
)}
</div>
</div>
<div className={selectorStyles.itemAction}>
<Checkbox checked={isSelected} disabled={isDisabled} aria-hidden={true} />
</div>
</button>
</FocusRing>
);
})}
</div>
)}
</Scroller>
</div>
</Modal.Content>
<div className={modalStyles.inputAreaContainer}>
{isAutocompleteAttached && (
<Autocomplete
type={autocompleteType}
onSelect={handleSelect}
selectedIndex={selectedIndex}
options={autocompleteOptions}
setSelectedIndex={setSelectedIndex}
referenceElement={containerRef.current}
zIndex={20000}
query={autocompleteQuery}
/>
)}
<div ref={containerRef} className={modalStyles.messageInputContainer}>
<TextareaAutosize
className={clsx(modalStyles.messageInput, modalStyles.messageInputBase)}
maxLength={currentUser.maxMessageLength}
ref={textareaRef}
value={optionalMessage}
onChange={(e) => {
const newValue = e.target.value;
handleTextChange(newValue, previousValueRef.current);
setOptionalMessage(newValue);
}}
onKeyDown={(e) => {
onCursorMove();
if (isAutocompleteAttached) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prevIndex) => {
const newIndex = e.key === 'ArrowUp' ? prevIndex - 1 : prevIndex + 1;
return (newIndex + autocompleteOptions.length) % autocompleteOptions.length;
});
} else if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
e.preventDefault();
const selectedOption = autocompleteOptions[selectedIndex];
if (selectedOption) {
handleSelect(selectedOption);
}
}
}
}}
placeholder={t`Add a comment (optional)`}
/>
<MessageCharacterCounter
currentLength={optionalMessage.length}
maxLength={currentUser.maxMessageLength}
isPremium={currentUser.isPremium()}
/>
<div className={modalStyles.messageInputActions}>
{mobileLayout.enabled ? (
<FocusRing offset={-2}>
<button
type="button"
onClick={() => setExpressionPickerOpen(true)}
className={clsx(
modalStyles.emojiPickerButton,
expressionPickerOpen && modalStyles.emojiPickerButtonActive,
)}
>
<SmileyIcon className={modalStyles.emojiIcon} weight="fill" />
</button>
</FocusRing>
) : (
<Popout
position="top-end"
animationType="none"
offsetMainAxis={8}
offsetCrossAxis={0}
onOpen={() => setExpressionPickerOpen(true)}
onClose={() => setExpressionPickerOpen(false)}
returnFocusRef={textareaRef}
render={({onClose}) => (
<ExpressionPickerPopout
channelId={message.channelId}
onEmojiSelect={(emoji) => {
handleEmojiSelect(emoji);
onClose();
}}
onClose={onClose}
visibleTabs={['emojis']}
/>
)}
>
<FocusRing offset={-2}>
<button
type="button"
className={clsx(
modalStyles.emojiPickerButton,
expressionPickerOpen && modalStyles.emojiPickerButtonActive,
)}
>
<SmileyIcon className={modalStyles.emojiIcon} weight="fill" />
</button>
</FocusRing>
</Popout>
)}
</div>
</div>
</div>
<Modal.Footer>
<div className={selectorStyles.actionRow}>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()} className={selectorStyles.actionButton}>
<Trans>Cancel</Trans>
</Button>
<Button
onClick={handleForward}
disabled={selectedChannelIds.size === 0 || isForwarding}
className={selectorStyles.actionButton}
>
<Trans>Send ({selectedChannelIds.size}/5)</Trans>
</Button>
</div>
</Modal.Footer>
{mobileLayout.enabled && (
<ExpressionPickerSheet
isOpen={expressionPickerOpen}
onClose={() => setExpressionPickerOpen(false)}
channelId={message.channelId}
onEmojiSelect={handleEmojiSelect}
visibleTabs={['emojis']}
selectedTab="emojis"
zIndex={30000}
/>
)}
</Modal.Root>
);
});

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/>.
*/
.content {
display: flex;
flex-direction: column;
}
.loadingContent {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.card {
padding: 1rem;
padding-top: 0;
}
.cardGrid {
display: flex;
align-items: center;
gap: 0.75rem;
}
.iconCircle {
display: flex;
flex-shrink: 0;
height: 3rem;
width: 3rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
}
.iconCircleActive {
background: linear-gradient(to bottom right, rgb(168 85 247 / 1), rgb(236 72 153 / 1));
}
.iconCircleInactive {
background: linear-gradient(to bottom right, rgb(168 85 247 / 0.5), rgb(236 72 153 / 0.5));
}
.iconCircleDisabled {
background-color: var(--background-tertiary);
}
.icon {
height: 1.5rem;
width: 1.5rem;
color: white;
}
.iconError {
color: var(--text-tertiary);
}
.cardContent {
display: flex;
flex-direction: column;
min-width: 0;
}
.title {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: 600;
font-size: 1rem;
}
.titlePrimary {
color: var(--text-primary);
}
.titleTertiary {
color: var(--text-tertiary);
}
.titleDanger {
color: var(--status-danger);
}
.subtitle {
color: var(--text-secondary);
font-size: 0.8rem;
line-height: 1.25;
}
.helpText {
color: var(--text-tertiary);
font-size: 0.8rem;
margin-top: 0.125rem;
}
.footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border-color);
}

View File

@@ -0,0 +1,175 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {GiftIcon, QuestionIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as GiftActionCreators from '~/actions/GiftActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner';
import i18n from '~/i18n';
import {UserRecord} from '~/records/UserRecord';
import GiftStore from '~/stores/GiftStore';
import {getGiftDurationText} from '~/utils/giftUtils';
import styles from './GiftAcceptModal.module.css';
interface GiftAcceptModalProps {
code: string;
}
export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcceptModalProps) {
const {t} = useLingui();
const giftState = GiftStore.gifts.get(code) ?? null;
const gift = giftState?.data ?? null;
const [isRedeeming, setIsRedeeming] = React.useState(false);
React.useEffect(() => {
if (!giftState) {
void GiftActionCreators.fetchWithCoalescing(code).catch(() => {});
}
}, [code, giftState]);
const creator = React.useMemo(() => {
if (!gift?.created_by) return null;
return new UserRecord({
id: gift.created_by.id,
username: gift.created_by.username,
discriminator: gift.created_by.discriminator,
avatar: gift.created_by.avatar,
flags: gift.created_by.flags,
});
}, [gift?.created_by]);
const handleDismiss = () => {
ModalActionCreators.pop();
};
const handleRedeem = async () => {
setIsRedeeming(true);
try {
await GiftActionCreators.redeem(i18n, code);
ModalActionCreators.pop();
} catch (error) {
console.error('[GiftAcceptModal] Failed to redeem gift:', error);
setIsRedeeming(false);
}
};
const renderLoading = () => (
<div className={styles.loadingContent}>
<Spinner />
</div>
);
const renderError = () => (
<>
<div className={styles.card}>
<div className={styles.cardGrid}>
<div className={`${styles.iconCircle} ${styles.iconCircleDisabled}`}>
<QuestionIcon className={`${styles.icon} ${styles.iconError}`} />
</div>
<div className={styles.cardContent}>
<h3 className={`${styles.title} ${styles.titleDanger}`}>{t`Unknown Gift`}</h3>
<span className={styles.helpText}>{t`This gift code is invalid or already claimed.`}</span>
</div>
</div>
</div>
<div className={styles.footer}>
<Button variant="secondary" onClick={handleDismiss}>
<Trans>Dismiss</Trans>
</Button>
</div>
</>
);
const renderRedeemed = () => {
const durationText = getGiftDurationText(i18n, gift!);
return (
<>
<div className={styles.card}>
<div className={styles.cardGrid}>
<div className={`${styles.iconCircle} ${styles.iconCircleInactive}`}>
<GiftIcon className={styles.icon} weight="fill" />
</div>
<div className={styles.cardContent}>
<h3 className={`${styles.title} ${styles.titleTertiary}`}>{durationText}</h3>
{creator && (
<span className={styles.subtitle}>{t`From ${creator.username}#${creator.discriminator}`}</span>
)}
<span className={styles.helpText}>{t`This gift has already been claimed.`}</span>
</div>
</div>
</div>
<div className={styles.footer}>
<Button variant="secondary" onClick={handleDismiss}>
<Trans>Dismiss</Trans>
</Button>
</div>
</>
);
};
const renderGift = () => {
const durationText = getGiftDurationText(i18n, gift!);
return (
<>
<div className={styles.card}>
<div className={styles.cardGrid}>
<div className={`${styles.iconCircle} ${styles.iconCircleActive}`}>
<GiftIcon className={styles.icon} weight="fill" />
</div>
<div className={styles.cardContent}>
<h3 className={`${styles.title} ${styles.titlePrimary}`}>{durationText}</h3>
{creator && (
<span className={styles.subtitle}>{t`From ${creator.username}#${creator.discriminator}`}</span>
)}
<span className={styles.helpText}>{t`Claim your gift to activate your premium subscription!`}</span>
</div>
</div>
</div>
<div className={styles.footer}>
<Button variant="secondary" onClick={handleDismiss} disabled={isRedeeming}>
<Trans>Maybe later</Trans>
</Button>
<Button variant="primary" onClick={handleRedeem} disabled={isRedeeming} submitting={isRedeeming}>
<Trans>Claim Gift</Trans>
</Button>
</div>
</>
);
};
return (
<Modal.Root size="small" centered>
<Modal.Header title={<Trans>Gift</Trans>} />
<Modal.Content padding="none" className={styles.content}>
{!giftState || giftState.loading
? renderLoading()
: giftState.error || !gift
? renderError()
: gift.redeemed
? renderRedeemed()
: renderGift()}
</Modal.Content>
</Modal.Root>
);
});

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/>.
*/
.container {
display: flex;
height: 100%;
flex-direction: column;
overflow: hidden;
}
.backButton {
display: flex;
align-items: center;
color: var(--text-primary);
}
.backIcon {
height: 20px;
width: 20px;
}
.scroller {
flex: 1;
}
.content {
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
}
.loadingContainer {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 0;
}
.loadingText {
color: var(--text-primary-muted);
}
.emptyContainer {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 0;
}
.emptyText {
color: var(--text-primary-muted);
}
.inviteList {
display: flex;
flex-direction: column;
gap: 8px;
}
.inviteItem {
display: flex;
align-items: center;
gap: 12px;
border-radius: 6px;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-tertiary);
padding: 12px;
}
.inviteDetails {
flex: 1;
}
.inviteUrl {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.inviteInfo {
font-size: 12px;
color: var(--text-primary-muted);
}
.revokeButton {
display: flex;
height: 32px;
width: 32px;
align-items: center;
justify-content: center;
border-radius: 9999px;
color: var(--text-primary-muted);
transition:
background-color 0.2s,
color 0.2s;
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) {
.revokeButton:hover {
background-color: color-mix(in srgb, var(--status-danger) 15%, transparent);
color: var(--status-danger);
}
}
.revokeIcon {
height: 16px;
width: 16px;
}

View File

@@ -0,0 +1,179 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowLeftIcon, TrashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as InviteActionCreators from '~/actions/InviteActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {InviteRevokeFailedModal} from '~/components/alerts/InviteRevokeFailedModal';
import {InvitesLoadFailedModal} from '~/components/alerts/InvitesLoadFailedModal';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import {Avatar} from '~/components/uikit/Avatar';
import {BottomSheet} from '~/components/uikit/BottomSheet/BottomSheet';
import {Scroller} from '~/components/uikit/Scroller';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import type {Invite} from '~/records/MessageRecord';
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import UserStore from '~/stores/UserStore';
import styles from './GroupInvitesBottomSheet.module.css';
interface GroupInvitesBottomSheetProps {
isOpen: boolean;
onClose: () => void;
channelId: string;
}
export const GroupInvitesBottomSheet: React.FC<GroupInvitesBottomSheetProps> = observer(
({isOpen, onClose, channelId}) => {
const {t} = useLingui();
const [invites, setInvites] = React.useState<Array<Invite> | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const loadInvites = React.useCallback(async () => {
try {
setIsLoading(true);
const data = await InviteActionCreators.list(channelId);
setInvites(data);
} catch (error) {
console.error('Failed to load invites:', error);
ModalActionCreators.push(modal(() => <InvitesLoadFailedModal />));
} finally {
setIsLoading(false);
}
}, [channelId]);
React.useEffect(() => {
if (isOpen) {
loadInvites();
}
}, [isOpen, loadInvites]);
const handleRevoke = React.useCallback(
(code: string) => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Revoke invite`}
description={t`Are you sure you want to revoke this invite? This action cannot be undone.`}
primaryText={t`Revoke`}
onPrimary={async () => {
try {
await InviteActionCreators.remove(code);
ToastActionCreators.createToast({
type: 'success',
children: <Trans>Invite revoked</Trans>,
});
await loadInvites();
} catch (error) {
console.error('Failed to revoke invite:', error);
ModalActionCreators.push(modal(() => <InviteRevokeFailedModal />));
}
}}
/>
)),
);
},
[loadInvites],
);
const formatExpiresAt = (expiresAt: string | null) => {
if (!expiresAt) return t`Never`;
const date = new Date(expiresAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return t`Expired`;
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(hours / 24);
if (days > 0) return t`${days} days`;
return t`${hours} hours`;
};
return (
<BottomSheet
isOpen={isOpen}
onClose={onClose}
snapPoints={[0, 1]}
initialSnap={1}
disablePadding={true}
surface="primary"
leadingAction={
<button type="button" onClick={onClose} className={styles.backButton}>
<ArrowLeftIcon className={styles.backIcon} weight="bold" />
</button>
}
title={t`Group Invites`}
>
<div className={styles.container}>
<Scroller className={styles.scroller} key="group-invites-bottom-sheet-scroller">
<div className={styles.content}>
{isLoading ? (
<div className={styles.loadingContainer}>
<p className={styles.loadingText}>
<Trans>Loading invites...</Trans>
</p>
</div>
) : invites && invites.length === 0 ? (
<div className={styles.emptyContainer}>
<p className={styles.emptyText}>
<Trans>No invites created</Trans>
</p>
</div>
) : (
<div className={styles.inviteList}>
{invites?.map((invite) => {
const inviter = invite.inviter ? UserStore.getUser(invite.inviter.id) : null;
return (
<div key={invite.code} className={styles.inviteItem}>
{inviter && <Avatar user={inviter} size={32} />}
<div className={styles.inviteDetails}>
<div className={styles.inviteUrl}>
{RuntimeConfigStore.inviteEndpoint}/{invite.code}
</div>
<div className={styles.inviteInfo}>
<Trans>
Created by {inviter?.username}. Expires in {formatExpiresAt(invite.expires_at)}.
</Trans>
</div>
</div>
<Tooltip text={t`Revoke invite`}>
<button
type="button"
onClick={() => handleRevoke(invite.code)}
className={styles.revokeButton}
aria-label={t`Revoke invite`}
>
<TrashIcon className={styles.revokeIcon} />
</button>
</Tooltip>
</div>
);
})}
</div>
)}
</div>
</Scroller>
</div>
</BottomSheet>
);
},
);

View File

@@ -0,0 +1,92 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.modalRoot {
composes: root large from './Modal.module.css';
width: 720px;
max-width: 720px;
overflow: visible;
}
@media screen and (max-width: 639px) {
.modalRoot {
width: 100%;
max-width: 100%;
}
}
.spinnerContainer {
display: flex;
justify-content: center;
padding: 1.5rem 0;
}
.errorBox {
border-radius: 0.375rem;
border: 1px solid var(--background-header-secondary);
background: var(--background-tertiary);
padding: 1rem;
}
.errorText {
margin: 0;
text-align: center;
color: var(--text-primary-muted);
}
.stateBox {
display: flex;
justify-content: center;
padding: 1.5rem 0;
}
.stateText {
margin: 0;
color: var(--text-primary-muted);
font-size: 0.875rem;
}
.invitesWrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.invitesList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.scroller {
max-height: 384px;
}
.inviteItems {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0 12px 12px 12px;
}

View File

@@ -0,0 +1,165 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 InviteActionCreators from '~/actions/InviteActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {InviteRevokeFailedModal} from '~/components/alerts/InviteRevokeFailedModal';
import {InvitesLoadFailedModal} from '~/components/alerts/InvitesLoadFailedModal';
import {InviteDateToggle} from '~/components/invites/InviteDateToggle';
import {InviteListHeader, InviteListItem} from '~/components/invites/InviteListItem';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import * as Modal from '~/components/modals/Modal';
import {Scroller} from '~/components/uikit/Scroller';
import {Spinner} from '~/components/uikit/Spinner';
import type {Invite} from '~/records/MessageRecord';
import AuthenticationStore from '~/stores/AuthenticationStore';
import ChannelStore from '~/stores/ChannelStore';
import styles from './GroupInvitesModal.module.css';
export const GroupInvitesModal = observer(({channelId}: {channelId: string}) => {
const {t} = useLingui();
const channel = ChannelStore.getChannel(channelId);
const isOwner = channel?.ownerId === AuthenticationStore.currentUserId;
const [invites, setInvites] = React.useState<Array<Invite>>([]);
const [fetchStatus, setFetchStatus] = React.useState<'idle' | 'pending' | 'success' | 'error'>('idle');
const [showCreatedDate, setShowCreatedDate] = React.useState(false);
const loadInvites = React.useCallback(async () => {
if (!isOwner) return;
try {
setFetchStatus('pending');
const data = await InviteActionCreators.list(channelId);
setInvites(data);
setFetchStatus('success');
} catch (error) {
console.error('Failed to load invites:', error);
setFetchStatus('error');
ModalActionCreators.push(modal(() => <InvitesLoadFailedModal />));
}
}, [channelId, isOwner]);
React.useEffect(() => {
if (isOwner && fetchStatus === 'idle') {
loadInvites();
}
}, [fetchStatus, loadInvites, isOwner]);
const handleRevoke = React.useCallback(
(code: string) => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Revoke invite`}
description={t`Are you sure you want to revoke this invite? This action cannot be undone.`}
primaryText={t`Revoke`}
onPrimary={async () => {
try {
await InviteActionCreators.remove(code);
ToastActionCreators.createToast({
type: 'success',
children: <Trans>Invite revoked</Trans>,
});
await loadInvites();
} catch (error) {
console.error('Failed to revoke invite:', error);
ModalActionCreators.push(modal(() => <InviteRevokeFailedModal />));
}
}}
/>
)),
);
},
[loadInvites],
);
if (!isOwner) {
return (
<Modal.Root className={styles.modalRoot}>
<Modal.Header title={t`Group Invites`} />
<Modal.Content>
<div className={styles.container}>
<div className={styles.errorBox}>
<p className={styles.errorText}>
<Trans>Only the group owner can manage invites.</Trans>
</p>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}
return (
<Modal.Root className={styles.modalRoot}>
<Modal.Header title={t`Group Invites`} />
<Modal.Content>
<div className={styles.container}>
{fetchStatus === 'pending' && (
<div className={styles.spinnerContainer}>
<Spinner />
</div>
)}
{fetchStatus === 'error' && (
<div className={styles.errorBox}>
<p className={styles.errorText}>
<Trans>Failed to load invites. Please try again.</Trans>
</p>
</div>
)}
{fetchStatus === 'success' && invites.length === 0 && (
<div className={styles.stateBox}>
<p className={styles.stateText}>
<Trans>No invites created</Trans>
</p>
</div>
)}
{fetchStatus === 'success' && invites.length > 0 && (
<div className={styles.invitesWrapper}>
<InviteDateToggle showCreatedDate={showCreatedDate} onToggle={setShowCreatedDate} />
<div className={styles.invitesList}>
<Scroller className={styles.scroller} key="group-invites-scroller">
<InviteListHeader showCreatedDate={showCreatedDate} />
<div className={styles.inviteItems}>
{invites.map((invite) => (
<InviteListItem
key={invite.code}
invite={invite}
onRevoke={handleRevoke}
showCreatedDate={showCreatedDate}
/>
))}
</div>
</Scroller>
</div>
</div>
)}
</div>
</Modal.Content>
</Modal.Root>
);
});

View File

@@ -0,0 +1,68 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useForm} from 'react-hook-form';
import * as GuildActionCreators from '~/actions/GuildActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {Form} from '~/components/form/Form';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
export const GuildDeleteModal = observer(({guildId}: {guildId: string}) => {
const {t} = useLingui();
const form = useForm();
const onSubmit = async () => {
await GuildActionCreators.remove(guildId);
ModalActionCreators.pop();
ToastActionCreators.createToast({type: 'success', children: t`Community deleted`});
};
const {handleSubmit} = useFormSubmit({
form,
onSubmit,
defaultErrorField: 'form',
});
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit} aria-label={t`Delete community form`}>
<Modal.Header title={t`Delete Community`} />
<Modal.Content>
<Trans>
Are you sure you want to delete this community? This action cannot be undone. All channels, messages, and
settings will be permanently deleted.
</Trans>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>I changed my mind</Trans>
</Button>
<Button type="submit" submitting={form.formState.isSubmitting} variant="danger-primary">
<Trans>Delete Community</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

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;
flex-direction: column;
gap: 24px;
}
.section {
display: flex;
flex-direction: column;
gap: 16px;
}
.sectionTitle {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
max-height: 2.8em;
color: var(--text-primary);
}
.notificationSection {
display: flex;
flex-direction: column;
gap: 12px;
}
.suppressSection {
display: flex;
flex-direction: column;
gap: 16px;
}
.mobilePushSection {
display: flex;
flex-direction: column;
gap: 16px;
}
.overridesSection {
display: flex;
flex-direction: column;
gap: 12px;
}
.overridesHeader {
margin-top: 8px;
display: none;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
gap: 8px;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
color: var(--text-tertiary);
text-transform: uppercase;
}
@media (min-width: 1024px) {
.overridesHeader {
display: grid;
}
}
.overridesHeaderCell {
text-align: center;
}
.overridesHeaderCellLeft {
text-align: left;
}
.overridesHeaderCellMute {
padding-left: 8px;
text-align: center;
}
.overrideItem {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
border-radius: 8px;
border: 1px solid var(--background-header-secondary);
padding: 12px;
transition: background-color 0.2s;
}
.overrideItem:hover {
background-color: var(--background-secondary);
}
@media (min-width: 1024px) {
.overrideItem {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
align-items: center;
gap: 8px;
min-height: 64px;
cursor: pointer;
}
}
.overrideHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
@media (min-width: 1024px) {
.overrideHeader {
display: contents;
}
}
.channelInfo {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.channelIcon {
flex-shrink: 0;
color: var(--text-tertiary);
}
.channelDetails {
display: flex;
min-width: 0;
flex-direction: column;
justify-content: center;
}
.channelName {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
max-height: 2.8em;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
}
.categoryName {
font-size: 12px;
line-height: 1.4;
max-height: 2.8em;
color: var(--text-tertiary);
}
.mobileOverrideOptions {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 8px;
border-top: 1px solid var(--background-header-secondary);
}
@media (min-width: 1024px) {
.mobileOverrideOptions {
display: none;
}
}
.desktopNotificationOptions {
display: none;
}
@media (min-width: 1024px) {
.desktopNotificationOptions {
display: contents;
}
}
.checkboxCell {
display: flex;
justify-content: center;
align-items: center;
}
.removeButton {
display: flex;
height: 24px;
width: 24px;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 9999px;
background-color: var(--background-tertiary);
color: var(--text-tertiary);
transition:
background-color 0.2s,
color 0.2s;
cursor: pointer;
}
.removeButton:hover {
background-color: var(--status-danger);
color: white;
}
@media (min-width: 1024px) {
.removeButton {
position: absolute;
top: 50%;
right: -12px;
transform: translateY(-50%);
background-color: var(--status-danger);
color: white;
opacity: 0;
transition: opacity 0.2s;
}
.overrideItem:hover .removeButton {
opacity: 1;
}
.removeButton:hover {
opacity: 0.8;
}
}
.removeIcon {
font-size: 14px;
font-weight: bold;
}
.optionContainer {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
.optionContent {
display: flex;
align-items: center;
gap: 0.5rem;
}
.optionCategory {
font-size: 0.75rem;
line-height: 1rem;
color: var(--text-tertiary);
text-transform: uppercase;
}
.singleValueContainer {
display: flex;
align-items: center;
gap: 0.5rem;
}
.iconTertiary {
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 {useLingui} from '@lingui/react/macro';
import {FolderIcon, XIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as UserGuildSettingsActionCreators from '~/actions/UserGuildSettingsActionCreators';
import {ChannelTypes, MessageNotifications} from '~/Constants';
import {Select} from '~/components/form/Select';
import {Switch} from '~/components/form/Switch';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import {RadioGroup, type RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
import ChannelStore from '~/stores/ChannelStore';
import GuildStore from '~/stores/GuildStore';
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
import styles from './GuildNotificationSettingsModal.module.css';
interface ChannelOption {
value: string;
label: string;
icon: React.ReactNode;
categoryName?: string;
isCategory: boolean;
}
export const GuildNotificationSettingsModal = observer(({guildId}: {guildId: string}) => {
const {t} = useLingui();
const guild = GuildStore.getGuild(guildId);
const settings = UserGuildSettingsStore.getSettings(guildId);
if (!guild || !settings) return null;
const channels = ChannelStore.getGuildChannels(guildId);
const categories = channels.filter((c) => c.type === ChannelTypes.GUILD_CATEGORY);
const channelOptions: Array<ChannelOption> = [
...categories.map((cat) => ({
value: cat.id,
label: cat.name || '',
icon: <FolderIcon size={16} className={styles.iconTertiary} />,
isCategory: true,
})),
...channels
.filter((c) => c.type !== ChannelTypes.GUILD_CATEGORY)
.map((ch) => {
const category = ch.parentId ? categories.find((c) => c.id === ch.parentId) : null;
return {
value: ch.id,
label: ch.name || '',
icon: ChannelUtils.getIcon(ch, {size: 16, className: styles.iconTertiary}),
categoryName: category?.name ?? undefined,
isCategory: false,
};
}),
];
const selectOptions = channelOptions.map((option) => ({
value: option.value,
label: option.label,
isDisabled: false,
}));
const notificationOptions: Array<RadioOption<number>> = [
{
value: MessageNotifications.ALL_MESSAGES,
name: t`All Messages`,
},
{
value: MessageNotifications.ONLY_MENTIONS,
name: t`Only @mentions`,
},
{
value: MessageNotifications.NO_MESSAGES,
name: t`Nothing`,
},
];
const handleAddOverride = (value: string | null) => {
if (!value) return;
const existingOverride = settings.channel_overrides?.[value];
if (existingOverride) {
return;
}
UserGuildSettingsActionCreators.updateChannelOverride(guildId, value, {
message_notifications: MessageNotifications.INHERIT,
muted: false,
});
};
const handleRemoveOverride = (channelId: string) => {
UserGuildSettingsActionCreators.updateChannelOverride(guildId, channelId, null);
};
const handleOverrideNotificationChange = (channelId: string, level: number) => {
UserGuildSettingsActionCreators.updateChannelOverride(guildId, channelId, {
message_notifications: level,
});
};
const handleOverrideMuteChange = (channelId: string, muted: boolean) => {
UserGuildSettingsActionCreators.updateChannelOverride(guildId, channelId, {
muted,
});
};
const overrideChannels = settings.channel_overrides
? Object.entries(settings.channel_overrides)
.map(([channelId, override]) => {
const channel = ChannelStore.getChannel(channelId);
const category = channel?.parentId ? ChannelStore.getChannel(channel.parentId) : null;
const isCategory = channel?.type === ChannelTypes.GUILD_CATEGORY;
return {
channelId,
override,
channel,
category,
isCategory,
};
})
.sort((a, b) => {
if (!a.channel && !b.channel) return 0;
if (!a.channel) return 1;
if (!b.channel) return -1;
const posA = a.channel.position ?? 0;
const posB = b.channel.position ?? 0;
if (posA !== posB) {
return posA - posB;
}
return a.channelId.localeCompare(b.channelId);
})
: [];
return (
<Modal.Root size="medium">
<Modal.Header title={t`Notification Settings`} />
<Modal.Content>
<div className={styles.container}>
<div className={styles.section}>
<Switch
label={t`Mute ${guild.name}`}
description={t`Muting a community prevents unread indicators and notifications from appearing unless you are mentioned`}
value={settings.muted}
onChange={(value) => UserGuildSettingsActionCreators.updateGuildSettings(guildId, {muted: value})}
/>
</div>
<div className={styles.notificationSection}>
<h3 className={styles.sectionTitle}>{t`Community Notification Settings`}</h3>
<RadioGroup
options={notificationOptions}
value={settings.message_notifications}
onChange={(value) =>
UserGuildSettingsActionCreators.updateGuildSettings(guildId, {message_notifications: value})
}
aria-label={t`Community notification level`}
/>
</div>
<div className={styles.suppressSection}>
<Switch
label={t`Suppress @everyone and @here`}
value={settings.suppress_everyone}
onChange={(value) =>
UserGuildSettingsActionCreators.updateGuildSettings(guildId, {suppress_everyone: value})
}
/>
<Switch
label={t`Suppress all role @mentions`}
value={settings.suppress_roles}
onChange={(value) =>
UserGuildSettingsActionCreators.updateGuildSettings(guildId, {suppress_roles: value})
}
/>
</div>
<div className={styles.mobilePushSection}>
<Switch
label={t`Mobile Push Notifications`}
value={settings.mobile_push}
onChange={(value) => UserGuildSettingsActionCreators.updateGuildSettings(guildId, {mobile_push: value})}
/>
</div>
<div className={styles.overridesSection}>
<h3 className={styles.sectionTitle}>{t`Notification Overrides`}</h3>
<Select<string | null>
value={null}
options={selectOptions}
onChange={handleAddOverride}
placeholder={t`Select a channel or category`}
/>
{overrideChannels.length > 0 && (
<div className={styles.overridesSection}>
<div className={styles.overridesHeader}>
<div className={styles.overridesHeaderCellLeft}>{t`Channel or Category`}</div>
<div className={styles.overridesHeaderCell}>{t`All`}</div>
<div className={styles.overridesHeaderCell}>{t`Mentions`}</div>
<div className={styles.overridesHeaderCell}>{t`Nothing`}</div>
<div className={styles.overridesHeaderCellMute}>{t`Mute`}</div>
</div>
{overrideChannels.map(({channelId, override, channel, category, isCategory}) => {
if (!channel) return null;
const notifLevel = override.message_notifications ?? MessageNotifications.INHERIT;
const isAll = notifLevel === MessageNotifications.ALL_MESSAGES;
const isMentions = notifLevel === MessageNotifications.ONLY_MENTIONS;
const isNothing = notifLevel === MessageNotifications.NO_MESSAGES;
const isInherit = notifLevel === MessageNotifications.INHERIT;
const resolvedLevel = isInherit ? settings.message_notifications : notifLevel;
return (
<div key={channelId} className={styles.overrideItem}>
<div className={styles.overrideHeader}>
<div className={styles.channelInfo}>
{isCategory ? (
<FolderIcon size={20} className={styles.channelIcon} />
) : (
ChannelUtils.getIcon(channel, {
size: 20,
className: styles.channelIcon,
})
)}
<div className={styles.channelDetails}>
<span className={styles.channelName}>{channel.name ?? ''}</span>
{!isCategory && (
<span className={styles.categoryName}>
{category ? (category.name ?? '') : t`No Category`}
</span>
)}
</div>
</div>
<button
type="button"
onClick={() => handleRemoveOverride(channelId)}
className={styles.removeButton}
aria-label={t`Remove override`}
>
<XIcon size={14} weight="bold" />
</button>
</div>
<div className={styles.mobileOverrideOptions}>
<Switch
label={t`All Messages`}
value={isAll || (isInherit && resolvedLevel === MessageNotifications.ALL_MESSAGES)}
onChange={() =>
handleOverrideNotificationChange(channelId, MessageNotifications.ALL_MESSAGES)
}
compact
/>
<Switch
label={t`Only @mentions`}
value={isMentions || (isInherit && resolvedLevel === MessageNotifications.ONLY_MENTIONS)}
onChange={() =>
handleOverrideNotificationChange(channelId, MessageNotifications.ONLY_MENTIONS)
}
compact
/>
<Switch
label={t`Nothing`}
value={isNothing || (isInherit && resolvedLevel === MessageNotifications.NO_MESSAGES)}
onChange={() => handleOverrideNotificationChange(channelId, MessageNotifications.NO_MESSAGES)}
compact
/>
<Switch
label={t`Mute Channel`}
value={override.muted}
onChange={(checked) => handleOverrideMuteChange(channelId, checked)}
compact
/>
</div>
<div className={styles.desktopNotificationOptions}>
<div className={styles.checkboxCell}>
<Checkbox
checked={isAll || (isInherit && resolvedLevel === MessageNotifications.ALL_MESSAGES)}
onChange={() =>
handleOverrideNotificationChange(channelId, MessageNotifications.ALL_MESSAGES)
}
aria-label={t`All Messages`}
/>
</div>
<div className={styles.checkboxCell}>
<Checkbox
checked={isMentions || (isInherit && resolvedLevel === MessageNotifications.ONLY_MENTIONS)}
onChange={() =>
handleOverrideNotificationChange(channelId, MessageNotifications.ONLY_MENTIONS)
}
aria-label={t`Only @mentions`}
/>
</div>
<div className={styles.checkboxCell}>
<Checkbox
checked={isNothing || (isInherit && resolvedLevel === MessageNotifications.NO_MESSAGES)}
onChange={() =>
handleOverrideNotificationChange(channelId, MessageNotifications.NO_MESSAGES)
}
aria-label={t`Nothing`}
/>
</div>
<div className={styles.checkboxCell}>
<Checkbox
checked={override.muted}
onChange={(checked) => handleOverrideMuteChange(channelId, checked)}
aria-label={t`Mute channel`}
/>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={() => ModalActionCreators.pop()}>{t`Done`}</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,81 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.content {
display: flex;
flex-direction: column;
gap: 16px;
color: var(--text-primary);
}
.guildList {
display: flex;
flex-direction: column;
gap: 8px;
}
.guildItem {
display: flex;
align-items: center;
gap: 12px;
border-radius: 6px;
border: 1px solid var(--background-header-secondary);
background-color: var(--background-secondary);
padding: 12px;
}
.guildIcon {
font-size: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
--guild-icon-size: 40px;
}
.guildInfo {
flex: 1;
}
.guildName {
font-weight: 500;
color: var(--text-primary);
}
.remainingCount {
text-align: center;
font-size: 14px;
color: var(--text-primary-muted);
}
.helpText {
color: var(--text-primary-muted);
}
.footer {
display: flex;
flex-wrap: wrap;
align-items: center;
width: 100%;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
}

View File

@@ -0,0 +1,94 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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 * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as Modal from '~/components/modals/Modal';
import {GuildIcon} from '~/components/popouts/GuildIcon';
import {Button} from '~/components/uikit/Button/Button';
import type {GuildRecord} from '~/records/GuildRecord';
import styles from './GuildOwnershipWarningModal.module.css';
interface GuildOwnershipWarningModalProps {
ownedGuilds: Array<GuildRecord>;
action: 'disable' | 'delete';
}
export const GuildOwnershipWarningModal: React.FC<GuildOwnershipWarningModalProps> = observer(
({ownedGuilds, action}) => {
const {t} = useLingui();
const displayedGuilds = ownedGuilds.slice(0, 3);
const remainingCount = ownedGuilds.length - 3;
return (
<Modal.Root size="small" centered>
<Modal.Header title={action === 'disable' ? t`Cannot Disable Account` : t`Cannot Delete Account`} />
<Modal.Content>
<div className={styles.content}>
<p>
{action === 'disable' ? (
<Trans>
You cannot disable your account while you own communities. Please transfer ownership of the following
communities first:
</Trans>
) : (
<Trans>
You cannot delete your account while you own communities. Please transfer ownership of the following
communities first:
</Trans>
)}
</p>
<div className={styles.guildList}>
{displayedGuilds.map((guild) => (
<div key={guild.id} className={styles.guildItem}>
<GuildIcon
id={guild.id}
name={guild.name}
icon={guild.icon}
className={styles.guildIcon}
sizePx={40}
/>
<div className={styles.guildInfo}>
<div className={styles.guildName}>{guild.name}</div>
</div>
</div>
))}
{remainingCount > 0 && (
<div className={styles.remainingCount}>
<Trans>and {remainingCount} more</Trans>
</div>
)}
</div>
<p className={styles.helpText}>
<Trans>
To transfer ownership, go to Community Settings Overview and use the Transfer Ownership option.
</Trans>
</p>
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop}>
<Trans>OK</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
},
);

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: 24px;
}

View File

@@ -0,0 +1,72 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
import {Switch} from '~/components/form/Switch';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import GuildStore from '~/stores/GuildStore';
import UserSettingsStore from '~/stores/UserSettingsStore';
import styles from './GuildPrivacySettingsModal.module.css';
export const GuildPrivacySettingsModal = observer(function GuildPrivacySettingsModal({guildId}: {guildId: string}) {
const {t} = useLingui();
const guild = GuildStore.getGuild(guildId);
const restrictedGuilds = UserSettingsStore.restrictedGuilds;
if (!guild) return null;
const isDMsAllowed = !restrictedGuilds.includes(guildId);
const handleToggleDMs = async (value: boolean) => {
let newRestrictedGuilds: Array<string>;
if (value) {
newRestrictedGuilds = restrictedGuilds.filter((id) => id !== guildId);
} else {
newRestrictedGuilds = [...restrictedGuilds, guildId];
}
await UserSettingsActionCreators.update({
restrictedGuilds: newRestrictedGuilds,
});
};
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Privacy Settings`} />
<Modal.Content>
<div className={styles.container}>
<Switch
label={t`Direct Messages`}
description={t`Allow direct messages from other members in this community`}
value={isDMsAllowed}
onChange={handleToggleDMs}
/>
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={() => ModalActionCreators.pop()}>{t`Done`}</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,56 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.sidebarHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 6px;
padding-right: 4px;
padding-bottom: 6px;
padding-left: 10px;
border-radius: 8px;
margin-bottom: 24px;
min-width: 0;
}
.guildName {
font-weight: 500;
font-size: 1.067rem;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 0;
min-width: 0;
margin-right: 0.75rem;
}
.sidebarButtonWrapper {
padding: 0 0.5rem 0.5rem;
}
.sidebarButtonIcon {
height: 1rem;
width: 1rem;
}
.deleteGuildButton {
width: 100%;
}

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 {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as UnsavedChangesActionCreators from '~/actions/UnsavedChangesActionCreators';
import * as Modal from '~/components/modals/Modal';
import GuildSettingsModalStore from '~/stores/GuildSettingsModalStore';
import GuildStore from '~/stores/GuildStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import PermissionStore from '~/stores/PermissionStore';
import UnsavedChangesStore from '~/stores/UnsavedChangesStore';
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
import {DesktopGuildSettingsView} from './components/DesktopGuildSettingsView';
import {MobileGuildSettingsView} from './components/MobileGuildSettingsView';
import {useMobileNavigation} from './hooks/useMobileNavigation';
import {SettingsModalContainer} from './shared/SettingsModalLayout';
import {type GuildSettingsTabType, getGuildSettingsTabs} from './utils/guildSettingsConstants';
interface GuildSettingsModalProps {
guildId: string;
initialTab?: GuildSettingsTabType;
initialMobileTab?: GuildSettingsTabType;
}
export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
({guildId, initialTab: initialTabProp, initialMobileTab}) => {
const {t} = useLingui();
const guild = GuildStore.getGuild(guildId);
const [selectedTab, setSelectedTab] = React.useState<GuildSettingsTabType>(initialTabProp ?? 'overview');
const availableTabs = React.useMemo(() => {
const guildSettingsTabs = getGuildSettingsTabs(t);
if (!guild) return guildSettingsTabs;
return guildSettingsTabs.filter((tab) => {
if (tab.permission && !PermissionStore.can(tab.permission, {guildId})) {
return false;
}
if (tab.requireFeature && !guild.features.has(tab.requireFeature)) {
return false;
}
return true;
});
}, [guild, guildId, t]);
const isMobileExperience = isMobileExperienceEnabled();
const initialMobileTabObject = React.useMemo(() => {
if (!isMobileExperience || !initialMobileTab) return;
const targetTab = availableTabs.find((tab) => tab.type === initialMobileTab);
if (!targetTab) return;
return {tab: initialMobileTab, title: targetTab.label};
}, [initialMobileTab, availableTabs, isMobileExperience]);
const mobileNav = useMobileNavigation<GuildSettingsTabType>(initialMobileTabObject);
const mobileNavigateTo = mobileNav.navigateTo;
const mobileResetToRoot = mobileNav.resetToRoot;
const mobileIsRootView = mobileNav.isRootView;
const {enabled: isMobile} = MobileLayoutStore;
const unsavedChangesStore = UnsavedChangesStore;
React.useEffect(() => {
if (!guild) {
ModalActionCreators.pop();
}
}, [guild]);
React.useEffect(() => {
if (availableTabs.length > 0 && !availableTabs.find((tab) => tab.type === selectedTab)) {
setSelectedTab(availableTabs[0].type);
}
}, [availableTabs, selectedTab]);
const groupedSettingsTabs = React.useMemo(() => {
return availableTabs.reduce(
(acc, tab) => {
if (!acc[tab.category]) {
acc[tab.category] = [];
}
acc[tab.category].push(tab);
return acc;
},
{} as Record<string, Array<(typeof availableTabs)[number]>>,
);
}, [availableTabs]);
const currentTab = React.useMemo(() => {
if (!isMobile) {
return availableTabs.find((tab) => tab.type === selectedTab);
}
if (mobileNav.isRootView) return;
return availableTabs.find((tab) => tab.type === mobileNav.currentView?.tab);
}, [isMobile, selectedTab, mobileNav.isRootView, mobileNav.currentView, availableTabs]);
const handleMobileBack = React.useCallback(() => {
if (mobileNav.isRootView) {
ModalActionCreators.pop();
} else {
mobileNav.navigateBack();
}
}, [mobileNav]);
const handleTabSelect = React.useCallback(
(tabType: string, title: string) => {
mobileNav.navigateTo(tabType as GuildSettingsTabType, title);
},
[mobileNav],
);
const handleClose = React.useCallback(() => {
const checkTabId = selectedTab;
if (checkTabId && unsavedChangesStore.unsavedChanges[checkTabId]) {
UnsavedChangesActionCreators.triggerFlashEffect(checkTabId);
return;
}
ModalActionCreators.pop();
}, [selectedTab, unsavedChangesStore.unsavedChanges]);
const handleExternalNavigate = React.useCallback(
(targetTab: GuildSettingsTabType) => {
const tabMeta = availableTabs.find((tab) => tab.type === targetTab);
if (!tabMeta) return;
if (isMobile) {
if (!mobileIsRootView) {
mobileResetToRoot();
}
mobileNavigateTo(tabMeta.type, tabMeta.label);
} else {
setSelectedTab(tabMeta.type);
}
},
[availableTabs, isMobile, mobileIsRootView, mobileNavigateTo, mobileResetToRoot],
);
React.useEffect(() => {
GuildSettingsModalStore.register({guildId, navigate: handleExternalNavigate});
return () => {
GuildSettingsModalStore.unregister(guildId);
};
}, [guildId, handleExternalNavigate]);
if (!guild) {
return null;
}
return (
<Modal.Root size="fullscreen" onClose={handleClose}>
<Modal.ScreenReaderLabel text={t`Community Settings`} />
<SettingsModalContainer fullscreen={true}>
{isMobile ? (
<MobileGuildSettingsView
guild={guild}
groupedSettingsTabs={groupedSettingsTabs}
currentTab={currentTab}
mobileNav={mobileNav}
onBack={handleMobileBack}
onTabSelect={handleTabSelect}
/>
) : (
<DesktopGuildSettingsView
guild={guild}
groupedSettingsTabs={groupedSettingsTabs}
currentTab={currentTab}
selectedTab={selectedTab}
onTabSelect={setSelectedTab}
/>
)}
</SettingsModalContainer>
</Modal.Root>
);
},
);

View File

@@ -0,0 +1,33 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.description {
color: var(--text-secondary);
}
.checkboxContainer {
margin-top: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.checkboxLabel {
font-size: 14px;
}

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