[Glitch] Profile redesign: About tab
Port f5aa5adcf7e4ac3bd3e3615e1e74959add29ada1 to glitch-soc Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
parent
5f72148834
commit
66027e4057
@ -92,8 +92,11 @@ export const CustomEmojiContext = createContext<ExtraCustomEmojiMap>({});
|
|||||||
export const CustomEmojiProvider = ({
|
export const CustomEmojiProvider = ({
|
||||||
children,
|
children,
|
||||||
emojis: rawEmojis,
|
emojis: rawEmojis,
|
||||||
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => {
|
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg | null }>) => {
|
||||||
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]);
|
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis), [rawEmojis]);
|
||||||
|
if (!emojis) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<CustomEmojiContext.Provider value={emojis}>
|
<CustomEmojiContext.Provider value={emojis}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
|||||||
extraEmojis,
|
extraEmojis,
|
||||||
htmlString,
|
htmlString,
|
||||||
as: asProp = 'div', // Rename for syntax highlighting
|
as: asProp = 'div', // Rename for syntax highlighting
|
||||||
className = '',
|
className,
|
||||||
onElement,
|
onElement,
|
||||||
onAttribute,
|
onAttribute,
|
||||||
...props
|
...props
|
||||||
|
|||||||
125
app/javascript/flavours/glitch/features/account_about/index.tsx
Normal file
125
app/javascript/flavours/glitch/features/account_about/index.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
|
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
||||||
|
import { Column } from '@/flavours/glitch/components/column';
|
||||||
|
import { ColumnBackButton } from '@/flavours/glitch/components/column_back_button';
|
||||||
|
import { LoadingIndicator } from '@/flavours/glitch/components/loading_indicator';
|
||||||
|
import BundleColumnError from '@/flavours/glitch/features/ui/components/bundle_column_error';
|
||||||
|
import type { AccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||||
|
import { useAccountId } from '@/flavours/glitch/hooks/useAccountId';
|
||||||
|
import { useAccountVisibility } from '@/flavours/glitch/hooks/useAccountVisibility';
|
||||||
|
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
|
||||||
|
|
||||||
|
import { AccountHeader } from '../account_timeline/components/account_header';
|
||||||
|
import { AccountHeaderFields } from '../account_timeline/components/fields';
|
||||||
|
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
|
||||||
|
|
||||||
|
import classes from './styles.module.css';
|
||||||
|
|
||||||
|
const selectIsProfileEmpty = createAppSelector(
|
||||||
|
[(state) => state.accounts, (_, accountId: AccountId) => accountId],
|
||||||
|
(accounts, accountId) => {
|
||||||
|
// Null means still loading, otherwise it's a boolean.
|
||||||
|
if (!accountId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const account = accounts.get(accountId);
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return !account.note && !account.fields.size;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AccountAbout: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||||
|
const accountId = useAccountId();
|
||||||
|
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
||||||
|
const forceEmptyState = blockedBy || hidden || suspended;
|
||||||
|
|
||||||
|
const isProfileEmpty = useAppSelector((state) =>
|
||||||
|
selectIsProfileEmpty(state, accountId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (accountId === null) {
|
||||||
|
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountId || isProfileEmpty === null) {
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn}>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showEmptyMessage = forceEmptyState || isProfileEmpty;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn}>
|
||||||
|
<ColumnBackButton />
|
||||||
|
<div className='scrollable scrollable--flex'>
|
||||||
|
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
|
||||||
|
<div className={classes.wrapper}>
|
||||||
|
{!showEmptyMessage ? (
|
||||||
|
<>
|
||||||
|
<AccountBio
|
||||||
|
accountId={accountId}
|
||||||
|
className={`${classes.bio} account__header__content`}
|
||||||
|
/>
|
||||||
|
<AccountHeaderFields accountId={accountId} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className='empty-column-indicator'>
|
||||||
|
<EmptyMessage accountId={accountId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
|
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
||||||
|
const currentUserId = useAppSelector(
|
||||||
|
(state) => state.meta.get('me') as string | null,
|
||||||
|
);
|
||||||
|
const { acct } = useParams<{ acct?: string }>();
|
||||||
|
|
||||||
|
if (suspended) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.account_suspended'
|
||||||
|
defaultMessage='Account suspended'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (hidden) {
|
||||||
|
return <LimitedAccountHint accountId={accountId} />;
|
||||||
|
} else if (blockedBy) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.account_unavailable'
|
||||||
|
defaultMessage='Profile unavailable'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (accountId === currentUserId) {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.account_about.me'
|
||||||
|
defaultMessage='You have not added any information about yourself yet.'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='empty_column.account_about.other'
|
||||||
|
defaultMessage='{acct} has not added any information about themselves yet.'
|
||||||
|
values={{ acct }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
.wrapper {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio {
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
@ -210,12 +210,15 @@ export const AccountHeader: React.FC<{
|
|||||||
<AccountNote accountId={accountId} />
|
<AccountNote accountId={accountId} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<AccountBio
|
{(!isRedesign || layout === 'single-column') && (
|
||||||
accountId={accountId}
|
<>
|
||||||
className='account__header__content'
|
<AccountBio
|
||||||
/>
|
accountId={accountId}
|
||||||
|
className='account__header__content'
|
||||||
<AccountHeaderFields accountId={accountId} />
|
/>
|
||||||
|
<AccountHeaderFields accountId={accountId} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,24 +1,25 @@
|
|||||||
import type { FC } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import type { FC, Key } from 'react';
|
||||||
|
|
||||||
import { FormattedMessage, useIntl } from 'react-intl';
|
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import htmlConfig from '@/config/html-tags.json';
|
||||||
import { AccountFields } from '@/flavours/glitch/components/account_fields';
|
import { AccountFields } from '@/flavours/glitch/components/account_fields';
|
||||||
|
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
||||||
|
import type { EmojiHTMLProps } from '@/flavours/glitch/components/emoji/html';
|
||||||
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||||
import { FormattedDateWrapper } from '@/flavours/glitch/components/formatted_date';
|
import { FormattedDateWrapper } from '@/flavours/glitch/components/formatted_date';
|
||||||
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
import { Icon } from '@/flavours/glitch/components/icon';
|
||||||
import { MiniCard } from '@/flavours/glitch/components/mini_card';
|
|
||||||
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
|
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
|
||||||
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
import { useAccount } from '@/flavours/glitch/hooks/useAccount';
|
||||||
import { useOverflowScroll } from '@/flavours/glitch/hooks/useOverflow';
|
|
||||||
import type { Account } from '@/flavours/glitch/models/account';
|
import type { Account } from '@/flavours/glitch/models/account';
|
||||||
import { isValidUrl } from '@/flavours/glitch/utils/checks';
|
import { isValidUrl } from '@/flavours/glitch/utils/checks';
|
||||||
|
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
|
||||||
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
||||||
import IconLeftArrow from '@/material-icons/400-24px/chevron_left.svg?react';
|
|
||||||
import IconRightArrow from '@/material-icons/400-24px/chevron_right.svg?react';
|
|
||||||
import IconLink from '@/material-icons/400-24px/link_2.svg?react';
|
|
||||||
|
|
||||||
|
import { cleanExtraEmojis } from '../../emoji/normalize';
|
||||||
import { isRedesignEnabled } from '../common';
|
import { isRedesignEnabled } from '../common';
|
||||||
|
|
||||||
import classes from './redesign.module.scss';
|
import classes from './redesign.module.scss';
|
||||||
@ -57,96 +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 RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
|
||||||
const htmlHandlers = useElementHandledLink();
|
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 intl = useIntl();
|
||||||
|
|
||||||
const {
|
|
||||||
bodyRef,
|
|
||||||
canScrollLeft,
|
|
||||||
canScrollRight,
|
|
||||||
handleLeftNav,
|
|
||||||
handleRightNav,
|
|
||||||
handleScroll,
|
|
||||||
} = useOverflowScroll();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<CustomEmojiProvider emojis={emojis}>
|
||||||
className={classNames(
|
<dl className={classes.fieldList}>
|
||||||
classes.fieldWrapper,
|
|
||||||
canScrollLeft && classes.fieldWrapperLeft,
|
|
||||||
canScrollRight && classes.fieldWrapperRight,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{canScrollLeft && (
|
|
||||||
<IconButton
|
|
||||||
icon='more'
|
|
||||||
iconComponent={IconLeftArrow}
|
|
||||||
title={intl.formatMessage({
|
|
||||||
id: 'account.fields.scroll_prev',
|
|
||||||
defaultMessage: 'Show previous',
|
|
||||||
})}
|
|
||||||
className={classes.fieldArrowButton}
|
|
||||||
onClick={handleLeftNav}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<dl ref={bodyRef} className={classes.fieldList} onScroll={handleScroll}>
|
|
||||||
{account.fields.map(
|
{account.fields.map(
|
||||||
(
|
(
|
||||||
{ name, name_emojified, value_emojified, value_plain, verified_at },
|
{ name, name_emojified, value_emojified, value_plain, verified_at },
|
||||||
key,
|
key,
|
||||||
) => (
|
) => (
|
||||||
<MiniCard
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
label={
|
|
||||||
<EmojiHTML
|
|
||||||
htmlString={name_emojified}
|
|
||||||
extraEmojis={account.emojis}
|
|
||||||
className='translate'
|
|
||||||
as='span'
|
|
||||||
title={name}
|
|
||||||
{...htmlHandlers}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
value={
|
|
||||||
<EmojiHTML
|
|
||||||
as='span'
|
|
||||||
htmlString={value_emojified}
|
|
||||||
extraEmojis={account.emojis}
|
|
||||||
title={value_plain ?? undefined}
|
|
||||||
{...htmlHandlers}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
icon={fieldIcon(verified_at, value_plain)}
|
|
||||||
className={classNames(
|
className={classNames(
|
||||||
classes.fieldCard,
|
classes.fieldRow,
|
||||||
verified_at && classes.fieldCardVerified,
|
verified_at && classes.fieldVerified,
|
||||||
)}
|
)}
|
||||||
/>
|
>
|
||||||
|
<FieldHTML
|
||||||
|
as='dt'
|
||||||
|
text={name}
|
||||||
|
textEmojified={name_emojified}
|
||||||
|
textHasCustomEmoji={textHasCustomEmoji(name)}
|
||||||
|
titleLength={50}
|
||||||
|
className='translate'
|
||||||
|
{...htmlHandlers}
|
||||||
|
/>
|
||||||
|
<FieldHTML
|
||||||
|
as='dd'
|
||||||
|
text={value_plain ?? ''}
|
||||||
|
textEmojified={value_emojified}
|
||||||
|
textHasCustomEmoji={textHasCustomEmoji(value_plain ?? '')}
|
||||||
|
titleLength={120}
|
||||||
|
{...htmlHandlers}
|
||||||
|
/>
|
||||||
|
{verified_at && (
|
||||||
|
<Icon
|
||||||
|
id='verified'
|
||||||
|
icon={IconVerified}
|
||||||
|
className={classes.fieldVerifiedIcon}
|
||||||
|
aria-label={intl.formatMessage(verifyMessage, {
|
||||||
|
date: intl.formatDate(verified_at, dateFormatOptions),
|
||||||
|
})}
|
||||||
|
noFill
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
{canScrollRight && (
|
</CustomEmojiProvider>
|
||||||
<IconButton
|
|
||||||
icon='more'
|
|
||||||
iconComponent={IconRightArrow}
|
|
||||||
title={intl.formatMessage({
|
|
||||||
id: 'account.fields.scroll_next',
|
|
||||||
defaultMessage: 'Show next',
|
|
||||||
})}
|
|
||||||
className={classes.fieldArrowButton}
|
|
||||||
onClick={handleRightNav}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function fieldIcon(verified_at: string | null, value_plain: string | null) {
|
const FieldHTML: FC<
|
||||||
if (verified_at) {
|
{
|
||||||
return IconVerified;
|
as: 'dd' | 'dt';
|
||||||
} else if (value_plain && isValidUrl(value_plain)) {
|
text: string;
|
||||||
return IconLink;
|
textEmojified: string;
|
||||||
|
textHasCustomEmoji: boolean;
|
||||||
|
titleLength: number;
|
||||||
|
} & Omit<EmojiHTMLProps, 'htmlString'>
|
||||||
|
> = ({
|
||||||
|
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 (
|
||||||
|
<span {...filterAttributesForSpan(props)} key={props.key as Key}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return onElement?.(element, props, children, extra);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[onElement, textHasCustomEmoji],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<EmojiHTML
|
||||||
|
as={as}
|
||||||
|
htmlString={textEmojified}
|
||||||
|
title={showTitleOnLength(text, titleLength)}
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
text && isValidUrl(text) && classes.fieldLink,
|
||||||
|
showAll && classes.fieldShowAll,
|
||||||
|
)}
|
||||||
|
onClick={handleClick}
|
||||||
|
onElement={handleElement}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function filterAttributesForSpan(props: Record<string, unknown>) {
|
||||||
|
const validAttributes: Record<string, unknown> = {};
|
||||||
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -192,115 +192,80 @@ svg.badgeIcon {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldWrapper {
|
|
||||||
margin-top: 16px;
|
|
||||||
width: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldWrapper::before,
|
|
||||||
.fieldWrapper::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 40px;
|
|
||||||
pointer-events: none;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.2s ease-in-out;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldWrapper::before {
|
|
||||||
left: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
to left,
|
|
||||||
transparent 0%,
|
|
||||||
var(--color-bg-primary) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldWrapper::after {
|
|
||||||
right: 0;
|
|
||||||
background: linear-gradient(
|
|
||||||
to right,
|
|
||||||
transparent 0%,
|
|
||||||
var(--color-bg-primary) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldWrapperLeft::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldWrapperRight::after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldList {
|
.fieldList {
|
||||||
display: flex;
|
display: grid;
|
||||||
flex-wrap: nowrap;
|
grid-template-columns: 160px 1fr min-content;
|
||||||
gap: 4px;
|
column-gap: 12px;
|
||||||
scroll-snap-type: x mandatory;
|
margin: 4px 0 16px;
|
||||||
scroll-padding-left: 40px;
|
|
||||||
scroll-padding-right: 40px;
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
overflow-x: scroll;
|
|
||||||
scrollbar-width: none;
|
|
||||||
overflow-y: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fieldCard {
|
@container (width < 420px) {
|
||||||
scroll-snap-align: start;
|
grid-template-columns: 100px 1fr min-content;
|
||||||
|
|
||||||
&:focus-visible,
|
|
||||||
&:focus-within {
|
|
||||||
outline: var(--outline-focus-default);
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(dt, dd) {
|
|
||||||
max-width: 200px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldCardVerified {
|
.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);
|
background-color: var(--color-bg-brand-softer);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldArrowButton {
|
.fieldLink:is(dd, dt) {
|
||||||
position: absolute;
|
margin: 0;
|
||||||
top: 50%;
|
}
|
||||||
transform: translateY(-50%);
|
|
||||||
background-color: var(--color-bg-primary);
|
|
||||||
box-shadow: 0 1px 4px 0 var(--color-shadow-primary);
|
|
||||||
border-radius: 9999px;
|
|
||||||
transition:
|
|
||||||
color 0.2s ease-in-out,
|
|
||||||
background-color 0.2s ease-in-out;
|
|
||||||
outline-offset: 2px;
|
|
||||||
z-index: 2;
|
|
||||||
|
|
||||||
&:first-child {
|
.fieldLink > a {
|
||||||
left: 4px;
|
display: block;
|
||||||
}
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
&:last-child {
|
.fieldVerifiedIcon {
|
||||||
right: 4px;
|
width: 16px;
|
||||||
}
|
height: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
&:hover,
|
|
||||||
&:focus,
|
|
||||||
&:focus-visible {
|
|
||||||
background-color: color-mix(
|
|
||||||
in oklab,
|
|
||||||
var(--color-bg-brand-base) var(--overlay-strength-brand),
|
|
||||||
var(--color-bg-primary)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fieldNumbersWrapper {
|
.fieldNumbersWrapper {
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
font-weight: unset;
|
font-weight: unset;
|
||||||
}
|
}
|
||||||
@ -358,7 +323,11 @@ svg.badgeIcon {
|
|||||||
border-bottom: 1px solid var(--color-border-primary);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 0 24px;
|
padding: 0 12px;
|
||||||
|
|
||||||
|
@container (width >= 500px) {
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@ -5,15 +5,23 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
import type { NavLinkProps } from 'react-router-dom';
|
import type { NavLinkProps } from 'react-router-dom';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useLayout } from '@/flavours/glitch/hooks/useLayout';
|
||||||
|
|
||||||
import { isRedesignEnabled } from '../common';
|
import { isRedesignEnabled } from '../common';
|
||||||
|
|
||||||
import classes from './redesign.module.scss';
|
import classes from './redesign.module.scss';
|
||||||
|
|
||||||
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||||
|
const { layout } = useLayout();
|
||||||
if (isRedesignEnabled()) {
|
if (isRedesignEnabled()) {
|
||||||
return (
|
return (
|
||||||
<div className={classes.tabs}>
|
<div className={classes.tabs}>
|
||||||
<NavLink isActive={isActive} to={`/@${acct}`}>
|
{layout !== 'single-column' && (
|
||||||
|
<NavLink exact to={`/@${acct}/about`}>
|
||||||
|
<FormattedMessage id='account.about' defaultMessage='About' />
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
<NavLink isActive={isActive} to={`/@${acct}/posts`}>
|
||||||
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<NavLink exact to={`/@${acct}/media`}>
|
<NavLink exact to={`/@${acct}/media`}>
|
||||||
|
|||||||
@ -181,7 +181,7 @@ export function emojiToInversionClassName(emoji: string): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
|
export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg | null) {
|
||||||
if (!extraEmojis) {
|
if (!extraEmojis) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,6 +81,7 @@ import {
|
|||||||
PrivacyPolicy,
|
PrivacyPolicy,
|
||||||
TermsOfService,
|
TermsOfService,
|
||||||
AccountFeatured,
|
AccountFeatured,
|
||||||
|
AccountAbout,
|
||||||
Quotes,
|
Quotes,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { ColumnsContextProvider } from './util/columns_context';
|
import { ColumnsContextProvider } from './util/columns_context';
|
||||||
@ -91,6 +92,7 @@ import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
|
|||||||
// Without this it ends up in ~8 very commonly used bundles.
|
// Without this it ends up in ~8 very commonly used bundles.
|
||||||
import '../../components/status';
|
import '../../components/status';
|
||||||
import { areCollectionsEnabled } from '../collections/utils';
|
import { areCollectionsEnabled } from '../collections/utils';
|
||||||
|
import { isClientFeatureEnabled } from '@/flavours/glitch/utils/environment';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
|
beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
|
||||||
@ -117,6 +119,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
location: PropTypes.object,
|
location: PropTypes.object,
|
||||||
singleColumn: PropTypes.bool,
|
singleColumn: PropTypes.bool,
|
||||||
|
layout: PropTypes.string.isRequired,
|
||||||
forceOnboarding: PropTypes.bool,
|
forceOnboarding: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -167,6 +170,37 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
redirect = <Redirect from='/' to='/about' exact />;
|
redirect = <Redirect from='/' to='/about' exact />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileRedesignEnabled = isClientFeatureEnabled('profile_redesign');
|
||||||
|
const profileRedesignRoutes = [];
|
||||||
|
if (profileRedesignEnabled) {
|
||||||
|
profileRedesignRoutes.push(
|
||||||
|
<WrappedRoute key="posts" path={['/@:acct/posts', '/accounts/:id/posts']} exact component={AccountTimeline} content={children} />,
|
||||||
|
);
|
||||||
|
// Check if we're in single-column mode. Confusingly, the singleColumn prop includes mobile.
|
||||||
|
if (this.props.layout === 'single-column') {
|
||||||
|
// When in single column mode (desktop w/o advanced view), redirect both the root and about to the posts tab.
|
||||||
|
profileRedesignRoutes.push(
|
||||||
|
<Redirect key="acct-redirect" from='/@:acct' to='/@:acct/posts' exact />,
|
||||||
|
<Redirect key="id-redirect" from='/accounts/:id' to='/accounts/:id/posts' exact />,
|
||||||
|
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct/posts' exact />,
|
||||||
|
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id/posts' exact />,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Otherwise, provide and redirect to the /about page.
|
||||||
|
profileRedesignRoutes.push(
|
||||||
|
<WrappedRoute key="about" path={['/@:acct/about', '/accounts/:id/about']} component={AccountAbout} content={children} />,
|
||||||
|
<Redirect key="acct-redirect" from='/@:acct' to='/@:acct/about' exact />,
|
||||||
|
<Redirect key="id-redirect" from='/accounts/:id' to='/accounts/:id/about' exact />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If the redesign is not enabled but someone shares an /about link, redirect to the root.
|
||||||
|
profileRedesignRoutes.push(
|
||||||
|
<Redirect key="about-acct-redirect" from='/@:acct/about' to='/@:acct' exact />,
|
||||||
|
<Redirect key="about-id-redirect" from='/accounts/:id/about' to='/accounts/:id' exact />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ColumnsContextProvider multiColumn={!singleColumn}>
|
<ColumnsContextProvider multiColumn={!singleColumn}>
|
||||||
<ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
|
<ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
|
||||||
@ -213,7 +247,8 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
<WrappedRoute path='/search' component={Search} content={children} />
|
<WrappedRoute path='/search' component={Search} content={children} />
|
||||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
|
{!profileRedesignEnabled && <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />}
|
||||||
|
{...profileRedesignRoutes}
|
||||||
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
|
<WrappedRoute path={['/@:acct/featured', '/accounts/:id/featured']} component={AccountFeatured} content={children} />
|
||||||
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
|
<WrappedRoute path='/@:acct/tagged/:tagged?' exact component={AccountTimeline} content={children} />
|
||||||
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
|
<WrappedRoute path={['/@:acct/with_replies', '/accounts/:id/with_replies']} component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
|
||||||
@ -243,7 +278,7 @@ class SwitchingColumnsArea extends PureComponent {
|
|||||||
}
|
}
|
||||||
{areCollectionsEnabled() &&
|
{areCollectionsEnabled() &&
|
||||||
<WrappedRoute path='/collections' component={Collections} content={children} />
|
<WrappedRoute path='/collections' component={Collections} content={children} />
|
||||||
}
|
}
|
||||||
|
|
||||||
<Route component={BundleColumnError} />
|
<Route component={BundleColumnError} />
|
||||||
</WrappedSwitch>
|
</WrappedSwitch>
|
||||||
@ -656,7 +691,13 @@ class UI extends PureComponent {
|
|||||||
/>
|
/>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
|
||||||
<SwitchingColumnsArea identity={this.props.identity} location={location} singleColumn={layout === 'mobile' || layout === 'single-column'} forceOnboarding={firstLaunch && newAccount}>
|
<SwitchingColumnsArea
|
||||||
|
identity={this.props.identity}
|
||||||
|
location={location}
|
||||||
|
singleColumn={layout === 'mobile' || layout === 'single-column'}
|
||||||
|
layout={layout}
|
||||||
|
forceOnboarding={firstLaunch && newAccount}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</SwitchingColumnsArea>
|
</SwitchingColumnsArea>
|
||||||
|
|
||||||
|
|||||||
@ -87,6 +87,11 @@ export function AccountFeatured() {
|
|||||||
return import('../../account_featured');
|
return import('../../account_featured');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function AccountAbout() {
|
||||||
|
return import('../../account_about')
|
||||||
|
.then((module) => ({ default: module.AccountAbout }));
|
||||||
|
}
|
||||||
|
|
||||||
export function Followers () {
|
export function Followers () {
|
||||||
return import('../../followers');
|
return import('../../followers');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,19 +4,41 @@ import { useParams } from 'react-router';
|
|||||||
|
|
||||||
import { fetchAccount, lookupAccount } from 'flavours/glitch/actions/accounts';
|
import { fetchAccount, lookupAccount } from 'flavours/glitch/actions/accounts';
|
||||||
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
|
import { normalizeForLookup } from 'flavours/glitch/reducers/accounts_map';
|
||||||
import { useAppDispatch, useAppSelector } from 'flavours/glitch/store';
|
import {
|
||||||
|
createAppSelector,
|
||||||
|
useAppDispatch,
|
||||||
|
useAppSelector,
|
||||||
|
} from 'flavours/glitch/store';
|
||||||
|
|
||||||
interface Params {
|
interface Params {
|
||||||
acct?: string;
|
acct?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAccountId = () => {
|
const selectNormalizedId = createAppSelector(
|
||||||
|
[
|
||||||
|
(state) => state.accounts_map,
|
||||||
|
(_, acct?: string) => acct,
|
||||||
|
(_, _acct, id?: string) => id,
|
||||||
|
],
|
||||||
|
(accountsMap, acct, id) => {
|
||||||
|
if (id) {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
if (acct) {
|
||||||
|
return accountsMap[normalizeForLookup(acct)];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export type AccountId = string | null | undefined;
|
||||||
|
|
||||||
|
export function useAccountId() {
|
||||||
const { acct, id } = useParams<Params>();
|
const { acct, id } = useParams<Params>();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const accountId = useAppSelector(
|
const accountId = useAppSelector((state) =>
|
||||||
(state) =>
|
selectNormalizedId(state, acct, id),
|
||||||
id ?? (acct ? state.accounts_map[normalizeForLookup(acct)] : undefined),
|
|
||||||
);
|
);
|
||||||
const account = useAppSelector((state) =>
|
const account = useAppSelector((state) =>
|
||||||
accountId ? state.accounts.get(accountId) : undefined,
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
@ -31,5 +53,5 @@ export const useAccountId = () => {
|
|||||||
}
|
}
|
||||||
}, [dispatch, accountId, acct, accountInStore]);
|
}, [dispatch, accountId, acct, accountInStore]);
|
||||||
|
|
||||||
return accountId;
|
return accountId satisfies AccountId;
|
||||||
};
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user