refactor progress
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
157
fluxer_app/src/components/modals/AddConnectionModal.module.css
Normal file
157
fluxer_app/src/components/modals/AddConnectionModal.module.css
Normal 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);
|
||||
}
|
||||
318
fluxer_app/src/components/modals/AddConnectionModal.tsx
Normal file
318
fluxer_app/src/components/modals/AddConnectionModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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...`}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
78
fluxer_app/src/components/modals/DisablePiPConfirmModal.tsx
Normal file
78
fluxer_app/src/components/modals/DisablePiPConfirmModal.tsx
Normal 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 > Audio & 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>
|
||||
);
|
||||
});
|
||||
138
fluxer_app/src/components/modals/DiscoveryModal.module.css
Normal file
138
fluxer_app/src/components/modals/DiscoveryModal.module.css
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
140
fluxer_app/src/components/modals/DiscoveryModal.tsx
Normal file
140
fluxer_app/src/components/modals/DiscoveryModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
164
fluxer_app/src/components/modals/EditAltTextModal.tsx
Normal file
164
fluxer_app/src/components/modals/EditAltTextModal.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
118
fluxer_app/src/components/modals/EditConnectionModal.tsx
Normal file
118
fluxer_app/src/components/modals/EditConnectionModal.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 = (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -34,10 +34,6 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.fluxerTagLabel {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
211
fluxer_app/src/components/modals/GuildFolderSettingsModal.tsx
Normal file
211
fluxer_app/src/components/modals/GuildFolderSettingsModal.tsx
Normal 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} />));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
.description {
|
||||
color: var(--text-primary-muted);
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cropperContainer {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user