- {me !== account.id && relationship && (
+
+ {me !== account.id && relationship && !isRedesign && (
)}
@@ -113,8 +135,18 @@ export const AccountHeader: React.FC<{
)}
-
-
+
+
- {!isRedesignEnabled() && (
+ {!isRedesign && (
-
- {isRedesignEnabled() && }
+
+ {isRedesign && (
+
+ )}
- {me && account.id !== me && !suspendedOrHidden && (
+ {!isMe && !suspendedOrHidden && (
)}
-
+ {!isRedesign && (
+
+ )}
{!suspendedOrHidden && (
- {me && account.id !== me && (
-
+ {me &&
+ account.id !== me &&
+ (isRedesign ? (
+
+ ) : (
+
+ ))}
+
+ {(!isRedesign || layout === 'single-column') && (
+ <>
+
+
+ >
)}
-
-
-
-
)}
+
+ {isRedesign && (
+
+ )}
{!hideTabs && !hidden &&
}
+
{titleFromAccount(account)}
diff --git a/app/javascript/mastodon/features/account_timeline/components/account_name.tsx b/app/javascript/mastodon/features/account_timeline/components/account_name.tsx
index 67815bf141b4d6..0733699242c69d 100644
--- a/app/javascript/mastodon/features/account_timeline/components/account_name.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/account_name.tsx
@@ -1,12 +1,19 @@
+import { useCallback, useId, useRef, useState } from 'react';
import type { FC } from 'react';
-import { useIntl } from 'react-intl';
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import classNames from 'classnames';
+
+import Overlay from 'react-overlays/esm/Overlay';
import { DisplayName } from '@/mastodon/components/display_name';
import { Icon } from '@/mastodon/components/icon';
import { useAccount } from '@/mastodon/hooks/useAccount';
import { useAppSelector } from '@/mastodon/store';
-import InfoIcon from '@/material-icons/400-24px/info.svg?react';
+import AtIcon from '@/material-icons/400-24px/alternate_email.svg?react';
+import HelpIcon from '@/material-icons/400-24px/help.svg?react';
+import DomainIcon from '@/material-icons/400-24px/language.svg?react';
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
import { DomainPill } from '../../account/components/domain_pill';
@@ -14,10 +21,19 @@ import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
-export const AccountName: FC<{ accountId: string; className?: string }> = ({
- accountId,
- className,
-}) => {
+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?',
+ },
+});
+
+export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const account = useAccount(accountId);
const me = useAppSelector((state) => state.meta.get('me') as string);
@@ -31,39 +47,151 @@ export const AccountName: FC<{ accountId: string; className?: string }> = ({
const [username = '', domain = localDomain] = account.acct.split('@');
- return (
-
-
-
-
- @{username}
- {isRedesignEnabled() && '@'}
-
- {!isRedesignEnabled() && '@'}
- {domain}
+ if (!isRedesignEnabled()) {
+ return (
+
+
+
+
+ @{username}
+ @{domain}
-
-
+ {account.locked && (
+
+ )}
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ @{username}@{domain}
+
- {isRedesignEnabled() && }
-
- {!isRedesignEnabled() && account.locked && (
-
+ isSelf={account.id === me}
+ />
+
+
+ );
+};
+
+const AccountNameHelp: FC<{
+ username: string;
+ domain: string;
+ software: string;
+ isSelf: boolean;
+}> = ({ username, domain, software, isSelf }) => {
+ const accessibilityId = useId();
+ const intl = useIntl();
+ const [open, setOpen] = useState(false);
+ const triggerRef = useRef(null);
+
+ const handleClick = useCallback(() => {
+ setOpen((prev) => !prev);
+ }, []);
+
+ return (
+ <>
+
+
+
+ {({ props }) => (
+
+
+
+ -
+
+ {isSelf ? (
+ {username} }}
+ tagName='p'
+ />
+ ) : (
+ {username} }}
+ tagName='p'
+ />
+ )}
+
+ -
+
+ {isSelf ? (
+ {domain} }}
+ tagName='p'
+ />
+ ) : (
+ {domain},
+ software: {software},
+ }}
+ tagName='p'
+ />
+ )}
+
+
+
+
)}
-
-
+
+ >
);
};
diff --git a/app/javascript/mastodon/features/account_timeline/components/badges.tsx b/app/javascript/mastodon/features/account_timeline/components/badges.tsx
index 1c5942d90d33ee..9bfc3b5da53008 100644
--- a/app/javascript/mastodon/features/account_timeline/components/badges.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/badges.tsx
@@ -1,11 +1,24 @@
-import type { FC, ReactNode } from 'react';
+import { useEffect } from 'react';
+import type { FC } from 'react';
-import IconAdmin from '@/images/icons/icon_admin.svg?react';
-import { AutomatedBadge, Badge, GroupBadge } from '@/mastodon/components/badge';
+import { FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+
+import IconPinned from '@/images/icons/icon_pinned.svg?react';
+import { fetchRelationships } from '@/mastodon/actions/accounts';
+import {
+ AdminBadge,
+ AutomatedBadge,
+ Badge,
+ BlockedBadge,
+ GroupBadge,
+ MutedBadge,
+} from '@/mastodon/components/badge';
import { Icon } from '@/mastodon/components/icon';
import { useAccount } from '@/mastodon/hooks/useAccount';
import type { AccountRole } from '@/mastodon/models/account';
-import { useAppSelector } from '@/mastodon/store';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { isRedesignEnabled } from '../common';
@@ -16,42 +29,94 @@ export const AccountBadges: FC<{ accountId: string }> = ({ 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 className = isRedesignEnabled() ? classes.badge : '';
-
- if (account.bot) {
- badges.push();
- } else if (account.group) {
- badges.push();
- }
+ const isRedesign = isRedesignEnabled();
+ const className = isRedesign ? classes.badge : '';
const domain = account.acct.includes('@')
? account.acct.split('@')[1]
: localDomain;
account.roles.forEach((role) => {
- let icon: ReactNode = undefined;
if (isAdminBadge(role)) {
- icon = (
-
+ badges.push(
+ ,
+ );
+ } else {
+ badges.push(
+ ,
);
}
- badges.push(
- ,
- );
});
+ if (account.bot) {
+ badges.push();
+ }
+ if (account.group) {
+ badges.push();
+ }
+ if (isRedesign && relationship) {
+ if (relationship.blocking) {
+ badges.push(
+ ,
+ );
+ }
+ if (relationship.domain_blocking) {
+ badges.push(
+
+ }
+ />,
+ );
+ }
+ if (relationship.muting) {
+ badges.push(
+ ,
+ );
+ }
+ }
+
if (!badges.length) {
return null;
}
@@ -59,6 +124,16 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
return {badges}
;
};
+export const PinnedBadge: FC = () => (
+ }
+ label={
+
+ }
+ />
+);
+
function isAdminBadge(role: AccountRole) {
const name = role.name.toLowerCase();
return isRedesignEnabled() && (name === 'admin' || name === 'owner');
diff --git a/app/javascript/mastodon/features/account_timeline/components/buttons.tsx b/app/javascript/mastodon/features/account_timeline/components/buttons.tsx
index c998d1472cea0d..fe0bbbfbd620e3 100644
--- a/app/javascript/mastodon/features/account_timeline/components/buttons.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/buttons.tsx
@@ -16,6 +16,8 @@ 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 { isRedesignEnabled } from '../common';
+
import { AccountMenu } from './menu';
const messages = defineMessages({
@@ -35,12 +37,14 @@ interface AccountButtonsProps {
accountId: string;
className?: string;
noShare?: boolean;
+ forceMenu?: boolean;
}
export const AccountButtons: FC = ({
accountId,
className,
noShare,
+ forceMenu,
}) => {
const hidden = useAppSelector((state) => getAccountHidden(state, accountId));
const me = useAppSelector((state) => state.meta.get('me') as string);
@@ -50,7 +54,7 @@ export const AccountButtons: FC = ({
{!hidden && (
)}
- {accountId !== me && }
+ {(accountId !== me || forceMenu) && }
);
};
@@ -93,6 +97,7 @@ const AccountButtonsOther: FC<
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
+ withUnmute={!isRedesignEnabled()}
/>
)}
{isFollowing && (
diff --git a/app/javascript/mastodon/features/account_timeline/components/fields.tsx b/app/javascript/mastodon/features/account_timeline/components/fields.tsx
index a73d92c1b6988e..5d22acc09a2f45 100644
--- a/app/javascript/mastodon/features/account_timeline/components/fields.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/fields.tsx
@@ -1,18 +1,25 @@
-import { useCallback, useMemo } from 'react';
-import type { FC } from 'react';
+import { useCallback, useMemo, useState } from 'react';
+import type { FC, Key } from 'react';
-import { FormattedMessage } from 'react-intl';
+import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
-import { openModal } from '@/mastodon/actions/modal';
+import classNames from 'classnames';
+
+import htmlConfig from '@/config/html-tags.json';
+import IconVerified from '@/images/icons/icon_verified.svg?react';
import { AccountFields } from '@/mastodon/components/account_fields';
+import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
+import type { EmojiHTMLProps } from '@/mastodon/components/emoji/html';
import { EmojiHTML } from '@/mastodon/components/emoji/html';
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
-import { MiniCardList } from '@/mastodon/components/mini_card/list';
+import { Icon } from '@/mastodon/components/icon';
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
import { useAccount } from '@/mastodon/hooks/useAccount';
import type { Account } from '@/mastodon/models/account';
-import { useAppDispatch } from '@/mastodon/store';
+import { isValidUrl } from '@/mastodon/utils/checks';
+import type { OnElementHandler } from '@/mastodon/utils/html';
+import { cleanExtraEmojis } from '../../emoji/normalize';
import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
@@ -51,47 +58,164 @@ export const AccountHeaderFields: FC<{ accountId: string }> = ({
);
};
+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',
+};
+
const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
- const htmlHandlers = useElementHandledLink();
- const cards = useMemo(
- () =>
- account.fields.toArray().map(({ value_emojified, name_emojified }) => ({
- label: (
-
- ),
- value: (
-
- ),
- })),
- [account.emojis, account.fields, htmlHandlers],
+ const emojis = useMemo(
+ () => cleanExtraEmojis(account.emojis),
+ [account.emojis],
);
+ const textHasCustomEmoji = useCallback(
+ (text: string) => {
+ if (!emojis) {
+ return false;
+ }
+ for (const emoji of Object.keys(emojis)) {
+ if (text.includes(`:${emoji}:`)) {
+ return true;
+ }
+ }
+ return false;
+ },
+ [emojis],
+ );
+ const htmlHandlers = useElementHandledLink({
+ hashtagAccountId: account.id,
+ });
+ const intl = useIntl();
- const dispatch = useAppDispatch();
- const handleOverflowClick = useCallback(() => {
- dispatch(
- openModal({
- modalType: 'ACCOUNT_FIELDS',
- modalProps: { accountId: account.id },
- }),
- );
- }, [account.id, dispatch]);
+ return (
+
+
+ {account.fields.map(
+ (
+ { name, name_emojified, value_emojified, value_plain, verified_at },
+ key,
+ ) => (
+
+
+
+ {verified_at && (
+
+ )}
+
+ ),
+ )}
+
+
+ );
+};
+
+const FieldHTML: FC<
+ {
+ as: 'dd' | 'dt';
+ text: string;
+ textEmojified: string;
+ textHasCustomEmoji: boolean;
+ titleLength: number;
+ } & Omit
+> = ({
+ as,
+ className,
+ extraEmojis,
+ text,
+ textEmojified,
+ textHasCustomEmoji,
+ titleLength,
+ onElement,
+ ...props
+}) => {
+ const [showAll, setShowAll] = useState(false);
+ const handleClick = useCallback(() => {
+ setShowAll((prev) => !prev);
+ }, []);
+ const handleElement: OnElementHandler = useCallback(
+ (element, props, children, extra) => {
+ if (element instanceof HTMLAnchorElement) {
+ // Don't allow custom emoji and links in the same field to prevent verification spoofing.
+ if (textHasCustomEmoji) {
+ return (
+
+ {children}
+
+ );
+ }
+ return onElement?.(element, props, children, extra);
+ }
+ return undefined;
+ },
+ [onElement, textHasCustomEmoji],
+ );
return (
-
);
};
+
+function filterAttributesForSpan(props: Record) {
+ const validAttributes: Record = {};
+ for (const key of Object.keys(props)) {
+ if (key in htmlConfig.tags.span.attributes) {
+ validAttributes[key] = props[key];
+ }
+ }
+ return validAttributes;
+}
+
+function showTitleOnLength(value: string | null, maxLength: number) {
+ if (value && value.length > maxLength) {
+ return value;
+ }
+ return undefined;
+}
diff --git a/app/javascript/mastodon/features/account_timeline/components/fields_modal.tsx b/app/javascript/mastodon/features/account_timeline/components/fields_modal.tsx
deleted file mode 100644
index 715f6097f45bca..00000000000000
--- a/app/javascript/mastodon/features/account_timeline/components/fields_modal.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import type { FC } from 'react';
-
-import { FormattedMessage, useIntl } from 'react-intl';
-
-import { DisplayName } from '@/mastodon/components/display_name';
-import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
-import { EmojiHTML } from '@/mastodon/components/emoji/html';
-import { IconButton } from '@/mastodon/components/icon_button';
-import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
-import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
-import { useAccount } from '@/mastodon/hooks/useAccount';
-import CloseIcon from '@/material-icons/400-24px/close.svg?react';
-
-import classes from './redesign.module.scss';
-
-export const AccountFieldsModal: FC<{
- accountId: string;
- onClose: () => void;
-}> = ({ accountId, onClose }) => {
- const intl = useIntl();
- const account = useAccount(accountId);
- const htmlHandlers = useElementHandledLink();
-
- if (!account) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
-
- ,
- }}
- />
-
-
-
-
-
- {account.fields.map((field, index) => (
-
-
-
-
- ))}
-
-
-
-
- );
-};
diff --git a/app/javascript/mastodon/features/account_timeline/components/menu.tsx b/app/javascript/mastodon/features/account_timeline/components/menu.tsx
index e366a73568a8af..33c422c2c874af 100644
--- a/app/javascript/mastodon/features/account_timeline/components/menu.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/menu.tsx
@@ -12,6 +12,7 @@ import {
unpinAccount,
} from '@/mastodon/actions/accounts';
import { removeAccountFromFollowers } from '@/mastodon/actions/accounts_typed';
+import { showAlert } from '@/mastodon/actions/alerts';
import { directCompose, mentionCompose } from '@/mastodon/actions/compose';
import {
initDomainBlockModal,
@@ -23,13 +24,80 @@ import { initReport } from '@/mastodon/actions/reports';
import { Dropdown } from '@/mastodon/components/dropdown_menu';
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 { isRedesignEnabled } from '../common';
+
+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 [];
+ }
+
+ if (isRedesignEnabled()) {
+ return redesignMenuItems({
+ account,
+ signedIn: !isMe && signedIn,
+ permissions,
+ intl,
+ relationship,
+ dispatch,
+ });
+ }
+ return currentMenuItems({
+ account,
+ signedIn,
+ permissions,
+ intl,
+ relationship,
+ dispatch,
+ });
+ }, [account, signedIn, isMe, permissions, intl, relationship, dispatch]);
+ return (
+
+ );
+};
+
+interface MenuItemsParams {
+ account: Account;
+ signedIn: boolean;
+ permissions: number;
+ intl: ReturnType;
+ relationship?: Relationship;
+ dispatch: AppDispatch;
+}
const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
@@ -55,6 +123,14 @@ const messages = defineMessages({
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',
@@ -111,80 +187,78 @@ const messages = defineMessages({
},
});
-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),
- );
+function currentMenuItems({
+ account,
+ signedIn,
+ permissions,
+ intl,
+ relationship,
+ dispatch,
+}: MenuItemsParams): MenuItem[] {
+ const items: MenuItem[] = [];
+ const isRemote = account.acct !== account.username;
- const dispatch = useAppDispatch();
- const menuItems = useMemo(() => {
- const arr: MenuItem[] = [];
-
- if (!account) {
- return arr;
- }
-
- const isRemote = account.acct !== account.username;
-
- if (signedIn && !account.suspended) {
- arr.push({
+ if (signedIn && !account.suspended) {
+ items.push(
+ {
text: intl.formatMessage(messages.mention, {
name: account.username,
}),
action: () => {
dispatch(mentionCompose(account));
},
- });
- arr.push({
+ },
+ {
text: intl.formatMessage(messages.direct, {
name: account.username,
}),
action: () => {
dispatch(directCompose(account));
},
- });
- arr.push(null);
- }
+ },
+ null,
+ );
+ }
- if (isRemote) {
- arr.push({
+ if (isRemote) {
+ items.push(
+ {
text: intl.formatMessage(messages.openOriginalPage),
href: account.url,
- });
- arr.push(null);
- }
+ },
+ null,
+ );
+ }
- if (!signedIn) {
- return arr;
- }
+ if (!signedIn) {
+ return items;
+ }
- if (relationship?.following) {
- if (!relationship.muting) {
- if (relationship.showing_reblogs) {
- arr.push({
- text: intl.formatMessage(messages.hideReblogs, {
- name: account.username,
- }),
- action: () => {
- dispatch(followAccount(account.id, { reblogs: false }));
- },
- });
- } else {
- arr.push({
- text: intl.formatMessage(messages.showReblogs, {
- name: account.username,
- }),
- action: () => {
- dispatch(followAccount(account.id, { reblogs: true }));
- },
- });
- }
+ if (relationship?.following) {
+ // Timeline options
+ if (!relationship.muting) {
+ if (relationship.showing_reblogs) {
+ items.push({
+ text: intl.formatMessage(messages.hideReblogs, {
+ name: account.username,
+ }),
+ action: () => {
+ dispatch(followAccount(account.id, { reblogs: false }));
+ },
+ });
+ } else {
+ items.push({
+ text: intl.formatMessage(messages.showReblogs, {
+ name: account.username,
+ }),
+ action: () => {
+ dispatch(followAccount(account.id, { reblogs: true }));
+ },
+ });
+ }
- arr.push({
+ items.push(
+ {
text: intl.formatMessage(messages.languages),
action: () => {
dispatch(
@@ -196,11 +270,13 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
}),
);
},
- });
- arr.push(null);
- }
+ },
+ null,
+ );
+ }
- arr.push({
+ items.push(
+ {
text: intl.formatMessage(
relationship.endorsed ? messages.unendorse : messages.endorse,
),
@@ -211,8 +287,8 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
dispatch(pinAccount(account.id));
}
},
- });
- arr.push({
+ },
+ {
text: intl.formatMessage(messages.add_or_remove_from_list),
action: () => {
dispatch(
@@ -224,38 +300,414 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
}),
);
},
+ },
+ null,
+ );
+ }
+
+ if (relationship?.followed_by) {
+ const handleRemoveFromFollowers = () => {
+ dispatch(
+ openModal({
+ modalType: 'CONFIRM',
+ modalProps: {
+ title: intl.formatMessage(messages.confirmRemoveFromFollowersTitle),
+ message: intl.formatMessage(
+ messages.confirmRemoveFromFollowersMessage,
+ { name: {account.acct} },
+ ),
+ confirm: intl.formatMessage(
+ messages.confirmRemoveFromFollowersButton,
+ ),
+ onConfirm: () => {
+ void dispatch(
+ removeAccountFromFollowers({ accountId: account.id }),
+ );
+ },
+ },
+ }),
+ );
+ };
+
+ items.push({
+ text: intl.formatMessage(messages.removeFromFollowers, {
+ name: account.username,
+ }),
+ action: handleRemoveFromFollowers,
+ dangerous: true,
+ });
+ }
+
+ if (relationship?.muting) {
+ items.push({
+ text: intl.formatMessage(messages.unmute, {
+ name: account.username,
+ }),
+ action: () => {
+ dispatch(unmuteAccount(account.id));
+ },
+ });
+ } else {
+ items.push({
+ text: intl.formatMessage(messages.mute, {
+ name: account.username,
+ }),
+ action: () => {
+ dispatch(initMuteModal(account));
+ },
+ dangerous: true,
+ });
+ }
+
+ if (relationship?.blocking) {
+ items.push({
+ text: intl.formatMessage(messages.unblock, {
+ name: account.username,
+ }),
+ action: () => {
+ dispatch(unblockAccount(account.id));
+ },
+ });
+ } else {
+ items.push({
+ text: intl.formatMessage(messages.block, {
+ name: account.username,
+ }),
+ action: () => {
+ dispatch(blockAccount(account.id));
+ },
+ dangerous: true,
+ });
+ }
+
+ if (!account.suspended) {
+ items.push({
+ text: intl.formatMessage(messages.report, {
+ name: account.username,
+ }),
+ action: () => {
+ dispatch(initReport(account));
+ },
+ dangerous: true,
+ });
+ }
+
+ const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
+ if (remoteDomain) {
+ items.push(null);
+
+ if (relationship?.domain_blocking) {
+ items.push({
+ text: intl.formatMessage(messages.unblockDomain, {
+ domain: remoteDomain,
+ }),
+ action: () => {
+ dispatch(unblockDomain(remoteDomain));
+ },
+ });
+ } else {
+ items.push({
+ text: intl.formatMessage(messages.blockDomain, {
+ domain: remoteDomain,
+ }),
+ action: () => {
+ dispatch(initDomainBlockModal(account));
+ },
+ dangerous: true,
});
}
+ }
- arr.push({
- text: intl.formatMessage(messages.add_or_remove_from_antenna),
+ 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 (
+ isRemote &&
+ (permissions & PERMISSION_MANAGE_FEDERATION) ===
+ PERMISSION_MANAGE_FEDERATION
+ ) {
+ items.push({
+ text: intl.formatMessage(messages.admin_domain, {
+ domain: remoteDomain,
+ }),
+ href: `/admin/instances/${remoteDomain}`,
+ });
+ }
+ }
+
+ return items;
+}
+
+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 redesignMenuItems({
+ 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: () => {
- dispatch(
- openModal({
- modalType: 'ANTENNA_ADDER',
- modalProps: {
- accountId: account.id,
- isExclude: false,
- },
- }),
- );
+ 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,
});
- arr.push({
- text: intl.formatMessage(messages.add_or_remove_from_exclude_antenna),
+ }
+
+ // 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: 'ANTENNA_ADDER',
+ modalType: 'ACCOUNT_NOTE',
modalProps: {
accountId: account.id,
- isExclude: true,
},
}),
);
},
- });
- arr.push({
+ },
+ null,
+ );
+
+ // Timeline options
+ if (relationship && !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(messages.add_or_remove_from_antenna),
+ action: () => {
+ dispatch(
+ openModal({
+ modalType: 'ANTENNA_ADDER',
+ modalProps: {
+ accountId: account.id,
+ isExclude: false,
+ },
+ }),
+ );
+ },
+ });
+ items.push({
+ text: intl.formatMessage(messages.add_or_remove_from_exclude_antenna),
+ action: () => {
+ dispatch(
+ openModal({
+ modalType: 'ANTENNA_ADDER',
+ modalProps: {
+ accountId: account.id,
+ isExclude: true,
+ },
+ }),
+ );
+ },
+ });
+
+ 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(messages.add_or_remove_from_circle),
action: () => {
dispatch(
@@ -268,10 +720,9 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
);
},
});
- arr.push(null);
-
- if (relationship?.followed_by) {
- const handleRemoveFromFollowers = () => {
+ items.push({
+ text: intl.formatMessage(redesignMessages.removeFollower),
+ action: () => {
dispatch(
openModal({
modalType: 'CONFIRM',
@@ -294,134 +745,91 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
},
}),
);
- };
+ },
+ dangerous: true,
+ icon: PersonRemoveIcon,
+ });
+ }
- arr.push({
- text: intl.formatMessage(messages.removeFromFollowers, {
- name: account.username,
- }),
- action: handleRemoveFromFollowers,
- dangerous: true,
- });
- }
+ items.push({
+ text: intl.formatMessage(
+ relationship?.blocking
+ ? redesignMessages.unblock
+ : redesignMessages.block,
+ ),
+ action: () => {
+ if (relationship?.blocking) {
+ dispatch(unblockAccount(account.id));
+ } else {
+ dispatch(blockAccount(account.id));
+ }
+ },
+ dangerous: true,
+ icon: BlockIcon,
+ });
- if (relationship?.muting) {
- arr.push({
- text: intl.formatMessage(messages.unmute, {
- name: account.username,
- }),
- action: () => {
- dispatch(unmuteAccount(account.id));
- },
- });
- } else {
- arr.push({
- text: intl.formatMessage(messages.mute, {
- name: account.username,
- }),
- action: () => {
- dispatch(initMuteModal(account));
- },
- dangerous: true,
- });
- }
+ if (!account.suspended) {
+ items.push({
+ text: intl.formatMessage(redesignMessages.report),
+ action: () => {
+ dispatch(initReport(account));
+ },
+ dangerous: true,
+ icon: ReportIcon,
+ });
+ }
- if (relationship?.blocking) {
- arr.push({
- text: intl.formatMessage(messages.unblock, {
- name: account.username,
- }),
- action: () => {
- dispatch(unblockAccount(account.id));
+ if (remoteDomain) {
+ items.push(null, {
+ text: intl.formatMessage(
+ relationship?.domain_blocking
+ ? redesignMessages.domainUnblock
+ : redesignMessages.domainBlock,
+ {
+ domain: remoteDomain,
},
- });
- } else {
- arr.push({
- text: intl.formatMessage(messages.block, {
- name: account.username,
- }),
- action: () => {
- dispatch(blockAccount(account.id));
- },
- dangerous: true,
- });
- }
+ ),
+ action: () => {
+ if (relationship?.domain_blocking) {
+ dispatch(unblockDomain(remoteDomain));
+ } else {
+ dispatch(initDomainBlockModal(account));
+ }
+ },
+ dangerous: true,
+ icon: BlockIcon,
+ iconId: 'domain-block',
+ });
+ }
- if (!account.suspended) {
- arr.push({
- text: intl.formatMessage(messages.report, {
+ 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,
}),
- action: () => {
- dispatch(initReport(account));
- },
- dangerous: true,
+ href: `/admin/accounts/${account.id}`,
});
}
-
- const remoteDomain = isRemote ? account.acct.split('@')[1] : null;
- if (remoteDomain) {
- arr.push(null);
-
- if (relationship?.domain_blocking) {
- arr.push({
- text: intl.formatMessage(messages.unblockDomain, {
- domain: remoteDomain,
- }),
- action: () => {
- dispatch(unblockDomain(remoteDomain));
- },
- });
- } else {
- arr.push({
- text: intl.formatMessage(messages.blockDomain, {
- domain: remoteDomain,
- }),
- action: () => {
- dispatch(initDomainBlockModal(account));
- },
- dangerous: true,
- });
- }
- }
-
if (
- (permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS ||
- (isRemote &&
- (permissions & PERMISSION_MANAGE_FEDERATION) ===
- PERMISSION_MANAGE_FEDERATION)
+ remoteDomain &&
+ (permissions & PERMISSION_MANAGE_FEDERATION) ===
+ PERMISSION_MANAGE_FEDERATION
) {
- arr.push(null);
- if ((permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS) {
- arr.push({
- text: intl.formatMessage(messages.admin_account, {
- name: account.username,
- }),
- href: `/admin/accounts/${account.id}`,
- });
- }
- if (
- isRemote &&
- (permissions & PERMISSION_MANAGE_FEDERATION) ===
- PERMISSION_MANAGE_FEDERATION
- ) {
- arr.push({
- text: intl.formatMessage(messages.admin_domain, {
- domain: remoteDomain,
- }),
- href: `/admin/instances/${remoteDomain}`,
- });
- }
+ items.push({
+ text: intl.formatMessage(messages.admin_domain, {
+ domain: remoteDomain,
+ }),
+ href: `/admin/instances/${remoteDomain}`,
+ });
}
+ }
- return arr;
- }, [account, signedIn, permissions, intl, relationship, dispatch]);
- return (
-
- );
-};
+ return items;
+}
diff --git a/app/javascript/mastodon/features/account_timeline/components/note.tsx b/app/javascript/mastodon/features/account_timeline/components/note.tsx
new file mode 100644
index 00000000000000..b344e81d6bf800
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/note.tsx
@@ -0,0 +1,69 @@
+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 { Callout } from '@/mastodon/components/callout';
+import { IconButton } from '@/mastodon/components/icon_button';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+import EditIcon from '@/material-icons/400-24px/edit_square.svg?react';
+
+import classes from './redesign.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 (
+
+ }
+ >
+ {relationship.note}
+
+ );
+};
diff --git a/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx b/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx
index cf569b9e2b93bc..978707ef62b8bf 100644
--- a/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx
@@ -73,24 +73,22 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
{isRedesignEnabled() && (
-
-
-
-
- ),
- }}
- />
-
+
+
+
+ ),
+ }}
+ />
)}
);
diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss
index 5ccdb1f310019d..a1ee8275075c03 100644
--- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss
+++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss
@@ -1,5 +1,24 @@
+.header {
+ height: 120px;
+ background: var(--color-bg-secondary);
+
+ @container (width >= 500px) {
+ height: 160px;
+ }
+}
+
+.barWrapper {
+ border-bottom: none;
+}
+
+.avatarWrapper {
+ margin-top: -64px;
+ padding-top: 0;
+}
+
.nameWrapper {
display: flex;
+ align-items: start;
gap: 16px;
}
@@ -7,43 +26,134 @@
flex-grow: 1;
font-size: 22px;
white-space: initial;
- text-overflow: initial;
line-height: normal;
- :global(.icon-info) {
- margin-left: 2px;
- width: 1em;
- height: 1em;
- align-self: center;
+ > h1 {
+ white-space: initial;
}
}
-// Overrides .account__header__tabs__name h1 small
-h1.name > small {
- gap: 0;
+.username {
+ display: flex;
+ font-size: 13px;
+ color: var(--color-text-secondary);
+ align-items: center;
+ user-select: all;
+ margin-top: 4px;
}
-.domainPill {
+.handleHelpButton {
appearance: none;
border: none;
background: none;
padding: 0;
- text-decoration: underline;
color: inherit;
font-size: 1em;
- font-weight: initial;
+ margin-left: 2px;
+ width: 16px;
+ height: 16px;
+ transition: color 0.2s ease-in-out;
+
+ > svg {
+ width: 100%;
+ height: 100%;
+ }
+
+ &: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;
+
+ > 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-softer);
+ width: 28px;
+ height: 28px;
+ padding: 5px;
+ border-radius: 9999px;
+ box-sizing: border-box;
+ }
- &:global(.active) {
- background: none;
- color: inherit;
+ 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;
+ }
+ }
+}
+
+.buttonsMobile {
+ position: sticky;
+ bottom: var(--mobile-bottom-nav-height);
+ padding: 12px 16px;
+ margin: 0 -20px;
+
+ @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);
+}
+
.badge {
background-color: var(--color-bg-secondary);
border: none;
color: var(--color-text-secondary);
- font-weight: 600;
+ font-weight: 500;
> span {
font-weight: unset;
@@ -51,23 +161,119 @@ h1.name > small {
}
}
+.badgeMuted {
+ background-color: var(--color-bg-inverted);
+ color: var(--color-text-on-inverted);
+}
+
+.badgeBlocked {
+ background-color: var(--color-bg-error-base);
+ color: var(--color-text-on-error-base);
+}
+
svg.badgeIcon {
opacity: 1;
- fill: revert-layer;
+}
+
+.note {
+ margin-bottom: 16px;
+}
- path {
- fill: revert-layer;
+.noteContent {
+ white-space-collapse: preserve-breaks;
+}
+
+.noteEditButton {
+ color: inherit;
+
+ svg {
+ width: 20px;
+ height: 20px;
}
}
.fieldList {
- margin-top: 16px;
+ display: grid;
+ grid-template-columns: 160px 1fr min-content;
+ column-gap: 12px;
+ margin: 4px 0 16px;
+
+ @container (width < 420px) {
+ grid-template-columns: 100px 1fr min-content;
+ }
+}
+
+.fieldRow {
+ display: grid;
+ grid-column: 1 / -1;
+ align-items: start;
+ grid-template-columns: subgrid;
+ padding: 0 4px;
+
+ > :is(dt, dd) {
+ margin: 8px 0;
+
+ &:not(.fieldShowAll) {
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ }
+
+ > dt {
+ color: var(--color-text-secondary);
+ }
+
+ &:not(.fieldVerified) > dd {
+ grid-column: span 2;
+ }
+
+ a {
+ font-weight: 500;
+ color: var(--color-text-brand);
+ text-decoration: none;
+ transition: 0.2s ease-in-out;
+
+ &:hover,
+ &:focus {
+ color: var(--color-text-brand-soft);
+ }
+ }
+}
+
+.fieldVerified {
+ background-color: var(--color-bg-brand-softer);
+}
+
+.fieldLink:is(dd, dt) {
+ margin: 0;
+}
+
+.fieldLink > a {
+ display: block;
+ padding: 8px 0;
+}
+
+.fieldVerifiedIcon {
+ width: 16px;
+ height: 16px;
+ margin-top: 8px;
}
.fieldNumbersWrapper {
+ padding: 0;
+
a {
font-weight: unset;
}
+
+ strong {
+ font-weight: 600;
+ color: var(--color-text-primary);
+ }
}
.modalCloseButton {
@@ -103,7 +309,44 @@ svg.badgeIcon {
}
dd {
- font-weight: 600;
+ font-weight: 500;
+ font-size: 15px;
+ }
+
+ .fieldIconVerified {
+ vertical-align: middle;
+ margin-left: 4px;
+ }
+}
+
+.tabs {
+ border-bottom: 1px solid var(--color-border-primary);
+ display: flex;
+ gap: 12px;
+ padding: 0 12px;
+
+ @container (width >= 500px) {
+ padding: 0 24px;
+ }
+
+ a {
+ display: block;
font-size: 15px;
+ font-weight: 500;
+ padding: 18px 4px;
+ text-decoration: none;
+ color: var(--color-text-primary);
+ border-radius: 0;
+ transition: color 0.2s ease-in-out;
+
+ &:not([aria-current='page']):is(:hover, :focus) {
+ color: var(--color-text-brand-soft);
+ }
+ }
+
+ :global(.active) {
+ color: var(--color-text-brand);
+ border-bottom: 4px solid var(--color-text-brand);
+ padding-bottom: 14px;
}
}
diff --git a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx
index c08de1390ec4b4..b718839ed89048 100644
--- a/app/javascript/mastodon/features/account_timeline/components/tabs.tsx
+++ b/app/javascript/mastodon/features/account_timeline/components/tabs.tsx
@@ -2,9 +2,37 @@ import type { FC } from 'react';
import { FormattedMessage } from 'react-intl';
+import type { NavLinkProps } from 'react-router-dom';
import { NavLink } from 'react-router-dom';
+import { useLayout } from '@/mastodon/hooks/useLayout';
+
+import { isRedesignEnabled } from '../common';
+
+import classes from './redesign.module.scss';
+
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
+ const { layout } = useLayout();
+ if (isRedesignEnabled()) {
+ return (
+
+ {layout !== 'single-column' && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+ }
return (
@@ -25,3 +53,7 @@ export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
);
};
+
+const isActive: Required
['isActive'] = (match, location) =>
+ match?.url === location.pathname ||
+ (!!match?.url && location.pathname.startsWith(`${match.url}/tagged/`));
diff --git a/app/javascript/mastodon/features/account_timeline/modals/modals.module.css b/app/javascript/mastodon/features/account_timeline/modals/modals.module.css
new file mode 100644
index 00000000000000..cee0bc498a95e6
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/modals/modals.module.css
@@ -0,0 +1,21 @@
+.noteCallout {
+ margin-bottom: 16px;
+}
+
+.noteInput {
+ min-height: 70px;
+ width: 100%;
+ padding: 8px;
+ border-radius: 8px;
+ box-sizing: border-box;
+ background: var(--color-bg-primary);
+ border: 1px solid var(--color-border-primary);
+ appearance: none;
+ resize: none;
+ margin-top: 4px;
+}
+
+.noteInput:focus-visible {
+ outline: var(--outline-focus-default);
+ outline-offset: 2px;
+}
diff --git a/app/javascript/mastodon/features/account_timeline/modals/note_modal.tsx b/app/javascript/mastodon/features/account_timeline/modals/note_modal.tsx
new file mode 100644
index 00000000000000..0fa665a9cc6339
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/modals/note_modal.tsx
@@ -0,0 +1,160 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import type { ChangeEventHandler, FC } from 'react';
+
+import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
+
+import { submitAccountNote } from '@/mastodon/actions/account_notes';
+import { fetchRelationships } from '@/mastodon/actions/accounts';
+import { Callout } from '@/mastodon/components/callout';
+import { TextAreaField } from '@/mastodon/components/form_fields';
+import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
+import type { Relationship } from '@/mastodon/models/relationship';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+
+import { ConfirmationModal } from '../../ui/components/confirmation_modals';
+
+import classes from './modals.module.css';
+
+const messages = defineMessages({
+ newTitle: {
+ id: 'account.node_modal.title',
+ defaultMessage: 'Add a personal note',
+ },
+ editTitle: {
+ id: 'account.node_modal.edit_title',
+ defaultMessage: 'Edit personal note',
+ },
+ save: {
+ id: 'account.node_modal.save',
+ defaultMessage: 'Save',
+ },
+ fieldLabel: {
+ id: 'account.node_modal.field_label',
+ defaultMessage: 'Personal Note',
+ },
+ errorUnknown: {
+ id: 'account.node_modal.error_unknown',
+ defaultMessage: 'Could not save the note',
+ },
+});
+
+export const AccountNoteModal: FC<{
+ accountId: string;
+ onClose: () => void;
+}> = ({ accountId, onClose }) => {
+ const relationship = useAppSelector((state) =>
+ state.relationships.get(accountId),
+ );
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ if (!relationship) {
+ dispatch(fetchRelationships([accountId]));
+ }
+ }, [accountId, dispatch, relationship]);
+
+ if (!relationship) {
+ return ;
+ }
+
+ return (
+
+ );
+};
+
+const InnerNodeModal: FC<{
+ relationship: Relationship;
+ accountId: string;
+ onClose: () => void;
+}> = ({ relationship, accountId, onClose }) => {
+ // Set up the state.
+ const initialContents = relationship.note;
+ const [note, setNote] = useState(initialContents);
+ const [errorText, setErrorText] = useState('');
+ const [state, setState] = useState<'idle' | 'saving' | 'error'>('idle');
+ const isDirty = note !== initialContents;
+
+ const handleChange: ChangeEventHandler = useCallback(
+ (e) => {
+ if (state !== 'saving') {
+ setNote(e.target.value);
+ }
+ },
+ [state],
+ );
+
+ const intl = useIntl();
+
+ // Create an abort controller to cancel the request if the modal is closed.
+ const abortController = useRef(new AbortController());
+ const dispatch = useAppDispatch();
+ const handleSave = useCallback(() => {
+ if (state === 'saving' || !isDirty) {
+ return;
+ }
+ setState('saving');
+ dispatch(
+ submitAccountNote(
+ { accountId, note },
+ { signal: abortController.current.signal },
+ ),
+ )
+ .then(() => {
+ setState('idle');
+ onClose();
+ })
+ .catch((err: unknown) => {
+ setState('error');
+ if (err instanceof Error) {
+ setErrorText(err.message);
+ } else {
+ setErrorText(intl.formatMessage(messages.errorUnknown));
+ }
+ });
+ }, [accountId, dispatch, intl, isDirty, note, onClose, state]);
+
+ const handleCancel = useCallback(() => {
+ abortController.current.abort();
+ onClose();
+ }, [onClose]);
+
+ return (
+
+
+
+
+
+ >
+ }
+ onClose={handleCancel}
+ confirm={intl.formatMessage(messages.save)}
+ onConfirm={handleSave}
+ updating={state === 'saving'}
+ disabled={!isDirty}
+ closeWhenConfirm={false}
+ noFocusButton
+ />
+ );
+};
diff --git a/app/javascript/mastodon/features/account_timeline/v2/context.tsx b/app/javascript/mastodon/features/account_timeline/v2/context.tsx
new file mode 100644
index 00000000000000..f41e19acf60745
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/v2/context.tsx
@@ -0,0 +1,101 @@
+import type { FC, ReactNode } from 'react';
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from 'react';
+
+import { useStorageState } from '@/mastodon/hooks/useStorage';
+
+interface AccountTimelineContextValue {
+ accountId: string;
+ boosts: boolean;
+ replies: boolean;
+ showAllPinned: boolean;
+ setBoosts: (value: boolean) => void;
+ setReplies: (value: boolean) => void;
+ onShowAllPinned: () => void;
+}
+
+const AccountTimelineContext =
+ createContext(null);
+
+export const AccountTimelineProvider: FC<{
+ accountId: string;
+ children: ReactNode;
+}> = ({ accountId, children }) => {
+ const storageOptions = {
+ type: 'session',
+ prefix: `filters-${accountId}:`,
+ } as const;
+
+ const [boosts, setBoosts] = useStorageState(
+ 'boosts',
+ true,
+ storageOptions,
+ );
+
+ const [replies, setReplies] = useStorageState(
+ 'replies',
+ false,
+ storageOptions,
+ );
+
+ const handleSetBoosts = useCallback(
+ (value: boolean) => {
+ setBoosts(value);
+ },
+ [setBoosts],
+ );
+ const handleSetReplies = useCallback(
+ (value: boolean) => {
+ setReplies(value);
+ },
+ [setReplies],
+ );
+
+ const [showAllPinned, setShowAllPinned] = useState(false);
+ const handleShowAllPinned = useCallback(() => {
+ setShowAllPinned(true);
+ }, []);
+
+ // Memoize the context value to avoid unnecessary re-renders.
+ const value = useMemo(
+ () => ({
+ accountId,
+ boosts,
+ replies,
+ showAllPinned,
+ setBoosts: handleSetBoosts,
+ setReplies: handleSetReplies,
+ onShowAllPinned: handleShowAllPinned,
+ }),
+ [
+ accountId,
+ boosts,
+ handleSetBoosts,
+ handleSetReplies,
+ handleShowAllPinned,
+ replies,
+ showAllPinned,
+ ],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export function useAccountContext() {
+ const values = useContext(AccountTimelineContext);
+ if (!values) {
+ throw new Error(
+ 'useAccountFilters must be used within an AccountTimelineProvider',
+ );
+ }
+ return values;
+}
diff --git a/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx
new file mode 100644
index 00000000000000..b8061fced433d4
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/v2/featured_tags.tsx
@@ -0,0 +1,123 @@
+import { useCallback, useEffect, useState } from 'react';
+import type { FC, MouseEventHandler } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+import { useParams } from 'react-router';
+
+import { fetchFeaturedTags } from '@/mastodon/actions/featured_tags';
+import { useAppHistory } from '@/mastodon/components/router';
+import { Tag } from '@/mastodon/components/tags/tag';
+import { useOverflowButton } from '@/mastodon/hooks/useOverflow';
+import { selectAccountFeaturedTags } from '@/mastodon/selectors/accounts';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+
+import { useAccountContext } from './context';
+import classes from './styles.module.scss';
+
+export const FeaturedTags: FC<{ accountId: string }> = ({ accountId }) => {
+ // Fetch tags.
+ const featuredTags = useAppSelector((state) =>
+ selectAccountFeaturedTags(state, accountId),
+ );
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ void dispatch(fetchFeaturedTags({ accountId }));
+ }, [accountId, dispatch]);
+
+ // Get list of tags with overflow handling.
+ const [showOverflow, setShowOverflow] = useState(false);
+ const { hiddenCount, wrapperRef, listRef, hiddenIndex, maxWidth } =
+ useOverflowButton();
+
+ // Handle whether to show all tags.
+ const handleOverflowClick: MouseEventHandler = useCallback(() => {
+ setShowOverflow(true);
+ }, []);
+
+ const { onClick, currentTag } = useTagNavigate();
+
+ if (featuredTags.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {featuredTags.map(({ id, name }, index) => (
+ 0 && index >= hiddenIndex ? '' : undefined}
+ onClick={onClick}
+ active={currentTag === name}
+ data-name={name}
+ />
+ ))}
+
+ {!showOverflow && hiddenCount > 0 && (
+
+ }
+ />
+ )}
+
+ );
+};
+
+function useTagNavigate() {
+ // Get current account, tag, and filters.
+ const { acct, tagged } = useParams<{ acct: string; tagged?: string }>();
+ const { boosts, replies } = useAccountContext();
+
+ const history = useAppHistory();
+
+ const handleTagClick: MouseEventHandler = useCallback(
+ (event) => {
+ const name = event.currentTarget.getAttribute('data-name');
+ if (!name || !acct) {
+ return;
+ }
+
+ // Determine whether to navigate to or from the tag.
+ let url = `/@${acct}/tagged/${encodeURIComponent(name)}`;
+ if (name === tagged) {
+ url = `/@${acct}`;
+ }
+
+ // Append filters.
+ const params = new URLSearchParams();
+ if (boosts) {
+ params.append('boosts', '1');
+ }
+ if (replies) {
+ params.append('replies', '1');
+ }
+
+ history.push({
+ pathname: url,
+ search: params.toString(),
+ });
+ },
+ [acct, tagged, boosts, replies, history],
+ );
+
+ return {
+ onClick: handleTagClick,
+ currentTag: tagged,
+ };
+}
diff --git a/app/javascript/mastodon/features/account_timeline/v2/filters.tsx b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx
new file mode 100644
index 00000000000000..28dcb5f5c47731
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/v2/filters.tsx
@@ -0,0 +1,145 @@
+import { useCallback, useId, useRef, useState } from 'react';
+import type { ChangeEventHandler, FC } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { useParams } from 'react-router';
+
+import Overlay from 'react-overlays/esm/Overlay';
+
+import { Toggle } from '@/mastodon/components/form_fields';
+import { Icon } from '@/mastodon/components/icon';
+import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
+
+import { AccountTabs } from '../components/tabs';
+
+import { useAccountContext } from './context';
+import classes from './styles.module.scss';
+
+export const AccountFilters: FC = () => {
+ const { acct } = useParams<{ acct: string }>();
+ if (!acct) {
+ return null;
+ }
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
+
+const FilterDropdown: FC = () => {
+ const [open, setOpen] = useState(false);
+ const buttonRef = useRef(null);
+
+ const handleClick = useCallback(() => {
+ setOpen(true);
+ }, []);
+ const handleHide = useCallback(() => {
+ setOpen(false);
+ }, []);
+
+ const { boosts, replies, setBoosts, setReplies } = useAccountContext();
+ const handleChange: ChangeEventHandler = useCallback(
+ (event) => {
+ const { name, checked } = event.target;
+ if (name === 'boosts') {
+ setBoosts(checked);
+ } else if (name === 'replies') {
+ setReplies(checked);
+ }
+ },
+ [setBoosts, setReplies],
+ );
+
+ const accessibleId = useId();
+ const containerRef = useRef(null);
+
+ return (
+
+
+
+ {({ props }) => (
+
+
+
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/app/javascript/mastodon/features/account_timeline/v2/index.tsx b/app/javascript/mastodon/features/account_timeline/v2/index.tsx
new file mode 100644
index 00000000000000..73649941886610
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/v2/index.tsx
@@ -0,0 +1,182 @@
+import { useCallback, useEffect } from 'react';
+import type { FC } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+import { useParams } from 'react-router';
+
+import { List as ImmutableList } from 'immutable';
+
+import {
+ expandTimelineByKey,
+ timelineKey,
+} from '@/mastodon/actions/timelines_typed';
+import { Column } from '@/mastodon/components/column';
+import { ColumnBackButton } from '@/mastodon/components/column_back_button';
+import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
+import { RemoteHint } from '@/mastodon/components/remote_hint';
+import StatusList from '@/mastodon/components/status_list';
+import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
+import { useAccountId } from '@/mastodon/hooks/useAccountId';
+import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
+import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+
+import { AccountHeader } from '../components/account_header';
+import { LimitedAccountHint } from '../components/limited_account_hint';
+
+import { AccountTimelineProvider, useAccountContext } from './context';
+import { FeaturedTags } from './featured_tags';
+import { AccountFilters } from './filters';
+import {
+ renderPinnedStatusHeader,
+ usePinnedStatusIds,
+} from './pinned_statuses';
+import classes from './styles.module.scss';
+
+const emptyList = ImmutableList();
+
+const AccountTimelineV2: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
+ const accountId = useAccountId();
+
+ // Null means accountId does not exist (e.g. invalid acct). Undefined means loading.
+ if (accountId === null) {
+ return ;
+ }
+
+ if (!accountId) {
+ return (
+
+
+
+ );
+ }
+
+ // Add this key to remount the timeline when accountId changes.
+ return (
+
+
+
+ );
+};
+
+const InnerTimeline: FC<{ accountId: string; multiColumn: boolean }> = ({
+ accountId,
+ multiColumn,
+}) => {
+ const { tagged } = useParams<{ tagged?: string }>();
+ const { boosts, replies } = useAccountContext();
+ const key = timelineKey({
+ type: 'account',
+ userId: accountId,
+ tagged,
+ boosts,
+ replies,
+ });
+
+ const timeline = useAppSelector((state) => selectTimelineByKey(state, key));
+ const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
+ const forceEmptyState = blockedBy || hidden || suspended;
+
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ if (accountId) {
+ if (!timeline) {
+ dispatch(expandTimelineByKey({ key }));
+ }
+ }
+ }, [accountId, dispatch, key, timeline]);
+
+ const handleLoadMore = useCallback(
+ (maxId: number) => {
+ if (accountId) {
+ dispatch(expandTimelineByKey({ key, maxId }));
+ }
+ },
+ [accountId, dispatch, key],
+ );
+
+ const { isLoading: isPinnedLoading, statusIds: pinnedStatusIds } =
+ usePinnedStatusIds({ accountId, tagged, forceEmptyState });
+
+ const isLoading = !!timeline?.isLoading || isPinnedLoading;
+
+ return (
+
+
+
+ }
+ append={}
+ scrollKey='account_timeline'
+ // We want to have this component when timeline is undefined (loading),
+ // because if we don't the prepended component will re-render with every filter change.
+ statusIds={forceEmptyState ? emptyList : (timeline?.items ?? emptyList)}
+ featuredStatusIds={pinnedStatusIds}
+ isLoading={isLoading}
+ hasMore={!forceEmptyState && !!timeline?.hasMore}
+ onLoadMore={handleLoadMore}
+ emptyMessage={}
+ bindToDocument={!multiColumn}
+ timelineId='account'
+ withCounters
+ className={classNames(classes.statusWrapper)}
+ statusProps={{ headerRenderFn: renderPinnedStatusHeader }}
+ />
+
+ );
+};
+
+const Prepend: FC<{
+ accountId: string;
+ forceEmpty: boolean;
+}> = ({ forceEmpty, accountId }) => {
+ if (forceEmpty) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
+ const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
+ if (suspended) {
+ return (
+
+ );
+ } else if (hidden) {
+ return ;
+ } else if (blockedBy) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
+
+// eslint-disable-next-line import/no-default-export
+export default AccountTimelineV2;
diff --git a/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx
new file mode 100644
index 00000000000000..7a8523c9de549e
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/v2/pinned_statuses.tsx
@@ -0,0 +1,106 @@
+import type { FC } from 'react';
+import { useEffect, useMemo } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+
+import IconPinned from '@/images/icons/icon_pinned.svg?react';
+import { TIMELINE_PINNED_VIEW_ALL } from '@/mastodon/actions/timelines';
+import {
+ expandTimelineByKey,
+ timelineKey,
+} from '@/mastodon/actions/timelines_typed';
+import { Button } from '@/mastodon/components/button';
+import { Icon } from '@/mastodon/components/icon';
+import { StatusHeader } from '@/mastodon/components/status/header';
+import type { StatusHeaderRenderFn } from '@/mastodon/components/status/header';
+import { selectTimelineByKey } from '@/mastodon/selectors/timelines';
+import { useAppDispatch, useAppSelector } from '@/mastodon/store';
+
+import { isRedesignEnabled } from '../common';
+import { PinnedBadge } from '../components/badges';
+
+import { useAccountContext } from './context';
+import classes from './styles.module.scss';
+
+export function usePinnedStatusIds({
+ accountId,
+ tagged,
+ forceEmptyState = false,
+}: {
+ accountId: string;
+ tagged?: string;
+ forceEmptyState?: boolean;
+}) {
+ const pinnedKey = timelineKey({
+ type: 'account',
+ userId: accountId,
+ tagged,
+ pinned: true,
+ });
+
+ const dispatch = useAppDispatch();
+ useEffect(() => {
+ dispatch(expandTimelineByKey({ key: pinnedKey }));
+ }, [dispatch, pinnedKey]);
+
+ const pinnedTimeline = useAppSelector((state) =>
+ selectTimelineByKey(state, pinnedKey),
+ );
+
+ const { showAllPinned } = useAccountContext();
+
+ const pinnedTimelineItems = pinnedTimeline?.items; // Make a const to avoid the React Compiler complaining.
+ const pinnedStatusIds = useMemo(() => {
+ if (!pinnedTimelineItems || forceEmptyState) {
+ return undefined;
+ }
+
+ if (pinnedTimelineItems.size <= 1 || showAllPinned) {
+ return pinnedTimelineItems;
+ }
+ return pinnedTimelineItems.slice(0, 1).push(TIMELINE_PINNED_VIEW_ALL);
+ }, [forceEmptyState, pinnedTimelineItems, showAllPinned]);
+
+ return {
+ statusIds: pinnedStatusIds,
+ isLoading: !!pinnedTimeline?.isLoading,
+ showAllPinned,
+ };
+}
+
+export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({
+ featured,
+ ...args
+}) => {
+ if (!featured) {
+ return ;
+ }
+ return (
+
+
+
+ );
+};
+
+export const PinnedShowAllButton: FC = () => {
+ const { onShowAllPinned } = useAccountContext();
+
+ if (!isRedesignEnabled()) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx b/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx
new file mode 100644
index 00000000000000..5f0ff886857866
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/v2/status_header.tsx
@@ -0,0 +1,52 @@
+import type { FC } from 'react';
+
+import { Link } from 'react-router-dom';
+
+import { RelativeTimestamp } from '@/mastodon/components/relative_timestamp';
+import type { StatusHeaderProps } from '@/mastodon/components/status/header';
+import {
+ StatusDisplayName,
+ StatusEditedAt,
+ StatusVisibility,
+} from '@/mastodon/components/status/header';
+import type { Account } from '@/mastodon/models/account';
+
+export const AccountStatusHeader: FC = ({
+ status,
+ account,
+ children,
+ avatarSize = 48,
+ wrapperProps,
+ onHeaderClick,
+}) => {
+ const statusAccount = status.get('account') as Account | undefined;
+ const editedAt = status.get('edited_at') as string;
+
+ return (
+ /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
+
+
+
+
+ {editedAt && }
+
+
+
+
+ {children}
+
+ );
+};
diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss
new file mode 100644
index 00000000000000..e16eca5254d1a0
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss
@@ -0,0 +1,119 @@
+.filtersWrapper {
+ padding: 16px 24px 8px;
+}
+
+.filterSelectButton {
+ appearance: none;
+ border: none;
+ background: none;
+ padding: 8px 0;
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ transition: color 0.2s ease-in-out;
+
+ &:hover,
+ &:focus {
+ color: var(--color-text-brand-soft);
+ }
+}
+
+.filterSelectIcon {
+ width: 16px;
+ height: 16px;
+}
+
+.filterOverlay {
+ background: var(--color-bg-primary);
+ border-radius: 12px;
+ box-shadow: var(--dropdown-shadow);
+ min-width: 230px;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: stretch;
+ row-gap: 16px;
+ padding: 8px 12px;
+ z-index: 1;
+
+ > label {
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ }
+}
+
+.tagsWrapper {
+ margin: 0 24px 8px;
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+ gap: 8px;
+}
+
+.tagsList {
+ display: flex;
+ gap: 4px;
+ flex-wrap: nowrap;
+ overflow: hidden;
+ position: relative;
+}
+
+.tagsListShowAll {
+ flex-wrap: wrap;
+ overflow: visible;
+ max-width: none !important;
+}
+
+.statusWrapper {
+ :global(.status) {
+ padding-left: 24px;
+ padding-right: 24px;
+ }
+
+ &:has(.pinnedViewAllButton) :global(.status):has(.pinnedStatusHeader) {
+ border-bottom: none;
+ }
+
+ article:has(.pinnedViewAllButton) {
+ border-bottom: 1px solid var(--color-border-primary);
+ }
+}
+
+.pinnedViewAllButton {
+ background-color: var(--color-bg-primary);
+ border-radius: 8px;
+ border: 1px solid var(--color-border-primary);
+ box-sizing: border-box;
+ color: var(--color-text-primary);
+ line-height: normal;
+ margin: 12px 24px;
+ padding: 8px;
+ transition: border-color 0.2s ease-in-out;
+ width: calc(100% - 48px);
+
+ &:hover,
+ &:focus {
+ background-color: inherit;
+ border-color: var(--color-bg-brand-base-hover);
+ }
+}
+
+.pinnedStatusHeader {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: 1fr 1fr;
+ gap: 4px;
+
+ > :global(.status__relative-time) {
+ grid-column: 2;
+ height: auto;
+ }
+
+ > :global(.status__display-name) {
+ grid-row: span 2;
+ }
+
+ > :global(.account-role) {
+ justify-self: end;
+ }
+}
diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx
new file mode 100644
index 00000000000000..4266de94d1a46a
--- /dev/null
+++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx
@@ -0,0 +1,363 @@
+import { useCallback, useMemo, useState } from 'react';
+
+import { FormattedMessage, useIntl } from 'react-intl';
+
+import { useHistory, useLocation } from 'react-router-dom';
+
+import CancelIcon from '@/material-icons/400-24px/cancel.svg?react';
+import CheckIcon from '@/material-icons/400-24px/check.svg?react';
+import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
+import { Account } from 'mastodon/components/account';
+import { Avatar } from 'mastodon/components/avatar';
+import { Button } from 'mastodon/components/button';
+import { Callout } from 'mastodon/components/callout';
+import { DisplayName } from 'mastodon/components/display_name';
+import { EmptyState } from 'mastodon/components/empty_state';
+import { FormStack, ComboboxField } from 'mastodon/components/form_fields';
+import { Icon } from 'mastodon/components/icon';
+import { IconButton } from 'mastodon/components/icon_button';
+import ScrollableList from 'mastodon/components/scrollable_list';
+import { useSearchAccounts } from 'mastodon/features/lists/use_search_accounts';
+import {
+ addCollectionItem,
+ removeCollectionItem,
+} from 'mastodon/reducers/slices/collections';
+import { useAppDispatch, useAppSelector } from 'mastodon/store';
+
+import type { TempCollectionState } from './state';
+import { getCollectionEditorState } from './state';
+import classes from './styles.module.scss';
+import { WizardStepHeader } from './wizard_step_header';
+
+const MIN_ACCOUNT_COUNT = 1;
+const MAX_ACCOUNT_COUNT = 25;
+
+const AddedAccountItem: React.FC<{
+ accountId: string;
+ isRemovable: boolean;
+ onRemove: (id: string) => void;
+}> = ({ accountId, isRemovable, onRemove }) => {
+ const intl = useIntl();
+
+ const handleRemoveAccount = useCallback(() => {
+ onRemove(accountId);
+ }, [accountId, onRemove]);
+
+ return (
+
+ {isRemovable && (
+
+ )}
+
+ );
+};
+
+interface SuggestionItem {
+ id: string;
+ isSelected: boolean;
+}
+
+const SuggestedAccountItem: React.FC = ({ id, isSelected }) => {
+ const account = useAppSelector((state) => state.accounts.get(id));
+
+ if (!account) return null;
+
+ return (
+ <>
+
+
+ {isSelected && (
+
+ )}
+ >
+ );
+};
+
+const renderAccountItem = (item: SuggestionItem) => (
+
+);
+
+const getItemId = (item: SuggestionItem) => item.id;
+const getIsItemSelected = (item: SuggestionItem) => item.isSelected;
+
+export const CollectionAccounts: React.FC<{
+ collection?: ApiCollectionJSON | null;
+}> = ({ collection }) => {
+ const intl = useIntl();
+ const dispatch = useAppDispatch();
+ const history = useHistory();
+ const location = useLocation();
+ const { id, initialItemIds } = getCollectionEditorState(
+ collection,
+ location.state,
+ );
+ const isEditMode = !!id;
+ const collectionItems = collection?.items;
+
+ const [searchValue, setSearchValue] = useState('');
+ // This state is only used when creating a new collection.
+ // In edit mode, the collection will be updated instantly
+ const [addedAccountIds, setAccountIds] = useState(initialItemIds);
+ const accountIds = useMemo(
+ () =>
+ isEditMode
+ ? (collectionItems
+ ?.map((item) => item.account_id)
+ .filter((id): id is string => !!id) ?? [])
+ : addedAccountIds,
+ [isEditMode, collectionItems, addedAccountIds],
+ );
+
+ const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT;
+ const hasMinAccounts = accountIds.length === MIN_ACCOUNT_COUNT;
+ const hasTooFewAccounts = accountIds.length < MIN_ACCOUNT_COUNT;
+ const canSubmit = !hasTooFewAccounts;
+
+ const {
+ accountIds: suggestedAccountIds,
+ isLoading: isLoadingSuggestions,
+ searchAccounts,
+ } = useSearchAccounts();
+
+ const suggestedItems = suggestedAccountIds.map((id) => ({
+ id,
+ isSelected: accountIds.includes(id),
+ }));
+
+ const handleSearchValueChange = useCallback(
+ (e: React.ChangeEvent) => {
+ setSearchValue(e.target.value);
+ searchAccounts(e.target.value);
+ },
+ [searchAccounts],
+ );
+
+ const handleSearchKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ }
+ },
+ [],
+ );
+
+ const toggleAccountItem = useCallback((item: SuggestionItem) => {
+ setAccountIds((ids) =>
+ ids.includes(item.id)
+ ? ids.filter((id) => id !== item.id)
+ : [...ids, item.id],
+ );
+ }, []);
+
+ const instantRemoveAccountItem = useCallback(
+ (accountId: string) => {
+ const itemId = collectionItems?.find(
+ (item) => item.account_id === accountId,
+ )?.id;
+ if (itemId && id) {
+ if (
+ window.confirm(
+ intl.formatMessage({
+ id: 'collections.confirm_account_removal',
+ defaultMessage:
+ 'Are you sure you want to remove this account from this collection?',
+ }),
+ )
+ ) {
+ void dispatch(removeCollectionItem({ collectionId: id, itemId }));
+ }
+ }
+ },
+ [collectionItems, dispatch, id, intl],
+ );
+
+ const instantToggleAccountItem = useCallback(
+ (item: SuggestionItem) => {
+ if (accountIds.includes(item.id)) {
+ instantRemoveAccountItem(item.id);
+ } else {
+ if (id) {
+ void dispatch(
+ addCollectionItem({ collectionId: id, accountId: item.id }),
+ );
+ }
+ }
+ },
+ [accountIds, dispatch, id, instantRemoveAccountItem],
+ );
+
+ const handleRemoveAccountItem = useCallback(
+ (accountId: string) => {
+ if (isEditMode) {
+ instantRemoveAccountItem(accountId);
+ } else {
+ setAccountIds((ids) => ids.filter((id) => id !== accountId));
+ }
+ },
+ [isEditMode, instantRemoveAccountItem],
+ );
+
+ const handleSubmit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!canSubmit) {
+ return;
+ }
+
+ if (!id) {
+ history.push(`/collections/new/details`, {
+ account_ids: accountIds,
+ });
+ }
+ },
+ [canSubmit, id, history, accountIds],
+ );
+
+ return (
+
+ );
+};
diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx
new file mode 100644
index 00000000000000..f931a42c6c406d
--- /dev/null
+++ b/app/javascript/mastodon/features/collections/editor/details.tsx
@@ -0,0 +1,172 @@
+import { useCallback, useState } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { useHistory, useLocation } from 'react-router-dom';
+
+import type {
+ ApiCollectionJSON,
+ ApiCreateCollectionPayload,
+ ApiUpdateCollectionPayload,
+} from 'mastodon/api_types/collections';
+import { Button } from 'mastodon/components/button';
+import { FormStack, TextAreaField } from 'mastodon/components/form_fields';
+import { TextInputField } from 'mastodon/components/form_fields/text_input_field';
+import { updateCollection } from 'mastodon/reducers/slices/collections';
+import { useAppDispatch } from 'mastodon/store';
+
+import type { TempCollectionState } from './state';
+import { getCollectionEditorState } from './state';
+import classes from './styles.module.scss';
+import { WizardStepHeader } from './wizard_step_header';
+
+export const CollectionDetails: React.FC<{
+ collection?: ApiCollectionJSON | null;
+}> = ({ collection }) => {
+ const dispatch = useAppDispatch();
+ const history = useHistory();
+ const location = useLocation();
+
+ const { id, initialName, initialDescription, initialTopic, initialItemIds } =
+ getCollectionEditorState(collection, location.state);
+
+ const [name, setName] = useState(initialName);
+ const [description, setDescription] = useState(initialDescription);
+ const [topic, setTopic] = useState(initialTopic);
+
+ const handleNameChange = useCallback(
+ (event: React.ChangeEvent) => {
+ setName(event.target.value);
+ },
+ [],
+ );
+
+ const handleDescriptionChange = useCallback(
+ (event: React.ChangeEvent) => {
+ setDescription(event.target.value);
+ },
+ [],
+ );
+
+ const handleTopicChange = useCallback(
+ (event: React.ChangeEvent) => {
+ setTopic(event.target.value);
+ },
+ [],
+ );
+
+ const handleSubmit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (id) {
+ const payload: ApiUpdateCollectionPayload = {
+ id,
+ name,
+ description,
+ tag_name: topic || null,
+ };
+
+ void dispatch(updateCollection({ payload })).then(() => {
+ history.push(`/collections`);
+ });
+ } else {
+ const payload: Partial = {
+ name,
+ description,
+ tag_name: topic || null,
+ account_ids: initialItemIds,
+ };
+
+ history.replace('/collections/new', payload);
+ history.push('/collections/new/settings', payload);
+ }
+ },
+ [id, name, description, topic, dispatch, history, initialItemIds],
+ );
+
+ return (
+
+ {!id && (
+
+ }
+ />
+ )}
+
+ }
+ hint={
+
+ }
+ value={name}
+ onChange={handleNameChange}
+ maxLength={40}
+ />
+
+
+ }
+ hint={
+
+ }
+ value={description}
+ onChange={handleDescriptionChange}
+ maxLength={100}
+ />
+
+
+ }
+ hint={
+
+ }
+ value={topic}
+ onChange={handleTopicChange}
+ maxLength={40}
+ />
+
+
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/features/collections/editor/index.tsx b/app/javascript/mastodon/features/collections/editor/index.tsx
new file mode 100644
index 00000000000000..ad378e3a4361c9
--- /dev/null
+++ b/app/javascript/mastodon/features/collections/editor/index.tsx
@@ -0,0 +1,135 @@
+import { useEffect } from 'react';
+
+import { defineMessages, useIntl } from 'react-intl';
+
+import { Helmet } from 'react-helmet';
+import {
+ Switch,
+ Route,
+ useParams,
+ useRouteMatch,
+ matchPath,
+ useLocation,
+} from 'react-router-dom';
+
+import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
+import { Column } from 'mastodon/components/column';
+import { ColumnHeader } from 'mastodon/components/column_header';
+import { LoadingIndicator } from 'mastodon/components/loading_indicator';
+import { fetchCollection } from 'mastodon/reducers/slices/collections';
+import { useAppDispatch, useAppSelector } from 'mastodon/store';
+
+import { CollectionAccounts } from './accounts';
+import { CollectionDetails } from './details';
+import { CollectionSettings } from './settings';
+
+export const messages = defineMessages({
+ create: {
+ id: 'collections.create_collection',
+ defaultMessage: 'Create collection',
+ },
+ newCollection: {
+ id: 'collections.new_collection',
+ defaultMessage: 'New collection',
+ },
+ editDetails: {
+ id: 'collections.edit_details',
+ defaultMessage: 'Edit basic details',
+ },
+ manageAccounts: {
+ id: 'collections.manage_accounts',
+ defaultMessage: 'Manage accounts',
+ },
+ manageAccountsLong: {
+ id: 'collections.manage_accounts_in_collection',
+ defaultMessage: 'Manage accounts in this collection',
+ },
+ editSettings: {
+ id: 'collections.edit_settings',
+ defaultMessage: 'Edit settings',
+ },
+});
+
+function usePageTitle(id: string | undefined) {
+ const { path } = useRouteMatch();
+ const location = useLocation();
+
+ if (!id) {
+ return messages.newCollection;
+ }
+
+ if (matchPath(location.pathname, { path, exact: true })) {
+ return messages.manageAccounts;
+ } else if (matchPath(location.pathname, { path: `${path}/details` })) {
+ return messages.editDetails;
+ } else if (matchPath(location.pathname, { path: `${path}/settings` })) {
+ return messages.editSettings;
+ } else {
+ throw new Error('No page title defined for route');
+ }
+}
+
+export const CollectionEditorPage: React.FC<{
+ multiColumn?: boolean;
+}> = ({ multiColumn }) => {
+ const intl = useIntl();
+ const dispatch = useAppDispatch();
+ const { id } = useParams<{ id?: string }>();
+ const { path } = useRouteMatch();
+ const collection = useAppSelector((state) =>
+ id ? state.collections.collections[id] : undefined,
+ );
+ const isEditMode = !!id;
+ const isLoading = isEditMode && !collection;
+
+ useEffect(() => {
+ if (id) {
+ void dispatch(fetchCollection({ collectionId: id }));
+ }
+ }, [dispatch, id]);
+
+ const pageTitle = intl.formatMessage(usePageTitle(id));
+
+ return (
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+
+ }
+ />
+ }
+ />
+ }
+ />
+
+ )}
+
+
+
+ {pageTitle}
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/features/collections/editor/settings.tsx b/app/javascript/mastodon/features/collections/editor/settings.tsx
new file mode 100644
index 00000000000000..10fb295f835e84
--- /dev/null
+++ b/app/javascript/mastodon/features/collections/editor/settings.tsx
@@ -0,0 +1,199 @@
+import { useCallback, useState } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { useHistory, useLocation } from 'react-router-dom';
+
+import { isFulfilled } from '@reduxjs/toolkit';
+
+import type {
+ ApiCollectionJSON,
+ ApiCreateCollectionPayload,
+ ApiUpdateCollectionPayload,
+} from 'mastodon/api_types/collections';
+import { Button } from 'mastodon/components/button';
+import {
+ Fieldset,
+ FormStack,
+ CheckboxField,
+ RadioButtonField,
+} from 'mastodon/components/form_fields';
+import {
+ createCollection,
+ updateCollection,
+} from 'mastodon/reducers/slices/collections';
+import { useAppDispatch } from 'mastodon/store';
+
+import type { TempCollectionState } from './state';
+import { getCollectionEditorState } from './state';
+import classes from './styles.module.scss';
+import { WizardStepHeader } from './wizard_step_header';
+
+export const CollectionSettings: React.FC<{
+ collection?: ApiCollectionJSON | null;
+}> = ({ collection }) => {
+ const dispatch = useAppDispatch();
+ const history = useHistory();
+ const location = useLocation();
+
+ const { id, initialDiscoverable, initialSensitive, ...editorState } =
+ getCollectionEditorState(collection, location.state);
+
+ const [discoverable, setDiscoverable] = useState(initialDiscoverable);
+ const [sensitive, setSensitive] = useState(initialSensitive);
+
+ const handleDiscoverableChange = useCallback(
+ (event: React.ChangeEvent) => {
+ setDiscoverable(event.target.value === 'public');
+ },
+ [],
+ );
+
+ const handleSensitiveChange = useCallback(
+ (event: React.ChangeEvent) => {
+ setSensitive(event.target.checked);
+ },
+ [],
+ );
+
+ const handleSubmit = useCallback(
+ (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (id) {
+ const payload: ApiUpdateCollectionPayload = {
+ id,
+ discoverable,
+ sensitive,
+ };
+
+ void dispatch(updateCollection({ payload })).then(() => {
+ history.push(`/collections`);
+ });
+ } else {
+ const payload: ApiCreateCollectionPayload = {
+ name: editorState.initialName,
+ description: editorState.initialDescription,
+ discoverable,
+ sensitive,
+ account_ids: editorState.initialItemIds,
+ };
+ if (editorState.initialTopic) {
+ payload.tag_name = editorState.initialTopic;
+ }
+
+ void dispatch(
+ createCollection({
+ payload,
+ }),
+ ).then((result) => {
+ if (isFulfilled(result)) {
+ history.replace(
+ `/collections/${result.payload.collection.id}/edit/settings`,
+ );
+ history.push(`/collections`);
+ }
+ });
+ }
+ },
+ [id, discoverable, sensitive, dispatch, history, editorState],
+ );
+
+ return (
+
+ {!id && (
+
+ }
+ />
+ )}
+
+ }
+ >
+
+ }
+ hint={
+
+ }
+ value='public'
+ checked={discoverable}
+ onChange={handleDiscoverableChange}
+ />
+
+ }
+ hint={
+
+ }
+ value='unlisted'
+ checked={!discoverable}
+ onChange={handleDiscoverableChange}
+ />
+
+
+
+ }
+ >
+
+ }
+ hint={
+
+ }
+ checked={sensitive}
+ onChange={handleSensitiveChange}
+ />
+
+
+
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/features/collections/editor/state.ts b/app/javascript/mastodon/features/collections/editor/state.ts
new file mode 100644
index 00000000000000..abac0b94b54c90
--- /dev/null
+++ b/app/javascript/mastodon/features/collections/editor/state.ts
@@ -0,0 +1,52 @@
+import type {
+ ApiCollectionJSON,
+ ApiCreateCollectionPayload,
+} from '@/mastodon/api_types/collections';
+
+/**
+ * Temporary editor state across creation steps,
+ * kept in location state
+ */
+export type TempCollectionState =
+ | Partial
+ | undefined;
+
+/**
+ * Resolve initial editor state. Temporary location state
+ * trumps stored data, otherwise initial values are returned.
+ */
+export function getCollectionEditorState(
+ collection: ApiCollectionJSON | null | undefined,
+ locationState: TempCollectionState,
+) {
+ const {
+ id,
+ name = '',
+ description = '',
+ tag,
+ language = '',
+ discoverable = true,
+ sensitive = false,
+ items,
+ } = collection ?? {};
+
+ const collectionItemIds =
+ items?.map((item) => item.account_id).filter(onlyExistingIds) ?? [];
+
+ const initialItemIds = (
+ locationState?.account_ids ?? collectionItemIds
+ ).filter(onlyExistingIds);
+
+ return {
+ id,
+ initialItemIds,
+ initialName: locationState?.name ?? name,
+ initialDescription: locationState?.description ?? description,
+ initialTopic: locationState?.tag_name ?? tag?.name ?? '',
+ initialLanguage: locationState?.language ?? language,
+ initialDiscoverable: locationState?.discoverable ?? discoverable,
+ initialSensitive: locationState?.sensitive ?? sensitive,
+ };
+}
+
+const onlyExistingIds = (id?: string): id is string => !!id;
diff --git a/app/javascript/mastodon/features/collections/editor/styles.module.scss b/app/javascript/mastodon/features/collections/editor/styles.module.scss
new file mode 100644
index 00000000000000..e64db98c4ac321
--- /dev/null
+++ b/app/javascript/mastodon/features/collections/editor/styles.module.scss
@@ -0,0 +1,88 @@
+.step {
+ font-size: 13px;
+ color: var(--color-text-secondary);
+}
+
+.title {
+ font-size: 22px;
+ line-height: 1.2;
+ margin-top: 4px;
+}
+
+.description {
+ font-size: 15px;
+ margin-top: 8px;
+}
+
+/* Make form stretch full height of the column */
+.form {
+ --bottom-spacing: 0;
+
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ min-height: 100%;
+ padding-bottom: var(--bottom-spacing);
+
+ @media (width < 760px) {
+ --bottom-spacing: var(--mobile-bottom-nav-height);
+ }
+}
+
+.selectedSuggestionIcon {
+ box-sizing: border-box;
+ width: 18px;
+ height: 18px;
+ margin-left: auto;
+ padding: 2px;
+ border-radius: 100%;
+ color: var(--color-text-on-brand-base);
+ background: var(--color-bg-brand-base);
+
+ [data-highlighted='true'] & {
+ color: var(--color-bg-brand-base);
+ background: var(--color-text-on-brand-base);
+ }
+}
+
+.formFieldStack {
+ flex-grow: 1;
+}
+
+.scrollableWrapper {
+ display: flex;
+ flex: 1;
+ margin-inline: -8px;
+}
+
+.scrollableInner {
+ margin-inline: -8px;
+}
+
+.submitDisabledCallout {
+ align-self: center;
+}
+
+.stickyFooter {
+ position: sticky;
+ bottom: var(--bottom-spacing);
+ padding: 16px;
+ background-image: linear-gradient(
+ to bottom,
+ transparent,
+ var(--color-bg-primary) 32px
+ );
+}
+
+.itemCountReadout {
+ text-align: center;
+}
+
+.actionWrapper {
+ display: flex;
+ flex-direction: column;
+ width: min-content;
+ min-width: 240px;
+ margin-inline: auto;
+ gap: 8px;
+}
diff --git a/app/javascript/mastodon/features/collections/editor/wizard_step_header.tsx b/app/javascript/mastodon/features/collections/editor/wizard_step_header.tsx
new file mode 100644
index 00000000000000..dcf0ed4a3f0b65
--- /dev/null
+++ b/app/javascript/mastodon/features/collections/editor/wizard_step_header.tsx
@@ -0,0 +1,23 @@
+import { FormattedMessage } from 'react-intl';
+
+import classes from './styles.module.scss';
+
+export const WizardStepHeader: React.FC<{
+ step: number;
+ title: React.ReactElement;
+ description?: React.ReactElement;
+}> = ({ step, title, description }) => {
+ return (
+
+
+ {(content) => {content}
}
+
+ {title}
+ {!!description && {description}
}
+
+ );
+};
diff --git a/app/javascript/mastodon/features/collections/index.tsx b/app/javascript/mastodon/features/collections/index.tsx
new file mode 100644
index 00000000000000..dd26174f381ba6
--- /dev/null
+++ b/app/javascript/mastodon/features/collections/index.tsx
@@ -0,0 +1,215 @@
+import { useEffect, useMemo, useCallback, useId } from 'react';
+
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+import { Helmet } from 'react-helmet';
+import { Link } from 'react-router-dom';
+
+import AddIcon from '@/material-icons/400-24px/add.svg?react';
+import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
+import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
+import SquigglyArrow from '@/svg-icons/squiggly_arrow.svg?react';
+import { openModal } from 'mastodon/actions/modal';
+import type { ApiCollectionJSON } from 'mastodon/api_types/collections';
+import { Column } from 'mastodon/components/column';
+import { ColumnHeader } from 'mastodon/components/column_header';
+import { Dropdown } from 'mastodon/components/dropdown_menu';
+import { Icon } from 'mastodon/components/icon';
+import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
+import ScrollableList from 'mastodon/components/scrollable_list';
+import {
+ fetchAccountCollections,
+ selectMyCollections,
+} from 'mastodon/reducers/slices/collections';
+import { useAppSelector, useAppDispatch } from 'mastodon/store';
+
+import { messages as editorMessages } from './editor';
+import classes from './styles.module.scss';
+
+const messages = defineMessages({
+ heading: { id: 'column.collections', defaultMessage: 'My collections' },
+ view: {
+ id: 'collections.view_collection',
+ defaultMessage: 'View collection',
+ },
+ delete: {
+ id: 'collections.delete_collection',
+ defaultMessage: 'Delete collection',
+ },
+ more: { id: 'status.more', defaultMessage: 'More' },
+});
+
+const CollectionItem: React.FC<{
+ collection: ApiCollectionJSON;
+}> = ({ collection }) => {
+ const dispatch = useAppDispatch();
+ const intl = useIntl();
+
+ const { id, name } = collection;
+
+ const handleDeleteClick = useCallback(() => {
+ dispatch(
+ openModal({
+ modalType: 'CONFIRM_DELETE_COLLECTION',
+ modalProps: {
+ name,
+ id,
+ },
+ }),
+ );
+ }, [dispatch, id, name]);
+
+ const menu = useMemo(
+ () => [
+ { text: intl.formatMessage(messages.view), to: `/collections/${id}` },
+ null,
+ {
+ text: intl.formatMessage(editorMessages.manageAccounts),
+ to: `/collections/${id}/edit`,
+ },
+ {
+ text: intl.formatMessage(editorMessages.editDetails),
+ to: `/collections/${id}/edit/details`,
+ },
+ {
+ text: intl.formatMessage(editorMessages.editSettings),
+ to: `/collections/${id}/edit/settings`,
+ },
+ null,
+ {
+ text: intl.formatMessage(messages.delete),
+ action: handleDeleteClick,
+ dangerous: true,
+ },
+ ],
+ [intl, id, handleDeleteClick],
+ );
+
+ const linkId = useId();
+
+ return (
+
+
+
+
+ {name}
+
+
+
+
+
+ ),
+ }}
+ tagName='li'
+ />
+
+
+
+
+
+ );
+};
+
+export const Collections: React.FC<{
+ multiColumn?: boolean;
+}> = ({ multiColumn }) => {
+ const dispatch = useAppDispatch();
+ const intl = useIntl();
+ const me = useAppSelector((state) => state.meta.get('me') as string);
+ const { collections, status } = useAppSelector(selectMyCollections);
+
+ useEffect(() => {
+ void dispatch(fetchAccountCollections({ accountId: me }));
+ }, [dispatch, me]);
+
+ const emptyMessage =
+ status === 'error' ? (
+
+ ) : (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+
+ return (
+
+
+
+
+ }
+ />
+
+
+ {collections.map((item) => (
+
+ ))}
+
+
+
+ {intl.formatMessage(messages.heading)}
+
+
+
+ );
+};
diff --git a/app/javascript/mastodon/features/collections/styles.module.scss b/app/javascript/mastodon/features/collections/styles.module.scss
new file mode 100644
index 00000000000000..ebcab70d593349
--- /dev/null
+++ b/app/javascript/mastodon/features/collections/styles.module.scss
@@ -0,0 +1,48 @@
+.collectionItemWrapper {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-inline: 10px;
+ padding-inline-end: 5px;
+ border-bottom: 1px solid var(--color-border-primary);
+}
+
+.collectionItemContent {
+ position: relative;
+ flex-grow: 1;
+ padding: 15px 5px;
+}
+
+.collectionItemLink {
+ display: block;
+ margin-bottom: 2px;
+ font-size: 15px;
+ font-weight: 500;
+ text-decoration: none;
+ color: var(--color-text-primary);
+
+ &:hover {
+ color: var(--color-text-brand);
+ }
+
+ &::after {
+ // Increase clickable area by extending link across parent
+ content: '';
+ position: absolute;
+ inset: 0;
+ }
+}
+
+.collectionItemInfo {
+ --gap: 0.75ch;
+
+ display: flex;
+ gap: var(--gap);
+ font-size: 13px;
+ color: var(--color-text-secondary);
+
+ & > li:not(:first-child)::before {
+ content: '·';
+ margin-inline-end: var(--gap);
+ }
+}
diff --git a/app/javascript/mastodon/features/collections/utils.ts b/app/javascript/mastodon/features/collections/utils.ts
new file mode 100644
index 00000000000000..616d0297a7441b
--- /dev/null
+++ b/app/javascript/mastodon/features/collections/utils.ts
@@ -0,0 +1,11 @@
+import {
+ isClientFeatureEnabled,
+ isServerFeatureEnabled,
+} from '@/mastodon/utils/environment';
+
+export function areCollectionsEnabled() {
+ return (
+ isClientFeatureEnabled('collections') &&
+ isServerFeatureEnabled('collections')
+ );
+}
diff --git a/app/javascript/mastodon/features/compose/components/upload.tsx b/app/javascript/mastodon/features/compose/components/upload.tsx
index 85fed0cbd3bf78..4190f3248e331f 100644
--- a/app/javascript/mastodon/features/compose/components/upload.tsx
+++ b/app/javascript/mastodon/features/compose/components/upload.tsx
@@ -10,8 +10,8 @@ import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import CloseIcon from '@/material-icons/400-20px/close.svg?react';
-import SoundIcon from '@/material-icons/400-24px/audio.svg?react';
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
+import SoundIcon from '@/material-icons/400-24px/graphic_eq.svg?react';
import WarningIcon from '@/material-icons/400-24px/warning.svg?react';
import { undoUploadCompose } from 'mastodon/actions/compose';
import { openModal } from 'mastodon/actions/modal';
diff --git a/app/javascript/mastodon/features/emoji/database.test.ts b/app/javascript/mastodon/features/emoji/database.test.ts
index b7f667e8ab1ece..e272ec2dea5c08 100644
--- a/app/javascript/mastodon/features/emoji/database.test.ts
+++ b/app/javascript/mastodon/features/emoji/database.test.ts
@@ -3,7 +3,6 @@ import { IDBFactory } from 'fake-indexeddb';
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
-import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import {
putEmojiData,
loadEmojiByHexcode,
@@ -12,8 +11,6 @@ import {
putCustomEmojiData,
putLegacyShortcodes,
loadLegacyShortcodesByShortcode,
- loadLatestEtag,
- putLatestEtag,
} from './database';
function rawEmojiFactory(data: Partial = {}): CompactEmoji {
@@ -120,36 +117,4 @@ describe('emoji database', () => {
).resolves.toEqual(data);
});
});
-
- describe('loadLatestEtag', () => {
- beforeEach(async () => {
- await putLatestEtag('etag', 'en');
- await putEmojiData([unicodeEmojiFactory()], 'en');
- await putLatestEtag('fr-etag', 'fr');
- });
-
- test('retrieves the etag for loaded locale', async () => {
- await putEmojiData(
- [unicodeEmojiFactory({ hexcode: EMOJI_DB_SHORTCODE_TEST })],
- 'en',
- );
- const etag = await loadLatestEtag('en');
- expect(etag).toBe('etag');
- });
-
- test('returns null if locale has no shortcodes', async () => {
- const etag = await loadLatestEtag('en');
- expect(etag).toBeNull();
- });
-
- test('returns null if locale not loaded', async () => {
- const etag = await loadLatestEtag('de');
- expect(etag).toBeNull();
- });
-
- test('returns null if locale has no data', async () => {
- const etag = await loadLatestEtag('fr');
- expect(etag).toBeNull();
- });
- });
});
diff --git a/app/javascript/mastodon/features/emoji/database.ts b/app/javascript/mastodon/features/emoji/database.ts
index f64f3fb80d6909..8dbd22c71bacbd 100644
--- a/app/javascript/mastodon/features/emoji/database.ts
+++ b/app/javascript/mastodon/features/emoji/database.ts
@@ -3,21 +3,16 @@ import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
-import { EMOJI_DB_SHORTCODE_TEST } from './constants';
import { openEmojiDB } from './db-schema';
import type { Database } from './db-schema';
-import {
- localeToSegmenter,
- toSupportedLocale,
- toSupportedLocaleOrCustom,
-} from './locale';
+import { localeToSegmenter, toSupportedLocale } from './locale';
import {
extractTokens,
skinHexcodeToEmoji,
transformCustomEmojiData,
transformEmojiData,
} from './normalize';
-import type { AnyEmojiData, EtagTypes } from './types';
+import type { AnyEmojiData, CacheKey } from './types';
import { emojiLogger } from './utils';
const loadedLocales = new Set();
@@ -214,16 +209,21 @@ export async function putLegacyShortcodes(shortcodes: ShortcodesDataset) {
await trx.done;
}
-export async function putLatestEtag(etag: string, name: EtagTypes) {
+export async function loadCacheValue(key: CacheKey) {
+ const db = await loadDB();
+ const value = await db.get('etags', key);
+ return value;
+}
+
+export async function putCacheValue(key: CacheKey, value: string) {
const db = await loadDB();
- await db.put('etags', etag, name);
+ await db.put('etags', value, key);
}
-export async function clearEtag(localeString: string) {
- const locale = toSupportedLocaleOrCustom(localeString);
+export async function clearCache(key: CacheKey) {
const db = await loadDB();
- await db.delete('etags', locale);
- log('Cleared etag for %s', locale);
+ await db.delete('etags', key);
+ log('Cleared cache for %s', key);
}
export async function loadEmojiByHexcode(
@@ -276,26 +276,6 @@ export async function loadLegacyShortcodesByShortcode(shortcode: string) {
);
}
-export async function loadLatestEtag(localeString: string) {
- const locale = toSupportedLocaleOrCustom(localeString);
- const db = await loadDB();
- const rowCount = await db.count(locale);
- if (!rowCount) {
- return null; // No data for this locale, return null even if there is an etag.
- }
-
- // Check if shortcodes exist for the given Unicode locale.
- if (locale !== 'custom') {
- const result = await db.get(locale, EMOJI_DB_SHORTCODE_TEST);
- if (!result?.shortcodes) {
- return null;
- }
- }
-
- const etag = await db.get('etags', locale);
- return etag ?? null;
-}
-
// Private functions
async function syncLocales(db: Database) {
diff --git a/app/javascript/mastodon/features/emoji/db-schema.ts b/app/javascript/mastodon/features/emoji/db-schema.ts
index f5582cf0c33b52..7f4d09fd0b01f0 100644
--- a/app/javascript/mastodon/features/emoji/db-schema.ts
+++ b/app/javascript/mastodon/features/emoji/db-schema.ts
@@ -10,7 +10,7 @@ import type {
StoreNames,
} from 'idb';
-import type { CustomEmojiData, EtagTypes, UnicodeEmojiData } from './types';
+import type { CustomEmojiData, CacheKey, UnicodeEmojiData } from './types';
import { emojiLogger } from './utils';
const log = emojiLogger('database');
@@ -35,7 +35,7 @@ interface EmojiDB extends LocaleTables, DBSchema {
};
};
etags: {
- key: EtagTypes;
+ key: CacheKey;
value: string;
};
}
diff --git a/app/javascript/mastodon/features/emoji/loader.ts b/app/javascript/mastodon/features/emoji/loader.ts
index 7a94d604a98b5a..0774fd4063f49f 100644
--- a/app/javascript/mastodon/features/emoji/loader.ts
+++ b/app/javascript/mastodon/features/emoji/loader.ts
@@ -4,11 +4,11 @@ import type { CompactEmoji, Locale, ShortcodesDataset } from 'emojibase';
import {
putEmojiData,
putCustomEmojiData,
- loadLatestEtag,
- putLatestEtag,
+ putCacheValue,
putLegacyShortcodes,
+ loadCacheValue,
} from './database';
-import { toSupportedLocale, toValidEtagName } from './locale';
+import { toSupportedLocale, toValidCacheKey } from './locale';
import type { CustomEmojiData } from './types';
import { emojiLogger } from './utils';
@@ -23,8 +23,8 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
shortcodes ? ' and shortcodes' : '',
);
- let emojis = await fetchAndCheckEtag({
- etagString: locale,
+ let emojis = await fetchIfNotLoaded({
+ key: locale,
path: localeToEmojiPath(locale),
});
if (!emojis) {
@@ -33,8 +33,8 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
const shortcodesData: ShortcodesDataset[] = [];
if (shortcodes) {
- const shortcodesResponse = await fetchAndCheckEtag({
- etagString: `${locale}-shortcodes`,
+ const shortcodesResponse = await fetchIfNotLoaded({
+ key: `${locale}-shortcodes`,
path: localeToShortcodesPath(locale),
});
if (shortcodesResponse) {
@@ -51,13 +51,24 @@ export async function importEmojiData(localeString: string, shortcodes = true) {
}
export async function importCustomEmojiData() {
- const emojis = await fetchAndCheckEtag({
- etagString: 'custom',
+ const response = await fetchAndCheckEtag({
+ oldEtag: await loadCacheValue('custom'),
path: '/api/v1/custom_emojis',
});
- if (!emojis) {
+
+ if (!response) {
return;
}
+
+ const etag = response.headers.get('ETag');
+ if (etag) {
+ log('Custom emoji data fetched successfully, storing etag %s', etag);
+ await putCacheValue('custom', etag);
+ } else {
+ log('No etag found in response for custom emoji data');
+ }
+
+ const emojis = (await response.json()) as CustomEmojiData[];
await putCustomEmojiData({ emojis, clear: true });
return emojis;
}
@@ -72,9 +83,8 @@ export async function importLegacyShortcodes() {
if (!path) {
throw new Error('IAMCAL shortcodes path not found');
}
- const shortcodesData = await fetchAndCheckEtag({
- checkEtag: true,
- etagString: 'shortcodes',
+ const shortcodesData = await fetchIfNotLoaded({
+ key: 'shortcodes',
path,
});
if (!shortcodesData) {
@@ -118,48 +128,64 @@ function localeToShortcodesPath(locale: Locale) {
return path;
}
-async function fetchAndCheckEtag({
- etagString,
+async function fetchIfNotLoaded({
+ key: rawKey,
path,
- checkEtag = false,
}: {
- etagString: string;
+ key: string;
path: string;
- checkEtag?: boolean;
}): Promise {
- const etagName = toValidEtagName(etagString);
- const oldEtag = checkEtag ? await loadLatestEtag(etagName) : null;
+ const key = toValidCacheKey(rawKey);
+
+ const value = await loadCacheValue(key);
+
+ if (value === path) {
+ log('data for %s already loaded, skipping fetch', key);
+ return null;
+ }
+
+ const response = await fetchAndCheckEtag({ path });
+ if (!response) {
+ return null;
+ }
+
+ log('data for %s fetched successfully, storing etag', key);
+ await putCacheValue(key, path);
+
+ return (await response.json()) as ResultType;
+}
+
+async function fetchAndCheckEtag({
+ oldEtag,
+ path,
+}: {
+ oldEtag?: string;
+ path: string;
+}) {
+ const headers = new Headers({
+ 'Content-Type': 'application/json',
+ });
+ if (oldEtag) {
+ headers.set('If-None-Match', oldEtag);
+ }
// Use location.origin as this script may be loaded from a CDN domain.
const url = new URL(path, location.origin);
-
const response = await fetch(url, {
- headers: {
- 'Content-Type': 'application/json',
- 'If-None-Match': oldEtag ?? '', // Send the old ETag to check for modifications
- },
+ headers,
});
+
// If not modified, return null
if (response.status === 304) {
- log('etag not modified for %s', etagName);
+ log('etag not modified for %s', path);
return null;
}
+
if (!response.ok) {
throw new Error(
- `Failed to fetch emoji data for ${etagName}: ${response.statusText}`,
+ `Failed to fetch emoji data for ${path}: ${response.statusText}`,
);
}
- const data = (await response.json()) as ResultType;
-
- // Store the ETag for future requests
- const etag = response.headers.get('ETag');
- if (etag && checkEtag) {
- log(`storing new etag for ${etagName}: ${etag}`);
- await putLatestEtag(etag, etagName);
- } else if (!etag) {
- log(`no etag found in response for ${etagName}`);
- }
-
- return data;
+ return response;
}
diff --git a/app/javascript/mastodon/features/emoji/locale.ts b/app/javascript/mastodon/features/emoji/locale.ts
index e8f2df340f1e6d..8f4e1d4adc29e3 100644
--- a/app/javascript/mastodon/features/emoji/locale.ts
+++ b/app/javascript/mastodon/features/emoji/locale.ts
@@ -2,7 +2,7 @@ import type { Locale } from 'emojibase';
import { SUPPORTED_LOCALES } from 'emojibase';
import { EMOJI_DB_NAME_SHORTCODES, EMOJI_TYPE_CUSTOM } from './constants';
-import type { EtagTypes, LocaleOrCustom, LocaleWithShortcodes } from './types';
+import type { CacheKey, LocaleOrCustom, LocaleWithShortcodes } from './types';
export function toSupportedLocale(localeBase: string): Locale {
const locale = localeBase.toLowerCase();
@@ -19,7 +19,7 @@ export function toSupportedLocaleOrCustom(locale: string): LocaleOrCustom {
return toSupportedLocale(locale);
}
-export function toValidEtagName(input: string): EtagTypes {
+export function toValidCacheKey(input: string): CacheKey {
const lower = input.toLowerCase();
if (lower === EMOJI_TYPE_CUSTOM || lower === EMOJI_DB_NAME_SHORTCODES) {
return lower;
diff --git a/app/javascript/mastodon/features/emoji/normalize.ts b/app/javascript/mastodon/features/emoji/normalize.ts
index bf06e058ef65b6..f19b300f3f0760 100644
--- a/app/javascript/mastodon/features/emoji/normalize.ts
+++ b/app/javascript/mastodon/features/emoji/normalize.ts
@@ -181,7 +181,7 @@ export function emojiToInversionClassName(emoji: string): string | null {
return null;
}
-export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
+export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg | null) {
if (!extraEmojis) {
return null;
}
diff --git a/app/javascript/mastodon/features/emoji/types.ts b/app/javascript/mastodon/features/emoji/types.ts
index 458bcd0c7cb210..f1444daf8ea51c 100644
--- a/app/javascript/mastodon/features/emoji/types.ts
+++ b/app/javascript/mastodon/features/emoji/types.ts
@@ -22,7 +22,7 @@ export type EmojiMode =
export type LocaleOrCustom = Locale | typeof EMOJI_TYPE_CUSTOM;
export type LocaleWithShortcodes = `${Locale}-shortcodes`;
-export type EtagTypes =
+export type CacheKey =
| LocaleOrCustom
| typeof EMOJI_DB_NAME_SHORTCODES
| LocaleWithShortcodes;
diff --git a/app/javascript/mastodon/features/emoji_reactions/index.jsx b/app/javascript/mastodon/features/emoji_reactions/index.jsx
index 46a1c3388e221a..05d057dc654702 100644
--- a/app/javascript/mastodon/features/emoji_reactions/index.jsx
+++ b/app/javascript/mastodon/features/emoji_reactions/index.jsx
@@ -102,11 +102,11 @@ class EmojiReactions extends ImmutablePureComponent {
bindToDocument={!multiColumn}
>
{Object.keys(groups).map((key) =>(
-
+
{groups[key].map((value, index2) => )}
-
+ )} />
))}
diff --git a/app/javascript/mastodon/features/followers/components/empty.tsx b/app/javascript/mastodon/features/followers/components/empty.tsx
new file mode 100644
index 00000000000000..eee034998aeeb0
--- /dev/null
+++ b/app/javascript/mastodon/features/followers/components/empty.tsx
@@ -0,0 +1,75 @@
+import type { FC, ReactNode } from 'react';
+
+import { FormattedMessage } from 'react-intl';
+
+import { LimitedAccountHint } from '@/mastodon/features/account_timeline/components/limited_account_hint';
+import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
+import { isHideItem, me } from '@/mastodon/initial_state';
+import type { Account } from '@/mastodon/models/account';
+
+import { RemoteHint } from './remote';
+
+interface BaseEmptyMessageProps {
+ account?: Account;
+ defaultMessage: ReactNode;
+}
+export type EmptyMessageProps = Omit