Remove and move profile code (#38863)
This commit is contained in:
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';
|
||||
|
||||
|
||||
Reference in new issue
Block a user