[skip ci] feat: prepare for public release
This commit is contained in:
@@ -84,9 +84,6 @@ function NativeDatePicker({
|
||||
error,
|
||||
}: NativeDatePickerProps) {
|
||||
const {t} = useLingui();
|
||||
const _monthPlaceholder = t`Month`;
|
||||
const _dayPlaceholder = t`Day`;
|
||||
const _yearPlaceholder = t`Year`;
|
||||
const dateOfBirthPlaceholder = t`Date of birth`;
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
@@ -131,6 +131,7 @@ import * as ChannelUtils from '~/utils/ChannelUtils';
|
||||
import {getMutedText, getNotificationSettingsLabel} from '~/utils/ContextMenuUtils';
|
||||
import {MAX_GROUP_DM_RECIPIENTS} from '~/utils/groupDmUtils';
|
||||
import * as InviteUtils from '~/utils/InviteUtils';
|
||||
import * as MemberListUtils from '~/utils/MemberListUtils';
|
||||
import {buildChannelLink} from '~/utils/messageLinkUtils';
|
||||
import * as NicknameUtils from '~/utils/NicknameUtils';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
@@ -278,7 +279,18 @@ interface LazyMemberListGroupProps {
|
||||
const LazyMemberListGroup = observer(
|
||||
({guild, group, channelId, members, onMemberLongPress}: LazyMemberListGroupProps) => {
|
||||
const {t} = useLingui();
|
||||
const groupName = group.id === 'online' ? t`Online` : group.id === 'offline' ? t`Offline` : group.id;
|
||||
const groupName = (() => {
|
||||
switch (group.id) {
|
||||
case 'online':
|
||||
return t`Online`;
|
||||
case 'offline':
|
||||
return t`Offline`;
|
||||
default: {
|
||||
const role = guild.getRole(group.id);
|
||||
return role?.name ?? group.id;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className={styles.memberGroupContainer}>
|
||||
@@ -321,6 +333,7 @@ const LazyGuildMemberList = observer(
|
||||
guildId: guild.id,
|
||||
channelId: channel.id,
|
||||
enabled,
|
||||
allowInitialUnfocusedLoad: true,
|
||||
});
|
||||
|
||||
const memberListState = MemberSidebarStore.getList(guild.id, channel.id);
|
||||
@@ -364,21 +377,22 @@ const LazyGuildMemberList = observer(
|
||||
|
||||
const groupedItems: Map<string, Array<GuildMemberRecord>> = new Map();
|
||||
const groups = memberListState.groups;
|
||||
const seenMemberIds = new Set<string>();
|
||||
|
||||
for (const group of groups) {
|
||||
groupedItems.set(group.id, []);
|
||||
}
|
||||
|
||||
for (const [, item] of memberListState.items) {
|
||||
if (item.type === 'member') {
|
||||
let currentGroup: string | null = null;
|
||||
const sortedItems = Array.from(memberListState.items.entries()).sort(([a], [b]) => a - b);
|
||||
for (const [, item] of sortedItems) {
|
||||
if (item.type === 'group') {
|
||||
currentGroup = (item.data as {id: string}).id;
|
||||
} else if (item.type === 'member' && currentGroup) {
|
||||
const member = item.data as GuildMemberRecord;
|
||||
for (let i = groups.length - 1; i >= 0; i--) {
|
||||
const group = groups[i];
|
||||
const members = groupedItems.get(group.id);
|
||||
if (members) {
|
||||
members.push(member);
|
||||
break;
|
||||
}
|
||||
if (!seenMemberIds.has(member.user.id)) {
|
||||
seenMemberIds.add(member.user.id);
|
||||
groupedItems.get(currentGroup)?.push(member);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -734,6 +748,24 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
|
||||
}, []);
|
||||
|
||||
const isMemberTabVisible = isOpen && activeTab === 'members';
|
||||
const dmMemberGroups = (() => {
|
||||
if (!(isDM || isGroupDM || isPersonalNotes)) return [];
|
||||
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
let memberIds: Array<string> = [];
|
||||
|
||||
if (isPersonalNotes) {
|
||||
memberIds = currentUser ? [currentUser.id] : [];
|
||||
} else {
|
||||
memberIds = [...channel.recipientIds];
|
||||
if (currentUserId && !memberIds.includes(currentUserId)) {
|
||||
memberIds.push(currentUserId);
|
||||
}
|
||||
}
|
||||
|
||||
const users = memberIds.map((id) => UserStore.getUser(id)).filter((u): u is UserRecord => u !== null);
|
||||
return MemberListUtils.getGroupDMMemberGroups(users);
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -910,68 +942,60 @@ export const ChannelDetailsBottomSheet: React.FC<ChannelDetailsBottomSheetProps>
|
||||
)}
|
||||
|
||||
<div className={styles.membersHeader}>
|
||||
<Trans>Members</Trans> —{' '}
|
||||
{isPersonalNotes ? 1 : isGroupDM ? channel.recipientIds.length + 1 : 2}
|
||||
<Trans>Members</Trans> — {dmMemberGroups.reduce((total, group) => total + group.count, 0)}
|
||||
</div>
|
||||
<div className={styles.membersListContainer}>
|
||||
{(() => {
|
||||
let memberIds: Array<string> = [];
|
||||
if (isPersonalNotes) {
|
||||
memberIds = currentUser ? [currentUser.id] : [];
|
||||
} else if (isGroupDM) {
|
||||
memberIds = [...channel.recipientIds];
|
||||
if (currentUser && !memberIds.includes(currentUser.id)) {
|
||||
memberIds.push(currentUser.id);
|
||||
}
|
||||
} else if (isDM) {
|
||||
memberIds = [...channel.recipientIds];
|
||||
if (currentUser && !memberIds.includes(currentUser.id)) {
|
||||
memberIds.push(currentUser.id);
|
||||
}
|
||||
}
|
||||
{dmMemberGroups.map((group) => (
|
||||
<div key={group.id} className={styles.memberGroupContainer}>
|
||||
<div className={styles.memberGroupHeader}>
|
||||
{group.displayName} — {group.count}
|
||||
</div>
|
||||
<div className={styles.memberGroupList}>
|
||||
{group.users.map((user, index) => {
|
||||
const isCurrentUser = user.id === currentUser?.id;
|
||||
const isOwner = isGroupDM && channel.ownerId === user.id;
|
||||
|
||||
return memberIds.map((userId, index, arr) => {
|
||||
const user = UserStore.getUser(userId);
|
||||
if (!user) return null;
|
||||
const handleUserClick = () => {
|
||||
UserProfileActionCreators.openUserProfile(user.id);
|
||||
};
|
||||
|
||||
const isCurrentUser = user.id === currentUser?.id;
|
||||
const isOwner = isGroupDM && channel.ownerId === user.id;
|
||||
|
||||
const handleUserClick = () => {
|
||||
UserProfileActionCreators.openUserProfile(user.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Fragment key={user.id}>
|
||||
<button type="button" onClick={handleUserClick} className={styles.memberItemButton}>
|
||||
<StatusAwareAvatar user={user} size={40} />
|
||||
<div className={styles.memberItemContent}>
|
||||
<span className={styles.memberItemName}>
|
||||
{user.username}
|
||||
{isCurrentUser && (
|
||||
<span className={styles.memberItemYou}>
|
||||
{' '}
|
||||
<Trans>(you)</Trans>
|
||||
return (
|
||||
<React.Fragment key={user.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUserClick}
|
||||
className={styles.memberItemButton}
|
||||
>
|
||||
<StatusAwareAvatar user={user} size={40} />
|
||||
<div className={styles.memberItemContent}>
|
||||
<span className={styles.memberItemName}>
|
||||
{user.username}
|
||||
{isCurrentUser && (
|
||||
<span className={styles.memberItemYou}>
|
||||
{' '}
|
||||
<Trans>(you)</Trans>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{(user.bot || isOwner) && (
|
||||
<div className={styles.memberItemTags}>
|
||||
{user.bot && <UserTag system={user.system} />}
|
||||
{isOwner && (
|
||||
<Tooltip text={t`Group Owner`}>
|
||||
<CrownIcon className={styles.ownerCrown} weight="fill" />
|
||||
</Tooltip>
|
||||
{(user.bot || isOwner) && (
|
||||
<div className={styles.memberItemTags}>
|
||||
{user.bot && <UserTag system={user.system} />}
|
||||
{isOwner && (
|
||||
<Tooltip text={t`Group Owner`}>
|
||||
<CrownIcon className={styles.ownerCrown} weight="fill" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{index < arr.length - 1 && <div className={styles.memberItemDivider} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</button>
|
||||
{index < group.users.length - 1 && <div className={styles.memberItemDivider} />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -78,6 +78,7 @@ import RuntimeConfigStore from '~/stores/RuntimeConfigStore';
|
||||
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import * as CallUtils from '~/utils/CallUtils';
|
||||
import {getMutedText} from '~/utils/ContextMenuUtils';
|
||||
import * as InviteUtils from '~/utils/InviteUtils';
|
||||
@@ -113,6 +114,7 @@ export const DMBottomSheet: React.FC<DMBottomSheetProps> = observer(({isOpen, on
|
||||
const isRecipientBot = recipient?.bot;
|
||||
const relationship = recipient ? RelationshipStore.getRelationship(recipient.id) : null;
|
||||
const relationshipType = relationship?.type;
|
||||
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
ReadStateActionCreators.ack(channel.id, true, true);
|
||||
@@ -517,7 +519,8 @@ export const DMBottomSheet: React.FC<DMBottomSheetProps> = observer(({isOpen, on
|
||||
});
|
||||
} else if (
|
||||
relationshipType !== RelationshipTypes.OUTGOING_REQUEST &&
|
||||
relationshipType !== RelationshipTypes.BLOCKED
|
||||
relationshipType !== RelationshipTypes.BLOCKED &&
|
||||
!currentUserUnclaimed
|
||||
) {
|
||||
relationshipItems.push({
|
||||
icon: <UserPlusIcon weight="fill" className={sharedStyles.icon} />,
|
||||
|
||||
@@ -23,8 +23,11 @@ import {PhoneIcon, VideoCameraIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as CallActionCreators from '~/actions/CallActionCreators';
|
||||
import * as ToastActionCreators from '~/actions/ToastActionCreators';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import type {ChannelRecord} from '~/records/ChannelRecord';
|
||||
import CallStateStore from '~/stores/CallStateStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||
import * as CallUtils from '~/utils/CallUtils';
|
||||
import {ChannelHeaderIcon} from './ChannelHeaderIcon';
|
||||
@@ -38,9 +41,20 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
||||
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
|
||||
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
|
||||
const participantCount = participants.length;
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
|
||||
const is1to1 = channel.type === ChannelTypes.DM;
|
||||
const blocked = isUnclaimed && is1to1;
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
async (event: React.MouseEvent) => {
|
||||
if (blocked) {
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: t`Claim your account to start or join 1:1 calls.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isInCall) {
|
||||
void CallActionCreators.leaveCall(channel.id);
|
||||
} else if (hasActiveCall) {
|
||||
@@ -50,7 +64,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
||||
await CallUtils.checkAndStartCall(channel.id, silent);
|
||||
}
|
||||
},
|
||||
[channel.id, isInCall, hasActiveCall],
|
||||
[channel.id, isInCall, hasActiveCall, blocked],
|
||||
);
|
||||
|
||||
let label: string;
|
||||
@@ -67,7 +81,13 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
||||
: t`Join Voice Call (${participantCount} participants)`;
|
||||
}
|
||||
} else {
|
||||
label = isInCall ? t`Leave Voice Call` : hasActiveCall ? t`Join Voice Call` : t`Start Voice Call`;
|
||||
label = blocked
|
||||
? t`Claim your account to call`
|
||||
: isInCall
|
||||
? t`Leave Voice Call`
|
||||
: hasActiveCall
|
||||
? t`Join Voice Call`
|
||||
: t`Start Voice Call`;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -76,6 +96,7 @@ const VoiceCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
||||
label={label}
|
||||
isSelected={isInCall}
|
||||
onClick={handleClick}
|
||||
disabled={blocked}
|
||||
keybindAction="start_pm_call"
|
||||
/>
|
||||
);
|
||||
@@ -90,9 +111,20 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
||||
const hasActiveCall = CallStateStore.hasActiveCall(channel.id);
|
||||
const participants = call ? CallStateStore.getParticipants(channel.id) : [];
|
||||
const participantCount = participants.length;
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
|
||||
const is1to1 = channel.type === ChannelTypes.DM;
|
||||
const blocked = isUnclaimed && is1to1;
|
||||
|
||||
const handleClick = React.useCallback(
|
||||
async (event: React.MouseEvent) => {
|
||||
if (blocked) {
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: t`Claim your account to start or join 1:1 calls.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isInCall) {
|
||||
void CallActionCreators.leaveCall(channel.id);
|
||||
} else if (hasActiveCall) {
|
||||
@@ -102,7 +134,7 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
||||
await CallUtils.checkAndStartCall(channel.id, silent);
|
||||
}
|
||||
},
|
||||
[channel.id, isInCall, hasActiveCall],
|
||||
[channel.id, isInCall, hasActiveCall, blocked],
|
||||
);
|
||||
|
||||
let label: string;
|
||||
@@ -119,10 +151,24 @@ const VideoCallButton = observer(({channel}: {channel: ChannelRecord}) => {
|
||||
: t`Join Video Call (${participantCount} participants)`;
|
||||
}
|
||||
} else {
|
||||
label = isInCall ? t`Leave Video Call` : hasActiveCall ? t`Join Video Call` : t`Start Video Call`;
|
||||
label = blocked
|
||||
? t`Claim your account to call`
|
||||
: isInCall
|
||||
? t`Leave Video Call`
|
||||
: hasActiveCall
|
||||
? t`Join Video Call`
|
||||
: t`Start Video Call`;
|
||||
}
|
||||
|
||||
return <ChannelHeaderIcon icon={VideoCameraIcon} label={label} isSelected={isInCall} onClick={handleClick} />;
|
||||
return (
|
||||
<ChannelHeaderIcon
|
||||
icon={VideoCameraIcon}
|
||||
label={label}
|
||||
isSelected={isInCall}
|
||||
onClick={handleClick}
|
||||
disabled={blocked}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const CallButtons = {
|
||||
|
||||
@@ -33,7 +33,7 @@ import type {UserRecord} from '~/records/UserRecord';
|
||||
import AuthenticationStore from '~/stores/AuthenticationStore';
|
||||
import MemberSidebarStore from '~/stores/MemberSidebarStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import type {GroupDMMemberGroup, MemberGroup} from '~/utils/MemberListUtils';
|
||||
import type {GroupDMMemberGroup} from '~/utils/MemberListUtils';
|
||||
import * as MemberListUtils from '~/utils/MemberListUtils';
|
||||
import * as NicknameUtils from '~/utils/NicknameUtils';
|
||||
import styles from './ChannelMembers.module.css';
|
||||
@@ -65,35 +65,6 @@ const SkeletonMemberItem = ({index}: {index: number}) => {
|
||||
);
|
||||
};
|
||||
|
||||
const _MemberListGroup = observer(
|
||||
({guild, group, channelId}: {guild: GuildRecord; group: MemberGroup; channelId: string}) => (
|
||||
<div className={styles.groupContainer}>
|
||||
<div className={styles.groupHeader}>
|
||||
{group.displayName} — {group.count}
|
||||
</div>
|
||||
<div className={styles.membersList}>
|
||||
{group.members.map((member: GuildMemberRecord) => {
|
||||
const user = member.user;
|
||||
const userId = user.id;
|
||||
return (
|
||||
<MemberListItem
|
||||
key={userId}
|
||||
user={user}
|
||||
channelId={channelId}
|
||||
guildId={guild.id}
|
||||
isOwner={guild.isOwner(userId)}
|
||||
roleColor={member.getColorString?.() ?? undefined}
|
||||
displayName={NicknameUtils.getNickname(user, guild.id)}
|
||||
disableBackdrop={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className={styles.groupSpacer} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
interface GroupDMMemberListGroupProps {
|
||||
group: GroupDMMemberGroup;
|
||||
channelId: string;
|
||||
|
||||
@@ -40,9 +40,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||
import * as ContextMenuActionCreators from '~/actions/ContextMenuActionCreators';
|
||||
import {MessagePreviewContext} from '~/Constants';
|
||||
import {Message, type MessageBehaviorOverrides} from '~/components/channel/Message';
|
||||
import {GroupDMAvatar} from '~/components/common/GroupDMAvatar';
|
||||
import {MessageContextPrefix} from '~/components/shared/MessageContextPrefix/MessageContextPrefix';
|
||||
import {Avatar} from '~/components/uikit/Avatar';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {ContextMenuCloseProvider} from '~/components/uikit/ContextMenu/ContextMenu';
|
||||
import {MenuGroup} from '~/components/uikit/ContextMenu/MenuGroup';
|
||||
@@ -58,9 +56,7 @@ import ChannelSearchStore, {getChannelSearchContextId} from '~/stores/ChannelSea
|
||||
import ChannelStore from '~/stores/ChannelStore';
|
||||
import GuildNSFWAgreeStore from '~/stores/GuildNSFWAgreeStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import {applyChannelSearchHighlight, clearChannelSearchHighlight} from '~/utils/ChannelSearchHighlight';
|
||||
import * as ChannelUtils from '~/utils/ChannelUtils';
|
||||
import {goToMessage} from '~/utils/MessageNavigator';
|
||||
import * as RouterUtils from '~/utils/RouterUtils';
|
||||
import {tokenizeSearchQuery} from '~/utils/SearchQueryTokenizer';
|
||||
@@ -78,14 +74,6 @@ import type {SearchMachineState} from './SearchResultsUtils';
|
||||
import {areSegmentsEqual} from './SearchResultsUtils';
|
||||
import {DEFAULT_SCOPE_VALUE, getScopeOptionsForChannel} from './searchScopeOptions';
|
||||
|
||||
const getChannelDisplayName = (channel: ChannelRecord): string => {
|
||||
if (channel.isPrivate()) {
|
||||
return ChannelUtils.getDMDisplayName(channel);
|
||||
}
|
||||
|
||||
return channel.name?.trim() || ChannelUtils.getName(channel);
|
||||
};
|
||||
|
||||
const getChannelGuild = (channel: ChannelRecord): GuildRecord | null => {
|
||||
if (!channel.guildId) {
|
||||
return null;
|
||||
@@ -101,37 +89,6 @@ const getChannelPath = (channel: ChannelRecord): string => {
|
||||
return Routes.dmChannel(channel.id);
|
||||
};
|
||||
|
||||
const _renderChannelIcon = (channel: ChannelRecord): React.ReactNode => {
|
||||
if (channel.isPersonalNotes()) {
|
||||
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
|
||||
}
|
||||
|
||||
if (channel.isDM()) {
|
||||
const recipientId = channel.recipientIds[0];
|
||||
const recipient = recipientId ? UserStore.getUser(recipientId) : null;
|
||||
|
||||
if (recipient) {
|
||||
return (
|
||||
<div className={styles.channelIconAvatar}>
|
||||
<Avatar user={recipient} size={20} status={null} className={styles.channelIconAvatarImage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
|
||||
}
|
||||
|
||||
if (channel.isGroupDM()) {
|
||||
return (
|
||||
<div className={styles.channelIconAvatar}>
|
||||
<GroupDMAvatar channel={channel} size={20} disableStatusIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return ChannelUtils.getIcon(channel, {className: styles.channelIcon});
|
||||
};
|
||||
|
||||
interface ChannelSearchResultsProps {
|
||||
channel: ChannelRecord;
|
||||
searchQuery: string;
|
||||
@@ -753,7 +710,6 @@ export const ChannelSearchResults = observer(
|
||||
}
|
||||
|
||||
const channelGuild = getChannelGuild(messageChannel);
|
||||
const _channelDisplayName = getChannelDisplayName(messageChannel);
|
||||
const showGuildMeta = shouldShowGuildMetaForScope(
|
||||
channelGuild,
|
||||
(activeScope ?? DEFAULT_SCOPE_VALUE) as MessageSearchScope,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '~/components/embeds/EmbedCard/EmbedCard';
|
||||
import cardStyles from '~/components/embeds/EmbedCard/EmbedCard.module.css';
|
||||
import {useEmbedSkeletonOverride} from '~/components/embeds/EmbedCard/useEmbedSkeletonOverride';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import i18n from '~/i18n';
|
||||
import {ComponentDispatch} from '~/lib/ComponentDispatch';
|
||||
@@ -48,6 +49,7 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
|
||||
const giftState = GiftStore.gifts.get(code) ?? null;
|
||||
const gift = giftState?.data;
|
||||
const creator = UserStore.getUser(gift?.created_by?.id ?? '');
|
||||
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
|
||||
const shouldForceSkeleton = useEmbedSkeletonOverride();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -76,6 +78,10 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
|
||||
const durationText = getGiftDurationText(i18n, gift);
|
||||
|
||||
const handleRedeem = async () => {
|
||||
if (isUnclaimed) {
|
||||
openClaimAccountModal({force: true});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await GiftActionCreators.redeem(i18n, code);
|
||||
} catch (error) {
|
||||
@@ -87,17 +93,22 @@ export const GiftEmbed = observer(function GiftEmbed({code}: GiftEmbedProps) {
|
||||
<span className={styles.subRow}>{t`From ${creator.username}#${creator.discriminator}`}</span>
|
||||
) : undefined;
|
||||
|
||||
const helpText = gift.redeemed ? t`Already redeemed` : t`Click to claim your gift!`;
|
||||
const helpText = gift.redeemed
|
||||
? t`Already redeemed`
|
||||
: isUnclaimed
|
||||
? t`Claim your account to redeem this gift.`
|
||||
: t`Click to claim your gift!`;
|
||||
|
||||
const footer = gift.redeemed ? (
|
||||
<Button variant="primary" matchSkeletonHeight disabled>
|
||||
{t`Gift Claimed`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" matchSkeletonHeight onClick={handleRedeem}>
|
||||
{t`Claim Gift`}
|
||||
</Button>
|
||||
);
|
||||
const footer =
|
||||
gift.redeemed && !isUnclaimed ? (
|
||||
<Button variant="primary" matchSkeletonHeight disabled>
|
||||
{t`Gift Claimed`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" matchSkeletonHeight onClick={handleRedeem} disabled={gift.redeemed || isUnclaimed}>
|
||||
{gift.redeemed ? t`Gift Claimed` : isUnclaimed ? t`Claim Account to Redeem` : t`Claim Gift`}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<EmbedCard
|
||||
|
||||
@@ -108,7 +108,6 @@ const MessageReactionItem = observer(
|
||||
|
||||
const emojiName = getEmojiName(reaction.emoji);
|
||||
const emojiUrl = useEmojiURL({emoji: reaction.emoji, isHovering});
|
||||
const _emojiIdentifier = reaction.emoji.id ?? reaction.emoji.name;
|
||||
const isUnicodeEmoji = reaction.emoji.id == null;
|
||||
|
||||
const variants = {
|
||||
|
||||
@@ -280,9 +280,7 @@ export const Messages = observer(function Messages({channel}: {channel: ChannelR
|
||||
const data = payload as {channelId?: string; heightDelta?: number} | undefined;
|
||||
if (data?.channelId && data.channelId !== channel.id) return;
|
||||
|
||||
if (scrollManager.isPinned()) {
|
||||
scrollManager.handleScroll();
|
||||
}
|
||||
scrollManager.handleScroll();
|
||||
};
|
||||
|
||||
const onFocusBottommostMessage = (payload?: unknown) => {
|
||||
|
||||
@@ -23,7 +23,7 @@ import {observer} from 'mobx-react-lite';
|
||||
import {useEffect, useState} from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {PhoneAddModal} from '~/components/modals/PhoneAddModal';
|
||||
import {UserSettingsModal} from '~/components/modals/UserSettingsModal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
@@ -103,7 +103,7 @@ export const UnclaimedAccountBarrier = observer(({onAction}: BarrierProps) => {
|
||||
small={true}
|
||||
onClick={() => {
|
||||
onAction?.();
|
||||
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
|
||||
openClaimAccountModal({force: true});
|
||||
}}
|
||||
>
|
||||
<Trans>Claim Account</Trans>
|
||||
@@ -234,7 +234,7 @@ export const UnclaimedDMBarrier = observer(({onAction}: BarrierProps) => {
|
||||
small={true}
|
||||
onClick={() => {
|
||||
onAction?.();
|
||||
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
|
||||
openClaimAccountModal({force: true});
|
||||
}}
|
||||
>
|
||||
<Trans>Claim Account</Trans>
|
||||
|
||||
@@ -205,6 +205,7 @@ export const DMChannelView = observer(({channelId}: DMChannelViewProps) => {
|
||||
const title = isDM && displayName ? `@${displayName}` : displayName;
|
||||
useFluxerDocumentTitle(title);
|
||||
const isGroupDM = channel?.type === ChannelTypes.GROUP_DM;
|
||||
const isPersonalNotes = channel?.type === ChannelTypes.DM_PERSONAL_NOTES;
|
||||
const callHeaderState = useCallHeaderState(channel);
|
||||
const call = callHeaderState.call;
|
||||
const showCompactVoiceView = callHeaderState.controlsVariant === 'inCall';
|
||||
@@ -411,7 +412,7 @@ export const DMChannelView = observer(({channelId}: DMChannelViewProps) => {
|
||||
textarea={
|
||||
isDM && isRecipientBlocked && recipient ? (
|
||||
<BlockedUserBarrier userId={recipient.id} username={recipient.username} />
|
||||
) : isCurrentUserUnclaimed ? (
|
||||
) : isCurrentUserUnclaimed && isDM && !isPersonalNotes && !isGroupDM ? (
|
||||
<UnclaimedDMBarrier />
|
||||
) : (
|
||||
<ChannelTextarea channel={channel} />
|
||||
|
||||
@@ -17,15 +17,19 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {WarningCircleIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as RelationshipActionCreators from '~/actions/RelationshipActionCreators';
|
||||
import {APIErrorCodes} from '~/Constants';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import {getApiErrorCode} from '~/utils/ApiErrorUtils';
|
||||
import styles from './AddFriendForm.module.css';
|
||||
|
||||
@@ -35,11 +39,30 @@ interface AddFriendFormProps {
|
||||
|
||||
export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}) => {
|
||||
const {t} = useLingui();
|
||||
|
||||
const [input, setInput] = React.useState('');
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [resultStatus, setResultStatus] = React.useState<'success' | 'error' | null>(null);
|
||||
const [errorCode, setErrorCode] = React.useState<string | null>(null);
|
||||
|
||||
const isClaimed = UserStore.currentUser?.isClaimed() ?? true;
|
||||
if (!isClaimed) {
|
||||
return (
|
||||
<StatusSlate
|
||||
Icon={WarningCircleIcon}
|
||||
title={<Trans>Claim your account</Trans>}
|
||||
description={<Trans>Claim your account to send friend requests.</Trans>}
|
||||
actions={[
|
||||
{
|
||||
text: <Trans>Claim Account</Trans>,
|
||||
onClick: () => openClaimAccountModal({force: true}),
|
||||
variant: 'primary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const parseInput = (input: string): [string, string] => {
|
||||
const parts = input.split('#');
|
||||
if (parts.length > 1) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {AvatarStack} from '~/components/uikit/avatars/AvatarStack';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {StatusAwareAvatar} from '~/components/uikit/StatusAwareAvatar';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import type {ProfileRecord} from '~/records/ProfileRecord';
|
||||
import GuildMemberStore from '~/stores/GuildMemberStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
@@ -92,6 +93,7 @@ export const DMWelcomeSection: React.FC<DMWelcomeSectionProps> = observer(functi
|
||||
};
|
||||
|
||||
const hasMutualGuilds = mutualGuilds.length > 0;
|
||||
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||
const shouldShowActionButton =
|
||||
!user.bot &&
|
||||
(relationshipType === undefined ||
|
||||
@@ -103,12 +105,22 @@ export const DMWelcomeSection: React.FC<DMWelcomeSectionProps> = observer(functi
|
||||
const renderActionButton = () => {
|
||||
if (user.bot) return null;
|
||||
switch (relationshipType) {
|
||||
case undefined:
|
||||
return (
|
||||
<Button small={true} onClick={handleSendFriendRequest}>
|
||||
case undefined: {
|
||||
const tooltipText = t`Claim your account to send friend requests.`;
|
||||
const button = (
|
||||
<Button small={true} onClick={handleSendFriendRequest} disabled={currentUserUnclaimed}>
|
||||
<Trans>Send Friend Request</Trans>
|
||||
</Button>
|
||||
);
|
||||
if (currentUserUnclaimed) {
|
||||
return (
|
||||
<Tooltip text={tooltipText} maxWidth="xl">
|
||||
<div>{button}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return button;
|
||||
}
|
||||
case RelationshipTypes.INCOMING_REQUEST:
|
||||
return (
|
||||
<div className={styles.actionButtonsContainer}>
|
||||
|
||||
@@ -457,8 +457,8 @@ export const EmbedGifv: FC<
|
||||
const {width, aspectRatio} = style;
|
||||
const containerStyle = {
|
||||
'--embed-width': `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
width,
|
||||
aspectRatio,
|
||||
} as React.CSSProperties;
|
||||
|
||||
@@ -488,7 +488,7 @@ export const EmbedGifv: FC<
|
||||
type="gifv"
|
||||
handlePress={openImagePreview}
|
||||
>
|
||||
<div className={styles.videoWrapper}>
|
||||
<div className={styles.videoWrapper} style={aspectRatio ? {aspectRatio} : undefined}>
|
||||
{(!loaded || error) && thumbHashURL && (
|
||||
<img src={thumbHashURL} className={styles.thumbHashPlaceholder} alt={t`Loading placeholder`} />
|
||||
)}
|
||||
@@ -551,6 +551,7 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
|
||||
const {shouldBlur, gateReason} = useNSFWMedia(nsfw, channelId);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
const isHoveredRef = useRef(false);
|
||||
|
||||
const defaultName = deriveDefaultNameFromMessage({
|
||||
message,
|
||||
@@ -622,16 +623,21 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
|
||||
useEffect(() => {
|
||||
if (gifAutoPlay) return;
|
||||
|
||||
const img = imgRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!img || !container) return;
|
||||
if (!container) return;
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (FocusManager.isFocused() && img) {
|
||||
img.src = optimizedAnimatedURL;
|
||||
isHoveredRef.current = true;
|
||||
if (FocusManager.isFocused()) {
|
||||
const img = imgRef.current;
|
||||
if (img) {
|
||||
img.src = optimizedAnimatedURL;
|
||||
}
|
||||
}
|
||||
};
|
||||
const handleMouseLeave = () => {
|
||||
isHoveredRef.current = false;
|
||||
const img = imgRef.current;
|
||||
if (img) {
|
||||
img.src = optimizedStaticURL;
|
||||
}
|
||||
@@ -646,6 +652,24 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
|
||||
};
|
||||
}, [gifAutoPlay, optimizedAnimatedURL, optimizedStaticURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gifAutoPlay) return;
|
||||
|
||||
const unsubscribe = FocusManager.subscribe((focused) => {
|
||||
const img = imgRef.current;
|
||||
if (!img) return;
|
||||
if (!focused) {
|
||||
img.src = optimizedStaticURL;
|
||||
return;
|
||||
}
|
||||
if (isHoveredRef.current && focused) {
|
||||
img.src = optimizedAnimatedURL;
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [gifAutoPlay, optimizedAnimatedURL, optimizedStaticURL]);
|
||||
|
||||
if (shouldBlur) {
|
||||
const {style} = mediaCalculator.calculate({width: naturalWidth, height: naturalHeight}, {forceScale: true});
|
||||
const {width: _width, height: _height, ...styleWithoutDimensions} = style;
|
||||
@@ -679,8 +703,8 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
|
||||
const {width, aspectRatio} = style;
|
||||
const containerStyle = {
|
||||
'--embed-width': `${width}px`,
|
||||
maxWidth: `${width}px`,
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
width,
|
||||
aspectRatio,
|
||||
} as React.CSSProperties;
|
||||
|
||||
@@ -716,7 +740,7 @@ export const EmbedGif: FC<GifvEmbedProps & {proxyURL: string; includeButton?: bo
|
||||
contentHash={contentHash}
|
||||
message={message}
|
||||
>
|
||||
<div className={styles.videoWrapper}>
|
||||
<div className={styles.videoWrapper} style={aspectRatio ? {aspectRatio} : undefined}>
|
||||
{(!loaded || error) && thumbHashURL && (
|
||||
<img src={thumbHashURL} className={styles.thumbHashPlaceholder} alt={t`Loading placeholder`} />
|
||||
)}
|
||||
|
||||
@@ -251,6 +251,11 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
|
||||
aspectRatio: true,
|
||||
},
|
||||
);
|
||||
const resolvedContainerStyle: React.CSSProperties = {
|
||||
...containerStyle,
|
||||
width: dimensions.width,
|
||||
maxWidth: '100%',
|
||||
};
|
||||
|
||||
const shouldRenderPlaceholder = error || !loaded;
|
||||
|
||||
@@ -295,7 +300,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
|
||||
<div className={styles.blurContainer}>
|
||||
<div className={clsx(styles.rowContainer, isInline && styles.justifyEnd)}>
|
||||
<div className={styles.innerContainer}>
|
||||
<div className={styles.imageWrapper} style={containerStyle}>
|
||||
<div className={styles.imageWrapper} style={resolvedContainerStyle}>
|
||||
<div className={styles.imageContainer}>
|
||||
{thumbHashURL && (
|
||||
<div className={styles.thumbHashContainer}>
|
||||
@@ -328,7 +333,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
|
||||
<div className={clsx(styles.rowContainer, isInline && styles.justifyEnd)}>
|
||||
<MediaContainer
|
||||
className={clsx(styles.mediaContainer, styles.cursorPointer)}
|
||||
style={containerStyle}
|
||||
style={resolvedContainerStyle}
|
||||
showFavoriteButton={showFavoriteButton}
|
||||
isFavorited={isFavorited}
|
||||
onFavoriteClick={handleFavoriteClick}
|
||||
|
||||
@@ -247,7 +247,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
}
|
||||
: {
|
||||
width: dimensions.width,
|
||||
maxWidth: dimensions.width,
|
||||
maxWidth: '100%',
|
||||
aspectRatio,
|
||||
};
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ export const PickerSearchInput = React.forwardRef<HTMLInputElement, PickerSearch
|
||||
{value, onChange, placeholder, inputRef, onKeyDown, maxLength = 100, showBackButton = false, onBackButtonClick},
|
||||
forwardedRef,
|
||||
) => {
|
||||
const _inputElementRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const {t} = useLingui();
|
||||
const inputElementRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const {canFocus, safeFocusTextarea} = useInputFocusManagement(inputElementRef);
|
||||
|
||||
@@ -18,14 +18,13 @@
|
||||
*/
|
||||
|
||||
import {FloatingPortal} from '@floating-ui/react';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {PencilIcon, SealCheckIcon, SmileyIcon} from '@phosphor-icons/react';
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {PencilIcon, SmileyIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import {GuildFeatures} from '~/Constants';
|
||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||
import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext';
|
||||
import {EmojiTooltipContent} from '~/components/uikit/EmojiTooltipContent/EmojiTooltipContent';
|
||||
import FocusRing from '~/components/uikit/FocusRing/FocusRing';
|
||||
import {useTooltipPortalRoot} from '~/components/uikit/Tooltip';
|
||||
@@ -35,7 +34,6 @@ import {useReactionTooltip} from '~/hooks/useReactionTooltip';
|
||||
import {type CustomStatus, getCustomStatusText, normalizeCustomStatus} from '~/lib/customStatus';
|
||||
import UnicodeEmojis from '~/lib/UnicodeEmojis';
|
||||
import EmojiStore from '~/stores/EmojiStore';
|
||||
import GuildListStore from '~/stores/GuildListStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import PresenceStore from '~/stores/PresenceStore';
|
||||
@@ -128,62 +126,6 @@ const getTooltipEmojiUrl = (status: CustomStatus): string | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const StatusEmojiTooltipSubtext = observer(({status}: {status: CustomStatus}) => {
|
||||
const {t} = useLingui();
|
||||
const isCustomEmoji = Boolean(status.emojiId);
|
||||
|
||||
if (!isCustomEmoji) {
|
||||
return (
|
||||
<span>
|
||||
<Trans>This is a default emoji on Fluxer.</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const emoji = status.emojiId ? EmojiStore.getEmojiById(status.emojiId) : null;
|
||||
const guildId = emoji?.guildId;
|
||||
const isMember = guildId ? GuildListStore.guilds.some((guild) => guild.id === guildId) : false;
|
||||
|
||||
if (!isMember) {
|
||||
return (
|
||||
<span>
|
||||
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const guild = guildId ? GuildStore.getGuild(guildId) : null;
|
||||
|
||||
if (!guild) {
|
||||
return (
|
||||
<span>
|
||||
<Trans>This is a custom emoji from a community.</Trans>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
|
||||
|
||||
return (
|
||||
<div className={styles.emojiTooltipSubtext}>
|
||||
<span>
|
||||
<Trans>This is a custom emoji from</Trans>
|
||||
</span>
|
||||
<div className={styles.emojiTooltipGuildRow}>
|
||||
<div className={styles.emojiTooltipGuildIcon}>
|
||||
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} sizePx={20} />
|
||||
</div>
|
||||
<span className={styles.emojiTooltipGuildName}>{guild.name}</span>
|
||||
{isVerified && (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={styles.emojiTooltipVerifiedIcon} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface StatusEmojiWithTooltipProps {
|
||||
status: CustomStatus;
|
||||
children: React.ReactNode;
|
||||
@@ -195,6 +137,13 @@ const StatusEmojiWithTooltip = observer(
|
||||
({status, children, onClick, isButton = false}: StatusEmojiWithTooltipProps) => {
|
||||
const tooltipPortalRoot = useTooltipPortalRoot();
|
||||
const {targetRef, tooltipRef, state, updatePosition, handlers, tooltipHandlers} = useReactionTooltip(500);
|
||||
const emoji = status.emojiId ? EmojiStore.getEmojiById(status.emojiId) : null;
|
||||
const attribution = getEmojiAttribution({
|
||||
emojiId: status.emojiId,
|
||||
guildId: emoji?.guildId ?? null,
|
||||
guild: emoji?.guildId ? GuildStore.getGuild(emoji.guildId) : null,
|
||||
emojiName: status.emojiName,
|
||||
});
|
||||
|
||||
const getEmojiDisplayName = (): string => {
|
||||
if (status.emojiId) {
|
||||
@@ -257,7 +206,18 @@ const StatusEmojiWithTooltip = observer(
|
||||
emoji={shouldUseNativeEmoji && status.emojiName && !status.emojiId ? status.emojiName : undefined}
|
||||
emojiAlt={status.emojiName ?? undefined}
|
||||
primaryContent={emojiName}
|
||||
subtext={<StatusEmojiTooltipSubtext status={status} />}
|
||||
subtext={
|
||||
<EmojiAttributionSubtext
|
||||
attribution={attribution}
|
||||
classes={{
|
||||
container: styles.emojiTooltipSubtext,
|
||||
guildRow: styles.emojiTooltipGuildRow,
|
||||
guildIcon: styles.emojiTooltipGuildIcon,
|
||||
guildName: styles.emojiTooltipGuildName,
|
||||
verifiedIcon: styles.emojiTooltipVerifiedIcon,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
152
fluxer_app/src/components/emojis/EmojiAttributionSubtext.tsx
Normal file
152
fluxer_app/src/components/emojis/EmojiAttributionSubtext.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {SealCheckIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import type {Guild, GuildRecord} from '~/records/GuildRecord';
|
||||
import GuildListStore from '~/stores/GuildListStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
|
||||
type EmojiAttributionType = 'default' | 'custom_invite_required' | 'custom_unknown' | 'custom_guild';
|
||||
type EmojiGuild = Guild | GuildRecord;
|
||||
|
||||
export interface EmojiAttribution {
|
||||
type: EmojiAttributionType;
|
||||
guild?: EmojiGuild | null;
|
||||
isVerified?: boolean;
|
||||
}
|
||||
|
||||
export interface EmojiAttributionSource {
|
||||
emojiId?: string | null;
|
||||
guildId?: string | null;
|
||||
guild?: EmojiGuild | null;
|
||||
emojiName?: string | null;
|
||||
}
|
||||
|
||||
const getIsVerified = (guild?: EmojiGuild | null): boolean => {
|
||||
if (!guild) return false;
|
||||
const features = (guild as GuildRecord).features ?? (guild as Guild).features;
|
||||
if (!features) return false;
|
||||
if (Array.isArray(features)) {
|
||||
return features.includes('VERIFIED');
|
||||
}
|
||||
if (features instanceof Set) {
|
||||
return features.has('VERIFIED');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getEmojiAttribution = ({emojiId, guildId, guild}: EmojiAttributionSource): EmojiAttribution => {
|
||||
if (!emojiId) {
|
||||
return {type: 'default'};
|
||||
}
|
||||
|
||||
const resolvedGuild = guildId ? (guild ?? GuildStore.getGuild(guildId)) : null;
|
||||
const isVerified = getIsVerified(resolvedGuild);
|
||||
|
||||
if (resolvedGuild) {
|
||||
return {type: 'custom_guild', guild: resolvedGuild, isVerified};
|
||||
}
|
||||
|
||||
const isMember = guildId ? GuildListStore.guilds.some((candidate) => candidate.id === guildId) : null;
|
||||
|
||||
if (isMember === false) {
|
||||
return {type: 'custom_invite_required'};
|
||||
}
|
||||
|
||||
return {type: 'custom_unknown'};
|
||||
};
|
||||
|
||||
interface EmojiAttributionSubtextProps {
|
||||
attribution: EmojiAttribution;
|
||||
classes?: {
|
||||
container?: string;
|
||||
text?: string;
|
||||
guildRow?: string;
|
||||
guildIcon?: string;
|
||||
guildName?: string;
|
||||
verifiedIcon?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const EmojiAttributionSubtext = observer(function EmojiAttributionSubtext({
|
||||
attribution,
|
||||
classes = {},
|
||||
}: EmojiAttributionSubtextProps) {
|
||||
const {t} = useLingui();
|
||||
|
||||
if (attribution.type === 'default') {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<span className={classes.text}>
|
||||
<Trans>This is a default emoji on Fluxer.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (attribution.type === 'custom_invite_required') {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<span className={classes.text}>
|
||||
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (attribution.type === 'custom_unknown' || !attribution.guild) {
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<span className={classes.text}>
|
||||
<Trans>This is a custom emoji from a community.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.container}>
|
||||
<span className={classes.text}>
|
||||
<Trans>This is a custom emoji from</Trans>
|
||||
</span>
|
||||
<div className={classes.guildRow}>
|
||||
<div className={classes.guildIcon}>
|
||||
<GuildIcon
|
||||
id={attribution.guild.id}
|
||||
name={attribution.guild.name}
|
||||
icon={attribution.guild.icon}
|
||||
sizePx={20}
|
||||
/>
|
||||
</div>
|
||||
<span className={classes.guildName}>{attribution.guild.name}</span>
|
||||
{attribution.isVerified && (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={classes.verifiedIcon} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
EmojiAttributionSubtext.displayName = 'EmojiAttributionSubtext';
|
||||
@@ -17,14 +17,9 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {SealCheckIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {GuildFeatures} from '~/Constants';
|
||||
import {GuildIcon} from '~/components/popouts/GuildIcon';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import {EmojiAttributionSubtext, getEmojiAttribution} from '~/components/emojis/EmojiAttributionSubtext';
|
||||
import type {Emoji} from '~/stores/EmojiStore';
|
||||
import GuildListStore from '~/stores/GuildListStore';
|
||||
import GuildStore from '~/stores/GuildStore';
|
||||
import styles from './EmojiInfoContent.module.css';
|
||||
|
||||
@@ -33,62 +28,25 @@ interface EmojiInfoContentProps {
|
||||
}
|
||||
|
||||
export const EmojiInfoContent = observer(function EmojiInfoContent({emoji}: EmojiInfoContentProps) {
|
||||
const {t} = useLingui();
|
||||
const isCustomEmoji = Boolean(emoji.guildId || emoji.id);
|
||||
|
||||
if (!isCustomEmoji) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<span className={styles.text}>
|
||||
<Trans>This is a default emoji on Fluxer.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const guildId = emoji.guildId;
|
||||
const isMember = guildId ? GuildListStore.guilds.some((guild) => guild.id === guildId) : false;
|
||||
|
||||
if (!isMember) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<span className={styles.text}>
|
||||
<Trans>This is a custom emoji from a community. Ask the author for an invite to use this emoji.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const guild = guildId ? GuildStore.getGuild(guildId) : null;
|
||||
|
||||
if (!guild) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<span className={styles.text}>
|
||||
<Trans>This is a custom emoji from a community.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isVerified = guild.features.has(GuildFeatures.VERIFIED);
|
||||
const guild = emoji.guildId ? GuildStore.getGuild(emoji.guildId) : null;
|
||||
const attribution = getEmojiAttribution({
|
||||
emojiId: emoji.id,
|
||||
guildId: emoji.guildId,
|
||||
guild,
|
||||
emojiName: emoji.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<span className={styles.text}>
|
||||
<Trans>This is a custom emoji from</Trans>
|
||||
</span>
|
||||
<div className={styles.guildRow}>
|
||||
<div className={styles.guildIcon}>
|
||||
<GuildIcon id={guild.id} name={guild.name} icon={guild.icon} sizePx={20} />
|
||||
</div>
|
||||
<span className={styles.guildName}>{guild.name}</span>
|
||||
{isVerified && (
|
||||
<Tooltip text={t`Verified Community`} position="top">
|
||||
<SealCheckIcon className={styles.verifiedIcon} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EmojiAttributionSubtext
|
||||
attribution={attribution}
|
||||
classes={{
|
||||
container: styles.container,
|
||||
text: styles.text,
|
||||
guildRow: styles.guildRow,
|
||||
guildIcon: styles.guildIcon,
|
||||
guildName: styles.guildName,
|
||||
verifiedIcon: styles.verifiedIcon,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -242,6 +242,11 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.channelItemDisabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.hoverAffordance {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ import ReadStateStore from '~/stores/ReadStateStore';
|
||||
import SelectedChannelStore from '~/stores/SelectedChannelStore';
|
||||
import TrustedDomainStore from '~/stores/TrustedDomainStore';
|
||||
import UserGuildSettingsStore from '~/stores/UserGuildSettingsStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import MediaEngineStore from '~/stores/voice/MediaEngineFacade';
|
||||
|
||||
import * as ChannelUtils from '~/utils/ChannelUtils';
|
||||
@@ -194,10 +195,18 @@ export const ChannelItem = observer(
|
||||
|
||||
const showKeyboardAffordances = keyboardModeEnabled && isFocused;
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
const currentUser = UserStore.getCurrentUser();
|
||||
const isUnclaimed = !(currentUser?.isClaimed() ?? false);
|
||||
const isGuildOwner = currentUser ? guild.isOwner(currentUser.id) : false;
|
||||
const currentMember = currentUserId ? GuildMemberStore.getMember(guild.id, currentUserId) : null;
|
||||
const isCurrentUserTimedOut = Boolean(currentMember?.isTimedOut());
|
||||
const voiceBlockedForUnclaimed = channelIsVoice && isUnclaimed && !isGuildOwner;
|
||||
const voiceTooltipText =
|
||||
channelIsVoice && isCurrentUserTimedOut ? t`You can't join while you're on timeout.` : undefined;
|
||||
channelIsVoice && isCurrentUserTimedOut
|
||||
? t`You can't join while you're on timeout.`
|
||||
: channelIsVoice && voiceBlockedForUnclaimed
|
||||
? t`Claim your account to join this voice channel.`
|
||||
: undefined;
|
||||
|
||||
const isVoiceSelected =
|
||||
channel.type === ChannelTypes.GUILD_VOICE &&
|
||||
@@ -367,6 +376,13 @@ export const ChannelItem = observer(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (channel.type === ChannelTypes.GUILD_VOICE && voiceBlockedForUnclaimed) {
|
||||
ToastActionCreators.createToast({
|
||||
type: 'error',
|
||||
children: t`Claim your account to join this voice channel.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
|
||||
onToggle?.();
|
||||
return;
|
||||
@@ -512,6 +528,7 @@ export const ChannelItem = observer(
|
||||
contextMenuOpen && styles.contextMenuOpen,
|
||||
showKeyboardAffordances && styles.keyboardFocus,
|
||||
channelIsVoice && styles.channelItemVoice,
|
||||
voiceBlockedForUnclaimed && styles.channelItemDisabled,
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
onContextMenu={handleContextMenu}
|
||||
|
||||
@@ -200,6 +200,10 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
if (nagbarState.forceHideInvitesDisabled) return false;
|
||||
if (nagbarState.forceInvitesDisabled) return true;
|
||||
|
||||
if (user && !user.isClaimed() && guild.ownerId === user.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasInvitesDisabled = guild.features.has(GuildFeatures.INVITES_DISABLED);
|
||||
if (!hasInvitesDisabled) return false;
|
||||
|
||||
@@ -218,6 +222,7 @@ export const GuildLayout = observer(({children}: {children: React.ReactNode}) =>
|
||||
invitesDisabledDismissed,
|
||||
nagbarState.forceInvitesDisabled,
|
||||
nagbarState.forceHideInvitesDisabled,
|
||||
user,
|
||||
]);
|
||||
|
||||
const shouldShowStaffOnlyGuild = React.useMemo(() => {
|
||||
|
||||
@@ -28,11 +28,9 @@ import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as DimensionActionCreators from '~/actions/DimensionActionCreators';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as UserSettingsActionCreators from '~/actions/UserSettingsActionCreators';
|
||||
import {ChannelTypes} from '~/Constants';
|
||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import {ComponentDispatch} from '~/lib/ComponentDispatch';
|
||||
import {Platform} from '~/lib/Platform';
|
||||
@@ -330,7 +328,7 @@ export const GuildsLayout = observer(({children}: {children: React.ReactNode}) =
|
||||
if (accountAgeMs < THIRTY_MINUTES_MS) return;
|
||||
|
||||
NagbarStore.markClaimAccountModalShown();
|
||||
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
|
||||
openClaimAccountModal();
|
||||
}, [isReady, user, location.pathname]);
|
||||
|
||||
const shouldShowSidebarDivider = !mobileLayout.enabled;
|
||||
|
||||
@@ -19,12 +19,10 @@
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {Nagbar} from '~/components/layout/Nagbar';
|
||||
import {NagbarButton} from '~/components/layout/NagbarButton';
|
||||
import {NagbarContent} from '~/components/layout/NagbarContent';
|
||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
|
||||
export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean}) => {
|
||||
@@ -34,7 +32,7 @@ export const UnclaimedAccountNagbar = observer(({isMobile}: {isMobile: boolean})
|
||||
}
|
||||
|
||||
const handleClaimAccount = () => {
|
||||
ModalActionCreators.push(modal(() => <ClaimAccountModal />));
|
||||
openClaimAccountModal({force: true});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -24,6 +24,7 @@ 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 {
|
||||
@@ -41,6 +42,7 @@ import type {ChannelSettingsTabType} from './utils/channelSettingsConstants';
|
||||
export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observer(({channelId, initialMobileTab}) => {
|
||||
const {t} = useLingui();
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
const guildId = channel?.guildId;
|
||||
const [selectedTab, setSelectedTab] = React.useState<ChannelSettingsTabType>('overview');
|
||||
|
||||
const availableTabs = React.useMemo(() => {
|
||||
@@ -59,6 +61,12 @@ export const ChannelSettingsModal: React.FC<ChannelSettingsModalProps> = observe
|
||||
const mobileNav = useMobileNavigation<ChannelSettingsTabType>(initialTab);
|
||||
const {enabled: isMobile} = MobileLayoutStore;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (guildId) {
|
||||
ConnectionStore.syncGuildIfNeeded(guildId, 'channel-settings-modal');
|
||||
}
|
||||
}, [guildId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!channel) {
|
||||
ModalActionCreators.pop();
|
||||
|
||||
@@ -22,6 +22,7 @@ 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';
|
||||
@@ -31,6 +32,7 @@ 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;
|
||||
@@ -230,3 +232,20 @@ export const ClaimAccountModal = observer(() => {
|
||||
</Modal.Root>
|
||||
);
|
||||
});
|
||||
|
||||
const CLAIM_ACCOUNT_MODAL_KEY = 'claim-account-modal';
|
||||
let hasShownClaimAccountModalThisSession = false;
|
||||
|
||||
export const openClaimAccountModal = ({force = false}: {force?: boolean} = {}): void => {
|
||||
if (ModalStore.hasModal(CLAIM_ACCOUNT_MODAL_KEY)) {
|
||||
return;
|
||||
}
|
||||
if (!force && hasShownClaimAccountModalThisSession) {
|
||||
return;
|
||||
}
|
||||
hasShownClaimAccountModalThisSession = true;
|
||||
ModalActionCreators.pushWithKey(
|
||||
modal(() => <ClaimAccountModal />),
|
||||
CLAIM_ACCOUNT_MODAL_KEY,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,12 +23,14 @@ 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';
|
||||
|
||||
@@ -41,6 +43,7 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
|
||||
const giftState = GiftStore.gifts.get(code) ?? null;
|
||||
const gift = giftState?.data ?? null;
|
||||
const [isRedeeming, setIsRedeeming] = React.useState(false);
|
||||
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!giftState) {
|
||||
@@ -64,6 +67,10 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
|
||||
};
|
||||
|
||||
const handleRedeem = async () => {
|
||||
if (isUnclaimed) {
|
||||
openClaimAccountModal({force: true});
|
||||
return;
|
||||
}
|
||||
setIsRedeeming(true);
|
||||
try {
|
||||
await GiftActionCreators.redeem(i18n, code);
|
||||
@@ -130,6 +137,42 @@ export const GiftAcceptModal = observer(function GiftAcceptModal({code}: GiftAcc
|
||||
|
||||
const renderGift = () => {
|
||||
const durationText = getGiftDurationText(i18n, gift!);
|
||||
if (isUnclaimed) {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.cardGrid}>
|
||||
<div className={`${styles.iconCircle} ${styles.iconCircleInactive}`}>
|
||||
<GiftIcon className={styles.icon} weight="fill" />
|
||||
</div>
|
||||
<div className={styles.cardContent}>
|
||||
<h3 className={`${styles.title} ${styles.titlePrimary}`}>{durationText}</h3>
|
||||
{creator && (
|
||||
<span className={styles.subtitle}>{t`From ${creator.username}#${creator.discriminator}`}</span>
|
||||
)}
|
||||
<span className={styles.helpText}>
|
||||
<Trans>Claim your account to redeem this gift.</Trans>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Button variant="secondary" onClick={handleDismiss}>
|
||||
<Trans>Maybe later</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
openClaimAccountModal({force: true});
|
||||
handleDismiss();
|
||||
}}
|
||||
>
|
||||
<Trans>Claim Account</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className={styles.card}>
|
||||
|
||||
@@ -25,6 +25,7 @@ import * as UnsavedChangesActionCreators from '~/actions/UnsavedChangesActionCre
|
||||
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';
|
||||
@@ -79,6 +80,10 @@ export const GuildSettingsModal: React.FC<GuildSettingsModalProps> = observer(
|
||||
|
||||
const unsavedChangesStore = UnsavedChangesStore;
|
||||
|
||||
React.useEffect(() => {
|
||||
ConnectionStore.syncGuildIfNeeded(guildId, 'guild-settings-modal');
|
||||
}, [guildId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!guild) {
|
||||
ModalActionCreators.pop();
|
||||
|
||||
@@ -230,6 +230,17 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
|
||||
<span className={styles.channelName}>{channel.name}</span>
|
||||
</Trans>
|
||||
</p>
|
||||
{invitesDisabled && (
|
||||
<div className={styles.warningContainer}>
|
||||
<WarningCircleIcon className={styles.warningIcon} weight="fill" />
|
||||
<p className={styles.warningText}>
|
||||
<Trans>
|
||||
Invites are currently disabled in this community by an admin. While this invite can be created, it
|
||||
cannot be accepted until invites are re-enabled.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className={selectorStyles.headerSearch}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
@@ -248,33 +259,19 @@ export const InviteModal = observer(({channelId}: {channelId: string}) => {
|
||||
<Spinner />
|
||||
</div>
|
||||
) : !showAdvanced ? (
|
||||
<>
|
||||
<RecipientList
|
||||
recipients={recipients}
|
||||
sendingTo={sendingTo}
|
||||
sentTo={sentInvites}
|
||||
onSend={handleSendInvite}
|
||||
defaultButtonLabel={t`Invite`}
|
||||
sentButtonLabel={t`Sent`}
|
||||
buttonClassName={styles.inviteButton}
|
||||
scrollerKey="invite-modal-friend-list-scroller"
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
showSearchInput={false}
|
||||
/>
|
||||
|
||||
{invitesDisabled && (
|
||||
<div className={styles.warningContainer}>
|
||||
<WarningCircleIcon className={styles.warningIcon} weight="fill" />
|
||||
<p className={styles.warningText}>
|
||||
<Trans>
|
||||
Invites are currently disabled in this community by an admin. While this invite can be created, it
|
||||
cannot be accepted until invites are re-enabled.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<RecipientList
|
||||
recipients={recipients}
|
||||
sendingTo={sendingTo}
|
||||
sentTo={sentInvites}
|
||||
onSend={handleSendInvite}
|
||||
defaultButtonLabel={t`Invite`}
|
||||
sentButtonLabel={t`Sent`}
|
||||
buttonClassName={styles.inviteButton}
|
||||
scrollerKey="invite-modal-friend-list-scroller"
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
showSearchInput={false}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.advancedView}>
|
||||
<Select
|
||||
|
||||
@@ -217,6 +217,7 @@ const UserProfileMobileSheetContent: React.FC<UserProfileMobileSheetContentProps
|
||||
const isCurrentUser = user.id === AuthenticationStore.currentUserId;
|
||||
const relationship = RelationshipStore.getRelationship(user.id);
|
||||
const relationshipType = relationship?.type;
|
||||
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||
|
||||
const guildMember = GuildMemberStore.getMember(profile?.guildId ?? guildId ?? '', user.id);
|
||||
const memberRoles = profile?.guildId && guildMember ? guildMember.getSortedRoles() : [];
|
||||
@@ -389,11 +390,14 @@ const UserProfileMobileSheetContent: React.FC<UserProfileMobileSheetContentProps
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" onClick={handleSendFriendRequest} className={styles.actionButton}>
|
||||
<UserPlusIcon className={styles.icon} />
|
||||
</button>
|
||||
);
|
||||
if (relationshipType === undefined && !currentUserUnclaimed) {
|
||||
return (
|
||||
<button type="button" onClick={handleSendFriendRequest} className={styles.actionButton}>
|
||||
<UserPlusIcon className={styles.icon} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1164,6 +1164,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
|
||||
};
|
||||
|
||||
const renderActionButtons = () => {
|
||||
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||
if (isCurrentUser && disableEditProfile) {
|
||||
return (
|
||||
<div className={userProfileModalStyles.actionButtons}>
|
||||
@@ -1284,8 +1285,11 @@ export const UserProfileModal: UserProfileModalComponent = observer(
|
||||
);
|
||||
}
|
||||
if (relationshipType === undefined && !isUserBot) {
|
||||
const tooltipText = currentUserUnclaimed
|
||||
? t`Claim your account to send friend requests.`
|
||||
: t`Send Friend Request`;
|
||||
return (
|
||||
<Tooltip text={t`Send Friend Request`} maxWidth="xl">
|
||||
<Tooltip text={tooltipText} maxWidth="xl">
|
||||
<div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -1293,6 +1297,7 @@ export const UserProfileModal: UserProfileModalComponent = observer(
|
||||
square={true}
|
||||
icon={<UserPlusIcon className={userProfileModalStyles.buttonIcon} />}
|
||||
onClick={handleSendFriendRequest}
|
||||
disabled={currentUserUnclaimed}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -32,11 +32,9 @@ import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {ClientInfo} from '~/components/modals/components/ClientInfo';
|
||||
import {LogoutModal} from '~/components/modals/components/LogoutModal';
|
||||
import styles from '~/components/modals/components/MobileSettingsView.module.css';
|
||||
import {ScrollSpyProvider, useScrollSpyContext} from '~/components/modals/hooks/ScrollSpyContext';
|
||||
import type {MobileNavigationState} from '~/components/modals/hooks/useMobileNavigation';
|
||||
import {useSettingsContentKey} from '~/components/modals/hooks/useSettingsContentKey';
|
||||
import {
|
||||
MobileSectionNav,
|
||||
MobileSettingsDangerItem,
|
||||
MobileHeader as SharedMobileHeader,
|
||||
} from '~/components/modals/shared/MobileSettingsComponents';
|
||||
@@ -44,16 +42,13 @@ import userSettingsStyles from '~/components/modals/UserSettingsModal.module.css
|
||||
import {getSettingsTabComponent} from '~/components/modals/utils/desktopSettingsTabs';
|
||||
import {
|
||||
getCategoryLabel,
|
||||
getSectionIdsForTab,
|
||||
getSectionsForTab,
|
||||
type SettingsTab,
|
||||
tabHasSections,
|
||||
type UserSettingsTabType,
|
||||
} from '~/components/modals/utils/settingsConstants';
|
||||
import {filterSettingsTabsForDeveloperMode} from '~/components/modals/utils/settingsTabFilters';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {MentionBadgeAnimated} from '~/components/uikit/MentionBadge';
|
||||
import {Scroller, type ScrollerHandle} from '~/components/uikit/Scroller';
|
||||
import {Scroller} from '~/components/uikit/Scroller';
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
import {usePressable} from '~/hooks/usePressable';
|
||||
import {usePushSubscriptions} from '~/hooks/usePushSubscriptions';
|
||||
@@ -397,26 +392,7 @@ const headerFadeVariants = {
|
||||
exit: {opacity: 0},
|
||||
};
|
||||
|
||||
interface MobileSectionNavWrapperProps {
|
||||
tabType: UserSettingsTabType;
|
||||
}
|
||||
|
||||
const MobileSectionNavWrapper: React.FC<MobileSectionNavWrapperProps> = observer(({tabType}) => {
|
||||
const {t} = useLingui();
|
||||
const scrollSpyContext = useScrollSpyContext();
|
||||
const sections = getSectionsForTab(tabType, t);
|
||||
|
||||
if (!scrollSpyContext || sections.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {activeSectionId, scrollToSection} = scrollSpyContext;
|
||||
|
||||
return <MobileSectionNav sections={sections} activeSectionId={activeSectionId} onSectionClick={scrollToSection} />;
|
||||
});
|
||||
|
||||
interface MobileContentWithScrollSpyProps {
|
||||
tabType: UserSettingsTabType;
|
||||
scrollKey: string;
|
||||
initialGuildId?: string;
|
||||
initialSubtab?: string;
|
||||
@@ -424,21 +400,9 @@ interface MobileContentWithScrollSpyProps {
|
||||
}
|
||||
|
||||
const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = observer(
|
||||
({tabType, scrollKey, initialGuildId, initialSubtab, currentTabComponent}) => {
|
||||
const scrollerRef = React.useRef<ScrollerHandle | null>(null);
|
||||
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
|
||||
const sectionIds = React.useMemo(() => getSectionIdsForTab(tabType), [tabType]);
|
||||
const hasSections = tabHasSections(tabType);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scrollerRef.current) {
|
||||
scrollContainerRef.current = scrollerRef.current.getScrollerNode();
|
||||
}
|
||||
});
|
||||
|
||||
const content = (
|
||||
<Scroller ref={scrollerRef} className={styles.scrollerFlex} key={scrollKey} data-settings-scroll-container>
|
||||
{hasSections && <MobileSectionNavWrapper tabType={tabType} />}
|
||||
({scrollKey, initialGuildId, initialSubtab, currentTabComponent}) => {
|
||||
return (
|
||||
<Scroller className={styles.scrollerFlex} key={scrollKey} data-settings-scroll-container>
|
||||
<div className={styles.contentContainer}>
|
||||
{currentTabComponent &&
|
||||
React.createElement(currentTabComponent, {
|
||||
@@ -448,16 +412,6 @@ const MobileContentWithScrollSpy: React.FC<MobileContentWithScrollSpyProps> = ob
|
||||
</div>
|
||||
</Scroller>
|
||||
);
|
||||
|
||||
if (hasSections) {
|
||||
return (
|
||||
<ScrollSpyProvider sectionIds={sectionIds} containerRef={scrollContainerRef}>
|
||||
{content}
|
||||
</ScrollSpyProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -582,7 +536,6 @@ export const MobileSettingsView: React.FC<MobileSettingsViewProps> = observer(
|
||||
style={{willChange: 'transform'}}
|
||||
>
|
||||
<MobileContentWithScrollSpy
|
||||
tabType={currentTab.type}
|
||||
scrollKey={scrollKey}
|
||||
initialGuildId={initialGuildId}
|
||||
initialSubtab={initialSubtab}
|
||||
|
||||
@@ -94,6 +94,17 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
||||
mobileLayoutState.enabled,
|
||||
);
|
||||
|
||||
const isClaimed = currentUser?.isClaimed() ?? false;
|
||||
const purchaseDisabled = !isClaimed;
|
||||
const purchaseDisabledTooltip = <Trans>Claim your account to purchase Fluxer Plutonium.</Trans>;
|
||||
const handleSelectPlanGuarded = React.useCallback(
|
||||
(plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => {
|
||||
if (purchaseDisabled) return;
|
||||
handleSelectPlan(plan);
|
||||
},
|
||||
[handleSelectPlan, purchaseDisabled],
|
||||
);
|
||||
|
||||
const monthlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Monthly, countryCode), [countryCode]);
|
||||
const yearlyPrice = React.useMemo(() => getFormattedPrice(PricingTier.Yearly, countryCode), [countryCode]);
|
||||
const visionaryPrice = React.useMemo(() => getFormattedPrice(PricingTier.Visionary, countryCode), [countryCode]);
|
||||
@@ -221,12 +232,14 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
||||
scrollToPerks={scrollToPerks}
|
||||
handlePerksKeyDown={handlePerksKeyDown}
|
||||
navigateToRedeemGift={navigateToRedeemGift}
|
||||
handleSelectPlan={handleSelectPlan}
|
||||
handleSelectPlan={handleSelectPlanGuarded}
|
||||
handleOpenCustomerPortal={handleOpenCustomerPortal}
|
||||
handleReactivateSubscription={handleReactivateSubscription}
|
||||
handleCancelSubscription={handleCancelSubscription}
|
||||
handleCommunityButtonPointerDown={handleCommunityButtonPointerDown}
|
||||
handleCommunityButtonClick={handleCommunityButtonClick}
|
||||
purchaseDisabled={purchaseDisabled}
|
||||
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||
/>
|
||||
<div className={styles.disclaimerContainer}>
|
||||
<PurchaseDisclaimer align="center" isPremium />
|
||||
@@ -245,7 +258,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
||||
loadingCheckout={loadingCheckout}
|
||||
loadingSlots={loadingSlots}
|
||||
isVisionarySoldOut={isVisionarySoldOut}
|
||||
handleSelectPlan={handleSelectPlan}
|
||||
handleSelectPlan={handleSelectPlanGuarded}
|
||||
purchaseDisabled={purchaseDisabled}
|
||||
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||
/>
|
||||
) : (
|
||||
<GiftSection
|
||||
@@ -257,7 +272,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
||||
loadingCheckout={loadingCheckout}
|
||||
loadingSlots={loadingSlots}
|
||||
isVisionarySoldOut={isVisionarySoldOut}
|
||||
handleSelectPlan={handleSelectPlan}
|
||||
handleSelectPlan={handleSelectPlanGuarded}
|
||||
purchaseDisabled={purchaseDisabled}
|
||||
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -280,7 +297,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
||||
isGiftSubscription={subscriptionStatus.isGiftSubscription}
|
||||
loadingCheckout={loadingCheckout}
|
||||
loadingSlots={loadingSlots}
|
||||
handleSelectPlan={handleSelectPlan}
|
||||
handleSelectPlan={handleSelectPlanGuarded}
|
||||
purchaseDisabled={purchaseDisabled}
|
||||
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -302,7 +321,9 @@ export const PlutoniumContent: React.FC<{defaultGiftMode?: boolean}> = observer(
|
||||
loadingCheckout={loadingCheckout}
|
||||
loadingSlots={loadingSlots}
|
||||
isVisionarySoldOut={isVisionarySoldOut}
|
||||
handleSelectPlan={handleSelectPlan}
|
||||
handleSelectPlan={handleSelectPlanGuarded}
|
||||
purchaseDisabled={purchaseDisabled}
|
||||
purchaseDisabledTooltip={purchaseDisabledTooltip}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,9 +19,7 @@
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {WarningAlert} from '~/components/uikit/WarningAlert/WarningAlert';
|
||||
|
||||
@@ -30,7 +28,7 @@ export const UnclaimedAccountAlert = observer(() => {
|
||||
<WarningAlert
|
||||
title={<Trans>Unclaimed Account</Trans>}
|
||||
actions={
|
||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
||||
<Button small={true} onClick={() => openClaimAccountModal({force: true})}>
|
||||
<Trans>Claim Account</Trans>
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
|
||||
import styles from './BottomCTASection.module.css';
|
||||
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
|
||||
|
||||
interface BottomCTASectionProps {
|
||||
isGiftMode: boolean;
|
||||
@@ -33,6 +34,8 @@ interface BottomCTASectionProps {
|
||||
loadingSlots: boolean;
|
||||
isVisionarySoldOut: boolean;
|
||||
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
|
||||
purchaseDisabled?: boolean;
|
||||
purchaseDisabledTooltip?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
||||
@@ -45,7 +48,12 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
||||
loadingSlots,
|
||||
isVisionarySoldOut,
|
||||
handleSelectPlan,
|
||||
purchaseDisabled = false,
|
||||
purchaseDisabledTooltip,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h2 className={styles.title}>
|
||||
@@ -54,63 +62,79 @@ export const BottomCTASection: React.FC<BottomCTASectionProps> = observer(
|
||||
<div className={styles.buttonContainer}>
|
||||
{!isGiftMode ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleSelectPlan('monthly')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.button}
|
||||
>
|
||||
<Trans>Monthly {monthlyPrice}</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('yearly')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.button}
|
||||
>
|
||||
<Trans>Yearly {yearlyPrice}</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('visionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
disabled={isVisionarySoldOut}
|
||||
className={styles.button}
|
||||
>
|
||||
{isVisionarySoldOut ? <Trans>Visionary Sold Out</Trans> : <Trans>Visionary {visionaryPrice}</Trans>}
|
||||
</Button>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleSelectPlan('monthly')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.button}
|
||||
disabled={purchaseDisabled}
|
||||
>
|
||||
<Trans>Monthly {monthlyPrice}</Trans>
|
||||
</Button>
|
||||
</PurchaseDisabledWrapper>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('yearly')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.button}
|
||||
disabled={purchaseDisabled}
|
||||
>
|
||||
<Trans>Yearly {yearlyPrice}</Trans>
|
||||
</Button>
|
||||
</PurchaseDisabledWrapper>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('visionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled || isVisionarySoldOut}
|
||||
className={styles.button}
|
||||
>
|
||||
{isVisionarySoldOut ? <Trans>Visionary Sold Out</Trans> : <Trans>Visionary {visionaryPrice}</Trans>}
|
||||
</Button>
|
||||
</PurchaseDisabledWrapper>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleSelectPlan('gift1Year')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.button}
|
||||
>
|
||||
<Trans>1 Year {yearlyPrice}</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('gift1Month')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.button}
|
||||
>
|
||||
<Trans>1 Month {monthlyPrice}</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('giftVisionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
disabled={isVisionarySoldOut}
|
||||
className={styles.button}
|
||||
>
|
||||
{isVisionarySoldOut ? (
|
||||
<Trans>Visionary Gift Sold Out</Trans>
|
||||
) : (
|
||||
<Trans>Visionary {visionaryPrice}</Trans>
|
||||
)}
|
||||
</Button>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleSelectPlan('gift1Year')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.button}
|
||||
disabled={purchaseDisabled}
|
||||
>
|
||||
<Trans>1 Year {yearlyPrice}</Trans>
|
||||
</Button>
|
||||
</PurchaseDisabledWrapper>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('gift1Month')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.button}
|
||||
disabled={purchaseDisabled}
|
||||
>
|
||||
<Trans>1 Month {monthlyPrice}</Trans>
|
||||
</Button>
|
||||
</PurchaseDisabledWrapper>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('giftVisionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled || isVisionarySoldOut}
|
||||
className={styles.button}
|
||||
>
|
||||
{isVisionarySoldOut ? (
|
||||
<Trans>Visionary Gift Sold Out</Trans>
|
||||
) : (
|
||||
<Trans>Visionary {visionaryPrice}</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</PurchaseDisabledWrapper>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {PricingCard} from '../PricingCard';
|
||||
import gridStyles from '../PricingGrid.module.css';
|
||||
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
|
||||
import styles from './GiftSection.module.css';
|
||||
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
|
||||
import {SectionHeader} from './SectionHeader';
|
||||
|
||||
interface GiftSectionProps {
|
||||
@@ -38,6 +39,8 @@ interface GiftSectionProps {
|
||||
loadingSlots: boolean;
|
||||
isVisionarySoldOut: boolean;
|
||||
handleSelectPlan: (plan: 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
|
||||
purchaseDisabled?: boolean;
|
||||
purchaseDisabledTooltip?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const GiftSection: React.FC<GiftSectionProps> = observer(
|
||||
@@ -51,8 +54,11 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
|
||||
loadingSlots,
|
||||
isVisionarySoldOut,
|
||||
handleSelectPlan,
|
||||
purchaseDisabled = false,
|
||||
purchaseDisabledTooltip,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
|
||||
|
||||
return (
|
||||
<div ref={giftSectionRef}>
|
||||
@@ -65,35 +71,43 @@ export const GiftSection: React.FC<GiftSectionProps> = observer(
|
||||
/>
|
||||
<div className={gridStyles.gridWrapper}>
|
||||
<div className={gridStyles.gridThreeColumns}>
|
||||
<PricingCard
|
||||
title={t`1 Year Gift`}
|
||||
price={yearlyPrice}
|
||||
period={t`one-time purchase`}
|
||||
badge={t`Save 17%`}
|
||||
onSelect={() => handleSelectPlan('gift1Year')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
/>
|
||||
<PricingCard
|
||||
title={t`1 Month Gift`}
|
||||
price={monthlyPrice}
|
||||
period={t`one-time purchase`}
|
||||
isPopular
|
||||
onSelect={() => handleSelectPlan('gift1Month')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
/>
|
||||
<PricingCard
|
||||
title={t`Visionary Gift`}
|
||||
price={visionaryPrice}
|
||||
period={t`one-time, lifetime`}
|
||||
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
||||
onSelect={() => handleSelectPlan('giftVisionary')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={isVisionarySoldOut}
|
||||
soldOut={isVisionarySoldOut}
|
||||
/>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||
<PricingCard
|
||||
title={t`1 Year Gift`}
|
||||
price={yearlyPrice}
|
||||
period={t`one-time purchase`}
|
||||
badge={t`Save 17%`}
|
||||
onSelect={() => handleSelectPlan('gift1Year')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled}
|
||||
/>
|
||||
</PurchaseDisabledWrapper>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||
<PricingCard
|
||||
title={t`1 Month Gift`}
|
||||
price={monthlyPrice}
|
||||
period={t`one-time purchase`}
|
||||
isPopular
|
||||
onSelect={() => handleSelectPlan('gift1Month')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled}
|
||||
/>
|
||||
</PurchaseDisabledWrapper>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
|
||||
<PricingCard
|
||||
title={t`Visionary Gift`}
|
||||
price={visionaryPrice}
|
||||
period={t`one-time, lifetime`}
|
||||
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
||||
onSelect={() => handleSelectPlan('giftVisionary')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled || isVisionarySoldOut}
|
||||
soldOut={isVisionarySoldOut}
|
||||
/>
|
||||
</PurchaseDisabledWrapper>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.footerContainer}>
|
||||
|
||||
@@ -27,6 +27,7 @@ import gridStyles from '../PricingGrid.module.css';
|
||||
import {PurchaseDisclaimer} from '../PurchaseDisclaimer';
|
||||
import {ToggleButton} from '../ToggleButton';
|
||||
import styles from './PricingSection.module.css';
|
||||
import {PurchaseDisabledWrapper} from './PurchaseDisabledWrapper';
|
||||
|
||||
interface PricingSectionProps {
|
||||
isGiftMode: boolean;
|
||||
@@ -39,6 +40,8 @@ interface PricingSectionProps {
|
||||
loadingSlots: boolean;
|
||||
isVisionarySoldOut: boolean;
|
||||
handleSelectPlan: (plan: 'monthly' | 'yearly' | 'visionary' | 'gift1Month' | 'gift1Year' | 'giftVisionary') => void;
|
||||
purchaseDisabled?: boolean;
|
||||
purchaseDisabledTooltip?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
@@ -53,8 +56,11 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
loadingSlots,
|
||||
isVisionarySoldOut,
|
||||
handleSelectPlan,
|
||||
purchaseDisabled = false,
|
||||
purchaseDisabledTooltip,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const tooltipText: React.ReactNode = purchaseDisabledTooltip ?? t`Claim your account to purchase Fluxer Plutonium.`;
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
@@ -67,65 +73,81 @@ export const PricingSection: React.FC<PricingSectionProps> = observer(
|
||||
<div className={gridStyles.gridThreeColumns}>
|
||||
{!isGiftMode ? (
|
||||
<>
|
||||
<PricingCard
|
||||
title={t`Monthly`}
|
||||
price={monthlyPrice}
|
||||
period={t`per month`}
|
||||
onSelect={() => handleSelectPlan('monthly')}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
/>
|
||||
<PricingCard
|
||||
title={t`Yearly`}
|
||||
price={yearlyPrice}
|
||||
period={t`per year`}
|
||||
badge={t`Save 17%`}
|
||||
isPopular
|
||||
onSelect={() => handleSelectPlan('yearly')}
|
||||
buttonText={t`Upgrade Now`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
/>
|
||||
<PricingCard
|
||||
title={t`Visionary`}
|
||||
price={visionaryPrice}
|
||||
period={t`one-time, lifetime`}
|
||||
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
||||
onSelect={() => handleSelectPlan('visionary')}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={isVisionarySoldOut}
|
||||
soldOut={isVisionarySoldOut}
|
||||
/>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||
<PricingCard
|
||||
title={t`Monthly`}
|
||||
price={monthlyPrice}
|
||||
period={t`per month`}
|
||||
onSelect={() => handleSelectPlan('monthly')}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled}
|
||||
/>
|
||||
</PurchaseDisabledWrapper>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||
<PricingCard
|
||||
title={t`Yearly`}
|
||||
price={yearlyPrice}
|
||||
period={t`per year`}
|
||||
badge={t`Save 17%`}
|
||||
isPopular
|
||||
onSelect={() => handleSelectPlan('yearly')}
|
||||
buttonText={t`Upgrade Now`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled}
|
||||
/>
|
||||
</PurchaseDisabledWrapper>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
|
||||
<PricingCard
|
||||
title={t`Visionary`}
|
||||
price={visionaryPrice}
|
||||
period={t`one-time, lifetime`}
|
||||
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
||||
onSelect={() => handleSelectPlan('visionary')}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled || isVisionarySoldOut}
|
||||
soldOut={isVisionarySoldOut}
|
||||
/>
|
||||
</PurchaseDisabledWrapper>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PricingCard
|
||||
title={t`1 Year Gift`}
|
||||
price={yearlyPrice}
|
||||
period={t`one-time purchase`}
|
||||
badge={t`Save 17%`}
|
||||
onSelect={() => handleSelectPlan('gift1Year')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
/>
|
||||
<PricingCard
|
||||
title={t`1 Month Gift`}
|
||||
price={monthlyPrice}
|
||||
period={t`one-time purchase`}
|
||||
isPopular
|
||||
onSelect={() => handleSelectPlan('gift1Month')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
/>
|
||||
<PricingCard
|
||||
title={t`Visionary Gift`}
|
||||
price={visionaryPrice}
|
||||
period={t`one-time, lifetime`}
|
||||
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
||||
onSelect={() => handleSelectPlan('giftVisionary')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={isVisionarySoldOut}
|
||||
soldOut={isVisionarySoldOut}
|
||||
/>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||
<PricingCard
|
||||
title={t`1 Year Gift`}
|
||||
price={yearlyPrice}
|
||||
period={t`one-time purchase`}
|
||||
badge={t`Save 17%`}
|
||||
onSelect={() => handleSelectPlan('gift1Year')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled}
|
||||
/>
|
||||
</PurchaseDisabledWrapper>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled} tooltipText={tooltipText}>
|
||||
<PricingCard
|
||||
title={t`1 Month Gift`}
|
||||
price={monthlyPrice}
|
||||
period={t`one-time purchase`}
|
||||
isPopular
|
||||
onSelect={() => handleSelectPlan('gift1Month')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled}
|
||||
/>
|
||||
</PurchaseDisabledWrapper>
|
||||
<PurchaseDisabledWrapper disabled={purchaseDisabled || isVisionarySoldOut} tooltipText={tooltipText}>
|
||||
<PricingCard
|
||||
title={t`Visionary Gift`}
|
||||
price={visionaryPrice}
|
||||
period={t`one-time, lifetime`}
|
||||
remainingSlots={loadingSlots ? undefined : visionarySlots?.remaining}
|
||||
onSelect={() => handleSelectPlan('giftVisionary')}
|
||||
buttonText={t`Buy Gift`}
|
||||
isLoading={loadingCheckout || loadingSlots}
|
||||
disabled={purchaseDisabled || isVisionarySoldOut}
|
||||
soldOut={isVisionarySoldOut}
|
||||
/>
|
||||
</PurchaseDisabledWrapper>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
|
||||
interface PurchaseDisabledWrapperProps {
|
||||
disabled: boolean;
|
||||
tooltipText: React.ReactNode;
|
||||
children: React.ReactElement;
|
||||
}
|
||||
|
||||
export const PurchaseDisabledWrapper: React.FC<PurchaseDisabledWrapperProps> = ({disabled, tooltipText, children}) => {
|
||||
if (!disabled) return children;
|
||||
|
||||
const tooltipContent = typeof tooltipText === 'function' ? (tooltipText as () => React.ReactNode) : () => tooltipText;
|
||||
|
||||
return (
|
||||
<Tooltip text={tooltipContent}>
|
||||
<div>{children}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -17,12 +17,13 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {DotsThreeIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import {PerksButton} from '../PerksButton';
|
||||
import type {GracePeriodInfo} from './hooks/useSubscriptionStatus';
|
||||
@@ -61,6 +62,8 @@ interface SubscriptionCardProps {
|
||||
handleCancelSubscription: () => void;
|
||||
handleCommunityButtonPointerDown: (event: React.PointerEvent) => void;
|
||||
handleCommunityButtonClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
purchaseDisabled?: boolean;
|
||||
purchaseDisabledTooltip?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
@@ -97,9 +100,25 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
handleCancelSubscription,
|
||||
handleCommunityButtonPointerDown,
|
||||
handleCommunityButtonClick,
|
||||
purchaseDisabled = false,
|
||||
purchaseDisabledTooltip,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const {isInGracePeriod, isExpired: isFullyExpired, graceEndDate} = gracePeriodInfo;
|
||||
const isPremium = currentUser.isPremium();
|
||||
const tooltipText: string | (() => React.ReactNode) =
|
||||
purchaseDisabledTooltip != null
|
||||
? () => purchaseDisabledTooltip
|
||||
: t`Claim your account to purchase or redeem Fluxer Plutonium.`;
|
||||
|
||||
const wrapIfDisabled = (element: React.ReactElement, key: string, disabled: boolean) =>
|
||||
disabled ? (
|
||||
<Tooltip key={key} text={tooltipText}>
|
||||
<div>{element}</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
element
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.card, subscriptionCardColorClass)}>
|
||||
@@ -251,44 +270,63 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
<div className={styles.actions}>
|
||||
{isGiftSubscription ? (
|
||||
<>
|
||||
<Button variant="inverted" onClick={navigateToRedeemGift} small className={styles.actionButton}>
|
||||
<Trans>Redeem Gift Code</Trans>
|
||||
</Button>
|
||||
{!isVisionary && !isVisionarySoldOut && (
|
||||
{wrapIfDisabled(
|
||||
<Button
|
||||
variant="inverted"
|
||||
onClick={() => handleSelectPlan('visionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
onClick={navigateToRedeemGift}
|
||||
small
|
||||
className={styles.actionButton}
|
||||
disabled={purchaseDisabled}
|
||||
>
|
||||
<Trans>Upgrade to Visionary</Trans>
|
||||
</Button>
|
||||
<Trans>Redeem Gift Code</Trans>
|
||||
</Button>,
|
||||
'redeem-gift',
|
||||
purchaseDisabled,
|
||||
)}
|
||||
{!isVisionary &&
|
||||
!isVisionarySoldOut &&
|
||||
wrapIfDisabled(
|
||||
<Button
|
||||
variant="inverted"
|
||||
onClick={() => handleSelectPlan('visionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
small
|
||||
className={styles.actionButton}
|
||||
disabled={purchaseDisabled}
|
||||
>
|
||||
<Trans>Upgrade to Visionary</Trans>
|
||||
</Button>,
|
||||
'upgrade-gift-visionary',
|
||||
purchaseDisabled,
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{hasEverPurchased && (
|
||||
<Button
|
||||
variant="inverted"
|
||||
onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal}
|
||||
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
|
||||
small
|
||||
className={styles.actionButton}
|
||||
>
|
||||
{isFullyExpired ? (
|
||||
<Trans>Resubscribe</Trans>
|
||||
) : isInGracePeriod ? (
|
||||
<Trans>Resubscribe</Trans>
|
||||
) : premiumWillCancel ? (
|
||||
<Trans>Reactivate</Trans>
|
||||
) : isVisionary ? (
|
||||
<Trans>Open Customer Portal</Trans>
|
||||
) : (
|
||||
<Trans>Manage Subscription</Trans>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{hasEverPurchased &&
|
||||
wrapIfDisabled(
|
||||
<Button
|
||||
variant="inverted"
|
||||
onClick={shouldUseReactivateQuickAction ? handleReactivateSubscription : handleOpenCustomerPortal}
|
||||
submitting={shouldUseReactivateQuickAction ? loadingReactivate : loadingPortal}
|
||||
small
|
||||
className={styles.actionButton}
|
||||
disabled={purchaseDisabled && shouldUseReactivateQuickAction}
|
||||
>
|
||||
{isFullyExpired ? (
|
||||
<Trans>Resubscribe</Trans>
|
||||
) : isInGracePeriod ? (
|
||||
<Trans>Resubscribe</Trans>
|
||||
) : premiumWillCancel ? (
|
||||
<Trans>Reactivate</Trans>
|
||||
) : isVisionary ? (
|
||||
<Trans>Open Customer Portal</Trans>
|
||||
) : (
|
||||
<Trans>Manage Subscription</Trans>
|
||||
)}
|
||||
</Button>,
|
||||
'manage-reactivate',
|
||||
purchaseDisabled && shouldUseReactivateQuickAction,
|
||||
)}
|
||||
|
||||
{isVisionary && (
|
||||
<Button
|
||||
@@ -305,17 +343,22 @@ export const SubscriptionCard: React.FC<SubscriptionCardProps> = observer(
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isVisionary && !isVisionarySoldOut && (
|
||||
<Button
|
||||
variant="inverted"
|
||||
onClick={() => handleSelectPlan('visionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
small
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<Trans>Upgrade to Visionary</Trans>
|
||||
</Button>
|
||||
)}
|
||||
{!isVisionary &&
|
||||
!isVisionarySoldOut &&
|
||||
wrapIfDisabled(
|
||||
<Button
|
||||
variant="inverted"
|
||||
onClick={() => handleSelectPlan('visionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
small
|
||||
className={styles.actionButton}
|
||||
disabled={purchaseDisabled}
|
||||
>
|
||||
<Trans>Upgrade to Visionary</Trans>
|
||||
</Button>,
|
||||
'upgrade-visionary',
|
||||
purchaseDisabled,
|
||||
)}
|
||||
|
||||
{shouldUseCancelQuickAction && (
|
||||
<Button
|
||||
|
||||
@@ -23,6 +23,7 @@ import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import type {VisionarySlots} from '~/actions/PremiumActionCreators';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import {VisionaryBenefit} from '../VisionaryBenefit';
|
||||
import {SectionHeader} from './SectionHeader';
|
||||
import styles from './VisionarySection.module.css';
|
||||
@@ -36,6 +37,8 @@ interface VisionarySectionProps {
|
||||
loadingCheckout: boolean;
|
||||
loadingSlots: boolean;
|
||||
handleSelectPlan: (plan: 'visionary') => void;
|
||||
purchaseDisabled?: boolean;
|
||||
purchaseDisabledTooltip?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const VisionarySection: React.FC<VisionarySectionProps> = observer(
|
||||
@@ -48,9 +51,15 @@ export const VisionarySection: React.FC<VisionarySectionProps> = observer(
|
||||
loadingCheckout,
|
||||
loadingSlots,
|
||||
handleSelectPlan,
|
||||
purchaseDisabled = false,
|
||||
purchaseDisabledTooltip,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const currentAccessLabel = isGiftSubscription ? t`gift time` : t`subscription`;
|
||||
const tooltipText: string | (() => React.ReactNode) =
|
||||
purchaseDisabledTooltip != null
|
||||
? () => purchaseDisabledTooltip
|
||||
: t`Claim your account to purchase Fluxer Plutonium.`;
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
@@ -99,15 +108,32 @@ export const VisionarySection: React.FC<VisionarySectionProps> = observer(
|
||||
|
||||
{!isVisionary && visionarySlots && visionarySlots.remaining > 0 && (
|
||||
<div className={styles.ctaContainer}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('visionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.ctaButton}
|
||||
>
|
||||
<CrownIcon className={styles.ctaIcon} weight="fill" />
|
||||
<Trans>Upgrade to Visionary — {formatter.format(visionarySlots.remaining)} Left</Trans>
|
||||
</Button>
|
||||
{purchaseDisabled ? (
|
||||
<Tooltip text={tooltipText}>
|
||||
<div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('visionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.ctaButton}
|
||||
disabled
|
||||
>
|
||||
<CrownIcon className={styles.ctaIcon} weight="fill" />
|
||||
<Trans>Upgrade to Visionary — {formatter.format(visionarySlots.remaining)} Left</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSelectPlan('visionary')}
|
||||
submitting={loadingCheckout || loadingSlots}
|
||||
className={styles.ctaButton}
|
||||
>
|
||||
<CrownIcon className={styles.ctaIcon} weight="fill" />
|
||||
<Trans>Upgrade to Visionary — {formatter.format(visionarySlots.remaining)} Left</Trans>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isPremium && (
|
||||
<p className={styles.disclaimer}>
|
||||
|
||||
@@ -54,6 +54,7 @@ import GuildStore from '~/stores/GuildStore';
|
||||
import PermissionStore from '~/stores/PermissionStore';
|
||||
import RelationshipStore from '~/stores/RelationshipStore';
|
||||
import UserProfileMobileStore from '~/stores/UserProfileMobileStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import * as CallUtils from '~/utils/CallUtils';
|
||||
import * as NicknameUtils from '~/utils/NicknameUtils';
|
||||
import * as PermissionUtils from '~/utils/PermissionUtils';
|
||||
@@ -74,6 +75,7 @@ export const GuildMemberActionsSheet: FC<GuildMemberActionsSheetProps> = observe
|
||||
const currentUserId = AuthenticationStore.currentUserId;
|
||||
const isCurrentUser = user.id === currentUserId;
|
||||
const isBot = user.bot;
|
||||
const currentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||
|
||||
const relationship = RelationshipStore.getRelationship(user.id);
|
||||
const relationshipType = relationship?.type;
|
||||
@@ -262,7 +264,8 @@ export const GuildMemberActionsSheet: FC<GuildMemberActionsSheetProps> = observe
|
||||
});
|
||||
} else if (
|
||||
relationshipType !== RelationshipTypes.OUTGOING_REQUEST &&
|
||||
relationshipType !== RelationshipTypes.BLOCKED
|
||||
relationshipType !== RelationshipTypes.BLOCKED &&
|
||||
!currentUserUnclaimed
|
||||
) {
|
||||
relationshipItems.push({
|
||||
icon: <UserPlusIcon className={styles.icon} />,
|
||||
|
||||
@@ -36,13 +36,16 @@ interface StatusSlateProps {
|
||||
description: React.ReactNode;
|
||||
actions?: Array<StatusAction>;
|
||||
fullHeight?: boolean;
|
||||
iconClassName?: string;
|
||||
iconStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const StatusSlate: React.FC<StatusSlateProps> = observer(
|
||||
({Icon, title, description, actions = [], fullHeight = false}) => {
|
||||
({Icon, title, description, actions = [], fullHeight = false, iconClassName, iconStyle}) => {
|
||||
const iconClass = [styles.icon, iconClassName].filter(Boolean).join(' ');
|
||||
return (
|
||||
<div className={`${styles.container} ${fullHeight ? styles.fullHeight : ''}`}>
|
||||
<Icon className={styles.icon} aria-hidden />
|
||||
<Icon className={iconClass} style={iconStyle} aria-hidden />
|
||||
<h3 className={styles.title}>{title}</h3>
|
||||
<p className={styles.description}>{description}</p>
|
||||
{actions.length > 0 && (
|
||||
|
||||
@@ -100,3 +100,7 @@
|
||||
border-top: 1px solid var(--background-header-secondary);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.claimButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {observer} from 'mobx-react-lite';
|
||||
import type React from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {EmailChangeModal} from '~/components/modals/EmailChangeModal';
|
||||
import {PasswordChangeModal} from '~/components/modals/PasswordChangeModal';
|
||||
import {SettingsTabSection} from '~/components/modals/shared/SettingsTabLayout';
|
||||
@@ -94,7 +94,7 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
|
||||
<Trans>No email address set</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
||||
<Button small={true} className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
|
||||
<Trans>Add Email</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@ export const AccountTabContent: React.FC<AccountTabProps> = observer(
|
||||
<Trans>No password set</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<Button small={true} onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
||||
<Button small={true} className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
|
||||
<Trans>Set Password</Trans>
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -96,3 +96,7 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.claimButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import * as UserActionCreators from '~/actions/UserActionCreators';
|
||||
import {BackupCodesViewModal} from '~/components/modals/BackupCodesViewModal';
|
||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {ConfirmModal} from '~/components/modals/ConfirmModal';
|
||||
import {MfaTotpDisableModal} from '~/components/modals/MfaTotpDisableModal';
|
||||
import {MfaTotpEnableModal} from '~/components/modals/MfaTotpEnableModal';
|
||||
@@ -202,7 +202,7 @@ export const SecurityTabContent: React.FC<SecurityTabProps> = observer(
|
||||
<Trans>Claim your account to access security features like two-factor authentication and passkeys.</Trans>
|
||||
}
|
||||
>
|
||||
<Button onClick={() => ModalActionCreators.push(modal(() => <ClaimAccountModal />))}>
|
||||
<Button className={styles.claimButton} fitContent onClick={() => openClaimAccountModal()}>
|
||||
<Trans>Claim Account</Trans>
|
||||
</Button>
|
||||
</SettingsTabSection>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {BookOpenIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
@@ -38,12 +38,16 @@ import styles from '~/components/modals/tabs/ApplicationsTab/ApplicationsTab.mod
|
||||
import ApplicationsTabStore from '~/components/modals/tabs/ApplicationsTab/ApplicationsTabStore';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import type {DeveloperApplication} from '~/records/DeveloperApplicationRecord';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
|
||||
const ApplicationsTab: React.FC = observer(() => {
|
||||
const {t} = useLingui();
|
||||
const {checkUnsavedChanges} = useUnsavedChangesFlash('applications');
|
||||
const {setContentKey} = useSettingsContentKey();
|
||||
const store = ApplicationsTabStore;
|
||||
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setContentKey(store.contentKey);
|
||||
@@ -138,9 +142,19 @@ const ApplicationsTab: React.FC = observer(() => {
|
||||
description={<Trans>Create and manage applications and bots for your account.</Trans>}
|
||||
>
|
||||
<div className={styles.buttonContainer}>
|
||||
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal}>
|
||||
<Trans>Create Application</Trans>
|
||||
</Button>
|
||||
{isUnclaimed ? (
|
||||
<Tooltip text={t`Claim your account to create applications.`}>
|
||||
<div>
|
||||
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal} disabled>
|
||||
<Trans>Create Application</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button variant="primary" fitContainer={false} fitContent onClick={openCreateModal}>
|
||||
<Trans>Create Application</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<a className={styles.documentationLink} href="https://fluxer.dev" target="_blank" rel="noreferrer">
|
||||
<BookOpenIcon weight="fill" size={18} className={styles.documentationIcon} />
|
||||
<Trans>Read the Documentation (fluxer.dev)</Trans>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
import * as BetaCodeActionCreators from '~/actions/BetaCodeActionCreators';
|
||||
import * as TextCopyActionCreators from '~/actions/TextCopyActionCreators';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {
|
||||
SettingsTabContainer,
|
||||
SettingsTabContent,
|
||||
@@ -39,6 +40,7 @@ import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import type {BetaCodeRecord} from '~/records/BetaCodeRecord';
|
||||
import BetaCodeStore from '~/stores/BetaCodeStore';
|
||||
import MobileLayoutStore from '~/stores/MobileLayoutStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import * as DateUtils from '~/utils/DateUtils';
|
||||
import styles from './BetaCodesTab.module.css';
|
||||
|
||||
@@ -247,10 +249,12 @@ const BetaCodesTab: React.FC = observer(() => {
|
||||
const fetchStatus = BetaCodeStore.fetchStatus;
|
||||
const allowance = BetaCodeStore.allowance;
|
||||
const nextResetAt = BetaCodeStore.nextResetAt;
|
||||
const isClaimed = UserStore.currentUser?.isClaimed() ?? false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isClaimed) return;
|
||||
BetaCodeActionCreators.fetch();
|
||||
}, []);
|
||||
}, [isClaimed]);
|
||||
|
||||
const sortedBetaCodes = React.useMemo(() => {
|
||||
return [...betaCodes].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
@@ -280,6 +284,27 @@ const BetaCodesTab: React.FC = observer(() => {
|
||||
return i18n._(msg`${allowance} codes remaining this week`);
|
||||
}, [allowance, nextResetAt, i18n]);
|
||||
|
||||
if (!isClaimed) {
|
||||
return (
|
||||
<SettingsTabContainer>
|
||||
<SettingsTabContent>
|
||||
<StatusSlate
|
||||
Icon={TicketIcon}
|
||||
title={<Trans>Claim your account</Trans>}
|
||||
description={<Trans>Claim your account to generate beta codes.</Trans>}
|
||||
actions={[
|
||||
{
|
||||
text: <Trans>Claim Account</Trans>,
|
||||
onClick: () => openClaimAccountModal({force: true}),
|
||||
variant: 'primary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</SettingsTabContent>
|
||||
</SettingsTabContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchStatus === 'pending' || fetchStatus === 'idle') {
|
||||
return (
|
||||
<div className={styles.spinnerContainer}>
|
||||
|
||||
@@ -24,7 +24,7 @@ import {useCallback, useState} from 'react';
|
||||
import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {testBulkDeleteAllMessages} from '~/actions/UserActionCreators';
|
||||
import {CaptchaModal} from '~/components/modals/CaptchaModal';
|
||||
import {ClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {KeyboardModeIntroModal} from '~/components/modals/KeyboardModeIntroModal';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import type {GatewaySocket} from '~/lib/GatewaySocket';
|
||||
@@ -67,7 +67,7 @@ export const ToolsTabContent: React.FC<ToolsTabContentProps> = observer(({socket
|
||||
}, []);
|
||||
|
||||
const handleOpenClaimAccountModal = useCallback(() => {
|
||||
ModalActionCreators.push(ModalActionCreators.modal(() => <ClaimAccountModal />));
|
||||
openClaimAccountModal({force: true});
|
||||
}, []);
|
||||
|
||||
if (shouldCrash) {
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--background-header-secondary);
|
||||
@@ -117,6 +118,8 @@
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -129,15 +132,39 @@
|
||||
}
|
||||
|
||||
.authSessionLocation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary-muted);
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.locationText {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.locationSeparator {
|
||||
background-color: var(--background-modifier-accent);
|
||||
width: 0.25rem;
|
||||
height: 0.25rem;
|
||||
border-radius: 9999px;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.8;
|
||||
margin: 0 0.15rem;
|
||||
}
|
||||
|
||||
.lastUsed {
|
||||
font-size: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.authSessionActions {
|
||||
@@ -228,17 +255,11 @@
|
||||
}
|
||||
|
||||
.devicesGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.devicesGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.logoutSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -115,10 +115,10 @@ const AuthSession: React.FC<AuthSessionProps> = observer(
|
||||
</span>
|
||||
|
||||
<div className={styles.authSessionLocation}>
|
||||
{authSession.clientLocation}
|
||||
<span className={styles.locationText}>{authSession.clientLocation}</span>
|
||||
{!isCurrent && (
|
||||
<>
|
||||
<StatusDot />
|
||||
<span aria-hidden className={styles.locationSeparator} />
|
||||
<span className={styles.lastUsed}>
|
||||
{DateUtils.getShortRelativeDateString(authSession.approxLastUsedAt ?? new Date(0))}
|
||||
</span>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {CaretDownIcon, CheckIcon, CopyIcon, GiftIcon, NetworkSlashIcon} from '@phosphor-icons/react';
|
||||
import {CaretDownIcon, CheckIcon, CopyIcon, GiftIcon, NetworkSlashIcon, WarningCircleIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import React from 'react';
|
||||
@@ -31,6 +31,7 @@ import * as UserActionCreators from '~/actions/UserActionCreators';
|
||||
import {UserPremiumTypes} from '~/Constants';
|
||||
import {Form} from '~/components/form/Form';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import {openClaimAccountModal} from '~/components/modals/ClaimAccountModal';
|
||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import {Spinner} from '~/components/uikit/Spinner';
|
||||
@@ -180,6 +181,7 @@ const GiftInventoryTab: React.FC = observer(() => {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState(false);
|
||||
const [expandedGiftId, setExpandedGiftId] = React.useState<string | null>(null);
|
||||
const isUnclaimed = !(UserStore.currentUser?.isClaimed() ?? false);
|
||||
|
||||
const giftCodeForm = useForm<GiftCodeFormInputs>({defaultValues: {code: ''}});
|
||||
|
||||
@@ -201,6 +203,10 @@ const GiftInventoryTab: React.FC = observer(() => {
|
||||
});
|
||||
|
||||
const fetchGifts = React.useCallback(async () => {
|
||||
if (isUnclaimed) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setError(false);
|
||||
const userGifts = await GiftActionCreators.fetchUserGifts();
|
||||
@@ -211,7 +217,7 @@ const GiftInventoryTab: React.FC = observer(() => {
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [isUnclaimed]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchGifts();
|
||||
@@ -225,6 +231,23 @@ const GiftInventoryTab: React.FC = observer(() => {
|
||||
fetchGifts();
|
||||
};
|
||||
|
||||
if (isUnclaimed) {
|
||||
return (
|
||||
<StatusSlate
|
||||
Icon={WarningCircleIcon}
|
||||
title={<Trans>Claim your account</Trans>}
|
||||
description={<Trans>Claim your account to redeem or manage Plutonium gift codes.</Trans>}
|
||||
actions={[
|
||||
{
|
||||
text: <Trans>Claim Account</Trans>,
|
||||
onClick: () => openClaimAccountModal({force: true}),
|
||||
variant: 'primary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div>
|
||||
|
||||
@@ -17,96 +17,221 @@
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.container {
|
||||
.page {
|
||||
--report-max-width: 640px;
|
||||
max-width: var(--report-max-width);
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 0 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
text-align: left;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stepIndicator {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
letter-spacing: 0.06em;
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.breadcrumbShell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
padding-top: 0.3rem;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
min-height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.breadcrumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.breadcrumbStep {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.1s ease,
|
||||
background 0.1s ease;
|
||||
}
|
||||
|
||||
.breadcrumbStep:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.breadcrumbStep:hover:not(:disabled) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.breadcrumbActive {
|
||||
color: var(--text-primary);
|
||||
cursor: default;
|
||||
background: color-mix(in srgb, var(--background-modifier-accent) 60%, transparent);
|
||||
}
|
||||
|
||||
.breadcrumbActive:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.breadcrumbNumber {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--background-modifier-accent);
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.breadcrumbActive .breadcrumbNumber {
|
||||
background: var(--brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.breadcrumbLabel {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.breadcrumbSeparator {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.cardHeader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
letter-spacing: 0.06em;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.025em;
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
line-height: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-bottom: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.metaLine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.metaValue {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metaSpacer {
|
||||
color: var(--text-muted);
|
||||
margin: 0.2rem 0 0;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 1.25rem;
|
||||
.footerLinks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.actionRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footerRow {
|
||||
@media (min-width: 640px) {
|
||||
.actionRow {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.linkRow {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.linkButton {
|
||||
all: unset;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.linkButton:hover {
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.linkSeparator {
|
||||
color: var(--background-modifier-accent);
|
||||
}
|
||||
|
||||
.link {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition-property: color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
padding: 0;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
@@ -121,44 +246,24 @@
|
||||
}
|
||||
|
||||
.errorBox {
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: color-mix(in srgb, var(--status-danger) 15%, transparent);
|
||||
color: var(--status-danger);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.successBox {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
background-color: color-mix(in srgb, var(--status-success) 12%, transparent);
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.successLabel {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.successValue {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
word-break: break-word;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3rem;
|
||||
}
|
||||
|
||||
.helperText {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mainColumn {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
78
fluxer_app/src/components/pages/report/ReportBreadcrumbs.tsx
Normal file
78
fluxer_app/src/components/pages/report/ReportBreadcrumbs.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 {Trans} from '@lingui/react/macro';
|
||||
import clsx from 'clsx';
|
||||
import React from 'react';
|
||||
import styles from '../ReportPage.module.css';
|
||||
import type {FlowStep} from './types';
|
||||
|
||||
type Props = {
|
||||
current: FlowStep;
|
||||
hasSelection: boolean;
|
||||
hasEmail: boolean;
|
||||
hasTicket: boolean;
|
||||
onSelect: (step: FlowStep) => void;
|
||||
};
|
||||
|
||||
const STEP_ORDER: Array<FlowStep> = ['selection', 'email', 'verification', 'details'];
|
||||
|
||||
export const ReportBreadcrumbs: React.FC<Props> = ({current, hasSelection, hasEmail, hasTicket, onSelect}) => {
|
||||
const isEnabled = (step: FlowStep) => {
|
||||
if (step === 'selection') return true;
|
||||
if (step === 'email') return hasSelection;
|
||||
if (step === 'verification') return hasEmail;
|
||||
if (step === 'details') return hasTicket;
|
||||
return false;
|
||||
};
|
||||
|
||||
const labelMap: Record<FlowStep, React.ReactNode> = {
|
||||
selection: <Trans>Choose</Trans>,
|
||||
email: <Trans>Email</Trans>,
|
||||
verification: <Trans>Code</Trans>,
|
||||
details: <Trans>Details</Trans>,
|
||||
complete: <Trans>Done</Trans>,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.breadcrumbs}>
|
||||
{STEP_ORDER.map((step, index) => {
|
||||
const active = current === step;
|
||||
const clickable = !active && isEnabled(step);
|
||||
|
||||
return (
|
||||
<React.Fragment key={step}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(styles.breadcrumbStep, active && styles.breadcrumbActive)}
|
||||
disabled={!clickable}
|
||||
onClick={() => clickable && onSelect(step)}
|
||||
>
|
||||
<span className={styles.breadcrumbNumber}>{index + 1}</span>
|
||||
<span className={styles.breadcrumbLabel}>{labelMap[step]}</span>
|
||||
</button>
|
||||
{index < STEP_ORDER.length - 1 && <span className={styles.breadcrumbSeparator}>›</span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportBreadcrumbs;
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import {CheckCircleIcon} from '@phosphor-icons/react';
|
||||
import type React from 'react';
|
||||
import {StatusSlate} from '~/components/modals/shared/StatusSlate';
|
||||
|
||||
const FilledCheckCircleIcon: React.FC<React.ComponentProps<typeof CheckCircleIcon>> = (props) => (
|
||||
<CheckCircleIcon weight="fill" {...props} />
|
||||
);
|
||||
|
||||
type Props = {
|
||||
onStartOver: () => void;
|
||||
};
|
||||
|
||||
export const ReportStepComplete: React.FC<Props> = ({onStartOver}) => (
|
||||
<StatusSlate
|
||||
Icon={FilledCheckCircleIcon}
|
||||
title={<Trans>Report submitted</Trans>}
|
||||
description={<Trans>Thank you for helping keep Fluxer safe. We'll review your report as soon as possible.</Trans>}
|
||||
iconStyle={{color: 'var(--status-success)'}}
|
||||
actions={[
|
||||
{
|
||||
text: <Trans>Submit another report</Trans>,
|
||||
onClick: onStartOver,
|
||||
variant: 'secondary',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
export default ReportStepComplete;
|
||||
260
fluxer_app/src/components/pages/report/ReportStepDetails.tsx
Normal file
260
fluxer_app/src/components/pages/report/ReportStepDetails.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {Input, Textarea} from '~/components/form/Input';
|
||||
import {Select, type SelectOption} from '~/components/form/Select';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import styles from '../ReportPage.module.css';
|
||||
import type {FormValues, ReportType} from './types';
|
||||
|
||||
type Props = {
|
||||
selectedType: ReportType;
|
||||
formValues: FormValues;
|
||||
categoryOptions: Array<SelectOption<string>>;
|
||||
countryOptions: Array<SelectOption<string>>;
|
||||
fieldErrors: Partial<Record<keyof FormValues, string>>;
|
||||
errorMessage: string | null;
|
||||
canSubmit: boolean;
|
||||
isSubmitting: boolean;
|
||||
onFieldChange: (field: keyof FormValues, value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStartOver: () => void;
|
||||
onBack: () => void;
|
||||
messageLinkOk: boolean;
|
||||
userTargetOk: boolean;
|
||||
guildTargetOk: boolean;
|
||||
};
|
||||
|
||||
export const ReportStepDetails: React.FC<Props> = ({
|
||||
selectedType,
|
||||
formValues,
|
||||
categoryOptions,
|
||||
countryOptions,
|
||||
fieldErrors,
|
||||
errorMessage,
|
||||
canSubmit,
|
||||
isSubmitting,
|
||||
onFieldChange,
|
||||
onSubmit,
|
||||
onStartOver,
|
||||
onBack,
|
||||
messageLinkOk,
|
||||
userTargetOk,
|
||||
guildTargetOk,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const hasFieldErrors = Object.values(fieldErrors).some((value) => Boolean(value));
|
||||
const showGeneralError = Boolean(errorMessage && !hasFieldErrors);
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<header className={styles.cardHeader}>
|
||||
<p className={styles.eyebrow}>
|
||||
<Trans>Step 4</Trans>
|
||||
</p>
|
||||
<h1 className={styles.title}>
|
||||
<Trans>Report details</Trans>
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
<Trans>Share only what's needed to help our team assess the content.</Trans>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className={styles.cardBody}>
|
||||
{showGeneralError && (
|
||||
<div className={styles.errorBox} role="alert" aria-live="polite">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<Select<string>
|
||||
label={t`Violation Category`}
|
||||
value={formValues.category}
|
||||
options={categoryOptions}
|
||||
error={fieldErrors.category}
|
||||
onChange={(value) => onFieldChange('category', value)}
|
||||
isSearchable={false}
|
||||
/>
|
||||
|
||||
{selectedType === 'message' && (
|
||||
<>
|
||||
<Input
|
||||
label={t`Message Link`}
|
||||
type="url"
|
||||
value={formValues.messageLink}
|
||||
onChange={(e) => onFieldChange('messageLink', e.target.value)}
|
||||
placeholder="https://fluxer.app/channels/..."
|
||||
autoComplete="off"
|
||||
error={fieldErrors.messageLink}
|
||||
footer={
|
||||
!formValues.messageLink.trim() ? undefined : !messageLinkOk ? (
|
||||
<span className={styles.helperText}>
|
||||
<Trans>That doesn't look like a valid URL.</Trans>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={t`Reported User Tag (optional)`}
|
||||
type="text"
|
||||
value={formValues.messageUserTag}
|
||||
onChange={(e) => onFieldChange('messageUserTag', e.target.value)}
|
||||
placeholder="username#1234"
|
||||
autoComplete="off"
|
||||
error={fieldErrors.messageUserTag}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedType === 'user' && (
|
||||
<>
|
||||
<Input
|
||||
label={t`User ID (optional)`}
|
||||
type="text"
|
||||
value={formValues.userId}
|
||||
onChange={(e) => onFieldChange('userId', e.target.value)}
|
||||
placeholder="123456789012345678"
|
||||
autoComplete="off"
|
||||
error={fieldErrors.userId}
|
||||
/>
|
||||
<Input
|
||||
label={t`User Tag (optional)`}
|
||||
type="text"
|
||||
value={formValues.userTag}
|
||||
onChange={(e) => onFieldChange('userTag', e.target.value)}
|
||||
placeholder="username#1234"
|
||||
autoComplete="off"
|
||||
error={fieldErrors.userTag}
|
||||
footer={
|
||||
userTargetOk ? undefined : (
|
||||
<span className={styles.helperText}>
|
||||
<Trans>Provide at least a user ID or a user tag.</Trans>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedType === 'guild' && (
|
||||
<>
|
||||
<Input
|
||||
label={t`Guild (Community) ID`}
|
||||
type="text"
|
||||
value={formValues.guildId}
|
||||
onChange={(e) => onFieldChange('guildId', e.target.value)}
|
||||
placeholder="123456789012345678"
|
||||
autoComplete="off"
|
||||
error={fieldErrors.guildId}
|
||||
footer={
|
||||
guildTargetOk ? undefined : (
|
||||
<span className={styles.helperText}>
|
||||
<Trans>Guild ID is required.</Trans>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={t`Invite Code (optional)`}
|
||||
type="text"
|
||||
value={formValues.inviteCode}
|
||||
onChange={(e) => onFieldChange('inviteCode', e.target.value)}
|
||||
placeholder="abcDEF12"
|
||||
autoComplete="off"
|
||||
error={fieldErrors.inviteCode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label={t`Full Legal Name`}
|
||||
type="text"
|
||||
value={formValues.reporterFullName}
|
||||
onChange={(e) => onFieldChange('reporterFullName', e.target.value)}
|
||||
placeholder={t`First and last name`}
|
||||
autoComplete="name"
|
||||
error={fieldErrors.reporterFullName}
|
||||
/>
|
||||
|
||||
<Select<string>
|
||||
label={t`Country of Residence`}
|
||||
value={formValues.reporterCountry}
|
||||
options={countryOptions}
|
||||
error={fieldErrors.reporterCountry}
|
||||
onChange={(value) => onFieldChange('reporterCountry', value)}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label={t`Your FluxerTag (optional)`}
|
||||
type="text"
|
||||
value={formValues.reporterFluxerTag}
|
||||
onChange={(e) => onFieldChange('reporterFluxerTag', e.target.value)}
|
||||
placeholder="username#1234"
|
||||
error={fieldErrors.reporterFluxerTag}
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label={t`Additional Comments (optional)`}
|
||||
value={formValues.additionalInfo}
|
||||
onChange={(e) => onFieldChange('additionalInfo', e.target.value)}
|
||||
placeholder={t`Describe what makes the content illegal`}
|
||||
maxLength={1000}
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
error={fieldErrors.additionalInfo}
|
||||
/>
|
||||
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
fitContent
|
||||
type="submit"
|
||||
disabled={!canSubmit || isSubmitting}
|
||||
submitting={isSubmitting}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<Trans>Submit DSA Report</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" fitContent type="button" onClick={onBack} disabled={isSubmitting}>
|
||||
<Trans>Back</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer className={styles.footerLinks}>
|
||||
<p className={styles.linkRow}>
|
||||
<button type="button" className={styles.linkButton} onClick={onStartOver} disabled={isSubmitting}>
|
||||
<Trans>Start over</Trans>
|
||||
</button>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportStepDetails;
|
||||
112
fluxer_app/src/components/pages/report/ReportStepEmail.tsx
Normal file
112
fluxer_app/src/components/pages/report/ReportStepEmail.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import styles from '../ReportPage.module.css';
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
errorMessage: string | null;
|
||||
isSending: boolean;
|
||||
onEmailChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStartOver: () => void;
|
||||
};
|
||||
|
||||
export const ReportStepEmail: React.FC<Props> = ({
|
||||
email,
|
||||
errorMessage,
|
||||
isSending,
|
||||
onEmailChange,
|
||||
onSubmit,
|
||||
onStartOver,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const normalizedEmail = email.trim();
|
||||
const emailLooksValid = normalizedEmail.length > 0 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail);
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<header className={styles.cardHeader}>
|
||||
<p className={styles.eyebrow}>
|
||||
<Trans>Step 2</Trans>
|
||||
</p>
|
||||
<h1 className={styles.title}>
|
||||
<Trans>Verify your email</Trans>
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
<Trans>We'll send a short code to confirm you can receive updates about this report.</Trans>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className={styles.cardBody}>
|
||||
{errorMessage && (
|
||||
<div className={styles.errorBox} role="alert" aria-live="polite">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label={t`Email Address`}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => onEmailChange(e.target.value)}
|
||||
placeholder="you@example.com"
|
||||
autoComplete="email"
|
||||
/>
|
||||
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
fitContent
|
||||
type="submit"
|
||||
disabled={!emailLooksValid || isSending}
|
||||
submitting={isSending}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<Trans>Send Verification Code</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
fitContent
|
||||
type="button"
|
||||
onClick={onStartOver}
|
||||
disabled={isSending}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<Trans>Start over</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportStepEmail;
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Trans} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import type {RadioOption} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import {RadioGroup} from '~/components/uikit/RadioGroup/RadioGroup';
|
||||
import styles from '../ReportPage.module.css';
|
||||
import type {ReportType} from './types';
|
||||
|
||||
type Props = {
|
||||
reportTypeOptions: ReadonlyArray<RadioOption<ReportType>>;
|
||||
selectedType: ReportType | null;
|
||||
onSelect: (type: ReportType) => void;
|
||||
};
|
||||
|
||||
export const ReportStepSelection: React.FC<Props> = ({reportTypeOptions, selectedType, onSelect}) => {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<header className={styles.cardHeader}>
|
||||
<p className={styles.eyebrow}>
|
||||
<Trans>Step 1</Trans>
|
||||
</p>
|
||||
<h1 className={styles.title}>
|
||||
<Trans>Report Illegal Content</Trans>
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
<Trans>Select what you want to report.</Trans>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className={styles.cardBody}>
|
||||
<RadioGroup<ReportType>
|
||||
options={reportTypeOptions}
|
||||
value={selectedType}
|
||||
onChange={onSelect}
|
||||
aria-label="Report Type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportStepSelection;
|
||||
@@ -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 {Trans, useLingui} from '@lingui/react/macro';
|
||||
import type React from 'react';
|
||||
import {Input} from '~/components/form/Input';
|
||||
import {Button} from '~/components/uikit/Button/Button';
|
||||
import styles from '../ReportPage.module.css';
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
verificationCode: string;
|
||||
errorMessage: string | null;
|
||||
isVerifying: boolean;
|
||||
isResending: boolean;
|
||||
resendCooldownSeconds: number;
|
||||
onChangeEmail: () => void;
|
||||
onResend: () => void;
|
||||
onVerify: () => void;
|
||||
onCodeChange: (value: string) => void;
|
||||
onStartOver: () => void;
|
||||
};
|
||||
|
||||
export const ReportStepVerification: React.FC<Props> = ({
|
||||
email,
|
||||
verificationCode,
|
||||
errorMessage,
|
||||
isVerifying,
|
||||
isResending,
|
||||
resendCooldownSeconds,
|
||||
onChangeEmail,
|
||||
onResend,
|
||||
onVerify,
|
||||
onCodeChange,
|
||||
onStartOver,
|
||||
}) => {
|
||||
const {t} = useLingui();
|
||||
const codeForValidation = verificationCode.trim().toUpperCase();
|
||||
const codeLooksValid = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(codeForValidation);
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<header className={styles.cardHeader}>
|
||||
<p className={styles.eyebrow}>
|
||||
<Trans>Step 3</Trans>
|
||||
</p>
|
||||
<h1 className={styles.title}>
|
||||
<Trans>Enter verification code</Trans>
|
||||
</h1>
|
||||
<p className={styles.description}>
|
||||
<Trans>We sent a code to {email}.</Trans>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className={styles.cardBody}>
|
||||
{errorMessage && (
|
||||
<div className={styles.errorBox} role="alert" aria-live="polite">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onVerify();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
label={t`Verification Code`}
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => onCodeChange(e.target.value)}
|
||||
placeholder="ABCD-1234"
|
||||
autoComplete="one-time-code"
|
||||
/>
|
||||
|
||||
<div className={styles.actionRow}>
|
||||
<Button
|
||||
fitContent
|
||||
type="submit"
|
||||
disabled={!codeLooksValid || isVerifying}
|
||||
submitting={isVerifying}
|
||||
className={styles.actionButton}
|
||||
>
|
||||
<Trans>Verify Code</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
fitContent
|
||||
type="button"
|
||||
onClick={onResend}
|
||||
disabled={isResending || isVerifying || resendCooldownSeconds > 0}
|
||||
submitting={isResending}
|
||||
>
|
||||
{resendCooldownSeconds > 0 ? (
|
||||
<Trans>Resend ({resendCooldownSeconds}s)</Trans>
|
||||
) : (
|
||||
<Trans>Resend code</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<footer className={styles.footerLinks}>
|
||||
<p className={styles.linkRow}>
|
||||
<button type="button" className={styles.linkButton} onClick={onChangeEmail}>
|
||||
<Trans>Change email</Trans>
|
||||
</button>
|
||||
<span aria-hidden="true" className={styles.linkSeparator}>
|
||||
·
|
||||
</span>
|
||||
<button type="button" className={styles.linkButton} onClick={onStartOver}>
|
||||
<Trans>Start over</Trans>
|
||||
</button>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportStepVerification;
|
||||
110
fluxer_app/src/components/pages/report/optionDescriptors.ts
Normal file
110
fluxer_app/src/components/pages/report/optionDescriptors.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import type {ReportType} from './types';
|
||||
|
||||
type MessageDescriptor = ReturnType<typeof msg>;
|
||||
|
||||
type SelectDescriptor = {
|
||||
value: string;
|
||||
label: MessageDescriptor;
|
||||
};
|
||||
|
||||
type RadioDescriptor<T> = {
|
||||
value: T;
|
||||
name: MessageDescriptor;
|
||||
};
|
||||
|
||||
export const REPORT_TYPE_OPTION_DESCRIPTORS: ReadonlyArray<RadioDescriptor<ReportType>> = [
|
||||
{value: 'message', name: msg`Report a Message`},
|
||||
{value: 'user', name: msg`Report a User Profile`},
|
||||
{value: 'guild', name: msg`Report a Community`},
|
||||
];
|
||||
|
||||
export const MESSAGE_CATEGORY_OPTIONS: ReadonlyArray<SelectDescriptor> = [
|
||||
{value: '', label: msg`Select a category`},
|
||||
{value: 'harassment', label: msg`Harassment or Bullying`},
|
||||
{value: 'hate_speech', label: msg`Hate Speech`},
|
||||
{value: 'violent_content', label: msg`Violent or Graphic Content`},
|
||||
{value: 'spam', label: msg`Spam or Scam`},
|
||||
{value: 'nsfw_violation', label: msg`NSFW Policy Violation`},
|
||||
{value: 'illegal_activity', label: msg`Illegal Activity`},
|
||||
{value: 'doxxing', label: msg`Sharing Personal Information`},
|
||||
{value: 'self_harm', label: msg`Self-Harm or Suicide`},
|
||||
{value: 'child_safety', label: msg`Child Safety Concerns`},
|
||||
{value: 'malicious_links', label: msg`Malicious Links`},
|
||||
{value: 'impersonation', label: msg`Impersonation`},
|
||||
{value: 'other', label: msg`Other`},
|
||||
];
|
||||
|
||||
export const USER_CATEGORY_OPTIONS: ReadonlyArray<SelectDescriptor> = [
|
||||
{value: '', label: msg`Select a category`},
|
||||
{value: 'harassment', label: msg`Harassment or Bullying`},
|
||||
{value: 'hate_speech', label: msg`Hate Speech`},
|
||||
{value: 'spam_account', label: msg`Spam Account`},
|
||||
{value: 'impersonation', label: msg`Impersonation`},
|
||||
{value: 'underage_user', label: msg`Underage User`},
|
||||
{value: 'inappropriate_profile', label: msg`Inappropriate Profile`},
|
||||
{value: 'other', label: msg`Other`},
|
||||
];
|
||||
|
||||
export const GUILD_CATEGORY_OPTIONS: ReadonlyArray<SelectDescriptor> = [
|
||||
{value: '', label: msg`Select a category`},
|
||||
{value: 'harassment', label: msg`Harassment`},
|
||||
{value: 'hate_speech', label: msg`Hate Speech`},
|
||||
{value: 'extremist_community', label: msg`Extremist Community`},
|
||||
{value: 'illegal_activity', label: msg`Illegal Activity`},
|
||||
{value: 'child_safety', label: msg`Child Safety Concerns`},
|
||||
{value: 'raid_coordination', label: msg`Raid Coordination`},
|
||||
{value: 'spam', label: msg`Spam or Scam Community`},
|
||||
{value: 'malware_distribution', label: msg`Malware Distribution`},
|
||||
{value: 'other', label: msg`Other`},
|
||||
];
|
||||
|
||||
export const COUNTRY_OPTIONS: ReadonlyArray<SelectDescriptor> = [
|
||||
{value: '', label: msg`Select a country`},
|
||||
{value: 'AT', label: msg`Austria`},
|
||||
{value: 'BE', label: msg`Belgium`},
|
||||
{value: 'BG', label: msg`Bulgaria`},
|
||||
{value: 'HR', label: msg`Croatia`},
|
||||
{value: 'CY', label: msg`Cyprus`},
|
||||
{value: 'CZ', label: msg`Czech Republic`},
|
||||
{value: 'DK', label: msg`Denmark`},
|
||||
{value: 'EE', label: msg`Estonia`},
|
||||
{value: 'FI', label: msg`Finland`},
|
||||
{value: 'FR', label: msg`France`},
|
||||
{value: 'DE', label: msg`Germany`},
|
||||
{value: 'GR', label: msg`Greece`},
|
||||
{value: 'HU', label: msg`Hungary`},
|
||||
{value: 'IE', label: msg`Ireland`},
|
||||
{value: 'IT', label: msg`Italy`},
|
||||
{value: 'LV', label: msg`Latvia`},
|
||||
{value: 'LT', label: msg`Lithuania`},
|
||||
{value: 'LU', label: msg`Luxembourg`},
|
||||
{value: 'MT', label: msg`Malta`},
|
||||
{value: 'NL', label: msg`Netherlands`},
|
||||
{value: 'PL', label: msg`Poland`},
|
||||
{value: 'PT', label: msg`Portugal`},
|
||||
{value: 'RO', label: msg`Romania`},
|
||||
{value: 'SK', label: msg`Slovakia`},
|
||||
{value: 'SI', label: msg`Slovenia`},
|
||||
{value: 'ES', label: msg`Spain`},
|
||||
{value: 'SE', label: msg`Sweden`},
|
||||
];
|
||||
152
fluxer_app/src/components/pages/report/state.ts
Normal file
152
fluxer_app/src/components/pages/report/state.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {type Action, INITIAL_FORM_VALUES, type State} from './types';
|
||||
|
||||
export const createInitialState = (): State => ({
|
||||
selectedType: null,
|
||||
flowStep: 'selection',
|
||||
|
||||
email: '',
|
||||
verificationCode: '',
|
||||
ticket: null,
|
||||
|
||||
formValues: {...INITIAL_FORM_VALUES},
|
||||
|
||||
isSendingCode: false,
|
||||
isVerifying: false,
|
||||
isSubmitting: false,
|
||||
|
||||
errorMessage: null,
|
||||
successReportId: null,
|
||||
|
||||
resendCooldownSeconds: 0,
|
||||
|
||||
fieldErrors: {},
|
||||
});
|
||||
|
||||
export function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'RESET_ALL':
|
||||
return createInitialState();
|
||||
|
||||
case 'SELECT_TYPE':
|
||||
return {
|
||||
...createInitialState(),
|
||||
selectedType: action.reportType,
|
||||
flowStep: 'email',
|
||||
};
|
||||
|
||||
case 'GO_TO_SELECTION':
|
||||
return {
|
||||
...createInitialState(),
|
||||
};
|
||||
|
||||
case 'GO_TO_EMAIL':
|
||||
return {
|
||||
...state,
|
||||
flowStep: 'email',
|
||||
verificationCode: '',
|
||||
ticket: null,
|
||||
isVerifying: false,
|
||||
errorMessage: null,
|
||||
resendCooldownSeconds: 0,
|
||||
fieldErrors: {},
|
||||
};
|
||||
|
||||
case 'GO_TO_VERIFICATION':
|
||||
return {
|
||||
...state,
|
||||
flowStep: 'verification',
|
||||
verificationCode: '',
|
||||
ticket: null,
|
||||
errorMessage: null,
|
||||
resendCooldownSeconds: 0,
|
||||
fieldErrors: {},
|
||||
};
|
||||
|
||||
case 'GO_TO_DETAILS':
|
||||
return {
|
||||
...state,
|
||||
flowStep: 'details',
|
||||
errorMessage: null,
|
||||
fieldErrors: {},
|
||||
};
|
||||
|
||||
case 'SET_ERROR':
|
||||
return {...state, errorMessage: action.message};
|
||||
|
||||
case 'SET_EMAIL':
|
||||
return {...state, email: action.email, errorMessage: null};
|
||||
|
||||
case 'SET_VERIFICATION_CODE':
|
||||
return {...state, verificationCode: action.code, errorMessage: null};
|
||||
|
||||
case 'SET_TICKET':
|
||||
return {...state, ticket: action.ticket};
|
||||
|
||||
case 'SET_FORM_FIELD':
|
||||
return {
|
||||
...state,
|
||||
formValues: {...state.formValues, [action.field]: action.value},
|
||||
errorMessage: null,
|
||||
fieldErrors: {...state.fieldErrors, [action.field]: undefined},
|
||||
};
|
||||
|
||||
case 'SENDING_CODE':
|
||||
return {...state, isSendingCode: action.value};
|
||||
|
||||
case 'VERIFYING':
|
||||
return {...state, isVerifying: action.value};
|
||||
|
||||
case 'SUBMITTING':
|
||||
return {...state, isSubmitting: action.value};
|
||||
|
||||
case 'SUBMIT_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
successReportId: action.reportId,
|
||||
flowStep: 'complete',
|
||||
isSubmitting: false,
|
||||
errorMessage: null,
|
||||
fieldErrors: {},
|
||||
};
|
||||
|
||||
case 'START_RESEND_COOLDOWN':
|
||||
return {...state, resendCooldownSeconds: action.seconds};
|
||||
|
||||
case 'TICK_RESEND_COOLDOWN':
|
||||
return {...state, resendCooldownSeconds: Math.max(0, state.resendCooldownSeconds - 1)};
|
||||
|
||||
case 'SET_FIELD_ERRORS':
|
||||
return {...state, fieldErrors: action.errors};
|
||||
|
||||
case 'CLEAR_FIELD_ERRORS':
|
||||
return {...state, fieldErrors: {}};
|
||||
|
||||
case 'CLEAR_FIELD_ERROR': {
|
||||
const next = {...state.fieldErrors};
|
||||
delete next[action.field];
|
||||
return {...state, fieldErrors: next};
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
81
fluxer_app/src/components/pages/report/types.ts
Normal file
81
fluxer_app/src/components/pages/report/types.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export type ReportType = 'message' | 'user' | 'guild';
|
||||
export type FlowStep = 'selection' | 'email' | 'verification' | 'details' | 'complete';
|
||||
|
||||
export const INITIAL_FORM_VALUES = {
|
||||
category: '',
|
||||
reporterFullName: '',
|
||||
reporterCountry: '',
|
||||
reporterFluxerTag: '',
|
||||
messageLink: '',
|
||||
messageUserTag: '',
|
||||
userId: '',
|
||||
userTag: '',
|
||||
guildId: '',
|
||||
inviteCode: '',
|
||||
additionalInfo: '',
|
||||
};
|
||||
|
||||
export type FormValues = typeof INITIAL_FORM_VALUES;
|
||||
|
||||
export type State = {
|
||||
selectedType: ReportType | null;
|
||||
flowStep: FlowStep;
|
||||
|
||||
email: string;
|
||||
verificationCode: string;
|
||||
ticket: string | null;
|
||||
|
||||
formValues: FormValues;
|
||||
|
||||
isSendingCode: boolean;
|
||||
isVerifying: boolean;
|
||||
isSubmitting: boolean;
|
||||
|
||||
errorMessage: string | null;
|
||||
successReportId: string | null;
|
||||
|
||||
resendCooldownSeconds: number;
|
||||
|
||||
fieldErrors: Partial<Record<keyof FormValues, string>>;
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| {type: 'RESET_ALL'}
|
||||
| {type: 'SELECT_TYPE'; reportType: ReportType}
|
||||
| {type: 'GO_TO_SELECTION'}
|
||||
| {type: 'GO_TO_EMAIL'}
|
||||
| {type: 'GO_TO_VERIFICATION'}
|
||||
| {type: 'GO_TO_DETAILS'}
|
||||
| {type: 'SET_ERROR'; message: string | null}
|
||||
| {type: 'SET_EMAIL'; email: string}
|
||||
| {type: 'SET_VERIFICATION_CODE'; code: string}
|
||||
| {type: 'SET_TICKET'; ticket: string | null}
|
||||
| {type: 'SET_FORM_FIELD'; field: keyof FormValues; value: string}
|
||||
| {type: 'SENDING_CODE'; value: boolean}
|
||||
| {type: 'VERIFYING'; value: boolean}
|
||||
| {type: 'SUBMITTING'; value: boolean}
|
||||
| {type: 'SUBMIT_SUCCESS'; reportId: string}
|
||||
| {type: 'START_RESEND_COOLDOWN'; seconds: number}
|
||||
| {type: 'TICK_RESEND_COOLDOWN'}
|
||||
| {type: 'SET_FIELD_ERRORS'; errors: Partial<Record<keyof FormValues, string>>}
|
||||
| {type: 'CLEAR_FIELD_ERRORS'}
|
||||
| {type: 'CLEAR_FIELD_ERROR'; field: keyof FormValues};
|
||||
50
fluxer_app/src/components/pages/report/validators.ts
Normal file
50
fluxer_app/src/components/pages/report/validators.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
export const VERIFICATION_CODE_REGEX = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/;
|
||||
|
||||
export function formatVerificationCodeInput(raw: string): string {
|
||||
const cleaned = raw
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, '')
|
||||
.slice(0, 8);
|
||||
if (cleaned.length <= 4) return cleaned;
|
||||
return `${cleaned.slice(0, 4)}-${cleaned.slice(4)}`;
|
||||
}
|
||||
|
||||
export function normalizeLikelyUrl(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)) {
|
||||
return `https://${trimmed}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function isValidHttpUrl(raw: string): boolean {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -455,7 +455,8 @@ export const UserAreaPopout = observer(() => {
|
||||
<div className={styles.footer}>
|
||||
<div className={styles.actionGroup}>
|
||||
<Popout
|
||||
hoverDelay={200}
|
||||
hoverDelay={0}
|
||||
hoverCloseDelay={120}
|
||||
position="right-start"
|
||||
preventInvert
|
||||
toggleClose={false}
|
||||
@@ -473,7 +474,8 @@ export const UserAreaPopout = observer(() => {
|
||||
<div className={styles.actionDivider} />
|
||||
|
||||
<Popout
|
||||
hoverDelay={200}
|
||||
hoverDelay={0}
|
||||
hoverCloseDelay={120}
|
||||
position="right-start"
|
||||
preventInvert
|
||||
toggleClose={false}
|
||||
|
||||
@@ -378,7 +378,7 @@ export const SubMenu = React.forwardRef<HTMLDivElement, SubMenuProps>(
|
||||
/>
|
||||
</svg>
|
||||
</AriaMenuItem>
|
||||
<AriaPopover placement="right top" offset={4} className={styles.submenuPopover}>
|
||||
<AriaPopover placement="right top" offset={0} className={styles.submenuPopover}>
|
||||
<AriaMenu className={styles.ariaMenu} aria-label="Submenu" autoFocus="first" selectionMode={selectionMode}>
|
||||
{children}
|
||||
</AriaMenu>
|
||||
|
||||
@@ -24,8 +24,10 @@ import * as ModalActionCreators from '~/actions/ModalActionCreators';
|
||||
import {modal} from '~/actions/ModalActionCreators';
|
||||
import {RelationshipTypes} from '~/Constants';
|
||||
import {ChangeFriendNicknameModal} from '~/components/modals/ChangeFriendNicknameModal';
|
||||
import {Tooltip} from '~/components/uikit/Tooltip/Tooltip';
|
||||
import type {UserRecord} from '~/records/UserRecord';
|
||||
import RelationshipStore from '~/stores/RelationshipStore';
|
||||
import UserStore from '~/stores/UserStore';
|
||||
import * as RelationshipActionUtils from '~/utils/RelationshipActionUtils';
|
||||
import {
|
||||
AcceptFriendRequestIcon,
|
||||
@@ -50,6 +52,7 @@ export const SendFriendRequestMenuItem: React.FC<SendFriendRequestMenuItemProps>
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
||||
const showFriendRequestSent = relationshipType === RelationshipTypes.OUTGOING_REQUEST;
|
||||
const isCurrentUserUnclaimed = !(UserStore.currentUser?.isClaimed() ?? true);
|
||||
|
||||
const handleSendFriendRequest = React.useCallback(async () => {
|
||||
if (submitting || showFriendRequestSent) return;
|
||||
@@ -58,6 +61,24 @@ export const SendFriendRequestMenuItem: React.FC<SendFriendRequestMenuItemProps>
|
||||
setSubmitting(false);
|
||||
}, [i18n, showFriendRequestSent, submitting, user.id]);
|
||||
|
||||
if (isCurrentUserUnclaimed) {
|
||||
const tooltip = t`Claim your account to send friend requests.`;
|
||||
return (
|
||||
<Tooltip text={tooltip} maxWidth="xl">
|
||||
<div>
|
||||
<MenuItem
|
||||
icon={<SendFriendRequestIcon />}
|
||||
onClick={handleSendFriendRequest}
|
||||
disabled={true}
|
||||
closeOnSelect={false}
|
||||
>
|
||||
{showFriendRequestSent ? t`Friend Request Sent` : t`Add Friend`}
|
||||
</MenuItem>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
icon={<SendFriendRequestIcon />}
|
||||
|
||||
@@ -50,6 +50,7 @@ interface PopoutProps {
|
||||
containerClass?: string;
|
||||
preventInvert?: boolean;
|
||||
hoverDelay?: number;
|
||||
hoverCloseDelay?: number;
|
||||
toggleClose?: boolean;
|
||||
subscribeTo?: ComponentActionType;
|
||||
onOpen?: () => void;
|
||||
@@ -192,8 +193,8 @@ export const Popout = React.forwardRef<HTMLElement, PopoutProps>((props, ref) =>
|
||||
if (!isTriggerHoveringRef.current && !isContentHoveringRef.current && !hasActiveDependents) {
|
||||
close();
|
||||
}
|
||||
}, 300);
|
||||
}, [close, state.id]);
|
||||
}, props.hoverCloseDelay ?? 300);
|
||||
}, [close, state.id, props.hoverCloseDelay]);
|
||||
|
||||
const handleContentMouseEnter = React.useCallback(() => {
|
||||
isContentHoveringRef.current = true;
|
||||
|
||||
@@ -125,10 +125,10 @@ const PopoutItem: React.FC<PopoutItemProps> = observer(
|
||||
const transitionStyles = React.useMemo(() => {
|
||||
const shouldAnimate = animationType === 'smooth' && !prefersReducedMotion;
|
||||
const duration = shouldAnimate ? '250ms' : '0ms';
|
||||
const isPositioned = state.isReady;
|
||||
const isPositioned = animationType === 'none' ? true : state.isReady;
|
||||
const transform = getTransform(shouldAnimate, isVisible, isPositioned, targetInDOM);
|
||||
return {
|
||||
opacity: isVisible && isPositioned && targetInDOM ? 1 : 0,
|
||||
opacity: isVisible && targetInDOM ? 1 : 0,
|
||||
transform,
|
||||
transition: `opacity ${duration} ease-in-out${shouldAnimate ? `, transform ${duration} ease-in-out` : ''}`,
|
||||
pointerEvents: isPositioned && targetInDOM ? ('auto' as const) : ('none' as const),
|
||||
|
||||
Reference in New Issue
Block a user