initial commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
98
fluxer_app/src/components/modals/AccountDeleteModal.tsx
Normal file
98
fluxer_app/src/components/modals/AccountDeleteModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
72
fluxer_app/src/components/modals/AccountDisableModal.tsx
Normal file
72
fluxer_app/src/components/modals/AccountDisableModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
198
fluxer_app/src/components/modals/AddFavoriteChannelModal.tsx
Normal file
198
fluxer_app/src/components/modals/AddFavoriteChannelModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
107
fluxer_app/src/components/modals/AddFavoriteMemeModal.tsx
Normal file
107
fluxer_app/src/components/modals/AddFavoriteMemeModal.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, 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>
|
||||
);
|
||||
});
|
||||
71
fluxer_app/src/components/modals/AddFriendSheet.module.css
Normal file
71
fluxer_app/src/components/modals/AddFriendSheet.module.css
Normal 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;
|
||||
}
|
||||
104
fluxer_app/src/components/modals/AddFriendSheet.tsx
Normal file
104
fluxer_app/src/components/modals/AddFriendSheet.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
113
fluxer_app/src/components/modals/AddFriendsToGroupModal.tsx
Normal file
113
fluxer_app/src/components/modals/AddFriendsToGroupModal.tsx
Normal 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';
|
||||
201
fluxer_app/src/components/modals/AddGuildModal.module.css
Normal file
201
fluxer_app/src/components/modals/AddGuildModal.module.css
Normal 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;
|
||||
}
|
||||
442
fluxer_app/src/components/modals/AddGuildModal.tsx
Normal file
442
fluxer_app/src/components/modals/AddGuildModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
124
fluxer_app/src/components/modals/AddGuildStickerModal.tsx
Normal file
124
fluxer_app/src/components/modals/AddGuildStickerModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
255
fluxer_app/src/components/modals/AssetCropModal.tsx
Normal file
255
fluxer_app/src/components/modals/AssetCropModal.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
97
fluxer_app/src/components/modals/AttachmentEditModal.tsx
Normal file
97
fluxer_app/src/components/modals/AttachmentEditModal.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
687
fluxer_app/src/components/modals/BackgroundImageGalleryModal.tsx
Normal file
687
fluxer_app/src/components/modals/BackgroundImageGalleryModal.tsx
Normal 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;
|
||||
125
fluxer_app/src/components/modals/BackupCodesModal.module.css
Normal file
125
fluxer_app/src/components/modals/BackupCodesModal.module.css
Normal file
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
101
fluxer_app/src/components/modals/BackupCodesModal.tsx
Normal file
101
fluxer_app/src/components/modals/BackupCodesModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
72
fluxer_app/src/components/modals/BackupCodesViewModal.tsx
Normal file
72
fluxer_app/src/components/modals/BackupCodesViewModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
117
fluxer_app/src/components/modals/BanDetailsModal.module.css
Normal file
117
fluxer_app/src/components/modals/BanDetailsModal.module.css
Normal 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);
|
||||
}
|
||||
135
fluxer_app/src/components/modals/BanDetailsModal.tsx
Normal file
135
fluxer_app/src/components/modals/BanDetailsModal.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {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>
|
||||
);
|
||||
});
|
||||
35
fluxer_app/src/components/modals/BanMemberModal.module.css
Normal file
35
fluxer_app/src/components/modals/BanMemberModal.module.css
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
147
fluxer_app/src/components/modals/BanMemberModal.tsx
Normal file
147
fluxer_app/src/components/modals/BanMemberModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
122
fluxer_app/src/components/modals/BaseChangeNicknameModal.tsx
Normal file
122
fluxer_app/src/components/modals/BaseChangeNicknameModal.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
167
fluxer_app/src/components/modals/BookmarksBottomSheet.tsx
Normal file
167
fluxer_app/src/components/modals/BookmarksBottomSheet.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
144
fluxer_app/src/components/modals/CameraPreviewModal.module.css
Normal file
144
fluxer_app/src/components/modals/CameraPreviewModal.module.css
Normal 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);
|
||||
}
|
||||
573
fluxer_app/src/components/modals/CameraPreviewModal.tsx
Normal file
573
fluxer_app/src/components/modals/CameraPreviewModal.tsx
Normal 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};
|
||||
76
fluxer_app/src/components/modals/CaptchaModal.module.css
Normal file
76
fluxer_app/src/components/modals/CaptchaModal.module.css
Normal file
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
179
fluxer_app/src/components/modals/CaptchaModal.tsx
Normal file
179
fluxer_app/src/components/modals/CaptchaModal.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
88
fluxer_app/src/components/modals/CategoryCreateModal.tsx
Normal file
88
fluxer_app/src/components/modals/CategoryCreateModal.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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>
|
||||
);
|
||||
});
|
||||
@@ -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} />;
|
||||
});
|
||||
@@ -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} />;
|
||||
});
|
||||
50
fluxer_app/src/components/modals/ChangeNicknameModal.tsx
Normal file
50
fluxer_app/src/components/modals/ChangeNicknameModal.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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} />;
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
109
fluxer_app/src/components/modals/ChannelCreateModal.tsx
Normal file
109
fluxer_app/src/components/modals/ChannelCreateModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
80
fluxer_app/src/components/modals/ChannelDeleteModal.tsx
Normal file
80
fluxer_app/src/components/modals/ChannelDeleteModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
128
fluxer_app/src/components/modals/ChannelSettingsModal.tsx
Normal file
128
fluxer_app/src/components/modals/ChannelSettingsModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
55
fluxer_app/src/components/modals/ChannelTopicModal.tsx
Normal file
55
fluxer_app/src/components/modals/ChannelTopicModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
232
fluxer_app/src/components/modals/ClaimAccountModal.tsx
Normal file
232
fluxer_app/src/components/modals/ClaimAccountModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
59
fluxer_app/src/components/modals/ConfirmModal.module.css
Normal file
59
fluxer_app/src/components/modals/ConfirmModal.module.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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);
|
||||
}
|
||||
158
fluxer_app/src/components/modals/ConfirmModal.tsx
Normal file
158
fluxer_app/src/components/modals/ConfirmModal.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
},
|
||||
);
|
||||
102
fluxer_app/src/components/modals/CreateDMModal.tsx
Normal file
102
fluxer_app/src/components/modals/CreateDMModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
33
fluxer_app/src/components/modals/CreatePackModal.module.css
Normal file
33
fluxer_app/src/components/modals/CreatePackModal.module.css
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.description {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.formFields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
114
fluxer_app/src/components/modals/CreatePackModal.tsx
Normal file
114
fluxer_app/src/components/modals/CreatePackModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
239
fluxer_app/src/components/modals/CustomStatusBottomSheet.tsx
Normal file
239
fluxer_app/src/components/modals/CustomStatusBottomSheet.tsx
Normal 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'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';
|
||||
146
fluxer_app/src/components/modals/CustomStatusModal.module.css
Normal file
146
fluxer_app/src/components/modals/CustomStatusModal.module.css
Normal 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%;
|
||||
}
|
||||
369
fluxer_app/src/components/modals/CustomStatusModal.tsx
Normal file
369
fluxer_app/src/components/modals/CustomStatusModal.tsx
Normal 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';
|
||||
74
fluxer_app/src/components/modals/DeviceRevokeModal.tsx
Normal file
74
fluxer_app/src/components/modals/DeviceRevokeModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
93
fluxer_app/src/components/modals/EditFavoriteMemeModal.tsx
Normal file
93
fluxer_app/src/components/modals/EditFavoriteMemeModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
138
fluxer_app/src/components/modals/EditGroupBottomSheet.module.css
Normal file
138
fluxer_app/src/components/modals/EditGroupBottomSheet.module.css
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
249
fluxer_app/src/components/modals/EditGroupBottomSheet.tsx
Normal file
249
fluxer_app/src/components/modals/EditGroupBottomSheet.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
97
fluxer_app/src/components/modals/EditGroupModal.module.css
Normal file
97
fluxer_app/src/components/modals/EditGroupModal.module.css
Normal 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;
|
||||
}
|
||||
232
fluxer_app/src/components/modals/EditGroupModal.tsx
Normal file
232
fluxer_app/src/components/modals/EditGroupModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
116
fluxer_app/src/components/modals/EditGuildStickerModal.tsx
Normal file
116
fluxer_app/src/components/modals/EditGuildStickerModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
110
fluxer_app/src/components/modals/EditPackModal.tsx
Normal file
110
fluxer_app/src/components/modals/EditPackModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
41
fluxer_app/src/components/modals/EmailChangeModal.module.css
Normal file
41
fluxer_app/src/components/modals/EmailChangeModal.module.css
Normal 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;
|
||||
}
|
||||
304
fluxer_app/src/components/modals/EmailChangeModal.tsx
Normal file
304
fluxer_app/src/components/modals/EmailChangeModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
32
fluxer_app/src/components/modals/EmojiUploadModal.module.css
Normal file
32
fluxer_app/src/components/modals/EmojiUploadModal.module.css
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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);
|
||||
}
|
||||
47
fluxer_app/src/components/modals/EmojiUploadModal.tsx
Normal file
47
fluxer_app/src/components/modals/EmojiUploadModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
257
fluxer_app/src/components/modals/ExpressionPickerSheet.tsx
Normal file
257
fluxer_app/src/components/modals/ExpressionPickerSheet.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
108
fluxer_app/src/components/modals/ExternalLinkWarningModal.tsx
Normal file
108
fluxer_app/src/components/modals/ExternalLinkWarningModal.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {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>
|
||||
);
|
||||
});
|
||||
110
fluxer_app/src/components/modals/FluxerTagChangeModal.module.css
Normal file
110
fluxer_app/src/components/modals/FluxerTagChangeModal.module.css
Normal 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;
|
||||
}
|
||||
262
fluxer_app/src/components/modals/FluxerTagChangeModal.tsx
Normal file
262
fluxer_app/src/components/modals/FluxerTagChangeModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
247
fluxer_app/src/components/modals/ForwardModal.module.css
Normal file
247
fluxer_app/src/components/modals/ForwardModal.module.css
Normal 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;
|
||||
}
|
||||
383
fluxer_app/src/components/modals/ForwardModal.tsx
Normal file
383
fluxer_app/src/components/modals/ForwardModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
120
fluxer_app/src/components/modals/GiftAcceptModal.module.css
Normal file
120
fluxer_app/src/components/modals/GiftAcceptModal.module.css
Normal 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);
|
||||
}
|
||||
175
fluxer_app/src/components/modals/GiftAcceptModal.tsx
Normal file
175
fluxer_app/src/components/modals/GiftAcceptModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
179
fluxer_app/src/components/modals/GroupInvitesBottomSheet.tsx
Normal file
179
fluxer_app/src/components/modals/GroupInvitesBottomSheet.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
165
fluxer_app/src/components/modals/GroupInvitesModal.tsx
Normal file
165
fluxer_app/src/components/modals/GroupInvitesModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
68
fluxer_app/src/components/modals/GuildDeleteModal.tsx
Normal file
68
fluxer_app/src/components/modals/GuildDeleteModal.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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%;
|
||||
}
|
||||
192
fluxer_app/src/components/modals/GuildSettingsModal.tsx
Normal file
192
fluxer_app/src/components/modals/GuildSettingsModal.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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
Reference in New Issue
Block a user