Add media filter to bookmark, favorite, emoji_reaction and personal column

This commit is contained in:
noellabo 2023-02-28 07:50:43 +09:00
parent 45986dc4dc
commit fc11b20384
23 changed files with 484 additions and 79 deletions

View File

@ -28,20 +28,43 @@ class Api::V1::BookmarksController < Api::BaseController
end
def results
@_results ||= account_bookmarks.joins(:status).eager_load(:status).to_a_paginated_by_id(
@_results ||= filtered_account_bookmarks.to_a_paginated_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
end
def filtered_account_bookmarks
account_bookmarks.joins(:status).eager_load(:status).tap do |scope|
scope.merge!(media_only_scope) if media_only?
scope.merge!(without_media_scope) if without_media?
end
end
def account_bookmarks
current_account.bookmarks
end
def media_only?
truthy_param?(:only_media)
end
def without_media?
truthy_param?(:without_media)
end
def compact?
truthy_param?(:compact)
end
def media_only_scope
Status.joins(:media_attachments)
end
def without_media_scope
Status.left_joins(:media_attachments).where(media_attachments: {status_id: nil})
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
@ -67,6 +90,6 @@ class Api::V1::BookmarksController < Api::BaseController
end
def pagination_params(core_params)
params.slice(:limit, :compact).permit(:limi, :compact).merge(core_params)
params_slice(:limit, :compact, :only_media, :without_media).merge(core_params)
end
end

View File

@ -28,15 +28,17 @@ class Api::V1::EmojiReactionsController < Api::BaseController
end
def results
@_results ||= filtered_emoji_reactions.joins(:status).eager_load(:status).to_a_paginated_by_id(
@_results ||= filtered_emoji_reactions.to_a_paginated_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
end
def filtered_emoji_reactions
account_emoji_reactions.tap do |emoji_reactions|
emoji_reactions.merge!(emojis_scope) if emojis_requested?
account_emoji_reactions.joins(:status).eager_load(:status).tap do |emoji_reactions|
emoji_reactions.merge!(emojis_scope) if emojis_requested?
emoji_reactions.merge!(media_only_scope) if media_only?
emoji_reactions.merge!(without_media_scope) if without_media?
end
end
@ -44,10 +46,43 @@ class Api::V1::EmojiReactionsController < Api::BaseController
current_account.emoji_reactions
end
def emojis_requested?
emoji_reactions_params[:emojis].present?
end
def media_only?
truthy_param?(:only_media)
end
def without_media?
truthy_param?(:without_media)
end
def compact?
truthy_param?(:compact)
end
def emojis_scope
emoji_reactions = EmojiReaction.none
emoji_reactions_params[:emojis].each do |emoji|
shortcode, domain = emoji.split('@')
custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain)
emoji_reactions = emoji_reactions.or(EmojiReaction.where(name: shortcode, custom_emoji: custom_emoji))
end
emoji_reactions
end
def media_only_scope
Status.joins(:media_attachments)
end
def without_media_scope
Status.left_joins(:media_attachments).where(media_attachments: {status_id: nil})
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
@ -72,25 +107,8 @@ class Api::V1::EmojiReactionsController < Api::BaseController
results.size == limit_param(DEFAULT_STATUSES_LIMIT)
end
def emojis_requested?
emoji_reactions_params[:emojis].present?
end
def emojis_scope
emoji_reactions = EmojiReaction.none
emoji_reactions_params[:emojis].each do |emoji|
shortcode, domain = emoji.split('@')
custom_emoji = CustomEmoji.find_by(shortcode: shortcode, domain: domain)
emoji_reactions = emoji_reactions.or(EmojiReaction.where(name: shortcode, custom_emoji: custom_emoji))
end
emoji_reactions
end
def pagination_params(core_params)
params.slice(:limit, :compact).permit(:limit, :compact).merge(core_params)
params_slice(:limit, :compact, :only_media, :without_media).merge(core_params)
end
def emoji_reactions_params

View File

@ -28,20 +28,43 @@ class Api::V1::FavouritesController < Api::BaseController
end
def results
@_results ||= account_favourites.joins(:status).eager_load(:status).to_a_paginated_by_id(
@_results ||= filtered_account_favourites.to_a_paginated_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
end
def filtered_account_favourites
account_favourites.joins(:status).eager_load(:status).tap do |scope|
scope.merge!(media_only_scope) if media_only?
scope.merge!(without_media_scope) if without_media?
end
end
def account_favourites
current_account.favourites
end
def media_only?
truthy_param?(:only_media)
end
def without_media?
truthy_param?(:without_media)
end
def compact?
truthy_param?(:compact)
end
def media_only_scope
Status.joins(:media_attachments)
end
def without_media_scope
Status.left_joins(:media_attachments).where(media_attachments: {status_id: nil})
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
@ -71,6 +94,6 @@ class Api::V1::FavouritesController < Api::BaseController
end
def pagination_params(core_params)
params.slice(:limit, :compact).permit(:limit, :compact).merge(core_params)
params_slice(:limit, :compact, :only_media, :without_media).merge(core_params)
end
end

View File

@ -37,7 +37,19 @@ class Api::V1::Timelines::PersonalController < Api::BaseController
end
def personal_feed
PersonalFeed.new(current_account)
PersonalFeed.new(
current_account,
only_media: media_only?,
without_media: without_media?,
)
end
def media_only?
truthy_param?(:only_media)
end
def without_media?
truthy_param?(:without_media)
end
def compact?
@ -49,7 +61,7 @@ class Api::V1::Timelines::PersonalController < Api::BaseController
end
def pagination_params(core_params)
params.slice(:local, :limit).permit(:local, :limit).merge(core_params)
params_slice(:limit, :compact, :only_media, :without_media).merge(core_params)
end
def next_path

View File

@ -10,15 +10,18 @@ export const BOOKMARKED_STATUSES_EXPAND_REQUEST = 'BOOKMARKED_STATUSES_EXPAND_RE
export const BOOKMARKED_STATUSES_EXPAND_SUCCESS = 'BOOKMARKED_STATUSES_EXPAND_SUCCESS';
export const BOOKMARKED_STATUSES_EXPAND_FAIL = 'BOOKMARKED_STATUSES_EXPAND_FAIL';
export function fetchBookmarkedStatuses() {
export function fetchBookmarkedStatuses({ onlyMedia, withoutMedia } = {}) {
return (dispatch, getState) => {
if (getState().getIn(['status_lists', 'bookmarks', 'isLoading'])) {
return;
}
const params = ['compact=true', onlyMedia ? 'only_media=true' : null, withoutMedia ? 'without_media=true' : null];
const param_string = params.filter(e => !!e).join('&');
dispatch(fetchBookmarkedStatusesRequest());
api(getState).get('/api/v1/bookmarks?compact=true').then(response => {
api(getState).get(`/api/v1/bookmarks?${param_string}`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
if ('statuses' in response.data && 'accounts' in response.data) {
const { statuses, referenced_statuses, accounts, relationships } = response.data;

View File

@ -10,15 +10,18 @@ export const EMOJI_REACTIONED_STATUSES_EXPAND_REQUEST = 'EMOJI_REACTIONED_STATUS
export const EMOJI_REACTIONED_STATUSES_EXPAND_SUCCESS = 'EMOJI_REACTIONED_STATUSES_EXPAND_SUCCESS';
export const EMOJI_REACTIONED_STATUSES_EXPAND_FAIL = 'EMOJI_REACTIONED_STATUSES_EXPAND_FAIL';
export function fetchEmojiReactionedStatuses() {
export function fetchEmojiReactionedStatuses({ onlyMedia, withoutMedia } = {}) {
return (dispatch, getState) => {
if (getState().getIn(['status_lists', 'emoji_reactions', 'isLoading'])) {
return;
}
const params = ['compact=true', onlyMedia ? 'only_media=true' : null, withoutMedia ? 'without_media=true' : null];
const param_string = params.filter(e => !!e).join('&');
dispatch(fetchEmojiReactionedStatusesRequest());
api(getState).get('/api/v1/emoji_reactions?compact=true').then(response => {
api(getState).get(`/api/v1/emoji_reactions?${param_string}`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
if ('statuses' in response.data && 'accounts' in response.data) {
const { statuses, referenced_statuses, accounts, relationships } = response.data;

View File

@ -10,15 +10,18 @@ export const FAVOURITED_STATUSES_EXPAND_REQUEST = 'FAVOURITED_STATUSES_EXPAND_RE
export const FAVOURITED_STATUSES_EXPAND_SUCCESS = 'FAVOURITED_STATUSES_EXPAND_SUCCESS';
export const FAVOURITED_STATUSES_EXPAND_FAIL = 'FAVOURITED_STATUSES_EXPAND_FAIL';
export function fetchFavouritedStatuses() {
export function fetchFavouritedStatuses({ onlyMedia, withoutMedia } = {}) {
return (dispatch, getState) => {
if (getState().getIn(['status_lists', 'favourites', 'isLoading'])) {
return;
}
const params = ['compact=true', onlyMedia ? 'only_media=true' : null, withoutMedia ? 'without_media=true' : null];
const param_string = params.filter(e => !!e).join('&');
dispatch(fetchFavouritedStatusesRequest());
api(getState).get('/api/v1/favourites?compact=true').then(response => {
api(getState).get(`/api/v1/favourites?${param_string}`).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
if ('statuses' in response.data && 'accounts' in response.data) {
const { statuses, referenced_statuses, accounts, relationships } = response.data;

View File

@ -183,7 +183,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
export const expandHomeTimeline = ({ maxId, visibilities } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId, visibilities: visibilities }, done);
export const expandLimitedTimeline = ({ maxId, visibilities } = {}, done = noOp) => expandTimeline('limited', '/api/v1/timelines/home', { max_id: maxId, visibilities: visibilities }, done);
export const expandPersonalTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('personal', '/api/v1/timelines/personal', { max_id: maxId}, done);
export const expandPersonalTimeline = ({ maxId, onlyMedia, withoutMedia } = {}, done = noOp) => expandTimeline(`personal${withoutMedia ? ':nomedia' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/personal', { max_id: maxId, only_media: !!onlyMedia, without_media: !!withoutMedia }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia, withoutMedia, withoutBot, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${withoutBot ? ':nobot' : ':bot'}${withoutMedia ? ':nomedia' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia, without_media: !!withoutMedia, without_bot: !!withoutBot }, done);
export const expandDomainTimeline = (domain, { maxId, onlyMedia, withoutMedia, withoutBot } = {}, done = noOp) => expandTimeline(`domain${withoutBot ? ':nobot' : ':bot'}${withoutMedia ? ':nomedia' : ''}${onlyMedia ? ':media' : ''}:${domain}`, '/api/v1/timelines/public', { local: false, domain: domain, max_id: maxId, only_media: !!onlyMedia, without_media: !!withoutMedia, without_bot: !!withoutBot }, done);
export const expandGroupTimeline = (id, { maxId, onlyMedia, withoutMedia, tagged } = {}, done = noOp) => expandTimeline(`group:${id}${withoutMedia ? ':nomedia' : ''}${onlyMedia ? ':media' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: !!onlyMedia, without_media: !!withoutMedia, tagged: tagged }, done);

View File

@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from '../../notifications/components/setting_toggle';
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
};
render () {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} exclusiveSettingPaths={[['other', 'withoutMedia']]} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
<SettingToggle settings={settings} settingPath={['other', 'withoutMedia']} exclusiveSettingPaths={[['other', 'onlyMedia']]} onChange={onChange} label={<FormattedMessage id='community.column_settings.without_media' defaultMessage='Without media' />} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { changeSetting } from '../../../actions/settings';
import { changeColumnParams } from '../../../actions/columns';
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', 'bookmarked_statuses']),
};
};
const mapDispatchToProps = (dispatch, { columnId }) => {
return {
onChange (key, checked) {
if (columnId) {
dispatch(changeColumnParams(columnId, key, checked));
} else {
dispatch(changeSetting(['bookmarked_statuses', ...key], checked));
}
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View File

@ -6,6 +6,7 @@ import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from '../../actions
import Column from '../ui/components/column';
import ColumnHeader from '../../components/column_header';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container';
import StatusList from '../../components/status_list';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -22,12 +23,16 @@ 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', 'bookmarked_statuses', 'other', 'onlyMedia']);
const withoutMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'withoutMedia']) : state.getIn(['settings', 'bookmarked_statuses', 'other', 'withoutMedia']);
const columnWidth = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'columnWidth']) : state.getIn(['settings', 'bookmarked_statuses', 'columnWidth']);
return {
statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
onlyMedia,
withoutMedia,
columnWidth: columnWidth ?? defaultColumnWidth,
};
};
@ -43,21 +48,38 @@ class Bookmarks extends ImmutablePureComponent {
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
columnWidth: PropTypes.string,
onlyMedia: PropTypes.bool,
withoutMedia: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
componentWillMount () {
this.props.dispatch(fetchBookmarkedStatuses());
static defaultProps = {
onlyMedia: false,
withoutMedia: false,
};
componentDidMount () {
const { dispatch, onlyMedia, withoutMedia } = this.props;
dispatch(fetchBookmarkedStatuses({ onlyMedia, withoutMedia }));
}
componentDidUpdate (prevProps) {
const { dispatch, onlyMedia, withoutMedia } = this.props;
if (prevProps.onlyMedia !== onlyMedia || prevProps.withoutMedia !== withoutMedia) {
dispatch(fetchBookmarkedStatuses({ onlyMedia, withoutMedia }));
}
}
handlePin = () => {
const { columnId, dispatch } = this.props;
const { columnId, dispatch, onlyMedia, withoutMedia } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('BOOKMARKS', {}));
dispatch(addColumn('BOOKMARKS', { other: { onlyMedia, withoutMedia } }));
}
}
@ -89,7 +111,7 @@ class Bookmarks extends ImmutablePureComponent {
}
render () {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading, columnWidth } = this.props;
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading, columnWidth, withoutMedia } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked toots yet. When you bookmark one, it will show up here." />;
@ -107,7 +129,9 @@ class Bookmarks extends ImmutablePureComponent {
columnWidth={columnWidth}
onWidthChange={this.handleWidthChange}
showBackButton
/>
>
<ColumnSettingsContainer columnId={columnId} />
</ColumnHeader>
<StatusList
trackScroll={!pinned}
@ -118,6 +142,7 @@ class Bookmarks extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
showCard={!withoutMedia}
/>
</Column>
);

View File

@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from '../../notifications/components/setting_toggle';
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
};
render () {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} exclusiveSettingPaths={[['other', 'withoutMedia']]} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
<SettingToggle settings={settings} settingPath={['other', 'withoutMedia']} exclusiveSettingPaths={[['other', 'onlyMedia']]} onChange={onChange} label={<FormattedMessage id='community.column_settings.without_media' defaultMessage='Without media' />} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { changeSetting } from '../../../actions/settings';
import { changeColumnParams } from '../../../actions/columns';
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', 'emoji_reactioned_statuses']),
};
};
const mapDispatchToProps = (dispatch, { columnId }) => {
return {
onChange (key, checked) {
if (columnId) {
dispatch(changeColumnParams(columnId, key, checked));
} else {
dispatch(changeSetting(['emoji_reactioned_statuses', ...key], checked));
}
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View File

@ -6,6 +6,7 @@ import { fetchEmojiReactionedStatuses, expandEmojiReactionedStatuses } from '../
import Column from '../ui/components/column';
import ColumnHeader from '../../components/column_header';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container';
import StatusList from '../../components/status_list';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -22,12 +23,16 @@ 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', 'emoji_reactioned_statuses', 'other', 'onlyMedia']);
const withoutMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'withoutMedia']) : state.getIn(['settings', 'emoji_reactioned_statuses', 'other', 'withoutMedia']);
const columnWidth = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'columnWidth']) : state.getIn(['settings', 'emoji_reactioned_statuses', 'columnWidth']);
return {
statusIds: state.getIn(['status_lists', 'emoji_reactions', 'items']),
isLoading: state.getIn(['status_lists', 'emoji_reactions', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'emoji_reactions', 'next']),
onlyMedia,
withoutMedia,
columnWidth: columnWidth ?? defaultColumnWidth,
};
};
@ -43,21 +48,38 @@ class EmojiReactions extends ImmutablePureComponent {
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
columnWidth: PropTypes.string,
onlyMedia: PropTypes.bool,
withoutMedia: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
componentWillMount () {
this.props.dispatch(fetchEmojiReactionedStatuses());
static defaultProps = {
onlyMedia: false,
withoutMedia: false,
};
componentDidMount () {
const { dispatch, onlyMedia, withoutMedia } = this.props;
dispatch(fetchEmojiReactionedStatuses({ onlyMedia, withoutMedia }));
}
componentDidUpdate (prevProps) {
const { dispatch, onlyMedia, withoutMedia } = this.props;
if (prevProps.onlyMedia !== onlyMedia || prevProps.withoutMedia !== withoutMedia) {
dispatch(fetchEmojiReactionedStatuses({ onlyMedia, withoutMedia }));
}
}
handlePin = () => {
const { columnId, dispatch } = this.props;
const { columnId, dispatch, onlyMedia, withoutMedia } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('EMOJI_REACTIONS', {}));
dispatch(addColumn('EMOJI_REACTIONS', { other: { onlyMedia, withoutMedia } }));
}
}
@ -89,7 +111,7 @@ class EmojiReactions extends ImmutablePureComponent {
}
render () {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading, columnWidth } = this.props;
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading, columnWidth, withoutMedia } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.emoji_reactioned_statuses' defaultMessage="You don't have any reaction posts yet. When you reaction one, it will show up here." />;
@ -107,7 +129,9 @@ class EmojiReactions extends ImmutablePureComponent {
columnWidth={columnWidth}
onWidthChange={this.handleWidthChange}
showBackButton
/>
>
<ColumnSettingsContainer columnId={columnId} />
</ColumnHeader>
<StatusList
trackScroll={!pinned}
@ -118,6 +142,7 @@ class EmojiReactions extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
showCard={!withoutMedia}
/>
</Column>
);

View File

@ -0,0 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from '../../notifications/components/setting_toggle';
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
};
render () {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} exclusiveSettingPaths={[['other', 'withoutMedia']]} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
<SettingToggle settings={settings} settingPath={['other', 'withoutMedia']} exclusiveSettingPaths={[['other', 'onlyMedia']]} onChange={onChange} label={<FormattedMessage id='community.column_settings.without_media' defaultMessage='Without media' />} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { changeSetting } from '../../../actions/settings';
import { changeColumnParams } from '../../../actions/columns';
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', 'favourited_statuses']),
};
};
const mapDispatchToProps = (dispatch, { columnId }) => {
return {
onChange (key, checked) {
if (columnId) {
dispatch(changeColumnParams(columnId, key, checked));
} else {
dispatch(changeSetting(['favourited_statuses', ...key], checked));
}
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View File

@ -6,6 +6,7 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions
import Column from '../ui/components/column';
import ColumnHeader from '../../components/column_header';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container';
import StatusList from '../../components/status_list';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -22,12 +23,16 @@ 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', 'favourited_statuses', 'other', 'onlyMedia']);
const withoutMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'withoutMedia']) : state.getIn(['settings', 'favourited_statuses', 'other', 'withoutMedia']);
const columnWidth = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'columnWidth']) : state.getIn(['settings', 'favourited_statuses', 'columnWidth']);
return {
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
onlyMedia,
withoutMedia,
columnWidth: columnWidth ?? defaultColumnWidth,
};
};
@ -43,21 +48,38 @@ class Favourites extends ImmutablePureComponent {
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
columnWidth: PropTypes.string,
onlyMedia: PropTypes.bool,
withoutMedia: PropTypes.bool,
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
};
componentWillMount () {
this.props.dispatch(fetchFavouritedStatuses());
static defaultProps = {
onlyMedia: false,
withoutMedia: false,
};
componentDidMount () {
const { dispatch, onlyMedia, withoutMedia } = this.props;
dispatch(fetchFavouritedStatuses({ onlyMedia, withoutMedia }));
}
componentDidUpdate (prevProps) {
const { dispatch, onlyMedia, withoutMedia } = this.props;
if (prevProps.onlyMedia !== onlyMedia || prevProps.withoutMedia !== withoutMedia) {
dispatch(fetchFavouritedStatuses({ onlyMedia, withoutMedia }));
}
}
handlePin = () => {
const { columnId, dispatch } = this.props;
const { columnId, dispatch, onlyMedia, withoutMedia } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('FAVOURITES', {}));
dispatch(addColumn('FAVOURITES', { other: { onlyMedia, withoutMedia } }));
}
}
@ -89,7 +111,7 @@ class Favourites extends ImmutablePureComponent {
}
render () {
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading, columnWidth } = this.props;
const { intl, statusIds, columnId, multiColumn, hasMore, isLoading, columnWidth, withoutMedia } = this.props;
const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite posts yet. When you favourite one, it will show up here." />;
@ -107,7 +129,9 @@ class Favourites extends ImmutablePureComponent {
columnWidth={columnWidth}
onWidthChange={this.handleWidthChange}
showBackButton
/>
>
<ColumnSettingsContainer columnId={columnId} />
</ColumnHeader>
<StatusList
trackScroll={!pinned}
@ -118,6 +142,7 @@ class Favourites extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
showCard={!withoutMedia}
/>
</Column>
);

View File

@ -10,7 +10,6 @@ class ColumnSettings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
onChangeClear: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
@ -22,7 +21,8 @@ class ColumnSettings extends React.PureComponent {
<span className='column-settings__section'><FormattedMessage id='personal.column_settings.basic' defaultMessage='Basic' /></span>
<div className='column-settings__row'>
<SettingToggle prefix='personal_timeline' settings={settings} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='personal.column_settings.show_replies' defaultMessage='Show replies' />} />
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} exclusiveSettingPaths={[['other', 'withoutMedia']]} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
<SettingToggle settings={settings} settingPath={['other', 'withoutMedia']} exclusiveSettingPaths={[['other', 'onlyMedia']]} onChange={onChange} label={<FormattedMessage id='community.column_settings.without_media' defaultMessage='Without media' />} />
</div>
</div>
);

View File

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

View File

@ -20,10 +20,14 @@ 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', 'personal', 'other', 'onlyMedia']);
const withoutMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'withoutMedia']) : state.getIn(['settings', 'personal', 'other', 'withoutMedia']);
const columnWidth = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'columnWidth']) : state.getIn(['settings', 'personal', 'columnWidth']);
return {
hasUnread: state.getIn(['timelines', 'personal', 'unread']) > 0,
onlyMedia,
withoutMedia,
columnWidth: columnWidth ?? defaultColumnWidth,
};
};
@ -37,17 +41,24 @@ class PersonalTimeline extends React.PureComponent {
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
columnId: PropTypes.string,
onlyMedia: PropTypes.bool,
withoutMedia: PropTypes.bool,
multiColumn: PropTypes.bool,
columnWidth: PropTypes.string,
};
static defaultProps = {
onlyMedia: false,
withoutMedia: false,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
const { columnId, dispatch, onlyMedia, withoutMedia } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('PERSONAL', {}));
dispatch(addColumn('PERSONAL', { other: { onlyMedia, withoutMedia } }));
}
}
@ -65,15 +76,23 @@ class PersonalTimeline extends React.PureComponent {
}
handleLoadMore = maxId => {
const { dispatch } = this.props;
const { dispatch, onlyMedia, withoutMedia } = this.props;
dispatch(expandPersonalTimeline({ maxId }));
dispatch(expandPersonalTimeline({ maxId, onlyMedia, withoutMedia }));
}
componentDidMount () {
const { dispatch } = this.props;
const { dispatch, onlyMedia, withoutMedia } = this.props;
dispatch(expandPersonalTimeline({}));
dispatch(expandPersonalTimeline({ onlyMedia, withoutMedia }));
}
componentDidUpdate (prevProps) {
const { dispatch, onlyMedia, withoutMedia } = this.props;
if (prevProps.onlyMedia !== onlyMedia || prevProps.withoutMedia !== withoutMedia) {
dispatch(expandPersonalTimeline({ onlyMedia, withoutMedia }));
}
}
handleWidthChange = (value) => {
@ -87,7 +106,7 @@ class PersonalTimeline extends React.PureComponent {
}
render () {
const { intl, hasUnread, columnId, multiColumn, columnWidth } = this.props;
const { intl, hasUnread, columnId, multiColumn, columnWidth, onlyMedia, withoutMedia } = this.props;
const pinned = !!columnId;
return (
@ -103,15 +122,18 @@ class PersonalTimeline extends React.PureComponent {
multiColumn={multiColumn}
columnWidth={columnWidth}
onWidthChange={this.handleWidthChange}
/>
>
<ColumnSettingsContainer columnId={columnId} />
</ColumnHeader>
<StatusListContainer
timelineId={`personal${withoutMedia ? ':nomedia' : ''}${onlyMedia ? ':media' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}
scrollKey={`personal_timeline-${columnId}`}
onLoadMore={this.handleLoadMore}
timelineId='personal'
emptyMessage={<FormattedMessage id='empty_column.personal' defaultMessage='Personal posts unavailable' />}
bindToDocument={!multiColumn}
showCard={!withoutMedia}
/>
</Column>
);

View File

@ -140,6 +140,24 @@ const initialState = ImmutableMap({
body: '',
}),
}),
bookmarked_statuses: ImmutableMap({
regex: ImmutableMap({
body: '',
}),
}),
favourited_statuses: ImmutableMap({
regex: ImmutableMap({
body: '',
}),
}),
emoji_reactioned_statuses: ImmutableMap({
regex: ImmutableMap({
body: '',
}),
}),
});
const defaultColumns = fromJS([

View File

@ -3,7 +3,8 @@
class PersonalFeed
# @param [Account] account
# @param [Hash] options
# @option [Boolean] :with_replies
# @option [Boolean] :only_media
# @option [Boolean] :without_media
def initialize(account, options = {})
@account = account
@options = options
@ -17,6 +18,9 @@ class PersonalFeed
def get(limit, max_id = nil, since_id = nil, min_id = nil)
scope = personal_scope
scope.merge!(media_only_scope) if media_only?
scope.merge!(without_media_scope) if without_media?
scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
end
@ -24,6 +28,22 @@ class PersonalFeed
attr_reader :account, :options
def media_only?
options[:only_media]
end
def without_media?
options[:without_media]
end
def media_only_scope
Status.joins(:media_attachments).group(:id)
end
def without_media_scope
Status.left_joins(:media_attachments).where(media_attachments: {status_id: nil})
end
def personal_scope
Status.include_expired.where(account_id: account.id).without_reblogs.with_personal_visibility
end

View File

@ -150,6 +150,10 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:account_conversations,
:enable_wide_emoji,
:enable_wide_emoji_reaction,
:timeline_bookmark_media_option,
:timeline_favourite_media_option,
:timeline_emoji_reaction_media_option,
:timeline_personal_media_option,
]
capabilities << :profile_search unless Chewy.enabled?