// SPDX-License-Identifier: AGPL-3.0-or-later import {showDmActionErrorModal} from '@app/features/app/components/alerts/DmActionErrorModal'; import {showGenericErrorModal} from '@app/features/app/components/alerts/GenericErrorModalCommands'; import * as Modal from '@app/features/app/components/dialogs/Modal'; import {CopyLinkSection} from '@app/features/app/components/dialogs/shared/CopyLinkSection'; import { RecipientList, useRecipientItems, useSendLinkToRecipients, } from '@app/features/app/components/dialogs/shared/RecipientList'; import selectorStyles from '@app/features/app/components/dialogs/shared/SelectorModalStyles.module.css'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import type {Channel} from '@app/features/channel/models/Channel'; import Channels from '@app/features/channel/state/Channels'; import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils'; import type {Guild} from '@app/features/guild/models/Guild'; import Guilds from '@app/features/guild/state/Guilds'; import { CUSTOM_ELLIPSIS_DESCRIPTOR, FAILED_TO_SEND_INVITE_DESCRIPTOR, NEVER_DESCRIPTOR, ONE_DAY_DURATION_DESCRIPTOR, ONE_HOUR_DURATION_DESCRIPTOR, SEARCH_FRIENDS_DESCRIPTOR, SENT_DESCRIPTOR, SEVEN_DAYS_DURATION_DESCRIPTOR, SIX_HOURS_DURATION_DESCRIPTOR, SOMETHING_WENT_WRONG_DESCRIPTOR, THIRTY_MINUTES_DURATION_DESCRIPTOR, TWELVE_HOURS_DURATION_DESCRIPTOR, } from '@app/features/i18n/utils/CommonMessageDescriptors'; import * as InviteCommands from '@app/features/invite/commands/InviteCommands'; import styles from '@app/features/invite/components/modals/InviteModal.module.css'; import * as InviteUtils from '@app/features/invite/utils/InviteUtils'; import {failureCode} from '@app/features/platform/utils/ResponseInspection'; import StreamerMode from '@app/features/streamer_mode/state/StreamerMode'; import {remFromPx} from '@app/features/theme/layout/RemFromPx'; import {Button} from '@app/features/ui/button/Button'; import {Combobox} from '@app/features/ui/components/form/FormCombobox'; import {Input} from '@app/features/ui/components/form/FormInput'; import {Switch} from '@app/features/ui/components/form/FormSwitch'; import {Spinner} from '@app/features/ui/components/Spinner'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import {SteppedCarousel} from '@app/features/ui/stepped_carousel/SteppedCarousel'; import {useCopyLinkHandler} from '@app/lib/copy-link'; import {GuildFeatures} from '@fluxer/constants/src/GuildConstants'; import {MAX_INVITE_AGE_SECONDS, MAX_INVITE_USES} from '@fluxer/constants/src/LimitConstants'; import type {Invite} from '@fluxer/schema/src/domains/invite/InviteSchemas'; import {msg} from '@lingui/core/macro'; 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 {useCallback, useEffect, useMemo, useState} from 'react'; const NO_LIMIT_DESCRIPTOR = msg({ message: 'No limit', comment: 'Invite max-uses option meaning unlimited uses. Short standalone label.', }); const MESSAGE_1_USE_DESCRIPTOR = msg({ message: '1 use', comment: 'Invite max-uses dropdown option for exactly one use.', }); const MESSAGE_5_USES_DESCRIPTOR = msg({ message: '5 uses', comment: 'Invite max-uses dropdown option for five uses.', }); const MESSAGE_10_USES_DESCRIPTOR = msg({ message: '10 uses', comment: 'Invite max-uses dropdown option for ten uses.', }); const MESSAGE_25_USES_DESCRIPTOR = msg({ message: '25 uses', comment: 'Invite max-uses dropdown option for 25 uses.', }); const MESSAGE_50_USES_DESCRIPTOR = msg({ message: '50 uses', comment: 'Invite max-uses dropdown option for 50 uses.', }); const MESSAGE_100_USES_DESCRIPTOR = msg({ message: '100 uses', comment: 'Invite max-uses dropdown option for 100 uses.', }); const INVITE_FRIENDS_DESCRIPTOR = msg({ message: 'Invite friends', comment: 'Fallback header on the invite modal when no community context is available. Refers to inviting friends to Fluxer.', }); const INVITE_FRIENDS_TO_DESCRIPTOR = msg({ message: 'Invite friends to {guildName}', comment: 'Title of the invite modal. {guildName} is the community name.', }); const INVITE_LINK_SETTINGS_DESCRIPTOR = msg({ message: 'Invite link settings', comment: 'Header of the invite modal when the user is editing the invite link expiry, max uses, and temporary flag.', }); const INVITE_DESCRIPTOR = msg({ message: 'Invite', comment: 'Per-recipient action button label in the invite modal recipient list. Sends the invite as a DM to that friend. Short standalone verb.', }); const EXPIRE_AFTER_DESCRIPTOR = msg({ message: 'Expire after', comment: 'Label above the expiration duration dropdown in the invite link settings.', }); const CUSTOM_EXPIRY_SECONDS_DESCRIPTOR = msg({ message: 'Custom expiration (seconds)', comment: 'Label of the numeric input shown after choosing "Custom..." in the invite expiration dropdown. The unit is seconds.', }); const ANY_VALUE_FROM_0_TO_SECONDS_0_MEANS_DESCRIPTOR = msg({ message: 'Any value from 0 to {maxInviteAgeSeconds} seconds. 0 means never expires.', comment: 'Helper text under the custom invite expiration input. {maxInviteAgeSeconds} is the platform max in seconds.', }); const MAX_NUMBER_OF_USES_DESCRIPTOR = msg({ message: 'Max number of uses', comment: 'Label above the max-uses dropdown in the invite link settings.', }); const CUSTOM_MAX_USES_DESCRIPTOR = msg({ message: 'Custom max uses', comment: 'Label of the numeric input shown after choosing "Custom..." in the invite max-uses dropdown.', }); const ANY_VALUE_FROM_0_TO_0_MEANS_NO_DESCRIPTOR = msg({ message: 'Any value from 0 to {maxInviteUses}. 0 means no limit.', comment: 'Helper text under the custom invite max-uses input. {maxInviteUses} is the platform max number of uses per invite.', }); const GRANT_TEMPORARY_MEMBERSHIP_DESCRIPTOR = msg({ message: 'Temporary membership', comment: 'Switch label in the invite link settings. When enabled, members joining via this invite are kicked when they go offline unless given a role.', }); const MEMBERS_WILL_BE_REMOVED_WHEN_THEY_GO_OFFLINE_DESCRIPTOR = msg({ message: 'Members are removed when offline unless given a role.', comment: 'Helper text under the temporary membership switch in the invite link settings.', }); const INVITE_LINK_DESCRIPTOR = msg({ message: 'Invite link', comment: 'Placeholder in the read-only invite link input when no link is available yet.', }); const LINK_HIDDEN_WHILE_SHARING_DESCRIPTOR = msg({ message: 'Link hidden while sharing', comment: 'Replacement text for an invite URL while streaming privacy is active.', }); const INVITE_LINK_HIDDEN_LABEL_DESCRIPTOR = msg({ message: 'Invite link is hidden while sharing:', comment: 'Label above a masked invite link while streaming privacy is active.', }); const YOUR_INVITE_LINK_EXPIRES_IN_SECONDS_DESCRIPTOR = msg({ message: '{seconds, plural, one {Your invite link expires in # second.} other {Your invite link expires in # seconds.}}', comment: 'Text under a generated invite link. {seconds} is how long the link stays valid, in whole seconds.', }); const YOUR_INVITE_LINK_EXPIRES_IN_MINUTES_DESCRIPTOR = msg({ message: '{minutes, plural, one {Your invite link expires in # minute.} other {Your invite link expires in # minutes.}}', comment: 'Text under a generated invite link. {minutes} is how long the link stays valid, in whole minutes.', }); const YOUR_INVITE_LINK_EXPIRES_IN_HOURS_DESCRIPTOR = msg({ message: '{hours, plural, one {Your invite link expires in # hour.} other {Your invite link expires in # hours.}}', comment: 'Text under a generated invite link. {hours} is how long the link stays valid, in whole hours.', }); const YOUR_INVITE_LINK_EXPIRES_IN_DAYS_DESCRIPTOR = msg({ message: '{days, plural, one {Your invite link expires in # day.} other {Your invite link expires in # days.}}', comment: 'Text under a generated invite link. {days} is how long the link stays valid, in whole days.', }); type InviteModalView = 'recipients' | 'advanced'; const INVITE_MODAL_VIEW_ORDER: ReadonlyArray = ['recipients', 'advanced']; interface InviteModalContentProps { channelId: string; channel: Channel; guild: Guild; inviteCapability: InviteUtils.InviteCapability; } const InviteModalUnavailable = observer(function InviteModalUnavailable() { const {i18n} = useLingui(); return (

