1
0
Fork 0
mirror of https://github.com/mastodon/mastodon.git synced 2024-08-20 21:08:15 -07:00

Compare commits

...

5 commits

Author SHA1 Message Date
Tiago Peralta
7cc11f420e
Merge 2166f44079 into a50c8e951f 2024-07-31 14:07:09 +00:00
Claire
a50c8e951f
Fix issue with grouped notifications UI due to recent API change (#31224) 2024-07-31 13:23:08 +00:00
Claire
2c1e75727d
Change filtered notification banner design to take up less space (#31222) 2024-07-31 12:36:08 +00:00
Alexandre Umbelino
2166f44079 Error cleaning 2024-06-28 20:16:32 +01:00
Alexandre Umbelino
77ec956d92 Display followed hashtags on the home header
Display the followed hashtags on the home header, allowing for easier
access to them. Listed from most recent to oldest, and with 2 dedicated
buttons.

Co-authored-by: Tiago Peralta <tiagofilipeperalta@tecnico.ulisboa.pt>
2024-06-28 15:59:31 +01:00
11 changed files with 595 additions and 41 deletions

View file

@ -60,7 +60,7 @@ export interface BaseNotificationGroupJSON {
interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
type: NotificationWithStatusType; type: NotificationWithStatusType;
status: ApiStatusJSON; status_id: string;
} }
interface NotificationWithStatusJSON extends BaseNotificationJSON { interface NotificationWithStatusJSON extends BaseNotificationJSON {

View file

@ -0,0 +1,258 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ButtonScrollList /> handles a large number of children correctly 1`] = `
<div
className="button-scroll-list-container"
>
<button
aria-label="Scroll left"
className="icon-button column-header__setting-btn"
onClick={[Function]}
>
<div>
MockIcon
</div>
</button>
<div
className="button-scroll-list"
>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
</div>
<button
aria-label="Scroll right"
className="icon-button column-header__setting-btn"
onClick={[Function]}
>
<div>
MockIcon
</div>
</button>
</div>
`;
exports[`<ButtonScrollList /> handles a single child correctly 1`] = `
<div
className="button-scroll-list-container"
>
<button
aria-label="Scroll left"
className="icon-button column-header__setting-btn"
onClick={[Function]}
>
<div>
MockIcon
</div>
</button>
<div
className="button-scroll-list"
>
<div>
<div />
</div>
</div>
<button
aria-label="Scroll right"
className="icon-button column-header__setting-btn"
onClick={[Function]}
>
<div>
MockIcon
</div>
</button>
</div>
`;
exports[`<ButtonScrollList /> renders an empty button scroll list element 1`] = `null`;
exports[`<ButtonScrollList /> renders the children 1`] = `
<div
className="button-scroll-list-container"
>
<button
aria-label="Scroll left"
className="icon-button column-header__setting-btn"
onClick={[Function]}
>
<div>
MockIcon
</div>
</button>
<div
className="button-scroll-list"
>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
<div>
<div />
</div>
</div>
<button
aria-label="Scroll right"
className="icon-button column-header__setting-btn"
onClick={[Function]}
>
<div>
MockIcon
</div>
</button>
</div>
`;

View file

@ -0,0 +1,119 @@
import renderer from 'react-test-renderer';
import { render, screen } from 'mastodon/test_helpers';
import ButtonScrollList from '../button_scroll_list';
jest.mock('mastodon/components/icon', () => {
return {
Icon: () => <div>MockIcon</div>,
};
});
describe('<ButtonScrollList />', () => {
it('renders an empty button scroll list element', () => {
const children = [];
const component = renderer.create(
<ButtonScrollList>{children}</ButtonScrollList>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('renders the children', () => {
const children = Array.from({ length: 5 }, (_, i) => (
<div key={i} ref={jest.fn()} />
));
const component = renderer.create(
<ButtonScrollList>{children}</ButtonScrollList>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('scrolls left', () => {
const children = Array.from({ length: 5 }, (_, i) => (
<div key={i} ref={jest.fn()} />
));
const component = renderer.create(
<ButtonScrollList>{children}</ButtonScrollList>,
);
const instance = component.getInstance();
instance.scrollLeft();
});
it('scrolls right', () => {
const children = Array.from({ length: 5 }, (_, i) => (
<div key={i} ref={jest.fn()} />
));
const component = renderer.create(
<ButtonScrollList>{children}</ButtonScrollList>,
);
const instance = component.getInstance();
instance.scrollRight();
});
it('scrolls left and right correctly', () => {
const children = Array.from({ length: 10 }, (_, i) => (
<div key={i}>{i}</div>
));
const component = renderer.create(
<ButtonScrollList>{children}</ButtonScrollList>,
);
setTimeout(() => {
let instance = component.getInstance();
instance.scrollRight();
expect(instance.slide).toBe(1);
}, 1000);
setTimeout(() => {
let instance = component.getInstance();
instance.scrollLeft();
expect(instance.slide).toBe(0);
}, 2000);
});
it('handles a single child correctly', () => {
const children = [<div key={0} ref={jest.fn()} />];
const component = renderer.create(
<ButtonScrollList>{children}</ButtonScrollList>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('handles a large number of children correctly', () => {
const children = Array.from({ length: 50 }, (_, i) => (
<div key={i} ref={jest.fn()} />
));
const component = renderer.create(
<ButtonScrollList>{children}</ButtonScrollList>,
);
const tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
it('checks if scroll buttons are accessible', () => {
const children = Array.from({ length: 5 }, (_, i) => (
<div key={i} ref={jest.fn()} />
));
render(<ButtonScrollList>{children}</ButtonScrollList>);
const leftButton = screen.getByRole('button', { name: /scroll left/i });
const rightButton = screen.getByRole('button', { name: /scroll right/i });
expect(leftButton).toBeTruthy();
expect(rightButton).toBeTruthy();
leftButton.focus();
expect(document.activeElement).toBe(leftButton);
rightButton.focus();
expect(document.activeElement).toBe(rightButton);
});
});

View file

@ -0,0 +1,86 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import { Icon } from 'mastodon/components/icon';
class ButtonScrollList extends Component {
static propTypes = {
children: PropTypes.node.isRequired,
};
constructor(props) {
super(props);
this.scrollRef = React.createRef();
this.slide = 0;
this.childrenLength = React.Children.count(props.children);
}
componentDidMount() {
setTimeout(() => {
if (this.scrollRef && this.scrollRef.current) {
const container = this.scrollRef.current;
container.scrollTo({ left: 0, behavior: 'auto' });
this.slide = 0;
}
}, 500);
}
scrollLeft = () => {
if (this.scrollRef && this.scrollRef.current) {
this.scrollRef.current.scrollBy({ left: -200, behavior: 'smooth' });
this.slide = Math.max(0, this.slide - 1);
}
};
scrollRight = () => {
if (this.scrollRef && this.scrollRef.current) {
const { children } = this.props;
const container = this.scrollRef.current;
const maxScrollLeft = container.scrollWidth - container.clientWidth;
if (container.scrollLeft < maxScrollLeft) {
container.scrollBy({ left: 200, behavior: 'smooth' });
this.slide = Math.min(
React.Children.count(children) - 1,
this.slide + 1,
);
}
}
};
render() {
const { children } = this.props;
if (React.Children.count(children) === 0) {
return null;
}
return (
<div className='button-scroll-list-container'>
<button
className='icon-button column-header__setting-btn'
aria-label='Scroll left'
onClick={this.scrollLeft}
>
<Icon id='chevron-left' icon={ChevronLeftIcon} />
</button>
<div className='button-scroll-list' ref={this.scrollRef}>
{React.Children.map(children, (child, index) => (
<div key={index}>{child}</div>
))}
</div>
<button
className='icon-button column-header__setting-btn'
aria-label='Scroll right'
onClick={this.scrollRight}
>
<Icon id='chevron-right' icon={ChevronRightIcon} />
</button>
</div>
);
}
}
export default ButtonScrollList;

View file

@ -10,6 +10,7 @@ import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'; import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react'; import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react'; import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import FollowedTagsList from 'mastodon/components/followed_tags_list';
import type { IconProp } from 'mastodon/components/icon'; import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context'; import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
@ -255,7 +256,11 @@ export const ColumnHeader: React.FC<Props> = ({
<> <>
{backButton} {backButton}
<button onClick={handleTitleClick} className='column-header__title'> <button
onClick={handleTitleClick}
className='column-header__title'
style={{ overflow: 'visible', paddingRight: '15px' }}
>
{!backButton && ( {!backButton && (
<Icon <Icon
id={icon} id={icon}
@ -268,6 +273,8 @@ export const ColumnHeader: React.FC<Props> = ({
</> </>
)} )}
{icon === 'home' ? <FollowedTagsList /> : null}
{!hasTitle && backButton} {!hasTitle && backButton}
<div className='column-header__buttons'> <div className='column-header__buttons'>

View file

@ -0,0 +1,67 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import debounce from 'lodash/debounce';
import {
expandFollowedHashtags,
fetchFollowedHashtags,
} from 'mastodon/actions/tags';
import ButtonScrollList from 'mastodon/components/button_scroll_list';
import { Hashtag } from 'mastodon/components/hashtag';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
const mapStateToProps = (state) => ({
hashtags: state.getIn(['followed_tags', 'items']),
isLoading: state.getIn(['followed_tags', 'isLoading'], true),
hasMore: !!state.getIn(['followed_tags', 'next']),
});
class FollowedTagsList extends PureComponent {
static propTypes = {
hashtags: ImmutablePropTypes.list.isRequired,
isLoading: PropTypes.bool.isRequired,
hasMore: PropTypes.bool.isRequired,
...WithRouterPropTypes,
};
componentDidMount() {
this.props.dispatch(fetchFollowedHashtags());
}
handleLoadMore = debounce(
() => {
this.props.dispatch(expandFollowedHashtags());
},
300,
{ leading: true },
);
render() {
const { hashtags } = this.props;
return (
<div className='followed-tags-list'>
<ButtonScrollList>
{hashtags.map((hashtag) => (
<div className='hashtag-wrapper' key={hashtag.get('name')}>
<Hashtag
name={hashtag.get('name')}
showSkeleton={false}
to={`/tags/${hashtag.get('name')}`}
withGraph={false}
/>
</div>
))}
</ButtonScrollList>
</div>
);
}
}
export default connect(mapStateToProps)(injectIntl(FollowedTagsList));

View file

@ -85,6 +85,7 @@ export interface HashtagProps {
description?: React.ReactNode; description?: React.ReactNode;
history?: number[]; history?: number[];
name: string; name: string;
showSkeleton?: boolean;
people: number; people: number;
to: string; to: string;
uses?: number; uses?: number;
@ -93,6 +94,7 @@ export interface HashtagProps {
export const Hashtag: React.FC<HashtagProps> = ({ export const Hashtag: React.FC<HashtagProps> = ({
name, name,
showSkeleton = true,
to, to,
people, people,
uses, uses,
@ -113,13 +115,15 @@ export const Hashtag: React.FC<HashtagProps> = ({
)} )}
</Link> </Link>
{description ? ( {showSkeleton ? (
description ? (
<span>{description}</span> <span>{description}</span>
) : typeof people !== 'undefined' ? ( ) : typeof people !== 'undefined' ? (
<ShortNumber value={people} renderer={accountsCountRenderer} /> <ShortNumber value={people} renderer={accountsCountRenderer} />
) : ( ) : (
<Skeleton width={100} /> <Skeleton width={100} />
)} )
) : null}
</div> </div>
{typeof uses !== 'undefined' && ( {typeof uses !== 'undefined' && (

View file

@ -49,22 +49,15 @@ export const FilteredNotificationsBanner: React.FC = () => {
<span> <span>
<FormattedMessage <FormattedMessage
id='filtered_notifications_banner.pending_requests' id='filtered_notifications_banner.pending_requests'
defaultMessage='Notifications from {count, plural, =0 {no one} one {one person} other {# people}} you may know' defaultMessage='From {count, plural, =0 {no one} one {one person} other {# people}} you may know'
values={{ count: policy.summary.pending_requests_count }} values={{ count: policy.summary.pending_requests_count }}
/> />
</span> </span>
</div> </div>
<div className='filtered-notifications-banner__badge'> <div className='filtered-notifications-banner__badge'>
<div className='filtered-notifications-banner__badge__badge'>
{toCappedNumber(policy.summary.pending_notifications_count)} {toCappedNumber(policy.summary.pending_notifications_count)}
</div> </div>
<FormattedMessage
id='filtered_notifications_banner.mentions'
defaultMessage='{count, plural, one {mention} other {mentions}}'
values={{ count: policy.summary.pending_notifications_count }}
/>
</div>
</Link> </Link>
); );
}; };

View file

@ -300,8 +300,7 @@
"filter_modal.select_filter.subtitle": "Use an existing category or create a new one", "filter_modal.select_filter.subtitle": "Use an existing category or create a new one",
"filter_modal.select_filter.title": "Filter this post", "filter_modal.select_filter.title": "Filter this post",
"filter_modal.title.status": "Filter a post", "filter_modal.title.status": "Filter a post",
"filtered_notifications_banner.mentions": "{count, plural, one {mention} other {mentions}}", "filtered_notifications_banner.pending_requests": "From {count, plural, =0 {no one} one {one person} other {# people}} you may know",
"filtered_notifications_banner.pending_requests": "Notifications from {count, plural, =0 {no one} one {one person} other {# people}} you may know",
"filtered_notifications_banner.title": "Filtered notifications", "filtered_notifications_banner.title": "Filtered notifications",
"firehose.all": "All", "firehose.all": "All",
"firehose.local": "This server", "firehose.local": "This server",

View file

@ -124,9 +124,9 @@ export function createNotificationGroupFromJSON(
case 'mention': case 'mention':
case 'poll': case 'poll':
case 'update': { case 'update': {
const { status, ...groupWithoutStatus } = group; const { status_id: statusId, ...groupWithoutStatus } = group;
return { return {
statusId: status.id, statusId,
sampleAccountIds, sampleAccountIds,
...groupWithoutStatus, ...groupWithoutStatus,
}; };

View file

@ -3561,6 +3561,42 @@ $ui-header-logo-wordmark-width: 99px;
} }
} }
.button-scroll-list-container {
display: flex;
align-items: center;
max-width: 100%;
overflow: hidden;
}
.icon-button {
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.button-scroll-list {
display: flex;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
flex-grow: 1;
white-space: nowrap;
}
.button-scroll-list::-webkit-scrollbar {
display: none;
}
.followed-tags-list {
overflow: hidden;
flex: 0 1 auto;
}
.hashtag-wrapper {
border-bottom: none;
}
.column-back-button { .column-back-button {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
@ -10170,20 +10206,6 @@ noscript {
} }
} }
&__badge {
display: flex;
align-items: center;
border-radius: 999px;
background: var(--background-border-color);
color: $darker-text-color;
padding: 4px;
padding-inline-end: 8px;
gap: 6px;
font-weight: 500;
font-size: 11px;
line-height: 16px;
word-break: keep-all;
&__badge { &__badge {
background: $ui-button-background-color; background: $ui-button-background-color;
color: $white; color: $white;
@ -10191,7 +10213,6 @@ noscript {
padding: 2px 8px; padding: 2px 8px;
} }
} }
}
.notification-request { .notification-request {
display: flex; display: flex;