A app/javascript/mastodon/features/firehose/index.jsx => app/javascript/mastodon/features/firehose/index.jsx +210 -0
@@ 0,0 1,210 @@
+import PropTypes from 'prop-types';
+import { useRef, useCallback, useEffect } from 'react';
+
+import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
+
+import { Helmet } from 'react-helmet';
+import { NavLink } from 'react-router-dom';
+
+import { addColumn } from 'mastodon/actions/columns';
+import { changeSetting } from 'mastodon/actions/settings';
+import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming';
+import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines';
+import DismissableBanner from 'mastodon/components/dismissable_banner';
+import initialState, { domain } from 'mastodon/initial_state';
+import { useAppDispatch, useAppSelector } from 'mastodon/store';
+
+import Column from '../../components/column';
+import ColumnHeader from '../../components/column_header';
+import SettingToggle from '../notifications/components/setting_toggle';
+import StatusListContainer from '../ui/containers/status_list_container';
+
+const messages = defineMessages({
+ title: { id: 'column.firehose', defaultMessage: 'Live feeds' },
+});
+
+// TODO: use a proper React context later on
+const useIdentity = () => ({
+ signedIn: !!initialState.meta.me,
+ accountId: initialState.meta.me,
+ disabledAccountId: initialState.meta.disabled_account_id,
+ accessToken: initialState.meta.access_token,
+ permissions: initialState.role ? initialState.role.permissions : 0,
+});
+
+const ColumnSettings = () => {
+ const dispatch = useAppDispatch();
+ const settings = useAppSelector((state) => state.getIn(['settings', 'firehose']));
+ const onChange = useCallback(
+ (key, checked) => dispatch(changeSetting(['firehose', ...key], checked)),
+ [dispatch],
+ );
+
+ return (
+ <div>
+ <div className='column-settings__row'>
+ <SettingToggle
+ settings={settings}
+ settingPath={['onlyMedia']}
+ onChange={onChange}
+ label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />}
+ />
+ </div>
+ </div>
+ );
+};
+
+const Firehose = ({ feedType, multiColumn }) => {
+ const dispatch = useAppDispatch();
+ const intl = useIntl();
+ const { signedIn } = useIdentity();
+ const columnRef = useRef(null);
+
+ const onlyMedia = useAppSelector((state) => state.getIn(['settings', 'firehose', 'onlyMedia'], false));
+ const hasUnread = useAppSelector((state) => state.getIn(['timelines', `${feedType}${onlyMedia ? ':media' : ''}`, 'unread'], 0) > 0);
+
+ const handlePin = useCallback(
+ () => {
+ switch(feedType) {
+ case 'community':
+ dispatch(addColumn('COMMUNITY', { other: { onlyMedia } }));
+ break;
+ case 'public':
+ dispatch(addColumn('PUBLIC', { other: { onlyMedia } }));
+ break;
+ case 'public:remote':
+ dispatch(addColumn('REMOTE', { other: { onlyMedia, onlyRemote: true } }));
+ break;
+ }
+ },
+ [dispatch, onlyMedia, feedType],
+ );
+
+ const handleLoadMore = useCallback(
+ (maxId) => {
+ switch(feedType) {
+ case 'community':
+ dispatch(expandCommunityTimeline({ onlyMedia }));
+ break;
+ case 'public':
+ dispatch(expandPublicTimeline({ maxId, onlyMedia }));
+ break;
+ case 'public:remote':
+ dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote: true }));
+ break;
+ }
+ },
+ [dispatch, onlyMedia, feedType],
+ );
+
+ const handleHeaderClick = useCallback(() => columnRef.current?.scrollTop(), []);
+
+ useEffect(() => {
+ let disconnect;
+
+ switch(feedType) {
+ case 'community':
+ dispatch(expandCommunityTimeline({ onlyMedia }));
+ if (signedIn) {
+ disconnect = dispatch(connectCommunityStream({ onlyMedia }));
+ }
+ break;
+ case 'public':
+ dispatch(expandPublicTimeline({ onlyMedia }));
+ if (signedIn) {
+ disconnect = dispatch(connectPublicStream({ onlyMedia }));
+ }
+ break;
+ case 'public:remote':
+ dispatch(expandPublicTimeline({ onlyMedia, onlyRemote: true }));
+ if (signedIn) {
+ disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote: true }));
+ }
+ break;
+ }
+
+ return () => disconnect?.();
+ }, [dispatch, signedIn, feedType, onlyMedia]);
+
+ const prependBanner = feedType === 'community' ? (
+ <DismissableBanner id='community_timeline'>
+ <FormattedMessage
+ id='dismissable_banner.community_timeline'
+ defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.'
+ values={{ domain }}
+ />
+ </DismissableBanner>
+ ) : (
+ <DismissableBanner id='public_timeline'>
+ <FormattedMessage
+ id='dismissable_banner.public_timeline'
+ defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.'
+ />
+ </DismissableBanner>
+ );
+
+ const emptyMessage = feedType === 'community' ? (
+ <FormattedMessage
+ id='empty_column.community'
+ defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!'
+ />
+ ) : (
+ <FormattedMessage
+ id='empty_column.public'
+ defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up'
+ />
+ );
+
+ return (
+ <Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
+ <ColumnHeader
+ icon='globe'
+ active={hasUnread}
+ title={intl.formatMessage(messages.title)}
+ onPin={handlePin}
+ onClick={handleHeaderClick}
+ multiColumn={multiColumn}
+ >
+ <ColumnSettings />
+ </ColumnHeader>
+
+ <div className='scrollable scrollable--flex'>
+ <div className='account__section-headline'>
+ <NavLink exact to='/public/local'>
+ <FormattedMessage tagName='div' id='firehose.local' defaultMessage='Local' />
+ </NavLink>
+
+ <NavLink exact to='/public/remote'>
+ <FormattedMessage tagName='div' id='firehose.remote' defaultMessage='Remote' />
+ </NavLink>
+
+ <NavLink exact to='/public'>
+ <FormattedMessage tagName='div' id='firehose.all' defaultMessage='All' />
+ </NavLink>
+ </div>
+
+ <StatusListContainer
+ prepend={prependBanner}
+ timelineId={`${feedType}${onlyMedia ? ':media' : ''}`}
+ onLoadMore={handleLoadMore}
+ trackScroll
+ scrollKey='firehose'
+ emptyMessage={emptyMessage}
+ bindToDocument={!multiColumn}
+ />
+ </div>
+
+ <Helmet>
+ <title>{intl.formatMessage(messages.title)}</title>
+ <meta name='robots' content='noindex' />
+ </Helmet>
+ </Column>
+ );
+}
+
+Firehose.propTypes = {
+ multiColumn: PropTypes.bool,
+ feedType: PropTypes.string,
+};
+
+export default Firehose;
M app/javascript/mastodon/features/ui/components/navigation_panel.jsx => app/javascript/mastodon/features/ui/components/navigation_panel.jsx +6 -6
@@ 20,8 20,7 @@ const messages = defineMessages({
home: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
explore: { id: 'explore.title', defaultMessage: 'Explore' },
- local: { id: 'tabs_bar.local_timeline', defaultMessage: 'Local' },
- federated: { id: 'tabs_bar.federated_timeline', defaultMessage: 'Federated' },
+ firehose: { id: 'column.firehose', defaultMessage: 'Live feeds' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
@@ 43,6 42,10 @@ class NavigationPanel extends Component {
intl: PropTypes.object.isRequired,
};
+ isFirehoseActive = (match, location) => {
+ return match || location.pathname.startsWith('/public');
+ };
+
render () {
const { intl } = this.props;
const { signedIn, disabledAccountId } = this.context.identity;
@@ 69,10 72,7 @@ class NavigationPanel extends Component {
)}
{(signedIn || timelinePreview) && (
- <>
- <ColumnLink transparent to='/public/local' icon='users' text={intl.formatMessage(messages.local)} />
- <ColumnLink transparent exact to='/public' icon='globe' text={intl.formatMessage(messages.federated)} />
- </>
+ <ColumnLink transparent to='/public/local' isActive={this.isFirehoseActive} icon='globe' text={intl.formatMessage(messages.firehose)} />
)}
{!signedIn && (
M app/javascript/mastodon/features/ui/index.jsx => app/javascript/mastodon/features/ui/index.jsx +6 -4
@@ 36,8 36,7 @@ import {
Status,
GettingStarted,
KeyboardShortcuts,
- PublicTimeline,
- CommunityTimeline,
+ Firehose,
AccountTimeline,
AccountGallery,
HomeTimeline,
@@ 188,8 187,11 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/privacy-policy' component={PrivacyPolicy} content={children} />
<WrappedRoute path={['/home', '/timelines/home']} component={HomeTimeline} content={children} />
- <WrappedRoute path={['/public', '/timelines/public']} exact component={PublicTimeline} content={children} />
- <WrappedRoute path={['/public/local', '/timelines/public/local']} exact component={CommunityTimeline} content={children} />
+ <Redirect from='/timelines/public' to='/public' exact />
+ <Redirect from='/timelines/public/local' to='/public/local' exact />
+ <WrappedRoute path='/public' exact component={Firehose} componentParams={{ feedType: 'public' }} content={children} />
+ <WrappedRoute path='/public/local' exact component={Firehose} componentParams={{ feedType: 'community' }} content={children} />
+ <WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
M app/javascript/mastodon/features/ui/util/async-components.js => app/javascript/mastodon/features/ui/util/async-components.js +4 -0
@@ 22,6 22,10 @@ export function CommunityTimeline () {
return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline');
}
+export function Firehose () {
+ return import(/* webpackChunkName: "features/firehose" */'../../firehose');
+}
+
export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
}
M app/javascript/mastodon/locales/en.json => app/javascript/mastodon/locales/en.json +4 -2
@@ 114,6 114,7 @@
"column.directory": "Browse profiles",
"column.domain_blocks": "Blocked domains",
"column.favourites": "Favourites",
+ "column.firehose": "Live feeds",
"column.follow_requests": "Follow requests",
"column.home": "Home",
"column.lists": "Lists",
@@ 267,6 268,9 @@
"filter_modal.select_filter.subtitle": "Use an existing category or create a new one",
"filter_modal.select_filter.title": "Filter this post",
"filter_modal.title.status": "Filter a post",
+ "firehose.all": "All",
+ "firehose.local": "Local",
+ "firehose.remote": "Remote",
"follow_request.authorize": "Authorize",
"follow_request.reject": "Reject",
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
@@ 649,9 653,7 @@
"subscribed_languages.target": "Change subscribed languages for {target}",
"suggestions.dismiss": "Dismiss suggestion",
"suggestions.header": "You might be interested in…",
- "tabs_bar.federated_timeline": "Federated",
"tabs_bar.home": "Home",
- "tabs_bar.local_timeline": "Local",
"tabs_bar.notifications": "Notifications",
"time_remaining.days": "{number, plural, one {# day} other {# days}} left",
"time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
M app/javascript/mastodon/reducers/settings.js => app/javascript/mastodon/reducers/settings.js +4 -0
@@ 79,6 79,10 @@ const initialState = ImmutableMap({
}),
}),
+ firehose: ImmutableMap({
+ onlyMedia: false,
+ }),
+
community: ImmutableMap({
regex: ImmutableMap({
body: '',