bubble timeline feature

This commit is contained in:
ihateblueb 2025-05-28 17:46:05 -04:00
commit b5038da7dd
12 changed files with 277 additions and 9 deletions

View file

@ -22,7 +22,7 @@ import {
fillHomeTimelineGaps,
fillPublicTimelineGaps,
fillCommunityTimelineGaps,
fillListTimelineGaps,
fillListTimelineGaps, fillBubbleTimelineGaps
} from './timelines';
/**
@ -157,6 +157,12 @@ export const connectUserStream = () =>
export const connectCommunityStream = ({ onlyMedia } = {}) =>
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) });
/**
* @returns {function(): void}
*/
export const connectBubbleStream = () =>
connectTimelineStream(`bubble`, `public:bubble`, {}, { fillGaps: () => (fillBubbleTimelineGaps()) });
/**
* @param {Object} options
* @param {boolean} [options.onlyMedia]

View file

@ -165,6 +165,7 @@ export function fillTimelineGaps(timelineId, path, params = {}, done = noOp) {
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, allow_local_only: !!allowLocalOnly, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandBubbleTimeline = ({ maxId } = {}, done = noOp) => expandTimeline(`bubble`, '/api/v1/timelines/bubble', { local: true, max_id: maxId }, done);
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies, tagged } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, exclude_reblogs: withReplies, tagged, max_id: maxId });
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
@ -184,6 +185,7 @@ export const expandHashtagTimeline = (hashtag, { maxId, tags, local } =
export const fillHomeTimelineGaps = (done = noOp) => fillTimelineGaps('home', '/api/v1/timelines/home', {}, done);
export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}, done = noOp) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia, allow_local_only: !!allowLocalOnly }, done);
export const fillCommunityTimelineGaps = ({ onlyMedia } = {}, done = noOp) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia }, done);
export const fillBubbleTimelineGaps = ({} = {}, done = noOp) => fillTimelineGaps(`bubble`, '/api/v1/timelines/bubble', { local: true }, done);
export const fillListTimelineGaps = (id, done = noOp) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {}, done);
export function expandTimelineRequest(timeline, isLoadingMore) {

View file

@ -0,0 +1,41 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
import SettingText from 'flavours/glitch/components/setting_text';
import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle';
const messages = defineMessages({
filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
settings: { id: 'home.settings', defaultMessage: 'Column settings' },
});
class ColumnSettings extends PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
};
render () {
const { settings, onChange, intl } = this.props;
return (
<div className='column-settings'>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
<div className='column-settings__row'>
<SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div>
</div>
);
}
}
export default injectIntl(ColumnSettings);

View file

@ -0,0 +1,29 @@
import { connect } from 'react-redux';
import { changeColumnParams } from '../../../actions/columns';
import { changeSetting } from '../../../actions/settings';
import ColumnSettings from '../components/column_settings';
const mapStateToProps = (state, { columnId }) => {
const uuid = columnId;
const columns = state.getIn(['settings', 'columns']);
const index = columns.findIndex(c => c.get('uuid') === uuid);
return {
settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'bubble']),
};
};
const mapDispatchToProps = (dispatch, { columnId }) => {
return {
onChange (key, checked) {
if (columnId) {
dispatch(changeColumnParams(columnId, key, checked));
} else {
dispatch(changeSetting(['bubble', ...key], checked));
}
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View file

@ -0,0 +1,160 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import { connect } from 'react-redux';
import BubbleChartIcon from '@/material-icons/400-24px/bubble-chart.svg?react';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
import { domain } from 'flavours/glitch/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { connectBubbleStream } from '../../actions/streaming';
import { expandBubbleTimeline } from '../../actions/timelines';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import StatusListContainer from '../ui/containers/status_list_container';
import ColumnSettingsContainer from './containers/column_settings_container';
const messages = defineMessages({
title: { id: 'column.bubble', defaultMessage: 'Bubble timeline' },
});
const mapStateToProps = (state, { columnId }) => {
const uuid = columnId;
const columns = state.getIn(['settings', 'columns']);
const index = columns.findIndex(c => c.get('uuid') === uuid);
const regex = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'regex', 'body']) : state.getIn(['settings', 'bubble', 'regex', 'body']);
const timelineState = state.getIn(['timelines', `bubble`]);
return {
hasUnread: !!timelineState && timelineState.get('unread') > 0,
regex,
};
};
class BubbleTimeline extends PureComponent {
static defaultProps = {};
static propTypes = {
identity: identityContextPropShape,
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
regex: PropTypes.string,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BUBBLE'));
}
};
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
componentDidMount () {
const { dispatch } = this.props;
const { signedIn } = this.props.identity;
dispatch(expandBubbleTimeline());
if (signedIn) {
this.disconnect = dispatch(connectBubbleStream());
}
}
componentDidUpdate (prevProps) {
const { signedIn } = this.props.identity;
if (prevProps !== this.props) {
const { dispatch } = this.props;
if (this.disconnect) {
this.disconnect();
}
dispatch(expandBubbleTimeline());
if (signedIn) {
this.disconnect = dispatch(connectBubbleStream());
}
}
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => {
this.column = c;
};
handleLoadMore = maxId => {
const { dispatch } = this.props;
dispatch(expandBubbleTimeline({ maxId }));
};
render () {
const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='users'
iconComponent={BubbleChartIcon}
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
>
<ColumnSettingsContainer columnId={columnId} />
</ColumnHeader>
<StatusListContainer
prepend={<DismissableBanner id='bubble_timeline'><FormattedMessage id='dismissable_banner.bubble_timeline' defaultMessage='These are posts from your local instance and others chosen by the administrators of {domain}.' values={{ domain }} /></DismissableBanner>}
trackScroll={!pinned}
scrollKey={`bubble_timeline-${columnId}`}
timelineId={`bubble`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.bubble' defaultMessage='The bubble timeline is empty. Write something publicly to get the ball rolling!' />}
bindToDocument={!multiColumn}
regex={this.props.regex}
/>
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default withIdentity(connect(mapStateToProps)(injectIntl(BubbleTimeline)));

View file

@ -10,8 +10,8 @@ import { useIdentity } from '@/flavours/glitch/identity_context';
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
import { addColumn } from 'flavours/glitch/actions/columns';
import { changeSetting } from 'flavours/glitch/actions/settings';
import { connectPublicStream, connectCommunityStream } from 'flavours/glitch/actions/streaming';
import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
import { connectPublicStream, connectCommunityStream, connectBubbleStream } from 'flavours/glitch/actions/streaming';
import { expandPublicTimeline, expandCommunityTimeline, expandBubbleTimeline } from 'flavours/glitch/actions/timelines';
import { DismissableBanner } from 'flavours/glitch/components/dismissable_banner';
import SettingText from 'flavours/glitch/components/setting_text';
import { domain } from 'flavours/glitch/initial_state';
@ -90,6 +90,9 @@ const Firehose = ({ feedType, multiColumn }) => {
case 'community':
dispatch(addColumn('COMMUNITY', { other: { onlyMedia }, regex: { body: regex } }));
break;
case 'bubble':
dispatch(addColumn('BUBBLE', { other: { onlyMedia }, regex: { body: regex } }));
break;
case 'public':
dispatch(addColumn('PUBLIC', { other: { onlyMedia, allowLocalOnly }, regex: { body: regex } }));
break;
@ -107,6 +110,9 @@ const Firehose = ({ feedType, multiColumn }) => {
case 'community':
dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
break;
case 'bubble':
dispatch(expandBubbleTimeline({ maxId, onlyMedia }));
break;
case 'public':
dispatch(expandPublicTimeline({ maxId, onlyMedia, allowLocalOnly }));
break;
@ -130,6 +136,12 @@ const Firehose = ({ feedType, multiColumn }) => {
disconnect = dispatch(connectCommunityStream({ onlyMedia }));
}
break;
case 'bubble':
dispatch(expandBubbleTimeline());
if (signedIn) {
disconnect = dispatch(connectBubbleStream());
}
break;
case 'public':
dispatch(expandPublicTimeline({ onlyMedia, allowLocalOnly }));
if (signedIn) {
@ -147,6 +159,7 @@ const Firehose = ({ feedType, multiColumn }) => {
return () => disconnect?.();
}, [dispatch, signedIn, feedType, onlyMedia, allowLocalOnly]);
// todo bubble
const prependBanner = feedType === 'community' ? (
<DismissableBanner id='community_timeline'>
<FormattedMessage
@ -165,7 +178,7 @@ const Firehose = ({ feedType, multiColumn }) => {
</DismissableBanner>
);
const emptyMessage = feedType === 'community' ? (
const emptyMessage = feedType === 'community' || feedType === 'bubble' ? (
<FormattedMessage
id='empty_column.community'
defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!'
@ -196,6 +209,10 @@ const Firehose = ({ feedType, multiColumn }) => {
<FormattedMessage tagName='div' id='firehose.local' defaultMessage='This server' />
</NavLink>
<NavLink exact to='/public/bubble'>
<FormattedMessage tagName='div' id='firehose.bubble' defaultMessage='Bubble' />
</NavLink>
<NavLink exact to='/public/remote'>
<FormattedMessage tagName='div' id='firehose.remote' defaultMessage='Other servers' />
</NavLink>

View file

@ -11,6 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import BubbleChartIcon from '@/material-icons/400-24px/bubble-chart.svg?react';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import HomeIcon from '@/material-icons/400-24px/home-fill.svg?react';
@ -47,6 +48,7 @@ const messages = defineMessages({
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
bubble_timeline: { id: 'navigation_bar.bubble_timeline', defaultMessage: 'Bubble timeline' },
explore: { id: 'navigation_bar.explore', defaultMessage: 'Explore' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Private mentions' },
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
@ -149,6 +151,10 @@ class GettingStarted extends ImmutablePureComponent {
navItems.push(<ColumnLink key='community_timeline' icon='users' iconComponent={PeopleIcon} text={intl.formatMessage(messages.community_timeline)} to='/public/local' />);
}
if (!columns.find(item => item.get('id') === 'BUBBLE')) {
navItems.push(<ColumnLink key='bubble_timeline' icon='bubble-chart' iconComponent={BubbleChartIcon} text={intl.formatMessage(messages.bubble_timeline)} to='/public/bubble' />);
}
if (!columns.find(item => item.get('id') === 'PUBLIC')) {
navItems.push(<ColumnLink key='public_timeline' icon='globe' iconComponent={PublicIcon} text={intl.formatMessage(messages.public_timeline)} to='/public' />);
}

View file

@ -210,6 +210,7 @@ class SwitchingColumnsArea extends PureComponent {
<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/bubble' exact component={Firehose} componentParams={{ feedType: 'bubble' }} 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} />

View file

@ -30,6 +30,10 @@ export function CommunityTimeline () {
return import(/* webpackChunkName: "flavours/glitch/async/community_timeline" */'../../community_timeline');
}
export function BubbleTimeline () {
return import(/* webpackChunkName: "flavours/glitch/async/community_timeline" */'../../bubble_timeline');
}
export function Firehose () {
return import(/* webpackChunkName: "flavours/glitch/async/firehose" */'../../firehose');
}

