refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -17,21 +17,26 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as UserActionCreators from '@app/actions/UserActionCreators';
import {Form} from '@app/components/form/Form';
import {FormErrorText} from '@app/components/form/FormErrorText';
import styles from '@app/components/modals/AccountDeleteModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import * as RouterUtils from '@app/utils/RouterUtils';
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';
interface FormInputs {
form: string;
}
export const AccountDeleteModal = observer(() => {
const {t} = useLingui();
const form = useForm();
const form = useForm<FormInputs>();
const onSubmit = async () => {
await UserActionCreators.deleteAccount();
@@ -49,8 +54,9 @@ export const AccountDeleteModal = observer(() => {
<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}>
<Modal.Content contentClassName={styles.content}>
<div className={styles.infoSection}>
<FormErrorText message={form.formState.errors.form?.message} />
<p>
<Trans>
Are you sure you want to delete your account? This action will schedule your account for permanent

View File

@@ -17,22 +17,27 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as UserActionCreators from '@app/actions/UserActionCreators';
import {Form} from '@app/components/form/Form';
import {FormErrorText} from '@app/components/form/FormErrorText';
import styles from '@app/components/modals/AccountDisableModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import {Routes} from '@app/Routes';
import * as RouterUtils from '@app/utils/RouterUtils';
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';
interface FormInputs {
form: string;
}
export const AccountDisableModal = observer(() => {
const {t} = useLingui();
const form = useForm();
const form = useForm<FormInputs>();
const onSubmit = async () => {
await UserActionCreators.disableAccount();
@@ -50,13 +55,14 @@ export const AccountDisableModal = observer(() => {
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit}>
<Modal.Header title={t`Disable Account`} />
<Modal.Content className={styles.content}>
<Modal.Content contentClassName={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>
<FormErrorText message={form.formState.errors.form?.message} />
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">

View File

@@ -0,0 +1,157 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* 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-4);
}
.stack {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.instructions {
color: var(--text-secondary);
font-size: 0.875rem;
line-height: 1.5;
margin: 0;
}
.dnsCard {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-selected);
border-radius: var(--radius-lg);
padding: var(--spacing-4);
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.dnsHeading {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.dnsTitle {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
}
.dnsFields {
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.dnsMeta {
margin: 0;
color: var(--text-secondary);
font-size: 0.8125rem;
line-height: 1.5;
}
.inlineCode {
font-family: var(--font-mono);
font-size: 0.85em;
background-color: var(--bg-code);
color: var(--text-code);
padding: 0.15em 0.3em;
margin: -0.15em 0;
border-radius: var(--radius-sm);
white-space: pre-wrap;
box-decoration-break: clone;
user-select: text;
}
.dnsInput {
user-select: text;
}
.tokenCard {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-selected);
border-radius: var(--radius-lg);
padding: var(--spacing-4);
display: flex;
flex-direction: column;
gap: var(--spacing-3);
}
.tokenCardHeader {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.tokenTitle {
font-size: 0.875rem;
font-weight: 600;
margin: 0;
}
.tokenSubtitle {
margin: 0;
color: var(--text-secondary);
font-size: 0.8125rem;
line-height: 1.4;
}
.tokenDownloadRow {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.tokenMeta {
margin: 0;
color: var(--text-secondary);
font-size: 0.8125rem;
line-height: 1.5;
}
.copyButton {
border: 1px solid var(--background-modifier-border);
background: var(--background-surface);
border-radius: var(--radius-sm);
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.15s ease;
}
.copyButton:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.copyButton:not(:disabled):hover {
border-color: var(--text-primary);
}
.copyIcon {
color: var(--text-primary);
}

View File

@@ -0,0 +1,318 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ConnectionActionCreators from '@app/actions/ConnectionActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as TextCopyActionCreators from '@app/actions/TextCopyActionCreators';
import {Form} from '@app/components/form/Form';
import {FormErrorText} from '@app/components/form/FormErrorText';
import {Input} from '@app/components/form/Input';
import {Select} from '@app/components/form/Select';
import styles from '@app/components/modals/AddConnectionModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import UserConnectionStore from '@app/stores/UserConnectionStore';
import {type ConnectionType, ConnectionTypes} from '@fluxer/constants/src/ConnectionConstants';
import type {ConnectionVerificationResponse} from '@fluxer/schema/src/domains/connection/ConnectionSchemas';
import {Trans, useLingui} from '@lingui/react/macro';
import {CheckCircleIcon, ClipboardIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useForm} from 'react-hook-form';
const COPY_RESET_DELAY_MS = 2000;
interface CopyButtonProps {
copied: boolean;
disabled?: boolean;
label: string;
onClick: () => void;
}
const CopyButton = ({copied, disabled = false, label, onClick}: CopyButtonProps) => (
<button type="button" className={styles.copyButton} onClick={onClick} disabled={disabled} aria-label={label}>
{copied ? (
<CheckCircleIcon className={styles.copyIcon} size={16} weight="bold" />
) : (
<ClipboardIcon className={styles.copyIcon} size={16} />
)}
</button>
);
CopyButton.displayName = 'CopyButton';
interface InitiateFormInputs {
identifier: string;
}
interface VerifyFormInputs {
_verify: string;
}
interface AddConnectionModalProps {
defaultType?: ConnectionType;
}
export const AddConnectionModal = observer(({defaultType}: AddConnectionModalProps) => {
const {t, i18n} = useLingui();
const [step, setStep] = useState<'initiate' | 'verify'>('initiate');
const [type, setType] = useState<ConnectionType>(defaultType ?? ConnectionTypes.BLUESKY);
const [verificationData, setVerificationData] = useState<ConnectionVerificationResponse | null>(null);
const [hostCopied, setHostCopied] = useState(false);
const [valueCopied, setValueCopied] = useState(false);
const pendingBlueskyHandle = useRef<string | null>(null);
const initiateForm = useForm<InitiateFormInputs>();
const verifyForm = useForm<VerifyFormInputs>();
const connectionTypeOptions = useMemo(
() => [
{value: ConnectionTypes.BLUESKY, label: t`Bluesky`},
{value: ConnectionTypes.DOMAIN, label: t`Domain`},
],
[t],
);
const handleTypeChange = useCallback((value: ConnectionType) => setType(value), []);
const onSubmitInitiate = useCallback(
async (data: InitiateFormInputs) => {
const identifier = data.identifier.trim();
if (UserConnectionStore.hasConnectionByTypeAndName(type, identifier)) {
initiateForm.setError('identifier', {type: 'validate', message: t`You already have this connection.`});
return;
}
if (type === ConnectionTypes.BLUESKY) {
await ConnectionActionCreators.authorizeBlueskyConnection(i18n, identifier);
pendingBlueskyHandle.current = identifier.toLowerCase();
return;
}
const result = await ConnectionActionCreators.initiateConnection(i18n, type, identifier);
setVerificationData(result);
setStep('verify');
},
[i18n, initiateForm, t, type],
);
const onSubmitVerify = useCallback(async () => {
if (!verificationData) return;
await ConnectionActionCreators.verifyAndCreateConnection(i18n, verificationData.initiation_token);
ModalActionCreators.pop();
}, [i18n, verificationData]);
const {handleSubmit: handleInitiateSubmit} = useFormSubmit({
form: initiateForm,
onSubmit: onSubmitInitiate,
defaultErrorField: 'identifier',
});
const {handleSubmit: handleVerifySubmit} = useFormSubmit({
form: verifyForm,
onSubmit: onSubmitVerify,
defaultErrorField: '_verify',
});
const hasBlueskyConnection = UserConnectionStore.hasConnectionByTypeAndName(
ConnectionTypes.BLUESKY,
pendingBlueskyHandle.current ?? '',
);
useEffect(() => {
if (pendingBlueskyHandle.current && hasBlueskyConnection) {
ModalActionCreators.pop();
}
}, [hasBlueskyConnection]);
const hostRecord = useMemo(
() => (verificationData?.id ? `_fluxer.${verificationData.id}` : ''),
[verificationData?.id],
);
const dnsValue = useMemo(
() => (verificationData?.token ? `fluxer-verification=${verificationData.token}` : ''),
[verificationData?.token],
);
const dnsUrl = useMemo(
() => (verificationData?.id ? `https://${verificationData.id}/.well-known/fluxer-verification` : ''),
[verificationData?.id],
);
const notifyAndReset = useCallback((setter: (value: boolean) => void) => {
setter(true);
setTimeout(() => setter(false), COPY_RESET_DELAY_MS);
}, []);
const handleCopyHost = useCallback(async () => {
if (!hostRecord) return;
const success = await TextCopyActionCreators.copy(i18n, hostRecord);
if (success) {
notifyAndReset(setHostCopied);
}
}, [hostRecord, i18n, notifyAndReset]);
const handleCopyValue = useCallback(async () => {
if (!dnsValue) return;
const success = await TextCopyActionCreators.copy(i18n, dnsValue);
if (success) {
notifyAndReset(setValueCopied);
}
}, [dnsValue, i18n, notifyAndReset]);
const downloadTokenFile = useCallback(() => {
if (!verificationData?.token) return;
const blob = new Blob([verificationData.token], {type: 'text/plain'});
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = 'fluxer-verification';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}, [verificationData?.token]);
const hostCopyLabel = hostCopied ? t`Copied!` : t`Copy host`;
const valueCopyLabel = valueCopied ? t`Copied!` : t`Copy value`;
if (step === 'initiate') {
return (
<Modal.Root size="small" centered>
<Form form={initiateForm} onSubmit={handleInitiateSubmit} aria-label={t`Add connection form`}>
<Modal.Header title={t`Add Connection`} />
<Modal.Content contentClassName={styles.content}>
<div className={styles.stack}>
<Select
label={t`Connection Type`}
value={type}
options={connectionTypeOptions}
onChange={handleTypeChange}
/>
<Input
{...initiateForm.register('identifier', {required: true})}
autoFocus={true}
error={initiateForm.formState.errors.identifier?.message}
label={type === ConnectionTypes.BLUESKY ? t`Handle` : t`Domain`}
placeholder={type === ConnectionTypes.BLUESKY ? 'username.bsky.social' : 'example.com'}
required={true}
/>
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button type="submit" submitting={initiateForm.formState.isSubmitting}>
{type === ConnectionTypes.BLUESKY ? <Trans>Connect with Bluesky</Trans> : <Trans>Continue</Trans>}
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
}
return (
<Modal.Root size="small" centered>
<Form form={verifyForm} onSubmit={handleVerifySubmit} aria-label={t`Verify connection form`}>
<Modal.Header title={t`Verify Connection`} />
<Modal.Content contentClassName={styles.content}>
<div className={styles.stack}>
<p className={styles.instructions}>
<Trans>Use the record below to prove domain ownership.</Trans>
</p>
<FormErrorText message={verifyForm.formState.errors._verify?.message} />
<div className={styles.dnsCard}>
<div className={styles.dnsHeading}>
<p className={styles.dnsTitle}>
<Trans>DNS TXT record</Trans>
</p>
</div>
<div className={styles.dnsFields}>
<Input
label={t`Host`}
value={hostRecord}
readOnly={true}
className={styles.dnsInput}
rightElement={
<CopyButton
onClick={handleCopyHost}
copied={hostCopied}
disabled={!hostRecord}
label={hostCopyLabel}
/>
}
/>
<Input
label={t`Value`}
value={dnsValue}
readOnly={true}
className={styles.dnsInput}
rightElement={
<CopyButton
onClick={handleCopyValue}
copied={valueCopied}
disabled={!dnsValue}
label={valueCopyLabel}
/>
}
/>
</div>
</div>
{dnsUrl && (
<div className={styles.tokenCard}>
<div className={styles.tokenCardHeader}>
<p className={styles.tokenTitle}>
<Trans>Serve the token file</Trans>
</p>
<p className={styles.tokenSubtitle}>
<Trans>
Download <code className={styles.inlineCode}>fluxer-verification</code> and place it in your{' '}
<code className={styles.inlineCode}>.well-known</code> folder so we can validate the domain.
</Trans>
</p>
</div>
<div className={styles.tokenDownloadRow}>
<Button
type="button"
variant="secondary"
compact
onClick={downloadTokenFile}
disabled={!verificationData?.token}
>
<Trans>Download fluxer-verification</Trans>
</Button>
</div>
<p className={styles.tokenMeta}>
<Trans>
The file contains the verification token we will fetch from{' '}
<code className={styles.inlineCode}>{dnsUrl}</code>.
</Trans>
</p>
</div>
)}
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={() => setStep('initiate')} variant="secondary">
<Trans>Back</Trans>
</Button>
<Button type="submit" submitting={verifyForm.formState.isSubmitting}>
<Trans>Verify</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
});

View File

@@ -17,13 +17,6 @@
* 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;

View File

@@ -17,26 +17,26 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Input} from '@app/components/form/Input';
import {Select, type SelectOption} from '@app/components/form/Select';
import styles from '@app/components/modals/AddFavoriteChannelModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import selectorStyles from '@app/components/modals/shared/SelectorModalStyles.module.css';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import {Scroller} from '@app/components/uikit/Scroller';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import ChannelStore from '@app/stores/ChannelStore';
import FavoritesStore from '@app/stores/FavoritesStore';
import GuildStore from '@app/stores/GuildStore';
import UserGuildSettingsStore from '@app/stores/UserGuildSettingsStore';
import * as ChannelUtils from '@app/utils/ChannelUtils';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
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';
import React, {useMemo, useState} from 'react';
interface ChannelWithCategory {
channel: ChannelRecord;
@@ -48,11 +48,11 @@ export const AddFavoriteChannelModal = observer(({categoryId}: {categoryId?: str
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 [selectedGuildId, setSelectedGuildId] = useState<string | null>(firstGuildId);
const [hideMutedChannels, setHideMutedChannels] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const guildOptions: Array<SelectOption<string>> = React.useMemo(
const guildOptions: Array<SelectOption<string>> = useMemo(
() =>
guilds.map((guild) => ({
value: guild.id,
@@ -63,7 +63,7 @@ export const AddFavoriteChannelModal = observer(({categoryId}: {categoryId?: str
const selectedGuild = selectedGuildId ? GuildStore.getGuild(selectedGuildId) : null;
const channels = React.useMemo(() => {
const channels = useMemo(() => {
if (!selectedGuild) return [];
const guildChannels = ChannelStore.getGuildChannels(selectedGuild.id);
@@ -71,7 +71,11 @@ export const AddFavoriteChannelModal = observer(({categoryId}: {categoryId?: str
const query = searchQuery.toLowerCase().trim();
for (const channel of guildChannels) {
if (channel.type !== ChannelTypes.GUILD_TEXT && channel.type !== ChannelTypes.GUILD_VOICE) {
if (
channel.type !== ChannelTypes.GUILD_TEXT &&
channel.type !== ChannelTypes.GUILD_VOICE &&
channel.type !== ChannelTypes.GUILD_LINK
) {
continue;
}
@@ -128,67 +132,69 @@ export const AddFavoriteChannelModal = observer(({categoryId}: {categoryId?: str
/>
</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>
<Modal.Content>
<Modal.ContentLayout>
<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>
{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);
<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,
})}
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>
<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>
</>
)}
</React.Fragment>
);
})
)}
</div>
</Scroller>
</>
)}
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop}>{t`Close`}</Button>

View File

@@ -17,18 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as FavoriteMemeActionCreators from '@app/actions/FavoriteMemeActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import * as Modal from '@app/components/modals/Modal';
import {MemeFormFields} from '@app/components/modals/meme_form/MemeFormFields';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useCallback} 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;
@@ -62,7 +61,7 @@ export const AddFavoriteMemeModal = observer(function AddFavoriteMemeModal({
},
});
const onSubmit = React.useCallback(
const onSubmit = useCallback(
async (data: FormInputs) => {
await FavoriteMemeActionCreators.createFavoriteMeme(i18n, {
channelId,
@@ -89,9 +88,9 @@ export const AddFavoriteMemeModal = observer(function AddFavoriteMemeModal({
<Modal.Header title={t`Add to Saved Media`} />
<Modal.Content>
<Form form={form} onSubmit={handleSave}>
<div className={styles.formContainer}>
<Modal.ContentLayout>
<MemeFormFields form={form} />
</div>
</Modal.ContentLayout>
</Form>
</Modal.Content>
<Modal.Footer>

View File

@@ -17,16 +17,16 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AddFriendForm} from '@app/components/channel/direct_message/AddFriendForm';
import {MobileFriendRequestItem} from '@app/components/channel/friends/MobileFriendRequestItem';
import styles from '@app/components/modals/AddFriendSheet.module.css';
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
import {Scroller} from '@app/components/uikit/Scroller';
import RelationshipStore from '@app/stores/RelationshipStore';
import {RelationshipTypes} from '@fluxer/constants/src/UserConstants';
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;

View File

@@ -17,17 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {FriendSelector} from '@app/components/common/FriendSelector';
import {Input} from '@app/components/form/Input';
import inviteStyles from '@app/components/modals/InviteModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {CopyLinkSection} from '@app/components/modals/shared/CopyLinkSection';
import selectorStyles from '@app/components/modals/shared/SelectorModalStyles.module.css';
import {Button} from '@app/components/uikit/button/Button';
import {useAddFriendsToGroupModalLogic} from '@app/utils/modals/AddFriendsToGroupModalUtils';
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;
@@ -85,7 +85,6 @@ export const AddFriendsToGroupModal = observer((props: AddFriendsToGroupModalPro
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={

View File

@@ -54,11 +54,6 @@
.actionButton:hover {
background: var(--background-secondary-alt);
border-color: var(--brand-primary-light);
}
:global(.theme-light) .actionButton:hover {
border-color: var(--brand-primary);
}
.actionIcon {
@@ -199,3 +194,12 @@
color: var(--text-primary-muted);
font-size: 12px;
}
.guidelinesLink {
color: var(--text-link);
text-decoration: none;
}
.guidelinesLink:hover {
text-decoration: underline;
}

View File

@@ -17,34 +17,33 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as GuildActionCreators from '@app/actions/GuildActionCreators';
import * as InviteActionCreators from '@app/actions/InviteActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as NavigationActionCreators from '@app/actions/NavigationActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {ExternalLink} from '@app/components/common/ExternalLink';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import styles from '@app/components/modals/AddGuildModal.module.css';
import {AssetCropModal, AssetType} from '@app/components/modals/AssetCropModal';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import {Routes} from '@app/Routes';
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
import {isAnimatedFile} from '@app/utils/AnimatedImageUtils';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import {openFilePicker} from '@app/utils/FilePickerUtils';
import {getInitialsLength} from '@app/utils/GuildInitialsUtils';
import * as InviteUtils from '@app/utils/InviteUtils';
import * as StringUtils from '@app/utils/StringUtils';
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 React, {useCallback, useContext, useEffect, useId, useMemo, 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;
@@ -59,6 +58,8 @@ interface ModalFooterContextValue {
setFooterContent: (content: React.ReactNode) => void;
}
export type AddGuildModalView = 'landing' | 'create_guild' | 'join_guild';
const ModalFooterContext = React.createContext<ModalFooterContextValue | null>(null);
const ActionButton = ({onClick, icon, label}: {onClick: () => void; icon: React.ReactNode; label: string}) => (
@@ -73,7 +74,7 @@ export const AddGuildModal = observer(({initialView = 'landing'}: {initialView?:
const [view, setView] = useState<AddGuildModalView>(initialView);
const [footerContent, setFooterContent] = useState<React.ReactNode>(null);
const getTitle = () => {
const getTitle = (): string => {
switch (view) {
case 'landing':
return t`Add a Community`;
@@ -81,10 +82,12 @@ export const AddGuildModal = observer(({initialView = 'landing'}: {initialView?:
return t`Create a Community`;
case 'join_guild':
return t`Join a Community`;
default:
return t`Add a Community`;
}
};
const contextValue = React.useMemo(
const contextValue = useMemo(
() => ({
setFooterContent,
}),
@@ -96,7 +99,7 @@ export const AddGuildModal = observer(({initialView = 'landing'}: {initialView?:
<Modal.Root size="small" centered>
<Modal.Header title={getTitle()} />
<Modal.Content className={styles.content}>
<Modal.Content contentClassName={styles.content}>
{view === 'landing' && <LandingView onViewChange={setView} />}
{view === 'create_guild' && <GuildCreateForm />}
{view === 'join_guild' && <GuildJoinForm />}
@@ -125,7 +128,7 @@ const LandingView = observer(({onViewChange}: {onViewChange: (view: AddGuildModa
/>
<ActionButton
onClick={() => onViewChange('join_guild')}
icon={<LinkIcon size={24} weight="regular" />}
icon={<LinkIcon size={24} weight="bold" />}
label={t`Join Community`}
/>
</div>
@@ -135,12 +138,11 @@ const LandingView = observer(({onViewChange}: {onViewChange: (view: AddGuildModa
const GuildCreateForm = observer(() => {
const {t} = useLingui();
const [previewIconUrl, setPreviewIconUrl] = React.useState<string | null>(null);
const [previewIconUrl, setPreviewIconUrl] = useState<string | null>(null);
const form = useForm<GuildCreateFormInputs>({defaultValues: {name: ''}});
const modalFooterContext = React.useContext(ModalFooterContext);
const formId = React.useId();
const guildNamePlaceholders = React.useMemo(
const modalFooterContext = useContext(ModalFooterContext);
const formId = useId();
const guildNamePlaceholders = useMemo(
() => [
t`The Midnight Gamers`,
t`Study Buddies United`,
@@ -175,7 +177,7 @@ const GuildCreateForm = observer(() => {
t`Art Club`,
t`Book Club`,
t`Sports Fans`,
t`Gaming Guild`,
t`Gaming Community`,
t`Study Group`,
t`Work Friends`,
t`Family Chat`,
@@ -186,22 +188,22 @@ const GuildCreateForm = observer(() => {
[],
);
const randomPlaceholder = React.useMemo(() => {
const randomPlaceholder = useMemo(() => {
const randomIndex = Math.floor(Math.random() * guildNamePlaceholders.length);
return guildNamePlaceholders[randomIndex];
}, [guildNamePlaceholders]);
const nameValue = form.watch('name');
const initials = React.useMemo(() => {
const initials = useMemo(() => {
const raw = (nameValue || '').trim();
if (!raw) return '';
return StringUtils.getInitialsFromName(raw);
}, [nameValue]);
const initialsLength = React.useMemo(() => (initials ? getInitialsLength(initials) : null), [initials]);
const initialsLength = useMemo(() => (initials ? getInitialsLength(initials) : null), [initials]);
const handleIconUpload = React.useCallback(async () => {
const handleIconUpload = useCallback(async () => {
try {
const [file] = await openFilePicker({accept: 'image/*'});
if (!file) return;
@@ -214,7 +216,9 @@ const GuildCreateForm = observer(() => {
return;
}
if (file.type === 'image/gif') {
const animated = await isAnimatedFile(file);
if (animated) {
ToastActionCreators.createToast({
type: 'error',
children: t`Animated icons are not supported when creating a new community. Please use JPEG, PNG, or WebP.`,
@@ -262,13 +266,13 @@ const GuildCreateForm = observer(() => {
}
}, [form]);
const onSubmit = React.useCallback(async (data: GuildCreateFormInputs) => {
const onSubmit = 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));
NavigationActionCreators.selectChannel(guild.id, guild.system_channel_id || undefined);
}, []);
const {handleSubmit, isSubmitting} = useFormSubmit({
@@ -277,7 +281,7 @@ const GuildCreateForm = observer(() => {
defaultErrorField: 'name',
});
React.useEffect(() => {
useEffect(() => {
const isNameEmpty = !nameValue?.trim();
modalFooterContext?.setFooterContent(
@@ -294,7 +298,7 @@ const GuildCreateForm = observer(() => {
return () => modalFooterContext?.setFooterContent(null);
}, [handleSubmit, isSubmitting, modalFooterContext, nameValue]);
const handleClearIcon = React.useCallback(() => {
const handleClearIcon = useCallback(() => {
form.setValue('icon', null);
setPreviewIconUrl(null);
}, [form]);
@@ -355,7 +359,10 @@ const GuildCreateForm = observer(() => {
<p className={styles.guidelines}>
<Trans>
By creating a community, you agree to follow and uphold the{' '}
<ExternalLink href={Routes.guidelines()}>Fluxer Community Guidelines</ExternalLink>.
<ExternalLink href={Routes.guidelines()} className={styles.guidelinesLink}>
Fluxer Community Guidelines
</ExternalLink>
.
</Trans>
</p>
</div>
@@ -367,10 +374,9 @@ const GuildCreateForm = observer(() => {
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 modalFooterContext = useContext(ModalFooterContext);
const formId = useId();
const randomInviteCode = useMemo(() => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
const length = Math.floor(Math.random() * 7) + 6;
let result = '';
@@ -380,7 +386,7 @@ const GuildJoinForm = observer(() => {
return result;
}, []);
const onSubmit = React.useCallback(
const onSubmit = useCallback(
async (data: GuildJoinFormInputs) => {
const parsedCode = InviteUtils.findInvite(data.code) ?? data.code;
const invite = await InviteActionCreators.fetch(parsedCode);
@@ -398,7 +404,7 @@ const GuildJoinForm = observer(() => {
const codeValue = form.watch('code');
React.useEffect(() => {
useEffect(() => {
const isCodeEmpty = !codeValue?.trim();
modalFooterContext?.setFooterContent(

View File

@@ -17,21 +17,24 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as GuildStickerActionCreators from '@app/actions/GuildStickerActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import styles from '@app/components/modals/AddGuildStickerModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {StickerFormFields} from '@app/components/modals/sticker_form/StickerFormFields';
import {StickerPreview} from '@app/components/modals/sticker_form/StickerPreview';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import {Logger} from '@app/lib/Logger';
import * as ImageCropUtils from '@app/utils/ImageCropUtils';
import {GlobalLimits} from '@app/utils/limits/GlobalLimits';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useCallback, useEffect, useState} 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';
const logger = new Logger('AddGuildStickerModal');
interface AddGuildStickerModalProps {
guildId: string;
@@ -51,8 +54,8 @@ export const AddGuildStickerModal = observer(function AddGuildStickerModal({
onSuccess,
}: AddGuildStickerModalProps) {
const {t} = useLingui();
const [isProcessing, setIsProcessing] = React.useState(false);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const form = useForm<FormInputs>({
defaultValues: {
@@ -62,17 +65,18 @@ export const AddGuildStickerModal = observer(function AddGuildStickerModal({
},
});
React.useEffect(() => {
useEffect(() => {
const url = URL.createObjectURL(file);
setPreviewUrl(url);
return () => URL.revokeObjectURL(url);
}, [file]);
const onSubmit = React.useCallback(
const onSubmit = useCallback(
async (data: FormInputs) => {
setIsProcessing(true);
try {
const base64Image = await ImageCropUtils.optimizeStickerImage(file, STICKER_MAX_SIZE, 320);
const maxStickerSize = GlobalLimits.getStickerMaxSize();
const base64Image = await ImageCropUtils.optimizeStickerImage(file, maxStickerSize, 320);
await GuildStickerActionCreators.create(guildId, {
name: data.name.trim(),
@@ -83,10 +87,10 @@ export const AddGuildStickerModal = observer(function AddGuildStickerModal({
onSuccess();
ModalActionCreators.pop();
} catch (error: any) {
console.error('Failed to create sticker:', error);
} catch (error: unknown) {
logger.error('Failed to create sticker:', error);
form.setError('name', {
message: error.message || t`Failed to create sticker`,
message: error instanceof Error ? error.message : t`Failed to create sticker`,
});
setIsProcessing(false);
}

View File

@@ -17,20 +17,23 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ImageCropModal} from '@app/components/modals/ImageCropModal';
import type {ValueOf} from '@fluxer/constants/src/ValueOf';
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',
}
export const 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',
} as const;
export type AssetType = ValueOf<typeof AssetType>;
interface AssetConfig {
aspectRatio: number;

View File

@@ -20,5 +20,5 @@
.content {
display: flex;
flex-direction: column;
gap: 12px;
gap: 16px;
}

View File

@@ -17,22 +17,26 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import {Input, Textarea} from '@app/components/form/Input';
import {Switch} from '@app/components/form/Switch';
import styles from '@app/components/modals/AttachmentEditModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useCursorAtEnd} from '@app/hooks/useCursorAtEnd';
import {type CloudAttachment, CloudUpload} from '@app/lib/CloudUpload';
import {MessageAttachmentFlags} from '@fluxer/constants/src/ChannelConstants';
import {MAX_ATTACHMENT_ALT_TEXT_LENGTH} from '@fluxer/constants/src/LimitConstants';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useMemo} from 'react';
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;
description: string;
}
export const AttachmentEditModal = observer(
@@ -44,30 +48,50 @@ export const AttachmentEditModal = observer(
defaultValues: {
filename: attachment.filename,
spoiler: defaultSpoiler,
description: attachment.description ?? '',
},
});
const onSubmit = async (data: FormInputs) => {
const nextFlags = data.spoiler
? attachment.flags | MessageAttachmentFlags.IS_SPOILER
: attachment.flags & ~MessageAttachmentFlags.IS_SPOILER;
const filenameRef = useCursorAtEnd<HTMLInputElement>();
CloudUpload.updateAttachment(channelId, attachment.id, {
filename: data.filename,
flags: nextFlags,
spoiler: data.spoiler,
});
const isAltTextSupported = useMemo(() => {
const mimeType = attachment.file.type.toLowerCase();
return mimeType.startsWith('image/') || mimeType.startsWith('video/');
}, [attachment.file.type]);
ModalActionCreators.pop();
};
const onSubmit = useCallback(
async (data: FormInputs) => {
const nextFlags = data.spoiler
? attachment.flags | MessageAttachmentFlags.IS_SPOILER
: attachment.flags & ~MessageAttachmentFlags.IS_SPOILER;
const nextDescription = data.description.trim();
const updates: Partial<CloudAttachment> = {
filename: data.filename,
flags: nextFlags,
spoiler: data.spoiler,
};
if (isAltTextSupported) {
updates.description = nextDescription.length > 0 ? nextDescription : undefined;
}
CloudUpload.updateAttachment(channelId, attachment.id, updates);
ModalActionCreators.pop();
},
[attachment, channelId, isAltTextSupported],
);
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}>
<Form form={form} onSubmit={onSubmit}>
<Modal.Header title={t`Edit Attachment`} onClose={ModalActionCreators.pop} />
<Modal.Content contentClassName={styles.content}>
<Input
{...form.register('filename')}
ref={(el) => {
filenameRef(el);
form.register('filename').ref(el);
}}
autoFocus={true}
label={t`Filename`}
minLength={1}
@@ -76,8 +100,19 @@ export const AttachmentEditModal = observer(
type="text"
spellCheck={false}
/>
{isAltTextSupported ? (
<Textarea
{...form.register('description')}
label={t`Alt Text Description`}
placeholder={t`Describe this media for screen readers`}
minRows={3}
maxRows={8}
showCharacterCount={true}
maxLength={MAX_ATTACHMENT_ALT_TEXT_LENGTH}
/>
) : null}
<Switch
label={t`Mark as spoiler`}
label={t`Mark as Spoiler`}
value={form.watch('spoiler')}
onChange={(value) => form.setValue('spoiler', value)}
/>

View File

@@ -17,9 +17,9 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
interface AudioPlaybackPermissionModalProps {
onStartAudio: () => Promise<void>;

View File

@@ -17,7 +17,27 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ContextMenuActionCreators from '@app/actions/ContextMenuActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as PremiumModalActionCreators from '@app/actions/PremiumModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import * as VoiceSettingsActionCreators from '@app/actions/VoiceSettingsActionCreators';
import styles from '@app/components/modals/BackgroundImageGalleryModal.module.css';
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {MenuItem} from '@app/components/uikit/context_menu/MenuItem';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import {Logger} from '@app/lib/Logger';
import VoiceSettingsStore, {BLUR_BACKGROUND_ID, NONE_BACKGROUND_ID} from '@app/stores/VoiceSettingsStore';
import * as BackgroundImageDB from '@app/utils/BackgroundImageDB';
import {openFilePicker} from '@app/utils/FilePickerUtils';
import {LimitResolver} from '@app/utils/limits/LimitResolverAdapter';
import {shouldShowPremiumFeatures} from '@app/utils/PremiumUtils';
import {Trans, useLingui} from '@lingui/react/macro';
import type {IconProps} from '@phosphor-icons/react';
import {
ArrowsClockwiseIcon,
CheckIcon,
@@ -29,24 +49,9 @@ import {
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';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
const logger = new Logger('BackgroundImageGalleryModal');
interface BackgroundImage {
id: string;
@@ -57,39 +62,12 @@ interface BuiltInBackground {
id: string;
type: 'none' | 'blur' | 'upload';
name: string;
icon: React.ComponentType<any>;
icon: React.ComponentType<IconProps>;
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'];
@@ -106,11 +84,11 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
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);
const [imageUrl, setImageUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(!isBuiltIn);
const [hasError, setHasError] = useState(false);
React.useEffect(() => {
useEffect(() => {
if (isBuiltIn) return;
let objectUrl: string | null = null;
setIsLoading(true);
@@ -122,7 +100,7 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
setIsLoading(false);
})
.catch((error) => {
console.error('Failed to load background image:', error);
logger.error('Failed to load background image:', error);
setHasError(true);
setIsLoading(false);
});
@@ -134,11 +112,11 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
};
}, [isBuiltIn, background.id]);
const handleClick = React.useCallback(() => {
const handleClick = useCallback(() => {
onSelect(background);
}, [background, onSelect]);
const handleKeyDown = React.useCallback(
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -148,7 +126,7 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
[background, onSelect],
);
const handleContextMenu = React.useCallback(
const handleContextMenu = useCallback(
(e: React.MouseEvent) => {
if (!isBuiltIn) {
onContextMenu?.(e, background as BackgroundImage);
@@ -157,7 +135,7 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
[isBuiltIn, background, onContextMenu],
);
const handleDelete = React.useCallback(
const handleDelete = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -168,7 +146,7 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
[isBuiltIn, background, onDelete],
);
const handleRetry = React.useCallback(() => {
const handleRetry = useCallback(() => {
setHasError(false);
setIsLoading(true);
BackgroundImageDB.getBackgroundImageURL(background.id)
@@ -177,7 +155,7 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
setIsLoading(false);
})
.catch((error) => {
console.error('Failed to load background image:', error);
logger.error('Failed to load background image:', error);
setHasError(true);
setIsLoading(false);
});
@@ -234,7 +212,7 @@ const BackgroundItem: React.FC<BackgroundItemProps> = React.memo(
</FocusRing>
</div>
) : imageUrl ? (
<img src={imageUrl} alt="Background" className={styles.backgroundImage} />
<img src={imageUrl} alt={t`Background`} className={styles.backgroundImage} />
) : null}
<div className={styles.imageOverlay} />
{!isBuiltIn && onDelete && !isLoading && !hasError && (
@@ -268,34 +246,57 @@ 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 isMountedRef = useRef(true);
const [isDragging, setIsDragging] = useState(false);
const dragCounterRef = useRef(0);
const hasPremium = React.useMemo(() => user?.isPremium?.() ?? false, [user]);
const maxBackgroundImages = hasPremium ? 15 : 1;
const maxBackgroundImages = useMemo(() => LimitResolver.resolve({key: 'max_custom_backgrounds', fallback: 1}), []);
const canAddMoreImages = backgroundImages.length < maxBackgroundImages;
const backgroundCount = backgroundImages.length;
const shouldShowReplace = !hasPremium && backgroundImages.length >= 1;
const builtInBackgrounds = React.useMemo(() => getBuiltInBackgrounds(shouldShowReplace), [shouldShowReplace]);
const shouldShowReplace = maxBackgroundImages === 1 && backgroundImages.length >= 1;
const builtInBackgrounds = useMemo(
(): ReadonlyArray<BuiltInBackground> => [
{
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: shouldShowReplace ? t`Replace` : t`Upload`,
icon: PlusIcon,
description: shouldShowReplace ? t`Replace your custom background` : t`Add a custom background`,
},
],
[shouldShowReplace],
);
const sortedImages = React.useMemo(
const sortedImages = useMemo(
() => [...backgroundImages].sort((a, b) => b.createdAt - a.createdAt),
[backgroundImages],
);
React.useEffect(() => {
useEffect(() => {
return () => {
isMountedRef.current = false;
};
}, []);
const processFileUpload = React.useCallback(
const processFileUpload = useCallback(
async (file: File | null) => {
if (!file) return;
@@ -327,7 +328,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
let updatedImages = [...backgroundImages];
let oldImageToDelete: string | null = null;
if (!hasPremium && backgroundImages.length >= 1) {
if (backgroundImages.length >= maxBackgroundImages) {
const oldImage = backgroundImages[0];
oldImageToDelete = oldImage.id;
updatedImages = [];
@@ -342,7 +343,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
if (oldImageToDelete) {
BackgroundImageDB.deleteBackgroundImage(oldImageToDelete).catch((error) => {
console.error('Failed to delete old background image:', error);
logger.error('Failed to delete old background image:', error);
});
}
@@ -356,19 +357,19 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
ModalActionCreators.pop();
}
} catch (error) {
console.error('File upload failed:', error);
logger.error('File upload failed:', error);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to upload background image. Please try again.`,
});
}
},
[backgroundImages, hasPremium],
[backgroundImages, maxBackgroundImages],
);
const handleUploadClick = React.useCallback(
const handleUploadClick = useCallback(
(showReplaceWarning: boolean = false) => {
if (!canAddMoreImages && hasPremium) {
if (!canAddMoreImages) {
ToastActionCreators.createToast({
type: 'error',
children: t`You've reached the maximum of ${maxBackgroundImages} backgrounds. Remove one to add a new background.`,
@@ -381,7 +382,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
await processFileUpload(file ?? null);
};
if (showReplaceWarning && !hasPremium && backgroundImages.length >= 1) {
if (showReplaceWarning && backgroundImages.length >= maxBackgroundImages) {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
@@ -403,10 +404,10 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
void pickAndProcess();
},
[canAddMoreImages, hasPremium, maxBackgroundImages, backgroundImages.length, processFileUpload],
[canAddMoreImages, maxBackgroundImages, backgroundImages.length, processFileUpload],
);
const handleBackgroundSelect = React.useCallback(
const handleBackgroundSelect = useCallback(
(background: BackgroundItemType) => {
if ('type' in background) {
if (background.type === 'upload') {
@@ -428,14 +429,14 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
[handleUploadClick],
);
const handleRemoveImage = React.useCallback(
const handleRemoveImage = useCallback(
async (image: BackgroundImage) => {
try {
await BackgroundImageDB.deleteBackgroundImage(image.id);
const updatedImages = backgroundImages.filter((img) => img.id !== image.id);
const updates: any = {
const updates: {backgroundImages: Array<BackgroundImage>; backgroundImageId?: string} = {
backgroundImages: updatedImages,
};
@@ -450,7 +451,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
children: t`Background image removed.`,
});
} catch (error) {
console.error('Failed to delete background image:', error);
logger.error('Failed to delete background image:', error);
ToastActionCreators.createToast({
type: 'error',
children: t`Failed to remove background image. Please try again.`,
@@ -460,7 +461,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
[backgroundImageId, backgroundImages],
);
const handleBackgroundContextMenu = React.useCallback(
const handleBackgroundContextMenu = useCallback(
(event: React.MouseEvent, image: BackgroundImage) => {
event.preventDefault();
event.stopPropagation();
@@ -482,7 +483,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
[handleRemoveImage],
);
const handleDrop = React.useCallback(
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -496,7 +497,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
[processFileUpload],
);
const handleDragEnter = React.useCallback((e: React.DragEvent) => {
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
@@ -505,7 +506,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
}
}, []);
const handleDragLeave = React.useCallback((e: React.DragEvent) => {
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
@@ -514,7 +515,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
}
}, []);
const handleDragOver = React.useCallback((e: React.DragEvent) => {
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
@@ -541,7 +542,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
</div>
</div>
)}
{!hasPremium ? (
{maxBackgroundImages === 1 ? (
<div className={styles.freeUserContainer}>
{sortedImages.length > 0 ? (
<div className={styles.customBackgroundWrapper}>
@@ -659,7 +660,7 @@ const BackgroundImageGalleryModal: React.FC = observer(() => {
<Trans>Supported: JPG, PNG, GIF, WebP, MP4. Max size: 10MB.</Trans>
</div>
{!hasPremium && (
{maxBackgroundImages === 1 && shouldShowPremiumFeatures() && (
<div className={styles.premiumUpsell}>
<div className={styles.premiumHeader}>
<CrownIcon weight="fill" size={18} className={styles.premiumIcon} />

View File

@@ -17,27 +17,31 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as TextCopyActionCreators from '@app/actions/TextCopyActionCreators';
import styles from '@app/components/modals/BackupCodesModal.module.css';
import {BackupCodesRegenerateModal} from '@app/components/modals/BackupCodesRegenerateModal';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import UserStore from '@app/stores/UserStore';
import type {BackupCode} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
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>}) => {
interface BackupCodesModalProps {
backupCodes: ReadonlyArray<BackupCode>;
}
export const BackupCodesModal = observer(({backupCodes}: BackupCodesModalProps) => {
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}>
<Modal.Header title={t`Backup Codes`} />
<Modal.Content contentClassName={styles.content}>
<p className={styles.description}>
<Trans>Use these codes to access your account if you lose your authenticator app.</Trans>
</p>

View File

@@ -17,22 +17,27 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as MfaActionCreators from '@app/actions/MfaActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Form} from '@app/components/form/Form';
import {FormErrorText} from '@app/components/form/FormErrorText';
import {BackupCodesModal} from '@app/components/modals/BackupCodesModal';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
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';
interface FormInputs {
form: string;
}
export const BackupCodesRegenerateModal = observer(() => {
const {t} = useLingui();
const form = useForm();
const form = useForm<FormInputs>();
const onSubmit = async () => {
const backupCodes = await MfaActionCreators.getBackupCodes(true);
@@ -53,8 +58,15 @@ export const BackupCodesRegenerateModal = observer(() => {
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.Header title={t`Regenerate Backup Codes`} />
<Modal.Content>
<Modal.ContentLayout>
<Modal.Description>
<Trans>This will invalidate your existing backup codes and generate new ones.</Trans>
</Modal.Description>
<FormErrorText message={form.formState.errors.form?.message} />
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>

View File

@@ -17,22 +17,26 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as MfaActionCreators from '@app/actions/MfaActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import {FormErrorText} from '@app/components/form/FormErrorText';
import {BackupCodesModal} from '@app/components/modals/BackupCodesModal';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
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';
interface FormInputs {
form: string;
}
export const BackupCodesViewModal = observer(() => {
const {t} = useLingui();
const form = useForm();
const form = useForm<FormInputs>();
const onSubmit = async () => {
const backupCodes = await MfaActionCreators.getBackupCodes();
@@ -52,11 +56,14 @@ export const BackupCodesViewModal = observer(() => {
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.Header title={t`View Backup Codes`} />
<Modal.Content>
<Modal.ContentLayout>
<Modal.Description>
<Trans>Verification may be required before viewing your backup codes.</Trans>
</Modal.Description>
<FormErrorText message={form.formState.errors.form?.message} />
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">

View File

@@ -17,12 +17,6 @@
* 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;

View File

@@ -17,18 +17,19 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import type {GuildBan} from '@app/actions/GuildActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import styles from '@app/components/modals/BanDetailsModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Avatar} from '@app/components/uikit/Avatar';
import {Button} from '@app/components/uikit/button/Button';
import UserStore from '@app/stores/UserStore';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import * as DateUtils from '@app/utils/DateUtils';
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';
import type React from 'react';
import {useCallback, useState} from 'react';
interface BanDetailsModalProps {
ban: GuildBan;
@@ -39,10 +40,10 @@ export const BanDetailsModal: React.FC<BanDetailsModalProps> = observer(({ban, o
const {t} = useLingui();
const moderator = UserStore.getUser(ban.moderator_id);
const avatarUrl = AvatarUtils.getUserAvatarURL(ban.user, false);
const [isRevoking, setIsRevoking] = React.useState(false);
const [isRevoking, setIsRevoking] = useState(false);
const userTag = ban.user.tag ?? `${ban.user.username}#${(ban.user.discriminator ?? '').padStart(4, '0')}`;
const handleRevoke = React.useCallback(async () => {
const handleRevoke = useCallback(async () => {
if (!onRevoke) return;
setIsRevoking(true);
try {
@@ -57,7 +58,7 @@ export const BanDetailsModal: React.FC<BanDetailsModalProps> = observer(({ban, o
<Modal.Root size="small" centered>
<Modal.Header title={t`Ban Details`} />
<Modal.Content>
<div className={styles.container}>
<Modal.ContentLayout>
<div className={styles.userSection}>
{avatarUrl ? (
<img src={avatarUrl} alt="" className={styles.avatar} />
@@ -118,7 +119,7 @@ export const BanDetailsModal: React.FC<BanDetailsModalProps> = observer(({ban, o
</span>
</div>
</div>
</div>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()}>

View File

@@ -17,22 +17,26 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as GuildActionCreators from '@app/actions/GuildActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Input} from '@app/components/form/Input';
import {Select as FormSelect} from '@app/components/form/Select';
import styles from '@app/components/modals/BanMemberModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {RadioGroup} from '@app/components/uikit/radio_group/RadioGroup';
import {Logger} from '@app/lib/Logger';
import type {UserRecord} from '@app/records/UserRecord';
import bannedMp4 from '@app/videos/banned.mp4';
import bannedPng from '@app/videos/banned.png';
import bannedWebm from '@app/videos/banned.webm';
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';
import type React from 'react';
import {useCallback, useState} from 'react';
const logger = new Logger('BanMemberModal');
interface SelectOption {
value: number;
@@ -41,12 +45,12 @@ interface SelectOption {
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 [reason, setReason] = useState('');
const [deleteMessageDays, setDeleteMessageDays] = useState<number>(1);
const [banDuration, setBanDuration] = useState<number>(0);
const [isBanning, setIsBanning] = useState(false);
const getBanDurationOptions = React.useCallback(
const getBanDurationOptions = useCallback(
(): ReadonlyArray<SelectOption> => [
{value: 0, label: t`Permanent`},
{value: 60 * 60, label: t`1 hour`},
@@ -73,7 +77,7 @@ export const BanMemberModal: React.FC<{guildId: string; targetUser: UserRecord}>
});
ModalActionCreators.pop();
} catch (error) {
console.error('Failed to ban member:', error);
logger.error('Failed to ban member:', error);
ToastActionCreators.createToast({
type: 'error',
children: <Trans>Failed to ban member. Please try again.</Trans>,
@@ -92,7 +96,7 @@ export const BanMemberModal: React.FC<{guildId: string; targetUser: UserRecord}>
<video autoPlay loop className={styles.video}>
<source src={bannedWebm} type="video/webm" />
<source src={bannedMp4} type="video/mp4" />
<img src={bannedPng} alt="Banned" />
<img src={bannedPng} alt={t`Banned`} />
</video>
<div>
@@ -125,7 +129,7 @@ export const BanMemberModal: React.FC<{guildId: string; targetUser: UserRecord}>
<Input
type="text"
label={t`Reason (optional)`}
label={t`Reason (Optional)`}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t`Enter a reason for the ban...`}

View File

@@ -17,21 +17,22 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import styles from '@app/components/modals/BaseChangeNicknameModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {useCursorAtEnd} from '@app/hooks/useCursorAtEnd';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import {Trans, useLingui} from '@lingui/react/macro';
import {XIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import type React from 'react';
import {useCallback} 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;
@@ -52,7 +53,9 @@ export const BaseChangeNicknameModal: React.FC<BaseChangeNicknameModalProps> = o
},
});
const onSubmit = React.useCallback(
const nickRef = useCursorAtEnd<HTMLInputElement>();
const onSubmit = useCallback(
async (data: FormInputs) => {
const nick = data.nick.trim() || null;
@@ -80,35 +83,41 @@ export const BaseChangeNicknameModal: React.FC<BaseChangeNicknameModalProps> = o
<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.ContentLayout>
<Input
{...form.register('nick', {
maxLength: {
value: 32,
message: t`Nickname must not exceed 32 characters`,
},
})}
ref={(el) => {
nickRef(el);
form.register('nick').ref(el);
}}
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.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button type="submit" submitting={isSubmitting}>

View File

@@ -17,24 +17,25 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as SavedMessageActionCreators from '@app/actions/SavedMessageActionCreators';
import {Message} from '@app/components/channel/Message';
import {LongPressable} from '@app/components/LongPressable';
import styles from '@app/components/modals/BookmarksBottomSheet.module.css';
import {SavedMessageMissingCard} from '@app/components/shared/SavedMessageMissingCard';
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
import type {MenuGroupType} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet';
import {MenuBottomSheet} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet';
import {Scroller, type ScrollerHandle} from '@app/components/uikit/Scroller';
import {useMessageListKeyboardNavigation} from '@app/hooks/useMessageListKeyboardNavigation';
import type {MessageRecord} from '@app/records/MessageRecord';
import ChannelStore from '@app/stores/ChannelStore';
import SavedMessagesStore from '@app/stores/SavedMessagesStore';
import {goToMessage} from '@app/utils/MessageNavigator';
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
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';
import {useEffect, useRef, useState} from 'react';
interface BookmarksBottomSheetProps {
isOpen: boolean;
@@ -45,15 +46,20 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
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);
const scrollerRef = useRef<ScrollerHandle | null>(null);
const [selectedMessage, setSelectedMessage] = useState<MessageRecord | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
React.useEffect(() => {
useEffect(() => {
if (!fetched && isOpen) {
SavedMessageActionCreators.fetch();
}
}, [fetched, isOpen]);
useMessageListKeyboardNavigation({
containerRef: scrollerRef,
});
const handleLongPress = (message: MessageRecord) => {
setSelectedMessage(message);
setMenuOpen(true);
@@ -102,7 +108,7 @@ export const BookmarksBottomSheet = observer(({isOpen, onClose}: BookmarksBottom
<>
<BottomSheet isOpen={isOpen} onClose={onClose} snapPoints={[0, 1]} initialSnap={1} title={t`Bookmarks`}>
{hasBookmarks ? (
<Scroller className={styles.messageList} key="bookmarks-bottom-sheet-scroller">
<Scroller className={styles.messageList} key="bookmarks-bottom-sheet-scroller" ref={scrollerRef}>
{missingSavedMessages.length > 0 && (
<div className={styles.missingList}>
{missingSavedMessages.map((entry) => (

View File

@@ -17,32 +17,33 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import * as VoiceSettingsActionCreators from '@app/actions/VoiceSettingsActionCreators';
import {Select} from '@app/components/form/Select';
import BackgroundImageGalleryModal from '@app/components/modals/BackgroundImageGalleryModal';
import styles from '@app/components/modals/CameraPreviewModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {Spinner} from '@app/components/uikit/Spinner';
import {Logger} from '@app/lib/Logger';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import VoiceSettingsStore, {BLUR_BACKGROUND_ID, NONE_BACKGROUND_ID} from '@app/stores/VoiceSettingsStore';
import VoiceDevicePermissionStore from '@app/stores/voice/VoiceDevicePermissionStore';
import VoiceMediaStateCoordinator from '@app/stores/voice/VoiceMediaStateCoordinator';
import {applyBackgroundProcessor} from '@app/utils/VideoBackgroundProcessor';
import type {VoiceDeviceState} from '@app/utils/VoiceDeviceManager';
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';
const logger = new Logger('CameraPreviewModal');
interface CameraPreviewModalProps {
onEnabled?: () => void;
@@ -65,10 +66,6 @@ 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},
@@ -83,14 +80,13 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
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 processorRef = useRef<{destroy: () => Promise<void>} | null>(null);
const isMountedRef = useRef(true);
const isIOSRef = useRef(/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream);
const isIOSRef = useRef(/iPad|iPhone|iPod/.test(navigator.userAgent) && !('MSStream' in window));
const prevConfigRef = useRef<{
videoDeviceId: string;
backgroundImageId: string;
@@ -112,7 +108,10 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
setVideoDevices(videoInputs);
const voiceSettings = VoiceSettingsStore;
if (voiceSettings.videoDeviceId === 'default' && videoInputs.length > 0) {
const currentDeviceId = voiceSettings.videoDeviceId;
const currentDeviceExists = videoInputs.some((device) => device.deviceId === currentDeviceId);
if (videoInputs.length > 0 && (currentDeviceId === 'default' || !currentDeviceExists)) {
VoiceSettingsActionCreators.update({videoDeviceId: videoInputs[0].deviceId});
}
}, []);
@@ -240,6 +239,11 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
trackRef.current = track;
track.attach(videoElement);
const actualDeviceId = track.mediaStreamTrack.getSettings().deviceId;
if (actualDeviceId && actualDeviceId !== voiceSettings.videoDeviceId) {
VoiceSettingsActionCreators.update({videoDeviceId: actualDeviceId});
}
await new Promise<void>((resolve) => {
let playbackAttempts = 0;
const checkPlayback = () => {
@@ -267,9 +271,6 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
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);
@@ -292,41 +293,10 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
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);
}
}
}
processorRef.current = await applyBackgroundProcessor(track);
} catch (_webglError) {
console.warn('WebGL not supported for background processing, falling back to basic camera');
logger.warn('WebGL not supported for background processing, falling back to basic camera');
}
if (!isMountedRef.current) {
@@ -376,8 +346,7 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
deviceId: voiceSettings.videoDeviceId !== 'default' ? voiceSettings.videoDeviceId : undefined,
});
LocalVoiceStateStore.updateSelfVideo(true);
MediaEngineStore.syncLocalVoiceStateWithServer({self_video: true});
VoiceMediaStateCoordinator.applyCameraState(true, {reason: 'user', sendUpdate: true});
onEnabled?.();
onEnableCamera?.();
@@ -455,18 +424,6 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
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`} />
@@ -520,27 +477,6 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
</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>
@@ -558,7 +494,7 @@ const CameraPreviewModalContent = observer((props: CameraPreviewModalProps) => {
);
});
const CameraPreviewModalInRoom: React.FC<Omit<CameraPreviewModalProps, 'localParticipant' | 'isCameraEnabled'>> =
export const CameraPreviewModalInRoom: React.FC<Omit<CameraPreviewModalProps, 'localParticipant' | 'isCameraEnabled'>> =
observer((props) => {
const {localParticipant, isCameraEnabled} = useLocalParticipant();
return (
@@ -566,8 +502,6 @@ const CameraPreviewModalInRoom: React.FC<Omit<CameraPreviewModalProps, 'localPar
);
});
const CameraPreviewModalStandalone: React.FC<CameraPreviewModalProps> = observer((props) => {
export const CameraPreviewModalStandalone: React.FC<CameraPreviewModalProps> = observer((props) => {
return <CameraPreviewModalContent localParticipant={undefined} isCameraEnabled={false} {...props} />;
});
export {CameraPreviewModalInRoom, CameraPreviewModalStandalone};

View File

@@ -18,10 +18,7 @@
*/
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: var(--text-primary);
}

View File

@@ -17,19 +17,33 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {TurnstileWidget} from '@app/components/captcha/TurnstileWidget';
import styles from '@app/components/modals/CaptchaModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {Logger} from '@app/lib/Logger';
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
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';
const logger = new Logger('CaptchaModal');
export type CaptchaType = 'turnstile' | 'hcaptcha';
interface HCaptchaComponentProps {
sitekey: string;
onVerify?: (token: string) => void;
onExpire?: () => void;
onError?: (error: string) => void;
theme?: 'light' | 'dark';
ref?: React.Ref<HCaptcha>;
}
const HCaptchaComponent = HCaptcha as React.ComponentType<HCaptchaComponentProps>;
interface CaptchaModalProps {
onVerify: (token: string, captchaType: CaptchaType) => void;
onCancel?: () => void;
@@ -97,7 +111,7 @@ export const CaptchaModal = observer(
const handleError = useCallback(
(error: string) => {
console.error(`${captchaType} error:`, error);
logger.error(`${captchaType} error:`, error);
},
[captchaType],
);
@@ -118,7 +132,7 @@ export const CaptchaModal = observer(
<Modal.Root size="small" centered onClose={handleCancel}>
<Modal.Header title={t`Verify You're Human`} onClose={handleCancel} />
<Modal.Content>
<div className={styles.container}>
<Modal.ContentLayout 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>
@@ -139,7 +153,7 @@ export const CaptchaModal = observer(
theme="dark"
/>
) : (
<HCaptcha
<HCaptchaComponent
ref={hcaptchaRef}
sitekey={RuntimeConfigStore.hcaptchaSiteKey ?? ''}
onVerify={handleVerify}
@@ -166,7 +180,7 @@ export const CaptchaModal = observer(
</button>
</div>
)}
</div>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={handleCancel} disabled={isVerifying}>

View File

@@ -17,18 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ChannelActionCreators from '@app/actions/ChannelActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
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;
@@ -61,18 +60,20 @@ export const CategoryCreateModal = observer(({guildId}: {guildId: string}) => {
<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.ContentLayout>
<Input
{...form.register('name')}
autoComplete="off"
autoFocus={true}
error={form.formState.errors.name?.message}
label={t`Name`}
maxLength={100}
minLength={1}
placeholder={t`New Category`}
required={true}
/>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">

View File

@@ -17,12 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as RelationshipActionCreators from '@app/actions/RelationshipActionCreators';
import {BaseChangeNicknameModal} from '@app/components/modals/BaseChangeNicknameModal';
import type {UserRecord} from '@app/records/UserRecord';
import RelationshipStore from '@app/stores/RelationshipStore';
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';
import type React from 'react';
import {useCallback} from 'react';
interface ChangeFriendNicknameModalProps {
user: UserRecord;
@@ -32,7 +33,7 @@ export const ChangeFriendNicknameModal: React.FC<ChangeFriendNicknameModalProps>
const relationship = RelationshipStore.getRelationship(user.id);
const currentNick = relationship?.nickname ?? '';
const handleSave = React.useCallback(
const handleSave = useCallback(
async (nick: string | null) => {
await RelationshipActionCreators.updateFriendNickname(user.id, nick);
},

View File

@@ -17,12 +17,13 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ChannelActionCreators from '@app/actions/ChannelActionCreators';
import {BaseChangeNicknameModal} from '@app/components/modals/BaseChangeNicknameModal';
import type {UserRecord} from '@app/records/UserRecord';
import ChannelStore from '@app/stores/ChannelStore';
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';
import type React from 'react';
import {useCallback} from 'react';
interface ChangeGroupDMNicknameModalProps {
channelId: string;
@@ -33,7 +34,7 @@ export const ChangeGroupDMNicknameModal: React.FC<ChangeGroupDMNicknameModalProp
const channel = ChannelStore.getChannel(channelId);
const currentNick = channel?.nicks?.[user.id] || '';
const handleSave = React.useCallback(
const handleSave = useCallback(
async (nick: string | null) => {
await ChannelActionCreators.updateGroupDMNickname(channelId, user.id, nick);
},

View File

@@ -17,13 +17,14 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as GuildMemberActionCreators from '@app/actions/GuildMemberActionCreators';
import {BaseChangeNicknameModal} from '@app/components/modals/BaseChangeNicknameModal';
import type {GuildMemberRecord} from '@app/records/GuildMemberRecord';
import type {UserRecord} from '@app/records/UserRecord';
import AuthenticationStore from '@app/stores/AuthenticationStore';
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';
import type React from 'react';
import {useCallback} from 'react';
interface ChangeNicknameModalProps {
guildId: string;
@@ -35,7 +36,7 @@ export const ChangeNicknameModal: React.FC<ChangeNicknameModalProps> = observer(
const currentUserId = AuthenticationStore.currentUserId;
const isCurrentUser = user.id === currentUserId;
const handleSave = React.useCallback(
const handleSave = useCallback(
async (nick: string | null) => {
if (isCurrentUser) {
await GuildMemberActionCreators.updateProfile(guildId, {nick});

View File

@@ -17,24 +17,24 @@
* 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 * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import styles from '@app/components/modals/ChannelCreateModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {RadioGroup} from '@app/components/uikit/radio_group/RadioGroup';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import {
channelTypeOptions,
createChannel,
type FormInputs,
getDefaultValues,
} from '~/utils/modals/ChannelCreateModalUtils';
} from '@app/utils/modals/ChannelCreateModalUtils';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {Controller, useForm} from 'react-hook-form';
export const ChannelCreateModal = observer(({guildId, parentId}: {guildId: string; parentId?: string}) => {
const {t} = useLingui();
@@ -56,7 +56,7 @@ export const ChannelCreateModal = observer(({guildId, parentId}: {guildId: strin
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit}>
<Modal.Header title={t`Create Channel`} />
<Modal.Content className={styles.content}>
<Modal.Content contentClassName={styles.content}>
<div className={styles.channelTypeSection}>
<div className={styles.channelTypeLabel}>{t`Channel Type`}</div>
<Controller
@@ -74,8 +74,8 @@ export const ChannelCreateModal = observer(({guildId, parentId}: {guildId: strin
</div>
<Input
{...form.register('name')}
autoFocus={true}
autoComplete="off"
autoFocus={true}
error={form.formState.errors.name?.message}
label={t`Name`}
maxLength={100}

View File

@@ -17,20 +17,17 @@
* 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 * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {
type ChannelDeleteModalProps,
deleteChannel,
getChannelDeleteInfo,
} from '~/utils/modals/ChannelDeleteModalUtils';
} from '@app/utils/modals/ChannelDeleteModalUtils';
import {Trans} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useState} from 'react';
export const ChannelDeleteModal = observer(({channelId}: ChannelDeleteModalProps) => {
const deleteInfo = getChannelDeleteInfo(channelId);
@@ -54,18 +51,20 @@ export const ChannelDeleteModal = observer(({channelId}: ChannelDeleteModalProps
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.ContentLayout>
<Modal.Description>
{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>
)}
</Modal.Description>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">

View File

@@ -17,67 +17,70 @@
* 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 ConnectionStore from '~/stores/gateway/ConnectionStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import {isMobileExperienceEnabled} from '~/utils/mobileExperience';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as UnsavedChangesActionCreators from '@app/actions/UnsavedChangesActionCreators';
import {DesktopChannelSettingsView} from '@app/components/modals/components/DesktopChannelSettingsView';
import {MobileChannelSettingsView} from '@app/components/modals/components/MobileChannelSettingsView';
import {useMobileNavigation} from '@app/components/modals/hooks/useMobileNavigation';
import * as Modal from '@app/components/modals/Modal';
import {SettingsModalContainer} from '@app/components/modals/shared/SettingsModalLayout';
import type {ChannelSettingsTabType} from '@app/components/modals/utils/ChannelSettingsConstants';
import ChannelStore from '@app/stores/ChannelStore';
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import UnsavedChangesStore from '@app/stores/UnsavedChangesStore';
import {isMobileExperienceEnabled} from '@app/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';
} from '@app/utils/modals/ChannelSettingsModalUtils';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observer(({channelId, initialMobileTab}) => {
const {t} = useLingui();
const {t, i18n} = useLingui();
const channel = ChannelStore.getChannel(channelId);
const guildId = channel?.guildId;
const [selectedTab, setSelectedTab] = React.useState<ChannelSettingsTabType>('overview');
const [selectedTab, setSelectedTab] = useState<ChannelSettingsTabType>('overview');
const availableTabs = React.useMemo(() => {
return getAvailableTabs(t, channelId);
}, [t, channelId]);
const availableTabs = useMemo(() => {
return getAvailableTabs(i18n, channelId);
}, [i18n, channelId]);
const isMobileExperience = isMobileExperienceEnabled();
const isMobileExp = isMobileExperienceEnabled();
const initialTab = React.useMemo(() => {
if (!isMobileExperience || !initialMobileTab) return;
const initialTab = useMemo(() => {
if (!isMobileExp || !initialMobileTab) return;
const targetTab = availableTabs.find((tab) => tab.type === initialMobileTab);
if (!targetTab) return;
return {tab: initialMobileTab, title: targetTab.label};
}, [initialMobileTab, availableTabs, isMobileExperience]);
}, [initialMobileTab, availableTabs, isMobileExp]);
const mobileNav = useMobileNavigation<ChannelSettingsTabType>(initialTab);
const {enabled: isMobile} = MobileLayoutStore;
const unsavedChangesStore = UnsavedChangesStore;
React.useEffect(() => {
useEffect(() => {
if (guildId) {
ConnectionStore.syncGuildIfNeeded(guildId, 'channel-settings-modal');
GatewayConnectionStore.syncGuildIfNeeded(guildId, 'channel-settings-modal');
}
}, [guildId]);
React.useEffect(() => {
useEffect(() => {
if (!channel) {
ModalActionCreators.pop();
}
}, [channel]);
const groupedSettingsTabs = React.useMemo(() => {
const groupedSettingsTabs = useMemo(() => {
return getGroupedSettingsTabs(availableTabs);
}, [availableTabs]);
const currentTab = React.useMemo(() => {
const currentTab = useMemo(() => {
if (!isMobile) {
return availableTabs.find((tab) => tab.type === selectedTab);
}
@@ -85,7 +88,7 @@ export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observe
return availableTabs.find((tab) => tab.type === mobileNav.currentView?.tab);
}, [isMobile, selectedTab, mobileNav.isRootView, mobileNav.currentView, availableTabs]);
const handleMobileBack = React.useCallback(() => {
const handleMobileBack = useCallback(() => {
if (mobileNav.isRootView) {
ModalActionCreators.pop();
} else {
@@ -93,14 +96,22 @@ export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observe
}
}, [mobileNav]);
const handleTabSelect = React.useCallback(
const handleTabSelect = useCallback(
(tabType: string, title: string) => {
mobileNav.navigateTo(tabType as ChannelSettingsTabType, title);
},
[mobileNav],
);
const handleClose = React.useCallback(createHandleClose(selectedTab), [selectedTab]);
const currentMobileTab = mobileNav.currentView?.tab;
const handleClose = useCallback(() => {
const checkTabId = isMobile ? currentMobileTab : selectedTab;
if (checkTabId && unsavedChangesStore.unsavedChanges[checkTabId]) {
UnsavedChangesActionCreators.triggerFlashEffect(checkTabId);
return;
}
ModalActionCreators.pop();
}, [currentMobileTab, isMobile, selectedTab, unsavedChangesStore.unsavedChanges]);
if (!channel) {
return null;

View File

@@ -17,15 +17,14 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import styles from '@app/components/modals/ChannelTopicModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {SafeMarkdown} from '@app/lib/markdown';
import {MarkdownContext} from '@app/lib/markdown/renderers/RendererTypes';
import markupStyles from '@app/styles/Markup.module.css';
import {type ChannelTopicModalProps, getChannelTopicInfo} from '@app/utils/modals/ChannelTopicModalUtils';
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);
@@ -39,16 +38,18 @@ export const ChannelTopicModal = observer(({channelId}: ChannelTopicModalProps)
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 className={styles.selectable}>
<Modal.ContentLayout>
<div className={clsx(markupStyles.markup, styles.topic)}>
<SafeMarkdown
content={topic}
options={{
context: MarkdownContext.STANDARD_WITHOUT_JUMBO,
channelId,
}}
/>
</div>
</Modal.ContentLayout>
</Modal.Content>
</Modal.Root>
);

View File

@@ -17,13 +17,6 @@
* 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;
@@ -36,8 +29,3 @@
flex: 1;
min-width: fit-content;
}
.error {
color: var(--warn-text, #f36);
margin-top: 8px;
}

View File

@@ -17,39 +17,41 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import * as UserActionCreators from '@app/actions/UserActionCreators';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import styles from '@app/components/modals/ClaimAccountModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import type {HttpError} from '@app/lib/HttpError';
import ModalStore from '@app/stores/ModalStore';
import * as FormUtils from '@app/utils/FormUtils';
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 {modal} 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';
import ModalStore from '~/stores/ModalStore';
interface FormInputs {
email: string;
newPassword: string;
verificationCode: string;
}
type Stage = 'collect' | 'verify';
export const ClaimAccountModal = observer(() => {
const {t} = useLingui();
const form = useForm<FormInputs>({defaultValues: {email: '', newPassword: ''}});
const {t, i18n} = useLingui();
const form = useForm<FormInputs>({
defaultValues: {email: '', newPassword: '', verificationCode: ''},
});
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());
@@ -65,7 +67,6 @@ export const ClaimAccountModal = observer(() => {
}, [resendNewAt, now]);
const startEmailTokenFlow = async (data: FormInputs) => {
setVerificationError(null);
let activeTicket = ticket;
let activeProof = originalProof;
if (!activeTicket || !activeProof) {
@@ -85,31 +86,27 @@ export const ClaimAccountModal = observer(() => {
const result = await UserActionCreators.requestEmailChangeNew(activeTicket!, data.email, activeProof);
setResendNewAt(result.resend_available_at ? new Date(result.resend_available_at) : null);
setVerificationCode('');
form.setValue('verificationCode', '');
form.clearErrors('verificationCode');
setStage('verify');
ToastActionCreators.createToast({type: 'success', children: t`Verification code sent`});
};
const handleVerifyNew = async () => {
const handleVerifyNew = async (data: FormInputs) => {
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);
const {email_token} = await UserActionCreators.verifyEmailChangeNew(ticket, data.verificationCode, originalProof);
await UserActionCreators.update({
email_token,
new_password: form.getValues('newPassword'),
new_password: data.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});
} catch (error: unknown) {
FormUtils.handleError(i18n, form, error as HttpError, 'verificationCode', {
pathMap: {new_password: 'newPassword'},
});
} finally {
setSubmittingAction(false);
}
@@ -118,15 +115,12 @@ export const ClaimAccountModal = observer(() => {
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});
} catch (error: unknown) {
FormUtils.handleError(i18n, form, error as HttpError, 'verificationCode');
} finally {
setSubmittingAction(false);
}
@@ -140,41 +134,43 @@ export const ClaimAccountModal = observer(() => {
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Claim your account`} />
<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.ContentLayout>
<Modal.Description>
<Trans>
Claim your account by adding an email and password. We will send a verification code to confirm your
email before finishing.
</Trans>
</Modal.Description>
<Modal.InputGroup>
<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"
/>
</Modal.InputGroup>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
@@ -186,48 +182,50 @@ export const ClaimAccountModal = observer(() => {
</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>
<Form form={form} onSubmit={handleVerifyNew}>
<Modal.Content>
<Modal.ContentLayout>
<Modal.Description>
<Trans>
Enter the code we sent to your email to verify it. Your password will be set once the code is
confirmed.
</Trans>
</Modal.Description>
<Modal.InputGroup>
<Input
{...form.register('verificationCode')}
autoFocus={true}
label={t`Verification Code`}
placeholder="XXXX-XXXX"
required={true}
error={form.formState.errors.verificationCode?.message}
/>
<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"
/>
</Modal.InputGroup>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Button onClick={ModalActionCreators.pop} variant="secondary" type="button">
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleResendNew} disabled={!canResendNew || submittingAction}>
<Button type="button" onClick={handleResendNew} disabled={!canResendNew || submittingAction}>
{canResendNew ? <Trans>Resend</Trans> : <Trans>Resend ({resendSecondsRemaining}s)</Trans>}
</Button>
<Button onClick={handleVerifyNew} submitting={submittingAction}>
<Button type="submit" submitting={submittingAction}>
<Trans>Claim Account</Trans>
</Button>
</Modal.Footer>
</>
</Form>
)}
</Modal.Root>
);

View File

@@ -38,22 +38,5 @@
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);
font-size: 87.5%;
}

View File

@@ -17,22 +17,23 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Message} from '@app/components/channel/Message';
import styles from '@app/components/modals/ConfirmModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {MessageRecord} from '@app/records/MessageRecord';
import ChannelStore from '@app/stores/ChannelStore';
import type {ModalProps} from '@app/utils/modals/ModalUtils';
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
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';
import React, {useCallback, useMemo, useRef, useState} from 'react';
type ConfirmModalCheckboxProps = {
interface ConfirmModalCheckboxProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
};
}
type ConfirmModalPrimaryVariant = 'primary' | 'danger-primary';
@@ -44,7 +45,7 @@ type ConfirmModalProps =
primaryText: React.ReactNode;
primaryVariant?: ConfirmModalPrimaryVariant;
secondaryText?: React.ReactNode | false;
size?: Modal.ModalProps['size'];
size?: ModalProps['size'];
onPrimary: (checkboxChecked?: boolean) => Promise<void> | void;
onSecondary?: (checkboxChecked?: boolean) => void;
checkboxContent?: React.ReactElement<ConfirmModalCheckboxProps>;
@@ -56,7 +57,7 @@ type ConfirmModalProps =
primaryText?: never;
primaryVariant?: never;
secondaryText?: React.ReactNode | false;
size?: Modal.ModalProps['size'];
size?: ModalProps['size'];
onPrimary?: never;
onSecondary?: (checkboxChecked?: boolean) => void;
checkboxContent?: React.ReactElement<ConfirmModalCheckboxProps>;
@@ -76,10 +77,10 @@ export const ConfirmModal = observer(
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(
const [submitting, setSubmitting] = useState(false);
const [checkboxChecked, setCheckboxChecked] = useState(false);
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
const previewBehaviorOverrides = useMemo(
() => ({
isEditing: false,
isHighlight: false,
@@ -90,12 +91,12 @@ export const ConfirmModal = observer(
[],
);
const messageSnapshot = React.useMemo(() => {
const messageSnapshot = useMemo(() => {
if (!message) return undefined;
return new MessageRecord(message.toJSON());
}, [message?.id]);
const handlePrimaryClick = React.useCallback(async () => {
const handlePrimaryClick = useCallback(async () => {
if (!onPrimary) {
return;
}
@@ -108,7 +109,7 @@ export const ConfirmModal = observer(
}
}, [onPrimary, checkboxChecked]);
const handleSecondaryClick = React.useCallback(() => {
const handleSecondaryClick = useCallback(() => {
if (onSecondary) {
onSecondary(checkboxChecked);
}
@@ -118,27 +119,26 @@ export const ConfirmModal = observer(
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, {
<Modal.Content>
<Modal.ContentLayout>
<Modal.Description>{description}</Modal.Description>
{React.isValidElement(checkboxContent) &&
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>
)}
{messageSnapshot && (
<div className={styles.messagePreview}>
<Message
channel={ChannelStore.getChannel(messageSnapshot.channelId)!}
message={messageSnapshot}
previewContext={MessagePreviewContext.LIST_POPOUT}
removeTopSpacing={true}
behaviorOverrides={previewBehaviorOverrides}
/>
</div>
)}
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer className={styles.footer}>
{secondaryText !== false && (

View File

@@ -17,25 +17,25 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import {FriendSelector} from '@app/components/common/FriendSelector';
import {Input} from '@app/components/form/Input';
import {DuplicateGroupConfirmModal} from '@app/components/modals/DuplicateGroupConfirmModal';
import * as Modal from '@app/components/modals/Modal';
import selectorStyles from '@app/components/modals/shared/SelectorModalStyles.module.css';
import {Button} from '@app/components/uikit/button/Button';
import {type CreateDMModalProps, useCreateDMModalLogic} from '@app/utils/modals/CreateDMModalUtils';
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';
import {useCallback} from 'react';
export const CreateDMModal = observer((props: CreateDMModalProps) => {
const {t} = useLingui();
const modalLogic = useCreateDMModalLogic(props);
const handleCreate = React.useCallback(async () => {
const handleCreate = useCallback(async () => {
const result = await modalLogic.handleCreate();
if (result && result.duplicates.length > 0) {
ModalActionCreators.push(

View File

@@ -17,17 +17,16 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import FavoritesStore from '@app/stores/FavoritesStore';
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;
@@ -56,18 +55,20 @@ export const CreateFavoriteCategoryModal = observer(() => {
<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.ContentLayout>
<Input
{...form.register('name')}
autoComplete="off"
autoFocus={true}
error={form.formState.errors.name?.message}
label={t`Category Name`}
maxLength={100}
minLength={1}
placeholder={t`New Category`}
required={true}
/>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">

View File

@@ -17,18 +17,18 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import {Input, Textarea} from '@app/components/form/Input';
import styles from '@app/components/modals/CreatePackModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import PackStore from '@app/stores/PackStore';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useCallback} 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;
@@ -51,7 +51,7 @@ export const CreatePackModal = observer(({type, onSuccess}: CreatePackModalProps
const title = type === 'emoji' ? t`Create Emoji Pack` : t`Create Sticker Pack`;
const submitHandler = React.useCallback(
const submitHandler = useCallback(
async (data: FormInputs) => {
await PackStore.createPack(type, data.name.trim(), data.description.trim() || null);
onSuccess?.();
@@ -81,7 +81,7 @@ export const CreatePackModal = observer(({type, onSuccess}: CreatePackModalProps
<div className={styles.formFields}>
<Input
id="pack-name"
label={t`Pack name`}
label={t`Pack Name`}
error={form.formState.errors.name?.message}
{...form.register('name', {
required: t`Pack name is required`,

View File

@@ -17,34 +17,42 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as UserSettingsActionCreators from '@app/actions/UserSettingsActionCreators';
import {Input} from '@app/components/form/Input';
import styles from '@app/components/modals/CustomStatusBottomSheet.module.css';
import {ExpressionPickerSheet} from '@app/components/modals/ExpressionPickerSheet';
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
import {Button} from '@app/components/uikit/button/Button';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {
getTimeWindowPresets,
minutesToMs,
TIME_WINDOW_LABEL_MESSAGES,
type TimeWindowKey,
type TimeWindowPreset,
} from '@app/constants/TimeWindowPresets';
import {type CustomStatus, normalizeCustomStatus} from '@app/lib/CustomStatus';
import DeveloperModeStore from '@app/stores/DeveloperModeStore';
import EmojiStore from '@app/stores/EmojiStore';
import PresenceStore from '@app/stores/PresenceStore';
import UserStore from '@app/stores/UserStore';
import type {FlatEmoji} from '@app/types/EmojiTypes';
import {getEmojiURL, shouldUseNativeEmoji} from '@app/utils/EmojiUtils';
import {getSkinTonedSurrogate} from '@app/utils/SkinToneUtils';
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';
import type React from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
const CUSTOM_STATUS_SNAP_POINTS: Array<number> = [0, 1];
const EXPIRY_OPTIONS = [
{id: 'never', label: <Trans>Don&apos;t clear</Trans>, minutes: null},
{id: '30m', label: <Trans>30 minutes</Trans>, minutes: 30},
{id: '1h', label: <Trans>1 hour</Trans>, minutes: 60},
{id: '4h', label: <Trans>4 hours</Trans>, minutes: 4 * 60},
{id: '24h', label: <Trans>24 hours</Trans>, minutes: 24 * 60},
];
interface ExpiryOption {
id: TimeWindowKey;
label: string;
minutes: number | null;
}
interface CustomStatusBottomSheetProps {
isOpen: boolean;
@@ -66,22 +74,21 @@ const buildDraftStatus = (params: {
};
export const CustomStatusBottomSheet = observer(({isOpen, onClose}: CustomStatusBottomSheetProps) => {
const {t} = useLingui();
const {t, i18n} = useLingui();
const currentUser = UserStore.getCurrentUser();
const currentUserId = currentUser?.id ?? null;
const existingCustomStatus = currentUserId ? PresenceStore.getCustomStatus(currentUserId) : null;
const normalizedExisting = normalizeCustomStatus(existingCustomStatus);
const isDeveloper = DeveloperModeStore.isDeveloper;
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 [statusText, setStatusText] = useState('');
const [emojiId, setEmojiId] = useState<string | null>(null);
const [emojiName, setEmojiName] = useState<string | null>(null);
const [selectedExpiry, setSelectedExpiry] = useState<TimeWindowKey>('never');
const [isSaving, setIsSaving] = useState(false);
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const mountedAt = React.useMemo(() => new Date(), []);
React.useEffect(() => {
useEffect(() => {
if (isOpen) {
setStatusText(normalizedExisting?.text ?? '');
setEmojiId(normalizedExisting?.emojiId ?? null);
@@ -90,27 +97,34 @@ export const CustomStatusBottomSheet = observer(({isOpen, onClose}: CustomStatus
}
}, [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 expiryOptions = useMemo(
() =>
getTimeWindowPresets({includeDeveloperOptions: isDeveloper}).map((preset: TimeWindowPreset) => ({
id: preset.key,
label: i18n._(TIME_WINDOW_LABEL_MESSAGES[preset.key]),
minutes: preset.minutes,
})),
[i18n, isDeveloper],
);
const draftStatus = React.useMemo(
() => buildDraftStatus({text: statusText.trim(), emojiId, emojiName, expiresAt: getExpiresAt(selectedExpiry)}),
[statusText, emojiId, emojiName, selectedExpiry, getExpiresAt],
const draftStatus = useMemo(
() => buildDraftStatus({text: statusText.trim(), emojiId, emojiName, expiresAt: null}),
[statusText, emojiId, emojiName],
);
const handleEmojiSelect = React.useCallback((emoji: Emoji) => {
const getExpiresAtForSave = useCallback((): string | null => {
const option = expiryOptions.find((o: ExpiryOption) => o.id === selectedExpiry);
if (!option?.minutes) return null;
return new Date(Date.now() + minutesToMs(option.minutes)!).toISOString();
}, [expiryOptions, selectedExpiry]);
const handleEmojiSelect = useCallback((emoji: FlatEmoji) => {
if (emoji.id) {
setEmojiId(emoji.id);
setEmojiName(emoji.name);
} else {
setEmojiId(null);
setEmojiName(emoji.surrogates ?? emoji.name);
setEmojiName(getSkinTonedSurrogate(emoji));
}
}, []);
@@ -125,7 +139,13 @@ export const CustomStatusBottomSheet = observer(({isOpen, onClose}: CustomStatus
setIsSaving(true);
try {
await UserSettingsActionCreators.update({customStatus: draftStatus});
const statusToSave = buildDraftStatus({
text: statusText.trim(),
emojiId,
emojiName,
expiresAt: getExpiresAtForSave(),
});
await UserSettingsActionCreators.update({customStatus: statusToSave});
onClose();
} finally {
setIsSaving(false);
@@ -217,12 +237,12 @@ export const CustomStatusBottomSheet = observer(({isOpen, onClose}: CustomStatus
<select
className={styles.expirySelect}
value={selectedExpiry}
onChange={(e) => setSelectedExpiry(e.target.value)}
onChange={(e) => setSelectedExpiry(e.target.value as TimeWindowKey)}
disabled={isSaving}
>
{EXPIRY_OPTIONS.map((option) => (
{expiryOptions.map((option: ExpiryOption) => (
<option key={option.id} value={option.id}>
{typeof option.label === 'string' ? option.label : option.id}
{option.label}
</option>
))}
</select>

View File

@@ -17,50 +17,66 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as UserSettingsActionCreators from '@app/actions/UserSettingsActionCreators';
import {Input} from '@app/components/form/Input';
import {Select, type SelectOption} from '@app/components/form/Select';
import styles from '@app/components/modals/CustomStatusModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {ExpressionPickerPopout} from '@app/components/popouts/ExpressionPickerPopout';
import {ProfilePreview} from '@app/components/profile/ProfilePreview';
import {Button} from '@app/components/uikit/button/Button';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Popout} from '@app/components/uikit/popout/Popout';
import {
DEFAULT_TIME_WINDOW_KEY,
getTimeWindowPresets,
TIME_WINDOW_LABEL_MESSAGES,
type TimeWindowKey,
type TimeWindowPreset,
} from '@app/constants/TimeWindowPresets';
import {type CustomStatus, normalizeCustomStatus} from '@app/lib/CustomStatus';
import DeveloperModeStore from '@app/stores/DeveloperModeStore';
import EmojiStore from '@app/stores/EmojiStore';
import UserSettingsStore from '@app/stores/UserSettingsStore';
import UserStore from '@app/stores/UserStore';
import type {FlatEmoji} from '@app/types/EmojiTypes';
import {getEmojiURL, shouldUseNativeEmoji} from '@app/utils/EmojiUtils';
import {getCurrentLocale} from '@app/utils/LocaleUtils';
import {getSkinTonedSurrogate} from '@app/utils/SkinToneUtils';
import {getDaysBetween} from '@fluxer/date_utils/src/DateComparison';
import {getFormattedFullDate, getFormattedTime} from '@fluxer/date_utils/src/DateFormatting';
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';
import type React from 'react';
import {useCallback, useMemo, useRef, useState} from 'react';
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 ExpirationPreset {
key: TimeWindowKey;
label: string;
minutes: number | null;
}
interface ExpirationOption {
key: ExpirationKey;
key: TimeWindowKey;
minutes: number | null;
expiresAt: string | null;
relativeLabel: TimeLabel | null;
label: string;
}
const DEFAULT_EXPIRATION_KEY: ExpirationKey = '24h';
const DEFAULT_EXPIRATION_KEY: TimeWindowKey = DEFAULT_TIME_WINDOW_KEY;
const getPopoutClose = (renderProps: unknown): (() => void) => {
const props = renderProps as {
@@ -91,13 +107,10 @@ const formatLabelWithRelative = (label: string, relative: TimeLabel | null): Rea
};
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);
return getDaysBetween(target, reference);
};
const formatTimeString = (date: Date): string =>
date.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit', hourCycle: 'h23'});
const formatTimeString = (date: Date): string => getFormattedTime(date, getCurrentLocale(), false);
const formatRelativeDayTimeLabel = (i18n: I18n, reference: Date, target: Date): TimeLabel => {
const dayOffset = getDayDifference(reference, target);
@@ -106,11 +119,7 @@ const formatRelativeDayTimeLabel = (i18n: I18n, reference: Date, target: Date):
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',
});
const dayLabel = getFormattedFullDate(target, getCurrentLocale());
return {dayLabel, timeString};
};
@@ -132,28 +141,28 @@ export const CustomStatusModal = observer(() => {
const {i18n} = useLingui();
const initialStatus = normalizeCustomStatus(UserSettingsStore.customStatus);
const currentUser = UserStore.getCurrentUser();
const isDeveloper = DeveloperModeStore.isDeveloper;
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 [statusText, setStatusText] = useState(initialStatus?.text ?? '');
const [emojiId, setEmojiId] = useState<string | null>(initialStatus?.emojiId ?? null);
const [emojiName, setEmojiName] = useState<string | null>(initialStatus?.emojiName ?? null);
const mountedAt = useMemo(() => new Date(), []);
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const emojiButtonRef = 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 expirationPresets = useMemo(
() =>
getTimeWindowPresets({includeDeveloperOptions: isDeveloper}).map((preset: TimeWindowPreset) => ({
key: preset.key,
label: i18n._(TIME_WINDOW_LABEL_MESSAGES[preset.key]),
minutes: preset.minutes,
})),
[i18n, isDeveloper],
);
const expirationOptions = React.useMemo<Array<ExpirationOption>>(
const expirationOptions = useMemo<Array<ExpirationOption>>(
() =>
expirationPresets.map((preset) => {
expirationPresets.map((preset: ExpirationPreset) => {
if (preset.minutes == null) {
return {...preset, expiresAt: null, relativeLabel: null};
}
@@ -169,44 +178,45 @@ export const CustomStatusModal = observer(() => {
[mountedAt, i18n, expirationPresets],
);
const expirationLabelMap = React.useMemo<Record<ExpirationKey, TimeLabel | null>>(() => {
return expirationOptions.reduce<Record<ExpirationKey, TimeLabel | null>>(
const expirationLabelMap = useMemo<Record<TimeWindowKey, TimeLabel | null>>(() => {
return expirationOptions.reduce<Record<TimeWindowKey, TimeLabel | null>>(
(acc, option) => {
acc[option.key] = option.relativeLabel;
return acc;
},
{} as Record<ExpirationKey, TimeLabel | null>,
{} as Record<TimeWindowKey, TimeLabel | null>,
);
}, [expirationOptions]);
const selectOptions = React.useMemo<Array<SelectOption<ExpirationKey>>>(() => {
const selectOptions = useMemo<Array<SelectOption<TimeWindowKey>>>(() => {
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 [selectedExpiration, setSelectedExpiration] = useState<TimeWindowKey>(DEFAULT_EXPIRATION_KEY);
const [isSaving, setIsSaving] = useState(false);
const draftStatus = React.useMemo(
() => buildDraftStatus({text: statusText.trim(), emojiId, emojiName, expiresAt}),
[statusText, emojiId, emojiName, expiresAt],
const draftStatus = useMemo(
() => buildDraftStatus({text: statusText.trim(), emojiId, emojiName, expiresAt: null}),
[statusText, emojiId, emojiName],
);
const handleExpirationChange = (value: ExpirationKey) => {
const option = expirationOptions.find((entry) => entry.key === value);
const getExpiresAtForSave = useCallback((): string | null => {
const option = expirationOptions.find((entry) => entry.key === selectedExpiration);
if (!option?.minutes) return null;
return new Date(Date.now() + option.minutes * MS_PER_MINUTE).toISOString();
}, [expirationOptions, selectedExpiration]);
const handleExpirationChange = (value: TimeWindowKey) => {
setSelectedExpiration(value);
setExpiresAt(option?.expiresAt ?? null);
};
const handleEmojiSelect = React.useCallback((emoji: Emoji) => {
const handleEmojiSelect = useCallback((emoji: FlatEmoji) => {
if (emoji.id) {
setEmojiId(emoji.id);
setEmojiName(emoji.name);
} else {
setEmojiId(null);
setEmojiName(emoji.surrogates ?? emoji.name);
setEmojiName(getSkinTonedSurrogate(emoji));
}
}, []);
@@ -215,7 +225,13 @@ export const CustomStatusModal = observer(() => {
setIsSaving(true);
try {
await UserSettingsActionCreators.update({customStatus: draftStatus});
const statusToSave = buildDraftStatus({
text: statusText.trim(),
emojiId,
emojiName,
expiresAt: getExpiresAtForSave(),
});
await UserSettingsActionCreators.update({customStatus: statusToSave});
ModalActionCreators.pop();
} finally {
setIsSaving(false);

View File

@@ -17,20 +17,29 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as AuthSessionActionCreators from '@app/actions/AuthSessionActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Form} from '@app/components/form/Form';
import {FormErrorText} from '@app/components/form/FormErrorText';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
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>}) => {
interface DeviceRevokeModalProps {
sessionIdHashes: Array<string>;
}
interface FormInputs {
form: string;
}
export const DeviceRevokeModal = observer(({sessionIdHashes}: DeviceRevokeModalProps) => {
const {t} = useLingui();
const form = useForm();
const form = useForm<FormInputs>();
const sessionCount = sessionIdHashes.length;
const title =
@@ -57,8 +66,13 @@ export const DeviceRevokeModal = observer(({sessionIdHashes}: {sessionIdHashes:
<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.ContentLayout>
<Modal.Description>
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.Description>
<FormErrorText message={form.formState.errors.form?.message} />
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">

View File

@@ -17,21 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.message {
margin-bottom: 1rem;
font-size: 0.875rem;
color: var(--text-primary);
.description {
color: var(--text-secondary);
}
.footer {
align-items: center;
.checkboxContainer {
margin-top: 16px;
display: flex;
flex-wrap: wrap;
width: 100%;
align-items: center;
gap: 8px;
}
.footer > * {
flex: 1;
min-width: fit-content;
.checkboxLabel {
font-size: 14px;
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as VoiceSettingsActionCreators from '@app/actions/VoiceSettingsActionCreators';
import styles from '@app/components/modals/DisablePiPConfirmModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import PiPStore from '@app/stores/PiPStore';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useRef, useState} from 'react';
export const DisablePiPConfirmModal = observer(() => {
const {t} = useLingui();
const [rememberPreference, setRememberPreference] = useState(false);
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
const handleConfirm = () => {
if (rememberPreference) {
VoiceSettingsActionCreators.update({disablePictureInPicturePopout: true});
} else {
PiPStore.setSessionDisable(true);
}
PiPStore.close();
ModalActionCreators.pop();
};
const handleCancel = () => {
ModalActionCreators.pop();
};
return (
<Modal.Root size="small" centered initialFocusRef={initialFocusRef}>
<Modal.Header title={t`Hide Picture-in-Picture Popout?`} />
<Modal.Content>
<p className={styles.description}>
<Trans>
If you don't remember this preference, we'll only hide the popout for this session. You can change this any
time in User Settings &gt; Audio &amp; Video.
</Trans>
</p>
<div className={styles.checkboxContainer}>
<Checkbox checked={rememberPreference} onChange={(checked) => setRememberPreference(checked)} size="small">
<span className={styles.checkboxLabel}>
<Trans>Remember this preference</Trans>
</span>
</Checkbox>
</div>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={handleCancel}>
{t`Cancel`}
</Button>
<Button variant="primary" onClick={handleConfirm} ref={initialFocusRef}>
{t`Close popout`}
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -0,0 +1,138 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.hero {
position: relative;
flex: 0 0 auto;
overflow: hidden;
}
.heroBackground {
position: absolute;
inset: 0;
background-color: var(--brand-primary);
pointer-events: none;
}
.heroPattern {
position: absolute;
inset: 0;
background-size: 260px 260px;
background-repeat: repeat;
opacity: 0.06;
filter: invert(1);
background-color: transparent;
pointer-events: none;
}
.heroContent {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-10) var(--spacing-6) var(--spacing-5);
}
.heroTitle {
margin: 0;
font-size: 1.75rem;
font-weight: 700;
color: white;
text-align: center;
}
.heroControls {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-3);
width: 100%;
max-width: 560px;
}
.searchInput {
width: 100%;
}
.categories {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--spacing-2);
}
.categoryChip {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-full);
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition:
background-color 0.1s ease,
color 0.1s ease;
}
@media (hover: hover) and (pointer: fine) {
.categoryChip:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
}
}
.categoryChipActive {
composes: categoryChip;
background: white;
border-color: white;
color: var(--brand-primary);
}
@media (hover: hover) and (pointer: fine) {
.categoryChipActive:hover {
background: rgba(255, 255, 255, 0.9);
color: var(--brand-primary);
}
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--spacing-4);
padding-bottom: var(--spacing-5);
}
.loadingState {
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-10) 0;
}
.loadMore {
display: flex;
justify-content: center;
padding: var(--spacing-4) 0;
}

View File

@@ -0,0 +1,140 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Input} from '@app/components/form/Input';
import styles from '@app/components/modals/DiscoveryModal.module.css';
import {DiscoveryGuildCard} from '@app/components/modals/discovery/DiscoveryGuildCard';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {Spinner} from '@app/components/uikit/Spinner';
import foodPatternUrl from '@app/images/i-like-food.svg';
import DiscoveryStore from '@app/stores/DiscoveryStore';
import {useLingui} from '@lingui/react/macro';
import {MagnifyingGlassIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useRef} from 'react';
export const DiscoveryModal = observer(function DiscoveryModal() {
const {t} = useLingui();
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
void DiscoveryStore.loadCategories();
void DiscoveryStore.search({offset: 0});
return () => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
};
}, []);
const handleSearchChange = useCallback((value: string) => {
if (searchTimerRef.current) {
clearTimeout(searchTimerRef.current);
}
searchTimerRef.current = setTimeout(() => {
void DiscoveryStore.search({query: value, offset: 0});
}, 300);
}, []);
const handleCategoryClick = useCallback((categoryId: number | null) => {
void DiscoveryStore.search({category: categoryId, offset: 0});
}, []);
const handleLoadMore = useCallback(() => {
void DiscoveryStore.search({offset: DiscoveryStore.guilds.length});
}, []);
const handleClose = useCallback(() => {
DiscoveryStore.reset();
ModalActionCreators.pop();
}, []);
const hasMore = DiscoveryStore.guilds.length < DiscoveryStore.total;
return (
<Modal.Root size="fullscreen" onClose={handleClose}>
<Modal.ScreenReaderLabel text={t`Explore Communities`} />
<Modal.InsetCloseButton onClick={handleClose} />
<div className={styles.hero}>
<div className={styles.heroBackground} aria-hidden>
<div className={styles.heroPattern} style={{backgroundImage: `url(${foodPatternUrl})`}} />
</div>
<div className={styles.heroContent}>
<h1 className={styles.heroTitle}>{t`Explore Communities`}</h1>
<div className={styles.heroControls}>
<Input
className={styles.searchInput}
placeholder={t`Search communities...`}
defaultValue={DiscoveryStore.query}
onChange={(e) => handleSearchChange(e.target.value)}
leftIcon={<MagnifyingGlassIcon size={16} weight="bold" />}
/>
<div className={styles.categories}>
<button
type="button"
className={DiscoveryStore.category === null ? styles.categoryChipActive : styles.categoryChip}
onClick={() => handleCategoryClick(null)}
>
{t`All`}
</button>
{DiscoveryStore.categories.map((cat) => (
<button
key={cat.id}
type="button"
className={DiscoveryStore.category === cat.id ? styles.categoryChipActive : styles.categoryChip}
onClick={() => handleCategoryClick(cat.id)}
>
{cat.name}
</button>
))}
</div>
</div>
</div>
</div>
<Modal.Content>
{DiscoveryStore.loading && DiscoveryStore.guilds.length === 0 ? (
<div className={styles.loadingState}>
<Spinner />
</div>
) : (
DiscoveryStore.guilds.length > 0 && (
<>
<div className={styles.grid}>
{DiscoveryStore.guilds.map((guild) => (
<DiscoveryGuildCard key={guild.id} guild={guild} />
))}
</div>
{hasMore && (
<div className={styles.loadMore}>
<Button variant="secondary" onClick={handleLoadMore} disabled={DiscoveryStore.loading}>
{DiscoveryStore.loading ? t`Loading...` : t`Load More`}
</Button>
</div>
)}
</>
)
)}
</Modal.Content>
</Modal.Root>
);
});

View File

@@ -17,20 +17,19 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as NavigationActionCreators from '@app/actions/NavigationActionCreators';
import {GroupDMAvatar} from '@app/components/common/GroupDMAvatar';
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
import styles from '@app/components/modals/DuplicateGroupConfirmModal.module.css';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import * as ChannelUtils from '@app/utils/ChannelUtils';
import {formatShortRelativeTime} from '@fluxer/date_utils/src/DateDuration';
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
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';
import {useCallback, useMemo} from 'react';
interface DuplicateGroupConfirmModalProps {
channels: Array<ChannelRecord>;
@@ -39,12 +38,12 @@ interface DuplicateGroupConfirmModalProps {
export const DuplicateGroupConfirmModal = observer(({channels, onConfirm}: DuplicateGroupConfirmModalProps) => {
const {t} = useLingui();
const handleChannelClick = React.useCallback((channelId: string) => {
const handleChannelClick = useCallback((channelId: string) => {
ModalActionCreators.pop();
RouterUtils.transitionTo(Routes.dmChannel(channelId));
NavigationActionCreators.selectChannel(undefined, channelId);
}, []);
const description = React.useMemo(() => {
const description = useMemo(() => {
return (
<>
<p className={styles.description}>
@@ -56,9 +55,7 @@ export const DuplicateGroupConfirmModal = observer(({channels, onConfirm}: Dupli
<div className={styles.channelList}>
{channels.map((channel) => {
const lastActivitySnowflake = channel.lastMessageId ?? channel.id;
const lastActiveText = DateUtils.getShortRelativeDateString(
SnowflakeUtils.extractTimestamp(lastActivitySnowflake),
);
const lastActiveText = formatShortRelativeTime(SnowflakeUtils.extractTimestamp(lastActivitySnowflake));
const lastActiveLabel = lastActiveText || t`No activity yet`;
return (

View File

@@ -0,0 +1,164 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Form} from '@app/components/form/Form';
import {Textarea} from '@app/components/form/Input';
import styles from '@app/components/modals/AttachmentEditModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {Endpoints} from '@app/Endpoints';
import {useCursorAtEnd} from '@app/hooks/useCursorAtEnd';
import http from '@app/lib/HttpClient';
import {Logger} from '@app/lib/Logger';
import type {MessageRecord} from '@app/records/MessageRecord';
import {MAX_ATTACHMENT_ALT_TEXT_LENGTH} from '@fluxer/constants/src/LimitConstants';
import type {Message} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {useForm} from 'react-hook-form';
const logger = new Logger('EditAltTextModal');
interface FormInputs {
description: string;
}
interface EditAltTextModalProps {
message: MessageRecord;
attachmentId: string;
currentDescription?: string | null;
onClose: () => void;
}
export const EditAltTextModal = observer(
({message, attachmentId, currentDescription, onClose}: EditAltTextModalProps) => {
const {t} = useLingui();
const [isSubmitting, setIsSubmitting] = useState(false);
const textareaRef = useCursorAtEnd<HTMLTextAreaElement>();
const form = useForm<FormInputs>({
defaultValues: {
description: currentDescription ?? '',
},
});
const currentDescriptionValue = form.watch('description');
const currentLength = useMemo(() => currentDescriptionValue.length, [currentDescriptionValue]);
const isOverLimit = currentLength > MAX_ATTACHMENT_ALT_TEXT_LENGTH;
const canSubmit = !isOverLimit && !isSubmitting;
const onSubmit = useCallback(
async (data: FormInputs) => {
if (!canSubmit) return;
setIsSubmitting(true);
logger.debug(`Updating alt text for attachment ${attachmentId} in message ${message.id}`);
try {
const attachmentUpdates = message.attachments.map((att) => {
if (att.id === attachmentId) {
return {
id: att.id,
description: data.description || null,
};
}
return {id: att.id};
});
await http.patch<Message>({
url: Endpoints.CHANNEL_MESSAGE(message.channelId, message.id),
body: {
content: message.content,
attachments: attachmentUpdates,
},
});
logger.debug(`Successfully updated alt text for attachment ${attachmentId}`);
ToastActionCreators.success(t`Alt text updated`);
onClose();
} catch (error) {
logger.error('Failed to update alt text:', error);
ToastActionCreators.error(t`Failed to update alt text`);
} finally {
setIsSubmitting(false);
}
},
[canSubmit, attachmentId, message, onClose, t],
);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
} else if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
event.stopPropagation();
if (canSubmit) {
void form.handleSubmit(onSubmit)();
}
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose, canSubmit, form, onSubmit]);
const handleCancel = useCallback(() => {
onClose();
}, [onClose]);
return (
<Modal.Root size="small" centered onClose={onClose}>
<Form form={form} onSubmit={onSubmit} aria-label={t`Edit alt text form`}>
<Modal.Header title={t`Edit Alt Text`} onClose={onClose} />
<Modal.Content className={styles.content}>
<Textarea
{...form.register('description')}
ref={(el) => {
textareaRef(el);
form.register('description').ref(el);
}}
autoFocus={true}
value={currentDescriptionValue}
label={t`Alt Text Description`}
placeholder={t`Describe this media for screen readers`}
minRows={3}
maxRows={8}
showCharacterCount={true}
maxLength={MAX_ATTACHMENT_ALT_TEXT_LENGTH}
disabled={isSubmitting}
/>
</Modal.Content>
<Modal.Footer>
<Button onClick={handleCancel} variant="secondary" disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" disabled={!canSubmit}>
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Form>
</Modal.Root>
);
},
);

View File

@@ -0,0 +1,118 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ConnectionActionCreators from '@app/actions/ConnectionActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Switch} from '@app/components/form/Switch';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import type {ConnectionRecord} from '@app/records/ConnectionRecord';
import {ConnectionVisibilityFlags} from '@fluxer/constants/src/ConnectionConstants';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useState} from 'react';
interface Props {
connection: ConnectionRecord;
}
export const EditConnectionModal = observer(({connection}: Props) => {
const {t, i18n} = useLingui();
const [visibilityFlags, setVisibilityFlags] = useState(connection.visibilityFlags);
const [submitting, setSubmitting] = useState(false);
const hasFlag = useCallback((flag: number) => (visibilityFlags & flag) === flag, [visibilityFlags]);
const handleToggle = useCallback(
(flag: number, value: boolean) => {
setVisibilityFlags((prev) => {
let next = prev;
if (value) {
next |= flag;
if (flag === ConnectionVisibilityFlags.EVERYONE) {
next |= ConnectionVisibilityFlags.FRIENDS;
next |= ConnectionVisibilityFlags.MUTUAL_GUILDS;
}
} else {
next &= ~flag;
if (flag === ConnectionVisibilityFlags.FRIENDS || flag === ConnectionVisibilityFlags.MUTUAL_GUILDS) {
next &= ~ConnectionVisibilityFlags.EVERYONE;
}
}
return next;
});
},
[setVisibilityFlags],
);
const handleSave = useCallback(async () => {
setSubmitting(true);
try {
await ConnectionActionCreators.updateConnection(i18n, connection.type, connection.id, {
visibility_flags: visibilityFlags,
});
ModalActionCreators.pop();
} finally {
setSubmitting(false);
}
}, [i18n, connection.type, connection.id, visibilityFlags]);
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Edit Connection`} />
<Modal.Content>
<Modal.ContentLayout>
<Modal.Description>
<Trans>Choose who can see this connection on your profile.</Trans>
</Modal.Description>
<Switch
label={<Trans>Everyone</Trans>}
description={<Trans>Allow anyone to see this connection on your profile</Trans>}
value={hasFlag(ConnectionVisibilityFlags.EVERYONE)}
onChange={(value) => handleToggle(ConnectionVisibilityFlags.EVERYONE, value)}
/>
<Switch
label={<Trans>Friends</Trans>}
description={<Trans>Allow your friends to see this connection</Trans>}
value={hasFlag(ConnectionVisibilityFlags.FRIENDS)}
onChange={(value) => handleToggle(ConnectionVisibilityFlags.FRIENDS, value)}
/>
<Switch
label={<Trans>Community Members</Trans>}
description={<Trans>Allow members from communities you're in to see this connection</Trans>}
value={hasFlag(ConnectionVisibilityFlags.MUTUAL_GUILDS)}
onChange={(value) => handleToggle(ConnectionVisibilityFlags.MUTUAL_GUILDS, value)}
/>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleSave} submitting={submitting}>
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -17,19 +17,19 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as FavoriteMemeActionCreators from '@app/actions/FavoriteMemeActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import styles from '@app/components/modals/EditFavoriteMemeModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {MemeFormFields} from '@app/components/modals/meme_form/MemeFormFields';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import type {FavoriteMemeRecord} from '@app/records/FavoriteMemeRecord';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useCallback} 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;
@@ -51,7 +51,7 @@ export const EditFavoriteMemeModal = observer(function EditFavoriteMemeModal({me
},
});
const onSubmit = React.useCallback(
const onSubmit = useCallback(
async (data: FormInputs) => {
await FavoriteMemeActionCreators.updateFavoriteMeme(i18n, {
memeId: meme.id,

View File

@@ -17,26 +17,28 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ChannelActionCreators from '@app/actions/ChannelActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import {AssetCropModal, AssetType} from '@app/components/modals/AssetCropModal';
import styles from '@app/components/modals/EditGroupBottomSheet.module.css';
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
import {Button} from '@app/components/uikit/button/Button';
import {Scroller} from '@app/components/uikit/Scroller';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import ChannelStore from '@app/stores/ChannelStore';
import {isAnimatedFile} from '@app/utils/AnimatedImageUtils';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import {openFilePicker} from '@app/utils/FilePickerUtils';
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 type React from 'react';
import {useCallback, useMemo, useState} 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;
@@ -52,13 +54,13 @@ interface EditGroupBottomSheetProps {
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 [hasClearedIcon, setHasClearedIcon] = useState(false);
const [previewIconUrl, setPreviewIconUrl] = useState<string | null>(null);
const form = useForm<FormInputs>({
defaultValues: React.useMemo(() => ({name: channel?.name || ''}), [channel]),
defaultValues: useMemo(() => ({name: channel?.name || ''}), [channel]),
});
const handleIconUpload = React.useCallback(
const handleIconUpload = useCallback(
async (file: File | null) => {
try {
if (!file) return;
@@ -71,7 +73,9 @@ export const EditGroupBottomSheet: React.FC<EditGroupBottomSheetProps> = observe
return;
}
if (file.type === 'image/gif') {
const animated = await isAnimatedFile(file);
if (animated) {
ToastActionCreators.createToast({
type: 'error',
children: t`Animated icons are not supported. Please use JPEG, PNG, or WebP.`,
@@ -123,18 +127,18 @@ export const EditGroupBottomSheet: React.FC<EditGroupBottomSheetProps> = observe
[form],
);
const handleIconUploadClick = React.useCallback(async () => {
const [file] = await openFilePicker({accept: 'image/jpeg,image/png,image/webp,image/gif'});
const handleIconUploadClick = useCallback(async () => {
const [file] = await openFilePicker({accept: 'image/jpeg,image/png,image/webp,image/gif,image/avif'});
await handleIconUpload(file ?? null);
}, [handleIconUpload]);
const handleClearIcon = React.useCallback(() => {
const handleClearIcon = useCallback(() => {
form.setValue('icon', null);
setPreviewIconUrl(null);
setHasClearedIcon(true);
}, [form]);
const onSubmit = React.useCallback(
const onSubmit = useCallback(
async (data: FormInputs) => {
const newChannel = await ChannelActionCreators.update(channelId, {
icon: data.icon,
@@ -159,7 +163,7 @@ export const EditGroupBottomSheet: React.FC<EditGroupBottomSheetProps> = observe
const iconPresentable = hasClearedIcon
? null
: (previewIconUrl ?? AvatarUtils.getChannelIconURL({id: channel.id, icon: channel.icon}, 256));
: (previewIconUrl ?? AvatarUtils.getChannelIconURL({id: channel.id, icon: channel.icon}));
return (
<BottomSheet

View File

@@ -17,27 +17,27 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ChannelActionCreators from '@app/actions/ChannelActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import {AssetCropModal, AssetType} from '@app/components/modals/AssetCropModal';
import styles from '@app/components/modals/EditGroupModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import ChannelStore from '@app/stores/ChannelStore';
import {isAnimatedFile} from '@app/utils/AnimatedImageUtils';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import * as ChannelUtils from '@app/utils/ChannelUtils';
import {openFilePicker} from '@app/utils/FilePickerUtils';
import {Trans, useLingui} from '@lingui/react/macro';
import {PlusIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useCallback, useMemo, useState} 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;
@@ -47,13 +47,13 @@ interface FormInputs {
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 [hasClearedIcon, setHasClearedIcon] = useState(false);
const [previewIconUrl, setPreviewIconUrl] = useState<string | null>(null);
const form = useForm<FormInputs>({
defaultValues: React.useMemo(() => ({name: channel?.name || ''}), [channel]),
defaultValues: useMemo(() => ({name: channel?.name || ''}), [channel]),
});
const handleIconUpload = React.useCallback(
const handleIconUpload = useCallback(
async (file: File | null) => {
try {
if (!file) return;
@@ -66,7 +66,9 @@ export const EditGroupModal = observer(({channelId}: {channelId: string}) => {
return;
}
if (file.type === 'image/gif') {
const animated = await isAnimatedFile(file);
if (animated) {
ToastActionCreators.createToast({
type: 'error',
children: t`Animated icons are not supported. Please use JPEG, PNG, or WebP.`,
@@ -118,18 +120,18 @@ export const EditGroupModal = observer(({channelId}: {channelId: string}) => {
[form],
);
const handleIconUploadClick = React.useCallback(async () => {
const [file] = await openFilePicker({accept: 'image/jpeg,image/png,image/webp,image/gif'});
const handleIconUploadClick = useCallback(async () => {
const [file] = await openFilePicker({accept: 'image/jpeg,image/png,image/webp,image/gif,image/avif'});
await handleIconUpload(file ?? null);
}, [handleIconUpload]);
const handleClearIcon = React.useCallback(() => {
const handleClearIcon = useCallback(() => {
form.setValue('icon', null);
setPreviewIconUrl(null);
setHasClearedIcon(true);
}, [form]);
const onSubmit = React.useCallback(
const onSubmit = useCallback(
async (data: FormInputs) => {
const newChannel = await ChannelActionCreators.update(channelId, {
icon: data.icon,
@@ -154,72 +156,74 @@ export const EditGroupModal = observer(({channelId}: {channelId: string}) => {
const iconPresentable = hasClearedIcon
? null
: (previewIconUrl ?? AvatarUtils.getChannelIconURL({id: channel.id, icon: channel.icon}, 256));
: (previewIconUrl ?? AvatarUtils.getChannelIconURL({id: channel.id, icon: channel.icon}));
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>
<Modal.Content>
<Modal.ContentLayout>
<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>
)}
</div>
<div className={styles.iconHint}>
<Trans>JPEG, PNG, WebP. Max 10MB. Recommended: 512×512px</Trans>
{(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>
{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}
/>
<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.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button type="submit" submitting={isSubmitting}>

View File

@@ -17,21 +17,25 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as GuildStickerActionCreators from '@app/actions/GuildStickerActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import styles from '@app/components/modals/EditGuildStickerModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {StickerFormFields} from '@app/components/modals/sticker_form/StickerFormFields';
import {StickerPreview} from '@app/components/modals/sticker_form/StickerPreview';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import {useStickerAnimation} from '@app/hooks/useStickerAnimation';
import {Logger} from '@app/lib/Logger';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import type {GuildStickerWithUser} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useCallback} 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';
const logger = new Logger('EditGuildStickerModal');
interface EditGuildStickerModalProps {
guildId: string;
@@ -51,6 +55,7 @@ export const EditGuildStickerModal = observer(function EditGuildStickerModal({
onUpdate,
}: EditGuildStickerModalProps) {
const {t} = useLingui();
const {shouldAnimate} = useStickerAnimation();
const form = useForm<FormInputs>({
defaultValues: {
name: sticker.name,
@@ -59,7 +64,7 @@ export const EditGuildStickerModal = observer(function EditGuildStickerModal({
},
});
const onSubmit = React.useCallback(
const onSubmit = useCallback(
async (data: FormInputs) => {
try {
await GuildStickerActionCreators.update(guildId, sticker.id, {
@@ -70,10 +75,10 @@ export const EditGuildStickerModal = observer(function EditGuildStickerModal({
onUpdate();
ModalActionCreators.pop();
} catch (error: any) {
console.error('Failed to update sticker:', error);
} catch (error: unknown) {
logger.error('Failed to update sticker:', error);
form.setError('name', {
message: error.message || t`Failed to update sticker`,
message: error instanceof Error ? error.message : t`Failed to update sticker`,
});
}
},
@@ -88,7 +93,7 @@ export const EditGuildStickerModal = observer(function EditGuildStickerModal({
const stickerUrl = AvatarUtils.getStickerURL({
id: sticker.id,
animated: isStickerAnimated(sticker),
animated: shouldAnimate,
size: 320,
});

View File

@@ -17,18 +17,19 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {Form} from '@app/components/form/Form';
import {Input, Textarea} from '@app/components/form/Input';
import styles from '@app/components/modals/CreatePackModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import PackStore from '@app/stores/PackStore';
import type {PackType} from '@fluxer/schema/src/domains/pack/PackSchemas';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useCallback} 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;
@@ -37,7 +38,7 @@ interface FormInputs {
interface EditPackModalProps {
packId: string;
type: 'emoji' | 'sticker';
type: PackType;
name: string;
description: string | null;
onSuccess?: () => void;
@@ -54,7 +55,7 @@ export const EditPackModal = observer(({packId, type, name, description, onSucce
const title = type === 'emoji' ? t`Edit Emoji Pack` : t`Edit Sticker Pack`;
const submitHandler = React.useCallback(
const submitHandler = useCallback(
async (data: FormInputs) => {
await PackStore.updatePack(packId, {name: data.name.trim(), description: data.description.trim() || null});
onSuccess?.();
@@ -77,7 +78,7 @@ export const EditPackModal = observer(({packId, type, name, description, onSucce
<div className={styles.formFields}>
<Input
id="pack-name"
label={t`Pack name`}
label={t`Pack Name`}
error={form.formState.errors.name?.message}
{...form.register('name', {
required: t`Pack name is required`,

View File

@@ -17,11 +17,6 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.inputContainer {
display: flex;
flex-direction: column;
}
.footer {
align-items: center;
display: flex;

View File

@@ -17,21 +17,20 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import * as UserActionCreators from '@app/actions/UserActionCreators';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import styles from '@app/components/modals/EmailChangeModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import UserStore from '@app/stores/UserStore';
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';
@@ -181,17 +180,19 @@ export const EmailChangeModal = observer(() => {
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Change your email`} />
<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.ContentLayout>
<Modal.Description>
{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>
)}
</Modal.Description>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
@@ -206,21 +207,23 @@ export const EmailChangeModal = observer(() => {
{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.ContentLayout>
<Modal.Description>
<Trans>Enter the code sent to your current email.</Trans>
</Modal.Description>
<Modal.InputGroup>
<Input
autoFocus={true}
value={originalCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setOriginalCode(event.target.value)}
label={t`Verification Code`}
placeholder="XXXX-XXXX"
required={true}
error={originalCodeError ?? undefined}
/>
</Modal.InputGroup>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
@@ -238,24 +241,26 @@ export const EmailChangeModal = observer(() => {
{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.ContentLayout>
<Modal.Description>
<Trans>Enter the new email you want to use. We'll send a code there next.</Trans>
</Modal.Description>
<Modal.InputGroup>
<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"
/>
</Modal.InputGroup>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">
@@ -270,21 +275,23 @@ export const EmailChangeModal = observer(() => {
{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.ContentLayout>
<Modal.Description>
<Trans>Enter the code we emailed to your new address.</Trans>
</Modal.Description>
<Modal.InputGroup>
<Input
autoFocus={true}
value={newCode}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => setNewCode(event.target.value)}
label={t`Verification Code`}
placeholder="XXXX-XXXX"
required={true}
error={newCodeError ?? undefined}
/>
</Modal.InputGroup>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer className={styles.footer}>
<Button onClick={ModalActionCreators.pop} variant="secondary">

View File

@@ -18,10 +18,7 @@
*/
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 32px;
}

View File

@@ -17,30 +17,31 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Plural, Trans} from '@lingui/react/macro';
import styles from '@app/components/modals/EmojiUploadModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Spinner} from '@app/components/uikit/Spinner';
import {Trans, useLingui} 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}) => {
const {t} = useLingui();
const emojiText = count === 1 ? t`${count} emoji` : t`${count} emojis`;
return (
<Modal.Root size="small" centered>
<Modal.Header title={<Trans>Uploading Emojis</Trans>} hideCloseButton />
<Modal.Content>
<div className={styles.container}>
<Modal.ContentLayout className={styles.container}>
<Spinner />
<p className={styles.message}>
<Trans>
Uploading <Plural value={count} one="# emoji" other="# emojis" />. This may take a little while.
</Trans>
<Trans>Uploading {emojiText}. This may take a little while.</Trans>
</p>
</div>
</Modal.ContentLayout>
</Modal.Content>
</Modal.Root>
);

View File

@@ -17,35 +17,39 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ExpressionPickerActionCreators from '@app/actions/ExpressionPickerActionCreators';
import {MobileEmojiPicker} from '@app/components/channel/MobileEmojiPicker';
import {MobileMemesPicker} from '@app/components/channel/MobileMemesPicker';
import {MobileStickersPicker} from '@app/components/channel/MobileStickersPicker';
import {GifPicker} from '@app/components/channel/pickers/gif/GifPicker';
import styles from '@app/components/modals/ExpressionPickerSheet.module.css';
import {
ExpressionPickerHeaderContext,
type ExpressionPickerTabType,
} from '@app/components/popouts/ExpressionPickerPopout';
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
import {type SegmentedTab, SegmentedTabs} from '@app/components/uikit/segmented_tabs/SegmentedTabs';
import * as StickerSendUtils from '@app/lib/StickerSendUtils';
import type {GuildStickerRecord} from '@app/records/GuildStickerRecord';
import ExpressionPickerStore from '@app/stores/ExpressionPickerStore';
import type {FlatEmoji} from '@app/types/EmojiTypes';
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';
import type React from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
interface ExpressionPickerCategoryDescriptor {
type: ExpressionPickerTabType;
label: MessageDescriptor;
renderComponent: (props: {
channelId?: string;
onSelect: (emoji: Emoji, shiftKey?: boolean) => void;
onSelect: (emoji: FlatEmoji, shiftKey?: boolean) => void;
onClose: () => void;
searchTerm?: string;
setSearchTerm?: (term: string) => void;
setHoveredEmoji?: (emoji: Emoji | null) => void;
setHoveredEmoji?: (emoji: FlatEmoji | null) => void;
}) => React.ReactNode;
}
@@ -108,7 +112,7 @@ interface ExpressionPickerSheetProps {
isOpen: boolean;
onClose: () => void;
channelId?: string;
onEmojiSelect: (emoji: Emoji, shiftKey?: boolean) => void;
onEmojiSelect: (emoji: FlatEmoji, shiftKey?: boolean) => void;
visibleTabs?: Array<ExpressionPickerTabType>;
selectedTab?: ExpressionPickerTabType;
onTabChange?: (tab: ExpressionPickerTabType) => void;
@@ -127,7 +131,7 @@ export const ExpressionPickerSheet = observer(
zIndex,
}: ExpressionPickerSheetProps) => {
const {t} = useLingui();
const categories = React.useMemo(
const categories = useMemo(
() =>
EXPRESSION_PICKER_CATEGORY_DESCRIPTORS.filter((category) => visibleTabs.includes(category.type)).map(
(category) => ({
@@ -139,17 +143,17 @@ export const ExpressionPickerSheet = observer(
[visibleTabs, t],
);
const [internalSelectedTab, setInternalSelectedTab] = React.useState<ExpressionPickerTabType>(
const [internalSelectedTab, setInternalSelectedTab] = useState<ExpressionPickerTabType>(
() => categories[0]?.type || 'emojis',
);
const [emojiSearchTerm, setEmojiSearchTerm] = React.useState('');
const [_hoveredEmoji, setHoveredEmoji] = React.useState<Emoji | null>(null);
const [emojiSearchTerm, setEmojiSearchTerm] = useState('');
const [_hoveredEmoji, setHoveredEmoji] = useState<FlatEmoji | null>(null);
const storeSelectedTab = ExpressionPickerStore.selectedTab;
const selectedTab = storeSelectedTab ?? controlledSelectedTab ?? internalSelectedTab;
const setSelectedTab = React.useCallback(
const setSelectedTab = useCallback(
(tab: ExpressionPickerTabType) => {
if (onTabChange) {
onTabChange(tab);
@@ -168,24 +172,16 @@ export const ExpressionPickerSheet = observer(
const selectedCategory = categories.find((category) => category.type === selectedTab) || categories[0];
React.useEffect(() => {
useEffect(() => {
if (!isOpen) return;
if (channelId && ExpressionPickerStore.channelId !== channelId) {
ExpressionPickerActionCreators.open(channelId, selectedTab);
}
}, [isOpen, 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) => {
const handleEmojiSelect = useCallback(
(emoji: FlatEmoji, shiftKey?: boolean) => {
onEmojiSelect(emoji, shiftKey);
if (!shiftKey) {
onClose();
@@ -196,18 +192,18 @@ export const ExpressionPickerSheet = observer(
const showTabs = categories.length > 1;
const segmentedTabs: Array<SegmentedTab<ExpressionPickerTabType>> = React.useMemo(
const segmentedTabs: Array<SegmentedTab<ExpressionPickerTabType>> = useMemo(
() => categories.map((category) => ({id: category.type, label: category.label})),
[categories],
);
const [headerPortalElement, setHeaderPortalElement] = React.useState<HTMLDivElement | null>(null);
const [headerPortalElement, setHeaderPortalElement] = useState<HTMLDivElement | null>(null);
const headerPortalCallback = React.useCallback((node: HTMLDivElement | null) => {
const headerPortalCallback = useCallback((node: HTMLDivElement | null) => {
setHeaderPortalElement(node);
}, []);
const headerContextValue = React.useMemo(() => ({headerPortalElement}), [headerPortalElement]);
const headerContextValue = useMemo(() => ({headerPortalElement}), [headerPortalElement]);
const headerContent = (
<>

View File

@@ -17,36 +17,44 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as TrustedDomainActionCreators from '@app/actions/TrustedDomainActionCreators';
import styles from '@app/components/modals/ExternalLinkWarningModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import {openExternalUrl} from '@app/utils/NativeUtils';
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';
import {useCallback, useMemo, useRef, useState} from 'react';
export const ExternalLinkWarningModal = observer(({url, hostname}: {url: string; hostname: string}) => {
export const ExternalLinkWarningModal = observer(({url}: {url: string}) => {
const {t} = useLingui();
const [trustDomain, setTrustDomain] = React.useState(false);
const initialFocusRef = React.useRef<HTMLButtonElement | null>(null);
const [trustDomain, setTrustDomain] = useState(false);
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
const handleContinue = React.useCallback(() => {
const hostname = useMemo(() => {
try {
return new URL(url).hostname;
} catch {
return url;
}
}, [url]);
const handleContinue = useCallback(async () => {
if (trustDomain) {
TrustedDomainActionCreators.addTrustedDomain(hostname);
await TrustedDomainActionCreators.addTrustedDomain(hostname);
}
void openExternalUrl(url);
ModalActionCreators.pop();
}, [url, hostname, trustDomain]);
const handleCancel = React.useCallback(() => {
const handleCancel = useCallback(() => {
ModalActionCreators.pop();
}, []);
const handleTrustChange = React.useCallback((checked: boolean) => {
const handleTrustChange = useCallback((checked: boolean) => {
setTrustDomain(checked);
}, []);

View File

@@ -34,10 +34,6 @@
gap: 0.25rem;
}
.description {
margin-bottom: var(--spacing-4);
}
.fluxerTagLabel {
margin-bottom: 0.25rem;
font-size: 0.875rem;

View File

@@ -17,29 +17,30 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as PremiumModalActionCreators from '@app/actions/PremiumModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import * as UserActionCreators from '@app/actions/UserActionCreators';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import {UsernameValidationRules} from '@app/components/form/UsernameValidationRules';
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
import styles from '@app/components/modals/FluxerTagChangeModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {PlutoniumUpsell} from '@app/components/uikit/plutonium_upsell/PlutoniumUpsell';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import UserStore from '@app/stores/UserStore';
import {LimitResolver} from '@app/utils/limits/LimitResolverAdapter';
import {isLimitToggleEnabled} from '@app/utils/limits/LimitUtils';
import {shouldShowPremiumFeatures} from '@app/utils/PremiumUtils';
import {Trans, useLingui} from '@lingui/react/macro';
import {clsx} from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import {useCallback, useEffect, useRef} 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;
@@ -49,11 +50,15 @@ interface FormInputs {
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 usernameRef = useRef<HTMLInputElement>(null);
const hasCustomDiscriminator = isLimitToggleEnabled(
{feature_custom_discriminator: LimitResolver.resolve({key: 'feature_custom_discriminator', fallback: 0})},
'feature_custom_discriminator',
);
const showPremium = shouldShowPremiumFeatures();
const skipAvailabilityCheckRef = useRef(false);
const resubmitHandlerRef = useRef<(() => Promise<void>) | null>(null);
const confirmedRerollRef = useRef(false);
const form = useForm<FormInputs>({
defaultValues: {
@@ -62,7 +67,7 @@ export const FluxerTagChangeModal = observer(() => {
},
});
React.useEffect(() => {
useEffect(() => {
const subscription = form.watch((_, info) => {
if (info?.name === 'username') {
confirmedRerollRef.current = false;
@@ -74,7 +79,7 @@ export const FluxerTagChangeModal = observer(() => {
};
}, [form]);
const onSubmit = React.useCallback(
const onSubmit = useCallback(
async (data: FormInputs) => {
const usernameValue = data.username.trim();
const normalizedDiscriminator = data.discriminator;
@@ -82,7 +87,7 @@ export const FluxerTagChangeModal = observer(() => {
const currentDiscriminator = user.discriminator;
const isSameTag = usernameValue === currentUsername && normalizedDiscriminator === currentDiscriminator;
if (!hasPremium && !skipAvailabilityCheckRef.current && !confirmedRerollRef.current) {
if (!hasCustomDiscriminator && !skipAvailabilityCheckRef.current && !confirmedRerollRef.current) {
const tagTaken = await UserActionCreators.checkFluxerTagAvailability({
username: usernameValue,
discriminator: normalizedDiscriminator,
@@ -138,7 +143,7 @@ export const FluxerTagChangeModal = observer(() => {
ModalActionCreators.pop();
ToastActionCreators.createToast({type: 'success', children: t`FluxerTag updated`});
},
[hasPremium, user.username, user.discriminator],
[hasCustomDiscriminator, user.username, user.discriminator],
);
const {handleSubmit, isSubmitting} = useFormSubmit({
@@ -151,102 +156,124 @@ export const FluxerTagChangeModal = observer(() => {
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}>
<Modal.Header title={t`Change Your FluxerTag`} />
<Modal.Content>
<Modal.ContentLayout>
<Modal.Description>
{hasCustomDiscriminator ? (
<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>
)}
</Modal.Description>
<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
{...form.register('discriminator')}
aria-label={t`4-digit tag`}
maxLength={4}
placeholder="0000"
{...field}
ref={usernameRef}
autoComplete="username"
aria-label={t`Username`}
placeholder={t`Marty_McFly`}
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>
<span className={styles.separator}>#</span>
<div className={styles.discriminatorInput}>
{!hasCustomDiscriminator ? (
showPremium ? (
<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>
) : (
<Tooltip text={t`Custom discriminators are not available on this instance`}>
<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);
}}
/>
</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>
{!hasCustomDiscriminator && (
<PlutoniumUpsell className={styles.premiumUpsell}>
<Trans>Customize your 4-digit tag or keep it when changing your username</Trans>
</PlutoniumUpsell>
)}
</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.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">

View File

@@ -17,68 +17,79 @@
* 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 * as MessageActionCreators from '@app/actions/MessageActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as NavigationActionCreators from '@app/actions/NavigationActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {MessageForwardFailedModal} from '@app/components/alerts/MessageForwardFailedModal';
import {Autocomplete} from '@app/components/channel/Autocomplete';
import {MessageCharacterCounter} from '@app/components/channel/MessageCharacterCounter';
import {GroupDMAvatar} from '@app/components/common/GroupDMAvatar';
import {Input} from '@app/components/form/Input';
import {ExpressionPickerSheet} from '@app/components/modals/ExpressionPickerSheet';
import modalStyles from '@app/components/modals/ForwardModal.module.css';
import * as Modal from '@app/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';
} from '@app/components/modals/shared/ForwardChannelSelection';
import selectorStyles from '@app/components/modals/shared/SelectorModalStyles.module.css';
import {ExpressionPickerPopout} from '@app/components/popouts/ExpressionPickerPopout';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Popout} from '@app/components/uikit/popout/Popout';
import {Scroller} from '@app/components/uikit/Scroller';
import {StatusAwareAvatar} from '@app/components/uikit/StatusAwareAvatar';
import {useTextareaAutocomplete} from '@app/hooks/useTextareaAutocomplete';
import {useTextareaEmojiPicker} from '@app/hooks/useTextareaEmojiPicker';
import {useTextareaPaste} from '@app/hooks/useTextareaPaste';
import {useTextareaSegments} from '@app/hooks/useTextareaSegments';
import {Logger} from '@app/lib/Logger';
import {TextareaAutosize} from '@app/lib/TextareaAutosize';
import type {ChannelRecord} from '@app/records/ChannelRecord';
import type {MessageRecord} from '@app/records/MessageRecord';
import ChannelStore from '@app/stores/ChannelStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import UserStore from '@app/stores/UserStore';
import {Limits} from '@app/utils/limits/UserLimits';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {MAX_MESSAGE_LENGTH_PREMIUM} from '@fluxer/constants/src/LimitConstants';
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 {useCallback, useMemo, useRef, useState} from 'react';
const logger = new Logger('ForwardModal');
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 [optionalMessage, setOptionalMessage] = useState('');
const [isForwarding, setIsForwarding] = useState(false);
const [expressionPickerOpen, setExpressionPickerOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const currentUser = UserStore.currentUser!;
const premiumMaxLength = Limits.getPremiumValue('max_message_length', MAX_MESSAGE_LENGTH_PREMIUM);
const mobileLayout = MobileLayoutStore;
const {segmentManagerRef, previousValueRef, displayToActual, insertSegment, handleTextChange} = useTextareaSegments();
const {segmentManagerRef, previousValueRef, displayToActual, handleTextChange} = useTextareaSegments();
const handleOptionalMessageExceedsLimit = useCallback(() => {
ToastActionCreators.error(t`Message is too long`);
}, [t]);
const {handleEmojiSelect} = useTextareaEmojiPicker({
setValue: setOptionalMessage,
textareaRef,
insertSegment,
segmentManagerRef,
previousValueRef,
maxActualLength: currentUser.maxMessageLength,
onExceedMaxLength: handleOptionalMessageExceedsLimit,
});
const channel = ChannelStore.getChannel(message.channelId)!;
@@ -98,6 +109,8 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
textareaRef,
segmentManagerRef,
previousValueRef,
maxActualLength: currentUser.maxMessageLength,
onExceedMaxLength: handleOptionalMessageExceedsLimit,
});
useTextareaPaste({
@@ -106,14 +119,21 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
segmentManagerRef,
setValue: setOptionalMessage,
previousValueRef,
maxMessageLength: currentUser.maxMessageLength,
onPasteExceedsLimit: () => handleOptionalMessageExceedsLimit(),
});
const actualOptionalMessage = useMemo(() => displayToActual(optionalMessage), [displayToActual, optionalMessage]);
const optionalMessageDisplayMaxLength = useMemo(() => {
return Math.max(0, optionalMessage.length + (currentUser.maxMessageLength - actualOptionalMessage.length));
}, [actualOptionalMessage.length, currentUser.maxMessageLength, optionalMessage.length]);
const handleForward = async () => {
if (selectedChannelIds.size === 0 || isForwarding) return;
setIsForwarding(true);
try {
const actualMessage = optionalMessage.trim() ? displayToActual(optionalMessage) : undefined;
const actualMessage = optionalMessage.trim() ? actualOptionalMessage : undefined;
await MessageActionCreators.forward(
Array.from(selectedChannelIds),
{
@@ -133,22 +153,18 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
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));
}
NavigationActionCreators.selectChannel(forwardedChannel.guildId ?? undefined, forwardedChannelId);
}
}
} catch (error) {
console.error('Failed to forward message:', error);
logger.error('Failed to forward message:', error);
ModalActionCreators.push(modal(() => <MessageForwardFailedModal />));
} finally {
setIsForwarding(false);
}
};
const getChannelIcon = (ch: any) => {
const getChannelIcon = (ch: ChannelRecord) => {
const iconSize = 32;
if (ch.type === ChannelTypes.DM_PERSONAL_NOTES) {
@@ -194,19 +210,14 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
</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}
>
<Scroller className={selectorStyles.scroller} key="forward-modal-channel-list-scroller" fade={false}>
{filteredChannels.length === 0 ? (
<div className={selectorStyles.emptyState}>
<Trans>No channels found</Trans>
</div>
) : (
<div className={selectorStyles.itemList}>
{filteredChannels.map((ch) => {
{filteredChannels.map((ch: ChannelRecord | null) => {
if (!ch) return null;
const isSelected = selectedChannelIds.has(ch.id);
const isDisabled = isChannelDisabled(ch.id);
@@ -269,7 +280,7 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
<div ref={containerRef} className={modalStyles.messageInputContainer}>
<TextareaAutosize
className={clsx(modalStyles.messageInput, modalStyles.messageInputBase)}
maxLength={currentUser.maxMessageLength}
maxLength={optionalMessageDisplayMaxLength}
ref={textareaRef}
value={optionalMessage}
onChange={(e) => {
@@ -298,9 +309,10 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
placeholder={t`Add a comment (optional)`}
/>
<MessageCharacterCounter
currentLength={optionalMessage.length}
currentLength={actualOptionalMessage.length}
maxLength={currentUser.maxMessageLength}
isPremium={currentUser.isPremium()}
canUpgrade={currentUser.maxMessageLength < premiumMaxLength}
premiumMaxLength={premiumMaxLength}
/>
<div className={modalStyles.messageInputActions}>
{mobileLayout.enabled ? (
@@ -328,9 +340,11 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
render={({onClose}) => (
<ExpressionPickerPopout
channelId={message.channelId}
onEmojiSelect={(emoji) => {
handleEmojiSelect(emoji);
onClose();
onEmojiSelect={(emoji, shiftKey) => {
const didInsert = handleEmojiSelect(emoji, shiftKey);
if (didInsert && !shiftKey) {
onClose();
}
}}
onClose={onClose}
visibleTabs={['emojis']}
@@ -372,7 +386,13 @@ export const ForwardModal = observer(({message}: {message: MessageRecord}) => {
isOpen={expressionPickerOpen}
onClose={() => setExpressionPickerOpen(false)}
channelId={message.channelId}
onEmojiSelect={handleEmojiSelect}
onEmojiSelect={(emoji, shiftKey) => {
const didInsert = handleEmojiSelect(emoji, shiftKey);
if (didInsert && !shiftKey) {
setExpressionPickerOpen(false);
}
return didInsert;
}}
visibleTabs={['emojis']}
selectedTab="emojis"
zIndex={30000}

View File

@@ -17,22 +17,25 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as GiftActionCreators from '@app/actions/GiftActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {openClaimAccountModal} from '@app/components/modals/ClaimAccountModal';
import styles from '@app/components/modals/GiftAcceptModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {Spinner} from '@app/components/uikit/Spinner';
import i18n from '@app/I18n';
import {Logger} from '@app/lib/Logger';
import {UserRecord} from '@app/records/UserRecord';
import GiftStore from '@app/stores/GiftStore';
import UserStore from '@app/stores/UserStore';
import {getGiftDurationText} from '@app/utils/GiftUtils';
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 {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
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 UserStore from '~/stores/UserStore';
import {getGiftDurationText} from '~/utils/giftUtils';
import styles from './GiftAcceptModal.module.css';
import {useEffect, useMemo, useState} from 'react';
const logger = new Logger('GiftAcceptModal');
interface GiftAcceptModalProps {
code: string;
@@ -42,22 +45,24 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
const {t} = useLingui();
const giftState = GiftStore.gifts.get(code) ?? null;
const gift = giftState?.data ?? null;
const [isRedeeming, setIsRedeeming] = React.useState(false);
const [isRedeeming, setIsRedeeming] = useState(false);
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
React.useEffect(() => {
useEffect(() => {
if (!giftState) {
void GiftActionCreators.fetchWithCoalescing(code).catch(() => {});
}
}, [code, giftState]);
const creator = React.useMemo(() => {
const creator = 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,
global_name: gift.created_by.global_name,
avatar: gift.created_by.avatar,
avatar_color: gift.created_by.avatar_color,
flags: gift.created_by.flags,
});
}, [gift?.created_by]);
@@ -76,7 +81,7 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
await GiftActionCreators.redeem(i18n, code);
ModalActionCreators.pop();
} catch (error) {
console.error('[GiftAcceptModal] Failed to redeem gift:', error);
logger.error('Failed to redeem gift:', error);
setIsRedeeming(false);
}
};

View File

@@ -17,25 +17,29 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as InviteActionCreators from '@app/actions/InviteActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {InviteRevokeFailedModal} from '@app/components/alerts/InviteRevokeFailedModal';
import {InvitesLoadFailedModal} from '@app/components/alerts/InvitesLoadFailedModal';
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
import styles from '@app/components/modals/GroupInvitesBottomSheet.module.css';
import {Avatar} from '@app/components/uikit/Avatar';
import {BottomSheet} from '@app/components/uikit/bottom_sheet/BottomSheet';
import {Scroller} from '@app/components/uikit/Scroller';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import {Logger} from '@app/lib/Logger';
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
import UserStore from '@app/stores/UserStore';
import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
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';
import type React from 'react';
import {useCallback, useEffect, useState} from 'react';
const logger = new Logger('GroupInvitesBottomSheet');
interface GroupInvitesBottomSheetProps {
isOpen: boolean;
@@ -46,34 +50,34 @@ interface GroupInvitesBottomSheetProps {
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 [invites, setInvites] = useState<Array<Invite> | null>(null);
const [isLoading, setIsLoading] = useState(true);
const loadInvites = React.useCallback(async () => {
const loadInvites = useCallback(async () => {
try {
setIsLoading(true);
const data = await InviteActionCreators.list(channelId);
setInvites(data);
} catch (error) {
console.error('Failed to load invites:', error);
logger.error('Failed to load invites:', error);
ModalActionCreators.push(modal(() => <InvitesLoadFailedModal />));
} finally {
setIsLoading(false);
}
}, [channelId]);
React.useEffect(() => {
useEffect(() => {
if (isOpen) {
loadInvites();
}
}, [isOpen, loadInvites]);
const handleRevoke = React.useCallback(
const handleRevoke = useCallback(
(code: string) => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Revoke invite`}
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 () => {
@@ -85,8 +89,10 @@ export const GroupInvitesBottomSheet: React.FC<GroupInvitesBottomSheetProps> = o
});
await loadInvites();
} catch (error) {
console.error('Failed to revoke invite:', error);
ModalActionCreators.push(modal(() => <InviteRevokeFailedModal />));
logger.error('Failed to revoke invite:', error);
window.setTimeout(() => {
ModalActionCreators.push(modal(() => <InviteRevokeFailedModal />));
}, 0);
}
}}
/>

View File

@@ -24,7 +24,7 @@
}
.modalRoot {
composes: root large from './Modal.module.css';
composes: root large from '@app/components/modals/Modal.module.css';
width: 720px;
max-width: 720px;
overflow: visible;

View File

@@ -17,36 +17,39 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as InviteActionCreators from '@app/actions/InviteActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {InviteRevokeFailedModal} from '@app/components/alerts/InviteRevokeFailedModal';
import {InvitesLoadFailedModal} from '@app/components/alerts/InvitesLoadFailedModal';
import {InviteDateToggle} from '@app/components/invites/InviteDateToggle';
import {InviteListHeader, InviteListItem} from '@app/components/invites/InviteListItem';
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
import styles from '@app/components/modals/GroupInvitesModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Scroller} from '@app/components/uikit/Scroller';
import {Spinner} from '@app/components/uikit/Spinner';
import {Logger} from '@app/lib/Logger';
import AuthenticationStore from '@app/stores/AuthenticationStore';
import ChannelStore from '@app/stores/ChannelStore';
import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
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';
import {useCallback, useEffect, useState} from 'react';
const logger = new Logger('GroupInvitesModal');
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 [invites, setInvites] = useState<Array<Invite>>([]);
const [fetchStatus, setFetchStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle');
const [showCreatedDate, setShowCreatedDate] = useState(false);
const loadInvites = React.useCallback(async () => {
const loadInvites = useCallback(async () => {
if (!isOwner) return;
try {
setFetchStatus('pending');
@@ -54,24 +57,24 @@ export const GroupInvitesModal = observer(({channelId}: {channelId: string}) =>
setInvites(data);
setFetchStatus('success');
} catch (error) {
console.error('Failed to load invites:', error);
logger.error('Failed to load invites:', error);
setFetchStatus('error');
ModalActionCreators.push(modal(() => <InvitesLoadFailedModal />));
}
}, [channelId, isOwner]);
React.useEffect(() => {
useEffect(() => {
if (isOwner && fetchStatus === 'idle') {
loadInvites();
}
}, [fetchStatus, loadInvites, isOwner]);
const handleRevoke = React.useCallback(
const handleRevoke = useCallback(
(code: string) => {
ModalActionCreators.push(
modal(() => (
<ConfirmModal
title={t`Revoke invite`}
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 () => {
@@ -83,8 +86,10 @@ export const GroupInvitesModal = observer(({channelId}: {channelId: string}) =>
});
await loadInvites();
} catch (error) {
console.error('Failed to revoke invite:', error);
ModalActionCreators.push(modal(() => <InviteRevokeFailedModal />));
logger.error('Failed to revoke invite:', error);
window.setTimeout(() => {
ModalActionCreators.push(modal(() => <InviteRevokeFailedModal />));
}, 0);
}
}}
/>

View File

@@ -17,20 +17,25 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as GuildActionCreators from '@app/actions/GuildActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Form} from '@app/components/form/Form';
import {FormErrorText} from '@app/components/form/FormErrorText';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
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';
interface FormInputs {
form: string;
}
export const GuildDeleteModal = observer(({guildId}: {guildId: string}) => {
const {t} = useLingui();
const form = useForm();
const form = useForm<FormInputs>();
const onSubmit = async () => {
await GuildActionCreators.remove(guildId);
@@ -49,10 +54,15 @@ export const GuildDeleteModal = observer(({guildId}: {guildId: string}) => {
<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.ContentLayout>
<Modal.Description>
<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.Description>
<FormErrorText message={form.formState.errors.form?.message} />
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">

View File

@@ -0,0 +1,211 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as UserSettingsActionCreators from '@app/actions/UserSettingsActionCreators';
import {ColorPickerField} from '@app/components/form/ColorPickerField';
import {Input} from '@app/components/form/Input';
import {Select, type SelectOption} from '@app/components/form/Select';
import {Switch} from '@app/components/form/Switch';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useCursorAtEnd} from '@app/hooks/useCursorAtEnd';
import GuildStore from '@app/stores/GuildStore';
import UserSettingsStore from '@app/stores/UserSettingsStore';
import {
DEFAULT_GUILD_FOLDER_ICON,
GuildFolderFlags,
type GuildFolderIcon,
GuildFolderIcons,
} from '@fluxer/constants/src/UserConstants';
import {useLingui} from '@lingui/react/macro';
import {
BookmarkSimpleIcon,
FolderIcon,
GameControllerIcon,
HeartIcon,
MusicNoteIcon,
ShieldIcon,
StarIcon,
} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import type {ReactNode} from 'react';
import {useCallback, useMemo, useState} from 'react';
const FOLDER_ICON_MAP: Record<GuildFolderIcon, ReactNode> = {
[GuildFolderIcons.FOLDER]: <FolderIcon weight="fill" size={18} />,
[GuildFolderIcons.STAR]: <StarIcon weight="fill" size={18} />,
[GuildFolderIcons.HEART]: <HeartIcon weight="fill" size={18} />,
[GuildFolderIcons.BOOKMARK]: <BookmarkSimpleIcon weight="fill" size={18} />,
[GuildFolderIcons.GAME_CONTROLLER]: <GameControllerIcon weight="fill" size={18} />,
[GuildFolderIcons.SHIELD]: <ShieldIcon weight="fill" size={18} />,
[GuildFolderIcons.MUSIC_NOTE]: <MusicNoteIcon weight="fill" size={18} />,
};
interface GuildFolderSettingsModalProps {
folderId: number;
}
export const GuildFolderSettingsModal = observer(({folderId}: GuildFolderSettingsModalProps) => {
const {t} = useLingui();
const folder = useMemo(() => {
return UserSettingsStore.guildFolders.find((f) => f.id === folderId);
}, [folderId]);
const autoGeneratedName = useMemo(() => {
if (!folder) return '';
const guildNames = folder.guildIds
.slice(0, 3)
.map((guildId) => GuildStore.getGuild(guildId)?.name)
.filter((name): name is string => name != null);
return guildNames.join(', ');
}, [folder]);
const [name, setName] = useState(folder?.name ?? '');
const nameRef = useCursorAtEnd<HTMLInputElement>();
const [color, setColor] = useState(folder?.color ?? 0);
const [flags, setFlags] = useState(folder?.flags ?? 0);
const [icon, setIcon] = useState<GuildFolderIcon>(folder?.icon ?? DEFAULT_GUILD_FOLDER_ICON);
const [isSaving, setIsSaving] = useState(false);
const showCollapsedIcon =
(flags & GuildFolderFlags.SHOW_ICON_WHEN_COLLAPSED) === GuildFolderFlags.SHOW_ICON_WHEN_COLLAPSED;
const iconOptions = useMemo<Array<SelectOption<GuildFolderIcon>>>(
() => [
{value: GuildFolderIcons.FOLDER, label: t`Folder`},
{value: GuildFolderIcons.STAR, label: t`Star`},
{value: GuildFolderIcons.HEART, label: t`Heart`},
{value: GuildFolderIcons.BOOKMARK, label: t`Bookmark`},
{value: GuildFolderIcons.GAME_CONTROLLER, label: t`Game controller`},
{value: GuildFolderIcons.SHIELD, label: t`Shield`},
{value: GuildFolderIcons.MUSIC_NOTE, label: t`Music note`},
],
[t],
);
const handleNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value);
}, []);
const handleColorChange = useCallback((newColor: number) => {
setColor(newColor);
}, []);
const handleShowCollapsedIconChange = useCallback((value: boolean) => {
setFlags((currentFlags) => {
if (value) {
return currentFlags | GuildFolderFlags.SHOW_ICON_WHEN_COLLAPSED;
}
return currentFlags & ~GuildFolderFlags.SHOW_ICON_WHEN_COLLAPSED;
});
}, []);
const handleIconChange = useCallback((value: GuildFolderIcon) => {
setIcon(value);
}, []);
const renderIconOption = useCallback(
(option: SelectOption<GuildFolderIcon>, _isSelected: boolean) => (
<span style={{display: 'flex', alignItems: 'center', gap: 8}}>
{FOLDER_ICON_MAP[option.value]}
{option.label}
</span>
),
[],
);
const handleCancel = useCallback(() => {
ModalActionCreators.pop();
}, []);
const handleSave = useCallback(async () => {
if (!folder) return;
setIsSaving(true);
try {
const updatedFolders = UserSettingsStore.guildFolders.map((f) => {
if (f.id === folderId) {
return {
...f,
name: name.trim() || null,
color: color || null,
flags,
icon,
};
}
return f;
});
await UserSettingsActionCreators.update({guildFolders: updatedFolders});
ModalActionCreators.pop();
} finally {
setIsSaving(false);
}
}, [folder, folderId, name, color, flags, icon]);
if (!folder) {
return null;
}
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Folder Settings`} />
<Modal.Content>
<Modal.ContentLayout>
<Input
ref={nameRef}
autoFocus={true}
label={t`Folder Name`}
placeholder={autoGeneratedName}
value={name}
onChange={handleNameChange}
autoComplete="off"
maxLength={100}
/>
<ColorPickerField label={t`Folder Color`} value={color} onChange={handleColorChange} />
<Switch
label={t`Show icon when collapsed`}
value={showCollapsedIcon}
onChange={handleShowCollapsedIconChange}
/>
<Select
label={t`Folder Icon`}
value={icon}
options={iconOptions}
onChange={handleIconChange}
isSearchable={false}
renderOption={renderIconOption}
/>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={handleCancel} variant="secondary">
{t`Cancel`}
</Button>
<Button onClick={handleSave} submitting={isSaving}>
{t`Save`}
</Button>
</Modal.Footer>
</Modal.Root>
);
});
export function openGuildFolderSettingsModal(folderId: number): void {
ModalActionCreators.push(ModalActionCreators.modal(() => <GuildFolderSettingsModal folderId={folderId} />));
}

View File

@@ -202,9 +202,13 @@
display: flex;
height: 24px;
width: 24px;
min-width: 24px;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
box-sizing: border-box;
border: 0;
border-radius: 9999px;
background-color: var(--background-tertiary);
color: var(--text-tertiary);

View File

@@ -17,24 +17,26 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as UserGuildSettingsActionCreators from '@app/actions/UserGuildSettingsActionCreators';
import {Select} from '@app/components/form/Select';
import {Switch} from '@app/components/form/Switch';
import styles from '@app/components/modals/GuildNotificationSettingsModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import {RadioGroup, type RadioOption} from '@app/components/uikit/radio_group/RadioGroup';
import ChannelStore from '@app/stores/ChannelStore';
import GuildStore from '@app/stores/GuildStore';
import UserGuildSettingsStore from '@app/stores/UserGuildSettingsStore';
import * as ChannelUtils from '@app/utils/ChannelUtils';
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import type {ChannelId} from '@fluxer/schema/src/branded/WireIds';
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;
@@ -88,7 +90,7 @@ export const GuildNotificationSettingsModal = observer(({guildId}: {guildId: str
},
{
value: MessageNotifications.ONLY_MENTIONS,
name: t`Only @mentions`,
name: t`Only Mentions`,
},
{
value: MessageNotifications.NO_MESSAGES,
@@ -99,7 +101,7 @@ export const GuildNotificationSettingsModal = observer(({guildId}: {guildId: str
const handleAddOverride = (value: string | null) => {
if (!value) return;
const existingOverride = settings.channel_overrides?.[value];
const existingOverride = settings.channel_overrides?.[value as ChannelId];
if (existingOverride) {
return;
}
@@ -192,7 +194,7 @@ export const GuildNotificationSettingsModal = observer(({guildId}: {guildId: str
}
/>
<Switch
label={t`Suppress all role @mentions`}
label={t`Suppress All Role @mentions`}
value={settings.suppress_roles}
onChange={(value) =>
UserGuildSettingsActionCreators.updateGuildSettings(guildId, {suppress_roles: value})

View File

@@ -17,78 +17,62 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import styles from '@app/components/modals/GuildOwnershipWarningModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {GuildIcon} from '@app/components/popouts/GuildIcon';
import {Button} from '@app/components/uikit/button/Button';
import type {GuildRecord} from '@app/records/GuildRecord';
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;
export const GuildOwnershipWarningModal: React.FC<GuildOwnershipWarningModalProps> = observer(({ownedGuilds}) => {
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>
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Cannot Delete Account`} />
<Modal.Content>
<div className={styles.content}>
<p>
<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>
))}
{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>
))}
{remainingCount > 0 && (
<div className={styles.remainingCount}>
<Trans>and {remainingCount} more</Trans>
</div>
)}
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop}>
<Trans>OK</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
},
);
<p className={styles.helpText}>
<Trans>
To transfer ownership, go to Community Settings Overview and use the Transfer Ownership option.
</Trans>
</p>
</div>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop}>
<Trans>OK</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -17,25 +17,27 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as UserSettingsActionCreators from '@app/actions/UserSettingsActionCreators';
import {Switch} from '@app/components/form/Switch';
import styles from '@app/components/modals/GuildPrivacySettingsModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import GuildStore from '@app/stores/GuildStore';
import UserSettingsStore from '@app/stores/UserSettingsStore';
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}) {
export const GuildPrivacySettingsModal = observer(({guildId}: {guildId: string}) => {
const {t} = useLingui();
const guild = GuildStore.getGuild(guildId);
const restrictedGuilds = UserSettingsStore.restrictedGuilds;
const botRestrictedGuilds = UserSettingsStore.botRestrictedGuilds;
if (!guild) return null;
const isDMsAllowed = !restrictedGuilds.includes(guildId);
const isBotDMsAllowed = !botRestrictedGuilds.includes(guildId);
const handleToggleDMs = async (value: boolean) => {
let newRestrictedGuilds: Array<string>;
@@ -51,6 +53,20 @@ export const GuildPrivacySettingsModal = observer(function GuildPrivacySettingsM
});
};
const handleToggleBotDMs = async (value: boolean) => {
let newRestrictedGuilds: Array<string>;
if (value) {
newRestrictedGuilds = botRestrictedGuilds.filter((id) => id !== guildId);
} else {
newRestrictedGuilds = [...botRestrictedGuilds, guildId];
}
await UserSettingsActionCreators.update({
botRestrictedGuilds: newRestrictedGuilds,
});
};
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Privacy Settings`} />
@@ -62,6 +78,12 @@ export const GuildPrivacySettingsModal = observer(function GuildPrivacySettingsM
value={isDMsAllowed}
onChange={handleToggleDMs}
/>
<Switch
label={t`Bot Direct Messages`}
description={t`Allow bots from this community to send you direct messages`}
value={isBotDMsAllowed}
onChange={handleToggleBotDMs}
/>
</div>
</Modal.Content>
<Modal.Footer>

View File

@@ -17,24 +17,27 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as UnsavedChangesActionCreators from '@app/actions/UnsavedChangesActionCreators';
import {DesktopGuildSettingsView} from '@app/components/modals/components/DesktopGuildSettingsView';
import {MobileGuildSettingsView} from '@app/components/modals/components/MobileGuildSettingsView';
import {useMobileNavigation} from '@app/components/modals/hooks/useMobileNavigation';
import * as Modal from '@app/components/modals/Modal';
import {SettingsModalContainer} from '@app/components/modals/shared/SettingsModalLayout';
import {type GuildSettingsTabType, getGuildSettingsTabs} from '@app/components/modals/utils/GuildSettingsConstants';
import {Routes} from '@app/Routes';
import GuildSettingsModalStore from '@app/stores/GuildSettingsModalStore';
import GuildStore from '@app/stores/GuildStore';
import GatewayConnectionStore from '@app/stores/gateway/GatewayConnectionStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import PermissionStore from '@app/stores/PermissionStore';
import UnsavedChangesStore from '@app/stores/UnsavedChangesStore';
import {isMobileExperienceEnabled} from '@app/utils/MobileExperience';
import * as RouterUtils from '@app/utils/RouterUtils';
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 ConnectionStore from '~/stores/gateway/ConnectionStore';
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';
import type React from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
interface GuildSettingsModalProps {
guildId: string;
@@ -44,12 +47,12 @@ interface GuildSettingsModalProps {
export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
({guildId, initialTab: initialTabProp, initialMobileTab}) => {
const {t} = useLingui();
const {t, i18n} = useLingui();
const guild = GuildStore.getGuild(guildId);
const [selectedTab, setSelectedTab] = React.useState<GuildSettingsTabType>(initialTabProp ?? 'overview');
const [selectedTab, setSelectedTab] = useState<GuildSettingsTabType>(initialTabProp ?? 'overview');
const availableTabs = React.useMemo(() => {
const guildSettingsTabs = getGuildSettingsTabs(t);
const availableTabs = useMemo(() => {
const guildSettingsTabs = getGuildSettingsTabs(i18n);
if (!guild) return guildSettingsTabs;
return guildSettingsTabs.filter((tab) => {
@@ -61,11 +64,11 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
}
return true;
});
}, [guild, guildId, t]);
}, [guild, guildId, i18n]);
const isMobileExperience = isMobileExperienceEnabled();
const initialMobileTabObject = React.useMemo(() => {
const initialMobileTabObject = useMemo(() => {
if (!isMobileExperience || !initialMobileTab) return;
const targetTab = availableTabs.find((tab) => tab.type === initialMobileTab);
if (!targetTab) return;
@@ -79,24 +82,25 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
const {enabled: isMobile} = MobileLayoutStore;
const unsavedChangesStore = UnsavedChangesStore;
const currentMobileTab = mobileNav.currentView?.tab;
React.useEffect(() => {
ConnectionStore.syncGuildIfNeeded(guildId, 'guild-settings-modal');
useEffect(() => {
GatewayConnectionStore.syncGuildIfNeeded(guildId, 'guild-settings-modal');
}, [guildId]);
React.useEffect(() => {
useEffect(() => {
if (!guild) {
ModalActionCreators.pop();
ModalActionCreators.popByType(GuildSettingsModal);
}
}, [guild]);
React.useEffect(() => {
useEffect(() => {
if (availableTabs.length > 0 && !availableTabs.find((tab) => tab.type === selectedTab)) {
setSelectedTab(availableTabs[0].type);
}
}, [availableTabs, selectedTab]);
const groupedSettingsTabs = React.useMemo(() => {
const groupedSettingsTabs = useMemo(() => {
return availableTabs.reduce(
(acc, tab) => {
if (!acc[tab.category]) {
@@ -109,7 +113,7 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
);
}, [availableTabs]);
const currentTab = React.useMemo(() => {
const currentTab = useMemo(() => {
if (!isMobile) {
return availableTabs.find((tab) => tab.type === selectedTab);
}
@@ -117,7 +121,7 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
return availableTabs.find((tab) => tab.type === mobileNav.currentView?.tab);
}, [isMobile, selectedTab, mobileNav.isRootView, mobileNav.currentView, availableTabs]);
const handleMobileBack = React.useCallback(() => {
const handleMobileBack = useCallback(() => {
if (mobileNav.isRootView) {
ModalActionCreators.pop();
} else {
@@ -125,23 +129,40 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
}
}, [mobileNav]);
const handleTabSelect = React.useCallback(
(tabType: string, title: string) => {
mobileNav.navigateTo(tabType as GuildSettingsTabType, title);
const handleDesktopTabSelect = useCallback(
(tabType: GuildSettingsTabType) => {
if (tabType === 'members') {
ModalActionCreators.pop();
RouterUtils.transitionTo(Routes.guildMembers(guildId));
return;
}
setSelectedTab(tabType);
},
[mobileNav],
[guildId],
);
const handleClose = React.useCallback(() => {
const checkTabId = selectedTab;
const handleTabSelect = useCallback(
(tabType: string, title: string) => {
if (tabType === 'members') {
ModalActionCreators.pop();
RouterUtils.transitionTo(Routes.guildMembers(guildId));
return;
}
mobileNav.navigateTo(tabType as GuildSettingsTabType, title);
},
[mobileNav, guildId],
);
const handleClose = useCallback(() => {
const checkTabId = isMobile ? currentMobileTab : selectedTab;
if (checkTabId && unsavedChangesStore.unsavedChanges[checkTabId]) {
UnsavedChangesActionCreators.triggerFlashEffect(checkTabId);
return;
}
ModalActionCreators.pop();
}, [selectedTab, unsavedChangesStore.unsavedChanges]);
}, [currentMobileTab, isMobile, selectedTab, unsavedChangesStore.unsavedChanges]);
const handleExternalNavigate = React.useCallback(
const handleExternalNavigate = useCallback(
(targetTab: GuildSettingsTabType) => {
const tabMeta = availableTabs.find((tab) => tab.type === targetTab);
if (!tabMeta) return;
@@ -157,7 +178,7 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
[availableTabs, isMobile, mobileIsRootView, mobileNavigateTo, mobileResetToRoot],
);
React.useEffect(() => {
useEffect(() => {
GuildSettingsModalStore.register({guildId, navigate: handleExternalNavigate});
return () => {
GuildSettingsModalStore.unregister(guildId);
@@ -187,7 +208,7 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
groupedSettingsTabs={groupedSettingsTabs}
currentTab={currentTab}
selectedTab={selectedTab}
onTabSelect={setSelectedTab}
onTabSelect={handleDesktopTabSelect}
/>
)}
</SettingsModalContainer>

View File

@@ -17,21 +17,21 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as VoiceSettingsActionCreators from '@app/actions/VoiceSettingsActionCreators';
import styles from '@app/components/modals/HideOwnCameraConfirmModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import VoicePromptsStore from '@app/stores/VoicePromptsStore';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as VoiceSettingsActionCreators from '~/actions/VoiceSettingsActionCreators';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {Checkbox} from '~/components/uikit/Checkbox/Checkbox';
import VoicePromptsStore from '~/stores/VoicePromptsStore';
import styles from './HideOwnCameraConfirmModal.module.css';
import {useRef, useState} from 'react';
export const HideOwnCameraConfirmModal: React.FC = observer(() => {
export const HideOwnCameraConfirmModal = observer(() => {
const {t} = useLingui();
const [dontAskAgain, setDontAskAgain] = React.useState(false);
const initialFocusRef = React.useRef<HTMLButtonElement | null>(null);
const [dontAskAgain, setDontAskAgain] = useState(false);
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
const handleConfirm = () => {
if (dontAskAgain) VoicePromptsStore.setSkipHideOwnCameraConfirm(true);
@@ -45,7 +45,7 @@ export const HideOwnCameraConfirmModal: React.FC = observer(() => {
return (
<Modal.Root size="small" centered initialFocusRef={initialFocusRef}>
<Modal.Header title={<Trans>Hide your own camera?</Trans>} />
<Modal.Header title={<Trans>Hide Your Own Camera?</Trans>} />
<Modal.Content>
<p className={styles.description}>
<Trans>

View File

@@ -0,0 +1,70 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as VoiceSettingsActionCreators from '@app/actions/VoiceSettingsActionCreators';
import styles from '@app/components/modals/HideOwnCameraConfirmModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {Checkbox} from '@app/components/uikit/checkbox/Checkbox';
import VoicePromptsStore from '@app/stores/VoicePromptsStore';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useRef, useState} from 'react';
export const HideOwnScreenShareConfirmModal = observer(() => {
const {t} = useLingui();
const [dontAskAgain, setDontAskAgain] = useState(false);
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
const handleConfirm = () => {
if (dontAskAgain) VoicePromptsStore.setSkipHideOwnScreenShareConfirm(true);
VoiceSettingsActionCreators.update({showMyOwnScreenShare: false});
ModalActionCreators.pop();
};
const handleCancel = () => {
ModalActionCreators.pop();
};
return (
<Modal.Root size="small" centered initialFocusRef={initialFocusRef}>
<Modal.Header title={<Trans>Hide Your Own Screen Share?</Trans>} />
<Modal.Content>
<p className={styles.description}>
<Trans>
Turning this off only hides your screen share from your own view. Others in the call can still see your
screen.
</Trans>
</p>
<div className={styles.checkboxContainer}>
<Checkbox checked={dontAskAgain} onChange={(checked) => setDontAskAgain(checked)} size="small">
<span className={styles.checkboxLabel}>
<Trans>Don't ask me again</Trans>
</span>
</Checkbox>
</div>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={handleCancel}>{t`Cancel`}</Button>
<Button variant="primary" onClick={handleConfirm} ref={initialFocusRef}>{t`Hide`}</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -17,12 +17,6 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
.container {
display: flex;
flex-direction: column;
gap: 16px;
}
.description {
font-size: 14px;
color: var(--text-tertiary);
@@ -37,6 +31,7 @@
background-color: var(--background-secondary);
padding: 8px 0;
margin-bottom: 16px;
font-size: 87.5%;
}
.userPreview {
@@ -79,9 +74,63 @@
margin-bottom: 16px;
}
.categoryLabel {
margin-bottom: 8px;
.categorySelect :global([class*='-control']) {
min-height: 62px !important;
}
.categorySelect :global([class*='-singleValue']) {
position: static !important;
transform: none !important;
max-width: 100% !important;
white-space: normal !important;
}
.optionContent {
display: flex;
flex-direction: column;
gap: 2px;
padding: 2px 0;
white-space: normal;
}
.optionName {
font-size: 14px;
font-weight: 600;
font-weight: 500;
color: var(--text-primary);
}
.optionDesc {
font-size: 12px;
color: var(--text-tertiary);
}
.optionDescSelected {
font-size: 12px;
color: inherit;
}
.valueContent {
display: flex;
flex-direction: column;
gap: 1px;
overflow: hidden;
min-width: 0;
}
.valueName {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.valueDesc {
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.3;
}

View File

@@ -17,28 +17,36 @@
* 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 IARActionCreators from '~/actions/IARActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {MessagePreviewContext} from '~/Constants';
import {Message} from '~/components/channel/Message';
import {Textarea} from '~/components/form/Input';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
import * as IARActionCreators from '@app/actions/IARActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Message} from '@app/components/channel/Message';
import {Textarea} from '@app/components/form/Input';
import {Select, type SelectOption} from '@app/components/form/Select';
import styles from '@app/components/modals/IARModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {
getGuildViolationCategories,
getMessageViolationCategories,
getUserViolationCategories,
} from '~/constants/IARConstants';
import type {GuildRecord} from '~/records/GuildRecord';
import type {MessageRecord} from '~/records/MessageRecord';
import type {UserRecord} from '~/records/UserRecord';
import ChannelStore from '~/stores/ChannelStore';
import styles from './IARModal.module.css';
} from '@app/constants/IARConstants';
import {Logger} from '@app/lib/Logger';
import type {GuildRecord} from '@app/records/GuildRecord';
import type {MessageRecord} from '@app/records/MessageRecord';
import type {UserRecord} from '@app/records/UserRecord';
import ChannelStore from '@app/stores/ChannelStore';
import {MessagePreviewContext} from '@fluxer/constants/src/ChannelConstants';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useCallback, useMemo, useState} from 'react';
interface ViolationSelectOption extends SelectOption<string> {
desc: string;
}
const logger = new Logger('IARModal');
export type IARContext =
| {
@@ -61,22 +69,47 @@ interface IARModalProps {
export const IARModal: React.FC<IARModalProps> = observer(({context}) => {
const {t, i18n} = useLingui();
const [selectedCategory, setSelectedCategory] = React.useState<string>('');
const [additionalInfo, setAdditionalInfo] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const [selectedCategory, setSelectedCategory] = useState<string>('');
const [additionalInfo, setAdditionalInfo] = useState('');
const [submitting, setSubmitting] = useState(false);
const categories = React.useMemo(() => {
const categoryOptions = useMemo((): Array<ViolationSelectOption> => {
let categories: Array<{value: string; name: string; desc: string}>;
switch (context.type) {
case 'message':
return getMessageViolationCategories(i18n);
categories = getMessageViolationCategories(i18n);
break;
case 'user':
return getUserViolationCategories(i18n);
categories = getUserViolationCategories(i18n);
break;
case 'guild':
return getGuildViolationCategories(i18n);
categories = getGuildViolationCategories(i18n);
break;
}
return categories.map((cat) => ({value: cat.value, label: cat.name, desc: cat.desc}));
}, [context.type, i18n]);
const title = React.useMemo(() => {
const renderOption = useCallback(
(option: ViolationSelectOption, isSelected: boolean) => (
<div className={styles.optionContent}>
<div className={styles.optionName}>{option.label}</div>
<div className={isSelected ? styles.optionDescSelected : styles.optionDesc}>{option.desc}</div>
</div>
),
[],
);
const renderValue = useCallback((option: ViolationSelectOption | null) => {
if (!option) return null;
return (
<div className={styles.valueContent}>
<div className={styles.valueName}>{option.label}</div>
<div className={styles.valueDesc}>{option.desc}</div>
</div>
);
}, []);
const title = useMemo(() => {
switch (context.type) {
case 'message':
return t`Report Message`;
@@ -87,15 +120,15 @@ export const IARModal: React.FC<IARModalProps> = observer(({context}) => {
}
}, [context.type]);
const handleCategoryChange = React.useCallback((value: string) => {
const handleCategoryChange = useCallback((value: string) => {
setSelectedCategory(value);
}, []);
const handleAdditionalInfoChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const handleAdditionalInfoChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setAdditionalInfo(e.target.value);
}, []);
const handleSubmit = React.useCallback(async () => {
const handleSubmit = useCallback(async () => {
if (!selectedCategory) {
ToastActionCreators.createToast({
type: 'error',
@@ -129,7 +162,7 @@ export const IARModal: React.FC<IARModalProps> = observer(({context}) => {
});
ModalActionCreators.pop();
} catch (error) {
console.error('Failed to submit report:', error);
logger.error('Failed to submit report:', error);
ToastActionCreators.createToast({
type: 'error',
children: <Trans>Failed to submit report. Please try again.</Trans>,
@@ -182,7 +215,7 @@ export const IARModal: React.FC<IARModalProps> = observer(({context}) => {
<Modal.Root size="small" centered>
<Modal.Header title={title} />
<Modal.Content>
<div className={styles.container}>
<Modal.ContentLayout>
<p className={styles.description}>
<Trans>
Thank you for helping keep Fluxer safe. Reports are reviewed by our Safety Team. False reports may result
@@ -191,13 +224,17 @@ export const IARModal: React.FC<IARModalProps> = observer(({context}) => {
</p>
{renderPreview()}
<div className={styles.categorySection}>
<div className={styles.categoryLabel}>{t`Why are you reporting this?`}</div>
<RadioGroup
value={selectedCategory || null}
options={categories}
<Select<string, false, ViolationSelectOption>
label={t`Why are you reporting this?`}
value={selectedCategory}
options={categoryOptions}
onChange={handleCategoryChange}
disabled={submitting}
aria-label={t`Violation category selection`}
placeholder={t`Select a reason...`}
renderOption={renderOption}
renderValue={renderValue}
isSearchable={false}
className={styles.categorySelect}
/>
</div>
<Textarea
@@ -211,7 +248,7 @@ export const IARModal: React.FC<IARModalProps> = observer(({context}) => {
showCharacterCount={true}
disabled={submitting}
/>
</div>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={() => ModalActionCreators.pop()} disabled={submitting}>

View File

@@ -27,7 +27,7 @@
.description {
color: var(--text-primary-muted);
font-size: 14px;
margin-bottom: 4px;
margin-bottom: 1rem;
}
.cropperContainer {

View File

@@ -17,23 +17,22 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import styles from '@app/components/modals/ImageCropModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Slider} from '@app/components/uikit/Slider';
import {Logger} from '@app/lib/Logger';
import {cropAnimatedImageWithWorkerPool} from '@app/workers/AnimatedImageCropWorkerManager';
import {Trans, useLingui} from '@lingui/react/macro';
import {ArrowClockwiseIcon, ImageSquareIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import styles from '~/components/modals/ImageCropModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Slider} from '~/components/uikit/Slider';
import type React from 'react';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
interface CropGifWorkerMessage {
type: number;
result?: Uint8Array;
error?: string;
}
const logger = new Logger('ImageCropModal');
interface Point {
x: number;
@@ -50,9 +49,7 @@ interface DragBoundaries {
right: number;
}
export type GifBytes = Uint8Array;
interface GifCropOptions {
interface AnimatedImageCropOptions {
x: number;
y: number;
width: number;
@@ -62,59 +59,12 @@ interface GifCropOptions {
resizeHeight?: number | null;
}
async function cropGifWithWorker(gif: GifBytes, options: GifCropOptions): Promise<GifBytes> {
const {x, y, width, height, imageRotation = 0, resizeWidth = null, resizeHeight = null} = options;
return new Promise<GifBytes>((resolve, reject) => {
const worker = new Worker(new URL('../../workers/gifCrop.worker.ts', import.meta.url), {
type: 'module',
});
worker.addEventListener(
'message',
(event: MessageEvent<CropGifWorkerMessage>) => {
const msg = event.data;
if (msg.type === 1) {
worker.terminate();
if (msg.result) {
resolve(msg.result);
} else {
reject(new Error('Empty result from worker'));
}
} else if (msg.type === 2) {
worker.terminate();
reject(new Error(msg.error || 'Unknown error from worker'));
}
},
{once: true},
);
worker.addEventListener('error', (error) => {
worker.terminate();
reject(error);
});
const transferables: Array<Transferable> = [];
if (gif.buffer) {
transferables.push(gif.buffer);
}
worker.postMessage(
{
type: 0,
gif,
x,
y,
width,
height,
imageRotation,
resizeWidth,
resizeHeight,
},
transferables,
);
});
async function cropAnimatedImageWithWorker(
imageBytes: Uint8Array,
format: 'gif' | 'webp' | 'avif' | 'apng',
options: AnimatedImageCropOptions,
): Promise<Uint8Array> {
return cropAnimatedImageWithWorkerPool(imageBytes, format, options);
}
function clamp(value: number, min: number, max: number): number {
@@ -370,7 +320,7 @@ async function exportStaticImage(
return blob;
}
async function exportAnimatedGif(
async function exportAnimatedImage(
image: HTMLImageElement,
displayDimensions: Size,
cropDimensions: Size,
@@ -380,6 +330,7 @@ async function exportAnimatedGif(
maxH: number,
maxBytes: number,
src: string,
mimeType: string,
): Promise<Blob> {
const scale = image.naturalWidth / displayDimensions.width;
const cropNativeWidth = cropDimensions.width * scale;
@@ -398,10 +349,18 @@ async function exportAnimatedGif(
const response = await fetch(src);
if (!response.ok) {
throw new Error('Failed to fetch GIF data');
throw new Error('Failed to fetch animated image data');
}
const buffer = await response.arrayBuffer();
const gifBytes = new Uint8Array(buffer);
const imageBytes = new Uint8Array(buffer);
const format = mimeType.toLowerCase().includes('gif')
? 'gif'
: mimeType.toLowerCase().includes('webp')
? 'webp'
: mimeType.toLowerCase().includes('avif')
? 'avif'
: 'apng';
const cropOptions = {
x: Math.max(0, Math.floor(geom.sourceX)),
@@ -414,23 +373,23 @@ async function exportAnimatedGif(
};
snapCropOptionsToImageBounds(cropOptions, image);
const resultBytes = await cropGifWithWorker(gifBytes, cropOptions);
const resultBlob = new Blob([new Uint8Array(resultBytes)], {type: 'image/gif'});
const resultBytes = await cropAnimatedImageWithWorker(imageBytes, format, cropOptions);
const resultBlob = new Blob([new Uint8Array(resultBytes)], {type: mimeType});
if (resultBlob.size === 0) {
throw new Error('Empty GIF blob returned');
throw new Error('Empty animated image blob returned');
}
if (resultBlob.size > maxBytes) {
throw new Error(
`GIF size ${(resultBlob.size / 1024).toFixed(1)} KB exceeds max ${(maxBytes / 1024).toFixed(0)} KB`,
`Animated image size ${(resultBlob.size / 1024).toFixed(1)} KB exceeds max ${(maxBytes / 1024).toFixed(0)} KB`,
);
}
return resultBlob;
}
function snapCropOptionsToImageBounds(options: GifCropOptions, image: HTMLImageElement): void {
function snapCropOptionsToImageBounds(options: AnimatedImageCropOptions, image: HTMLImageElement): void {
const EPS = 2;
const naturalWidth = image.naturalWidth;
const naturalHeight = image.naturalHeight;
@@ -508,32 +467,34 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
maxHeightRatio,
}) => {
const {t} = useLingui();
const imageRef = React.useRef<HTMLImageElement | null>(null);
const cropperContainerRef = React.useRef<HTMLDivElement | null>(null);
const transformRef = React.useRef<Point>({x: 0, y: 0});
const imageRef = useRef<HTMLImageElement | null>(null);
const cropperContainerRef = useRef<HTMLDivElement | null>(null);
const transformRef = useRef<Point>({x: 0, y: 0});
const [displayDimensions, setDisplayDimensions] = React.useState<Size | null>(null);
const [cropDimensions, setCropDimensions] = React.useState<Size | null>(null);
const [dragBoundaries, setDragBoundaries] = React.useState<DragBoundaries>({
const [displayDimensions, setDisplayDimensions] = useState<Size | null>(null);
const [cropDimensions, setCropDimensions] = useState<Size | null>(null);
const [dragBoundaries, setDragBoundaries] = useState<DragBoundaries>({
top: 0,
bottom: 0,
left: 0,
right: 0,
});
const [zoomRatio, setZoomRatio] = React.useState(1);
const [rotation, setRotation] = React.useState(0);
const [hasEdits, setHasEdits] = React.useState(false);
const [isProcessing, setIsProcessing] = React.useState(false);
const [isDragging, setIsDragging] = React.useState(false);
const [dragStart, setDragStart] = React.useState<Point>({x: 0, y: 0});
const [loadError, setLoadError] = React.useState(false);
const [sliderKey, setSliderKey] = React.useState(0);
const [zoomRatio, setZoomRatio] = useState(1);
const [rotation, setRotation] = useState(0);
const [hasEdits, setHasEdits] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<Point>({x: 0, y: 0});
const [loadError, setLoadError] = useState(false);
const [sliderKey, setSliderKey] = useState(0);
const [heightRatio, setHeightRatio] = React.useState(1);
const [heightSliderKey, setHeightSliderKey] = React.useState(0);
const [heightRatio, setHeightRatio] = useState(1);
const [heightSliderKey, setHeightSliderKey] = useState(0);
const isRound = cropShape === 'round';
const isGif = sourceMimeType.toLowerCase() === 'image/gif';
const isAnimated = ['image/gif', 'image/webp', 'image/avif', 'image/png'].some((type) =>
sourceMimeType.toLowerCase().includes(type.replace('image/', '')),
);
const MIN_ZOOM = 1;
const MAX_ZOOM = 3;
@@ -542,14 +503,14 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
const effectiveMaxHeightRatio = !isRound ? (maxHeightRatio ?? 1) : 1;
const heightSliderEnabled = !isRound && effectiveMinHeightRatio < effectiveMaxHeightRatio;
const applyTransform = React.useCallback((x: number, y: number, rotationDeg: number) => {
const applyTransform = useCallback((x: number, y: number, rotationDeg: number) => {
transformRef.current = {x, y};
const img = imageRef.current;
if (!img) return;
img.style.transform = `translate3d(calc(-50% + ${x}px), calc(-50% + ${y}px), 0) rotate(${rotationDeg}deg)`;
}, []);
const recalculateLayout = React.useCallback(
const recalculateLayout = useCallback(
(nextHeightRatio: number, resetTransform: boolean) => {
const img = imageRef.current;
const container = cropperContainerRef.current;
@@ -611,11 +572,11 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
],
);
const handleImageLoad = React.useCallback(() => {
const handleImageLoad = useCallback(() => {
recalculateLayout(heightRatio, true);
}, [heightRatio, recalculateLayout]);
const handleMouseDown: React.MouseEventHandler<HTMLImageElement> = React.useCallback((event) => {
const handleMouseDown: React.MouseEventHandler<HTMLImageElement> = useCallback((event) => {
event.preventDefault();
const {x, y} = transformRef.current;
setIsDragging(true);
@@ -625,7 +586,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
});
}, []);
const handleMouseMove = React.useCallback(
const handleMouseMove = useCallback(
(event: MouseEvent) => {
if (!isDragging || !displayDimensions || !cropDimensions) return;
const newX = event.clientX - dragStart.x;
@@ -648,14 +609,14 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
],
);
const handleMouseUp = React.useCallback(() => {
const handleMouseUp = useCallback(() => {
if (!isDragging) return;
setIsDragging(false);
const transform = transformRef.current;
setHasEdits(hasEditsFrom(zoomRatio, rotation, transform, heightRatio));
}, [heightRatio, isDragging, rotation, zoomRatio]);
const handleZoomChange = React.useCallback(
const handleZoomChange = useCallback(
(ratio: number) => {
if (!displayDimensions || !cropDimensions) return;
@@ -681,7 +642,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
[MIN_ZOOM, MAX_ZOOM, applyTransform, cropDimensions, displayDimensions, heightRatio, rotation],
);
const handleHeightRatioChange = React.useCallback(
const handleHeightRatioChange = useCallback(
(value: number) => {
if (!heightSliderEnabled) return;
recalculateLayout(value, false);
@@ -689,7 +650,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
[heightSliderEnabled, recalculateLayout],
);
const handleRotate = React.useCallback(() => {
const handleRotate = useCallback(() => {
if (!displayDimensions || !cropDimensions) return;
const nextRotation = (rotation + 90) % 360;
@@ -708,7 +669,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
setHasEdits(hasEditsFrom(zoomRatio, nextRotation, clamped, heightRatio));
}, [applyTransform, cropDimensions, displayDimensions, heightRatio, rotation, zoomRatio]);
const handleReset = React.useCallback(() => {
const handleReset = useCallback(() => {
if (!displayDimensions || !cropDimensions) return;
recalculateLayout(1, true);
@@ -716,7 +677,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
setHeightSliderKey((k) => k + 1);
}, [cropDimensions, displayDimensions, recalculateLayout]);
const handleSave = React.useCallback(async () => {
const handleSave = useCallback(async () => {
const img = imageRef.current;
if (!img || !displayDimensions || !cropDimensions) return;
@@ -728,8 +689,8 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
height: displayDimensions.height * zoomRatio,
};
const outBlob = isGif
? await exportAnimatedGif(
const outBlob = isAnimated
? await exportAnimatedImage(
img,
scaledDisplayDimensions,
cropDimensions,
@@ -739,6 +700,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
maxHeight,
sizeLimitBytes,
imageUrl,
sourceMimeType,
)
: await exportStaticImage(
img,
@@ -754,7 +716,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
onCropComplete(outBlob);
ModalActionCreators.pop();
} catch (error) {
console.error('Error cropping image:', error);
logger.error('Error cropping image:', error);
const message = error instanceof Error && error.message ? error.message : errorMessage;
ToastActionCreators.createToast({
type: 'error',
@@ -768,7 +730,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
displayDimensions,
errorMessage,
imageUrl,
isGif,
isAnimated,
maxHeight,
maxWidth,
onCropComplete,
@@ -777,16 +739,16 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
zoomRatio,
]);
const handleSkip = React.useCallback(() => {
const handleSkip = useCallback(() => {
if (onSkip) onSkip();
ModalActionCreators.pop();
}, [onSkip]);
const handleCancel = React.useCallback(() => {
const handleCancel = useCallback(() => {
ModalActionCreators.pop();
}, []);
React.useEffect(() => {
useEffect(() => {
const onMouseMove = (e: MouseEvent) => handleMouseMove(e);
const onMouseUp = () => handleMouseUp();
window.addEventListener('mousemove', onMouseMove);
@@ -800,7 +762,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
};
}, [handleImageLoad, handleMouseMove, handleMouseUp]);
React.useEffect(() => {
useEffect(() => {
let isSliderDragging = false;
const onSliderDragStart = () => {
@@ -830,7 +792,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
};
}, []);
const imageStyle: React.CSSProperties = React.useMemo(() => {
const imageStyle: React.CSSProperties = useMemo(() => {
if (!displayDimensions) return {};
const width = displayDimensions.width * zoomRatio;
const height = displayDimensions.height * zoomRatio;
@@ -855,7 +817,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
<img
ref={imageRef}
src={imageUrl}
alt="crop"
alt={t`Crop preview`}
className={styles.image}
style={{
opacity: displayDimensions ? 1 : 0,
@@ -964,7 +926,7 @@ export const ImageCropModal: React.FC<ImageCropModalProps> = observer(
className={styles.rotateButton}
onClick={handleRotate}
disabled={isProcessing}
title={t`Rotate clockwise`}
title={t`Rotate Clockwise`}
>
<ArrowClockwiseIcon size={24} weight="regular" className={styles.rotateIcon} />
</button>

View File

@@ -17,17 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import KeybindManager from '@app/lib/KeybindManager';
import InputMonitoringPromptsStore from '@app/stores/InputMonitoringPromptsStore';
import {openNativePermissionSettings, requestNativePermission} from '@app/utils/NativePermissions';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import KeybindManager from '~/lib/KeybindManager';
import InputMonitoringPromptsStore from '~/stores/InputMonitoringPromptsStore';
import {openNativePermissionSettings, requestNativePermission} from '~/utils/NativePermissions';
import {Button} from '../uikit/Button/Button';
import styles from './ConfirmModal.module.css';
import * as Modal from './Modal';
import type React from 'react';
import {useRef, useState} from 'react';
interface InputMonitoringCTAModalProps {
onComplete?: () => void;
@@ -35,8 +35,8 @@ interface InputMonitoringCTAModalProps {
export const InputMonitoringCTAModal: React.FC<InputMonitoringCTAModalProps> = observer(({onComplete}) => {
const {t} = useLingui();
const [submitting, setSubmitting] = React.useState(false);
const initialFocusRef = React.useRef<HTMLButtonElement | null>(null);
const [submitting, setSubmitting] = useState(false);
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
const handleEnable = async () => {
setSubmitting(true);
@@ -71,19 +71,21 @@ export const InputMonitoringCTAModal: React.FC<InputMonitoringCTAModalProps> = o
return (
<Modal.Root size="small" centered initialFocusRef={initialFocusRef}>
<Modal.Header title={t`Enable Input Monitoring`} />
<Modal.Content className={styles.content}>
<p>
<Trans>
Fluxer needs permission to monitor keyboard and mouse input so that <strong>Push-to-Talk</strong> and{' '}
<strong>Global Shortcuts</strong> work even when you're in another app or game.
</Trans>
</p>
<p style={{marginTop: '12px'}}>
<Trans>
This is required to detect any key or mouse button you choose for Push-to-Talk. You can change this later in{' '}
<strong>System Settings Privacy & Security Input Monitoring</strong>.
</Trans>
</p>
<Modal.Content>
<Modal.ContentLayout>
<Modal.Description>
<Trans>
Fluxer needs permission to monitor keyboard and mouse input so that <strong>Push-to-Talk</strong> and{' '}
<strong>Global Shortcuts</strong> work even when you're in another app or game.
</Trans>
</Modal.Description>
<Modal.Description>
<Trans>
This is required to detect any key or mouse button you choose for Push-to-Talk. You can change this later
in <strong>System Settings Privacy & Security Input Monitoring</strong>.
</Trans>
</Modal.Description>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={handleDismiss} variant="secondary">

View File

@@ -17,28 +17,31 @@
* 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 {AuthErrorState} from '~/components/auth/AuthErrorState';
import {AuthLoadingState} from '~/components/auth/AuthLoadingState';
import {InviteHeader} from '~/components/auth/InviteHeader';
import styles from '~/components/modals/InviteAcceptModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import foodPatternUrl from '~/images/i-like-food.svg';
import InviteStore from '~/stores/InviteStore';
import {isGroupDmInvite, isGuildInvite, isPackInvite as isPackInviteGuard} from '~/types/InviteTypes';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {getGroupDmInviteCounts} from '~/utils/invite/GroupDmInviteCounts';
import * as InviteActionCreators from '@app/actions/InviteActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {AuthErrorState} from '@app/components/auth/AuthErrorState';
import {AuthLoadingState} from '@app/components/auth/AuthLoadingState';
import {InviteHeader} from '@app/components/auth/InviteHeader';
import styles from '@app/components/modals/InviteAcceptModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import foodPatternUrl from '@app/images/i-like-food.svg';
import {Logger} from '@app/lib/Logger';
import InviteStore from '@app/stores/InviteStore';
import {isGroupDmInvite, isGuildInvite, isPackInvite as isPackInviteGuard} from '@app/types/InviteTypes';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import {getGroupDmInviteCounts} from '@app/utils/invite/GroupDmInviteCounts';
import {
GuildInvitePrimaryAction,
getGuildInviteActionState,
getGuildInvitePrimaryAction,
isGuildInviteActionDisabled,
} from '~/utils/invite/GuildInviteActionState';
} from '@app/utils/invite/GuildInviteActionState';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import {useCallback, useEffect, useMemo, useState} from 'react';
const logger = new Logger('InviteAcceptModal');
interface InviteAcceptModalProps {
code: string;
@@ -49,9 +52,9 @@ export const InviteAcceptModal = observer(function InviteAcceptModal({code}: Inv
const inviteState = InviteStore.invites.get(code) ?? null;
const invite = inviteState?.data ?? null;
const [isAccepting, setIsAccepting] = React.useState(false);
const [isAccepting, setIsAccepting] = useState(false);
React.useEffect(() => {
useEffect(() => {
if (!inviteState) {
void InviteActionCreators.fetchWithCoalescing(code).catch(() => {});
}
@@ -70,7 +73,7 @@ export const InviteAcceptModal = observer(function InviteAcceptModal({code}: Inv
const guildActionState = getGuildInviteActionState({invite});
const {presenceCount, memberCount} = guildActionState;
const inviteForHeader = React.useMemo(() => {
const inviteForHeader = useMemo(() => {
if (!invite) return null;
if (isGroupDM && groupDMCounts) {
return {
@@ -85,7 +88,7 @@ export const InviteAcceptModal = observer(function InviteAcceptModal({code}: Inv
};
}, [invite, isGroupDM, presenceCount, memberCount, groupDMCounts?.memberCount]);
const splashUrl = React.useMemo(() => {
const splashUrl = useMemo(() => {
if (!invite || !isGuildInvite(invite)) {
return null;
}
@@ -105,7 +108,7 @@ export const InviteAcceptModal = observer(function InviteAcceptModal({code}: Inv
const isJoinDisabled = isGuildInviteActionDisabled(guildActionState);
const primaryActionType = getGuildInvitePrimaryAction(guildActionState);
const primaryLabel = React.useMemo(() => {
const primaryLabel = useMemo(() => {
if (isGroupDM) return t`Join Group DM`;
switch (primaryActionType) {
case GuildInvitePrimaryAction.InvitesDisabled:
@@ -117,17 +120,17 @@ export const InviteAcceptModal = observer(function InviteAcceptModal({code}: Inv
}
}, [isGroupDM, primaryActionType]);
const handleDismiss = React.useCallback(() => {
const handleDismiss = useCallback(() => {
ModalActionCreators.pop();
}, []);
const handleAccept = React.useCallback(async () => {
const handleAccept = useCallback(async () => {
setIsAccepting(true);
try {
await InviteActionCreators.acceptAndTransitionToChannel(code, i18n);
ModalActionCreators.pop();
} catch (error) {
console.error('[InviteAcceptModal] Failed to accept invite:', error);
logger.error(' Failed to accept invite:', error);
setIsAccepting(false);
}
}, [code]);

View File

@@ -17,20 +17,19 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {PreviewGuildInviteHeader} from '@app/components/auth/InviteHeader';
import styles from '@app/components/modals/InviteAcceptModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import foodPatternUrl from '@app/images/i-like-food.svg';
import type {GuildRecord} from '@app/records/GuildRecord';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import PresenceStore from '@app/stores/PresenceStore';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {GuildFeatures} from '~/Constants';
import {PreviewGuildInviteHeader} from '~/components/auth/InviteHeader';
import styles from '~/components/modals/InviteAcceptModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import foodPatternUrl from '~/images/i-like-food.svg';
import type {GuildRecord} from '~/records/GuildRecord';
import GuildMemberStore from '~/stores/GuildMemberStore';
import PresenceStore from '~/stores/PresenceStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import {useCallback, useMemo} from 'react';
interface InviteAcceptModalPreviewProps {
guild: GuildRecord;
@@ -50,20 +49,18 @@ export const InviteAcceptModalPreview = observer(function InviteAcceptModalPrevi
hasClearedSplash,
}: InviteAcceptModalPreviewProps) {
const {t} = useLingui();
const handleDismiss = React.useCallback(() => {
const handleDismiss = useCallback(() => {
ModalActionCreators.pop();
}, []);
const presenceCount = PresenceStore.getPresenceCount(guild.id);
const memberCount = GuildMemberStore.getMemberCount(guild.id);
const guildFeatures = React.useMemo(() => {
const guildFeatures = useMemo(() => {
return Array.from(guild.features);
}, [guild.features]);
const isVerified = guildFeatures.includes(GuildFeatures.VERIFIED);
const splashUrl = React.useMemo(() => {
const splashUrl = useMemo(() => {
if (hasClearedSplash) {
return null;
}
@@ -102,7 +99,7 @@ export const InviteAcceptModalPreview = observer(function InviteAcceptModalPrevi
guildId={guild.id}
guildName={guild.name}
guildIcon={guild.icon}
isVerified={isVerified}
features={guildFeatures}
presenceCount={presenceCount}
memberCount={memberCount}
previewIconUrl={hasClearedIcon ? null : previewIconUrl}

View File

@@ -138,12 +138,6 @@
text-decoration: underline;
}
.editLink:focus-visible {
outline: 2px solid var(--brand-primary);
outline-offset: 2px;
border-radius: 2px;
}
.advancedView {
display: flex;
flex-direction: column;

View File

@@ -17,57 +17,60 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as InviteActionCreators from '@app/actions/InviteActionCreators';
import * as MessageActionCreators from '@app/actions/MessageActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as PrivateChannelActionCreators from '@app/actions/PrivateChannelActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Input} from '@app/components/form/Input';
import {Select} from '@app/components/form/Select';
import {Switch} from '@app/components/form/Switch';
import styles from '@app/components/modals/InviteModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {CopyLinkSection} from '@app/components/modals/shared/CopyLinkSection';
import type {RecipientItem} from '@app/components/modals/shared/RecipientList';
import {RecipientList, useRecipientItems} from '@app/components/modals/shared/RecipientList';
import selectorStyles from '@app/components/modals/shared/SelectorModalStyles.module.css';
import {Button} from '@app/components/uikit/button/Button';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Spinner} from '@app/components/uikit/Spinner';
import {Logger} from '@app/lib/Logger';
import ChannelStore from '@app/stores/ChannelStore';
import GuildStore from '@app/stores/GuildStore';
import RuntimeConfigStore from '@app/stores/RuntimeConfigStore';
import * as ChannelUtils from '@app/utils/ChannelUtils';
import {useCopyLinkHandler} from '@app/utils/CopyLinkHandlers';
import * as InviteUtils from '@app/utils/InviteUtils';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas';
import * as SnowflakeUtils from '@fluxer/snowflake/src/SnowflakeUtils';
import {Trans, useLingui} from '@lingui/react/macro';
import {MagnifyingGlassIcon, WarningCircleIcon, WarningIcon} from '@phosphor-icons/react';
import clsx from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as InviteActionCreators from '~/actions/InviteActionCreators';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as PrivateChannelActionCreators from '~/actions/PrivateChannelActionCreators';
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {GuildFeatures} from '~/Constants';
import {Input} from '~/components/form/Input';
import {Select} from '~/components/form/Select';
import {Switch} from '~/components/form/Switch';
import styles from '~/components/modals/InviteModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {CopyLinkSection} from '~/components/modals/shared/CopyLinkSection';
import type {RecipientItem} from '~/components/modals/shared/RecipientList';
import {RecipientList, useRecipientItems} from '~/components/modals/shared/RecipientList';
import selectorStyles from '~/components/modals/shared/SelectorModalStyles.module.css';
import {Button} from '~/components/uikit/Button/Button';
import {Spinner} from '~/components/uikit/Spinner';
import type {Invite} from '~/records/MessageRecord';
import ChannelStore from '~/stores/ChannelStore';
import GuildStore from '~/stores/GuildStore';
import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
import * as ChannelUtils from '~/utils/ChannelUtils';
import * as InviteUtils from '~/utils/InviteUtils';
import * as SnowflakeUtils from '~/utils/SnowflakeUtils';
import {useCallback, useEffect, useMemo, useState} from 'react';
const logger = new Logger('InviteModal');
export const InviteModal = observer(({channelId}: {channelId: string}) => {
const {t, i18n} = useLingui();
const {t} = useLingui();
const channel = ChannelStore.getChannel(channelId);
const inviteCapability = InviteUtils.getInviteCapability(channelId, channel?.guildId);
const isUsingVanityUrl = inviteCapability.useVanityUrl;
const [invite, setInvite] = React.useState<Invite | null>(null);
const [loading, setLoading] = React.useState(!isUsingVanityUrl);
const [showAdvanced, setShowAdvanced] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const [sentInvites, setSentInvites] = React.useState(new Map<string, boolean>());
const [sendingTo, setSendingTo] = React.useState(new Set<string>());
const [invite, setInvite] = useState<Invite | null>(null);
const [loading, setLoading] = useState(!isUsingVanityUrl);
const [showAdvanced, setShowAdvanced] = useState(false);
const [sentInvites, setSentInvites] = useState(new Map<string, boolean>());
const [sendingTo, setSendingTo] = useState(new Set<string>());
const [maxAge, setMaxAge] = React.useState('604800');
const [maxUses, setMaxUses] = React.useState('0');
const [temporary, setTemporary] = React.useState(false);
const [maxAge, setMaxAge] = useState('604800');
const [maxUses, setMaxUses] = useState('0');
const [temporary, setTemporary] = useState(false);
const recipients = useRecipientItems();
const [searchQuery, setSearchQuery] = React.useState('');
const [searchQuery, setSearchQuery] = useState('');
const maxAgeOptions = React.useMemo(
const maxAgeOptions = useMemo(
() => [
{value: '0', label: t`Never`},
{value: '1800', label: t`30 minutes`},
@@ -80,7 +83,7 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
[t],
);
const maxUsesOptions = React.useMemo(
const maxUsesOptions = useMemo(
() => [
{value: '0', label: t`No limit`},
{value: '1', label: t`1 use`},
@@ -93,7 +96,7 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
[t],
);
const loadInvite = React.useCallback(
const loadInvite = useCallback(
async (options?: {maxAge?: number; maxUses?: number; temporary?: boolean}) => {
if (isUsingVanityUrl) {
return;
@@ -113,7 +116,7 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
[channelId, isUsingVanityUrl],
);
React.useEffect(() => {
useEffect(() => {
if (!isUsingVanityUrl) {
loadInvite({maxAge: 604800, maxUses: 0, temporary: false});
}
@@ -121,7 +124,7 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
if (!channel || channel.guildId == null) {
return (
<Modal.Root size="small" centered>
<Modal.Header title={t`Invite friends`} />
<Modal.Header title={t`Invite Friends`} />
<Modal.Content className={styles.noChannelContent}>
<WarningIcon size={48} weight="fill" className={styles.noChannelIcon} />
<p className={styles.noChannelText}>
@@ -144,37 +147,32 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
? `${RuntimeConfigStore.inviteEndpoint}/${invite.code}`
: '';
const handleCopy = async () => {
if (!inviteUrl) return;
await TextCopyActionCreators.copy(i18n, inviteUrl, true);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleCopy = useCopyLinkHandler(inviteUrl, true);
const handleSendInvite = async (item: RecipientItem) => {
const userId = item.type === 'group_dm' ? item.id : item.user.id;
setSendingTo((prev) => new Set(prev).add(userId));
try {
let targetChannelId: string;
if (item.channelId) {
targetChannelId = item.channelId;
} else {
targetChannelId = await PrivateChannelActionCreators.ensureDMChannel(item.user.id);
}
await MessageActionCreators.send(targetChannelId, {
let targetChannelId: string;
if (item.channelId) {
targetChannelId = item.channelId;
} else {
targetChannelId = await PrivateChannelActionCreators.ensureDMChannel(item.user.id);
}
try {
const result = await MessageActionCreators.send(targetChannelId, {
content: inviteUrl,
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
});
setSentInvites((prev) => new Map(prev).set(userId, true));
if (result) {
setSentInvites((prev) => new Map(prev).set(userId, true));
}
} catch (error) {
console.error('Failed to send invite:', error);
ToastActionCreators.createToast({
type: 'error',
children: <Trans>Failed to send invite</Trans>,
});
logger.error('Failed to send invite:', error);
ToastActionCreators.error(t`Failed to send invite. Please try again.`);
} finally {
setSendingTo((prev) => {
const next = new Set(prev);
@@ -275,7 +273,7 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
) : (
<div className={styles.advancedView}>
<Select
label={t`Expire after`}
label={t`Expire After`}
options={maxAgeOptions}
value={maxAge}
onChange={(value) => {
@@ -285,7 +283,7 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
/>
<Select
label={t`Max number of uses`}
label={t`Max Number of Uses`}
options={maxUsesOptions}
value={maxUses}
onChange={(value) => {
@@ -295,7 +293,7 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
/>
<Switch
label={t`Grant temporary membership`}
label={t`Grant Temporary Membership`}
description={t`Members will be removed when they go offline unless a role is assigned`}
value={temporary}
onChange={setTemporary}
@@ -309,7 +307,6 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
label={<Trans>or send an invite link to a friend:</Trans>}
value={inviteUrl}
onCopy={handleCopy}
copied={copied}
onInputClick={(e) => e.currentTarget.select()}
inputProps={{placeholder: t`Invite link`}}
>
@@ -320,9 +317,11 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
) : (
<p className={styles.expirationText}>
<Trans>Your invite link expires in {getExpirationText()}.</Trans>{' '}
<button type="button" onClick={() => setShowAdvanced(true)} className={styles.editLink}>
<Trans>Edit invite link</Trans>
</button>
<FocusRing offset={-2}>
<button type="button" onClick={() => setShowAdvanced(true)} className={styles.editLink}>
<Trans>Edit invite link</Trans>
</button>
</FocusRing>
</p>
)}
</CopyLinkSection>

View File

@@ -17,31 +17,32 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {AuthBackground} from '@app/components/auth/AuthBackground';
import {AuthBottomLink} from '@app/components/auth/AuthBottomLink';
import {AuthCardContainer} from '@app/components/auth/AuthCardContainer';
import authPageStyles from '@app/components/auth/AuthPageStyles.module.css';
import {PreviewGuildInviteHeader} from '@app/components/auth/InviteHeader';
import {MockMinimalRegisterForm} from '@app/components/auth/MockMinimalRegisterForm';
import authLayoutStyles from '@app/components/layout/AuthLayout.module.css';
import styles from '@app/components/modals/InvitePagePreviewModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {CardAlignmentControls} from '@app/components/uikit/card_alignment_controls/CardAlignmentControls';
import {useAuthBackground} from '@app/hooks/useAuthBackground';
import foodPatternUrl from '@app/images/i-like-food.svg';
import GuildMemberStore from '@app/stores/GuildMemberStore';
import GuildStore from '@app/stores/GuildStore';
import PresenceStore from '@app/stores/PresenceStore';
import WindowStore from '@app/stores/WindowStore';
import * as AvatarUtils from '@app/utils/AvatarUtils';
import type {GuildSplashCardAlignmentValue} from '@fluxer/constants/src/GuildConstants';
import {GuildSplashCardAlignment} from '@fluxer/constants/src/GuildConstants';
import {Trans, useLingui} from '@lingui/react/macro';
import clsx from 'clsx';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import type {GuildSplashCardAlignmentValue} from '~/Constants';
import {GuildFeatures, GuildSplashCardAlignment} from '~/Constants';
import {AuthBackground} from '~/components/auth/AuthBackground';
import {AuthBottomLink} from '~/components/auth/AuthBottomLink';
import {AuthCardContainer} from '~/components/auth/AuthCardContainer';
import authPageStyles from '~/components/auth/AuthPageStyles.module.css';
import {PreviewGuildInviteHeader} from '~/components/auth/InviteHeader';
import {MockMinimalRegisterForm} from '~/components/auth/MockMinimalRegisterForm';
import authLayoutStyles from '~/components/layout/AuthLayout.module.css';
import {Button} from '~/components/uikit/Button/Button';
import {CardAlignmentControls} from '~/components/uikit/CardAlignmentControls/CardAlignmentControls';
import {useAuthBackground} from '~/hooks/useAuthBackground';
import foodPatternUrl from '~/images/i-like-food.svg';
import GuildMemberStore from '~/stores/GuildMemberStore';
import GuildStore from '~/stores/GuildStore';
import PresenceStore from '~/stores/PresenceStore';
import WindowStore from '~/stores/WindowStore';
import * as AvatarUtils from '~/utils/AvatarUtils';
import styles from './InvitePagePreviewModal.module.css';
import * as Modal from './Modal';
import type React from 'react';
import {useCallback, useMemo, useState} from 'react';
interface InvitePagePreviewModalProps {
guildId: string;
@@ -59,10 +60,10 @@ export const InvitePagePreviewModal: React.FC<InvitePagePreviewModalProps> = obs
const {t} = useLingui();
const guild = GuildStore.getGuild(guildId);
const initialAlignment = previewSplashAlignment ?? guild?.splashCardAlignment ?? GuildSplashCardAlignment.CENTER;
const [localAlignment, setLocalAlignment] = React.useState<GuildSplashCardAlignmentValue>(initialAlignment);
const [localAlignment, setLocalAlignment] = useState<GuildSplashCardAlignmentValue>(initialAlignment);
const alignmentControlsEnabled = WindowStore.windowSize.width >= ALIGNMENT_MIN_WIDTH;
const splashUrl = React.useMemo(() => {
const splashUrl = useMemo(() => {
if (previewSplashUrl) return previewSplashUrl;
if (guild?.splash) {
return AvatarUtils.getGuildSplashURL({id: guild.id, splash: guild.splash}, 4096);
@@ -73,11 +74,11 @@ export const InvitePagePreviewModal: React.FC<InvitePagePreviewModalProps> = obs
const {patternReady, splashLoaded, splashDimensions} = useAuthBackground(splashUrl, foodPatternUrl);
const shouldShowSplash = Boolean(splashUrl && splashDimensions);
const handleClose = React.useCallback(() => {
const handleClose = useCallback(() => {
ModalActionCreators.pop();
}, []);
const handleAlignmentChange = React.useCallback(
const handleAlignmentChange = useCallback(
(alignment: GuildSplashCardAlignmentValue) => {
setLocalAlignment(alignment);
onAlignmentChange?.(alignment);
@@ -89,7 +90,7 @@ export const InvitePagePreviewModal: React.FC<InvitePagePreviewModalProps> = obs
const splashAlignment = localAlignment;
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
const guildFeatures = Array.from(guild.features);
const presenceCount = PresenceStore.getPresenceCount(guildId);
const memberCount = GuildMemberStore.getMemberCount(guildId);
@@ -149,7 +150,7 @@ export const InvitePagePreviewModal: React.FC<InvitePagePreviewModalProps> = obs
guildId={guild.id}
guildName={guild.name}
guildIcon={guild.icon}
isVerified={isVerified}
features={guildFeatures}
presenceCount={presenceCount}
memberCount={memberCount}
previewIconUrl={previewIconUrl}

View File

@@ -17,23 +17,23 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import styles from '@app/components/modals/KeyboardModeIntroModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import KeyboardModeStore from '@app/stores/KeyboardModeStore';
import {SHIFT_KEY_SYMBOL} from '@app/utils/KeyboardUtils';
import {isNativeMacOS} from '@app/utils/NativeUtils';
import {useLingui} from '@lingui/react/macro';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import KeyboardModeStore from '~/stores/KeyboardModeStore';
import {SHIFT_KEY_SYMBOL} from '~/utils/KeyboardUtils';
import {isNativeMacOS} from '~/utils/NativeUtils';
import styles from './KeyboardModeIntroModal.module.css';
import {useCallback, useRef} from 'react';
export const KeyboardModeIntroModal: React.FC = () => {
export function KeyboardModeIntroModal() {
const {t} = useLingui();
const initialFocusRef = React.useRef<HTMLButtonElement | null>(null);
const initialFocusRef = useRef<HTMLButtonElement | null>(null);
const title = t`Keyboard Mode`;
const commandKeyLabel = isNativeMacOS() ? '⌘' : 'Ctrl';
const handleClose = React.useCallback(() => {
const handleClose = useCallback(() => {
KeyboardModeStore.dismissIntro();
ModalActionCreators.pop();
}, []);
@@ -41,7 +41,7 @@ export const KeyboardModeIntroModal: React.FC = () => {
return (
<Modal.Root size="small" centered initialFocusRef={initialFocusRef}>
<Modal.Header title={title} />
<Modal.Content className={styles.content}>
<Modal.Content contentClassName={styles.content}>
<p className={styles.description}>
{t`You just pressed Tab. Keyboard Mode is now on so you can navigate Fluxer without a mouse.`}
</p>
@@ -82,4 +82,4 @@ export const KeyboardModeIntroModal: React.FC = () => {
</Modal.Footer>
</Modal.Root>
);
};
}

View File

@@ -17,13 +17,16 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as GuildMemberActionCreators from '@app/actions/GuildMemberActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {ConfirmModal} from '@app/components/modals/ConfirmModal';
import {Logger} from '@app/lib/Logger';
import type {UserRecord} from '@app/records/UserRecord';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import * as GuildMemberActionCreators from '~/actions/GuildMemberActionCreators';
import * as ToastActionCreators from '~/actions/ToastActionCreators';
import {ConfirmModal} from '~/components/modals/ConfirmModal';
import type {UserRecord} from '~/records/UserRecord';
const logger = new Logger('KickMemberModal');
export const KickMemberModal: React.FC<{guildId: string; targetUser: UserRecord}> = observer(
({guildId, targetUser}) => {
@@ -36,7 +39,7 @@ export const KickMemberModal: React.FC<{guildId: string; targetUser: UserRecord}
children: <Trans>Successfully kicked {targetUser.tag} from the community</Trans>,
});
} catch (error) {
console.error('Failed to kick member:', error);
logger.error('Failed to kick member:', error);
ToastActionCreators.createToast({
type: 'error',
children: <Trans>Failed to kick member. Please try again.</Trans>,

View File

@@ -33,7 +33,6 @@
}
.mediaContainer img,
.mediaContainer video,
.mediaContainer canvas,
.mediaContainer picture,
.mediaContainer svg {
@@ -46,6 +45,14 @@
pointer-events: auto;
}
.mediaContainer video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
display: block;
pointer-events: auto;
}
.transformWrapper {
width: 100% !important;
height: 100% !important;
@@ -82,15 +89,16 @@
background: transparent;
}
.controlButton:focus-visible {
outline: 2px solid var(--brand-primary);
outline-offset: -2px;
}
.controlButton:active {
transform: scale(0.95);
}
@media (pointer: coarse), (max-width: 767px) {
.controlButton:active {
transform: none;
}
}
.controlButtonDefault {
color: var(--text-tertiary);
}
@@ -196,7 +204,7 @@
.controlsBox {
display: flex;
align-items: center;
gap: 0.125rem;
gap: 0.5rem;
pointer-events: auto;
flex-shrink: 0;
}
@@ -245,6 +253,8 @@
}
.modalContentInner {
--media-content-inner-gap: 0.75rem;
--media-content-inner-padding: var(--media-content-padding, 1rem);
position: relative;
display: flex;
flex-direction: column;
@@ -253,15 +263,15 @@
width: 100%;
height: 100%;
pointer-events: none;
gap: 0.75rem;
padding: var(--media-content-padding, 1rem);
gap: var(--media-content-inner-gap);
padding: var(--media-content-inner-padding);
box-sizing: border-box;
}
@media (max-width: 768px) {
.modalContentInner {
padding: 0.5rem;
gap: 0.5rem;
--media-content-inner-gap: 0;
--media-content-inner-padding: 0;
}
}
@@ -325,13 +335,38 @@
.headerControls {
display: flex;
align-items: stretch;
gap: 0.375rem;
gap: 0.75rem;
margin-left: auto;
flex-shrink: 0;
pointer-events: none;
height: 100%;
}
@media (max-width: 768px) {
.headerControls {
width: 100%;
margin-left: 0;
}
}
.mobileTopBarControls {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
pointer-events: none;
}
.mobileTopBarControls .controlButton {
pointer-events: auto;
background: transparent;
color: white;
}
.mobileTopBarControls .controlButton:hover {
color: white;
}
.actionControlsBox {
display: flex;
align-items: center;
@@ -349,7 +384,7 @@
.closeControlBox {
display: flex;
align-items: center;
padding: 0.25rem 0.375rem;
padding: 0.25rem;
border-radius: var(--radius-lg);
background-color: var(--background-textarea);
border: 1px solid var(--background-modifier-accent);
@@ -357,6 +392,7 @@
pointer-events: auto;
height: 100%;
box-sizing: border-box;
aspect-ratio: 1 / 1;
}
.mediaArea {
@@ -365,7 +401,10 @@
display: flex;
align-items: center;
justify-content: center;
padding: var(--media-content-padding, 1rem);
padding-top: calc(var(--media-top-overlay-height, 48px) + var(--media-overlay-gap, 8px));
padding-bottom: calc(var(--media-bottom-overlay-height, 48px) + var(--media-overlay-gap, 8px));
padding-left: var(--media-side-overlay-width, 0px);
padding-right: var(--media-side-overlay-width, 0px);
width: 100%;
height: 100%;
box-sizing: border-box;
@@ -378,16 +417,16 @@
pointer-events: auto;
}
.mediaAreaZoomed {
padding: 0;
}
@media (max-width: 768px) {
.mediaArea {
padding: 0;
}
}
.mediaAreaZoomed {
padding: 0;
}
.desktopViewerContainer {
display: flex;
height: 100%;
@@ -446,7 +485,7 @@
width: 100%;
align-items: center;
justify-content: center;
padding: var(--media-content-padding, 1rem);
padding: 0;
box-sizing: border-box;
}
@@ -458,9 +497,11 @@
.nonZoomContentInner {
pointer-events: auto;
width: 100%;
height: 100%;
display: flex;
width: fit-content;
height: fit-content;
max-width: 100%;
max-height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
}
@@ -487,21 +528,35 @@
color: white;
}
.thumbnailCarousel {
.thumbnailCarouselWrapper {
position: absolute;
bottom: var(--media-content-padding, 1rem);
bottom: calc(var(--media-content-padding, 1rem) * 0.65);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.25rem;
padding: 0.25rem;
border-radius: var(--radius-md);
background-color: transparent;
backdrop-filter: none;
padding: 0.35rem 0.4rem;
pointer-events: auto;
max-width: min(960px, 94vw);
overflow-x: auto;
box-shadow: none;
max-width: min(760px, 90vw);
width: auto;
overflow: hidden;
background-clip: padding-box;
display: inline-flex;
align-items: center;
justify-content: center;
}
.thumbnailCarouselScroller {
max-width: 100%;
}
.thumbnailCarousel {
display: flex;
width: auto;
min-width: max-content;
gap: 1px;
padding: 0.125rem;
margin: 0;
border-radius: 0.5rem;
background-color: transparent;
}
.thumbnailButton {
@@ -509,38 +564,37 @@
padding: 0;
background: transparent;
cursor: pointer;
border-radius: 0.5rem;
border-radius: 0;
flex: 0 0 auto;
}
.thumbnailButton:focus-visible {
outline: none;
}
.thumbnailImageWrapper {
position: relative;
width: 56px;
height: 56px;
border-radius: 0.5rem;
width: 44px;
height: 44px;
border-radius: 0;
overflow: hidden;
background-color: var(--background-primary);
opacity: 0.7;
background-color: var(--background-secondary);
filter: grayscale(0.25) brightness(0.7);
opacity: 0.55;
transition:
opacity 140ms ease,
box-shadow 140ms ease;
opacity 150ms ease,
filter 150ms ease;
}
.thumbnailButton:hover .thumbnailImageWrapper,
.thumbnailButton:focus-visible .thumbnailImageWrapper {
opacity: 1;
box-shadow:
0 0 0 2px var(--background-tertiary),
0 10px 28px rgb(0 0 0 / 0.35);
.thumbnailImageWrapperFirst {
border-top-left-radius: 0.375rem;
border-bottom-left-radius: 0.375rem;
}
.thumbnailImageWrapperLast {
border-top-right-radius: 0.375rem;
border-bottom-right-radius: 0.375rem;
}
.thumbnailButtonSelected .thumbnailImageWrapper {
opacity: 1;
box-shadow: 0 0 0 2px var(--brand-primary);
filter: none;
}
.thumbnailImage {
@@ -572,16 +626,82 @@
letter-spacing: 0.02em;
}
.thumbnailBadge {
.floatingNavButtonLeft,
.floatingNavButtonRight {
position: absolute;
left: 8px;
bottom: 8px;
padding: 2px 5px;
border-radius: 9999px;
background-color: rgb(0 0 0 / 0.65);
color: white;
font-size: 0.625rem;
line-height: 0.875rem;
font-weight: 600;
text-transform: uppercase;
top: 50%;
transform: translateY(-50%);
z-index: 100;
pointer-events: auto;
}
.floatingNavButtonLeft {
left: 12px;
}
.floatingNavButtonRight {
right: 12px;
}
.floatingNavButton {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 9999px;
background-color: var(--background-textarea);
border: 1px solid var(--background-modifier-accent);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.15);
color: var(--text-secondary);
cursor: pointer;
transition:
background-color 150ms ease,
color 150ms ease,
transform 150ms ease,
opacity 150ms ease;
}
.floatingNavButton:hover {
background-color: var(--background-secondary-alt);
color: var(--text-primary);
}
.floatingNavButton:active {
transform: scale(0.95);
}
.floatingNavButtonDisabled {
opacity: 0.4;
cursor: not-allowed;
}
.floatingNavButtonDisabled:hover {
background-color: var(--background-textarea);
color: var(--text-secondary);
}
.floatingNavButtonDisabled:active {
transform: none;
}
.klipyAttribution {
position: absolute;
bottom: var(--media-content-padding, 1rem);
right: var(--media-content-padding, 1rem);
z-index: 50;
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: var(--radius-lg);
background-color: var(--background-textarea);
border: 1px solid var(--background-modifier-accent);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.15);
pointer-events: none;
}
.klipyAttribution svg {
height: 16px;
width: auto;
color: var(--text-primary-muted);
}

View File

@@ -17,11 +17,23 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as MediaViewerActionCreators from '@app/actions/MediaViewerActionCreators';
import {ExpiryFootnote} from '@app/components/common/ExpiryFootnote';
import styles from '@app/components/modals/MediaModal.module.css';
import {MobileVideoViewer} from '@app/components/modals/MobileVideoViewer';
import FocusRing from '@app/components/uikit/focus_ring/FocusRing';
import {Scroller} from '@app/components/uikit/Scroller';
import {Tooltip} from '@app/components/uikit/tooltip/Tooltip';
import PoweredByKlipySvg from '@app/images/powered-by-klipy.svg?react';
import AccessibilityStore from '@app/stores/AccessibilityStore';
import LayerManager from '@app/stores/LayerManager';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import {useLingui} from '@lingui/react/macro';
import {
ArrowSquareOutIcon,
CaretLeftIcon,
CaretRightIcon,
DotsThreeIcon,
DownloadSimpleIcon,
InfoIcon,
MagnifyingGlassMinusIcon,
@@ -32,24 +44,20 @@ import {
import {clsx} from 'clsx';
import {AnimatePresence, motion} from 'framer-motion';
import {observer} from 'mobx-react-lite';
import type {
CSSProperties,
FC,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
ReactNode,
import type {CSSProperties, FC, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent} from 'react';
import {
createElement,
forwardRef,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {createElement, forwardRef, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import {TransformComponent, TransformWrapper} from 'react-zoom-pan-pinch';
import * as MediaViewerActionCreators from '~/actions/MediaViewerActionCreators';
import {ExpiryFootnote} from '~/components/common/ExpiryFootnote';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Tooltip} from '~/components/uikit/Tooltip';
import AccessibilityStore from '~/stores/AccessibilityStore';
import LayerManager from '~/stores/LayerManager';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import styles from './MediaModal.module.css';
interface MediaModalProps {
title: string;
@@ -72,12 +80,19 @@ interface MediaModalProps {
totalAttachments?: number;
onPrevious?: () => void;
onNext?: () => void;
thumbnails?: Array<{
src: string;
alt?: string;
type?: 'image' | 'gif' | 'gifv' | 'video' | 'audio';
}>;
thumbnails?: ReadonlyArray<MediaThumbnail>;
onSelectThumbnail?: (index: number) => void;
providerName?: string;
videoSrc?: string;
initialTime?: number;
mediaType?: 'image' | 'video' | 'audio';
onMenuOpen?: () => void;
}
interface MediaThumbnail {
src: string;
alt?: string;
type?: 'image' | 'gif' | 'gifv' | 'video' | 'audio';
}
interface ControlButtonProps {
@@ -139,7 +154,7 @@ interface FileInfoProps {
}
const FileInfo: FC<FileInfoProps> = observer(
({fileName, fileSize, dimensions, expiryInfo, currentIndex, totalAttachments, onPrevious, onNext}) => {
({fileName, fileSize, dimensions, expiryInfo, currentIndex, totalAttachments, onPrevious, onNext}: FileInfoProps) => {
const {t} = useLingui();
const hasNavigation = currentIndex !== undefined && totalAttachments !== undefined && totalAttachments > 1;
@@ -218,7 +233,7 @@ const Controls: FC<ControlsProps> = observer(
zoomState = 'fit',
onZoom,
enableZoomControls = false,
}) => {
}: ControlsProps) => {
const {t} = useLingui();
const [isHoveringActions, setIsHoveringActions] = useState(false);
const hasActions =
@@ -320,6 +335,30 @@ const Controls: FC<ControlsProps> = observer(
},
);
interface CompactMobileControlsProps {
onClose: () => void;
onMenuOpen?: () => void;
}
const CompactMobileControls: FC<CompactMobileControlsProps> = observer(
({onClose, onMenuOpen}: CompactMobileControlsProps) => {
const {t} = useLingui();
return (
<div className={styles.mobileTopBarControls} role="toolbar" aria-label={t`Media controls`}>
<ControlButton icon={<XIcon size={20} weight="bold" />} label={t`Close`} onClick={onClose} />
{onMenuOpen && (
<ControlButton
icon={<DotsThreeIcon size={20} weight="bold" />}
label={t`More options`}
onClick={onMenuOpen}
/>
)}
</div>
);
},
);
type ZoomState = 'fit' | 'zoomed';
interface DesktopMediaViewerProps {
@@ -331,7 +370,7 @@ interface DesktopMediaViewerProps {
}
const DesktopMediaViewer: FC<DesktopMediaViewerProps> = observer(
({children, onClose, onZoomStateChange, zoomState: externalZoomState, onZoom}) => {
({children, onClose, onZoomStateChange, zoomState: externalZoomState, onZoom}: DesktopMediaViewerProps) => {
const [internalZoomState, setInternalZoomState] = useState<ZoomState>('fit');
const [panX, setPanX] = useState(0);
const [panY, setPanY] = useState(0);
@@ -348,8 +387,21 @@ const DesktopMediaViewer: FC<DesktopMediaViewerProps> = observer(
const zoomState = externalZoomState ?? internalZoomState;
currentZoomStateRef.current = zoomState;
useLayoutEffect(() => {
if (externalZoomState === undefined) {
return;
}
setPanX(0);
setPanY(0);
}, [externalZoomState]);
const updateZoomState = useCallback(
(newState: ZoomState) => {
if (currentZoomStateRef.current === newState) {
return;
}
currentZoomStateRef.current = newState;
setPanX(0);
setPanY(0);
@@ -511,6 +563,7 @@ const DesktopMediaViewer: FC<DesktopMediaViewerProps> = observer(
role="img"
style={{
transform: `translate3d(${panX / zoomScale}px, ${panY / zoomScale}px, 0) scale(${zoomScale})`,
transformOrigin: 'center center',
willChange: isDragging ? 'transform' : 'auto',
}}
onMouseEnter={() => setIsHoveringContent(true)}
@@ -525,7 +578,11 @@ const DesktopMediaViewer: FC<DesktopMediaViewerProps> = observer(
},
);
const MobileMediaViewer: FC<{children: ReactNode}> = observer(({children}) => {
interface MobileMediaViewerProps {
children: ReactNode;
}
const MobileMediaViewer: FC<MobileMediaViewerProps> = observer(({children}: MobileMediaViewerProps) => {
return (
<div className={styles.mobileViewerContainer}>
<TransformWrapper
@@ -573,20 +630,57 @@ export const MediaModal: FC<MediaModalProps> = observer(
onNext,
thumbnails,
onSelectThumbnail,
}) => {
providerName,
videoSrc,
initialTime,
mediaType,
onMenuOpen,
}: MediaModalProps) => {
const {enabled: isMobile} = MobileLayoutStore;
const modalKey = useRef(Math.random().toString(36).substring(7));
const prefersReducedMotion = AccessibilityStore.useReducedMotion;
const [zoomState, setZoomState] = useState<ZoomState>('fit');
const [viewportPadding, setViewportPadding] = useState(getViewportPadding);
const headerBarRef = useRef<HTMLDivElement>(null);
const thumbnailCarouselRef = useRef<HTMLDivElement>(null);
const navigationOverlayRef = useRef<HTMLDivElement>(null);
const klipyAttributionRef = useRef<HTMLDivElement>(null);
const latestIndexRef = useRef(currentIndex ?? 0);
const [topOverlayHeight, setTopOverlayHeight] = useState(0);
const [bottomOverlayHeight, setBottomOverlayHeight] = useState(0);
const measureOverlayHeights = useCallback(() => {
const nextTopOverlayHeight = Math.ceil(headerBarRef.current?.getBoundingClientRect().height ?? 0);
const nextBottomOverlayHeight = Math.ceil(
Math.max(
thumbnailCarouselRef.current?.getBoundingClientRect().height ?? 0,
navigationOverlayRef.current?.getBoundingClientRect().height ?? 0,
klipyAttributionRef.current?.getBoundingClientRect().height ?? 0,
),
);
setTopOverlayHeight((previousHeight) =>
previousHeight === nextTopOverlayHeight ? previousHeight : nextTopOverlayHeight,
);
setBottomOverlayHeight((previousHeight) =>
previousHeight === nextBottomOverlayHeight ? previousHeight : nextBottomOverlayHeight,
);
}, []);
const handleClose = useCallback(() => {
MediaViewerActionCreators.closeMediaViewer();
}, []);
const handleZoom = useCallback((state: ZoomState) => {
setZoomState(state);
setZoomState((previousState) => (previousState === state ? previousState : state));
}, []);
useEffect(() => {
if (currentIndex !== undefined) {
latestIndexRef.current = currentIndex;
}
}, [currentIndex]);
useEffect(() => {
LayerManager.addLayer('modal', modalKey.current, handleClose);
return () => {
@@ -612,73 +706,213 @@ export const MediaModal: FC<MediaModalProps> = observer(
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return;
if (e.key === 'Escape') {
handleClose();
} else if (e.key === 'ArrowLeft' && onPrevious) {
return;
}
const target = e.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && currentIndex !== undefined && onSelectThumbnail) {
const count = thumbnails?.length ?? 0;
if (count > 1) {
e.preventDefault();
const delta = e.key === 'ArrowRight' ? 1 : -1;
const latest = latestIndexRef.current;
const nextIndex = (latest + delta + count) % count;
latestIndexRef.current = nextIndex;
setZoomState('fit');
setRovingThumbnailIndex(nextIndex);
onSelectThumbnail(nextIndex);
return;
}
}
if (e.key === 'ArrowLeft' && onPrevious) {
e.preventDefault();
onPrevious();
} else if (e.key === 'ArrowRight' && onNext) {
return;
}
if (e.key === 'ArrowRight' && onNext) {
e.preventDefault();
onNext();
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleClose, onPrevious, onNext]);
const contentSizingStyle = useMemo(
() => ({'--media-content-padding': `${viewportPadding}px`}) as CSSProperties,
[viewportPadding],
);
}, [handleClose, onPrevious, onNext, onSelectThumbnail, thumbnails]);
const hasThumbnailCarousel =
thumbnails && thumbnails.length > 1 && currentIndex !== undefined && onSelectThumbnail !== undefined;
const contentSizingStyle = useMemo(() => {
const minimumTopOverlayHeight = isMobile ? 40 : 48;
const hasNavigationOverlay = currentIndex !== undefined && totalAttachments !== undefined && totalAttachments > 1;
const hasBottomOverlay =
zoomState === 'fit' &&
(Boolean(providerName === 'KLIPY') || Boolean(hasThumbnailCarousel) || hasNavigationOverlay);
const minimumBottomOverlayHeight = hasBottomOverlay ? 48 : 0;
const hasSideNavButtons = hasThumbnailCarousel && zoomState !== 'zoomed' && !isMobile;
const navButtonInset = 12 + 48 + 12;
const sideOverlayWidth = hasSideNavButtons ? Math.max(0, navButtonInset - viewportPadding) : 0;
return {
'--media-content-padding': `${viewportPadding}px`,
'--media-edge-padding': `${viewportPadding}px`,
'--media-top-overlay-height': `${Math.max(topOverlayHeight, minimumTopOverlayHeight)}px`,
'--media-bottom-overlay-height': `${Math.max(bottomOverlayHeight, minimumBottomOverlayHeight)}px`,
'--media-overlay-gap': '8px',
'--media-side-overlay-width': `${sideOverlayWidth}px`,
} as CSSProperties;
}, [
viewportPadding,
topOverlayHeight,
bottomOverlayHeight,
isMobile,
currentIndex,
totalAttachments,
zoomState,
providerName,
hasThumbnailCarousel,
]);
useLayoutEffect(() => {
measureOverlayHeights();
const observer = new ResizeObserver(() => {
measureOverlayHeights();
});
if (headerBarRef.current) observer.observe(headerBarRef.current);
if (thumbnailCarouselRef.current) observer.observe(thumbnailCarouselRef.current);
if (navigationOverlayRef.current) observer.observe(navigationOverlayRef.current);
if (klipyAttributionRef.current) observer.observe(klipyAttributionRef.current);
return () => observer.disconnect();
}, [measureOverlayHeights, hasThumbnailCarousel, providerName, currentIndex, totalAttachments, zoomState]);
const thumbnailCount = thumbnails?.length ?? 0;
const thumbnailButtonRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [rovingThumbnailIndex, setRovingThumbnailIndex] = useState<number>(() => currentIndex ?? 0);
useEffect(() => {
thumbnailButtonRefs.current = thumbnailButtonRefs.current.slice(0, thumbnailCount);
}, [thumbnailCount]);
useEffect(() => {
if (!thumbnailCount && rovingThumbnailIndex !== 0) {
setRovingThumbnailIndex(0);
return;
}
if (thumbnailCount && rovingThumbnailIndex >= thumbnailCount) {
setRovingThumbnailIndex(thumbnailCount - 1);
}
}, [thumbnailCount, rovingThumbnailIndex]);
useEffect(() => {
if (currentIndex !== undefined && currentIndex !== rovingThumbnailIndex) {
setRovingThumbnailIndex(currentIndex);
}
}, [currentIndex, rovingThumbnailIndex]);
const handleThumbnailSelect = useCallback(
(index: number) => {
if (!onSelectThumbnail || currentIndex === undefined) return;
setZoomState('fit');
setRovingThumbnailIndex(index);
onSelectThumbnail(index);
},
[onSelectThumbnail, currentIndex],
);
const focusThumbnailButton = useCallback((index: number) => {
const button = thumbnailButtonRefs.current[index];
button?.focus();
}, []);
const handleThumbnailKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLElement>) => {
if (!thumbnailCount) return;
let nextIndex = rovingThumbnailIndex;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
nextIndex = (rovingThumbnailIndex + 1) % thumbnailCount;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
nextIndex = (rovingThumbnailIndex - 1 + thumbnailCount) % thumbnailCount;
} else if (e.key === 'Home') {
nextIndex = 0;
} else if (e.key === 'End') {
nextIndex = thumbnailCount - 1;
} else {
return;
}
if (nextIndex === rovingThumbnailIndex) return;
e.preventDefault();
e.stopPropagation();
handleThumbnailSelect(nextIndex);
focusThumbnailButton(nextIndex);
},
[focusThumbnailButton, handleThumbnailSelect, rovingThumbnailIndex, thumbnailCount],
);
const {t} = useLingui();
const wrappedChildren = useMemo(() => <div className={styles.mediaContainer}>{children}</div>, [children]);
const mediaContent = enablePanZoom
? isMobile
? createElement(MobileMediaViewer, null, wrappedChildren)
: createElement(DesktopMediaViewer, {
onClose: handleClose,
onZoomStateChange: setZoomState,
zoomState,
onZoom: handleZoom,
// biome-ignore lint/correctness/noChildrenProp: Desktop viewer expects children prop to wrap content
children: wrappedChildren,
})
: createElement(
'div',
{className: styles.nonZoomMediaContainer},
createElement('div', {
className: styles.nonZoomBackdrop,
role: 'button',
tabIndex: 0,
onClick: handleClose,
onKeyDown: (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClose();
}
},
'aria-label': 'Close media viewer',
}),
createElement(
const isMobileVideo = isMobile && mediaType === 'video' && videoSrc;
const showDesktopControls = !isMobile;
const showCompactMobileControls = isMobile && !isMobileVideo;
const mediaContent = isMobileVideo
? createElement(MobileVideoViewer, {
src: videoSrc,
initialTime,
loop: true,
onClose: handleClose,
onMenuOpen,
})
: enablePanZoom
? isMobile
? createElement(MobileMediaViewer, null, wrappedChildren)
: createElement(DesktopMediaViewer, {
onClose: handleClose,
onZoomStateChange: setZoomState,
zoomState,
onZoom: handleZoom,
children: wrappedChildren,
})
: createElement(
'div',
{className: styles.nonZoomContent},
createElement('div', {className: styles.nonZoomContentInner}, wrappedChildren),
),
);
{className: styles.nonZoomMediaContainer},
createElement('div', {
className: styles.nonZoomBackdrop,
role: 'button',
tabIndex: 0,
onClick: handleClose,
onKeyDown: (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClose();
}
},
'aria-label': 'Close media viewer',
}),
createElement(
'div',
{className: styles.nonZoomContent},
createElement('div', {className: styles.nonZoomContentInner}, wrappedChildren),
),
);
const modalContent = (
<AnimatePresence>
@@ -688,7 +922,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
transition={prefersReducedMotion ? {duration: 0.05} : {duration: 0.2}}
transition={prefersReducedMotion ? {duration: 0} : {duration: 0.2}}
aria-hidden="true"
onClick={handleClose}
/>
@@ -698,13 +932,16 @@ export const MediaModal: FC<MediaModalProps> = observer(
initial={{opacity: 0}}
animate={{opacity: 1}}
exit={{opacity: 0}}
transition={prefersReducedMotion ? {duration: 0.05} : {duration: 0.2}}
transition={prefersReducedMotion ? {duration: 0} : {duration: 0.2}}
role="dialog"
aria-modal="true"
aria-label={title}
>
<div className={clsx(styles.modalContentInner, zoomState === 'zoomed' && styles.modalContentInnerZoomed)}>
<div className={styles.headerBar}>
<div
className={clsx(styles.modalContentInner, zoomState === 'zoomed' && styles.modalContentInnerZoomed)}
style={contentSizingStyle}
>
<div ref={headerBarRef} className={styles.headerBar}>
{zoomState !== 'zoomed' && (
<div className={styles.headerMeta}>
<FileInfo
@@ -720,78 +957,144 @@ export const MediaModal: FC<MediaModalProps> = observer(
</div>
)}
<div className={styles.headerControls}>
<Controls
isFavorited={isFavorited}
onFavorite={onFavorite}
onSave={onSave}
onOpenInBrowser={onOpenInBrowser}
onInfo={onInfo}
onClose={handleClose}
additionalActions={additionalActions}
zoomState={zoomState}
onZoom={enablePanZoom && !isMobile ? handleZoom : undefined}
enableZoomControls={enablePanZoom && !isMobile}
/>
</div>
{showDesktopControls && (
<div className={styles.headerControls}>
<Controls
isFavorited={isFavorited}
onFavorite={onFavorite}
onSave={onSave}
onOpenInBrowser={onOpenInBrowser}
onInfo={onInfo}
onClose={handleClose}
additionalActions={additionalActions}
zoomState={zoomState}
onZoom={enablePanZoom && !isMobile ? handleZoom : undefined}
enableZoomControls={enablePanZoom && !isMobile}
/>
</div>
)}
{showCompactMobileControls && (
<div className={styles.headerControls}>
<CompactMobileControls onClose={handleClose} onMenuOpen={onMenuOpen} />
</div>
)}
</div>
<div
className={clsx(styles.mediaArea, zoomState === 'zoomed' && styles.mediaAreaZoomed)}
style={contentSizingStyle}
>
<div className={clsx(styles.mediaArea, zoomState === 'zoomed' && styles.mediaAreaZoomed)}>
{mediaContent}
</div>
{hasThumbnailCarousel && (
<div className={styles.thumbnailCarousel} role="listbox" aria-label={t`Attachment thumbnails`}>
{thumbnails?.map((thumb, index) => {
const isSelected = currentIndex === index;
const badgeText =
thumb.type === 'audio'
? t`Audio`
: thumb.type === 'video' || thumb.type === 'gifv'
? t`Video`
: thumb.type === 'gif'
? t`GIF`
: undefined;
{providerName === 'KLIPY' && zoomState === 'fit' && (
<div ref={klipyAttributionRef} className={styles.klipyAttribution}>
<PoweredByKlipySvg />
</div>
)}
return (
<FocusRing key={`${thumb.src}-${index}`} offset={-2}>
<button
type="button"
role="option"
aria-selected={isSelected}
aria-label={thumb.alt ?? t`Attachment ${index + 1}`}
className={clsx(styles.thumbnailButton, isSelected && styles.thumbnailButtonSelected)}
onClick={() => handleThumbnailSelect(index)}
>
<div className={styles.thumbnailImageWrapper}>
{thumb.type === 'video' || thumb.type === 'gifv' ? (
<video
className={styles.thumbnailVideo}
src={thumb.src}
muted
playsInline
preload="metadata"
aria-label={thumb.alt ?? t`Video preview`}
/>
) : thumb.type === 'audio' ? (
<div className={styles.thumbnailPlaceholder}>{t`Audio`}</div>
) : (
<img
src={thumb.src}
alt={thumb.alt ?? ''}
className={styles.thumbnailImage}
draggable={false}
/>
)}
{badgeText && <span className={styles.thumbnailBadge}>{badgeText}</span>}
</div>
</button>
</FocusRing>
);
})}
{hasThumbnailCarousel && zoomState !== 'zoomed' && !isMobile && (
<>
<div className={styles.floatingNavButtonLeft}>
<Tooltip text={t`Previous attachment`} position="right">
<span>
<FocusRing offset={-2}>
<button
type="button"
className={styles.floatingNavButton}
onClick={onPrevious ?? (() => {})}
aria-label={t`Previous attachment`}
>
<CaretLeftIcon size={24} weight="bold" />
</button>
</FocusRing>
</span>
</Tooltip>
</div>
<div className={styles.floatingNavButtonRight}>
<Tooltip text={t`Next attachment`} position="left">
<span>
<FocusRing offset={-2}>
<button
type="button"
className={styles.floatingNavButton}
onClick={onNext ?? (() => {})}
aria-label={t`Next attachment`}
>
<CaretRightIcon size={24} weight="bold" />
</button>
</FocusRing>
</span>
</Tooltip>
</div>
</>
)}
{hasThumbnailCarousel && zoomState !== 'zoomed' && (
<div ref={thumbnailCarouselRef} className={styles.thumbnailCarouselWrapper}>
<Scroller
className={styles.thumbnailCarouselScroller}
orientation="horizontal"
overflow="auto"
fade={false}
key="media-modal-thumbnail-carousel-scroller"
role="listbox"
aria-label={t`Attachment thumbnails`}
onKeyDown={handleThumbnailKeyDown}
>
<div className={styles.thumbnailCarousel}>
{thumbnails?.map((thumb: MediaThumbnail, index: number) => {
const isSelected = currentIndex === index;
const isRovingTarget = rovingThumbnailIndex === index;
const isFirstThumbnail = index === 0;
const isLastThumbnail = index === thumbnailCount - 1;
return (
<FocusRing key={`${thumb.src}-${index}`} offset={-2}>
<button
ref={(el) => {
thumbnailButtonRefs.current[index] = el;
}}
type="button"
role="option"
aria-selected={isSelected}
aria-label={thumb.alt ?? t`Attachment ${index + 1}`}
className={clsx(styles.thumbnailButton, isSelected && styles.thumbnailButtonSelected)}
tabIndex={isRovingTarget ? 0 : -1}
onClick={() => handleThumbnailSelect(index)}
onKeyDown={handleThumbnailKeyDown}
>
<div
className={clsx(
styles.thumbnailImageWrapper,
isFirstThumbnail && styles.thumbnailImageWrapperFirst,
isLastThumbnail && styles.thumbnailImageWrapperLast,
)}
>
{thumb.type === 'video' || thumb.type === 'gifv' ? (
<video
className={styles.thumbnailVideo}
src={thumb.src}
muted
playsInline
preload="metadata"
aria-label={thumb.alt ?? t`Video preview`}
/>
) : thumb.type === 'audio' ? (
<div className={styles.thumbnailPlaceholder}>{t`Audio`}</div>
) : (
<img
src={thumb.src}
alt={thumb.alt ?? ''}
className={styles.thumbnailImage}
draggable={false}
/>
)}
</div>
</button>
</FocusRing>
);
})}
</div>
</Scroller>
</div>
)}
@@ -799,7 +1102,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
currentIndex !== undefined &&
totalAttachments !== undefined &&
totalAttachments > 1 && (
<div className={styles.navigationOverlay}>
<div ref={navigationOverlayRef} className={styles.navigationOverlay}>
<Tooltip text={t`Previous attachment`} position="top">
<span>
<ControlButton

View File

@@ -31,6 +31,8 @@
height: 100%;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
}
.audioPlayerContainer {
@@ -45,29 +47,41 @@
.videoPlayerContainer {
display: flex;
height: 100%;
width: 100%;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 0;
pointer-events: auto;
width: min(
var(--video-natural-width, 960px),
calc(100dvw - (2 * var(--media-content-padding, 1rem)) - (2 * var(--media-side-overlay-width, 0px))),
calc(
(
100dvh -
(2 * var(--media-content-padding, 1rem)) -
var(--media-top-overlay-height, 48px) -
var(--media-bottom-overlay-height, 0px) -
(2 * var(--media-overlay-gap, 8px))
) *
var(--video-aspect-ratio, 1.778)
)
);
max-height: calc(
100dvh -
(2 * var(--media-content-padding, 1rem)) -
var(--media-top-overlay-height, 48px) -
var(--media-bottom-overlay-height, 0px) -
(2 * var(--media-overlay-gap, 8px))
);
}
.videoPlayer {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
}
@media (max-width: 768px) {
.videoPlayerContainer {
width: 100%;
height: auto;
max-height: 100%;
aspect-ratio: auto;
}
.videoPlayer {
width: 100%;
height: auto;
max-height: 100%;
border-radius: 0;
}
}
@@ -81,6 +95,8 @@
}
.gifvVideo {
width: auto;
height: auto;
max-height: 100%;
max-width: 100%;
object-fit: contain;

View File

@@ -17,36 +17,111 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ContextMenuActionCreators from '@app/actions/ContextMenuActionCreators';
import * as FavoriteMemeActionCreators from '@app/actions/FavoriteMemeActionCreators';
import * as MediaViewerActionCreators from '@app/actions/MediaViewerActionCreators';
import * as MessageActionCreators from '@app/actions/MessageActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import {modal} from '@app/actions/ModalActionCreators';
import {deriveDefaultNameFromMessage} from '@app/components/channel/embeds/EmbedUtils';
import {AudioPlayer} from '@app/components/media_player/components/AudioPlayer';
import {VideoPlayer} from '@app/components/media_player/components/VideoPlayer';
import {AddFavoriteMemeModal} from '@app/components/modals/AddFavoriteMemeModal';
import {MediaModal} from '@app/components/modals/MediaModal';
import styles from '@app/components/modals/MediaViewerModal.module.css';
import {useMediaMenuData} from '@app/components/uikit/context_menu/items/MediaMenuData';
import {MediaContextMenu} from '@app/components/uikit/context_menu/MediaContextMenu';
import {MenuBottomSheet} from '@app/components/uikit/menu_bottom_sheet/MenuBottomSheet';
import {Platform} from '@app/lib/Platform';
import type {MessageRecord} from '@app/records/MessageRecord';
import FavoriteMemeStore from '@app/stores/FavoriteMemeStore';
import MediaViewerStore, {type MediaViewerItem} from '@app/stores/MediaViewerStore';
import MobileLayoutStore from '@app/stores/MobileLayoutStore';
import {formatAttachmentDate} from '@app/utils/AttachmentExpiryUtils';
import {createSaveHandler} from '@app/utils/FileDownloadUtils';
import * as ImageCacheUtils from '@app/utils/ImageCacheUtils';
import {buildMediaProxyURL, stripMediaProxyParams} from '@app/utils/MediaProxyUtils';
import {openExternalUrl} from '@app/utils/NativeUtils';
import {useLingui} from '@lingui/react/macro';
import {TrashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import {type FC, type MouseEvent, useCallback, useEffect, useMemo, useRef} from 'react';
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
import * as FavoriteMemeActionCreators from '~/actions/FavoriteMemeActionCreators';
import * as MediaViewerActionCreators from '~/actions/MediaViewerActionCreators';
import * as MessageActionCreators from '~/actions/MessageActionCreators';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import {modal} from '~/actions/ModalActionCreators';
import {deriveDefaultNameFromMessage} from '~/components/channel/embeds/EmbedUtils';
import {AudioPlayer} from '~/components/media-player/components/AudioPlayer';
import {VideoPlayer} from '~/components/media-player/components/VideoPlayer';
import {AddFavoriteMemeModal} from '~/components/modals/AddFavoriteMemeModal';
import {MediaModal} from '~/components/modals/MediaModal';
import styles from '~/components/modals/MediaViewerModal.module.css';
import {MediaContextMenu} from '~/components/uikit/ContextMenu/MediaContextMenu';
import {Platform} from '~/lib/Platform';
import FavoriteMemeStore from '~/stores/FavoriteMemeStore';
import MediaViewerStore from '~/stores/MediaViewerStore';
import MobileLayoutStore from '~/stores/MobileLayoutStore';
import {formatAttachmentDate} from '~/utils/AttachmentExpiryUtils';
import {createSaveHandler} from '~/utils/FileDownloadUtils';
import {buildMediaProxyURL} from '~/utils/MediaProxyUtils';
import {openExternalUrl} from '~/utils/NativeUtils';
import {type CSSProperties, type FC, type MouseEvent, useCallback, useEffect, useMemo, useRef, useState} from 'react';
interface MobileMediaOptionsSheetProps {
currentItem: MediaViewerItem;
defaultName: string;
isOpen: boolean;
message: MessageRecord;
onClose: () => void;
onDelete: (bypassConfirm?: boolean) => void;
}
function getBaseProxyURL(src: string): string {
if (src.startsWith('blob:')) {
return src;
}
return stripMediaProxyParams(src);
}
const MobileMediaOptionsSheet: FC<MobileMediaOptionsSheetProps> = observer(function MobileMediaOptionsSheet({
currentItem,
defaultName,
isOpen,
message,
onClose,
onDelete,
}: MobileMediaOptionsSheetProps) {
const {t} = useLingui();
const mediaMenuData = useMediaMenuData(
{
message,
originalSrc: currentItem.originalSrc,
proxyURL: currentItem.src,
type: currentItem.type,
contentHash: currentItem.contentHash,
attachmentId: currentItem.attachmentId,
embedIndex: currentItem.embedIndex,
defaultName,
defaultAltText: undefined,
},
{
onClose,
},
);
const mediaMenuGroupsWithDelete = useMemo(
() => [
...mediaMenuData.groups,
{
items: [
{
label: t`Delete Message`,
icon: <TrashIcon size={20} />,
onClick: () => {
onDelete();
onClose();
},
danger: true,
},
],
},
],
[mediaMenuData.groups, onClose, onDelete, t],
);
return (
<MenuBottomSheet isOpen={isOpen} onClose={onClose} groups={mediaMenuGroupsWithDelete} title={t`Media Options`} />
);
});
const MediaViewerModalComponent: FC = observer(() => {
const {t, i18n} = useLingui();
const {isOpen, items, currentIndex, channelId, messageId, message} = MediaViewerStore;
const {enabled: isMobile} = MobileLayoutStore;
const videoRef = useRef<HTMLVideoElement>(null);
const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false);
const currentItem = items[currentIndex];
@@ -62,6 +137,66 @@ const MediaViewerModalComponent: FC = observer(() => {
void video.play().catch(() => {});
}, [currentItem?.src, currentItem?.type, currentIndex]);
useEffect(() => {
if (!isOpen || items.length <= 1) return;
const preloadIndices = [currentIndex - 1, currentIndex + 1].filter(
(i) => i >= 0 && i < items.length && i !== currentIndex,
);
for (const idx of preloadIndices) {
const item = items[idx];
if (!item) continue;
if (item.type === 'image' || item.type === 'gif') {
const isItemBlob = item.src.startsWith('blob:');
if (isItemBlob) continue;
const baseProxyURL = getBaseProxyURL(item.src);
const shouldRequestAnimated = item.animated || item.type === 'gif';
let preloadSrc: string;
if (shouldRequestAnimated) {
preloadSrc = buildMediaProxyURL(baseProxyURL, {
format: 'webp',
animated: true,
});
} else {
const maxPreviewSize = 1920;
const aspectRatio = item.naturalWidth / item.naturalHeight;
let targetWidth = item.naturalWidth;
let targetHeight = item.naturalHeight;
if (item.naturalWidth > maxPreviewSize || item.naturalHeight > maxPreviewSize) {
if (aspectRatio > 1) {
targetWidth = Math.min(item.naturalWidth, maxPreviewSize);
targetHeight = Math.round(targetWidth / aspectRatio);
} else {
targetHeight = Math.min(item.naturalHeight, maxPreviewSize);
targetWidth = Math.round(targetHeight * aspectRatio);
}
}
preloadSrc = buildMediaProxyURL(baseProxyURL, {
format: 'webp',
width: targetWidth,
height: targetHeight,
animated: item.animated,
});
}
if (!ImageCacheUtils.hasImage(preloadSrc)) {
ImageCacheUtils.loadImage(preloadSrc, () => {});
}
} else if (item.type === 'gifv' || item.type === 'video') {
const video = document.createElement('video');
video.preload = 'metadata';
video.src = item.src;
video.load();
}
}
}, [isOpen, currentIndex, items]);
const memes = FavoriteMemeStore.memes;
const isFavorited = currentItem?.contentHash
? memes.some((meme) => meme.contentHash === currentItem.contentHash)
@@ -95,11 +230,15 @@ const MediaViewerModalComponent: FC = observer(() => {
)),
);
}
}, [channelId, messageId, currentItem, defaultName, isFavorited, memes]);
}, [channelId, messageId, currentItem, defaultName, i18n, isFavorited, memes]);
const handleSave = useCallback(() => {
if (!currentItem) return;
const mediaType = currentItem.type === 'audio' ? 'audio' : currentItem.type === 'video' ? 'video' : 'image';
const mediaType = (() => {
if (currentItem.type === 'audio') return 'audio';
if (currentItem.type === 'video') return 'video';
return 'image';
})();
createSaveHandler(currentItem.originalSrc, mediaType)();
}, [currentItem]);
@@ -123,15 +262,11 @@ const MediaViewerModalComponent: FC = observer(() => {
);
const handlePrevious = useCallback(() => {
if (currentIndex > 0) {
MediaViewerActionCreators.navigateMediaViewer(currentIndex - 1);
}
}, [currentIndex]);
MediaViewerActionCreators.navigateMediaViewer((currentIndex - 1 + items.length) % items.length);
}, [currentIndex, items.length]);
const handleNext = useCallback(() => {
if (currentIndex < items.length - 1) {
MediaViewerActionCreators.navigateMediaViewer(currentIndex + 1);
}
MediaViewerActionCreators.navigateMediaViewer((currentIndex + 1) % items.length);
}, [currentIndex, items.length]);
const handleThumbnailSelect = useCallback(
@@ -180,6 +315,28 @@ const MediaViewerModalComponent: FC = observer(() => {
[currentItem, defaultName, handleDelete, message],
);
const handleMenuOpen = useCallback(() => {
if (!currentItem || !message) return;
if (isMobile) {
setIsMediaMenuOpen(true);
} else {
ContextMenuActionCreators.openAtPoint({x: window.innerWidth / 2, y: window.innerHeight / 2}, ({onClose}) => (
<MediaContextMenu
message={message}
originalSrc={currentItem.originalSrc}
proxyURL={currentItem.src}
type={currentItem.type}
contentHash={currentItem.contentHash}
attachmentId={currentItem.attachmentId}
embedIndex={currentItem.embedIndex}
defaultName={defaultName}
onClose={onClose}
onDelete={handleDelete}
/>
));
}
}, [currentItem, defaultName, handleDelete, message, isMobile]);
const isBlob = currentItem?.src.startsWith('blob:');
const imageSrc = useMemo(() => {
@@ -187,15 +344,18 @@ const MediaViewerModalComponent: FC = observer(() => {
if (isBlob) {
return currentItem.src;
}
const baseProxyURL = getBaseProxyURL(currentItem.src);
if (currentItem.type === 'gif') {
return buildMediaProxyURL(currentItem.src, {
const shouldRequestAnimated = currentItem.animated || currentItem.type === 'gif';
if (shouldRequestAnimated) {
return buildMediaProxyURL(baseProxyURL, {
format: 'webp',
animated: true,
});
}
if (currentItem.type === 'gifv' || currentItem.type === 'video' || currentItem.type === 'audio') {
return currentItem.src;
return baseProxyURL;
}
const maxPreviewSize = 1920;
@@ -214,10 +374,11 @@ const MediaViewerModalComponent: FC = observer(() => {
}
}
return buildMediaProxyURL(currentItem.src, {
return buildMediaProxyURL(baseProxyURL, {
format: 'webp',
width: targetWidth,
height: targetHeight,
animated: currentItem.animated,
});
}, [currentItem, isBlob]);
@@ -225,12 +386,14 @@ const MediaViewerModalComponent: FC = observer(() => {
() =>
items.map((item, index) => {
const name = item.filename || item.originalSrc.split('/').pop()?.split('?')[0] || t`Attachment ${index + 1}`;
if ((item.type === 'image' || item.type === 'gif') && !item.src.startsWith('blob:')) {
if ((item.type === 'image' || item.type === 'gif' || item.animated) && !item.src.startsWith('blob:')) {
const baseProxyURL = getBaseProxyURL(item.src);
return {
src: buildMediaProxyURL(item.src, {
src: buildMediaProxyURL(baseProxyURL, {
format: 'webp',
width: 320,
height: 320,
animated: Boolean(item.animated),
}),
alt: name,
type: item.type,
@@ -243,7 +406,7 @@ const MediaViewerModalComponent: FC = observer(() => {
type: item.type,
};
}),
[items],
[items, t],
);
if (!isOpen || !currentItem) {
@@ -265,20 +428,19 @@ const MediaViewerModalComponent: FC = observer(() => {
: undefined;
const getTitle = () => {
switch (currentItem.type) {
case 'image':
return t`Image preview`;
case 'gif':
return t`GIF preview`;
case 'gifv':
return t`GIF preview`;
case 'video':
return t`Video preview`;
case 'audio':
return t`Audio preview`;
default:
return t`Media preview`;
if (currentItem.type === 'image') {
return currentItem.animated ? t`Animated image preview` : t`Image preview`;
}
if (currentItem.type === 'gif' || currentItem.type === 'gifv') {
return t`GIF preview`;
}
if (currentItem.type === 'video') {
return t`Video preview`;
}
if (currentItem.type === 'audio') {
return t`Audio preview`;
}
return t`Media preview`;
};
const modalTitle = getTitle();
@@ -323,16 +485,32 @@ const MediaViewerModalComponent: FC = observer(() => {
}
if (currentItem.type === 'video') {
const hasNaturalVideoDimensions = currentItem.naturalWidth > 0 && currentItem.naturalHeight > 0;
const videoAspectRatio = hasNaturalVideoDimensions
? `${currentItem.naturalWidth} / ${currentItem.naturalHeight}`
: '16 / 9';
return (
<div className={styles.videoPlayerContainer}>
<div
className={styles.videoPlayerContainer}
style={
{
'--video-natural-width': hasNaturalVideoDimensions ? `${currentItem.naturalWidth}px` : '960px',
'--video-aspect-ratio': hasNaturalVideoDimensions
? currentItem.naturalWidth / currentItem.naturalHeight
: 16 / 9,
aspectRatio: videoAspectRatio,
} as CSSProperties
}
>
<VideoPlayer
src={currentItem.src}
width={currentItem.naturalWidth}
height={currentItem.naturalHeight}
duration={currentItem.duration}
autoPlay
fillContainer
isMobile={isMobile}
fillContainer={isMobile}
className={styles.videoPlayer}
/>
</div>
@@ -356,10 +534,16 @@ const MediaViewerModalComponent: FC = observer(() => {
);
}
const imageAlt = (() => {
if (currentItem.type === 'gif') return t`Animated GIF`;
if (currentItem.animated) return t`Animated image`;
return t`Image`;
})();
return (
<img
src={imageSrc}
alt={currentItem.type === 'gif' ? t`Animated GIF` : t`Image`}
alt={imageAlt}
className={styles.image}
style={{
objectFit: 'contain',
@@ -369,52 +553,66 @@ const MediaViewerModalComponent: FC = observer(() => {
);
};
const canFavoriteCurrentItem =
Boolean(channelId) &&
Boolean(messageId) &&
(currentItem.type === 'image' ||
currentItem.type === 'gif' ||
currentItem.type === 'gifv' ||
currentItem.type === 'video');
return (
<MediaModal
title={modalTitle}
fileName={fileName}
expiryInfo={
expiryInfo
? {
expiresAt: expiryInfo.expiresAt,
isExpired: expiryInfo.isExpired,
}
: undefined
}
dimensions={dimensions}
isFavorited={
channelId &&
messageId &&
(currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv')
? isFavorited
: undefined
}
onFavorite={
channelId &&
messageId &&
(currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv')
? handleFavoriteClick
: undefined
}
onSave={currentItem.type !== 'gifv' ? handleSave : undefined}
onOpenInBrowser={handleOpenInBrowser}
enablePanZoom={currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv'}
currentIndex={currentIndex}
totalAttachments={items.length}
onPrevious={currentIndex > 0 ? handlePrevious : undefined}
onNext={currentIndex < items.length - 1 ? handleNext : undefined}
thumbnails={thumbnails}
onSelectThumbnail={handleThumbnailSelect}
>
<div
className={styles.mediaContextMenuWrapper}
onContextMenu={handleContextMenu}
role="region"
aria-label={modalTitle}
<>
<MediaModal
title={modalTitle}
fileName={fileName}
expiryInfo={
expiryInfo
? {
expiresAt: expiryInfo.expiresAt,
isExpired: expiryInfo.isExpired,
}
: undefined
}
dimensions={dimensions}
isFavorited={canFavoriteCurrentItem ? isFavorited : undefined}
onFavorite={canFavoriteCurrentItem ? handleFavoriteClick : undefined}
onSave={currentItem.type !== 'gifv' ? handleSave : undefined}
onOpenInBrowser={handleOpenInBrowser}
enablePanZoom={currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv'}
currentIndex={currentIndex}
totalAttachments={items.length}
onPrevious={items.length > 1 ? handlePrevious : undefined}
onNext={items.length > 1 ? handleNext : undefined}
thumbnails={thumbnails}
onSelectThumbnail={handleThumbnailSelect}
providerName={currentItem.providerName}
videoSrc={currentItem.type === 'video' ? currentItem.src : undefined}
initialTime={currentItem.initialTime}
mediaType={currentItem.type === 'audio' ? 'audio' : currentItem.type === 'video' ? 'video' : 'image'}
onMenuOpen={handleMenuOpen}
>
{renderMedia()}
</div>
</MediaModal>
<div
className={styles.mediaContextMenuWrapper}
onContextMenu={handleContextMenu}
role="region"
aria-label={modalTitle}
>
{renderMedia()}
</div>
</MediaModal>
{isMobile && message && (
<MobileMediaOptionsSheet
currentItem={currentItem}
defaultName={defaultName}
isOpen={isMediaMenuOpen}
message={message}
onClose={() => setIsMediaMenuOpen(false)}
onDelete={handleDelete}
/>
)}
</>
);
});

View File

@@ -0,0 +1,111 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as GuildActionCreators from '@app/actions/GuildActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Form} from '@app/components/form/Form';
import {
MessageHistoryThresholdAccordion,
MessageHistoryThresholdField,
type MessageHistoryThresholdFormValues,
} from '@app/components/modals/guild_tabs/guild_overview_tab/sections/MessageHistoryThresholdContent';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
import GuildStore from '@app/stores/GuildStore';
import PermissionStore from '@app/stores/PermissionStore';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {extractTimestamp} from '@fluxer/snowflake/src/SnowflakeUtils';
import {Trans, useLingui} from '@lingui/react/macro';
import {observer} from 'mobx-react-lite';
import type React from 'react';
import {useEffect, useMemo} from 'react';
import {useForm} from 'react-hook-form';
interface MessageHistoryThresholdModalProps {
guildId: string;
}
export const MessageHistoryThresholdModal: React.FC<MessageHistoryThresholdModalProps> = observer(({guildId}) => {
const {t} = useLingui();
const guild = GuildStore.getGuild(guildId);
const canManageGuild = PermissionStore.can(Permissions.MANAGE_GUILD, {guildId});
const form = useForm<MessageHistoryThresholdFormValues>({
defaultValues: {message_history_cutoff: guild?.messageHistoryCutoff ?? null},
});
const {handleSubmit, isSubmitting} = useFormSubmit({
form,
onSubmit: async (data) => {
if (!guild) return;
await GuildActionCreators.update(guild.id, {message_history_cutoff: data.message_history_cutoff});
form.reset(data);
ToastActionCreators.createToast({type: 'success', children: t`Message history threshold updated`});
ModalActionCreators.pop();
},
defaultErrorField: 'message_history_cutoff',
});
useEffect(() => {
if (!guild) return;
if (form.formState.isDirty) return;
form.reset({message_history_cutoff: guild.messageHistoryCutoff ?? null});
}, [form, guild]);
const guildCreatedAt = useMemo(() => {
const timestamp = extractTimestamp(guildId);
return new Date(timestamp);
}, [guildId]);
const maxDate = useMemo(() => new Date(), []);
if (!guild) return null;
return (
<Modal.Root size="medium" centered onClose={ModalActionCreators.pop}>
<Modal.Header title={t`Message History Threshold`} />
<Modal.Content>
<Modal.ContentLayout>
<Modal.Description>
<MessageHistoryThresholdAccordion />
</Modal.Description>
<Form form={form} onSubmit={handleSubmit}>
<MessageHistoryThresholdField
form={form}
name="message_history_cutoff"
canManageGuild={canManageGuild}
guildCreatedAt={guildCreatedAt}
maxDate={maxDate}
/>
</Form>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button variant="secondary" onClick={ModalActionCreators.pop} disabled={isSubmitting}>
<Trans>Cancel</Trans>
</Button>
<Button onClick={handleSubmit} disabled={!canManageGuild} submitting={isSubmitting}>
<Trans>Save</Trans>
</Button>
</Modal.Footer>
</Modal.Root>
);
});

View File

@@ -18,7 +18,7 @@
*/
.modalRoot {
composes: root default from './Modal.module.css';
composes: root default from '@app/components/modals/Modal.module.css';
width: 580px;
max-width: min(580px, calc(100vw - 32px));
min-height: 420px;
@@ -73,80 +73,6 @@
padding: 0;
}
.scrollerPadding {
padding: 0.35rem 0.35rem 0.45rem 0.35rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: center;
}
.sidebarScroller {
height: 100%;
min-height: 0;
}
.reactionFilterButtonContainer {
display: flex;
justify-content: center;
width: 100%;
margin-bottom: 0.25rem;
}
.reactionFilterButton {
width: 100%;
padding: 0;
border-radius: 0.6rem;
background: transparent;
border: none;
color: inherit;
transition:
color 0.15s ease,
transform 0.15s ease;
}
.reactionFilterButtonIdle {
cursor: pointer;
}
.reactionFilterButtonIdle:hover {
transform: translateY(-1px);
}
.reactionFilterButtonSelected {
box-shadow: 0 0 0 2px var(--background-modifier-accent-focus);
background-color: var(--background-modifier-hover);
transform: translateY(-1px);
}
.reactionEmoji {
width: 24px;
height: 16px;
object-fit: contain;
}
.reactionCount {
font-weight: 600;
font-size: 0.75rem;
color: inherit;
line-height: 1;
margin-left: auto;
}
.noReactionsContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem;
color: var(--text-primary-muted);
min-height: 0;
}
.noReactionsText {
font-size: 0.875rem;
}
.reactionListContainer {
display: flex;
flex-direction: column;
@@ -156,110 +82,3 @@
padding-top: 0;
background: var(--background-secondary);
}
.reactionListPanel {
display: flex;
flex: 1;
flex-direction: column;
background: var(--background-secondary-lighter);
border-radius: 8px;
padding: 0.2rem 0;
overflow: hidden;
border: 1px solid var(--border-color);
}
.scrollerColumn {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.reactorScroller {
height: 100%;
min-height: 0;
}
.reactorItem {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.85rem;
min-height: 44px;
}
.reactorItemBorder {
border-top: 1px solid var(--background-header-secondary);
}
.reactorInfo {
margin-left: 0.35rem;
display: flex;
flex: 1 1 auto;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.4rem;
overflow: hidden;
}
.reactorName {
font-size: 0.95rem;
color: var(--text-chat);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
margin-top: -2px;
}
.reactorTag {
font-size: 0.75rem;
color: var(--text-chat-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 120px;
}
.removeReactionButton {
margin-left: 0.5rem;
flex: none;
color: var(--text-chat-muted);
cursor: pointer;
background: transparent;
border: none;
padding: 0;
}
.removeReactionButton:hover {
color: var(--text-chat);
}
.removeReactionIcon {
height: 1rem;
width: 1rem;
}
.loadingContainer {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 1rem;
gap: 1rem;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

View File

@@ -17,150 +17,86 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as ContextMenuActionCreators from '@app/actions/ContextMenuActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ReactionActionCreators from '@app/actions/ReactionActionCreators';
import styles from '@app/components/modals/MessageReactionsModal.module.css';
import * as Modal from '@app/components/modals/Modal';
import {MessageReactionsFilters, MessageReactionsReactorsList} from '@app/components/shared/MessageReactionsContent';
import {MenuGroup} from '@app/components/uikit/context_menu/MenuGroup';
import {MenuItem} from '@app/components/uikit/context_menu/MenuItem';
import {useMessageReactionsState} from '@app/hooks/useMessageReactionsState';
import type {UserRecord} from '@app/records/UserRecord';
import AuthenticationStore from '@app/stores/AuthenticationStore';
import type {MessageReaction} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
import {Trans, useLingui} from '@lingui/react/macro';
import {XIcon} from '@phosphor-icons/react';
import {clsx} from 'clsx';
import {TrashIcon} from '@phosphor-icons/react';
import {observer} from 'mobx-react-lite';
import React from 'react';
import * as ModalActionCreators from '~/actions/ModalActionCreators';
import * as ReactionActionCreators from '~/actions/ReactionActionCreators';
import {Permissions} from '~/Constants';
import reactionStyles from '~/components/channel/MessageReactions.module.css';
import styles from '~/components/modals/MessageReactionsModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Avatar} from '~/components/uikit/Avatar';
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
import {Scroller} from '~/components/uikit/Scroller';
import {Spinner} from '~/components/uikit/Spinner';
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
import {useHover} from '~/hooks/useHover';
import type {MessageReaction} from '~/records/MessageRecord';
import ChannelStore from '~/stores/ChannelStore';
import MessageReactionsStore from '~/stores/MessageReactionsStore';
import MessageStore from '~/stores/MessageStore';
import PermissionStore from '~/stores/PermissionStore';
import {emojiEquals, getEmojiName, getEmojiNameWithColons, getReactionKey, useEmojiURL} from '~/utils/ReactionUtils';
const MessageReactionItem = observer(
({
reaction,
selectedReaction,
setSelectedReaction,
}: {
reaction: MessageReaction;
selectedReaction: MessageReaction;
setSelectedReaction: (reaction: MessageReaction) => void;
}) => {
const {t} = useLingui();
const [hoverRef, isHovering] = useHover();
const emojiName = getEmojiName(reaction.emoji);
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering});
const isUnicodeEmoji = reaction.emoji.id == null;
const isSelected = emojiEquals(selectedReaction.emoji, reaction.emoji);
return (
<Tooltip text={getEmojiNameWithColons(reaction.emoji)} position="left">
<div className={styles.reactionFilterButtonContainer}>
<FocusRing offset={-2}>
<button
type="button"
aria-label={
reaction.count === 1
? t`${emojiName}, ${reaction.count} reaction`
: t`${emojiName}, ${reaction.count} reactions`
}
onClick={() => setSelectedReaction(reaction)}
ref={hoverRef}
className={clsx(
reactionStyles.reactionButton,
styles.reactionFilterButton,
isSelected ? styles.reactionFilterButtonSelected : styles.reactionFilterButtonIdle,
)}
>
<div className={reactionStyles.reactionInner}>
{emojiUrl ? (
<img
className={clsx('emoji', reactionStyles.emoji)}
src={emojiUrl}
alt={emojiName}
draggable={false}
/>
) : isUnicodeEmoji ? (
<span className={clsx('emoji', reactionStyles.emoji)}>{reaction.emoji.name}</span>
) : null}
<div className={reactionStyles.countWrapper}>{reaction.count}</div>
</div>
</button>
</FocusRing>
</div>
</Tooltip>
);
},
);
import {type MouseEvent, useCallback} from 'react';
export const MessageReactionsModal = observer(
({channelId, messageId, openToReaction}: {channelId: string; messageId: string; openToReaction: MessageReaction}) => {
const {t, i18n} = useLingui();
const [selectedReaction, setSelectedReaction] = React.useState(openToReaction);
const message = MessageStore.getMessage(channelId, messageId);
const channel = ChannelStore.getChannel(channelId);
const guildId = channel?.guildId;
const canManageMessages = PermissionStore.can(Permissions.MANAGE_MESSAGES, {
const {
message,
reactions,
selectedReaction,
setSelectedReaction,
reactors,
isLoading,
canManageMessages,
guildId,
reactorScrollerKey,
} = useMessageReactionsState({
channelId,
messageId,
openToReaction,
isOpen: true,
onMissingMessage: () => ModalActionCreators.pop(),
});
const reactors = MessageReactionsStore.getReactions(messageId, selectedReaction.emoji);
const fetchStatus = MessageReactionsStore.getFetchStatus(messageId, selectedReaction.emoji);
const isLoading = fetchStatus === 'pending';
const reactorScrollerKey = React.useMemo(
() =>
message
? `message-reactions-reactor-scroller-${getReactionKey(message.id, selectedReaction.emoji)}`
: 'message-reactions-reactor-scroller',
[message?.id, selectedReaction.emoji],
const handleReactionContextMenu = useCallback(
(reaction: MessageReaction, event: MouseEvent<HTMLButtonElement>) => {
if (!canManageMessages) {
return;
}
ContextMenuActionCreators.openFromEvent(event, ({onClose}) => (
<MenuGroup>
<MenuItem
icon={<TrashIcon />}
onClick={() => {
ReactionActionCreators.removeReactionEmoji(i18n, channelId, messageId, reaction.emoji);
onClose();
}}
danger
>
{t`Remove Reaction`}
</MenuItem>
</MenuGroup>
));
},
[canManageMessages, channelId, i18n, messageId, t],
);
React.useEffect(() => {
if (!message || fetchStatus === 'pending') {
return;
}
const handleRemoveReactor = useCallback(
(reactor: UserRecord) => {
if (!selectedReaction) {
return;
}
const isOwnReaction =
AuthenticationStore.currentUserId != null && reactor.id === AuthenticationStore.currentUserId;
ReactionActionCreators.removeReaction(
i18n,
channelId,
messageId,
selectedReaction.emoji,
isOwnReaction ? undefined : reactor.id,
);
},
[channelId, i18n, messageId, selectedReaction],
);
if (!message?.reactions) return;
const reactionOnMessage = message.reactions.find((reaction) =>
emojiEquals(reaction.emoji, selectedReaction.emoji),
);
if (!reactionOnMessage || reactionOnMessage.count === 0) {
return;
}
const desired = Math.min(100, reactionOnMessage.count);
if (fetchStatus === 'idle' || reactors.length < desired) {
ReactionActionCreators.getReactions(channelId, messageId, selectedReaction.emoji, 100);
}
}, [channelId, messageId, selectedReaction.emoji, fetchStatus, reactors.length, message?.reactions]);
React.useEffect(() => {
if (!message) {
ModalActionCreators.pop();
return;
}
const reactions = message.reactions;
if (!reactions || reactions.length === 0) {
ModalActionCreators.pop();
return;
}
const selectedReactionExists = reactions.some((reaction) => emojiEquals(reaction.emoji, selectedReaction.emoji));
if (!selectedReactionExists) {
setSelectedReaction(reactions[0]);
}
}, [message, selectedReaction.emoji]);
if (!message) {
if (!message || !selectedReaction) {
return null;
}
@@ -171,76 +107,29 @@ export const MessageReactionsModal = observer(
<div className={styles.modalLayout}>
<div className={styles.sidebar}>
<div className={styles.reactionFiltersPane}>
<Scroller
className={clsx(styles.scrollerPadding, styles.sidebarScroller)}
key="message-reactions-filter-scroller"
reserveScrollbarTrack={false}
>
{message.reactions.length > 0 &&
message.reactions.map((reaction) => (
<MessageReactionItem
key={getReactionKey(message.id, reaction.emoji)}
reaction={reaction}
selectedReaction={selectedReaction}
setSelectedReaction={setSelectedReaction}
/>
))}
{message.reactions.length === 0 && (
<div className={styles.noReactionsContainer}>
<div className={styles.noReactionsText}>{t`No reactions`}</div>
</div>
)}
</Scroller>
<MessageReactionsFilters
messageId={messageId}
reactions={reactions}
selectedReaction={selectedReaction}
onSelectReaction={setSelectedReaction}
canManageMessages={canManageMessages}
variant="modal"
onReactionContextMenu={handleReactionContextMenu}
/>
</div>
</div>
<div className={styles.reactionListContainer}>
<div className={styles.reactionListPanel}>
<Scroller
className={clsx(styles.scrollerColumn, styles.reactorScroller)}
key={reactorScrollerKey}
reserveScrollbarTrack={false}
>
{reactors.map((reactor, index) => (
<div
key={reactor.id}
className={clsx(styles.reactorItem, index > 0 && styles.reactorItemBorder)}
data-user-id={reactor.id}
data-channel-id={channelId}
>
<Avatar user={reactor} size={24} guildId={guildId} />
<div className={styles.reactorInfo}>
<span className={styles.reactorName}>{reactor.displayName}</span>
<span className={styles.reactorTag}>{reactor.tag}</span>
</div>
{canManageMessages && (
<FocusRing offset={-2}>
<button
type="button"
onClick={() =>
ReactionActionCreators.removeReaction(
i18n,
channelId,
messageId,
selectedReaction.emoji,
reactor.id,
)
}
className={styles.removeReactionButton}
>
<XIcon weight="regular" className={styles.removeReactionIcon} />
</button>
</FocusRing>
)}
</div>
))}
{isLoading && reactors.length === 0 && (
<div className={styles.loadingContainer}>
<Spinner size="medium" />
<span className={styles.srOnly}>Loading reactions</span>
</div>
)}
</Scroller>
</div>
<MessageReactionsReactorsList
channelId={channelId}
reactors={reactors}
isLoading={isLoading}
canManageMessages={canManageMessages}
currentUserId={AuthenticationStore.currentUserId}
guildId={guildId}
scrollerKey={reactorScrollerKey}
loadingLabel={t`Loading reactions`}
onRemoveReactor={handleRemoveReactor}
/>
</div>
</div>
</Modal.Content>

View File

@@ -17,19 +17,17 @@
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import * as MfaActionCreators from '@app/actions/MfaActionCreators';
import * as ModalActionCreators from '@app/actions/ModalActionCreators';
import * as ToastActionCreators from '@app/actions/ToastActionCreators';
import {Form} from '@app/components/form/Form';
import {Input} from '@app/components/form/Input';
import * as Modal from '@app/components/modals/Modal';
import {Button} from '@app/components/uikit/button/Button';
import {useFormSubmit} from '@app/hooks/useFormSubmit';
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 * 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/MfaTotpDisableModal.module.css';
import * as Modal from '~/components/modals/Modal';
import {Button} from '~/components/uikit/Button/Button';
import {useFormSubmit} from '~/hooks/useFormSubmit';
interface FormInputs {
code: string;
@@ -54,21 +52,23 @@ export const MfaTotpDisableModal = observer(() => {
return (
<Modal.Root size="small" centered>
<Form form={form} onSubmit={handleSubmit} aria-label={t`Disable two-factor authentication form`}>
<Modal.Header title={t`Remove authenticator app`} />
<Modal.Content className={confirmStyles.content}>
<Input
{...form.register('code')}
autoFocus={true}
autoComplete="one-time-code"
error={form.formState.errors.code?.message}
label={t`Code`}
required={true}
footer={
<p className={styles.footer}>
<Trans>Enter the 6-digit code from your authenticator app.</Trans>
</p>
}
/>
<Modal.Header title={t`Remove Authenticator App`} />
<Modal.Content>
<Modal.ContentLayout>
<Input
{...form.register('code')}
autoComplete="one-time-code"
autoFocus={true}
error={form.formState.errors.code?.message}
label={t`Code`}
required={true}
footer={
<Modal.Description>
<Trans>Enter the 6-digit code from your authenticator app.</Trans>
</Modal.Description>
}
/>
</Modal.ContentLayout>
</Modal.Content>
<Modal.Footer>
<Button onClick={ModalActionCreators.pop} variant="secondary">

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