Merge commit 'd20d04922672ed016029c40ed64798f341ebca91' into glitch-soc/merge-upstream

This commit is contained in:
Claire 2026-05-26 20:48:49 +02:00
commit 06176deee2
50 changed files with 710 additions and 112 deletions

View File

@ -16,6 +16,7 @@ class Api::V1::Admin::ReportsController < Api::BaseController
FILTER_PARAMS = %i( FILTER_PARAMS = %i(
resolved resolved
unresolved
account_id account_id
target_account_id target_account_id
).freeze ).freeze

View File

@ -329,7 +329,7 @@ export function uploadCompose(files) {
dispatch(showAlert({ message: messages.uploadQuote })); dispatch(showAlert({ message: messages.uploadQuote }));
return; return;
} }
const uploadLimit = getState().getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']); const uploadLimit = getState().getIn(['server', 'server', 'item', 'configuration', 'statuses', 'max_media_attachments']);
const media = getState().getIn(['compose', 'media_attachments']); const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']); const pending = getState().getIn(['compose', 'pending_media_attachments']);
const progress = new Array(files.length).fill(0); const progress = new Array(files.length).fill(0);

View File

@ -1,5 +1,8 @@
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji'; import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
import { loadCustomEmoji } from '@/mastodon/features/emoji'; import { loadCustomEmoji } from '@/mastodon/features/emoji';
import { emojiLogger } from '@/mastodon/features/emoji/utils';
const log = emojiLogger('actions');
export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) { export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
if (emojis.length === 0) { if (emojis.length === 0) {
@ -18,5 +21,11 @@ export async function importCustomEmoji(emojis: ApiCustomEmojiJSON[]) {
if (existingEmojis.length < emojis.length) { if (existingEmojis.length < emojis.length) {
await clearCache('custom'); await clearCache('custom');
await loadCustomEmoji(); await loadCustomEmoji();
const { reloadCustomEmojis } =
await import('@/mastodon/features/emoji/picker');
await reloadCustomEmojis();
log('Custom emojis updated, reloaded cache and picker data.');
} }
} }

View File

@ -16,19 +16,33 @@ export const fetchServer = createDataLoadingThunk(
dispatch(importFetchedAccount(instance.contact.account)); dispatch(importFetchedAccount(instance.contact.account));
} }
}, },
{
condition: (_, { getState }) => !getState().server.server.isLoading,
},
); );
export const fetchExtendedDescription = createDataLoadingThunk( export const fetchExtendedDescription = createDataLoadingThunk(
'server/extended_description', 'server/extended_description',
() => apiGetExtendedDescription(), () => apiGetExtendedDescription(),
{
condition: (_, { getState }) =>
!getState().server.extendedDescription.isLoading,
},
); );
export const fetchServerTranslationLanguages = createDataLoadingThunk( export const fetchServerTranslationLanguages = createDataLoadingThunk(
'server/translation_languages', 'server/translation_languages',
() => apiGetTranslationLanguages(), () => apiGetTranslationLanguages(),
{
condition: (_, { getState }) =>
!getState().server.translationLanguages.isLoading,
},
); );
export const fetchDomainBlocks = createDataLoadingThunk( export const fetchDomainBlocks = createDataLoadingThunk(
'server/domain_blocks', 'server/domain_blocks',
() => apiGetDomainBlocks(), () => apiGetDomainBlocks(),
{
condition: (_, { getState }) => !getState().server.domainBlocks.isLoading,
},
); );

View File