View file

@ -9,12 +9,12 @@ const initialState = ImmutableMap({
stretch : true,
side_arm : 'none',
side_arm_reply_mode : 'keep',
show_reply_count : false,
always_show_spoilers_field: false,
show_reply_count : true,
always_show_spoilers_field: true,
confirm_missing_media_description: false,
confirm_boost_missing_media_description: false,
confirm_before_clearing_draft: true,
prepend_cw_re: false,
prepend_cw_re: true,
preselect_on_reply: false,
inline_preview_cards: true,
hicolor_privacy_icons: false,

View file

@ -97,10 +97,11 @@ a:focus-visible,
background: none;
}
.account__avatar,
.account__avatar img,
#profile_page_avatar,
.account__avatar-composite,
.account-card__title__avatar img {
border-radius: 30%;
border-radius: 25%;
flex: none;
}
:not(body):not(.scrollable)::-webkit-scrollbar {
@ -3258,4 +3259,4 @@ a:focus-visible,
}
.emoji-picker-dropdown__modifiers {
top: 16px;
}
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3"><path d="M580-120q-50 0-85-35t-35-85q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T620-240q0-17-11.5-28.5T580-280q-17 0-28.5 11.5T540-240q0 17 11.5 28.5T580-200Zm80-200q-92 0-156-64t-64-156q0-92 64-156t156-64q92 0 156 64t64 156q0 92-64 156t-156 64Zm0-80q59 0 99.5-40.5T800-620q0-59-40.5-99.5T660-760q-59 0-99.5 40.5T520-620q0 59 40.5 99.5T660-480ZM280-240q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47Zm0-80q33 0 56.5-23.5T360-400q0-33-23.5-56.5T280-480q-33 0-56.5 23.5T200-400q0 33 23.5 56.5T280-320Zm300 80Zm80-380ZM280-400Z"/></svg>

After

Width:  |  Height:  |  Size: 702 B