Merge pull request #3394 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to fb89198460
This commit is contained in:
Claire
2026-02-11 12:52:19 +01:00
committed by GitHub
27 changed files with 874 additions and 396 deletions

View File

@@ -5,7 +5,6 @@ import { useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { useIdentity } from '@/flavours/glitch/identity_context';
import { isClientFeatureEnabled } from '@/flavours/glitch/utils/environment';
import {
fetchRelationships,
followAccount,
@@ -60,7 +59,14 @@ export const FollowButton: React.FC<{
compact?: boolean;
labelLength?: 'auto' | 'short' | 'long';
className?: string;
}> = ({ accountId, compact, labelLength = 'auto', className }) => {
withUnmute?: boolean;
}> = ({
accountId,
compact,
labelLength = 'auto',
className,
withUnmute = true,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
@@ -102,10 +108,7 @@ export const FollowButton: React.FC<{
modalProps: { account },
}),
);
} else if (
relationship.muting &&
!isClientFeatureEnabled('profile_redesign')
) {
} else if (relationship.muting && withUnmute) {
dispatch(unmuteAccount(accountId));
} else if (account && relationship.following) {
dispatch(
@@ -121,7 +124,7 @@ export const FollowButton: React.FC<{
} else {
dispatch(followAccount(accountId));
}
}, [dispatch, accountId, relationship, account, signedIn]);
}, [signedIn, relationship, accountId, withUnmute, account, dispatch]);
const isNarrow = useBreakpoint('narrow');
const useShortLabel =
@@ -140,10 +143,7 @@ export const FollowButton: React.FC<{
label = intl.formatMessage(messages.edit_profile);
} else if (!relationship) {
label = <LoadingIndicator />;
} else if (
relationship.muting &&
!isClientFeatureEnabled('profile_redesign')
) {
} else if (relationship.muting && withUnmute) {
label = intl.formatMessage(messages.unmute);
} else if (relationship.following) {
label = intl.formatMessage(messages.unfollow);

View File

@@ -1,23 +1,24 @@
import type { RefCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { openModal } from '@/flavours/glitch/actions/modal';
import { AccountBio } from '@/flavours/glitch/components/account_bio';
import { Avatar } from '@/flavours/glitch/components/avatar';
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
import { openModal } from 'flavours/glitch/actions/modal';
import { Avatar } from 'flavours/glitch/components/avatar';
import { AccountNote } from 'flavours/glitch/features/account/components/account_note';
import FollowRequestNoteContainer from 'flavours/glitch/features/account/containers/follow_request_note_container';
import { AccountNote } from '@/flavours/glitch/features/account/components/account_note';
import FollowRequestNoteContainer from '@/flavours/glitch/features/account/containers/follow_request_note_container';
import { useLayout } from '@/flavours/glitch/hooks/useLayout';
import { useVisibility } from '@/flavours/glitch/hooks/useVisibility';
import {
autoPlayGif,
me,
domain as localDomain,
} from 'flavours/glitch/initial_state';
import type { Account } from 'flavours/glitch/models/account';
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
} from '@/flavours/glitch/initial_state';
import type { Account } from '@/flavours/glitch/models/account';
import { getAccountHidden } from '@/flavours/glitch/selectors/accounts';
import { useAppSelector, useAppDispatch } from '@/flavours/glitch/store';
import { ActionBar } from '../../account/components/action_bar';
import { isRedesignEnabled } from '../common';
@@ -50,6 +51,8 @@ export const AccountHeader: React.FC<{
accountId: string;
hideTabs?: boolean;
}> = ({ accountId, hideTabs }) => {
const isRedesign = isRedesignEnabled();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const relationship = useAppSelector((state) =>
@@ -82,39 +85,12 @@ export const AccountHeader: React.FC<{
[dispatch, account],
);
const [isFooterIntersecting, setIsIntersecting] = useState(false);
const handleIntersect: IntersectionObserverCallback = useCallback(
(entries) => {
const entry = entries.at(0);
if (!entry) {
return;
}
setIsIntersecting(entry.isIntersecting);
const { layout } = useLayout();
const { observedRef, isIntersecting } = useVisibility({
observerOptions: {
rootMargin: layout === 'mobile' ? '0px 0px -55px 0px' : '', // Height of bottom nav bar.
},
[],
);
const [observer] = useState(
() =>
new IntersectionObserver(handleIntersect, {
rootMargin: '0px 0px -55px 0px', // Height of bottom nav bar.
}),
);
const handleObserverRef: RefCallback<HTMLDivElement> = useCallback(
(node) => {
if (node) {
observer.observe(node);
}
},
[observer],
);
useEffect(() => {
return () => {
observer.disconnect();
};
}, [observer]);
});
if (!account) {
return null;
@@ -122,6 +98,7 @@ export const AccountHeader: React.FC<{
const suspendedOrHidden = hidden || account.suspended;
const isLocal = !account.acct.includes('@');
const isMe = me && account.id === me;
return (
<div className='account-timeline__header'>
@@ -139,8 +116,13 @@ export const AccountHeader: React.FC<{
<FollowRequestNoteContainer account={account} />
)}
<div className='account__header__image'>
{me !== account.id && relationship && !isRedesignEnabled() && (
<div
className={classNames(
'account__header__image',
isRedesign && redesignClasses.header,
)}
>
{me !== account.id && relationship && !isRedesign && (
<AccountInfo relationship={relationship} />
)}
@@ -156,10 +138,15 @@ export const AccountHeader: React.FC<{
<div
className={classNames(
'account__header__bar',
isRedesignEnabled() && redesignClasses.barWrapper,
isRedesign && redesignClasses.barWrapper,
)}
>
<div className='account__header__tabs'>
<div
className={classNames(
'account__header__tabs',
isRedesign && redesignClasses.avatarWrapper,
)}
>
<a
className='avatar'
href={account.avatar}
@@ -169,11 +156,11 @@ export const AccountHeader: React.FC<{
>
<Avatar
account={suspendedOrHidden ? undefined : account}
size={92}
size={isRedesign ? 80 : 92}
/>
</a>
{!isRedesignEnabled() && (
{!isRedesign && (
<AccountButtons
accountId={accountId}
className='account__header__buttons--desktop'
@@ -184,26 +171,27 @@ export const AccountHeader: React.FC<{
<div
className={classNames(
'account__header__tabs__name',
isRedesignEnabled() && redesignClasses.nameWrapper,
isRedesign && redesignClasses.nameWrapper,
)}
>
<AccountName accountId={accountId} />
{isRedesignEnabled() && (
{isRedesign && (
<AccountButtons
accountId={accountId}
className={redesignClasses.buttonsDesktop}
noShare
noShare={!isMe || 'share' in navigator}
forceMenu={'share' in navigator}
/>
)}
</div>
<AccountBadges accountId={accountId} />
{me && account.id !== me && !suspendedOrHidden && (
{!isMe && !suspendedOrHidden && (
<FamiliarFollowers accountId={accountId} />
)}
{!isRedesignEnabled() && (
{!isRedesign && (
<AccountButtons
className='account__header__buttons--mobile'
accountId={accountId}
@@ -216,7 +204,7 @@ export const AccountHeader: React.FC<{
<div className='account__header__bio'>
{me &&
account.id !== me &&
(isRedesignEnabled() ? (
(isRedesign ? (
<AccountNoteRedesign accountId={accountId} />
) : (
<AccountNote accountId={accountId} />
@@ -232,11 +220,11 @@ export const AccountHeader: React.FC<{
</div>
)}
{isRedesignEnabled() && (
{isRedesign && (
<AccountButtons
className={classNames(
redesignClasses.buttonsMobile,
!isFooterIntersecting && redesignClasses.buttonsMobileIsStuck,
!isIntersecting && redesignClasses.buttonsMobileIsStuck,
)}
accountId={accountId}
noShare
@@ -248,7 +236,7 @@ export const AccountHeader: React.FC<{
<ActionBar account={account} />
{!hideTabs && !hidden && <AccountTabs acct={account.acct} />}
<div ref={handleObserverRef} />
<div ref={observedRef} />
<Helmet>
<title>{titleFromAccount(account)}</title>

View File

@@ -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 '@/flavours/glitch/components/display_name';
import { Icon } from '@/flavours/glitch/components/icon';
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
import { useAppSelector } from '@/flavours/glitch/store';
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,6 +21,18 @@ import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
const messages = defineMessages({
lockedInfo: {
id: 'account.locked_info',
defaultMessage:
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
},
nameInfo: {
id: 'account.name_info',
defaultMessage: 'What does this mean?',
},
});
export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const account = useAccount(accountId);
@@ -46,11 +65,7 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
<Icon
id='lock'
icon={LockIcon}
aria-label={intl.formatMessage({
id: 'account.locked_info',
defaultMessage:
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
})}
aria-label={intl.formatMessage(messages.lockedInfo)}
/>
)}
</small>
@@ -65,15 +80,112 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
</h1>
<p className={classes.username}>
@{username}@{domain}
<DomainPill
<AccountNameHelp
username={username}
domain={domain}
isSelf={me === account.id}
className={classes.domainPill}
>
<Icon id='help' icon={HelpIcon} />
</DomainPill>
isSelf={account.id === me}
/>
</p>
</div>
);
};
const AccountNameHelp: FC<{
username: string;
domain: string;
isSelf: boolean;
}> = ({ username, domain, isSelf }) => {
const accessibilityId = useId();
const intl = useIntl();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const handleClick = useCallback(() => {
setOpen((prev) => !prev);
}, []);
return (
<>
<button
type='button'
ref={triggerRef}
className={classes.handleHelpButton}
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
>
<Icon
id='help'
icon={HelpIcon}
aria-label={intl.formatMessage(messages.nameInfo)}
/>
</button>
<Overlay
show={open}
rootClose
target={triggerRef}
onHide={handleClick}
offset={[5, 5]}
>
{({ props }) => (
<div
{...props}
role='region'
id={accessibilityId}
className={classNames('dropdown-animation', classes.handleHelp)}
>
<FormattedMessage
id='account.name.help.header'
defaultMessage='A handle is like an email address'
tagName='h3'
/>
<ol>
<li>
<Icon id='at' icon={AtIcon} />
{isSelf ? (
<FormattedMessage
id='account.name.help.username_self'
defaultMessage='{username} is your username on this server. Someone on another server might have the same username.'
values={{ username: <strong>{username}</strong> }}
tagName='p'
/>
) : (
<FormattedMessage
id='account.name.help.username'
defaultMessage='{username} is this accounts username on their server. Someone on another server might have the same username.'
values={{ username: <strong>{username}</strong> }}
tagName='p'
/>
)}
</li>
<li>
<Icon id='domain' icon={DomainIcon} />
{isSelf ? (
<FormattedMessage
id='account.name.help.domain_self'
defaultMessage='{domain} is your server that hosts your profile and posts.'
values={{ domain: <strong>{domain}</strong> }}
tagName='p'
/>
) : (
<FormattedMessage
id='account.name.help.domain'
defaultMessage='{domain} is the server that hosts the users profile and posts.'
values={{ domain: <strong>{domain}</strong> }}
tagName='p'
/>
)}
</li>
</ol>
<FormattedMessage
id='account.name.help.footer'
defaultMessage='Just like you can send emails to people using different email clients, you can interact with people on other Mastodon servers and with anyone on other social apps powered by the same set of rules as Mastodon uses (the ActivityPub protocol).'
tagName='p'
/>
</div>
)}
</Overlay>
</>
);
};

View File

@@ -46,7 +46,8 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
return null;
}
const className = isRedesignEnabled() ? classes.badge : '';
const isRedesign = isRedesignEnabled();
const className = isRedesign ? classes.badge : '';
const domain = account.acct.includes('@')
? account.acct.split('@')[1]
@@ -68,7 +69,7 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
key={role.id}
label={role.name}
className={className}
domain={isRedesignEnabled() ? `(${domain})` : domain}
domain={isRedesign ? `(${domain})` : domain}
roleId={role.id}
/>,
);
@@ -81,7 +82,7 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
if (account.group) {
badges.push(<GroupBadge key='group-badge' className={className} />);
}
if (isRedesignEnabled() && relationship) {
if (isRedesign && relationship) {
if (relationship.blocking) {
badges.push(
<BlockedBadge
@@ -89,7 +90,8 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
className={classNames(className, classes.badgeBlocked)}
/>,
);
} else if (relationship.domain_blocking) {
}
if (relationship.domain_blocking) {
badges.push(
<BlockedBadge
key='domain-blocking'
@@ -103,7 +105,8 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
}
/>,
);
} else if (relationship.muting) {
}
if (relationship.muting) {
badges.push(
<MutedBadge
key='muted-badge'

View File

@@ -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<AccountButtonsProps> = ({
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<AccountButtonsProps> = ({
{!hidden && (
<AccountButtonsOther accountId={accountId} noShare={noShare} />
)}
{accountId !== me && <AccountMenu accountId={accountId} />}
{(accountId !== me || forceMenu) && <AccountMenu accountId={accountId} />}
</div>
);
};
@@ -93,6 +97,7 @@ const AccountButtonsOther: FC<
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
withUnmute={!isRedesignEnabled()}
/>
)}
{isFollowing && (

View File

@@ -37,7 +37,6 @@ import {
import type { AppDispatch } from '@/flavours/glitch/store';
import { useAppDispatch, useAppSelector } from '@/flavours/glitch/store';
import BlockIcon from '@/material-icons/400-24px/block.svg?react';
import EditIcon from '@/material-icons/400-24px/edit_square.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';
@@ -54,6 +53,10 @@ export const AccountMenu: FC<{ accountId: string }> = ({ 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(() => {
@@ -64,7 +67,7 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
if (isRedesignEnabled()) {
return redesignMenuItems({
account,
signedIn,
signedIn: !isMe && signedIn,
permissions,
intl,
relationship,
@@ -79,7 +82,7 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
relationship,
dispatch,
});
}, [account, signedIn, permissions, intl, relationship, dispatch]);
}, [account, signedIn, isMe, permissions, intl, relationship, dispatch]);
return (
<Dropdown
disabled={menuItems.length === 0}
@@ -446,6 +449,10 @@ const redesignMessages = defineMessages({
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',
@@ -516,22 +523,30 @@ function redesignMenuItems({
icon: ShareIcon,
});
}
items.push(
{
text: intl.formatMessage(redesignMessages.copy),
action: () => {
void navigator.clipboard.writeText(account.url);
dispatch(showAlert({ message: redesignMessages.copied }));
},
icon: LinkIcon,
items.push({
text: intl.formatMessage(redesignMessages.copy),
action: () => {
void navigator.clipboard.writeText(account.url);
dispatch(showAlert({ message: redesignMessages.copied }));
},
null,
);
icon: LinkIcon,
});
}
// Open on remote page.
if (isRemote) {
items.push({
text: intl.formatMessage(redesignMessages.openOriginalPage, {
domain: remoteDomain,
}),
href: account.url,
});
}
// Mention and direct message options
if (signedIn && !account.suspended) {
items.push(
null,
{
text: intl.formatMessage(redesignMessages.mention),
action: () => {
@@ -546,36 +561,6 @@ function redesignMenuItems({
},
},
null,
{
text: intl.formatMessage(
relationship?.note ? messages.editNote : messages.addNote,
),
action: () => {
dispatch(
openModal({
modalType: 'ACCOUNT_NOTE',
modalProps: {
accountId: account.id,
},
}),
);
},
icon: EditIcon,
},
null,
);
}
// Open on remote page.
if (isRemote) {
items.push(
{
text: intl.formatMessage(redesignMessages.openOriginalPage, {
domain: remoteDomain,
}),
href: account.url,
},
null,
);
}
@@ -611,59 +596,78 @@ function redesignMenuItems({
}
},
},
null,
);
}
// Timeline options
if (!relationship.muting) {
items.push(
{
text: intl.formatMessage(
relationship.showing_reblogs
? redesignMessages.hideReblogs
: redesignMessages.showReblogs,
),
action: () => {
dispatch(
followAccount(account.id, {
reblogs: !relationship.showing_reblogs,
}),
);
},
},
{
text: intl.formatMessage(messages.languages),
action: () => {
dispatch(
openModal({
modalType: 'SUBSCRIBED_LANGUAGES',
modalProps: {
accountId: account.id,
},
}),
);
},
},
);
}
items.push(
{
text: intl.formatMessage(
relationship?.note ? messages.editNote : messages.addNote,
),
description: intl.formatMessage(redesignMessages.noteDescription),
action: () => {
dispatch(
openModal({
modalType: 'ACCOUNT_NOTE',
modalProps: {
accountId: account.id,
},
}),
);
},
},
null,
);
// Timeline options
if (relationship && !relationship.muting) {
items.push(
{
text: intl.formatMessage(
relationship.muting ? redesignMessages.unmute : redesignMessages.mute,
relationship.showing_reblogs
? redesignMessages.hideReblogs
: redesignMessages.showReblogs,
),
action: () => {
if (relationship.muting) {
dispatch(unmuteAccount(account.id));
} else {
dispatch(initMuteModal(account));
}
dispatch(
followAccount(account.id, {
reblogs: !relationship.showing_reblogs,
}),
);
},
},
{
text: intl.formatMessage(messages.languages),
action: () => {
dispatch(
openModal({
modalType: 'SUBSCRIBED_LANGUAGES',
modalProps: {
accountId: account.id,
},
}),
);
},
},
null,
);
}
items.push(
{
text: intl.formatMessage(
relationship?.muting ? redesignMessages.unmute : redesignMessages.mute,
),
action: () => {
if (relationship?.muting) {
dispatch(unmuteAccount(account.id));
} else {
dispatch(initMuteModal(account));
}
},
},
null,
);
if (relationship?.followed_by) {
items.push({
text: intl.formatMessage(redesignMessages.removeFollower),
@@ -725,7 +729,7 @@ function redesignMenuItems({
}
if (remoteDomain) {
items.push({
items.push(null, {
text: intl.formatMessage(
relationship?.domain_blocking
? redesignMessages.domainUnblock

View File

@@ -63,7 +63,7 @@ export const AccountNote: FC<{ accountId: string }> = ({ accountId }) => {
/>
}
>
{relationship.note}
<div className={classes.noteContent}>{relationship.note}</div>
</Callout>
);
};

View File

@@ -70,24 +70,22 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
</NavLink>
{isRedesignEnabled() && (
<NavLink exact to={`/@${account.acct}`}>
<FormattedMessage
id='account.joined_long'
defaultMessage='Joined on {date}'
values={{
date: (
<strong>
<FormattedDateWrapper
value={account.created_at}
year='numeric'
month='short'
day='2-digit'
/>
</strong>
),
}}
/>
</NavLink>
<FormattedMessage
id='account.joined_long'
defaultMessage='Joined on {date}'
values={{
date: (
<strong>
<FormattedDateWrapper
value={account.created_at}
year='numeric'
month='short'
day='2-digit'
/>
</strong>
),
}}
/>
)}
</div>
);

View File

@@ -1,9 +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;
}
@@ -12,6 +27,10 @@
font-size: 22px;
white-space: initial;
line-height: normal;
> h1 {
white-space: initial;
}
}
.username {
@@ -23,15 +42,13 @@
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;
@@ -43,12 +60,53 @@
}
&:hover,
&:global(.active) {
background: none;
&: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;
}
strong {
font-weight: 600;
}
}
$button-breakpoint: 420px;
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
@@ -66,7 +124,9 @@ $button-fallback-breakpoint: #{$button-breakpoint} + 55px;
.buttonsMobile {
position: sticky;
bottom: 55px; // Height of bottom nav bar.
bottom: var(--mobile-bottom-nav-height);
padding: 12px 16px;
margin: 0 -20px;
@container (width >= #{$button-breakpoint}) {
display: none;
@@ -77,13 +137,16 @@ $button-fallback-breakpoint: #{$button-breakpoint} + 55px;
display: none;
}
}
// Multi-column layout
@media (width >= #{$button-breakpoint}) {
bottom: 0;
}
}
.buttonsMobileIsStuck {
padding: 12px 16px;
background-color: var(--color-bg-primary);
border-top: 1px solid var(--color-border-primary);
margin: 0 -20px;
}
.badge {
@@ -116,6 +179,10 @@ svg.badgeIcon {
margin-bottom: 16px;
}
.noteContent {
white-space-collapse: preserve-breaks;
}
.noteEditButton {
color: inherit;
@@ -237,6 +304,11 @@ svg.badgeIcon {
a {
font-weight: unset;
}
strong {
font-weight: 600;
color: var(--color-text-primary);
}
}
.modalCloseButton {

View File

@@ -100,7 +100,7 @@
.pinnedStatusHeader {
display: grid;
grid-template-columns: max-content auto;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr 1fr;
gap: 4px;

View File

@@ -0,0 +1,45 @@
import type { RefCallback } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
export function useVisibility({
observerOptions,
}: {
observerOptions?: IntersectionObserverInit;
} = {}) {
const [isIntersecting, setIsIntersecting] = useState(false);
const handleIntersect: IntersectionObserverCallback = useCallback(
(entries) => {
const entry = entries.at(0);
if (!entry) {
return;
}
setIsIntersecting(entry.isIntersecting);
},
[],
);
const observer = useMemo(
() => new IntersectionObserver(handleIntersect, observerOptions),
[handleIntersect, observerOptions],
);
const handleObserverRef: RefCallback<HTMLElement> = useCallback(
(node) => {
if (node) {
observer.observe(node);
}
},
[observer],
);
useEffect(() => {
return () => {
observer.disconnect();
};
}, [observer]);
return {
isIntersecting,
observedRef: handleObserverRef,
};
}

View File

@@ -2934,6 +2934,7 @@ a.account__display-name {
}
&-subtitle {
color: var(--color-text-tertiary);
font-weight: 400;
}
@@ -2976,6 +2977,10 @@ a.account__display-name {
outline: 0;
}
}
button:is(:disabled, [aria-disabled='true']) &-subtitle {
color: inherit;
}
}
.reblog-menu-item {

View File

@@ -5,7 +5,6 @@ import { useIntl, defineMessages } from 'react-intl';
import classNames from 'classnames';
import { useIdentity } from '@/mastodon/identity_context';
import { isClientFeatureEnabled } from '@/mastodon/utils/environment';
import {
fetchRelationships,
followAccount,
@@ -60,7 +59,14 @@ export const FollowButton: React.FC<{
compact?: boolean;
labelLength?: 'auto' | 'short' | 'long';
className?: string;
}> = ({ accountId, compact, labelLength = 'auto', className }) => {
withUnmute?: boolean;
}> = ({
accountId,
compact,
labelLength = 'auto',
className,
withUnmute = true,
}) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
@@ -102,10 +108,7 @@ export const FollowButton: React.FC<{
modalProps: { account },
}),
);
} else if (
relationship.muting &&
!isClientFeatureEnabled('profile_redesign')
) {
} else if (relationship.muting && withUnmute) {
dispatch(unmuteAccount(accountId));
} else if (account && relationship.following) {
dispatch(
@@ -121,7 +124,7 @@ export const FollowButton: React.FC<{
} else {
dispatch(followAccount(accountId));
}
}, [dispatch, accountId, relationship, account, signedIn]);
}, [signedIn, relationship, accountId, withUnmute, account, dispatch]);
const isNarrow = useBreakpoint('narrow');
const useShortLabel =
@@ -140,10 +143,7 @@ export const FollowButton: React.FC<{
label = intl.formatMessage(messages.editProfile);
} else if (!relationship) {
label = <LoadingIndicator />;
} else if (
relationship.muting &&
!isClientFeatureEnabled('profile_redesign')
) {
} else if (relationship.muting && withUnmute) {
label = intl.formatMessage(messages.unmute);
} else if (relationship.following) {
label = intl.formatMessage(messages.unfollow);

View File

@@ -1,19 +1,24 @@
import type { RefCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback } from 'react';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { openModal } from '@/mastodon/actions/modal';
import { AccountBio } from '@/mastodon/components/account_bio';
import { Avatar } from '@/mastodon/components/avatar';
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
import { openModal } from 'mastodon/actions/modal';
import { Avatar } from 'mastodon/components/avatar';
import { AccountNote } from 'mastodon/features/account/components/account_note';
import FollowRequestNoteContainer from 'mastodon/features/account/containers/follow_request_note_container';
import { autoPlayGif, me, domain as localDomain } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { AccountNote } from '@/mastodon/features/account/components/account_note';
import FollowRequestNoteContainer from '@/mastodon/features/account/containers/follow_request_note_container';
import { useLayout } from '@/mastodon/hooks/useLayout';
import { useVisibility } from '@/mastodon/hooks/useVisibility';
import {
autoPlayGif,
me,
domain as localDomain,
} from '@/mastodon/initial_state';
import type { Account } from '@/mastodon/models/account';
import { getAccountHidden } from '@/mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
import { isRedesignEnabled } from '../common';
@@ -46,6 +51,8 @@ export const AccountHeader: React.FC<{
accountId: string;
hideTabs?: boolean;
}> = ({ accountId, hideTabs }) => {
const isRedesign = isRedesignEnabled();
const dispatch = useAppDispatch();
const account = useAppSelector((state) => state.accounts.get(accountId));
const relationship = useAppSelector((state) =>
@@ -78,39 +85,12 @@ export const AccountHeader: React.FC<{
[dispatch, account],
);
const [isFooterIntersecting, setIsIntersecting] = useState(false);
const handleIntersect: IntersectionObserverCallback = useCallback(
(entries) => {
const entry = entries.at(0);
if (!entry) {
return;
}
setIsIntersecting(entry.isIntersecting);
const { layout } = useLayout();
const { observedRef, isIntersecting } = useVisibility({
observerOptions: {
rootMargin: layout === 'mobile' ? '0px 0px -55px 0px' : '', // Height of bottom nav bar.
},
[],
);
const [observer] = useState(
() =>
new IntersectionObserver(handleIntersect, {
rootMargin: '0px 0px -55px 0px', // Height of bottom nav bar.
}),
);
const handleObserverRef: RefCallback<HTMLDivElement> = useCallback(
(node) => {
if (node) {
observer.observe(node);
}
},
[observer],
);
useEffect(() => {
return () => {
observer.disconnect();
};
}, [observer]);
});
if (!account) {
return null;
@@ -118,6 +98,7 @@ export const AccountHeader: React.FC<{
const suspendedOrHidden = hidden || account.suspended;
const isLocal = !account.acct.includes('@');
const isMe = me && account.id === me;
return (
<div className='account-timeline__header'>
@@ -135,8 +116,13 @@ export const AccountHeader: React.FC<{
<FollowRequestNoteContainer account={account} />
)}
<div className='account__header__image'>
{me !== account.id && relationship && !isRedesignEnabled() && (
<div
className={classNames(
'account__header__image',
isRedesign && redesignClasses.header,
)}
>
{me !== account.id && relationship && !isRedesign && (
<AccountInfo relationship={relationship} />
)}
@@ -152,10 +138,15 @@ export const AccountHeader: React.FC<{
<div
className={classNames(
'account__header__bar',
isRedesignEnabled() && redesignClasses.barWrapper,
isRedesign && redesignClasses.barWrapper,
)}
>
<div className='account__header__tabs'>
<div
className={classNames(
'account__header__tabs',
isRedesign && redesignClasses.avatarWrapper,
)}
>
<a
className='avatar'
href={account.avatar}
@@ -165,11 +156,11 @@ export const AccountHeader: React.FC<{
>
<Avatar
account={suspendedOrHidden ? undefined : account}
size={92}
size={isRedesign ? 80 : 92}
/>
</a>
{!isRedesignEnabled() && (
{!isRedesign && (
<AccountButtons
accountId={accountId}
className='account__header__buttons--desktop'
@@ -180,26 +171,27 @@ export const AccountHeader: React.FC<{
<div
className={classNames(
'account__header__tabs__name',
isRedesignEnabled() && redesignClasses.nameWrapper,
isRedesign && redesignClasses.nameWrapper,
)}
>
<AccountName accountId={accountId} />
{isRedesignEnabled() && (
{isRedesign && (
<AccountButtons
accountId={accountId}
className={redesignClasses.buttonsDesktop}
noShare
noShare={!isMe || 'share' in navigator}
forceMenu={'share' in navigator}
/>
)}
</div>
<AccountBadges accountId={accountId} />
{me && account.id !== me && !suspendedOrHidden && (
{!isMe && !suspendedOrHidden && (
<FamiliarFollowers accountId={accountId} />
)}
{!isRedesignEnabled() && (
{!isRedesign && (
<AccountButtons
className='account__header__buttons--mobile'
accountId={accountId}
@@ -212,7 +204,7 @@ export const AccountHeader: React.FC<{
<div className='account__header__bio'>
{me &&
account.id !== me &&
(isRedesignEnabled() ? (
(isRedesign ? (
<AccountNoteRedesign accountId={accountId} />
) : (
<AccountNote accountId={accountId} />
@@ -230,11 +222,11 @@ export const AccountHeader: React.FC<{
</div>
)}
{isRedesignEnabled() && (
{isRedesign && (
<AccountButtons
className={classNames(
redesignClasses.buttonsMobile,
!isFooterIntersecting && redesignClasses.buttonsMobileIsStuck,
!isIntersecting && redesignClasses.buttonsMobileIsStuck,
)}
accountId={accountId}
noShare
@@ -244,7 +236,7 @@ export const AccountHeader: React.FC<{
</AnimateEmojiProvider>
{!hideTabs && !hidden && <AccountTabs acct={account.acct} />}
<div ref={handleObserverRef} />
<div ref={observedRef} />
<Helmet>
<title>{titleFromAccount(account)}</title>

View File

@@ -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 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,6 +21,18 @@ import { isRedesignEnabled } from '../common';
import classes from './redesign.module.scss';
const messages = defineMessages({
lockedInfo: {
id: 'account.locked_info',
defaultMessage:
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
},
nameInfo: {
id: 'account.name_info',
defaultMessage: 'What does this mean?',
},
});
export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const account = useAccount(accountId);
@@ -46,11 +65,7 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
<Icon
id='lock'
icon={LockIcon}
aria-label={intl.formatMessage({
id: 'account.locked_info',
defaultMessage:
'This account privacy status is set to locked. The owner manually reviews who can follow them.',
})}
aria-label={intl.formatMessage(messages.lockedInfo)}
/>
)}
</small>
@@ -65,15 +80,112 @@ export const AccountName: FC<{ accountId: string }> = ({ accountId }) => {
</h1>
<p className={classes.username}>
@{username}@{domain}
<DomainPill
<AccountNameHelp
username={username}
domain={domain}
isSelf={me === account.id}
className={classes.domainPill}
>
<Icon id='help' icon={HelpIcon} />
</DomainPill>
isSelf={account.id === me}
/>
</p>
</div>
);
};
const AccountNameHelp: FC<{
username: string;
domain: string;
isSelf: boolean;
}> = ({ username, domain, isSelf }) => {
const accessibilityId = useId();
const intl = useIntl();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const handleClick = useCallback(() => {
setOpen((prev) => !prev);
}, []);
return (
<>
<button
type='button'
ref={triggerRef}
className={classes.handleHelpButton}
onClick={handleClick}
aria-expanded={open}
aria-controls={accessibilityId}
>
<Icon
id='help'
icon={HelpIcon}
aria-label={intl.formatMessage(messages.nameInfo)}
/>
</button>
<Overlay
show={open}
rootClose
target={triggerRef}
onHide={handleClick}
offset={[5, 5]}
>
{({ props }) => (
<div
{...props}
role='region'
id={accessibilityId}
className={classNames('dropdown-animation', classes.handleHelp)}
>
<FormattedMessage
id='account.name.help.header'
defaultMessage='A handle is like an email address'
tagName='h3'
/>
<ol>
<li>
<Icon id='at' icon={AtIcon} />
{isSelf ? (
<FormattedMessage
id='account.name.help.username_self'
defaultMessage='{username} is your username on this server. Someone on another server might have the same username.'
values={{ username: <strong>{username}</strong> }}
tagName='p'
/>
) : (
<FormattedMessage
id='account.name.help.username'
defaultMessage='{username} is this accounts username on their server. Someone on another server might have the same username.'
values={{ username: <strong>{username}</strong> }}
tagName='p'
/>
)}
</li>
<li>
<Icon id='domain' icon={DomainIcon} />
{isSelf ? (
<FormattedMessage
id='account.name.help.domain_self'
defaultMessage='{domain} is your server that hosts your profile and posts.'
values={{ domain: <strong>{domain}</strong> }}
tagName='p'
/>
) : (
<FormattedMessage
id='account.name.help.domain'
defaultMessage='{domain} is the server that hosts the users profile and posts.'
values={{ domain: <strong>{domain}</strong> }}
tagName='p'
/>
)}
</li>
</ol>
<FormattedMessage
id='account.name.help.footer'
defaultMessage='Just like you can send emails to people using different email clients, you can interact with people on other Mastodon servers and with anyone on other social apps powered by the same set of rules as Mastodon uses (the ActivityPub protocol).'
tagName='p'
/>
</div>
)}
</Overlay>
</>
);
};

View File

@@ -46,7 +46,8 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
return null;
}
const className = isRedesignEnabled() ? classes.badge : '';
const isRedesign = isRedesignEnabled();
const className = isRedesign ? classes.badge : '';
const domain = account.acct.includes('@')
? account.acct.split('@')[1]
@@ -68,7 +69,7 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
key={role.id}
label={role.name}
className={className}
domain={isRedesignEnabled() ? `(${domain})` : domain}
domain={isRedesign ? `(${domain})` : domain}
roleId={role.id}
/>,
);
@@ -81,7 +82,7 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
if (account.group) {
badges.push(<GroupBadge key='group-badge' className={className} />);
}
if (isRedesignEnabled() && relationship) {
if (isRedesign && relationship) {
if (relationship.blocking) {
badges.push(
<BlockedBadge
@@ -89,7 +90,8 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
className={classNames(className, classes.badgeBlocked)}
/>,
);
} else if (relationship.domain_blocking) {
}
if (relationship.domain_blocking) {
badges.push(
<BlockedBadge
key='domain-blocking'
@@ -103,7 +105,8 @@ export const AccountBadges: FC<{ accountId: string }> = ({ accountId }) => {
}
/>,
);
} else if (relationship.muting) {
}
if (relationship.muting) {
badges.push(
<MutedBadge
key='muted-badge'

View File

@@ -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<AccountButtonsProps> = ({
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<AccountButtonsProps> = ({
{!hidden && (
<AccountButtonsOther accountId={accountId} noShare={noShare} />
)}
{accountId !== me && <AccountMenu accountId={accountId} />}
{(accountId !== me || forceMenu) && <AccountMenu accountId={accountId} />}
</div>
);
};
@@ -93,6 +97,7 @@ const AccountButtonsOther: FC<
accountId={accountId}
className='account__header__follow-button'
labelLength='long'
withUnmute={!isRedesignEnabled()}
/>
)}
{isFollowing && (

View File

@@ -34,7 +34,6 @@ import {
import type { AppDispatch } from '@/mastodon/store';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import BlockIcon from '@/material-icons/400-24px/block.svg?react';
import EditIcon from '@/material-icons/400-24px/edit_square.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';
@@ -51,6 +50,10 @@ export const AccountMenu: FC<{ accountId: string }> = ({ 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(() => {
@@ -61,7 +64,7 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
if (isRedesignEnabled()) {
return redesignMenuItems({
account,
signedIn,
signedIn: !isMe && signedIn,
permissions,
intl,
relationship,
@@ -76,7 +79,7 @@ export const AccountMenu: FC<{ accountId: string }> = ({ accountId }) => {
relationship,
dispatch,
});
}, [account, signedIn, permissions, intl, relationship, dispatch]);
}, [account, signedIn, isMe, permissions, intl, relationship, dispatch]);
return (
<Dropdown
disabled={menuItems.length === 0}
@@ -443,6 +446,10 @@ const redesignMessages = defineMessages({
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',
@@ -513,22 +520,30 @@ function redesignMenuItems({
icon: ShareIcon,
});
}
items.push(
{
text: intl.formatMessage(redesignMessages.copy),
action: () => {
void navigator.clipboard.writeText(account.url);
dispatch(showAlert({ message: redesignMessages.copied }));
},
icon: LinkIcon,
items.push({
text: intl.formatMessage(redesignMessages.copy),
action: () => {
void navigator.clipboard.writeText(account.url);
dispatch(showAlert({ message: redesignMessages.copied }));
},
null,
);
icon: LinkIcon,
});
}
// Open on remote page.
if (isRemote) {
items.push({
text: intl.formatMessage(redesignMessages.openOriginalPage, {
domain: remoteDomain,
}),
href: account.url,
});
}
// Mention and direct message options
if (signedIn && !account.suspended) {
items.push(
null,
{
text: intl.formatMessage(redesignMessages.mention),
action: () => {
@@ -543,36 +558,6 @@ function redesignMenuItems({
},
},
null,
{
text: intl.formatMessage(
relationship?.note ? messages.editNote : messages.addNote,
),
action: () => {
dispatch(
openModal({
modalType: 'ACCOUNT_NOTE',
modalProps: {
accountId: account.id,
},
}),
);
},
icon: EditIcon,
},
null,
);
}
// Open on remote page.
if (isRemote) {
items.push(
{
text: intl.formatMessage(redesignMessages.openOriginalPage, {
domain: remoteDomain,
}),
href: account.url,
},
null,
);
}
@@ -608,59 +593,78 @@ function redesignMenuItems({
}
},
},
null,
);
}
// Timeline options
if (!relationship.muting) {
items.push(
{
text: intl.formatMessage(
relationship.showing_reblogs
? redesignMessages.hideReblogs
: redesignMessages.showReblogs,
),
action: () => {
dispatch(
followAccount(account.id, {
reblogs: !relationship.showing_reblogs,
}),
);
},
},
{
text: intl.formatMessage(messages.languages),
action: () => {
dispatch(
openModal({
modalType: 'SUBSCRIBED_LANGUAGES',
modalProps: {
accountId: account.id,
},
}),
);
},
},
);
}
items.push(
{
text: intl.formatMessage(
relationship?.note ? messages.editNote : messages.addNote,
),
description: intl.formatMessage(redesignMessages.noteDescription),
action: () => {
dispatch(
openModal({
modalType: 'ACCOUNT_NOTE',
modalProps: {
accountId: account.id,
},
}),
);
},
},
null,
);
// Timeline options
if (relationship && !relationship.muting) {
items.push(
{
text: intl.formatMessage(
relationship.muting ? redesignMessages.unmute : redesignMessages.mute,
relationship.showing_reblogs
? redesignMessages.hideReblogs
: redesignMessages.showReblogs,
),
action: () => {
if (relationship.muting) {
dispatch(unmuteAccount(account.id));
} else {
dispatch(initMuteModal(account));
}
dispatch(
followAccount(account.id, {
reblogs: !relationship.showing_reblogs,
}),
);
},
},
{
text: intl.formatMessage(messages.languages),
action: () => {
dispatch(
openModal({
modalType: 'SUBSCRIBED_LANGUAGES',
modalProps: {
accountId: account.id,
},
}),
);
},
},
null,
);
}
items.push(
{
text: intl.formatMessage(
relationship?.muting ? redesignMessages.unmute : redesignMessages.mute,
),
action: () => {
if (relationship?.muting) {
dispatch(unmuteAccount(account.id));
} else {
dispatch(initMuteModal(account));
}
},
},
null,
);
if (relationship?.followed_by) {
items.push({
text: intl.formatMessage(redesignMessages.removeFollower),
@@ -722,7 +726,7 @@ function redesignMenuItems({
}
if (remoteDomain) {
items.push({
items.push(null, {
text: intl.formatMessage(
relationship?.domain_blocking
? redesignMessages.domainUnblock

View File

@@ -63,7 +63,7 @@ export const AccountNote: FC<{ accountId: string }> = ({ accountId }) => {
/>
}
>
{relationship.note}
<div className={classes.noteContent}>{relationship.note}</div>
</Callout>
);
};

View File

@@ -70,24 +70,22 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({
</NavLink>
{isRedesignEnabled() && (
<NavLink exact to={`/@${account.acct}`}>
<FormattedMessage
id='account.joined_long'
defaultMessage='Joined on {date}'
values={{
date: (
<strong>
<FormattedDateWrapper
value={account.created_at}
year='numeric'
month='short'
day='2-digit'
/>
</strong>
),
}}
/>
</NavLink>
<FormattedMessage
id='account.joined_long'
defaultMessage='Joined on {date}'
values={{
date: (
<strong>
<FormattedDateWrapper
value={account.created_at}
year='numeric'
month='short'
day='2-digit'
/>
</strong>
),
}}
/>
)}
</div>
);

View File

@@ -1,9 +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;
}
@@ -12,6 +27,10 @@
font-size: 22px;
white-space: initial;
line-height: normal;
> h1 {
white-space: initial;
}
}
.username {
@@ -23,15 +42,13 @@
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;
@@ -43,12 +60,53 @@
}
&:hover,
&:global(.active) {
background: none;
&: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;
}
strong {
font-weight: 600;
}
}
$button-breakpoint: 420px;
$button-fallback-breakpoint: #{$button-breakpoint} + 55px;
@@ -66,7 +124,9 @@ $button-fallback-breakpoint: #{$button-breakpoint} + 55px;
.buttonsMobile {
position: sticky;
bottom: 55px; // Height of bottom nav bar.
bottom: var(--mobile-bottom-nav-height);
padding: 12px 16px;
margin: 0 -20px;
@container (width >= #{$button-breakpoint}) {
display: none;
@@ -77,13 +137,16 @@ $button-fallback-breakpoint: #{$button-breakpoint} + 55px;
display: none;
}
}
// Multi-column layout
@media (width >= #{$button-breakpoint}) {
bottom: 0;
}
}
.buttonsMobileIsStuck {
padding: 12px 16px;
background-color: var(--color-bg-primary);
border-top: 1px solid var(--color-border-primary);
margin: 0 -20px;
}
.badge {
@@ -116,6 +179,10 @@ svg.badgeIcon {
margin-bottom: 16px;
}
.noteContent {
white-space-collapse: preserve-breaks;
}
.noteEditButton {
color: inherit;
@@ -237,6 +304,11 @@ svg.badgeIcon {
a {
font-weight: unset;
}
strong {
font-weight: 600;
color: var(--color-text-primary);
}
}
.modalCloseButton {

View File

@@ -100,7 +100,7 @@
.pinnedStatusHeader {
display: grid;
grid-template-columns: max-content auto;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr 1fr;
gap: 4px;

View File

@@ -0,0 +1,45 @@
import type { RefCallback } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
export function useVisibility({
observerOptions,
}: {
observerOptions?: IntersectionObserverInit;
} = {}) {
const [isIntersecting, setIsIntersecting] = useState(false);
const handleIntersect: IntersectionObserverCallback = useCallback(
(entries) => {
const entry = entries.at(0);
if (!entry) {
return;
}
setIsIntersecting(entry.isIntersecting);
},
[],
);
const observer = useMemo(
() => new IntersectionObserver(handleIntersect, observerOptions),
[handleIntersect, observerOptions],
);
const handleObserverRef: RefCallback<HTMLElement> = useCallback(
(node) => {
if (node) {
observer.observe(node);
}
},
[observer],
);
useEffect(() => {
return () => {
observer.disconnect();
};
}, [observer]);
return {
isIntersecting,
observedRef: handleObserverRef,
};
}

View File

@@ -89,6 +89,7 @@
"account.menu.hide_reblogs": "Hide boosts in timeline",
"account.menu.mention": "Mention",
"account.menu.mute": "Mute account",
"account.menu.note.description": "Visible only to you",
"account.menu.open_original_page": "View on {domain}",
"account.menu.remove_follower": "Remove follower",
"account.menu.report": "Report account",
@@ -104,6 +105,13 @@
"account.muted": "Muted",
"account.muting": "Muting",
"account.mutual": "You follow each other",
"account.name.help.domain": "{domain} is the server that hosts the users profile and posts.",
"account.name.help.domain_self": "{domain} is your server that hosts your profile and posts.",
"account.name.help.footer": "Just like you can send emails to people using different email clients, you can interact with people on other Mastodon servers and with anyone on other social apps powered by the same set of rules as Mastodon uses (the ActivityPub protocol).",
"account.name.help.header": "A handle is like an email address",
"account.name.help.username": "{username} is this accounts username on their server. Someone on another server might have the same username.",
"account.name.help.username_self": "{username} is your username on this server. Someone on another server might have the same username.",
"account.name_info": "What does this mean?",
"account.no_bio": "No description provided.",
"account.node_modal.callout": "Personal notes are visible only to you.",
"account.node_modal.edit_title": "Edit personal note",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M325-111.5q-73-31.5-127.5-86t-86-127.5Q80-398 80-480.5t31.5-155q31.5-72.5 86-127t127.5-86Q398-880 480.5-880t155 31.5q72.5 31.5 127 86t86 127Q880-563 880-480.5T848.5-325q-31.5 73-86 127.5t-127 86Q563-80 480.5-80T325-111.5ZM480-162q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>

After

Width:  |  Height:  |  Size: 977 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M325-111.5q-73-31.5-127.5-86t-86-127.5Q80-398 80-480.5t31.5-155q31.5-72.5 86-127t127.5-86Q398-880 480.5-880t155 31.5q72.5 31.5 127 86t86 127Q880-563 880-480.5T848.5-325q-31.5 73-86 127.5t-127 86Q563-80 480.5-80T325-111.5ZM480-162q26-36 45-75t31-83H404q12 44 31 83t45 75Zm-104-16q-18-33-31.5-68.5T322-320H204q29 50 72.5 87t99.5 55Zm208 0q56-18 99.5-55t72.5-87H638q-9 38-22.5 73.5T584-178ZM170-400h136q-3-20-4.5-39.5T300-480q0-21 1.5-40.5T306-560H170q-5 20-7.5 39.5T160-480q0 21 2.5 40.5T170-400Zm216 0h188q3-20 4.5-39.5T580-480q0-21-1.5-40.5T574-560H386q-3 20-4.5 39.5T380-480q0 21 1.5 40.5T386-400Zm268 0h136q5-20 7.5-39.5T800-480q0-21-2.5-40.5T790-560H654q3 20 4.5 39.5T660-480q0 21-1.5 40.5T654-400Zm-16-240h118q-29-50-72.5-87T584-782q18 33 31.5 68.5T638-640Zm-234 0h152q-12-44-31-83t-45-75q-26 36-45 75t-31 83Zm-200 0h118q9-38 22.5-73.5T376-782q-56 18-99.5 55T204-640Z"/></svg>

After

Width:  |  Height:  |  Size: 977 B

View File

@@ -2869,6 +2869,7 @@ a.account__display-name {
}
&-subtitle {
color: var(--color-text-tertiary);
font-weight: 400;
}
@@ -2911,6 +2912,10 @@ a.account__display-name {
outline: 0;
}
}
button:is(:disabled, [aria-disabled='true']) &-subtitle {
color: inherit;
}
}
.reblog-menu-item {