soapbox/src/features/ui/components/profile-info-panel.tsx

267 wiersze
8.9 KiB
TypeScript

import React from 'react';
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
import { usePatronUser } from 'soapbox/api/hooks';
import Badge from 'soapbox/components/badge';
import Markup from 'soapbox/components/markup';
import { dateFormatOptions } from 'soapbox/components/relative-timestamp';
import { Icon, IconButton, HStack, Stack, Text } from 'soapbox/components/ui';
import { useAppSelector, useSoapboxConfig } from 'soapbox/hooks';
import toast from 'soapbox/toast';
import { badgeToTag, getBadges as getAccountBadges } from 'soapbox/utils/badges';
import { capitalize } from 'soapbox/utils/strings';
import ProfileFamiliarFollowers from './profile-familiar-followers';
import ProfileField from './profile-field';
import ProfileStats from './profile-stats';
import type { Account } from 'soapbox/schemas';
/** Basically ensure the URL isn't `javascript:alert('hi')` or something like that */
const isSafeUrl = (text: string): boolean => {
try {
const url = new URL(text);
return ['http:', 'https:'].includes(url.protocol);
} catch (e) {
return false;
}
};
const messages = defineMessages({
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
deactivated: { id: 'account.deactivated', defaultMessage: 'Deactivated' },
bot: { id: 'account.badges.bot', defaultMessage: 'Bot' },
copy_success: { id: 'copy.success', defaultMessage: 'Copied to clipboard!' },
copy: { id: 'copy', defaultMessage: 'Copy' },
copy_failed: { id: 'copy.failed', defaultMessage: 'Failed to copy' },
});
interface IProfileInfoPanel {
account?: Account;
/** Username from URL params, in case the account isn't found. */
username: string;
}
/** User profile metadata, such as location, birthday, etc. */
const ProfileInfoPanel: React.FC<IProfileInfoPanel> = ({ account, username }) => {
const intl = useIntl();
const { displayFqn } = useSoapboxConfig();
const { patronUser } = usePatronUser(account?.url);
const me = useAppSelector(state => state.me);
const ownAccount = account?.id === me;
const handleUsernameClick: React.MouseEventHandler = () => {
if ('clipboard' in navigator) {
const c = (displayFqn) ? account?.fqn : account?.acct;
navigator.clipboard.writeText('@' + c);
toast.success(messages.copy_success);
} else {
toast.error(messages.copy_failed);
}
};
const getStaffBadge = (): React.ReactNode => {
if (account?.admin) {
return <Badge slug='admin' title={<FormattedMessage id='account_moderation_modal.roles.admin' defaultMessage='Admin' />} key='staff' />;
} else if (account?.moderator) {
return <Badge slug='moderator' title={<FormattedMessage id='account_moderation_modal.roles.moderator' defaultMessage='Moderator' />} key='staff' />;
} else {
return null;
}
};
const getCustomBadges = (): React.ReactNode[] => {
const badges = account ? getAccountBadges(account) : [];
return badges.map(badge => (
<Badge
key={badge}
slug={badge}
title={capitalize(badgeToTag(badge))}
/>
));
};
const getBadges = (): React.ReactNode[] => {
const custom = getCustomBadges();
const staffBadge = getStaffBadge();
const isPatron = patronUser?.is_patron === true;
const badges = [];
if (staffBadge) {
badges.push(staffBadge);
}
if (isPatron) {
badges.push(<Badge slug='patron' title={<FormattedMessage id='account.patron' defaultMessage='Patron' />} key='patron' />);
}
return [...badges, ...custom];
};
const renderBirthday = (): React.ReactNode => {
const birthday = account?.pleroma?.birthday;
if (!birthday) return null;
const formattedBirthday = intl.formatDate(birthday, { timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' });
const date = new Date(birthday);
const today = new Date();
const hasBirthday = date.getDate() === today.getDate() && date.getMonth() === today.getMonth();
return (
<HStack alignItems='center' space={0.5}>
<Icon
src={require('@tabler/icons/balloon.svg')}
className='h-4 w-4 text-gray-800 dark:text-gray-200'
/>
<Text size='sm'>
{hasBirthday ? (
<FormattedMessage id='account.birthday_today' defaultMessage='Birthday is today!' />
) : (
<FormattedMessage id='account.birthday' defaultMessage='Born {date}' values={{ date: formattedBirthday }} />
)}
</Text>
</HStack>
);
};
if (!account) {
return (
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
<Stack space={2}>
<Stack>
<HStack space={1} alignItems='center'>
<Text size='sm' theme='muted' direction='ltr' truncate>
@{username}
</Text>
</HStack>
</Stack>
</Stack>
</div>
);
}
const deactivated = account.pleroma?.deactivated ?? false;
const displayNameHtml = deactivated ? { __html: intl.formatMessage(messages.deactivated) } : { __html: account.display_name_html };
const memberSinceDate = intl.formatDate(account.created_at, { month: 'long', year: 'numeric' });
const badges = getBadges();
return (
<div className='mt-6 min-w-0 flex-1 sm:px-2'>
<Stack space={2}>
<Stack>
<HStack space={1} alignItems='center'>
<Text size='lg' weight='bold' dangerouslySetInnerHTML={displayNameHtml} truncate />
{account.bot && <Badge slug='bot' title={intl.formatMessage(messages.bot)} />}
{badges.length > 0 && (
<HStack space={1} alignItems='center'>
{badges}
</HStack>
)}
</HStack>
<HStack alignItems='center' space={0.5}>
<Text size='sm' theme='muted' direction='ltr' truncate>
@{displayFqn ? account.fqn : account.acct}
</Text>
{account.locked && (
<Icon
src={require('@tabler/icons/lock.svg')}
alt={intl.formatMessage(messages.account_locked)}
className='h-4 w-4 text-gray-600'
/>
)}
<IconButton
src={require('@tabler/icons/copy.svg')}
title={intl.formatMessage(messages.copy)}
className='text-gray-600'
iconClassName='h-4 w-4'
onClick={handleUsernameClick}
/>
</HStack>
</Stack>
<ProfileStats account={account} />
{account.note.length > 0 && (
<Markup size='sm' dangerouslySetInnerHTML={{ __html: account.note_emojified }} truncate />
)}
<div className='flex flex-col items-start gap-2 md:flex-row md:flex-wrap md:items-center'>
{account.local ? (
<HStack alignItems='center' space={0.5}>
<Icon
src={require('@tabler/icons/calendar.svg')}
className='h-4 w-4 text-gray-800 dark:text-gray-200'
/>
<Text size='sm' title={intl.formatDate(account.created_at, dateFormatOptions)}>
<FormattedMessage
id='account.member_since' defaultMessage='Joined {date}' values={{
date: memberSinceDate,
}}
/>
</Text>
</HStack>
) : null}
{account.location ? (
<HStack alignItems='center' space={0.5}>
<Icon
src={require('@tabler/icons/map-pin.svg')}
className='h-4 w-4 text-gray-800 dark:text-gray-200'
/>
<Text size='sm'>
{account.location}
</Text>
</HStack>
) : null}
{account.website ? (
<HStack alignItems='center' space={0.5}>
<Icon
src={require('@tabler/icons/link.svg')}
className='h-4 w-4 text-gray-800 dark:text-gray-200'
/>
<div className='max-w-[300px]'>
<Text size='sm' truncate>
{isSafeUrl(account.website) ? (
<a className='text-primary-600 hover:underline dark:text-accent-blue' href={account.website} target='_blank'>{account.website}</a>
) : (
account.website
)}
</Text>
</div>
</HStack>
) : null}
{renderBirthday()}
</div>
{ownAccount ? null : <ProfileFamiliarFollowers account={account} />}
</Stack>
{account.fields.length > 0 && (
<Stack space={2} className='mt-4 xl:hidden'>
{account.fields.map((field, i) => (
<ProfileField field={field} key={i} />
))}
</Stack>
)}
</div>
);
};
export default ProfileInfoPanel;