// SPDX-License-Identifier: AGPL-3.0-or-later import {Routes} from '@app/app/Routes'; import {PRODUCT_NAME} from '@app/features/app/config/I18nDisplayConstants'; import RuntimeConfig from '@app/features/app/state/RuntimeConfig'; import styles from '@app/features/channel/components/InviteEmbed.module.css'; import { getGroupDMTitle, getGuildEmbedSplashAspectRatio, getImageAspectRatioFromBase64, } from '@app/features/channel/components/invite_embed/InviteEmbedUtils'; import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext'; import type {Channel} from '@app/features/channel/models/Channel'; import {clampWideAssetAspectRatio} from '@app/features/expressions/utils/AssetImageGeometry'; import {GuildBadge} from '@app/features/guild/components/GuildBadge'; import {GuildIcon} from '@app/features/guild/components/popouts/GuildIcon'; import GuildCount from '@app/features/guild/state/GuildCount'; import Guilds from '@app/features/guild/state/Guilds'; import { JOIN_COMMUNITY_DESCRIPTOR, NO_DESCRIPTION_PROVIDED_DESCRIPTOR, } from '@app/features/i18n/utils/CommonMessageDescriptors'; import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils'; import * as InviteCommands from '@app/features/invite/commands/InviteCommands'; import Invites from '@app/features/invite/state/Invites'; import { isGroupDmInvite, isGuildInvite, isPackInvite as isPackInviteGuard, } from '@app/features/invite/types/InviteTypes'; import {getGroupDmInviteCounts} from '@app/features/invite/utils/GroupDmInviteCounts'; import { GuildInvitePrimaryAction, getGuildInviteActionState, getGuildInvitePrimaryAction, isGuildInviteActionDisabled, } from '@app/features/invite/utils/GuildInviteActionState'; import { EmbedCard, EmbedSkeletonButton, EmbedSkeletonCircle, EmbedSkeletonDot, EmbedSkeletonIcon, EmbedSkeletonStatLong, EmbedSkeletonStatShort, EmbedSkeletonTitle, } from '@app/features/messaging/components/embeds/embed_card/EmbedCard'; import cardStyles from '@app/features/messaging/components/embeds/embed_card/EmbedCard.module.css'; import {useEmbedSkeletonOverride} from '@app/features/messaging/components/embeds/embed_card/useEmbedSkeletonOverride'; import type {Message} from '@app/features/messaging/models/MessagingMessage'; import * as RouterUtils from '@app/features/navigation/utils/RouterUtils'; import {ComponentBus} from '@app/features/platform/utils/ComponentBus'; import StreamerMode from '@app/features/streamer_mode/state/StreamerMode'; import {InviteEmbedContextMenu} from '@app/features/ui/action_menu/InviteEmbedContextMenu'; import {Button} from '@app/features/ui/button/Button'; import * as ContextMenuCommands from '@app/features/ui/commands/ContextMenuCommands'; import {Avatar} from '@app/features/ui/components/Avatar'; import FocusRing from '@app/features/ui/focus_ring/FocusRing'; import {User} from '@app/features/user/models/User'; import Users from '@app/features/user/state/Users'; import * as AvatarUtils from '@app/features/user/utils/AvatarUtils'; import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils'; import * as NicknameUtils from '@app/features/user/utils/NicknameUtils'; import {msg} from '@lingui/core/macro'; import {Plural, Trans, useLingui} from '@lingui/react/macro'; import {QuestionIcon} from '@phosphor-icons/react'; import {formatNumber} from '@pkgs/number_utils/src/NumberFormatting'; import {observer} from 'mobx-react-lite'; import type React from 'react'; import {useEffect, useMemo, useRef, useState} from 'react'; const UNNAMED_GROUP_DESCRIPTOR = msg({ message: 'Unnamed group', comment: 'Fallback title in an invite embed for a group DM with no name set.', }); const ALREADY_JOINED_DESCRIPTOR = msg({ message: 'Already joined', comment: 'Status label on a group DM invite embed when the current user is already a member.', }); const JOIN_GROUP_DESCRIPTOR = msg({ message: 'Join group', comment: 'Button label on a group DM invite embed that accepts the invite.', }); const EMOJI_PACK_DESCRIPTOR = msg({ message: 'Emoji pack', comment: 'Kind label on an emoji pack invite embed.', }); const STICKER_PACK_DESCRIPTOR = msg({ message: 'Sticker pack', comment: 'Kind label on a sticker pack invite embed.', }); const INSTALL_EMOJI_PACK_DESCRIPTOR = msg({ message: 'Install emoji pack', comment: 'Button label on an emoji pack invite embed that installs the pack.', }); const INSTALL_STICKER_PACK_DESCRIPTOR = msg({ message: 'Install sticker pack', comment: 'Button label on a sticker pack invite embed that installs the pack.', }); const ACCEPTING_THIS_INVITE_INSTALLS_THE_PACK_AUTOMATICALLY_DESCRIPTOR = msg({ message: 'Accepting this invite installs the pack automatically.', comment: 'Helper text on a pack invite embed explaining that accepting installs the pack.', }); const INVITES_DISABLED_DESCRIPTOR = msg({ message: 'Invites disabled', comment: 'Status label on a community invite embed when invites are paused or anti-raid mode is active.', }); const GO_TO_COMMUNITY_DESCRIPTOR = msg({ message: 'Go to community', comment: 'Button label on a community invite embed when the current user is already a member.', }); const ONLINE_DESCRIPTOR = msg({ message: '{renderedPresenceCount} online', comment: 'Presence summary on a community invite embed. renderedPresenceCount is a formatted count of online members.', }); const DETECTED_A_POTENTIAL_RAID_SO_NEW_USERS_CAN_DESCRIPTOR = msg({ message: "{productName} detected a potential raid, so new users can't join right now.", comment: 'Anti-raid status message on a community invite embed. productName is the product brand name placeholder.', }); const INVITES_ARE_CURRENTLY_PAUSED_FOR_THIS_COMMUNITY_DESCRIPTOR = msg({ message: 'Invites are paused for this community.', comment: 'Status message on a community invite embed when invites are paused by community admins.', }); const UNKNOWN_INVITE_DESCRIPTOR = msg({ message: 'Unknown invite', comment: 'Title shown on an invite embed when the invite code resolves to nothing (expired, revoked, or invalid).', }); const TRY_ASKING_FOR_A_NEW_INVITE_DESCRIPTOR = msg({ message: 'Try asking for a new invite.', comment: 'Helper text on an unknown invite embed suggesting the user request a fresh invite.', }); const INVITE_UNAVAILABLE_DESCRIPTOR = msg({ message: 'Invite unavailable', comment: 'Title shown on an invite embed when the invite cannot be resolved due to a non-permanent failure.', }); const INVITE_MASKED_DESCRIPTOR = msg({ message: 'Invite masked', comment: 'Title shown on an invite embed while streaming privacy is active.', }); const THIS_INVITE_IS_MASKED_WHILE_SHARING_DESCRIPTOR = msg({ message: 'This invite is hidden while sharing is protected.', comment: 'Helper text shown on an invite embed while streaming privacy is active.', }); const createTitleKeyDownHandler = (callback: () => void) => (event: React.KeyboardEvent) => { if (isKeyboardActivationKey(event.key)) { event.preventDefault(); callback(); } }; function formatInviteCount(value: number): string { return formatNumber(value, getCurrentLocale()); } interface InviteEmbedProps { code: string; message?: Message; sourceChannel?: Channel | null; onDelete?: (bypassConfirm?: boolean) => void; } export const InviteEmbed = observer(function InviteEmbed({code, message, sourceChannel, onDelete}: InviteEmbedProps) { if (StreamerMode.shouldHideInviteLinks) { return ; } return ( ); }); const InviteEmbedInner = observer(function InviteEmbedInner({ code, message, sourceChannel, onDelete, }: InviteEmbedProps) { const {i18n} = useLingui(); const inviteState = Invites.invites.get(code) ?? null; const shouldForceSkeleton = useEmbedSkeletonOverride(); const invite = inviteState?.data ?? null; const isPackInvite = invite != null && isPackInviteGuard(invite); const isGuildInviteType = invite != null && isGuildInvite(invite); const packCreatorRecord = useMemo(() => { if (!isPackInvite || !invite) return null; return new User(invite.pack.creator); }, [invite, isPackInvite]); const guildFromInvite = isGuildInviteType ? invite!.guild : null; const guild = Guilds.getGuild(guildFromInvite?.id ?? '') || guildFromInvite; const embedSplash = guild != null ? ('embedSplash' in guild ? guild.embedSplash : guild.embed_splash) : undefined; const splashURL = guild != null ? AvatarUtils.getGuildEmbedSplashURL({id: guild.id, embedSplash: embedSplash || null}) : null; const messageViewContext = useMaybeMessageViewContext(); const currentChannelId = messageViewContext?.channel.id; const inviteWrapperRef = useRef(null); const isLoading = shouldForceSkeleton || !inviteState || inviteState.loading; const prevLoadingRef = useRef(true); const prevCodeRef = useRef(code); useEffect(() => { if (prevCodeRef.current !== code) { prevLoadingRef.current = true; prevCodeRef.current = code; } }, [code]); useEffect(() => { if (prevLoadingRef.current && !isLoading && currentChannelId) { ComponentBus.dispatch('LAYOUT_RESIZED', {channelId: currentChannelId}); } prevLoadingRef.current = isLoading; }, [isLoading, currentChannelId]); useEffect(() => { if (!inviteState) { void InviteCommands.fetchWithCoalescing(code).catch(() => {}); } }, [code, inviteState]); const memberGuildId = isGuildInviteType && Guilds.getGuild(guildFromInvite?.id ?? '') ? guild!.id : null; useEffect(() => { if (memberGuildId) { GuildCount.requestCounts(memberGuildId); } }, [memberGuildId]); let content: React.ReactNode; if (shouldForceSkeleton || !inviteState || inviteState.loading) { content = ; } else if (inviteState.error || !invite) { content = ; } else if (isGroupDmInvite(invite)) { const inviter = Users.getUser(invite.inviter?.id ?? ''); const groupDMTitle = getGroupDMTitle(invite.channel, i18n._(UNNAMED_GROUP_DESCRIPTOR)); const groupDMPath = Routes.dmChannel(invite.channel.id); const handleAcceptInvite = () => InviteCommands.acceptAndTransitionToChannel(invite.code, i18n); const handleNavigateToGroup = () => RouterUtils.transitionTo(groupDMPath); const groupDMCounts = getGroupDmInviteCounts({ channelId: invite.channel.id, inviteMemberCount: invite.member_count, }); const isAlreadyInGroupDM = groupDMCounts.hasLocalChannel; const memberCount = groupDMCounts.memberCount; const renderedMemberCount = formatInviteCount(memberCount); content = ( ) : (
) } title={

} body={
{renderedMemberCount}{' '}
} footer={ } data-flx="channel.invite-embed.embed-card" /> ); } else if (isPackInviteGuard(invite)) { const pack = invite.pack; const packCreator = packCreatorRecord ?? new User(pack.creator); const packKindLabel = pack.type === 'emoji' ? i18n._(EMOJI_PACK_DESCRIPTOR) : i18n._(STICKER_PACK_DESCRIPTOR); const packActionLabel = pack.type === 'emoji' ? i18n._(INSTALL_EMOJI_PACK_DESCRIPTOR) : i18n._(INSTALL_STICKER_PACK_DESCRIPTOR); const inviterTag = invite.inviter ? NicknameUtils.formatTagForStreamerMode(`${invite.inviter.username}#${invite.inviter.discriminator}`) : null; const handleAcceptInvite = () => InviteCommands.acceptAndTransitionToChannel(invite.code, i18n); content = ( } title={

{pack.name}

{packKindLabel}
} body={

{pack.description || i18n._(NO_DESCRIPTION_PROVIDED_DESCRIPTOR)}

Created by {NicknameUtils.getDisplayName(packCreator)} {inviterTag ? ( Invited by {inviterTag} ) : null}

{i18n._(ACCEPTING_THIS_INVITE_INSTALLS_THE_PACK_AUTOMATICALLY_DESCRIPTOR)}

} footer={ } data-flx="channel.invite-embed.embed-card--2" /> ); } else if (!guild || !isGuildInvite(invite)) { content = ; } else { const guildActionState = getGuildInviteActionState({invite, guild}); const {features, isMember} = guildActionState; const liveCounts = isMember ? GuildCount.getCounts(guild.id) : null; const presenceCount = liveCounts?.onlineCount ?? guildActionState.presenceCount; const memberCount = liveCounts?.memberCount ?? guildActionState.memberCount; const splashAspectRatio = getGuildEmbedSplashAspectRatio(guild); const renderedPresenceCount = formatInviteCount(presenceCount); const renderedMemberCount = formatInviteCount(memberCount); const handleAcceptInvite = () => InviteCommands.acceptAndTransitionToChannel(invite.code, i18n); const guildPath = Routes.guildChannel(guild.id, invite.channel.id); const handleNavigateToGuild = () => RouterUtils.transitionTo(guildPath); const actionType = getGuildInvitePrimaryAction(guildActionState); const isButtonDisabled = isGuildInviteActionDisabled(guildActionState); const getButtonLabel = () => { switch (actionType) { case GuildInvitePrimaryAction.InvitesDisabled: return i18n._(INVITES_DISABLED_DESCRIPTOR); case GuildInvitePrimaryAction.GoToCommunity: return i18n._(GO_TO_COMMUNITY_DESCRIPTOR); default: return i18n._(JOIN_COMMUNITY_DESCRIPTOR); } }; content = ( } title={

} body={
{i18n._(ONLINE_DESCRIPTOR, {renderedPresenceCount})}
{renderedMemberCount}{' '}
{actionType === GuildInvitePrimaryAction.InvitesDisabled && (

{guildActionState.isRaidDetected ? i18n._(DETECTED_A_POTENTIAL_RAID_SO_NEW_USERS_CAN_DESCRIPTOR, {productName: PRODUCT_NAME}) : i18n._(INVITES_ARE_CURRENTLY_PAUSED_FOR_THIS_COMMUNITY_DESCRIPTOR)}

)}
} footer={ } data-flx="channel.invite-embed.embed-card--3" /> ); } const handleContextMenu = (event: React.MouseEvent) => { const channelId = invite && (isGuildInvite(invite) || isGroupDmInvite(invite)) ? invite.channel.id : null; const guildForMenu = guild ? {id: guild.id, name: guild.name} : null; const inviteCodeForMenu = guildForMenu && invite ? invite.code : null; if (!message && !guildForMenu && !channelId) return; event.preventDefault(); event.stopPropagation(); ContextMenuCommands.openFromEvent(event, (props) => ( )); }; return (
{content}
); }); const InviteHiddenState = observer(() => { const {i18n} = useLingui(); return (
} title={

{i18n._(INVITE_MASKED_DESCRIPTOR)}

} body={

{i18n._(THIS_INVITE_IS_MASKED_WHILE_SHARING_DESCRIPTOR)}

} footer={null} data-flx="channel.invite-embed.invite-hidden-state.embed-card" /> ); }); const InviteLoadingState = observer(() => { return ( } title={
} body={
} footer={} data-flx="channel.invite-embed.invite-loading-state.embed-card" /> ); }); const InviteNotFoundError = observer(() => { const {i18n} = useLingui(); return (
} title={

{i18n._(UNKNOWN_INVITE_DESCRIPTOR)}

} subtitle={ {i18n._(TRY_ASKING_FOR_A_NEW_INVITE_DESCRIPTOR)} } footer={ } data-flx="channel.invite-embed.invite-not-found-error.embed-card" /> ); }); interface GuildInviteEmbedPreviewProps { guildId: string; splashURLOverride?: string | null; } export const GuildInviteEmbedPreview = observer(function GuildInviteEmbedPreview({ guildId, splashURLOverride, }: GuildInviteEmbedPreviewProps) { const {i18n} = useLingui(); const guild = Guilds.getGuild(guildId); const [base64AspectRatio, setBase64AspectRatio] = useState(); const splashAspectRatio = useMemo(() => { if (!guild) return undefined; if (splashURLOverride) { return base64AspectRatio; } return getGuildEmbedSplashAspectRatio(guild); }, [guild, splashURLOverride, base64AspectRatio]); useEffect(() => { if (splashURLOverride) { getImageAspectRatioFromBase64(splashURLOverride) .then((ratio) => setBase64AspectRatio(clampWideAssetAspectRatio(ratio))) .catch(() => { setBase64AspectRatio(undefined); }); } else { setBase64AspectRatio(undefined); } }, [splashURLOverride]); useEffect(() => { GuildCount.requestCounts(guildId); }, [guildId]); if (!guild) return null; const splashURL = splashURLOverride !== undefined ? splashURLOverride : AvatarUtils.getGuildEmbedSplashURL({id: guild.id, embedSplash: guild.embedSplash || null}); const liveCounts = GuildCount.getCounts(guild.id); const presenceCount = liveCounts?.onlineCount ?? 0; const memberCount = liveCounts?.memberCount ?? 0; const renderedPresenceCount = formatInviteCount(presenceCount); const renderedMemberCount = formatInviteCount(memberCount); return ( } title={

{guild.name}

} body={
{i18n._(ONLINE_DESCRIPTOR, {renderedPresenceCount})}
{renderedMemberCount}{' '}
} footer={ } data-flx="channel.invite-embed.guild-invite-embed-preview.embed-card" /> ); });