Remove and move profile code (#38863)

This commit is contained in:
Echo authored and GitHub committed 2026-04-30 13:58:22 +00:00
1 parent 3021cd8002
commit 945ac23910
35 files changed
+248 -569

No files matched your search

@@ -0,0 +1,113 @@
import { useEffect } from 'react';
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchRelationships } from '@/mastodon/actions/accounts';
import { useAccount } from '@/mastodon/hooks/useAccount';
import type { AccountRole } from '@/mastodon/models/account';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import {
AdminBadge,
AutomatedBadge,
Badge,
BlockedBadge,
GroupBadge,
MutedBadge,
} from '../badge';
import classes from './styles.module.scss';
export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
const account = useAccount(accountId);
const localDomain = useAppSelector(
(state) => state.meta.get('domain') as string,
);
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!relationship) {
dispatch(fetchRelationships([accountId]));
}
}, [accountId, dispatch, relationship]);
const badges = [];
if (!account) {
return null;
}
const domain = account.acct.includes('@')
? account.acct.split('@')[1]
: localDomain;
account.roles.forEach((role) => {
if (isAdminBadge(role)) {
badges.push(
<AdminBadge
key={role.id}
label={role.name}
domain={`(${domain})`}
roleId={role.id}
/>,
);
} else {
badges.push(
<Badge
key={role.id}
label={role.name}
domain={`(${domain})`}
roleId={role.id}
/>,
);
}
});
if (account.bot) {
badges.push(<AutomatedBadge key='bot-badge' />);
}
if (account.group) {
badges.push(<GroupBadge key='group-badge' />);
}
if (relationship) {
if (relationship.blocking) {
badges.push(<BlockedBadge key='blocking' />);
}
if (relationship.domain_blocking) {
badges.push(
<BlockedBadge
key='domain-blocking'
domain={domain}
label={
<FormattedMessage
id='account.badges.domain_blocked'
defaultMessage='Blocked domain'
/>
}
/>,
);
}
if (relationship.muting) {
badges.push(
<MutedBadge
key='muted-badge'
expiresAt={relationship.muting_expires_at}
/>,
);
}
}
if (!badges.length) {
return null;
}
return <div className={classes.badges}>{badges}</div>;
};
function isAdminBadge(role: AccountRole) {
const name = role.name.toLowerCase();
return name === 'admin' || name === 'owner';
}
@@ -0,0 +1,137 @@
import { useCallback } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { followAccount } from '@/mastodon/actions/accounts';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { getAccountHidden } from '@/mastodon/selectors/accounts';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications_active-fill.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { CopyIconButton } from '../copy_icon_button';
import { FollowButton } from '../follow_button';
import { IconButton } from '../icon_button';
import { AccountMenu } from './menu';
const messages = defineMessages({
enableNotifications: {
id: 'account.enable_notifications',
defaultMessage: 'Notify me when @{name} posts',
},
disableNotifications: {
id: 'account.disable_notifications',
defaultMessage: 'Stop notifying me when @{name} posts',
},
share: { id: 'account.share', defaultMessage: "Share @{name}'s profile" },
copy: { id: 'account.copy', defaultMessage: 'Copy link to profile' },
});
interface AccountButtonsProps {
accountId: string;
className?: string;
noShare?: boolean;
forceMenu?: boolean;
}
export const AccountButtons: FC<AccountButtonsProps> = ({
accountId,
className,
noShare,
forceMenu,
}) => {
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
const me = useAppSelector((state) => state.meta.get('me') as string);
return (
<div className={classNames('account__header__buttons', className)}>
{!hidden && (
<AccountButtonsOther accountId={accountId} noShare={noShare} />
)}
{(accountId !== me || forceMenu) && <AccountMenu accountId={accountId} />}
</div>
);
};
const AccountButtonsOther: FC<
Pick<AccountButtonsProps, 'accountId' | 'noShare'>
> = ({ accountId, noShare }) => {
const intl = useIntl();
const account = useAccount(accountId);
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
const handleNotifyToggle = useCallback(() => {
if (account) {
dispatch(followAccount(account.id, { notify: !relationship?.notifying }));
}
}, [dispatch, account, relationship]);
const accountUrl = account?.url;
const handleShare = useCallback(() => {
if (accountUrl) {
void navigator.share({
url: accountUrl,
});
}
}, [accountUrl]);
if (!account) {
return null;
}
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
const isFollowing = relationship?.requested || relationship?.following;
return (
<>
{!isMovedAndUnfollowedAccount && (
<FollowButton
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
/>
)}
{isFollowing && (
<IconButton
icon={relationship.notifying ? 'bell' : 'bell-o'}
iconComponent={
relationship.notifying ? NotificationsActiveIcon : NotificationsIcon
}
active={relationship.notifying}
title={intl.formatMessage(
relationship.notifying
? messages.disableNotifications
: messages.enableNotifications,
{ name: account.username },
)}
onClick={handleNotifyToggle}
/>
)}
{!noShare &&
('share' in navigator ? (
<IconButton
className='optional'
icon=''
iconComponent={ShareIcon}
title={intl.formatMessage(messages.share, {
name: account.username,
})}
onClick={handleShare}
/>
) : (
<CopyIconButton
className='optional'
title={intl.formatMessage(messages.copy)}
value={account.url}
/>
))}
</>
);
};
@@ -0,0 +1,379 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import type { FC } from 'react';
import { defineMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import IconVerified from '@/images/icons/icon_verified.svg?react';
import { openModal } from '@/mastodon/actions/modal';
import { useFieldHtml } from '@/mastodon/features/account_timeline/hooks/useFieldHtml';
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useResizeObserver } from '@/mastodon/hooks/useObserver';
import type { AccountFieldShape } from '@/mastodon/models/account';
import { useAppDispatch } from '@/mastodon/store';
import MoreIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import { CustomEmojiProvider } from '../emoji/context';
import type { EmojiHTMLProps } from '../emoji/html';
import { EmojiHTML } from '../emoji/html';
import { Icon } from '../icon';
import { IconButton } from '../icon_button';
import { MiniCard } from '../mini_card';
import { useElementHandledLink } from '../status/handled_link';
import classes from './styles.module.scss';
const verifyMessage = defineMessage({
id: 'account.link_verified_on',
defaultMessage: 'Ownership of this link was checked on {date}',
});
const dateFormatOptions: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
export interface AccountField extends AccountFieldShape {
nameHasEmojis: boolean;
value_plain: string;
valueHasEmojis: boolean;
}
export const AccountHeaderFields: FC<{ accountId: string }> = ({
accountId,
}) => {
const account = useAccount(accountId);
const emojis = useMemo(
() => cleanExtraEmojis(account?.emojis),
[account?.emojis],
);
const accountFields = account?.fields;
const fields: AccountField[] = useMemo(() => {
const fields = accountFields?.toJS();
if (!fields) {
return [];
}
if (!emojis) {
return fields.map((field) => ({
...field,
nameHasEmojis: false,
value_plain: field.value_plain ?? '',
valueHasEmojis: false,
}));
}
const shortcodes = Object.keys(emojis);
return fields.map((field) => ({
...field,
nameHasEmojis: shortcodes.some((code) =>
field.name.includes(`:${code}:`),
),
value_plain: field.value_plain ?? '',
valueHasEmojis: shortcodes.some((code) =>
field.value_plain?.includes(`:${code}:`),
),
}));
}, [accountFields, emojis]);
const htmlHandlers = useElementHandledLink({
hashtagAccountId: account?.id,
});
const { wrapperRef } = useColumnWrap();
if (fields.length === 0) {
return null;
}
return (
<CustomEmojiProvider emojis={emojis}>
<dl className={classes.fieldList} ref={wrapperRef}>
{fields.map((field, key) => (
<FieldCard key={key} field={field} htmlHandlers={htmlHandlers} />
))}
</dl>
</CustomEmojiProvider>
);
};
const FieldCard: FC<{
htmlHandlers: ReturnType<typeof useElementHandledLink>;
field: AccountField;
}> = ({ htmlHandlers, field }) => {
const intl = useIntl();
const {
name,
name_emojified,
nameHasEmojis,
value_emojified,
value_plain,
valueHasEmojis,
verified_at,
} = field;
const { wrapperRef, isLabelOverflowing, isValueOverflowing } =
useFieldOverflow();
const dispatch = useAppDispatch();
const handleOverflowClick = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_FIELD_OVERFLOW',
modalProps: { field },
}),
);
}, [dispatch, field]);
return (
<MiniCard
className={classNames(
classes.fieldItem,
verified_at && classes.fieldVerified,
)}
label={
<FieldHTML
text={name}
textEmojified={name_emojified}
textHasCustomEmoji={nameHasEmojis}
className='translate'
isOverflowing={isLabelOverflowing}
onOverflowClick={handleOverflowClick}
{...htmlHandlers}
/>
}
value={
<FieldHTML
text={value_plain}
textEmojified={value_emojified}
textHasCustomEmoji={valueHasEmojis}
isOverflowing={isValueOverflowing}
onOverflowClick={handleOverflowClick}
{...htmlHandlers}
/>
}
ref={wrapperRef}
>
{verified_at && (
<span
className={classes.fieldVerifiedIcon}
title={intl.formatMessage(verifyMessage, {
date: intl.formatDate(verified_at, dateFormatOptions),
})}
>
<Icon id='verified' icon={IconVerified} noFill />
</span>
)}
</MiniCard>
);
};
type FieldHTMLProps = {
text: string;
textEmojified: string;
textHasCustomEmoji: boolean;
isOverflowing?: boolean;
onOverflowClick?: () => void;
} & Omit<EmojiHTMLProps, 'htmlString'>;
const FieldHTML: FC<FieldHTMLProps> = ({
className,
extraEmojis,
text,
textEmojified,
textHasCustomEmoji,
isOverflowing,
onOverflowClick,
onElement,
...props
}) => {
const intl = useIntl();
const handleElement = useFieldHtml(textHasCustomEmoji, onElement);
const html = (
<EmojiHTML
as='span'
htmlString={textEmojified}
className={className}
onElement={handleElement}
data-contents
{...props}
/>
);
if (!isOverflowing) {
return html;
}
return (
<>
{html}
<IconButton
icon='ellipsis'
iconComponent={MoreIcon}
title={intl.formatMessage({
id: 'account.field_overflow',
defaultMessage: 'Show full content',
})}
className={classes.fieldOverflowButton}
onClick={onOverflowClick}
/>
</>
);
};
function useColumnWrap() {
const listRef = useRef<HTMLDListElement | null>(null);
const handleRecalculate = useCallback(() => {
const listEle = listRef.current;
if (!listEle) {
return;
}
// Calculate dimensions from styles and element size to determine column spans.
const styles = getComputedStyle(listEle);
const gap = parseFloat(styles.columnGap || styles.gap || '0');
const columnCount = parseInt(styles.getPropertyValue('--cols')) || 2;
const listWidth = listEle.offsetWidth;
const colWidth = (listWidth - gap * (columnCount - 1)) / columnCount;
const halfColSpan = columnCount / 2;
// Matrix to hold the grid layout.
const itemGrid: { ele: HTMLElement; span: number }[][] = [];
// First, determine the column span for each item and populate the grid matrix.
let currentRow = 0;
for (const child of listEle.children) {
if (!(child instanceof HTMLElement)) {
continue;
}
// This uses a data attribute to detect which elements to measure that overflow.
const contents = child.querySelectorAll('[data-contents]');
const childStyles = getComputedStyle(child);
const padding =
parseFloat(childStyles.paddingLeft) +
parseFloat(childStyles.paddingRight);
const contentWidth =
Math.max(
...Array.from(contents).map((content) => content.scrollWidth),
) + padding;
const contentSpan = Math.ceil(contentWidth / colWidth);
const maxColSpan = Math.min(contentSpan, columnCount);
const curRow = itemGrid[currentRow] ?? [];
const availableCols =
columnCount - curRow.reduce((carry, curr) => carry + curr.span, 0);
// Move to next row if current item doesn't fit.
if (maxColSpan > availableCols) {
currentRow++;
}
itemGrid[currentRow] = (itemGrid[currentRow] ?? []).concat({
ele: child,
span: maxColSpan,
});
}
// Next, iterate through the grid matrix and set the column spans and row breaks.
for (const row of itemGrid) {
let remainingRowSpan = columnCount;
for (let i = 0; i < row.length; i++) {
const item = row[i];
if (!item) {
break;
}
const { ele, span } = item;
if (i < row.length - 1) {
ele.dataset.cols = span.toString();
remainingRowSpan -= span;
} else if (
row.length > 1 &&
row.length === halfColSpan &&
span === 1 &&
remainingRowSpan > 1
) {
// Special case for 2 items in a row where both items only need 1 column.
ele.dataset.cols = halfColSpan.toString();
if (row[0]) {
row[0].ele.dataset.cols = halfColSpan.toString();
}
} else {
// Last item in the row takes up remaining space to fill the row.
ele.dataset.cols = remainingRowSpan.toString();
break;
}
}
}
}, []);
const observer = useResizeObserver(handleRecalculate);
const wrapperRefCallback = useCallback(
(element: HTMLDListElement | null) => {
if (element) {
listRef.current = element;
observer.observe(element);
}
},
[observer],
);
return { wrapperRef: wrapperRefCallback };
}
function useFieldOverflow() {
const [isLabelOverflowing, setIsLabelOverflowing] = useState(false);
const [isValueOverflowing, setIsValueOverflowing] = useState(false);
const wrapperRef = useRef<HTMLElement | null>(null);
const handleRecalculate = useCallback(() => {
const wrapperEle = wrapperRef.current;
if (!wrapperEle) return;
const wrapperStyles = getComputedStyle(wrapperEle);
const maxWidth =
wrapperEle.offsetWidth -
(parseFloat(wrapperStyles.paddingLeft) +
parseFloat(wrapperStyles.paddingRight));
const label = wrapperEle.querySelector<HTMLSpanElement>(
'dt > [data-contents]',
);
const value = wrapperEle.querySelector<HTMLSpanElement>(
'dd > [data-contents]',
);
setIsLabelOverflowing(label ? label.scrollWidth > maxWidth : false);
setIsValueOverflowing(value ? value.scrollWidth > maxWidth : false);
}, []);
const observer = useResizeObserver(handleRecalculate);
const wrapperRefCallback = useCallback(
(element: HTMLElement | null) => {
if (element) {
wrapperRef.current = element;
observer.observe(element);
}
},
[observer],
);
return {
isLabelOverflowing,
isValueOverflowing,
wrapperRef: wrapperRefCallback,
};
}
@@ -0,0 +1,219 @@
import { useCallback } from 'react';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { openModal } from '@/mastodon/actions/modal';
import FollowRequestNoteContainer from '@/mastodon/features/account/containers/follow_request_note_container';
import { useLayout } from '@/mastodon/hooks/useLayout';
import { useVisibility } from '@/mastodon/hooks/useVisibility';
import {
autoPlayGif,
me,
domain as localDomain,
} from '@/mastodon/initial_state';
import type { Account } from '@/mastodon/models/account';
import { getAccountHidden } from '@/mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import { AccountBio } from '../account_bio';
import { Avatar } from '../avatar';
import { AnimateEmojiProvider } from '../emoji/context';
import { FamiliarFollowers } from '../familiar_followers';
import { AccountButtons } from './buttons';
import { AccountHeaderFields } from './fields';
import { MemorialNote } from './memorial_note';
import { MovedNote } from './moved_note';
import { AccountName } from './name';
import { AccountNote } from './note';
import { AccountNumberFields } from './number_fields';
import classes from './styles.module.scss';
import { AccountSubscriptionForm } from './subscription_form';
import { AccountTabs } from './tabs';
const titleFromAccount = (account: Account) => {
const displayName = account.display_name;
const acct =
account.acct === account.username
? `${account.username}@${localDomain}`
: account.acct;
const prefix =
displayName.trim().length === 0 ? account.username : displayName;
return `${prefix} (@${acct})`;
};
export const AccountHeader: React.FC<{
accountId: string;
hideTabs?: boolean;
}> = ({ accountId, hideTabs }) => {
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
const handleOpenAvatar = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0 || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
if (!account) {
return;
}
dispatch(
openModal({
modalType: 'IMAGE',
modalProps: {
src: account.avatar,
alt: account.avatar_description,
},
}),
);
},
[dispatch, account],
);
const { layout } = useLayout();
const { observedRef, isIntersecting } = useVisibility({
observerOptions: {
rootMargin: layout === 'mobile' ? '0px 0px -55px 0px' : '', // Height of bottom nav bar.
},
});
if (!account) {
return null;
}
const suspendedOrHidden = hidden || account.suspended;
const isLocal = !account.acct.includes('@');
const isMe = me && account.id === me;
return (
<div className='account-timeline__header'>
{!hidden && account.memorial && <MemorialNote />}
{!hidden && account.moved && (
<MovedNote accountId={account.id} targetAccountId={account.moved} />
)}
<AnimateEmojiProvider
className={classNames('account__header', {
inactive: !!account.moved,
})}
>
{!suspendedOrHidden && !account.moved && relationship?.requested_by && (
<FollowRequestNoteContainer account={account} />
)}
<div className={classNames('account__header__image', classes.header)}>
{!suspendedOrHidden && (
<img
src={autoPlayGif ? account.header : account.header_static}
alt={account.header_description}
className='parallax'
/>
)}
</div>
<div className={classNames('account__header__bar', classes.barWrapper)}>
<div
className={classNames(
'account__header__tabs',
classes.avatarWrapper,
)}
>
<a
className='avatar'
href={account.avatar}
rel='noopener'
target='_blank'
onClick={handleOpenAvatar}
>
<Avatar
account={suspendedOrHidden ? undefined : account}
alt={account.avatar_description}
size={80}
/>
</a>
</div>
<div
className={classNames(
'account__header__tabs__name',
classes.displayNameWrapper,
)}
>
<AccountName accountId={accountId} />
<AccountButtons
accountId={accountId}
className={classes.buttonsDesktop}
noShare={!isMe || 'share' in navigator}
forceMenu={'share' in navigator}
/>
</div>
<AccountNumberFields accountId={accountId} />
{!isMe && !suspendedOrHidden && (
<FamiliarFollowers
accountId={accountId}
className={classes.familiarFollowers}
/>
)}
{!suspendedOrHidden && (
<div className='account__header__extra'>
<div className='account__header__bio'>
{me && account.id !== me && (
<AccountNote accountId={accountId} />
)}
<AccountBio
showDropdown
accountId={accountId}
className={classNames(
'account__header__content',
classes.bio,
)}
/>
<AccountHeaderFields accountId={accountId} />
</div>
{!me && account.email_subscriptions && (
<AccountSubscriptionForm accountId={accountId} />
)}
</div>
)}
<AccountButtons
className={classNames(
classes.buttonsMobile,
!isIntersecting && classes.buttonsMobileIsStuck,
)}
accountId={accountId}
noShare
/>
</div>
</AnimateEmojiProvider>
{!hideTabs && !hidden && <AccountTabs />}
<div ref={observedRef} />
<Helmet>
<title>{titleFromAccount(account)}</title>
<meta
name='robots'
content={isLocal && !account.noindex ? 'all' : 'noindex'}
/>
<link rel='canonical' href={account.url} />
</Helmet>
</div>
);
};
@@ -0,0 +1,12 @@
import { FormattedMessage } from 'react-intl';
export const MemorialNote: React.FC = () => (
<div className='account-memorial-banner'>
<div className='account-memorial-banner__message'>
<FormattedMessage
id='account.in_memoriam'
defaultMessage='In Memoriam.'
/>
</div>
</div>
);
@@ -0,0 +1,510 @@
import { useMemo } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import {
followAccount,
pinAccount,
unblockAccount,
unmuteAccount,
unpinAccount,
} from '@/mastodon/actions/accounts';
import { removeAccountFromFollowers } from '@/mastodon/actions/accounts_typed';
import { showAlert } from '@/mastodon/actions/alerts';
import { initBlockModal } from '@/mastodon/actions/blocks';
import { directCompose, mentionCompose } from '@/mastodon/actions/compose';
import {
initDomainBlockModal,
unblockDomain,
} from '@/mastodon/actions/domain_blocks';
import { openModal } from '@/mastodon/actions/modal';
import { initMuteModal } from '@/mastodon/actions/mutes';
import { initReport } from '@/mastodon/actions/reports';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useIdentity } from '@/mastodon/identity_context';
import type { Account } from '@/mastodon/models/account';
import type { MenuItem } from '@/mastodon/models/dropdown_menu';
import type { Relationship } from '@/mastodon/models/relationship';
import {
PERMISSION_MANAGE_FEDERATION,
PERMISSION_MANAGE_USERS,
} from '@/mastodon/permissions';
import type { AppDispatch } from '@/mastodon/store';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import BlockIcon from '@/material-icons/400-24px/block.svg?react';
import LinkIcon from '@/material-icons/400-24px/link_2.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import PersonRemoveIcon from '@/material-icons/400-24px/person_remove.svg?react';
import ReportIcon from '@/material-icons/400-24px/report.svg?react';
import ShareIcon from '@/material-icons/400-24px/share.svg?react';
import { Dropdown } from '../dropdown_menu';
import classes from './styles.module.scss';
export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const { signedIn, permissions } = useIdentity();
const account = useAccount(accountId);
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const currentAccountId = useAppSelector(
(state) => state.meta.get('me') as string,
);
const isMe = currentAccountId === accountId;
const dispatch = useAppDispatch();
const menuItems = useMemo(() => {
if (!account) {
return [];
}
return getMenuItems({
account,
signedIn: !isMe && signedIn,
permissions,
intl,
relationship,
dispatch,
});
}, [account, signedIn, isMe, permissions, intl, relationship, dispatch]);
return (
<Dropdown
disabled={menuItems.length === 0}
items={menuItems}
icon='ellipsis-v'
iconComponent={MoreHorizIcon}
className={classes.buttonMenu}
/>
);
};
interface MenuItemsParams {
account: Account;
signedIn: boolean;
permissions: number;
intl: ReturnType<typeof useIntl>;
relationship?: Relationship;
dispatch: AppDispatch;
}
const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
direct: { id: 'account.direct', defaultMessage: 'Privately mention @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
report: { id: 'account.report', defaultMessage: 'Report @{name}' },
blockDomain: {
id: 'account.block_domain',
defaultMessage: 'Block domain {domain}',
},
unblockDomain: {
id: 'account.unblock_domain',
defaultMessage: 'Unblock domain {domain}',
},
hideReblogs: {
id: 'account.hide_reblogs',
defaultMessage: 'Hide boosts from @{name}',
},
showReblogs: {
id: 'account.show_reblogs',
defaultMessage: 'Show boosts from @{name}',
},
addNote: {
id: 'account.add_note',
defaultMessage: 'Add a personal note',
},
editNote: {
id: 'account.edit_note',
defaultMessage: 'Edit personal note',
},
endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
unendorse: {
id: 'account.unendorse',
defaultMessage: "Don't feature on profile",
},
add_or_remove_from_list: {
id: 'account.add_or_remove_from_list',
defaultMessage: 'Add or Remove from lists',
},
admin_account: {
id: 'status.admin_account',
defaultMessage: 'Open moderation interface for @{name}',
},
admin_domain: {
id: 'status.admin_domain',
defaultMessage: 'Open moderation interface for {domain}',
},
languages: {
id: 'account.languages',
defaultMessage: 'Change subscribed languages',
},
openOriginalPage: {
id: 'account.open_original_page',
defaultMessage: 'Open original page',
},
removeFromFollowers: {
id: 'account.remove_from_followers',
defaultMessage: 'Remove {name} from followers',
},
confirmRemoveFromFollowersTitle: {
id: 'confirmations.remove_from_followers.title',
defaultMessage: 'Remove follower?',
},
confirmRemoveFromFollowersMessage: {
id: 'confirmations.remove_from_followers.message',
defaultMessage:
'{name} will stop following you. Are you sure you want to proceed?',
},
confirmRemoveFromFollowersButton: {
id: 'confirmations.remove_from_followers.confirm',
defaultMessage: 'Remove follower',
},
});
const redesignMessages = defineMessages({
share: { id: 'account.menu.share', defaultMessage: 'Share…' },
copy: { id: 'account.menu.copy', defaultMessage: 'Copy link' },
copied: {
id: 'account.menu.copied',
defaultMessage: 'Copied account link to clipboard',
},
mention: { id: 'account.menu.mention', defaultMessage: 'Mention' },
noteDescription: {
id: 'account.menu.note.description',
defaultMessage: 'Visible only to you',
},
direct: {
id: 'account.menu.direct',
defaultMessage: 'Privately mention',
},
mute: { id: 'account.menu.mute', defaultMessage: 'Mute account' },
unmute: {
id: 'account.menu.unmute',
defaultMessage: 'Unmute account',
},
block: { id: 'account.menu.block', defaultMessage: 'Block account' },
unblock: {
id: 'account.menu.unblock',
defaultMessage: 'Unblock account',
},
domainBlock: {
id: 'account.menu.block_domain',
defaultMessage: 'Block {domain}',
},
domainUnblock: {
id: 'account.menu.unblock_domain',
defaultMessage: 'Unblock {domain}',
},
report: { id: 'account.menu.report', defaultMessage: 'Report account' },
hideReblogs: {
id: 'account.menu.hide_reblogs',
defaultMessage: 'Hide boosts in timeline',
},
showReblogs: {
id: 'account.menu.show_reblogs',
defaultMessage: 'Show boosts in timeline',
},
addToList: {
id: 'account.menu.add_to_list',
defaultMessage: 'Add to list…',
},
openOriginalPage: {
id: 'account.menu.open_original_page',
defaultMessage: 'View on {domain}',
},
removeFollower: {
id: 'account.menu.remove_follower',
defaultMessage: 'Remove follower',
},
});
function getMenuItems({
account,
signedIn,
permissions,
intl,
relationship,
dispatch,
}: MenuItemsParams): MenuItem[] {
const items: MenuItem[] = [];
const isRemote = account.acct !== account.username;
const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
// Share and copy link options
if (account.url) {
if ('share' in navigator) {
items.push({
text: intl.formatMessage(redesignMessages.share),
action: () => {
void navigator.share({
url: account.url,
});
},
icon: ShareIcon,
});
}
items.push({
text: intl.formatMessage(redesignMessages.copy),
action: () => {
void navigator.clipboard.writeText(account.url);
dispatch(showAlert({ message: redesignMessages.copied }));
},
icon: LinkIcon,
});
}
// Open on remote page.
if (isRemote) {
items.push({
text: intl.formatMessage(redesignMessages.openOriginalPage, {
domain: remoteDomain,
}),
href: account.url,
});
}
// Mention and direct message options
if (signedIn && !account.suspended) {
items.push(
null,
{
text: intl.formatMessage(redesignMessages.mention),
action: () => {
dispatch(mentionCompose(account));
},
},
{
text: intl.formatMessage(redesignMessages.direct),
action: () => {
dispatch(directCompose(account));
},
},
null,
);
}
if (!signedIn) {
return items;
}
// List and featuring options
if (relationship?.following) {
items.push(
{
text: intl.formatMessage(redesignMessages.addToList),
action: () => {
dispatch(
openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: account.id,
},
}),
);
},
},
{
text: intl.formatMessage(
relationship.endorsed ? messages.unendorse : messages.endorse,
),
action: () => {
if (relationship.endorsed) {
dispatch(unpinAccount(account.id));
} else {
dispatch(pinAccount(account.id));
}
},
},
);
}
items.push(
{
text: intl.formatMessage(
relationship?.note ? messages.editNote : messages.addNote,
),
description: intl.formatMessage(redesignMessages.noteDescription),
action: () => {
dispatch(
openModal({
modalType: 'ACCOUNT_NOTE',
modalProps: {
accountId: account.id,
},
}),
);
},
},
null,
);
// Timeline options
if (relationship?.following && !relationship.muting) {
items.push(
{
text: intl.formatMessage(
relationship.showing_reblogs
? redesignMessages.hideReblogs
: redesignMessages.showReblogs,
),
action: () => {
dispatch(
followAccount(account.id, {
reblogs: !relationship.showing_reblogs,
}),
);
},
},
{
text: intl.formatMessage(messages.languages),
action: () => {
dispatch(
openModal({
modalType: 'SUBSCRIBED_LANGUAGES',
modalProps: {
accountId: account.id,
},
}),
);
},
},
);
}
items.push(
{
text: intl.formatMessage(
relationship?.muting ? redesignMessages.unmute : redesignMessages.mute,
),
action: () => {
if (relationship?.muting) {
dispatch(unmuteAccount(account.id));
} else {
dispatch(initMuteModal(account));
}
},
},
null,
);
if (relationship?.followed_by) {
items.push({
text: intl.formatMessage(redesignMessages.removeFollower),
action: () => {
dispatch(
openModal({
modalType: 'CONFIRM',
modalProps: {
title: intl.formatMessage(
messages.confirmRemoveFromFollowersTitle,
),
message: intl.formatMessage(
messages.confirmRemoveFromFollowersMessage,
{ name: <strong>{account.acct}</strong> },
),
confirm: intl.formatMessage(
messages.confirmRemoveFromFollowersButton,
),
onConfirm: () => {
void dispatch(
removeAccountFromFollowers({ accountId: account.id }),
);
},
},
}),
);
},
dangerous: true,
icon: PersonRemoveIcon,
});
}
items.push({
text: intl.formatMessage(
relationship?.blocking
? redesignMessages.unblock
: redesignMessages.block,
),
action: () => {
if (relationship?.blocking) {
dispatch(unblockAccount(account.id));
} else {
dispatch(initBlockModal(account));
}
},
dangerous: true,
icon: BlockIcon,
});
if (!account.suspended) {
items.push({
text: intl.formatMessage(redesignMessages.report),
action: () => {
dispatch(initReport(account));
},
dangerous: true,
icon: ReportIcon,
});
}
if (remoteDomain) {
items.push(null, {
text: intl.formatMessage(
relationship?.domain_blocking
? redesignMessages.domainUnblock
: redesignMessages.domainBlock,
{
domain: remoteDomain,
},
),
action: () => {
if (relationship?.domain_blocking) {
dispatch(unblockDomain(remoteDomain));
} else {
dispatch(initDomainBlockModal(account));
}
},
dangerous: true,
icon: BlockIcon,
iconId: 'domain-block',
});
}
if (
(permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
(isRemote &&
(permissions & PERMISSION_MANAGE_FEDERATION) ===
PERMISSION_MANAGE_FEDERATION)
) {
items.push(null);
if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
items.push({
text: intl.formatMessage(messages.admin_account, {
name: account.username,
}),
href: `/admin/accounts/${account.id}`,
});
}
if (
remoteDomain &&
(permissions & PERMISSION_MANAGE_FEDERATION) ===
PERMISSION_MANAGE_FEDERATION
) {
items.push({
text: intl.formatMessage(messages.admin_domain, {
domain: remoteDomain,
}),
href: `/admin/instances/${remoteDomain}`,
});
}
}
return items;
}
@@ -0,0 +1,46 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { useAppSelector } from '@/mastodon/store';
import { AvatarOverlay } from '../avatar_overlay';
import { DisplayName } from '../display_name';
export const MovedNote: React.FC<{
accountId: string;
targetAccountId: string;
}> = ({ accountId, targetAccountId }) => {
const from = useAppSelector((state) => state.accounts.get(accountId));
const to = useAppSelector((state) => state.accounts.get(targetAccountId));
return (
<div className='moved-account-banner'>
<div className='moved-account-banner__message'>
<FormattedMessage
id='account.moved_to'
defaultMessage='{name} has indicated that their new account is now:'
values={{
name: <DisplayName account={from} variant='simple' />,
}}
/>
</div>
<div className='moved-account-banner__action'>
<Link to={`/@${to?.acct}`} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'>
<AvatarOverlay account={to} friend={from} />
</div>
<DisplayName account={to} />
</Link>
<Link to={`/@${to?.acct}`} className='button'>
<FormattedMessage
id='account.go_to_profile'
defaultMessage='Go to profile'
/>
</Link>
</div>
</div>
);
};
@@ -0,0 +1,206 @@
import { useCallback, useId, useRef, useState } from 'react';
import type { FC } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import classNames from 'classnames';
import Overlay from 'react-overlays/esm/Overlay';
import { showAlert } from '@/mastodon/actions/alerts';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useRelationship } from '@/mastodon/hooks/useRelationship';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import AtIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import ContentCopyIcon from '@/material-icons/400-24px/content_copy.svg?react';
import HelpIcon from '@/material-icons/400-24px/help.svg?react';
import DomainIcon from '@/material-icons/400-24px/language.svg?react';
import { FollowsYouBadge } from '../badge';
import { Button } from '../button';
import { DisplayName } from '../display_name';
import { Icon } from '../icon';
import { AccountBadges } from './badges';
import classes from './styles.module.scss';
const messages = defineMessages({
lockedInfo: {
id: 'account.locked_info',
defaultMessage:
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
},
nameInfo: {
id: 'account.name_info',
defaultMessage: 'What does this mean?',
},
copied: {
id: 'copy_icon_button.copied',
defaultMessage: 'Copied to clipboard',
},
});
export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
const account = useAccount(accountId);
const me = useAppSelector((state) => state.meta.get('me') as string);
const localDomain = useAppSelector(
(state) => state.meta.get('domain') as string,
);
const relationship = useRelationship(accountId);
if (!account) {
return null;
}
const [username = '', domain = localDomain] = account.acct.split('@');
return (
<div className={classes.nameWrapper}>
<div className={classes.name}>
<h1>
<DisplayName account={account} variant='simple' />
</h1>
{relationship?.followed_by && <FollowsYouBadge />}
</div>
<AccountNameHelp
username={username}
domain={domain}
isSelf={account.id === me}
/>
<AccountBadges accountId={accountId} />
</div>
);
};
const AccountNameHelp: FC<{
username: string;
domain: string;
isSelf: boolean;
}> = ({ username, domain, isSelf }) => {
const accessibilityId = useId();
const intl = useIntl();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const handleClick = useCallback(() => {
setOpen((prev) => !prev);
}, []);
const handle = `@${username}@${domain}`;
const dispatch = useAppDispatch();
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(handle);
setCopied(true);
dispatch(showAlert({ message: messages.copied }));
setTimeout(() => {
setCopied(false);
}, 700);
}, [handle, dispatch]);
return (
<>
<button
type='button'
ref={triggerRef}
className={classes.handleHelpButton}
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
>
{handle}
<Icon
id='help'
icon={HelpIcon}
aria-label={intl.formatMessage(messages.nameInfo)}
/>
</button>
<Overlay
show={open}
rootClose
target={triggerRef}
onHide={handleClick}
offset={[5, 5]}
>
{({ props }) => (
<div
{...props}
role='region'
id={accessibilityId}
className={classNames('dropdown-animation', classes.handleHelp)}
>
<FormattedMessage
id='account.name.help.header'
defaultMessage='A handle is like an email address'
tagName='h3'
/>
<ol>
<li>
<Icon id='at' icon={AtIcon} />
{isSelf ? (
<FormattedMessage
id='account.name.help.username_self'
defaultMessage='{username} is your username on this server. Someone on another server might have the same username.'
values={{ username: <strong>{username}</strong> }}
tagName='p'
/>
) : (
<FormattedMessage
id='account.name.help.username'
defaultMessage='{username} is this account’s username on their server. Someone on another server might have the same username.'
values={{ username: <strong>{username}</strong> }}
tagName='p'
/>
)}
</li>
<li>
<Icon id='domain' icon={DomainIcon} />
{isSelf ? (
<FormattedMessage
id='account.name.help.domain_self'
defaultMessage='{domain} is your server that hosts your profile and posts.'
values={{ domain: <strong>{domain}</strong> }}
tagName='p'
/>
) : (
<FormattedMessage
id='account.name.help.domain'
defaultMessage='{domain} is the server that hosts the user’s profile and posts.'
values={{ domain: <strong>{domain}</strong> }}
tagName='p'
/>
)}
</li>
</ol>
<FormattedMessage
id='account.name.help.footer'
defaultMessage='Just like you can send emails to people using different email providers, you can interact with people on other Mastodon servers, and with anyone on other Mastodon-compatible social apps.'
tagName='p'
/>
<Button onClick={handleCopy} className={classes.handleCopy}>
<Icon id='copy' icon={ContentCopyIcon} />
{!copied && (
<FormattedMessage
id='account.name.copy'
defaultMessage='Copy handle'
/>
)}
{copied && (
<FormattedMessage
id='copypaste.copied'
defaultMessage='Copied'
/>
)}
</Button>
</div>
)}
</Overlay>
</>
);
};
@@ -0,0 +1,70 @@
import { useCallback, useEffect } from 'react';
import type { FC } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { fetchRelationships } from '@/mastodon/actions/accounts';
import { openModal } from '@/mastodon/actions/modal';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import EditIcon from '@/material-icons/400-24px/edit_square.svg?react';
import { Callout } from '../callout';
import { IconButton } from '../icon_button';
import classes from './styles.module.scss';
const messages = defineMessages({
title: {
id: 'account.note.title',
defaultMessage: 'Personal note (visible only to you)',
},
editButton: {
id: 'account.note.edit_button',
defaultMessage: 'Edit',
},
});
export const AccountNote: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const relationship = useAppSelector((state) =>
state.relationships.get(accountId),
);
const dispatch = useAppDispatch();
useEffect(() => {
if (!relationship) {
dispatch(fetchRelationships([accountId]));
}
}, [accountId, dispatch, relationship]);
const handleEdit = useCallback(() => {
dispatch(
openModal({
modalType: 'ACCOUNT_NOTE',
modalProps: { accountId },
}),
);
}, [accountId, dispatch]);
if (!relationship?.note) {
return null;
}
return (
<Callout
icon={false}
title={intl.formatMessage(messages.title)}
className={classes.note}
extraContent={
<IconButton
icon='edit'
iconComponent={EditIcon}
title={intl.formatMessage(messages.editButton)}
className={classes.noteEditButton}
onClick={handleEdit}
/>
}
>
<div className={classes.noteContent}>{relationship.note}</div>
</Callout>
);
};
@@ -0,0 +1,84 @@
import { useCallback, useMemo } from 'react';
import type { FC } from 'react';
import { FormattedMessage, useIntl } from 'react-intl';
import { openModal } from '@/mastodon/actions/modal';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAppDispatch } from '@/mastodon/store';
import { FormattedDateWrapper } from '../formatted_date';
import { NumberFields, NumberFieldsItem } from '../number_fields';
import { ShortNumber } from '../short_number';
export const AccountNumberFields: FC<{ accountId: string }> = ({
accountId,
}) => {
const intl = useIntl();
const account = useAccount(accountId);
const createdThisYear = useMemo(
() => account?.created_at.includes(new Date().getFullYear().toString()),
[account?.created_at],
);
const dispatch = useAppDispatch();
const showJoinModal = useCallback(() => {
dispatch(
openModal({ modalType: 'ACCOUNT_JOIN_DATE', modalProps: { accountId } }),
);
}, [accountId, dispatch]);
if (!account) {
return null;
}
return (
<NumberFields>
<NumberFieldsItem
label={
<FormattedMessage id='account.followers' defaultMessage='Followers' />
}
hint={intl.formatNumber(account.followers_count)}
link={`/@${account.acct}/followers`}
>
<ShortNumber value={account.followers_count} />
</NumberFieldsItem>
<NumberFieldsItem
label={
<FormattedMessage id='account.following' defaultMessage='Following' />
}
hint={intl.formatNumber(account.following_count)}
link={`/@${account.acct}/following`}
>
<ShortNumber value={account.following_count} />
</NumberFieldsItem>
<NumberFieldsItem
label={<FormattedMessage id='account.posts' defaultMessage='Posts' />}
hint={intl.formatNumber(account.statuses_count)}
>
<ShortNumber value={account.statuses_count} />
</NumberFieldsItem>
<NumberFieldsItem
label={
<FormattedMessage id='account.joined_short' defaultMessage='Joined' />
}
hint={intl.formatDate(account.created_at)}
>
<button type='button' onClick={showJoinModal}>
{createdThisYear ? (
<FormattedDateWrapper
value={account.created_at}
month='short'
day='2-digit'
/>
) : (
<FormattedDateWrapper value={account.created_at} year='numeric' />
)}
</button>
</NumberFieldsItem>
</NumberFields>
);
};
@@ -0,0 +1,424 @@
.header {
height: 120px;
background: var(--color-bg-secondary);
@container (width >= 500px) {
height: 160px;
}
}
.barWrapper {
border-bottom: none;
padding-inline: 16px;
}
.avatarWrapper {
margin-top: -64px;
padding-top: 0;
}
.displayNameWrapper {
display: flex;
align-items: start;
gap: 16px;
}
.nameWrapper {
flex-grow: 1;
}
.name {
> h1 {
display: inline;
font-size: 22px;
line-height: normal;
white-space: initial;
margin-inline-end: 4px;
}
}
.badges {
margin-top: 8px;
}
.handleHelpButton {
display: flex;
gap: 2px;
padding: 0;
margin-top: 4px;
align-items: center;
appearance: none;
background: none;
border: none;
color: var(--color-text-secondary);
font-size: 13px;
transition: color 0.2s ease-in-out;
> svg {
width: 16px;
height: 16px;
}
&:hover,
&:focus {
color: var(--color-text-brand-soft);
}
}
.handleHelp {
padding: 16px;
background: var(--color-bg-primary);
color: var(--color-text-primary);
border-radius: 12px;
box-shadow: var(--dropdown-shadow);
max-width: 400px;
box-sizing: border-box;
[data-color-scheme='dark'] & {
border: 1px solid var(--color-border-primary);
}
> h3 {
font-size: 17px;
font-weight: 600;
}
> ol {
margin: 12px 0;
}
li {
display: flex;
gap: 8px;
align-items: start;
&:first-child {
margin-bottom: 12px;
}
> svg {
background: var(--color-bg-brand-softest);
width: 28px;
height: 28px;
padding: 5px;
border-radius: 9999px;
box-sizing: border-box;
}
}
strong {
font-weight: 600;
}
}
$button-breakpoint: 420px;
$button-fallback-breakpoint: $button-breakpoint + 55px;
.buttonsDesktop {
@container (width < #{$button-breakpoint}) {
display: none;
}
@supports (not (container-type: inline-size)) {
@media (max-width: #{$button-fallback-breakpoint}) {
display: none;
}
}
}
.handleCopy {
border: 1px solid var(--color-border-primary);
border-radius: 8px;
box-sizing: border-box;
padding: 4px 8px;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
font-size: 13px;
transition:
color 0.2s ease-in-out,
background-color 0.2s ease-in-out;
margin-top: 12px;
&:active,
&:focus,
&:hover {
background-color: var(--color-bg-brand-softest);
}
}
.buttonsMobile {
position: sticky;
bottom: var(--mobile-bottom-nav-height);
padding: 12px 16px;
margin: 0 -16px;
@container (width >= #{$button-breakpoint}) {
display: none;
}
@supports (not (container-type: inline-size)) {
@media (min-width: ($button-fallback-breakpoint + 1px)) {
display: none;
}
}
// Multi-column layout
@media (width >= #{$button-breakpoint}) {
bottom: 0;
}
}
.buttonsMobileIsStuck {
background-color: var(--color-bg-primary);
border-top: 1px solid var(--color-border-primary);
}
.buttonMenu {
// Override the modal for mobile.
&:global(.actions-modal) {
max-height: none;
}
li :global(.icon) {
width: 20px;
height: 20px;
}
}
.bio {
font-size: 15px;
}
.familiarFollowers {
margin-top: 16px;
}
.note {
margin-bottom: 16px;
}
.noteContent {
white-space-collapse: preserve-breaks;
}
.noteEditButton {
color: inherit;
svg {
width: 20px;
height: 20px;
}
}
.fieldList {
--cols: 4;
position: relative;
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: 4px;
margin: 16px 0;
@container (width < 420px) {
--cols: 2;
}
}
.fieldItem {
--col-span: 1;
grid-column: span var(--col-span);
position: relative;
@for $col from 2 through 4 {
&[data-cols='#{$col}'] {
--col-span: #{$col};
}
}
dt {
font-weight: normal;
}
dd {
font-weight: 500;
}
:is(dt, dd) {
text-overflow: initial;
// Override the MiniCard link styles
a {
color: inherit;
font-weight: inherit;
&:hover,
&:focus {
color: inherit;
text-decoration: underline;
}
}
}
// See: https://stackoverflow.com/questions/13226296/is-scrollwidth-property-of-a-span-not-working-on-chrome
[data-contents] {
display: inline-block;
}
}
.fieldVerified {
background-color: var(--color-bg-success-softest);
&.fieldItem {
border-color: var(--color-border-success-soft);
}
dt {
padding-right: 24px;
}
}
.fieldVerifiedIcon {
display: block;
position: absolute;
width: 16px;
height: 16px;
top: 8px;
right: 8px;
> svg {
width: 100%;
height: 100%;
}
}
.fieldOverflowButton {
--default-bg-color: var(--color-bg-secondary);
--hover-bg-color: var(--color-bg-brand-softest);
position: absolute;
right: 8px;
padding: 0 2px;
transition: background-color 0.2s ease-in-out;
border: 2px solid var(--color-bg-primary);
> svg {
width: 16px;
height: 12px;
}
}
.modalCloseButton {
padding: 8px;
border-radius: 50%;
border: 1px solid var(--color-border-primary);
}
.modalTitle {
flex-grow: 1;
text-align: center;
}
.modalFieldsList {
padding: 16px;
}
.modalFieldItem {
&:not(:first-child) {
padding-top: 12px;
}
&:not(:last-child)::after {
content: '';
display: block;
border-bottom: 1px solid var(--color-border-primary);
margin-top: 12px;
}
dt {
color: var(--color-text-secondary);
font-size: 13px;
}
dd {
font-weight: 500;
font-size: 15px;
}
.fieldIconVerified {
vertical-align: middle;
margin-left: 4px;
}
}
.noTabs {
width: 100%;
border-width: 0 0 1px;
border-bottom: 1px solid var(--color-border-primary);
}
.bannerBase {
box-sizing: border-box;
padding: 16px;
border-radius: 12px;
background: var(--color-bg-secondary);
display: flex;
flex-direction: column;
gap: 12px;
justify-content: center;
align-items: flex-start;
margin: 16px 0;
}
.bannerBaseCentered {
min-height: 146px;
align-items: center;
.bannerTextAndActions {
text-align: center;
}
}
.bannerTextAndActions {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
font-weight: 400;
color: var(--color-text-primary);
h2 {
font-size: 17px;
font-weight: 600;
}
}
.bannerDisclaimer {
a {
color: inherit;
}
}
.bannerInputButton {
display: flex;
gap: 8px;
align-self: stretch;
align-items: flex-start;
& > div {
flex-grow: 1;
}
label {
font-weight: 400;
}
:global(.button) {
margin-top: 24px; // To align with input under label
}
input[type='email'] {
padding: 7px 8px; // To align size with button
background: var(--color-bg-primary);
}
}
@@ -0,0 +1,194 @@
import { useState, useCallback, useId } from 'react';
import { FormattedMessage, useIntl, defineMessages } from 'react-intl';
import type { IntlShape } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { AxiosError } from 'axios';
import { apiSubscribeByEmail } from '@/mastodon/api/accounts';
import type {
ValidationErrorResponse,
ValidationError,
} from '@/mastodon/api_types/errors';
import { useAppSelector } from '@/mastodon/store';
import { Button } from '../button';
import { DisplayName } from '../display_name';
import type { FieldStatus } from '../form_fields';
import { TextInputField } from '../form_fields/text_input_field';
import classes from './styles.module.scss';
const messages = defineMessages({
emailInvalid: {
id: 'email_subscriptions.validation.email.invalid',
defaultMessage: 'Invalid email address',
},
emailBlocked: {
id: 'email_subscriptions.validation.email.blocked',
defaultMessage: 'Blocked email provider',
},
email: {
id: 'email_subscriptions.email',
defaultMessage: 'Email',
},
});
const isValidationErrorResponse = (
data: unknown,
): data is ValidationErrorResponse =>
typeof data === 'object' &&
data !== null &&
'error' in data &&
'details' in data;
const fieldStatusFromErrors = (
intl: IntlShape,
errors: ValidationError[],
): FieldStatus | undefined => {
const error = errors[0];
if (!error) {
return undefined;
}
let message: string;
switch (error.error) {
case 'ERR_BLOCKED':
message = intl.formatMessage(messages.emailBlocked);
break;
case 'ERR_INVALID':
default:
message = intl.formatMessage(messages.emailInvalid);
break;
}
return { variant: 'error', message };
};
export const AccountSubscriptionForm: React.FC<{ accountId: string }> = ({
accountId,
}) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
const intl = useIntl();
const accessibilityId = useId();
const [email, setEmail] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [errors, setErrors] = useState<Record<string, ValidationError[]>>({});
const handleChange = useCallback<React.ChangeEventHandler<HTMLInputElement>>(
(e) => {
setEmail(e.target.value);
setErrors({});
},
[],
);
const handleSubmit = useCallback<React.FormEventHandler>(
(e) => {
e.preventDefault();
if (email.length === 0) {
return;
}
setSubmitting(true);
apiSubscribeByEmail(accountId, email)
.then(() => {
setSubmitting(false);
setSubmitted(true);
return '';
})
.catch((err: unknown) => {
setSubmitting(false);
if (err instanceof AxiosError && err.response) {
const data: unknown = err.response.data;
if (isValidationErrorResponse(data)) {
if (data.details.email?.some((k) => k.error === 'ERR_TAKEN')) {
setSubmitted(true);
return;
}
setErrors(data.details);
}
}
});
},
[accountId, email],
);
if (submitted) {
return (
<div
className={classNames(classes.bannerBase, classes.bannerBaseCentered)}
>
<div className={classes.bannerTextAndActions}>
<h2>
<FormattedMessage
id='email_subscriptions.submitted.title'
defaultMessage='One more step'
/>
</h2>
<FormattedMessage
id='email_subscriptions.submitted.lead'
defaultMessage='Check your inbox for an email to finish signing up for email updates.'
/>
</div>
</div>
);
}
return (
<form onSubmit={handleSubmit} className={classes.bannerBase} noValidate>
<div className={classes.bannerTextAndActions}>
<h2>
<FormattedMessage
id='email_subscriptions.form.title'
defaultMessage='Sign up for email updates from {name}'
values={{
name: <DisplayName account={account} variant='simple' />,
}}
/>
</h2>
</div>
<div className={classes.bannerInputButton}>
<TextInputField
id={`${accessibilityId}-input`}
type='email'
value={email}
onChange={handleChange}
label={intl.formatMessage(messages.email)}
status={
errors.email ? fieldStatusFromErrors(intl, errors.email) : undefined
}
/>
<Button type='submit' loading={submitting}>
<FormattedMessage
id='email_subscriptions.form.action'
defaultMessage='Subscribe'
/>
</Button>
</div>
<div className={classes.bannerDisclaimer}>
<FormattedMessage
id='email_subscriptions.form.bottom'
defaultMessage='Get posts in your inbox without creating a Mastodon account. Unsubscribe at any time. For more information, refer to the <a>Privacy Policy</a>.'
values={{ a: (str) => <Link to='/privacy-policy'>{str}</Link> }}
/>
</div>
</form>
);
};
@@ -0,0 +1,48 @@
import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
import type { NavLinkProps } from 'react-router-dom';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAccountId } from '@/mastodon/hooks/useAccountId';
import { TabLink, TabList } from '../tab_list';
import classes from './styles.module.scss';
const isActive: Required<NavLinkProps>['isActive'] = (match, location) =>
match?.url === location.pathname ||
(!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`));
export const AccountTabs: FC = () => {
const accountId = useAccountId();
const account = useAccount(accountId);
if (!account) {
return <hr className={classes.noTabs} />;
}
const { acct, show_featured, show_media } = account;
if (!show_featured && !show_media) {
return <hr className={classes.noTabs} />;
}
return (
<TabList>
<TabLink isActive={isActive} to={`/@${acct}`}>
<FormattedMessage id='account.activity' defaultMessage='Activity' />
</TabLink>
{show_media && (
<TabLink exact to={`/@${acct}/media`}>
<FormattedMessage id='account.media' defaultMessage='Media' />
</TabLink>
)}
{show_featured && (
<TabLink exact to={`/@${acct}/featured`}>
<FormattedMessage id='account.featured' defaultMessage='Featured' />
</TabLink>
)}
</TabList>
);
};
@@ -0,0 +1,36 @@
import { useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { revealAccount } from '@/mastodon/actions/accounts_typed';
import { domain } from '@/mastodon/initial_state';
import { useAppDispatch } from '@/mastodon/store';
import { Button } from './button';
export const LimitedAccountHint: React.FC<{ accountId: string }> = ({
accountId,
}) => {
const dispatch = useAppDispatch();
const reveal = useCallback(() => {
dispatch(revealAccount({ id: accountId }));
}, [dispatch, accountId]);
return (
<div className='limited-account-hint'>
<p>
<FormattedMessage
id='limited_account_hint.title'
defaultMessage='This profile has been hidden by the moderators of {domain}.'
values={{ domain }}
/>
</p>
<Button onClick={reveal}>
<FormattedMessage
id='limited_account_hint.action'
defaultMessage='Show profile anyway'
/>
</Button>
</div>
);
};
@@ -8,7 +8,7 @@ import { debounce } from 'lodash';
import { TIMELINE_GAP, TIMELINE_PINNED_VIEW_ALL, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
import { PinnedShowAllButton } from '@/mastodon/features/account_timeline/v2/pinned_statuses';
import { PinnedShowAllButton } from '@/mastodon/features/account_timeline/components/pinned_statuses';
import { StatusQuoteManager } from '../components/status_quoted';