Skip to content

Commit

Permalink
Merge remote-tracking branch 'TheEssem/feature/bubble-timeline' into …
Browse files Browse the repository at this point in the history
…merge/TheEssem/20241202_2

 Conflicts:
	app/services/fan_out_on_write_service.rb
  • Loading branch information
neatchee committed Dec 3, 2024
2 parents 2a1e99b + 4fcabc3 commit 5487ea2
Show file tree
Hide file tree
Showing 34 changed files with 634 additions and 30 deletions.
49 changes: 49 additions & 0 deletions app/controllers/admin/bubble_domains_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# frozen_string_literal: true

module Admin
class BubbleDomainsController < BaseController
before_action :set_bubble_domain, except: [:index, :new, :create]

def index
authorize :bubble_domain, :update?
@bubble_domains = BubbleDomain.all
end

def new
authorize :bubble_domain, :update?
@bubble_domain = BubbleDomain.new(domain: params[:_domain])
end

def create
authorize :bubble_domain, :update?

domain = TagManager.instance.normalize_domain(resource_params[:domain])

@bubble_domain = BubbleDomain.new(domain: domain)

if @bubble_domain.save!
log_action :create, @bubble_domain
redirect_to admin_bubble_domains_path, notice: I18n.t('admin.bubble_domains.created_msg')
else
render :new
end
end

def destroy
authorize :bubble_domain, :update?
@bubble_domain.destroy
log_action :destroy, @bubble_domain
redirect_to admin_bubble_domains_path
end

private

def set_bubble_domain
@bubble_domain = BubbleDomain.find(params[:id])
end

def resource_params
params.require(:bubble_domain).permit(:domain)
end
end
end
3 changes: 2 additions & 1 deletion app/controllers/api/v1/timelines/public_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }

PERMITTED_PARAMS = %i(local remote limit only_media allow_local_only).freeze
PERMITTED_PARAMS = %i(local remote bubble limit only_media allow_local_only).freeze

def show
cache_if_unauthenticated!
Expand Down Expand Up @@ -34,6 +34,7 @@ def public_feed
PublicFeed.new(
current_account,
local: truthy_param?(:local),
bubble: truthy_param?(:bubble),
remote: truthy_param?(:remote),
only_media: truthy_param?(:only_media),
allow_local_only: truthy_param?(:allow_local_only),
Expand Down
9 changes: 9 additions & 0 deletions app/javascript/flavours/glitch/actions/streaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
fillPublicTimelineGaps,
fillCommunityTimelineGaps,
fillListTimelineGaps,
fillBubbleTimelineGaps,
} from './timelines';

/**
Expand Down Expand Up @@ -164,6 +165,14 @@ export const connectUserStream = () =>
export const connectCommunityStream = ({ onlyMedia } = {}) =>
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillCommunityTimelineGaps({ onlyMedia })) });

/**
* @param {Object} options
* @param {boolean} [options.onlyMedia]
* @returns {function(): void}
*/
export const connectBubbleStream = ({ onlyMedia } = {}) =>
connectTimelineStream(`bubble${onlyMedia ? ':media' : ''}`, `public:bubble${onlyMedia ? ':media' : ''}`, {}, { fillGaps: () => (fillBubbleTimelineGaps({ onlyMedia })) });

/**
* @param {Object} options
* @param {boolean} [options.onlyMedia]
Expand Down
2 changes: 2 additions & 0 deletions app/javascript/flavours/glitch/actions/timelines.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ export function fillTimelineGaps(timelineId, path, params = {}) {
export const expandHomeTimeline = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId });
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote, allowLocalOnly } = {}) => 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 });
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia });
export const expandBubbleTimeline = ({ maxId, onlyMedia } = {}) => expandTimeline(`bubble${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { bubble: true, max_id: maxId, only_media: !!onlyMedia });
export const expandDirectTimeline = ({ maxId } = {}) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId });
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 });
Expand All @@ -178,6 +179,7 @@ export const expandHashtagTimeline = (hashtag, { maxId, tags, local } =
export const fillHomeTimelineGaps = () => fillTimelineGaps('home', '/api/v1/timelines/home', {});
export const fillPublicTimelineGaps = ({ onlyMedia, onlyRemote, allowLocalOnly } = {}) => fillTimelineGaps(`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, only_media: !!onlyMedia, allow_local_only: !!allowLocalOnly });
export const fillCommunityTimelineGaps = ({ onlyMedia } = {}) => fillTimelineGaps(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, only_media: !!onlyMedia });
export const fillBubbleTimelineGaps = ({ onlyMedia } = {}) => fillTimelineGaps(`bubble${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { bubble: true, only_media: !!onlyMedia });
export const fillListTimelineGaps = (id) => fillTimelineGaps(`list:${id}`, `/api/v1/timelines/list/${id}`, {});

export function expandTimelineRequest(timeline, isLoadingMore) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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'>
<section>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='bubble.column_settings.media_only' defaultMessage='Media only' />} />
</div>
</section>

<section>
<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>
</section>
</div>
);
}

}

export default injectIntl(ColumnSettings);
Original file line number Diff line number Diff line change
@@ -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);
165 changes: 165 additions & 0 deletions app/javascript/flavours/glitch/features/bubble_timeline/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
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 onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'bubble', 'other', 'onlyMedia']);
const regex = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'regex', 'body']) : state.getIn(['settings', 'bubble', 'regex', 'body']);
const timelineState = state.getIn(['timelines', `bubble${onlyMedia ? ':media' : ''}`]);

return {
hasUnread: !!timelineState && timelineState.get('unread') > 0,
onlyMedia,
regex,
};
};

class BubbleTimeline extends PureComponent {
static defaultProps = {
onlyMedia: false,
};

static propTypes = {
identity: identityContextPropShape,
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
onlyMedia: PropTypes.bool,
regex: PropTypes.string,
};

handlePin = () => {
const { columnId, dispatch, onlyMedia } = this.props;

if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BUBBLE', { other: { onlyMedia } }));
}
};

handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};

handleHeaderClick = () => {
this.column.scrollTop();
};

componentDidMount () {
const { dispatch, onlyMedia } = this.props;
const { signedIn } = this.props.identity;

dispatch(expandBubbleTimeline({ onlyMedia }));

if (signedIn) {
this.disconnect = dispatch(connectBubbleStream({ onlyMedia }));
}
}

componentDidUpdate (prevProps) {
const { signedIn } = this.props.identity;

if (prevProps.onlyMedia !== this.props.onlyMedia) {
const { dispatch, onlyMedia } = this.props;

if (this.disconnect) {
this.disconnect();
}

dispatch(expandBubbleTimeline({ onlyMedia }));

if (signedIn) {
this.disconnect = dispatch(connectBubbleStream({ onlyMedia }));
}
}
}

componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}

setRef = c => {
this.column = c;
};

handleLoadMore = maxId => {
const { dispatch, onlyMedia } = this.props;

dispatch(expandBubbleTimeline({ maxId, onlyMedia }));
};

render () {
const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
const pinned = !!columnId;

return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='bubble'
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 the most recent public posts from people on the fediverse whose accounts are on other servers selected by {domain}.' values={{ domain }} /></DismissableBanner>}
trackScroll={!pinned}
scrollKey={`bubble_timeline-${columnId}`}
timelineId={`bubble${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.bubble' defaultMessage='The bubble timeline is currently empty, but something might show up here soon!' />}
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)));
Loading

0 comments on commit 5487ea2

Please sign in to comment.