There are no channels available to create an invite for.

); }); const InviteModalContent = observer(function InviteModalContent({ channelId, channel, guild, inviteCapability, }: InviteModalContentProps) { const {i18n} = useLingui(); const isUsingVanityUrl = inviteCapability.useVanityUrl; const [invite, setInvite] = useState(null); const [loading, setLoading] = useState(!isUsingVanityUrl); const [showAdvanced, setShowAdvanced] = useState(false); const [maxAge, setMaxAge] = useState('604800'); const [maxUses, setMaxUses] = useState('0'); const [temporary, setTemporary] = useState(false); const [isMaxAgeCustom, setIsMaxAgeCustom] = useState(false); const [isMaxUsesCustom, setIsMaxUsesCustom] = useState(false); const recipients = useRecipientItems(); const [searchQuery, setSearchQuery] = useState(''); const CUSTOM_OPTION = '__custom__'; const maxAgeOptions = useMemo( () => [ {value: '0', label: i18n._(NEVER_DESCRIPTOR)}, {value: '1800', label: i18n._(THIRTY_MINUTES_DURATION_DESCRIPTOR)}, {value: '3600', label: i18n._(ONE_HOUR_DURATION_DESCRIPTOR)}, {value: '21600', label: i18n._(SIX_HOURS_DURATION_DESCRIPTOR)}, {value: '43200', label: i18n._(TWELVE_HOURS_DURATION_DESCRIPTOR)}, {value: '86400', label: i18n._(ONE_DAY_DURATION_DESCRIPTOR)}, {value: '604800', label: i18n._(SEVEN_DAYS_DURATION_DESCRIPTOR)}, {value: CUSTOM_OPTION, label: i18n._(CUSTOM_ELLIPSIS_DESCRIPTOR)}, ], [i18n.locale], ); const maxUsesOptions = useMemo( () => [ {value: '0', label: i18n._(NO_LIMIT_DESCRIPTOR)}, {value: '1', label: i18n._(MESSAGE_1_USE_DESCRIPTOR)}, {value: '5', label: i18n._(MESSAGE_5_USES_DESCRIPTOR)}, {value: '10', label: i18n._(MESSAGE_10_USES_DESCRIPTOR)}, {value: '25', label: i18n._(MESSAGE_25_USES_DESCRIPTOR)}, {value: '50', label: i18n._(MESSAGE_50_USES_DESCRIPTOR)}, {value: '100', label: i18n._(MESSAGE_100_USES_DESCRIPTOR)}, {value: CUSTOM_OPTION, label: i18n._(CUSTOM_ELLIPSIS_DESCRIPTOR)}, ], [i18n.locale], ); const clampNumber = (raw: string, max: number): string => { if (raw === '') return '0'; const parsed = Number.parseInt(raw, 10); if (Number.isNaN(parsed)) return '0'; return String(Math.max(0, Math.min(max, parsed))); }; const loadInvite = useCallback( async (options?: {maxAge?: number; maxUses?: number; temporary?: boolean}) => { if (isUsingVanityUrl) { return; } setLoading(true); try { const newInvite = await InviteCommands.create(channelId, { max_age: options?.maxAge, max_uses: options?.maxUses, temporary: options?.temporary, }); setInvite(newInvite); } finally { setLoading(false); } }, [channelId, isUsingVanityUrl], ); useEffect(() => { if (!isUsingVanityUrl) { loadInvite({maxAge: 604800, maxUses: 0, temporary: false}); } }, [loadInvite, isUsingVanityUrl]); const title = i18n._(INVITE_FRIENDS_TO_DESCRIPTOR, {guildName: guild.name}); const invitesDisabled = guild.features.has(GuildFeatures.INVITES_DISABLED); const inviteUrl = isUsingVanityUrl ? InviteUtils.getVanityInviteUrl(inviteCapability.vanityUrlCode!) : invite ? `${RuntimeConfig.inviteEndpoint}/${invite.code}` : ''; const hideInviteLinks = StreamerMode.shouldHideInviteLinks; const displayedInviteUrl = hideInviteLinks && inviteUrl ? i18n._(LINK_HIDDEN_WHILE_SHARING_DESCRIPTOR) : inviteUrl; const handleCopy = useCopyLinkHandler(inviteUrl, true); const { sendingTo, sentTo: sentInvites, send: handleSendInvite, } = useSendLinkToRecipients( () => inviteUrl, (error: unknown) => { if (failureCode(error)) { showDmActionErrorModal(error); return; } showGenericErrorModal({ title: () => i18n._(SOMETHING_WENT_WRONG_DESCRIPTOR), message: () => i18n._(FAILED_TO_SEND_INVITE_DESCRIPTOR), dataFlx: 'invite.invite-modal.send-invite-error-modal', }); }, ); const handleGenerateNew = () => { loadInvite({ maxAge: parseInt(maxAge, 10), maxUses: parseInt(maxUses, 10), temporary, }); setShowAdvanced(false); }; const getExpirationText = () => { const seconds = Number.parseInt(maxAge, 10); const minutes = seconds / 60; const hours = minutes / 60; const days = hours / 24; if (days >= 1 && days % 1 === 0) { return i18n._(YOUR_INVITE_LINK_EXPIRES_IN_DAYS_DESCRIPTOR, {days}); } if (hours >= 1 && hours % 1 === 0) { return i18n._(YOUR_INVITE_LINK_EXPIRES_IN_HOURS_DESCRIPTOR, {hours}); } if (minutes >= 1 && minutes % 1 === 0) { return i18n._(YOUR_INVITE_LINK_EXPIRES_IN_MINUTES_DESCRIPTOR, {minutes}); } return i18n._(YOUR_INVITE_LINK_EXPIRES_IN_SECONDS_DESCRIPTOR, {seconds}); }; return ( {!showAdvanced && ( <>

Recipients will be taken to {ChannelUtils.getIcon(channel, {size: 16, className: styles.channelIcon})}{' '} {channel.name}

{invitesDisabled && (

Invites are paused in this community by an admin. While this invite can be created, it cannot be accepted until invites are re-enabled.

)}
setSearchQuery(e.target.value)} placeholder={i18n._(SEARCH_FRIENDS_DESCRIPTOR)} leftIcon={ } className={selectorStyles.headerSearchInput} data-flx="invite.invite-modal.input.set-search-query" />
)}
{loading ? (
) : ( {!showAdvanced ? ( ) : (
{ if (value == null) return; if (value === CUSTOM_OPTION) { setIsMaxAgeCustom(true); return; } setIsMaxAgeCustom(false); setMaxAge(String(value)); }} data-flx="invite.invite-modal.select.set-is-max-age-custom" /> {isMaxAgeCustom && ( setMaxAge(clampNumber(event.target.value, MAX_INVITE_AGE_SECONDS))} data-flx="invite.invite-modal.input.set-max-age.number" /> )} { if (value == null) return; if (value === CUSTOM_OPTION) { setIsMaxUsesCustom(true); return; } setIsMaxUsesCustom(false); setMaxUses(String(value)); }} data-flx="invite.invite-modal.select.set-is-max-uses-custom" /> {isMaxUsesCustom && ( setMaxUses(clampNumber(event.target.value, MAX_INVITE_USES))} data-flx="invite.invite-modal.input.set-max-uses.number" /> )}
)}
)}
{!showAdvanced ? ( Or send an invite link to a friend: ) } value={displayedInviteUrl} onCopy={handleCopy} copyDisabled={hideInviteLinks} onInputClick={hideInviteLinks ? undefined : (e) => e.currentTarget.select()} inputProps={{placeholder: i18n._(INVITE_LINK_DESCRIPTOR)}} data-flx="invite.invite-modal.copy-link-section" > {isUsingVanityUrl || maxAge === '0' ? (

This invite link never expires.{' '} {!isUsingVanityUrl && ( )}

) : (

{getExpirationText()}{' '}

)}
) : ( )}
); }); export const InviteModal = observer(({channelId}: {channelId: string}) => { const channel = Channels.getChannel(channelId); if (!channel || channel.guildId == null) { return ; } const guild = Guilds.getGuild(channel.guildId); if (!guild) { return ; } const inviteCapability = InviteUtils.getInviteCapability(channelId, channel.guildId); return ( ); });