@ -480,6 +480,7 @@ $button-fallback-breakpoint: $button-breakpoint + 55px;
justify-content: center; justify-content: center;
align-items: flex-start; align-items: flex-start;
margin: 16px 0; margin: 16px 0;
padding: 16px;
} }
.bannerBaseCentered { .bannerBaseCentered {

View File

@ -144,11 +144,16 @@ export const HoverCardController: React.FC = () => {
setScrollTimeout(handleScrollEnd, 100); setScrollTimeout(handleScrollEnd, 100);
}; };
const handleMouseMove = () => { const handleMouseMove = (e: MouseEvent) => {
if (isUsingTouch) { if (isUsingTouch) {
isUsingTouch = false; isUsingTouch = false;
} }
const hasMoved = Math.max(e.movementX, e.movementY) > 0;
if (!hasMoved) {
return;
}
delayEnterTimeout(enterDelay); delayEnterTimeout(enterDelay);
cancelMoveTimeout(); cancelMoveTimeout();

View File

@ -285,7 +285,7 @@ class ScrollableList extends PureComponent {
if (this.props.bindToDocument) { if (this.props.bindToDocument) {
document.removeEventListener('scroll', this.handleScroll); document.removeEventListener('scroll', this.handleScroll);
document.removeEventListener('wheel', this.handleWheel, listenerOptions); document.removeEventListener('wheel', this.handleWheel, listenerOptions);
} else { } else if (this.node) {
this.node.removeEventListener('scroll', this.handleScroll); this.node.removeEventListener('scroll', this.handleScroll);
this.node.removeEventListener('wheel', this.handleWheel, listenerOptions); this.node.removeEventListener('wheel', this.handleWheel, listenerOptions);
} }

View File

@ -20,7 +20,8 @@ export interface StatusHeaderProps {
status: Status; status: Status;
account?: Account; account?: Account;
avatarSize?: number; avatarSize?: number;
children?: ReactNode; contentBeforeDate?: ReactNode;
contentAfterDate?: ReactNode;
wrapperProps?: HTMLAttributes<HTMLDivElement>; wrapperProps?: HTMLAttributes<HTMLDivElement>;
displayNameProps?: DisplayNameProps; displayNameProps?: DisplayNameProps;
onHeaderClick?: MouseEventHandler<HTMLDivElement>; onHeaderClick?: MouseEventHandler<HTMLDivElement>;
@ -33,10 +34,11 @@ export type StatusHeaderRenderFn = (args: StatusHeaderProps) => ReactNode;
export const StatusHeader: FC<StatusHeaderProps> = ({ export const StatusHeader: FC<StatusHeaderProps> = ({
status, status,
account, account,
children,
className, className,
avatarSize = 48, avatarSize = 48,
wrapperProps, wrapperProps,
contentBeforeDate,
contentAfterDate,
onHeaderClick, onHeaderClick,
}) => { }) => {
const statusAccount = status.get('account') as Account | undefined; const statusAccount = status.get('account') as Account | undefined;
@ -51,6 +53,14 @@ export const StatusHeader: FC<StatusHeaderProps> = ({
className={classNames('status__info', className)} className={classNames('status__info', className)}
/* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
> >
<StatusDisplayName
statusAccount={statusAccount}
friendAccount={account}
avatarSize={avatarSize}
/>
{contentBeforeDate}
<Link <Link
to={`/@${statusAccount?.acct}/${status.get('id') as string}`} to={`/@${statusAccount?.acct}/${status.get('id') as string}`}
className='status__relative-time' className='status__relative-time'
@ -60,13 +70,7 @@ export const StatusHeader: FC<StatusHeaderProps> = ({
{editedAt && <StatusEditedAt editedAt={editedAt} />} {editedAt && <StatusEditedAt editedAt={editedAt} />}
</Link> </Link>
<StatusDisplayName {contentAfterDate}
statusAccount={statusAccount}
friendAccount={account}
avatarSize={avatarSize}
/>
{children}
</div> </div>
); );
}; };

View File

@ -69,7 +69,7 @@ class TranslateButton extends PureComponent {
} }
const mapStateToProps = state => ({ const mapStateToProps = state => ({
languages: state.server.translationLanguages.items, languages: state.server.translationLanguages.item,
}); });
class StatusContent extends PureComponent { class StatusContent extends PureComponent {
@ -187,7 +187,7 @@ class StatusContent extends PureComponent {
const renderReadMore = this.props.onClick && status.get('collapsed'); const renderReadMore = this.props.onClick && status.get('collapsed');
const contentLocale = intl.locale.replace(/[_-].*/, ''); const contentLocale = intl.locale.replace(/[_-].*/, '');
const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); const targetLanguages = this.props.languages?.[status.get('language') || 'und'];
const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale); const renderTranslate = this.props.onTranslate && this.props.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
const content = statusContent ?? getStatusContent(status); const content = statusContent ?? getStatusContent(status);

View File

@ -225,17 +225,20 @@ export const QuotedStatus: React.FC<QuotedStatusProps> = ({
const intl = useIntl(); const intl = useIntl();
const headerRenderFn: StatusHeaderRenderFn = useCallback( const headerRenderFn: StatusHeaderRenderFn = useCallback(
(props) => ( (props) => (
<StatusHeader {...props}> <StatusHeader
{onQuoteCancel && ( {...props}
<IconButton contentAfterDate={
onClick={onQuoteCancel} onQuoteCancel && (
className='status__quote-cancel' <IconButton
title={intl.formatMessage(quoteCancelMessage)} onClick={onQuoteCancel}
icon='cancel-fill' className='status__quote-cancel'
iconComponent={CancelFillIcon} title={intl.formatMessage(quoteCancelMessage)}
/> icon='cancel-fill'
)} iconComponent={CancelFillIcon}
</StatusHeader> />
)
}
/>
), ),
[intl, onQuoteCancel], [intl, onQuoteCancel],
); );

View File

@ -22,18 +22,22 @@ export const renderPinnedStatusHeader: StatusHeaderRenderFn = ({
return <StatusHeader {...args} />; return <StatusHeader {...args} />;
} }
return ( return (
<StatusHeader {...args} className={classes.pinnedStatusHeader}> <StatusHeader
<Badge {...args}
className={classes.pinnedBadge} className={classes.pinnedStatusHeader}
icon={<Icon id='pinned' icon={IconPinned} />} contentBeforeDate={
label={ <Badge
<FormattedMessage className={classes.pinnedBadge}
id='account.timeline.pinned' icon={<Icon id='pinned' icon={IconPinned} />}
defaultMessage='Pinned' label={
/> <FormattedMessage
} id='account.timeline.pinned'
/> defaultMessage='Pinned'
</StatusHeader> />
}
/>
}
/>
); );
}; };

View File

@ -126,4 +126,7 @@
.pinnedBadge { .pinnedBadge {
justify-self: end; justify-self: end;
// Allow "click to open post" event to pass through
pointer-events: none;
} }

View File

@ -27,14 +27,14 @@ export const CollectionListItem: React.FC<CollectionListItemProps> = ({
...otherProps ...otherProps
}) => { }) => {
const uniqueId = useId(); const uniqueId = useId();
const linkId = `${uniqueId}-link`; const titleId = `${uniqueId}-title`;
const infoId = `${uniqueId}-info`; const subtitleId = `${uniqueId}-info`;
return ( return (
<Article <Article
focusable focusable
aria-labelledby={linkId} aria-labelledby={titleId}
aria-describedby={infoId} aria-describedby={subtitleId}
aria-posinset={positionInList} aria-posinset={positionInList}
aria-setsize={listSize} aria-setsize={listSize}
> >
@ -52,6 +52,8 @@ export const CollectionListItem: React.FC<CollectionListItemProps> = ({
className={classes.menuButton} className={classes.menuButton}
/> />
} }
titleId={titleId}
subtitleId={subtitleId}
{...otherProps} {...otherProps}
/> />
</Article> </Article>

View File

@ -48,6 +48,8 @@ export interface CollectionLockupProps {
sideContent?: React.ReactNode; sideContent?: React.ReactNode;
className?: string; className?: string;
headingLevel?: 'h2' | 'h3' | 'h4'; headingLevel?: 'h2' | 'h3' | 'h4';
titleId?: string;
subtitleId?: string;
} }
export const CollectionLockup: React.FC<CollectionLockupProps> = ({ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
@ -56,6 +58,8 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
withTimestamp, withTimestamp,
sideContent, sideContent,
headingLevel = 'h3', headingLevel = 'h3',
titleId,
subtitleId,
className, className,
}) => { }) => {
const { id, name } = collection; const { id, name } = collection;
@ -74,6 +78,7 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
<ListItemLink <ListItemLink
as={headingLevel} as={headingLevel}
to={getCollectionPath(id)} to={getCollectionPath(id)}
id={titleId}
subtitle={ subtitle={
<CollectionInfo <CollectionInfo
collection={collection} collection={collection}
@ -81,6 +86,7 @@ export const CollectionLockup: React.FC<CollectionLockupProps> = ({
withTimestamp={withTimestamp} withTimestamp={withTimestamp}
/> />
} }
subtitleId={subtitleId}
> >
{name} {name}
</ListItemLink> </ListItemLink>

View File

@ -248,7 +248,15 @@ class ComposeForm extends ImmutablePureComponent {
const { intl, onPaste, onDrop, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props; const { intl, onPaste, onDrop, autoFocus, withoutNavigation, maxChars, isSubmitting } = this.props;
return ( return (
<form className='compose-form' onSubmit={this.handleSubmit}> <form
className='compose-form'
role='region'
aria-label={intl.formatMessage({
id: 'tabs_bar.publish',
defaultMessage: 'New Post'
})}
onSubmit={this.handleSubmit}
>
<ReplyIndicator /> <ReplyIndicator />
{!withoutNavigation && <NavigationBar />} {!withoutNavigation && <NavigationBar />}
<Warning /> <Warning />

View File

@ -58,7 +58,7 @@ const Option = ({ multipleChoice, index, title, autoFocus }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const suggestions = useSelector(state => state.getIn(['compose', 'suggestions'])); const suggestions = useSelector(state => state.getIn(['compose', 'suggestions']));
const lang = useSelector(state => state.getIn(['compose', 'language'])); const lang = useSelector(state => state.getIn(['compose', 'language']));
const maxOptions = useSelector(state => state.getIn(['server', 'server', 'configuration', 'polls', 'max_options'])); const maxOptions = useSelector(state => state.getIn(['server', 'server', 'item', 'configuration', 'polls', 'max_options']));
const handleChange = useCallback(({ target: { value } }) => { const handleChange = useCallback(({ target: { value } }) => {
dispatch(changePollOption(index, value, maxOptions)); dispatch(changePollOption(index, value, maxOptions));

View File

@ -547,11 +547,15 @@ export const Search: React.FC<{
const searchOptionsHeading = useId(); const searchOptionsHeading = useId();
return ( return (
<form ref={formRef} className={classNames('search', { active: expanded })}> <form
role='search'
ref={formRef}
className={classNames('search', { active: expanded })}
>
<input <input
ref={searchInputRef} ref={searchInputRef}
className='search__input' className='search__input'
type='text' type='search'
placeholder={intl.formatMessage( placeholder={intl.formatMessage(
signedIn ? messages.placeholderSignedIn : messages.placeholder, signedIn ? messages.placeholderSignedIn : messages.placeholder,
)} )}

View File

@ -9,7 +9,7 @@ const mapStateToProps = state => {
const readyAttachmentsSize = state.getIn(['compose', 'media_attachments']).size ?? 0; const readyAttachmentsSize = state.getIn(['compose', 'media_attachments']).size ?? 0;
const pendingAttachmentsSize = state.getIn(['compose', 'pending_media_attachments']).size ?? 0; const pendingAttachmentsSize = state.getIn(['compose', 'pending_media_attachments']).size ?? 0;
const attachmentsSize = readyAttachmentsSize + pendingAttachmentsSize; const attachmentsSize = readyAttachmentsSize + pendingAttachmentsSize;
const isOverLimit = attachmentsSize > state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments'])-1; const isOverLimit = attachmentsSize > state.getIn(['server', 'server', 'item', 'configuration', 'statuses', 'max_media_attachments'])-1;
const hasVideoOrAudio = state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type'))); const hasVideoOrAudio = state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')));
const hasQuote = !!state.compose.get('quoted_status_id'); const hasQuote = !!state.compose.get('quoted_status_id');

View File

@ -87,12 +87,11 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
if (multiColumn) { if (multiColumn) {
return ( return (
<div <div className='drawer'>
className='drawer' <nav
role='region' className='drawer__header'
aria-label={intl.formatMessage(navbarMessages.publish)} aria-label={intl.formatMessage(navbarMessages.advancedUiQuickLinks)}
> >
<nav className='drawer__header'>
<Link <Link
to='/getting-started' to='/getting-started'
className='drawer__tab' className='drawer__tab'
@ -163,7 +162,11 @@ const Compose: React.FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
<Search singleColumn={false} /> <Search singleColumn={false} />
<div className='drawer__pager'> <div
className='drawer__pager'
role='region'
aria-label={intl.formatMessage(navbarMessages.publish)}
>
<div className='drawer__inner'> <div className='drawer__inner'>
<ComposeFormContainer /> <ComposeFormContainer />

View File

@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { fetchExtendedDescription } from 'mastodon/actions/server';
import { Account } from 'mastodon/components/account';
import { Skeleton } from 'mastodon/components/skeleton';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import classes from './styles.module.scss';
const Placeholder = () => (
<div className={classes.placeholder}>
<Skeleton width='100%' />
<Skeleton width='100%' />
<Skeleton width='100%' />
</div>
);
export const About = () => {
const dispatch = useAppDispatch();
const server = useAppSelector((state) => state.server.server);
const extendedDescription = useAppSelector(
(state) => state.server.extendedDescription,
);
const accountId = server.item?.contact.account?.id ?? '';
const isLoading = extendedDescription.isLoading;
const hasContent = (extendedDescription.item?.content.length ?? 0) > 0;
const content = extendedDescription.item?.content ?? '';
useEffect(() => {
void dispatch(fetchExtendedDescription());
}, [dispatch]);
return (
<>
<div className={classes.block}>
<h2>
<FormattedMessage
id='custom_homepage.administered_by'
defaultMessage='Administered by'
/>
</h2>
<Account id={accountId} size={36} minimal />
</div>
<div className={classes.block}>
<h2>
<FormattedMessage
id='custom_homepage.about_this_server'
defaultMessage='About this server'
/>
</h2>
{isLoading ? (
<Placeholder />
) : hasContent ? (
<div
className='prose'
dangerouslySetInnerHTML={{ __html: content }}
/>
) : (
<div className='prose'>
<p>
<FormattedMessage
id='about.not_available'
defaultMessage='This information has not been made available on this server.'
/>
</p>
</div>
)}
</div>
</>
);
};

View File

@ -0,0 +1,39 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { fetchServer } from 'mastodon/actions/server';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import classes from '../styles.module.scss';
export const Footer = () => {
const dispatch = useAppDispatch();
const server = useAppSelector((state) => state.server.server);
const email = server.item?.contact.email ?? '';
useEffect(() => {
void dispatch(fetchServer());
}, [dispatch]);
return (
<footer className={classes.minimalFooter}>
<div className={classes.contact}>
<FormattedMessage
id='custom_homepage.contact'
defaultMessage='Contact:'
/>
<a href={`mailto:${email}`}>{email}</a>
</div>
<Link to='/privacy-policy' rel='privacy-policy'>
<FormattedMessage
id='footer.privacy_policy'
defaultMessage='Privacy policy'
/>
</Link>
</footer>
);
};

View File

@ -0,0 +1,25 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { domain, sso_redirect } from 'mastodon/initial_state';
import classes from '../styles.module.scss';
export const Header = () => (
<div className={classes.minimalHeader}>
<div className={classes.leftSide}>
<Link to='/overview'>{domain}</Link>
</div>
<div className={classes.rightSide}>
<a
href={sso_redirect ?? '/auth/sign_in'}
data-method={sso_redirect ? 'post' : undefined}
className='button button-secondary'
>
<FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' />
</a>
</div>
</div>
);

View File

@ -0,0 +1,71 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { Route, Switch, useRouteMatch } from 'react-router-dom';
import { Helmet } from '@unhead/react/helmet';
import { fetchServer } from 'mastodon/actions/server';
import { ServerHeroImage } from 'mastodon/components/server_hero_image';
import { TabLink, TabList } from 'mastodon/components/tab_list';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { About } from './about';
import { LatestActivity } from './latest_activity';
import classes from './styles.module.scss';
export const CustomHomepage: React.FC = () => {
const dispatch = useAppDispatch();
const server = useAppSelector((state) => state.server.server);
const { path } = useRouteMatch();
useEffect(() => {
void dispatch(fetchServer());
}, [dispatch]);
return (
<div className={classes.page}>
<ServerHeroImage
alt={server.item?.thumbnail.description ?? ''}
blurhash={server.item?.thumbnail.blurhash ?? ''}
src={server.item?.thumbnail.url ?? ''}
srcSet={Object.keys(server.item?.thumbnail.versions ?? {})
.map(
(key) =>
`${server.item?.thumbnail.versions?.[key]} ${key.replace('@', '')}`,
)
.join(', ')}
className={classes.header}
/>
<div className={classes.topSection}>
<h1>{server.item?.domain}</h1>
<p>{server.item?.description}</p>
</div>
<TabList>
<TabLink to={path} exact>
<FormattedMessage
id='custom_homepage.latest_activity'
defaultMessage='Latest activity'
/>
</TabLink>
<TabLink to={`${path}/about`} exact>
<FormattedMessage id='custom_homepage.about' defaultMessage='About' />
</TabLink>
</TabList>
<Switch>
<Route path={path} exact component={LatestActivity} />
<Route path={`${path}/about`} exact component={About} />
</Switch>
<Helmet>
<title>{server.item?.domain}</title>
<meta name='robots' content='all' />
</Helmet>
</div>
);
};

View File

@ -0,0 +1,35 @@
import { useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import { expandCommunityTimeline } from 'mastodon/actions/timelines';
import { Callout } from 'mastodon/components/callout';
import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
import { useAppDispatch } from 'mastodon/store';
import classes from './styles.module.scss';
export const LatestActivity = () => {
const dispatch = useAppDispatch();
useEffect(() => {
void dispatch(expandCommunityTimeline());
}, [dispatch]);
return (
<StatusListContainer
prepend={
<Callout className={classes.banner}>
<FormattedMessage
id='custom_homepage.these_are_the_latest_posts'
defaultMessage='These are the latest 40 posts from accounts on this server.'
/>
</Callout>
}
scrollKey='custom_homepage'
timelineId='community'
maxItems={40}
bindToDocument
/>
);
};

View File

@ -0,0 +1,145 @@
.page {
border-radius: 16px;
border: 1px solid var(--color-border-primary);
background: var(--color-bg-primary);
min-height: 100%;
:global(.item-list) article:last-child :global(.status) {
border-bottom: 0;
}
@media screen and (width <= 1175px) {
border-radius: 0;
}
}
.header {
aspect-ratio: 40/21;
border-radius: 16px 16px 0 0;
@media screen and (width <= 1175px) {
border-radius: 0;
}
}
.banner {
margin: 16px;
margin-bottom: 0;
}
.topSection {
display: flex;
padding: 16px;
flex-direction: column;
gap: 8px;
color: var(--color-text-primary);
h1 {
font-size: 24px;
font-weight: 500;
line-height: 30px;
letter-spacing: -0.12px;
}
p {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: 16px;
line-height: 24px;
text-overflow: ellipsis;
}
}
.block {
padding: 16px;
h2 {
font-size: 16px;
font-weight: 500;
line-height: 22.4px;
margin-bottom: 8px;
}
:global(.account) {
border: 1px solid var(--color-border-primary);
padding: 18px 16px;
border-radius: 12px;
--avatar-border-radius: 50%;
}
}
.placeholder {
padding: 4px 0;
display: flex;
flex-direction: column;
gap: 12px;
:global(.skeleton) {
height: 40px;
border-radius: 12px;
background: var(--color-bg-overlay-highlight);
}
}
.minimalHeader {
padding-top: 24px;
padding-bottom: 12px;
display: flex;
align-items: center;
@media screen and (width <= 1175px) {
padding-inline-start: 12px;
padding-inline-end: 12px;
}
}
.leftSide {
font-size: 15px;
font-weight: 600;
max-width: 300px;
a {
text-decoration: none;
color: inherit;
}
}
.rightSide {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 4px;
flex: 1 0 0;
}
.minimalFooter {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--color-text-secondary);
font-size: 16px;
font-weight: 500;
line-height: 22.4px;
padding-top: 28px;
padding-bottom: 54px;
gap: 8px;
flex-wrap: wrap;
a {
color: inherit;
text-decoration: underline;
}
@media screen and (width <= 1175px) {
justify-content: center;
padding-inline-start: 12px;
padding-inline-end: 12px;
}
}
.contact {
display: flex;
gap: 8px;
}

View File

@ -85,13 +85,16 @@ export async function search({
// Only query the range for the last token to allow partial matches. // Only query the range for the last token to allow partial matches.
const range = const range =
i === queryTokens.length - 1 i === queryTokens.length - 1
? IDBKeyRange.bound(token, token + '\uffff') ? IDBKeyRange.lowerBound(token)
: IDBKeyRange.only(token); : IDBKeyRange.only(token);
const [unicodeResults, customResults] = await Promise.all([ const [unicodeResults, customResults, shortcodeResults] = await Promise.all(
db.getAllFromIndex(locale, 'tokens', range), [
db.getAllFromIndex('custom', 'tokens', range), db.getAllFromIndex(locale, 'tokens', range),
]); db.getAllFromIndex('custom', 'tokens', range),
db.getAllFromIndex('shortcodes', 'shortcodes', range),
],
);
const resultMap: ScoreMap = new Map(); const resultMap: ScoreMap = new Map();
for (const emoji of unicodeResults) { for (const emoji of unicodeResults) {
const score = getScoreForEmoji(emoji, token); const score = getScoreForEmoji(emoji, token);
@ -107,6 +110,22 @@ export async function search({
} }
resultMap.set(emoji.shortcode, { ...emoji, score }); resultMap.set(emoji.shortcode, { ...emoji, score });
} }
for (const shortcodeResult of shortcodeResults) {
if (resultMap.has(shortcodeResult.hexcode)) {
continue;
}
const emoji = await db.get(locale, shortcodeResult.hexcode);
if (!emoji) {
continue;
}
const score = getScoreForEmoji(emoji, token);
if (score === null) {
continue;
}
resultMap.set(emoji.hexcode, { ...emoji, score });
}
log('found %d results for token "%s"', resultMap.size, token); log('found %d results for token "%s"', resultMap.size, token);
resultArrays.push(resultMap); resultArrays.push(resultMap);
} }
@ -147,7 +166,7 @@ function getScoreForEmoji(emoji: AnyEmojiData, query: string) {
} }
let index = 1; let index = 1;
for (const token of [id, emoji.tokens]) { for (const token of [id, ...emoji.tokens]) {
const tokenIndex = token.indexOf(query); const tokenIndex = token.indexOf(query);
if (tokenIndex !== -1) { if (tokenIndex !== -1) {
return index + tokenIndex / token.length; return index + tokenIndex / token.length;

View File

@ -2,6 +2,7 @@ import { initialState } from '@/mastodon/initial_state';
import type { EMOJI_DB_NAME_SHORTCODES } from './constants'; import type { EMOJI_DB_NAME_SHORTCODES } from './constants';
import { toSupportedLocale } from './locale'; import { toSupportedLocale } from './locale';
import { reloadCustomEmojis } from './picker';
import type { LocaleOrCustom } from './types'; import type { LocaleOrCustom } from './types';
import { emojiLogger } from './utils'; import { emojiLogger } from './utils';
@ -90,6 +91,7 @@ export async function loadCustomEmoji() {
const emojis = await importCustomEmojiData(); const emojis = await importCustomEmojiData();
if (emojis && emojis.length > 0) { if (emojis && emojis.length > 0) {
log('loaded %d custom emojis', emojis.length); log('loaded %d custom emojis', emojis.length);
await reloadCustomEmojis();
} }
} }
} }

View File

@ -14,6 +14,7 @@ import {
EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE, EMOJIS_REQUIRING_INVERSION_IN_DARK_MODE,
EMOJI_MIN_TOKEN_LENGTH, EMOJI_MIN_TOKEN_LENGTH,
} from './constants'; } from './constants';
import { localeToSegmenter } from './locale';
import type { import type {
CustomEmojiData, CustomEmojiData,
CustomEmojiMapArg, CustomEmojiMapArg,
@ -92,10 +93,11 @@ export function transformEmojiData(
export function transformCustomEmojiData( export function transformCustomEmojiData(
emoji: ApiCustomEmojiJSON, emoji: ApiCustomEmojiJSON,
): CustomEmojiData { ): CustomEmojiData {
const tokens = emoji.shortcode const tokens = extractTokens(emoji.shortcode, localeToSegmenter('en'));
.split('_') if (!tokens.includes(emoji.shortcode)) {
.filter((word) => word.length >= EMOJI_MIN_TOKEN_LENGTH) tokens.unshift(emoji.shortcode);
.map((word) => word.toLowerCase()); }
return { return {
...emoji, ...emoji,
tokens, tokens,
@ -215,7 +217,9 @@ export function extractTokens(
// Prefer to use Intl.Segmenter if available for better locale support. // Prefer to use Intl.Segmenter if available for better locale support.
if (segmenter) { if (segmenter) {
for (const { isWordLike, segment } of segmenter.segment( for (const { isWordLike, segment } of segmenter.segment(
input.replaceAll('_', ' '), // Handle underscores from shortcodes. input
.replaceAll(/[_-]+/g, ' ') // Handle underscores from shortcodes.
.replaceAll(/([a-z])([A-Z])/g, '$1 $2'), // Handle camelCase.
)) { )) {
if (isWordLike && segment.length >= EMOJI_MIN_TOKEN_LENGTH) { if (isWordLike && segment.length >= EMOJI_MIN_TOKEN_LENGTH) {
tokens.push(segment.toLowerCase()); tokens.push(segment.toLowerCase());

View File

@ -82,13 +82,22 @@ type LegacyEmoji =
custom: true; custom: true;
}; };
export async function reloadCustomEmojis() {
customEmojis = null;
const { loadEmojisIntoCache } =
await import('@/mastodon/hooks/useCustomEmojis');
await Promise.all([fetchCustomEmojiData(), loadEmojisIntoCache()]);
}
// Replicates the old legacy search function. // Replicates the old legacy search function.
export async function emojiMartSearch( export async function emojiMartSearch(
token: string, token: string,
locale: string, locale: string,
limit = 5, limit = 5,
): Promise<LegacyEmoji[]> { ): Promise<LegacyEmoji[]> {
const query = token.replace(':', '').toLowerCase().trim(); const query = token.replace(':', '').trim();
if (!query.length) { if (!query.length) {
return []; return [];
} }

View File

@ -4,14 +4,16 @@ import { Helmet } from '@unhead/react/helmet';
import { Column } from 'mastodon/components/column'; import { Column } from 'mastodon/components/column';
import { NavigationPanel } from '../navigation_panel'; import { NavigationPanel, messages } from '../navigation_panel';
import { LinkFooter } from '../ui/components/link_footer'; import { LinkFooter } from '../ui/components/link_footer';
const GettingStarted: React.FC = () => { const GettingStarted: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
return ( return (
<Column> <Column>
<NavigationPanel multiColumn /> <nav aria-label={intl.formatMessage(messages.main)}>
<NavigationPanel multiColumn />
</nav>
<LinkFooter context='multi-column' /> <LinkFooter context='multi-column' />

View File

@ -60,7 +60,7 @@ import { MoreLink } from './components/more_link';
import { SignInBanner } from './components/sign_in_banner'; import { SignInBanner } from './components/sign_in_banner';
import { Trends } from './components/trends'; import { Trends } from './components/trends';
const messages = defineMessages({ export const messages = defineMessages({
home: { id: 'tabs_bar.home', defaultMessage: 'Home' }, home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { notifications: {
id: 'tabs_bar.notifications', id: 'tabs_bar.notifications',
@ -72,6 +72,12 @@ const messages = defineMessages({
id: 'column.firehose_singular', id: 'column.firehose_singular',
defaultMessage: 'Live feed', defaultMessage: 'Live feed',
}, },
main: {
id: 'navigation_bar.main',
defaultMessage: 'Main',
description:
'Label for the main navigation; should not contain the word "navigation".',
},
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favorites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' }, bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
@ -419,6 +425,7 @@ export const NavigationPanel: React.FC<{ multiColumn?: boolean }> = ({
}; };
export const CollapsibleNavigationPanel: React.FC = () => { export const CollapsibleNavigationPanel: React.FC = () => {
const intl = useIntl();
const open = useAppSelector((state) => state.navigation.open); const open = useAppSelector((state) => state.navigation.open);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const openable = useBreakpoint('openable'); const openable = useBreakpoint('openable');
@ -527,7 +534,8 @@ export const CollapsibleNavigationPanel: React.FC = () => {
const showOverlay = openable && open; const showOverlay = openable && open;
return ( return (
<div <nav
aria-label={intl.formatMessage(messages.main)}
className={classNames( className={classNames(
'columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational', 'columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational',
{ 'columns-area__panels__pane--overlay': showOverlay }, { 'columns-area__panels__pane--overlay': showOverlay },
@ -541,6 +549,6 @@ export const CollapsibleNavigationPanel: React.FC = () => {
> >
<NavigationPanel /> <NavigationPanel />
</animated.div> </animated.div>
</div> </nav>
); );
}; };

View File

@ -12,6 +12,8 @@ import classNames from 'classnames';
import type { List, Record } from 'immutable'; import type { List, Record } from 'immutable';
import { useAppSelector } from '@/mastodon/store'; import { useAppSelector } from '@/mastodon/store';
import { Footer } from 'mastodon/features/custom_homepage/components/footer';
import { Header } from 'mastodon/features/custom_homepage/components/header';
import { CollapsibleNavigationPanel } from 'mastodon/features/navigation_panel'; import { CollapsibleNavigationPanel } from 'mastodon/features/navigation_panel';
import { useBreakpoint } from '../hooks/useBreakpoint'; import { useBreakpoint } from '../hooks/useBreakpoint';
@ -85,9 +87,10 @@ export const ColumnsArea = forwardRef<
HTMLDivElement, HTMLDivElement,
{ {
singleColumn?: boolean; singleColumn?: boolean;
minimalShell?: boolean;
children: React.ReactElement | React.ReactElement[]; children: React.ReactElement | React.ReactElement[];
} }
>(({ children, singleColumn }, ref) => { >(({ children, minimalShell, singleColumn }, ref) => {
const renderComposePanel = !useBreakpoint('full'); const renderComposePanel = !useBreakpoint('full');
const columns = useAppSelector((state) => const columns = useAppSelector((state) =>
(state.settings as Record<{ columns: List<Record<Column>> }>).get( (state.settings as Record<{ columns: List<Record<Column>> }>).get(
@ -98,6 +101,24 @@ export const ColumnsArea = forwardRef<
(state) => !state.modal.get('stack').isEmpty(), (state) => !state.modal.get('stack').isEmpty(),
); );
if (minimalShell) {
return (
<div className='columns-area__panels'>
<div className='columns-area__panels__main'>
<Header />
<div className='tabs-bar__wrapper'>
<TabsBarPortal />
</div>
<div className='columns-area columns-area--mobile'>{children}</div>
<Footer />
</div>
</div>
);
}
if (singleColumn) { if (singleColumn) {
return ( return (
<div className='columns-area__panels'> <div className='columns-area__panels'>
@ -108,12 +129,13 @@ export const ColumnsArea = forwardRef<
</div> </div>
</div> </div>
<div className='columns-area__panels__main'> <main className='columns-area__panels__main'>
<div className='tabs-bar__wrapper'> <div className='tabs-bar__wrapper'>
<TabsBarPortal /> <TabsBarPortal />
</div> </div>
<div className='columns-area columns-area--mobile'>{children}</div> <div className='columns-area columns-area--mobile'>{children}</div>
</div> </main>
<CollapsibleNavigationPanel /> <CollapsibleNavigationPanel />
</div> </div>
@ -121,7 +143,7 @@ export const ColumnsArea = forwardRef<
} }
return ( return (
<div <main
className={classNames('columns-area', { unscrollable: isModalOpen })} className={classNames('columns-area', { unscrollable: isModalOpen })}
ref={ref} ref={ref}
tabIndex={isModalOpen ? undefined : 0} tabIndex={isModalOpen ? undefined : 0}
@ -160,7 +182,7 @@ export const ColumnsArea = forwardRef<
cloneElement(child, { multiColumn: true }), cloneElement(child, { multiColumn: true }),
)} )}
</ColumnIndexContext.Provider> </ColumnIndexContext.Provider>
</div> </main>
); );
}); });

View File

@ -41,6 +41,10 @@
&:not(:last-child)::after { &:not(:last-child)::after {
content: ' · '; content: ' · ';
@supports (content: 'x' / 'y') {
content: ' · ' / '';
}
} }
} }

View File

@ -31,6 +31,10 @@ export const messages = defineMessages({
defaultMessage: 'Notifications', defaultMessage: 'Notifications',
}, },
menu: { id: 'tabs_bar.menu', defaultMessage: 'Menu' }, menu: { id: 'tabs_bar.menu', defaultMessage: 'Menu' },
advancedUiQuickLinks: {
id: 'tabs_bar.quick_links',
defaultMessage: 'Quick links',
},
}); });
const IconLabelButton: React.FC<{ const IconLabelButton: React.FC<{

View File

@ -11,7 +11,15 @@ import { me } from '@/mastodon/initial_state';
const makeGetStatusIds = (pending = false) => createSelector([ const makeGetStatusIds = (pending = false) => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()), (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
(state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()), (state, { type, maxItems }) => {
const items = state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList());
if (maxItems) {
return items.take(maxItems);
}
return items;
},
(state) => state.get('statuses'), (state) => state.get('statuses'),
], (columnSettings, statusIds, statuses) => { ], (columnSettings, statusIds, statuses) => {
return statusIds.filter(id => { return statusIds.filter(id => {
@ -41,8 +49,15 @@ const makeMapStateToProps = () => {
const getStatusIds = makeGetStatusIds(); const getStatusIds = makeGetStatusIds();
const getPendingStatusIds = makeGetStatusIds(true); const getPendingStatusIds = makeGetStatusIds(true);
const mapStateToProps = (state, { timelineId, initialLoadingState = true }) => ({ /**
statusIds: getStatusIds(state, { type: timelineId }), * @param {import('mastodon/store').RootState} state
* @param {Object} props
* @param {string} props.timelineId
* @param {boolean} [props.initialLoadingState]
* @param {number} [props.maxItems]
*/
const mapStateToProps = (state, { timelineId, initialLoadingState = true, maxItems }) => ({
statusIds: getStatusIds(state, { type: timelineId, maxItems }),
lastId: state.getIn(['timelines', timelineId, 'items'])?.last(), lastId: state.getIn(['timelines', timelineId, 'items'])?.last(),
isLoading: state.getIn(['timelines', timelineId, 'isLoading'], initialLoadingState), isLoading: state.getIn(['timelines', timelineId, 'isLoading'], initialLoadingState),
isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),

View File

@ -29,7 +29,7 @@ import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../act
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server'; import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
import { expandHomeTimeline } from '../../actions/timelines'; import { expandHomeTimeline } from '../../actions/timelines';
import { initialState, me, owner, singleUserMode, trendsEnabled, landingPage, localLiveFeedAccess, disableHoverCards } from '../../initial_state'; import { initialState, me, owner, singleUserMode, trendsEnabled, landingPage, localLiveFeedAccess, disableHoverCards, domain } from '../../initial_state';
import BundleColumnError from './components/bundle_column_error'; import BundleColumnError from './components/bundle_column_error';
import { NavigationBar } from './components/navigation_bar'; import { NavigationBar } from './components/navigation_bar';
@ -88,6 +88,7 @@ import {
import { ColumnsContextProvider } from './util/columns_context'; import { ColumnsContextProvider } from './util/columns_context';
import { focusColumn, getFocusedItemIndex, focusItemSibling, focusFirstItem } from './util/focusUtils'; import { focusColumn, getFocusedItemIndex, focusItemSibling, focusFirstItem } from './util/focusUtils';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import { CustomHomepage } from 'mastodon/features/custom_homepage';
// Dummy import, to make sure that <Status /> ends up in the application bundle. // Dummy import, to make sure that <Status /> ends up in the application bundle.
// Without this it ends up in ~8 very commonly used bundles. // Without this it ends up in ~8 very commonly used bundles.
@ -105,7 +106,7 @@ const mapStateToProps = state => ({
hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null, hasComposingContents: state.getIn(['compose', 'text']).trim().length !== 0 || state.getIn(['compose', 'media_attachments']).size > 0 || state.getIn(['compose', 'poll']) !== null || state.getIn(['compose', 'quoted_status_id']) !== null,
canUploadMore: canUploadMore:
!state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type')))
&& state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'configuration', 'statuses', 'max_media_attachments']), && state.getIn(['compose', 'media_attachments']).size < state.getIn(['server', 'server', 'item', 'configuration', 'statuses', 'max_media_attachments']),
isUploadEnabled: isUploadEnabled:
state.getIn(['compose', 'isDragDisabled']) !== true, state.getIn(['compose', 'isDragDisabled']) !== true,
firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION, firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
@ -177,13 +178,15 @@ class SwitchingColumnsArea extends PureComponent {
rootRedirect = '/explore'; rootRedirect = '/explore';
} else if (localLiveFeedAccess === 'public' && landingPage === 'local_feed') { } else if (localLiveFeedAccess === 'public' && landingPage === 'local_feed') {
rootRedirect = '/public/local'; rootRedirect = '/public/local';
} else if (landingPage === 'overview') {
rootRedirect = '/overview';
} else { } else {
rootRedirect = '/about'; rootRedirect = '/about';
} }
return ( return (
<ColumnsContextProvider multiColumn={!singleColumn}> <ColumnsContextProvider multiColumn={!singleColumn}>
<ColumnsArea ref={this.setRef} singleColumn={singleColumn}> <ColumnsArea ref={this.setRef} singleColumn={singleColumn} domain={domain} minimalShell={!signedIn && landingPage === 'overview'}>
<WrappedSwitch> <WrappedSwitch>
<Redirect from='/' to={{pathname: rootRedirect, state: this.props.location.state}} exact /> <Redirect from='/' to={{pathname: rootRedirect, state: this.props.location.state}} exact />
@ -262,6 +265,8 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/followed_tags' component={FollowedTags} content={children} /> <WrappedRoute path='/followed_tags' component={FollowedTags} content={children} />
<WrappedRoute path='/mutes' component={Mutes} content={children} /> <WrappedRoute path='/mutes' component={Mutes} content={children} />
<WrappedRoute path='/lists' component={Lists} content={children} /> <WrappedRoute path='/lists' component={Lists} content={children} />
<Route path='/overview' component={CustomHomepage} />
<Route component={BundleColumnError} /> <Route component={BundleColumnError} />
</WrappedSwitch> </WrappedSwitch>
</ColumnsArea> </ColumnsArea>
@ -633,13 +638,18 @@ class UI extends PureComponent {
cheat: this.handleDonate, cheat: this.handleDonate,
}; };
const minimalShell = !this.props.identity.signedIn && landingPage === 'overview';
return ( return (
<Hotkeys global handlers={handlers}> <Hotkeys global handlers={handlers}>
<div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}> <div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef}>
<SkipLinks {!minimalShell && (
multiColumn={layout === 'multi-column'} <SkipLinks
onFocusGettingStartedColumn={this.handleHotkeyGoToStart} multiColumn={layout === 'multi-column'}
/> onFocusGettingStartedColumn={this.handleHotkeyGoToStart}
/>
)}
<SwitchingColumnsArea <SwitchingColumnsArea
identity={this.props.identity} identity={this.props.identity}
location={location} location={location}
@ -650,7 +660,7 @@ class UI extends PureComponent {
{children} {children}
</SwitchingColumnsArea> </SwitchingColumnsArea>
<NavigationBar /> {!minimalShell && <NavigationBar />}
{layout !== 'mobile' && <PictureInPicture />} {layout !== 'mobile' && <PictureInPicture />}
<AlertsController /> <AlertsController />
{!disableHoverCards && <HoverCardController />} {!disableHoverCards && <HoverCardController />}

View File

@ -20,7 +20,7 @@ export function useCustomEmojis() {
return emojis; return emojis;
} }
async function loadEmojisIntoCache() { export async function loadEmojisIntoCache() {
const { loadAllCustomEmoji } = await import('../features/emoji/database'); const { loadAllCustomEmoji } = await import('../features/emoji/database');
const emojisRaw = await loadAllCustomEmoji(); const emojisRaw = await loadAllCustomEmoji();
if (emojisRaw === null) { if (emojisRaw === null) {

View File

@ -584,6 +584,12 @@
"copy_icon_button.copy_this_text": "Copy link to clipboard", "copy_icon_button.copy_this_text": "Copy link to clipboard",
"copypaste.copied": "Copied", "copypaste.copied": "Copied",
"copypaste.copy_to_clipboard": "Copy to clipboard", "copypaste.copy_to_clipboard": "Copy to clipboard",
"custom_homepage.about": "About",
"custom_homepage.about_this_server": "About this server",
"custom_homepage.administered_by": "Administered by",
"custom_homepage.contact": "Contact:",
"custom_homepage.latest_activity": "Latest activity",
"custom_homepage.these_are_the_latest_posts": "These are the latest 40 posts from accounts on this server.",
"directory.federated": "From known fediverse", "directory.federated": "From known fediverse",
"directory.local": "From {domain} only", "directory.local": "From {domain} only",
"directory.new_arrivals": "New arrivals", "directory.new_arrivals": "New arrivals",
@ -908,6 +914,7 @@
"navigation_bar.live_feed_local": "Live feed (local)", "navigation_bar.live_feed_local": "Live feed (local)",
"navigation_bar.live_feed_public": "Live feed (public)", "navigation_bar.live_feed_public": "Live feed (public)",
"navigation_bar.logout": "Logout", "navigation_bar.logout": "Logout",
"navigation_bar.main": "Main",
"navigation_bar.moderation": "Moderation", "navigation_bar.moderation": "Moderation",
"navigation_bar.more": "More", "navigation_bar.more": "More",
"navigation_bar.mutes": "Muted users", "navigation_bar.mutes": "Muted users",
@ -1305,6 +1312,7 @@
"tabs_bar.menu": "Menu", "tabs_bar.menu": "Menu",
"tabs_bar.notifications": "Notifications", "tabs_bar.notifications": "Notifications",
"tabs_bar.publish": "New Post", "tabs_bar.publish": "New Post",
"tabs_bar.quick_links": "Quick links",
"tabs_bar.search": "Search", "tabs_bar.search": "Search",
"tag.remove": "Remove", "tag.remove": "Remove",
"terms_of_service.effective_as_of": "Effective as of {date}", "terms_of_service.effective_as_of": "Effective as of {date}",

View File

@ -411,7 +411,7 @@ $content-width: 840px;
display: flex; display: flex;
} }
& > ul { & > nav > ul {
display: none; display: none;
&.visible { &.visible {

View File

@ -1613,7 +1613,6 @@ body > [data-popper-placement] {
font-size: 15px; font-size: 15px;
line-height: 22px; line-height: 22px;
height: 40px; height: 40px;
order: 2;
flex: 0 0 auto; flex: 0 0 auto;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
@ -1666,7 +1665,6 @@ body > [data-popper-placement] {
.status__quote-cancel { .status__quote-cancel {
align-self: self-start; align-self: self-start;
order: 5;
} }
.status__info { .status__info {
@ -10778,6 +10776,7 @@ noscript {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
max-height: 2 * 20px; max-height: 2 * 20px;
overflow: hidden; overflow: hidden;
overflow-wrap: anywhere;
p { p {
margin-bottom: 0; margin-bottom: 0;

View File

@ -109,7 +109,7 @@ class Form::AdminSettings
REGISTRATION_MODES = %w(open approved none).freeze REGISTRATION_MODES = %w(open approved none).freeze
FEED_ACCESS_MODES = %w(public authenticated disabled).freeze FEED_ACCESS_MODES = %w(public authenticated disabled).freeze
ALTERNATE_FEED_ACCESS_MODES = %w(public authenticated).freeze ALTERNATE_FEED_ACCESS_MODES = %w(public authenticated).freeze
LANDING_PAGE = %w(trends about local_feed).freeze LANDING_PAGE = %w(trends overview local_feed about).freeze
attr_accessor(*KEYS) attr_accessor(*KEYS)

View File

@ -3,6 +3,7 @@
class ReportFilter class ReportFilter
KEYS = %i( KEYS = %i(
resolved resolved
unresolved
account_id account_id
target_account_id target_account_id
by_target_domain by_target_domain
@ -16,7 +17,7 @@ class ReportFilter
end end
def results def results
scope = Report.unresolved scope = status_scope
relevant_params.each do |key, value| relevant_params.each do |key, value|
scope = scope.merge scope_for(key, value) scope = scope.merge scope_for(key, value)
@ -28,7 +29,7 @@ class ReportFilter
private private
def relevant_params def relevant_params
params.tap do |args| params.except(:resolved, :unresolved).tap do |args|
args.delete(:target_origin) if origin_is_remote_and_domain_present? args.delete(:target_origin) if origin_is_remote_and_domain_present?
end end
end end
@ -37,12 +38,20 @@ class ReportFilter
params[:target_origin] == 'remote' && params[:by_target_domain].present? params[:target_origin] == 'remote' && params[:by_target_domain].present?
end end
def status_scope
resolved = params.key?(:resolved)
unresolved = params.key?(:unresolved)
return Report.all if resolved && unresolved
return Report.resolved if resolved
Report.unresolved
end
def scope_for(key, value) def scope_for(key, value)
case key.to_sym case key.to_sym
when :by_target_domain when :by_target_domain
Report.where(target_account: Account.where(domain: value)) Report.where(target_account: Account.where(domain: value))
when :resolved
Report.resolved
when :account_id when :account_id
Report.where(account_id: value) Report.where(account_id: value)
when :target_account_id when :target_account_id

View File

@ -75,10 +75,11 @@
.fields-row .fields-row
= f.input :landing_page, = f.input :landing_page,
as: :radio_buttons,
collection: f.object.class::LANDING_PAGE, collection: f.object.class::LANDING_PAGE,
include_blank: false, include_blank: false,
label_method: ->(page) { I18n.t("admin.settings.landing_page.values.#{page}") }, label_method: ->(page) { safe_join([I18n.t("admin.settings.landing_page.values.#{page}"), content_tag(:span, I18n.t("admin.settings.landing_page.hints.#{page}_html"), class: 'hint')]) },
wrapper: :with_label wrapper: :with_block_label
.actions .actions
= f.button :button, t('generic.save_changes'), type: :submit = f.button :button, t('generic.save_changes'), type: :submit

View File

@ -8,7 +8,7 @@
- content_for :content do - content_for :content do
%a.navigation-skip-link{ href: '#content' }= t('admin.skip_to_content') %a.navigation-skip-link{ href: '#content' }= t('admin.skip_to_content')
.admin-wrapper .admin-wrapper
%nav.sidebar-wrapper %header.sidebar-wrapper
.sidebar-wrapper__inner .sidebar-wrapper__inner
.sidebar .sidebar
= link_to root_path do = link_to root_path do
@ -23,7 +23,8 @@
= material_symbol 'menu' = material_symbol 'menu'
= material_symbol 'close' = material_symbol 'close'
= render_navigation %nav
= render_navigation
%main.content-wrapper#content %main.content-wrapper#content
.content .content

View File

@ -956,10 +956,16 @@ en:
disabled: Require specific user role disabled: Require specific user role
public: Everyone public: Everyone
landing_page: landing_page:
hints:
about_html: A page with the description, contact information, rules and other information regarding this server.
local_feed_html: A live feed featuring most recent posts by users on this server.
overview_html: A page showcasing the description of your server alongside the most recent local posts by users on this server.
trends_html: A page featuring what's popular on this server right now.
values: values:
about: About about: About page
local_feed: Local feed local_feed: Local live feed
trends: Trends overview: Overview
trends: Trending
registrations: registrations:
moderation_recommandation: Please make sure you have an adequate and reactive moderation team before you open registrations to everyone! moderation_recommandation: Please make sure you have an adequate and reactive moderation team before you open registrations to everyone!
preamble: Control who can create an account on your server. preamble: Control who can create an account on your server.

View File

@ -34,4 +34,6 @@
/search /search
/start/(*any) /start/(*any)
/statuses/(*any) /statuses/(*any)
/overview
/overview/about
).each { |path| get path, to: 'home#index' } ).each { |path| get path, to: 'home#index' }

View File

@ -164,7 +164,7 @@
"@vitest/browser-playwright": "^4.1.0", "@vitest/browser-playwright": "^4.1.0",
"@vitest/coverage-v8": "^4.1.0", "@vitest/coverage-v8": "^4.1.0",
"@vitest/ui": "^4.1.0", "@vitest/ui": "^4.1.0",
"chromatic": "^16.0.0", "chromatic": "^17.0.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-import-resolver-typescript": "^4.2.5", "eslint-import-resolver-typescript": "^4.2.5",
"eslint-plugin-formatjs": "^6.0.0", "eslint-plugin-formatjs": "^6.0.0",

View File

@ -78,6 +78,17 @@ RSpec.describe 'Reports' do
end end
end end
context 'with both resolved and unresolved params' do
let(:params) { { resolved: true, unresolved: true } }
let(:scope) { Report.all }
it 'returns all reports' do
subject
expect(response.parsed_body).to match_array(expected_response)
end
end
context 'with account_id param' do context 'with account_id param' do
let(:params) { { account_id: reporter.id } } let(:params) { { account_id: reporter.id } }
let(:scope) { Report.unresolved.where(account: reporter) } let(:scope) { Report.unresolved.where(account: reporter) }

View File

@ -12,6 +12,6 @@ RSpec.describe 'UnloggedBrowsing', :js, :streaming do
it 'loads the home page' do it 'loads the home page' do
expect(subject).to have_css('div.app-holder') expect(subject).to have_css('div.app-holder')
expect(subject).to have_css('div.columns-area__panels__main') expect(subject).to have_css('main.columns-area__panels__main')
end end
end end

View File

@ -2964,7 +2964,7 @@ __metadata:
axios: "npm:^1.4.0" axios: "npm:^1.4.0"
babel-plugin-transform-react-remove-prop-types: "npm:^0.4.24" babel-plugin-transform-react-remove-prop-types: "npm:^0.4.24"
blurhash: "npm:^2.0.5" blurhash: "npm:^2.0.5"
chromatic: "npm:^16.0.0" chromatic: "npm:^17.0.0"
classnames: "npm:^2.3.2" classnames: "npm:^2.3.2"
cocoon-js-vanilla: "npm:^1.5.1" cocoon-js-vanilla: "npm:^1.5.1"
color-blend: "npm:^4.0.0" color-blend: "npm:^4.0.0"
@ -6830,22 +6830,27 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"chromatic@npm:^16.0.0": "chromatic@npm:^17.0.0":
version: 16.0.0 version: 17.0.0
resolution: "chromatic@npm:16.0.0" resolution: "chromatic@npm:17.0.0"
dependencies:
semver: "npm:^7.3.5"
peerDependencies: peerDependencies:
"@chromatic-com/cypress": ^0.*.* || ^1.0.0 "@chromatic-com/cypress": ^0.*.* || ^1.0.0
"@chromatic-com/playwright": ^0.*.* || ^1.0.0 "@chromatic-com/playwright": ^0.*.* || ^1.0.0
"@chromatic-com/vitest": ^0.*.* || ^1.0.0
peerDependenciesMeta: peerDependenciesMeta:
"@chromatic-com/cypress": "@chromatic-com/cypress":
optional: true optional: true
"@chromatic-com/playwright": "@chromatic-com/playwright":
optional: true optional: true
"@chromatic-com/vitest":
optional: true
bin: bin:
chroma: dist/bin.js chroma: dist/bin.cjs
chromatic: dist/bin.js chromatic: dist/bin.cjs
chromatic-cli: dist/bin.js chromatic-cli: dist/bin.cjs
checksum: 10c0/ebebbf1c7d57e1ee9863997416c5125aab0a1886dce60fcb0358d34a51e0e1a45edc4635c8f8fb56d9facbcf21cd48014320c550f723b4791da51dde8552ee2b checksum: 10c0/962c86feec17b12757fa3327b7e98abff272a048c03227bb21ecb51c7f1d7ec3589386611ece8e2c413ac4049f26d71e9c9226a5fb7628fdcbb47e87905863a0
languageName: node languageName: node
linkType: hard linkType: hard