Add personal visibility feature

This commit is contained in:
noellabo 2022-12-29 17:47:23 +09:00
parent d73a197f61
commit ec075c3ccb
53 changed files with 540 additions and 51 deletions

View File

@ -31,10 +31,11 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
def cached_account_statuses
statuses = truthy_param?(:pinned) ? pinned_scope : permitted_account_statuses
statuses.merge!(only_media_scope) if truthy_param?(:only_media)
statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
statuses.merge!(hashtag_scope) if params[:tagged].present?
statuses.merge!(only_media_scope) if truthy_param?(:only_media)
statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
statuses.merge!(hashtag_scope) if params[:tagged].present?
statuses.merge!(no_personal_scope) if current_user&.setting_hide_personal_from_account
cache_collection_paginated_by_id(
statuses,
@ -78,6 +79,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end
end
def no_personal_scope
Status.without_personal_visibility
end
def pagination_params(core_params)
params.slice(:limit, :only_media, :exclude_replies, :compact).permit(:limit, :only_media, :exclude_replies, :compact).merge(core_params)
end

View File

@ -0,0 +1,70 @@
# frozen_string_literal: true
class Api::V1::Timelines::PersonalController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: [:show]
before_action :require_user!, only: [:show]
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
@statuses = load_statuses
if compact?
render json: CompactStatusesPresenter.new(statuses: @statuses), serializer: REST::CompactStatusesSerializer
else
account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id), status: account_home_feed.regenerating? ? 206 : 200
end
end
private
def load_statuses
cached_personal_statuses
end
def cached_personal_statuses
cache_collection personal_statuses, Status
end
def personal_statuses
personal_feed.get(
limit_param(DEFAULT_STATUSES_LIMIT),
params[:max_id],
params[:since_id],
params[:min_id],
)
end
def personal_feed
PersonalFeed.new(current_account)
end
def compact?
truthy_param?(:compact)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def pagination_params(core_params)
params.slice(:local, :limit).permit(:local, :limit).merge(core_params)
end
def next_path
api_v1_timelines_home_url pagination_params(max_id: pagination_max_id)
end
def prev_path
api_v1_timelines_home_url pagination_params(min_id: pagination_since_id)
end
def pagination_max_id
@statuses.last.id
end
def pagination_since_id
@statuses.first.id
end
end

View File

@ -72,6 +72,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_show_target,
:setting_enable_federated_timeline,
:setting_enable_limited_timeline,
:setting_enable_personal_timeline,
:setting_enable_local_timeline,
:setting_enable_reaction,
:setting_compact_reaction,
@ -113,6 +114,9 @@ class Settings::PreferencesController < Settings::BaseController
:setting_disable_account_delete,
:setting_prohibited_words,
:setting_disable_relative_time,
:setting_hide_direct_from_timeline,
:setting_hide_personal_from_timeline,
:setting_hide_personal_from_account,
setting_prohibited_visibilities: [],
notification_emails: %i(follow follow_request reblog favourite emoji_reaction status_reference mention digest report pending_account trending_tag),
interactions: %i(must_be_follower must_be_following must_be_following_dm must_be_dm_to_send_email must_be_following_reference)

View File

@ -106,6 +106,8 @@ module ApplicationHelper
fa_icon('user-circle', title: I18n.t('statuses.visibilities.limited'))
elsif status.direct_visibility?
fa_icon('envelope', title: I18n.t('statuses.visibilities.direct'))
elsif status.personal_visibility?
fa_icon('book', title: I18n.t('statuses.visibilities.personal'))
end
end
@ -205,7 +207,7 @@ module ApplicationHelper
text: [params[:title], params[:text], params[:url]].compact.join(' '),
}
permit_visibilities = %w(public unlisted private mutual direct)
permit_visibilities = %w(public unlisted private mutual direct personal)
default_privacy = current_account&.user&.setting_default_privacy
permit_visibilities.shift(permit_visibilities.index(default_privacy) + 1) if default_privacy.present?
state_params[:visibility] = params[:visibility] if permit_visibilities.include? params[:visibility]

View File

@ -207,14 +207,14 @@ export const getDateTimeFromText = (value, origin = new Date()) => {
if (value.length >= 7) {
const isoDateTime = parseISO(value);
if (isoDateTime.toString() === "Invalid Date") {
if (isoDateTime.toString() === 'Invalid Date') {
return null;
} else {
return isoDateTime;
}
}
return null
return null;
})();
return {
@ -304,7 +304,7 @@ export function submitCompose(routerHistory) {
}
};
if (homeVisibilities.length == 0 || homeVisibilities.includes(response.data.visibility)) {
if (homeVisibilities.length || homeVisibilities.includes(response.data.visibility)) {
insertIfOnline('home');
}
@ -312,6 +312,10 @@ export function submitCompose(routerHistory) {
insertIfOnline('limited');
}
if (['personal'].includes(response.data.visibility)) {
insertIfOnline('personal');
}
if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
if (enableFederatedTimeline) {
insertIfOnline('public');

View File

@ -63,7 +63,7 @@ export function updateTimeline(timeline, status, accept) {
const limitedVisibilities = getLimitedVisibilities(getState());
if (timeline === 'home') {
if (homeVisibilities.length == 0 || homeVisibilities.includes(visibility)) {
if (homeVisibilities.length || homeVisibilities.includes(visibility)) {
insertTimeline('home');
dispatch(submitMarkers());
}
@ -71,6 +71,10 @@ export function updateTimeline(timeline, status, accept) {
if (limitedVisibilities.includes(visibility)) {
insertTimeline('limited');
}
if (visibility === 'personal') {
insertTimeline('personal');
}
} else {
insertTimeline(timeline);
}
@ -177,8 +181,9 @@ 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 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 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

@ -84,6 +84,7 @@ const messages = defineMessages({
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual-followers-only' },
personal_short: { id: 'privacy.personal.short', defaultMessage: 'Personal' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
mark_ancestor: { id: 'thread_mark.ancestor', defaultMessage: 'Has reference' },
@ -420,6 +421,7 @@ class Status extends ImmutablePureComponent {
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) },
'limited': { icon: 'user-circle', text: intl.formatMessage(messages.limited_short) },
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
'personal': { icon: 'book', text: intl.formatMessage(messages.personal_short) },
};
if (hidden) {

View File

@ -473,9 +473,9 @@ class StatusActionBar extends ImmutablePureComponent {
<IconButton className='status__action-bar-button' disabled={expired} title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
);
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct', 'personal'].includes(status.get('visibility'));
const reactionsCounter = compactReaction && contextType != 'thread' && status.get('emoji_reactions_count') > 0 ? status.get('emoji_reactions_count') : undefined;
const reactionsCounter = compactReaction && contextType !== 'thread' && status.get('emoji_reactions_count') > 0 ? status.get('emoji_reactions_count') : undefined;
return (
<div className='status__action-bar'>

View File

@ -19,6 +19,8 @@ const messages = defineMessages({
private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutuals-followers-only' },
mutual_long: { id: 'privacy.mutual.long', defaultMessage: 'Visible for mutual followers only (Supported servers only)' },
personal_short: { id: 'privacy.personal.short', defaultMessage: 'Personal' },
personal_long: { id: 'privacy.personal.long', defaultMessage: 'Visible for personal only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' },
@ -249,8 +251,9 @@ class PrivacyDropdown extends React.PureComponent {
...!this.props.noDirect && [
{ icon: 'user-circle', value: 'limited', text: formatMessage(messages.limited_short), meta: formatMessage(messages.limited_long) },
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
{ icon: 'book', value: 'personal', text: formatMessage(messages.personal_short), meta: formatMessage(messages.personal_long) },
],
].filter(option => !prohibitedVisibilities?.includes(option.value));
].filter(option => option && !prohibitedVisibilities?.includes(option.value));
}
render () {

View File

@ -7,7 +7,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, profile_directory, showTrends, enableLimitedTimeline, enableFederatedTimeline, enableLocalTimeline, enableEmptyColumn, defaultColumnWidth } from '../../initial_state';
import { me, profile_directory, showTrends, enableLimitedTimeline, enablePersonalTimeline, enableFederatedTimeline, enableLocalTimeline, enableEmptyColumn, defaultColumnWidth } from '../../initial_state';
import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { fetchFavouriteDomains } from 'mastodon/actions/favourite_domains';
import { fetchFavouriteTags } from 'mastodon/actions/favourite_tags';
@ -38,6 +38,7 @@ const messages = defineMessages({
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
limited_timeline: { id: 'navigation_bar.limited_timeline', defaultMessage: 'Limited home' },
personal_timeline: { id: 'navigation_bar.personal_timeline', defaultMessage: 'Personal' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
circles: { id: 'navigation_bar.circles', defaultMessage: 'Circles' },
discover: { id: 'navigation_bar.discover', defaultMessage: 'Discover' },
@ -122,7 +123,7 @@ class GettingStarted extends ImmutablePureComponent {
render () {
const { intl, myAccount, columns, multiColumn, unreadFollowRequests, lists, favourite_domains, favourite_tags, columnWidth } = this.props;
const navItems = [];
let height = (multiColumn) ? 0 : 60;
@ -178,7 +179,7 @@ class GettingStarted extends ImmutablePureComponent {
height += 34 + 48*2;
navItems.push(
<ColumnSubheading key='header-personal' text={intl.formatMessage(messages.personal)} />
<ColumnSubheading key='header-personal' text={intl.formatMessage(messages.personal)} />,
);
height += 34;
@ -226,6 +227,13 @@ class GettingStarted extends ImmutablePureComponent {
height += 48;
}
if (enablePersonalTimeline && multiColumn && !columns.find(item => item.get('id') === 'PERSONAL')) {
navItems.push(
<ColumnLink key='personal_timeline' icon='lock' text={intl.formatMessage(messages.personal_timeline)} to='/timelines/personal' />,
);
height += 48;
}
navItems.push(
<ColumnLink key='direct' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />,
<ColumnLink key='bookmark' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,

View File

@ -44,6 +44,10 @@ class ColumnSettings extends React.PureComponent {
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'direct']} onChange={onChangeClear} label={<FormattedMessage id='home.column_settings.show_direct' defaultMessage='Show direct' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'personal']} onChange={onChangeClear} label={<FormattedMessage id='home.column_settings.show_personal' defaultMessage='Show personal' />} />
</div>
</Fragment>}
</div>
);

View File

@ -42,6 +42,10 @@ class ColumnSettings extends React.PureComponent {
<div className='column-settings__row'>
<SettingToggle prefix='limited_timeline' settings={settings} settingPath={['shows', 'direct']} onChange={onChangeClear} label={<FormattedMessage id='limited.column_settings.show_direct' defaultMessage='Show direct' />} />
</div>
<div className='column-settings__row'>
<SettingToggle prefix='limited_timeline' settings={settings} settingPath={['shows', 'personal']} onChange={onChangeClear} label={<FormattedMessage id='limited.column_settings.show_personal' defaultMessage='Show personal' />} />
</div>
</div>
);
}

View File

@ -0,0 +1,31 @@
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,
onChangeClear: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
render () {
const { settings, onChange } = this.props;
return (
<div>
<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' />} />
</div>
</div>
);
}
}

View File

@ -0,0 +1,21 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { changeSetting, saveSettings } from '../../../actions/settings';
const mapStateToProps = state => ({
settings: state.getIn(['settings', 'personal']),
});
const mapDispatchToProps = dispatch => ({
onChange (key, checked) {
dispatch(changeSetting(['personal', ...key], checked));
},
onSave () {
dispatch(saveSettings());
},
});
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View File

@ -0,0 +1,120 @@
import React from 'react';
import { connect } from 'react-redux';
import { expandPersonalTimeline } from '../../actions/timelines';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
import { defaultColumnWidth } from 'mastodon/initial_state';
import { changeSetting } from '../../actions/settings';
import { changeColumnParams } from '../../actions/columns';
const messages = defineMessages({
title: { id: 'column.personal', defaultMessage: 'Personal' },
});
const mapStateToProps = (state, { columnId }) => {
const uuid = columnId;
const columns = state.getIn(['settings', 'columns']);
const index = columns.findIndex(c => c.get('uuid') === uuid);
const columnWidth = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'columnWidth']) : state.getIn(['settings', 'personal', 'columnWidth']);
return {
hasUnread: state.getIn(['timelines', 'personal', 'unread']) > 0,
columnWidth: columnWidth ?? defaultColumnWidth,
};
};
export default @connect(mapStateToProps)
@injectIntl
class PersonalTimeline extends React.PureComponent {
static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
columnWidth: PropTypes.string,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('PERSONAL', {}));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
setRef = c => {
this.column = c;
}
handleLoadMore = maxId => {
const { dispatch } = this.props;
dispatch(expandPersonalTimeline({ maxId }));
}
componentDidMount () {
const { dispatch } = this.props;
dispatch(expandPersonalTimeline({}));
}
handleWidthChange = (value) => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(changeColumnParams(columnId, 'columnWidth', value));
} else {
dispatch(changeSetting(['personal', 'columnWidth'], value));
}
}
render () {
const { intl, hasUnread, columnId, multiColumn, columnWidth } = this.props;
const pinned = !!columnId;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)} columnWidth={columnWidth}>
<ColumnHeader
icon='lock'
active={hasUnread}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
columnWidth={columnWidth}
onWidthChange={this.handleWidthChange}
/>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`personal_timeline-${columnId}`}
onLoadMore={this.handleLoadMore}
timelineId='personal'
emptyMessage={<FormattedMessage id='empty_column.personal' defaultMessage='Personal posts unavailable' />}
bindToDocument={!multiColumn}
/>
</Column>
);
}
}

View File

@ -247,7 +247,7 @@ class Footer extends ImmutablePureComponent {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
}
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct', 'personal'].includes(status.get('visibility'));
return (
<div className='picture-in-picture__footer'>

View File

@ -419,7 +419,7 @@ class ActionBar extends React.PureComponent {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
}
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct'].includes(status.get('visibility'));
const referenceDisabled = expired || !referenced && referenceCountLimit || ['limited', 'direct', 'personal'].includes(status.get('visibility'));
return (
<div className='detailed-status__action-bar'>

View File

@ -25,6 +25,7 @@ const messages = defineMessages({
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
mutual_short: { id: 'privacy.mutual.short', defaultMessage: 'Mutual-followers-only' },
personal_short: { id: 'privacy.personal.short', defaultMessage: 'Personal' },
limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
});
@ -357,6 +358,7 @@ class DetailedStatus extends ImmutablePureComponent {
'mutual': { icon: 'exchange', text: intl.formatMessage(messages.mutual_short) },
'limited': { icon: 'user-circle', text: intl.formatMessage(messages.limited_short) },
'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) },
'personal': { icon: 'book', text: intl.formatMessage(messages.personal_short) },
};
const visibilityIcon = visibilityIconInfo[status.get('visibility')];

View File

@ -1,4 +1,4 @@
import React, { Fragment } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
@ -25,6 +25,7 @@ import {
HashtagTimeline,
DirectTimeline,
LimitedTimeline,
PersonalTimeline,
FavouritedStatuses,
BookmarkedStatuses,
ListTimeline,
@ -56,6 +57,7 @@ const componentMap = {
'HASHTAG': HashtagTimeline,
'DIRECT': DirectTimeline,
'LIMITED': LimitedTimeline,
'PERSONAL': PersonalTimeline,
'FAVOURITES': FavouritedStatuses,
'BOOKMARKS': BookmarkedStatuses,
'LIST': ListTimeline,

View File

@ -2,7 +2,7 @@ import React from 'react';
import { NavLink, withRouter } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import Icon from 'mastodon/components/icon';
import { profile_directory, showTrends, enableLimitedTimeline, enableFederatedTimeline, enableLocalTimeline } from 'mastodon/initial_state';
import { profile_directory, showTrends, enableLimitedTimeline, enableFederatedTimeline, enableLocalTimeline, enablePersonalTimeline } from 'mastodon/initial_state';
import NotificationsCounterIcon from './notifications_counter_icon';
import FollowRequestsNavLink from './follow_requests_nav_link';
import ListPanel from './list_panel';
@ -14,6 +14,7 @@ const NavigationPanel = () => (
<div className='navigation-panel'>
<NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
{enableLimitedTimeline && <NavLink className='column-link column-link--transparent' to='/timelines/limited' data-preview-title-id='column.limited' data-preview-icon='lock' ><Icon className='column-link__icon' id='lock' fixedWidth /><FormattedMessage id='navigation_bar.limited_timeline' defaultMessage='Limited home' /></NavLink>}
{enablePersonalTimeline && <NavLink className='column-link column-link--transparent' to='/timelines/personal' data-preview-title-id='column.personal' data-preview-icon='book' ><Icon className='column-link__icon' id='book' fixedWidth /><FormattedMessage id='navigation_bar.personal_timeline' defaultMessage='Personal' /></NavLink>}
<NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
<FollowRequestsNavLink />
{enableLocalTimeline && <NavLink className='column-link column-link--transparent' exact to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>}

View File

@ -6,7 +6,7 @@ import { debounce, memoize } from 'lodash';
import { isUserTouching } from '../../../is_mobile';
import Icon from 'mastodon/components/icon';
import NotificationsCounterIcon from './notifications_counter_icon';
import { place_tab_bar_at_bottom, show_tab_bar_label, enableLimitedTimeline, enableFederatedTimeline, enableLocalTimeline } from 'mastodon/initial_state';
import { place_tab_bar_at_bottom, show_tab_bar_label, enableLimitedTimeline, enableFederatedTimeline, enableLocalTimeline, enablePersonalTimeline } from 'mastodon/initial_state';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
@ -14,6 +14,7 @@ import classNames from 'classnames';
const link_home = <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.home' defaultMessage='Home' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.home' defaultMessage='Home' children={msg=> <>{msg}</>} /></span></NavLink>;
const link_limited = <NavLink className='tabs-bar__link tabs-bar__limited' to='/timelines/limited' data-preview-title-id='column.limited' data-preview-icon='lock' ><Icon id='lock' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.limited_timeline' defaultMessage='Limited' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.limited_timeline' defaultMessage='Ltd.' children={msg=> <>{msg}</>} /></span></NavLink>;
const link_personal = <NavLink className='tabs-bar__link tabs-bar__personal' to='/timelines/personal' data-preview-title-id='column.personal' data-preview-icon='book' ><Icon id='book' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.personal_timeline' defaultMessage='Personal' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.personal_timeline' defaultMessage='Per.' children={msg=> <>{msg}</>} /></span></NavLink>;
const link_notifications = <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.notifications' defaultMessage='Notif.' children={msg=> <>{msg}</>} /></span></NavLink>;
const link_local = <NavLink className='tabs-bar__link' exact to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.local_timeline' defaultMessage='LTL' children={msg=> <>{msg}</>} /></span></NavLink>;
const link_public = <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><span className='tabs-bar__link__full-label'><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' children={msg=> <>{msg}</>} /></span><span className='tabs-bar__link__short-label'><FormattedMessage id='navigation_bar.short.public_timeline' defaultMessage='FTL' children={msg=> <>{msg}</>} /></span></NavLink>;
@ -40,6 +41,7 @@ export const getLinks = memoize((favouriteLists = null) => {
return [
link_home,
enableLimitedTimeline ? link_limited : null,
enablePersonalTimeline ? link_personal : null,
link_favourite_lists,
link_notifications,
enableLocalTimeline ? link_local : null,

View File

@ -7,9 +7,19 @@ import { createSelector } from 'reselect';
import { debounce } from 'lodash';
import { me } from '../../../initial_state';
const visibilitiesByType = (state, type) => {
if (type === 'home') {
return getHomeVisibilities(state);
} else if (type === 'limited') {
return getLimitedVisibilities(state);
} else {
return [];
}
};
const makeGetStatusIds = (pending = false) => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()),
(state, { type }) => type === 'home' ? getHomeVisibilities(state) : type === 'limited' ? getLimitedVisibilities(state) : [],
(state, { type }) => visibilitiesByType(state, type),
(state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()),
(state) => state.get('statuses'),
], (columnSettings, visibilities, statusIds, statuses) => {

View File

@ -46,6 +46,7 @@ import {
Mentions,
DirectTimeline,
LimitedTimeline,
PersonalTimeline,
HashtagTimeline,
Notifications,
FollowRequests,
@ -182,6 +183,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/timelines/groups/:id/:tagged?' exact component={GroupTimeline} content={children} />
<WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
<WrappedRoute path='/timelines/limited' component={LimitedTimeline} content={children} />
<WrappedRoute path='/timelines/personal' component={PersonalTimeline} content={children} />
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />

View File

@ -42,6 +42,10 @@ export function LimitedTimeline() {
return import(/* webpackChunkName: "features/limited_timeline" */'../../limited_timeline');
}
export function PersonalTimeline() {
return import(/* webpackChunkName: "features/personal_timeline" */'../../personal_timeline');
}
export function ListTimeline () {
return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline');
}

View File

@ -43,6 +43,7 @@ export const show_target = getMeta('show_target');
export const place_tab_bar_at_bottom = getMeta('place_tab_bar_at_bottom');
export const show_tab_bar_label = getMeta('show_tab_bar_label');
export const enableLimitedTimeline = getMeta('enable_limited_timeline');
export const enablePersonalTimeline = getMeta('enable_personal_timeline');
export const enableFederatedTimeline = getMeta('enable_federated_timeline') ?? true;
export const enableLocalTimeline = getMeta('enable_local_timeline') ?? true;
export const enableReaction = getMeta('enable_reaction');
@ -66,6 +67,9 @@ export const disableDomainBlock = getMeta('disable_domain_block');
export const disableClearAllNotifications = getMeta('disable_clear_all_notifications');
export const disableAccountDelete = getMeta('disable_account_delete');
export const disableRelativeTime = getMeta('disable_relative_time');
export const hideDirectFromTimeline = getMeta('hide_direct_from_timeline');
export const hidePersonalFromTimeline = getMeta('hide_personal_from_timeline');
export const hidePersonalFromAccount = getMeta('hide_personal_from_account');
export const maxChars = initialState?.max_toot_chars ?? 500;

View File

@ -125,6 +125,7 @@
"column.lists": "Lists",
"column.mutes": "Muted users",
"column.notifications": "Notifications",
"column.personal": "Personal home",
"column.pins": "Pinned posts",
"column.public": "Federated timeline",
"column_back_button.label": "Back",
@ -265,6 +266,7 @@
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven't muted any users yet.",
"empty_column.notifications": "You don't have any notifications yet. When other people interact with you, you will see it here.",
"empty_column.personal": "Personal posts unavailable",
"empty_column.pinned_unavailable": "Pinned posts unavailable",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"empty_column.referred_by_statuses": "There are no referred by posts yet. When someone refers a post, it will appear here.",
@ -310,6 +312,7 @@
"home.column_settings.basic": "Basic",
"home.column_settings.show_direct": "Show direct",
"home.column_settings.show_limited": "Show limited",
"home.column_settings.show_personal": "Show personal",
"home.column_settings.show_private": "Show private",
"home.column_settings.show_reblogs": "Show boosts",
"home.column_settings.show_replies": "Show replies",
@ -367,6 +370,7 @@
"limited.column_settings.basic": "Basic",
"limited.column_settings.show_direct": "Show direct",
"limited.column_settings.show_limited": "Show limited",
"limited.column_settings.show_personal": "Show personal",
"limited.column_settings.show_private": "Show private",
"limited.column_settings.show_reblogs": "Show boosts",
"limited.column_settings.show_replies": "Show replies",
@ -418,6 +422,7 @@
"navigation_bar.logout": "Logout",
"navigation_bar.mutes": "Muted users",
"navigation_bar.personal": "Personal",
"navigation_bar.personal_timeline": "Personal home",
"navigation_bar.pins": "Pinned posts",
"navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline",
@ -429,6 +434,7 @@
"navigation_bar.short.lists": "Lists",
"navigation_bar.short.logout": "Logout",
"navigation_bar.short.notifications": "Notif.",
"navigation_bar.short.personal_timeline": "Per.",
"navigation_bar.short.preferences": "Pref.",
"navigation_bar.short.public_timeline": "FTL",
"navigation_bar.short.search": "Search",
@ -481,6 +487,8 @@
"notifications_permission_banner.enable": "Enable desktop notifications",
"notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
"notifications_permission_banner.title": "Never miss a thing",
"personal.column_settings.basic": "Basic",
"personal.column_settings.show_replies": "Show replies",
"picture_in_picture.restore": "Put it back",
"poll.closed": "Closed",
"poll.refresh": "Refresh",
@ -499,6 +507,8 @@
"privacy.mutual.short": "Mutual-followers-only",
"privacy.none.long": "No visibility allowed",
"privacy.none.short": "None",
"privacy.personal.long": "Visible for personal only",
"privacy.personal.short": "Personal",
"privacy.private.long": "Visible for followers only",
"privacy.private.short": "Followers-only",
"privacy.public.long": "Visible for all, shown in public timelines",
@ -614,6 +624,7 @@
"tabs_bar.lists": "List",
"tabs_bar.local_timeline": "Local",
"tabs_bar.notifications": "Notifications",
"tabs_bar.personal_timeline": "Personal",
"tabs_bar.search": "Search",
"thread_mark.ancestor": "Has reference",
"thread_mark.both": "Has reference and reply",

View File

@ -125,6 +125,7 @@
"column.lists": "リスト",
"column.mutes": "ミュートしたユーザー",
"column.notifications": "通知",
"column.personal": "自分限定ホーム",
"column.pins": "固定された投稿",
"column.public": "連合タイムライン",
"column_back_button.label": "戻る",
@ -265,6 +266,7 @@
"empty_column.lists": "まだリストがありません。リストを作るとここに表示されます。",
"empty_column.mutes": "まだ誰もミュートしていません。",
"empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。",
"empty_column.personal": "自分限定投稿はありません。",
"empty_column.pinned_unavailable": "固定された投稿はありません。",
"empty_column.referred_by_statuses": "まだ、参照している投稿はありません。誰かが投稿を参照すると、ここに表示されます。",
"empty_column.suggestions": "まだおすすめできるユーザーがいません。",
@ -310,6 +312,7 @@
"home.column_settings.basic": "基本設定",
"home.column_settings.show_direct": "ダイレクトメッセージを表示",
"home.column_settings.show_limited": "サークルを表示",
"home.column_settings.show_personal": "自分限定を表示",
"home.column_settings.show_private": "フォロワー限定を表示",
"home.column_settings.show_reblogs": "ブースト表示",
"home.column_settings.show_replies": "返信表示",
@ -367,6 +370,7 @@
"limited.column_settings.basic": "基本設定",
"limited.column_settings.show_direct": "ダイレクトメッセージを表示",
"limited.column_settings.show_limited": "サークルを表示",
"limited.column_settings.show_personal": "自分限定を表示",
"limited.column_settings.show_private": "フォロワー限定を表示",
"limited.column_settings.show_reblogs": "ブースト表示",
"limited.column_settings.show_replies": "返信表示",
@ -418,6 +422,7 @@
"navigation_bar.logout": "ログアウト",
"navigation_bar.mutes": "ミュートしたユーザー",
"navigation_bar.personal": "個人用",
"navigation_bar.personal_timeline": "自分限定ホーム",
"navigation_bar.pins": "固定した投稿",
"navigation_bar.preferences": "ユーザー設定",
"navigation_bar.public_timeline": "連合タイムライン",
@ -429,6 +434,7 @@
"navigation_bar.short.lists": "リスト",
"navigation_bar.short.logout": "ログアウト",
"navigation_bar.short.notifications": "通知",
"navigation_bar.short.personal_timeline": "自分",
"navigation_bar.short.preferences": "設定",
"navigation_bar.short.public_timeline": "連合",
"navigation_bar.short.search": "検索",
@ -481,6 +487,8 @@
"notifications_permission_banner.enable": "デスクトップ通知を有効にする",
"notifications_permission_banner.how_to_control": "Mastodon を閉じている間でも通知を受信するにはデスクトップ通知を有効にしてください。有効にすると上の {icon} ボタンから通知の内容を細かくカスタマイズできます。",
"notifications_permission_banner.title": "お見逃しなく",
"personal.column_settings.basic": "基本設定",
"personal.column_settings.show_replies": "返信表示",
"picture_in_picture.restore": "元に戻す",
"poll.closed": "終了",
"poll.refresh": "更新",
@ -499,6 +507,8 @@
"privacy.mutual.short": "相互フォロー限定",
"privacy.none.long": "許可された公開範囲なし",
"privacy.none.short": "なし",
"privacy.personal.long": "自分のみ閲覧可",
"privacy.personal.short": "自分限定",
"privacy.private.long": "フォロワーのみ閲覧可",
"privacy.private.short": "フォロワー限定",
"privacy.public.long": "誰でも閲覧可、公開TLに表示",
@ -614,6 +624,7 @@
"tabs_bar.lists": "リスト",
"tabs_bar.local_timeline": "ローカル",
"tabs_bar.notifications": "通知",
"tabs_bar.personal_timeline": "自分限定ホーム",
"tabs_bar.search": "検索",
"thread_mark.ancestor": "参照あり",
"thread_mark.both": "参照・返信あり",

View File

@ -248,18 +248,18 @@ const insertEmoji = (state, position, emojiData, needsSpace) => {
};
const privacyExpand = (a, b) => {
const order = ['public', 'unlisted', 'private', 'mutual', 'limited', 'direct'];
const order = ['public', 'unlisted', 'private', 'mutual', 'limited', 'direct', 'personal'];
return order[Math.min(order.indexOf(a), order.indexOf(b), order.length - 1)];
};
const privacyCap = (a, b) => {
const order = ['public', 'unlisted', 'private', 'mutual', 'limited', 'direct'];
const order = ['public', 'unlisted', 'private', 'mutual', 'limited', 'direct', 'personal'];
return order[Math.max(order.indexOf(a), order.indexOf(b), 0)];
};
const searchabilityCap = (a, b) => {
const order = ['public', 'unlisted', 'private', 'mutual', 'limited', 'direct'];
const to = ['public', 'private', 'private', 'direct', 'direct', 'direct'];
const order = ['public', 'unlisted', 'private', 'mutual', 'limited', 'direct', 'personal'];
const to = ['public', 'private', 'private', 'direct', 'direct', 'direct', 'direct'];
return to[Math.max(order.indexOf(a), order.indexOf(b), 0)];
};

View File

@ -31,9 +31,10 @@ const initialState = ImmutableMap({
shows: ImmutableMap({
reblog: true,
reply: true,
private: false,
limited: false,
direct: false,
private: true,
limited: true,
direct: true,
personal: true,
}),
regex: ImmutableMap({
@ -48,6 +49,17 @@ const initialState = ImmutableMap({
private: true,
limited: true,
direct: true,
personal: true,
}),
regex: ImmutableMap({
body: '',
}),
}),
personal: ImmutableMap({
shows: ImmutableMap({
reply: true,
}),
regex: ImmutableMap({

View File

@ -69,7 +69,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is
const updateTimeline = (state, timeline, status, usePendingItems) => {
const top = state.getIn([timeline, 'top']);
if (usePendingItems || !state.getIn([timeline, 'pendingItems']).isEmpty()) {
if (usePendingItems || !state.getIn([timeline, 'pendingItems'], ImmutableList()).isEmpty()) {
if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) {
return state;
}

View File

@ -1,6 +1,6 @@
import { createSelector } from 'reselect';
import { List as ImmutableList, Map as ImmutableMap, is } from 'immutable';
import { me, enableLimitedTimeline } from '../initial_state';
import { me, enableLimitedTimeline, hideDirectFromTimeline, hidePersonalFromTimeline, enablePersonalTimeline } from '../initial_state';
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
const getAccountCounters = (state, id) => state.getIn(['accounts_counters', id], null);
@ -249,13 +249,21 @@ export const getAccountGallery = createSelector([
export const getHomeVisibilities = createSelector(
state => state.getIn(['settings', 'home', 'shows']),
shows => !enableLimitedTimeline ? [] : [
shows => (!enableLimitedTimeline ? [
'public',
'unlisted',
'private',
'limited',
!hideDirectFromTimeline ? 'direct' : null,
!hidePersonalFromTimeline ? 'personal' : null,
] : [
'public',
'unlisted',
shows.get('private') ? 'private' : null,
shows.get('limited') ? 'limited' : null,
shows.get('direct') ? 'direct' : null,
].filter(x => !!x),
shows.get('direct') && !hideDirectFromTimeline ? 'direct' : null,
shows.get('personal') && !hidePersonalFromTimeline ? 'personal' : null,
]).filter(x => !!x),
);
export const getLimitedVisibilities = createSelector(
@ -264,5 +272,10 @@ export const getLimitedVisibilities = createSelector(
shows.get('private') ? 'private' : null,
shows.get('limited') ? 'limited' : null,
shows.get('direct') ? 'direct' : null,
shows.get('personal') ? 'personal' : null,
].filter(x => !!x),
);
export const getPersonalVisibilities = createSelector(
state => state.getIn(['settings', 'personal', 'shows']),
);

View File

@ -65,6 +65,7 @@ class UserSettingsDecorator
show_tab_bar_label
enable_federated_timeline
enable_limited_timeline
enable_personal_timeline
enable_local_timeline
enable_reaction
compact_reaction
@ -90,6 +91,9 @@ class UserSettingsDecorator
disable_clear_all_notifications
disable_account_delete
disable_relative_time
hide_direct_from_timeline
hide_personal_from_timeline
hide_personal_from_account
).freeze
STRING_KEYS = %w(

View File

@ -89,7 +89,7 @@ class Account < ApplicationRecord
enum protocol: [:ostatus, :activitypub]
enum suspension_origin: [:local, :remote], _prefix: true
enum silence_mode: { soft: 0, hard: 1 }, _suffix: :silence_mode
enum searchability: [:public, :unlisted, :private, :direct, :limited, :mutual], _suffix: :searchability
enum searchability: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, mutual: 100, personal: 200 }, _suffix: :searchability
validates :username, presence: true
validates_with UniqueUsernameValidator, if: -> { will_save_change_to_username? }

View File

@ -243,6 +243,10 @@ module AccountInteractions
.where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
end
def self_included_lists
owned_lists.joins(:list_accounts).where(list_accounts: {account_id: id})
end
def remote_followers_hash(url)
url_prefix = url[Account::URL_PREFIX_RE]
return if url_prefix.blank?

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
class PersonalFeed
# @param [Account] account
# @param [Hash] options
# @option [Boolean] :with_replies
def initialize(account, options = {})
@account = account
@options = options
end
# @param [Integer] limit
# @param [Integer] max_id
# @param [Integer] since_id
# @param [Integer] min_id
# @return [Array<Status>]
def get(limit, max_id = nil, since_id = nil, min_id = nil)
scope = personal_scope
scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id)
end
private
attr_reader :account, :options
def personal_scope
Status.include_expired.where(account_id: account.id).without_reblogs.with_personal_visibility
end
end

View File

@ -48,8 +48,13 @@ class Status < ApplicationRecord
update_index('statuses', :proper)
enum visibility: [:public, :unlisted, :private, :direct, :limited, :mutual], _suffix: :visibility
enum searchability: [:public, :unlisted, :private, :direct, :limited, :mutual], _suffix: :searchability
enum visibility: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, mutual: 100, personal: 200 }, _suffix: :visibility
enum searchability: { public: 0, unlisted: 1, private: 2, direct: 3, limited: 4, mutual: 100, personal: 200 }, _suffix: :searchability
STANDARD_VISIBILITY = %w(public unlisted private direct)
EXTRA_VISIBILITY = %w(limited personal)
PSEUDO_VISIBILITY = %w(mutual)
UNCOUNT_VISIBILITY = %w(direct personal)
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
@ -111,6 +116,8 @@ class Status < ApplicationRecord
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
scope :with_public_visibility, -> { where(visibility: :public) }
scope :with_personal_visibility, -> { where(visibility: :personal) }
scope :without_personal_visibility, -> { where.not(visibility: :personal) }
scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) }
scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) }
scope :mentioned_with, ->(account) { joins(:mentions).where(mentions: { account_id: account }) }
@ -189,6 +196,22 @@ class Status < ApplicationRecord
searchability || Status.searchabilities.invert.fetch([Account.searchabilities[account.searchability], Status.visibilities[visibility] || 0].max, nil) || 'direct'
end
def standard_visibility?
STANDARD_VISIBILITY.include?(visibility)
end
def extra_visibility?
EXTRA_VISIBILITY.include?(visibility)
end
def pseudo_visibility?
PSEUDO_VISIBILITY.include?(visibility)
end
def uncount_visibility?
UNCOUNT_VISIBILITY.include?(visibility)
end
def reply?
!in_reply_to_id.nil? || attributes['reply']
end
@ -412,7 +435,7 @@ class Status < ApplicationRecord
end
def selectable_searchabilities
searchabilities.keys - %w(unlisted limited mutual)
searchabilities.keys - %w(unlisted limited mutual personal)
end
def favourites_map(status_ids, account_id)
@ -628,7 +651,7 @@ class Status < ApplicationRecord
end
def increment_counter_caches
return if direct_visibility?
return if uncount_visibility?
account&.increment_count!(:statuses_count)
reblog&.increment_count!(:reblogs_count) if reblog?
@ -636,7 +659,7 @@ class Status < ApplicationRecord
end
def decrement_counter_caches
return if direct_visibility?
return if uncount_visibility?
account&.decrement_count!(:statuses_count)
reblog&.decrement_count!(:reblogs_count) if reblog?
@ -644,7 +667,7 @@ class Status < ApplicationRecord
end
def unlink_from_conversations
return unless direct_visibility?
return if uncount_visibility?
mentioned_accounts = (association(:mentions).loaded? ? mentions : mentions.includes(:account)).map(&:account)
inbox_owners = mentioned_accounts.select(&:local?) + (account.local? ? [account] : [])

View File

@ -130,7 +130,7 @@ class User < ApplicationRecord
:show_follow_button_on_timeline, :show_subscribe_button_on_timeline, :show_followed_by, :show_target,
:follow_button_to_list_adder, :show_navigation_panel, :show_quote_button, :show_bookmark_button,
:place_tab_bar_at_bottom,:show_tab_bar_label,
:enable_local_timeline, :enable_federated_timeline, :enable_limited_timeline,
:enable_local_timeline, :enable_federated_timeline, :enable_limited_timeline, :enable_personal_timeline,
:enable_reaction, :compact_reaction,
:show_reply_tree_button,
:hide_statuses_count, :hide_following_count, :hide_followers_count, :disable_joke_appearance,
@ -145,7 +145,7 @@ class User < ApplicationRecord
:show_reload_button, :default_column_width,
:disable_post, :disable_reactions, :disable_follow, :disable_unfollow, :disable_block, :disable_domain_block, :disable_clear_all_notifications, :disable_account_delete,
:prohibited_visibilities, :prohibited_words,
:disable_relative_time,
:disable_relative_time, :hide_direct_from_timeline, :hide_personal_from_timeline, :hide_personal_from_account,
to: :settings, prefix: :setting, allow_nil: false

View File

@ -18,7 +18,9 @@ class StatusPolicy < ApplicationPolicy
return false if author.suspended?
return false unless expired_show?
if requires_mention?
if personal?
owned?
elsif requires_mention?
owned? || mention_exists?
elsif private?
owned? || following_author? || mention_exists?
@ -85,6 +87,10 @@ class StatusPolicy < ApplicationPolicy
record.limited_visibility?
end
def personal?
record.personal_visibility?
end
def mention_exists?
return false if current_account.nil?

View File

@ -55,6 +55,7 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:show_tab_bar_label] = object.current_account.user.setting_show_tab_bar_label
store[:enable_federated_timeline] = object.current_account.user.setting_enable_federated_timeline
store[:enable_limited_timeline] = object.current_account.user.setting_enable_limited_timeline
store[:enable_personal_timeline] = object.current_account.user.setting_enable_personal_timeline
store[:enable_local_timeline] = false #object.current_account.user.setting_enable_local_timeline
store[:enable_reaction] = object.current_account.user.setting_enable_reaction
store[:compact_reaction] = object.current_account.user.setting_compact_reaction
@ -87,6 +88,9 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:disable_clear_all_notifications] = object.current_account.user.setting_disable_clear_all_notifications
store[:disable_account_delete] = object.current_account.user.setting_disable_account_delete
store[:disable_relative_time] = object.current_account.user.setting_disable_relative_time
store[:hide_direct_from_timeline] = object.current_account.user.setting_hide_direct_from_timeline
store[:hide_personal_from_timeline] = object.current_account.user.setting_hide_personal_from_timeline
store[:hide_personal_from_account] = object.current_account.user.setting_hide_personal_from_account
else
store[:auto_play_gif] = Setting.auto_play_gif
store[:display_media] = Setting.display_media

View File

@ -139,6 +139,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:timeline_group_directory,
:visibility_mutual,
:visibility_limited,
:visibility_personal,
:emoji_reaction,
:misskey_birthday,
:misskey_location,

View File

@ -102,7 +102,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
def visibility_ex?
object.limited_visibility?
!object.standard_visibility?
end
def visibility
@ -111,6 +111,8 @@ class REST::StatusSerializer < ActiveModel::Serializer
# UX differences
if object.limited_visibility?
'private'
elsif object.personal_visibility?
'direct'
else
object.visibility
end

View File

@ -6,9 +6,12 @@ class FanOutOnWriteService < BaseService
def call(status)
raise Mastodon::RaceConditionError if status.visibility.nil?
deliver_to_self(status) if status.account.local?
deliver_to_self(status) if status.account.local? && !(status.direct_visibility? && status.account.user.setting_hide_direct_from_timeline)
if status.direct_visibility?
if status.personal_visibility?
deliver_to_self_included_lists(status) if status.account.local? && !status.account.user.setting_hide_personal_from_timeline
return
elsif status.direct_visibility?
deliver_to_mentioned_followers(status)
deliver_to_own_conversation(status)
elsif status.limited_visibility?
@ -283,4 +286,10 @@ class FanOutOnWriteService < BaseService
def deliver_to_own_conversation(status)
AccountConversation.add_status(status.account, status)
end
def deliver_to_self_included_lists(status)
FeedInsertWorker.push_bulk(status.account.self_included_lists.pluck(:id)) do |list_id|
[status.id, list_id, :list]
end
end
end

View File

@ -120,7 +120,7 @@ class PostStatusService < BaseService
end
ProcessHashtagsService.new.call(@status)
ProcessMentionsService.new.call(@status, @circle)
ProcessMentionsService.new.call(@status, @circle) unless @status.personal_visibility?
ProcessStatusReferenceService.new.call(@status, status_reference_ids: (@options[:status_reference_ids] || []) + [@quote_id], urls: @options[:status_reference_urls])
end
@ -144,7 +144,7 @@ class PostStatusService < BaseService
def postprocess_status!
LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text?
DistributionWorker.perform_async(@status.id)
ActivityPub::DistributionWorker.perform_async(@status.id)
ActivityPub::DistributionWorker.perform_async(@status.id) unless @status.personal_visibility?
PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
@status.status_expire.queue_action if expires_soon?
end

View File

@ -37,7 +37,7 @@ class ReblogService < BaseService
end
end
raise Mastodon::ValidationError, I18n.t('status_prohibit.validations.prohibited_visibilities') if account.user&.setting_prohibited_visibilities&.filter(&:present?)&.include?(visibility)
raise Mastodon::ValidationError, I18n.t('status_prohibit.validations.prohibited_visibilities') if account.user&.setting_prohibited_visibilities&.filter(&:present?)&.include?(visibility) || visibility == 'personal'
ApplicationRecord.transaction do
reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])

View File

@ -95,6 +95,9 @@
.fields-group
= f.input :setting_enable_limited_timeline, as: :boolean, wrapper: :with_label, fedibird_features: true
.fields-group
= f.input :setting_enable_personal_timeline, as: :boolean, wrapper: :with_label, fedibird_features: true
.fields-group
= f.input :setting_enable_reaction, as: :boolean, wrapper: :with_label, fedibird_features: true
@ -119,6 +122,15 @@
.fields-group
= f.input :setting_disable_relative_time, as: :boolean, wrapper: :with_label, fedibird_features: true
.fields-group
= f.input :setting_hide_direct_from_timeline, as: :boolean, wrapper: :with_label, fedibird_features: true
.fields-group
= f.input :setting_hide_personal_from_timeline, as: :boolean, wrapper: :with_label, fedibird_features: true
.fields-group
= f.input :setting_hide_personal_from_account, as: :boolean, wrapper: :with_label, fedibird_features: true
%h4= t 'preferences.public_timelines'
.fields-group

View File

@ -1464,6 +1464,8 @@ en:
limited_long: Only show to circle users
mutual: Mutual
mutual_long: Only show to mutual followers
personal: Personal
personal_long: Only show to personal
private: Followers-only
private_long: Only show to followers
public: Public

View File

@ -1401,6 +1401,8 @@ ja:
limited_long: サークルで指定したユーザーのみ閲覧可
mutual: 相互フォロー限定
mutual_long: 相互フォロー相手にのみ表示されます
personal: 自分限定
personal_long: 自分にのみ表示されます
private: フォロワー限定
private_long: フォロワーにのみ表示されます
public: 公開

View File

@ -87,14 +87,18 @@ en:
setting_enable_federated_timeline: 'Enable a federated timeline (default: enable)'
setting_enable_limited_timeline: Enable a limited home to display private and circle and direct message
setting_enable_local_timeline: 'Enable a local timeline (default: enable)'
setting_enable_personal_timeline: Enable a personal home to display personal post
setting_enable_reaction: Enable the reaction display on the timeline and display the reaction button
setting_enable_status_reference: Enable the feature where a post references another post
setting_follow_button_to_list_adder: Change the behavior of the Follow / Subscribe button, open a dialog where you can select a list to follow / subscribe, or opt out of receiving at home
setting_hexagon_avatar: Display everyone's avatar icon as a hollowed out hexagon (joke feature)
setting_hide_bot_on_public_timeline: Disable Bot accounts from appearing on federation & hashtag & domain & group timelines (overridden by column setting)
setting_hide_direct_from_timeline: Hide direct messages from the home and list timelines
setting_hide_followers_count: The number of followers will be hidden in your profile
setting_hide_following_count: The number of following will be hidden in your profile
setting_hide_network: Who you follow and who follows you will be hidden on your profile
setting_hide_personal_from_account: Hide personal posts from the account post lists
setting_hide_personal_from_timeline: Hide personal posts from the home and list timelines
setting_hide_statuses_count: The number of post will be hidden in your profile
setting_match_visibility_of_references: If the referenced post is private, default the visibility of the post to private behavior accordingly
setting_new_features_policy: Set the acceptance policy when new features are added to Fedibird. The recommended setting will enable many new features, so set it to disabled if it is not desirable
@ -274,15 +278,19 @@ en:
setting_enable_federated_timeline: Enable federated timeline
setting_enable_limited_timeline: Enable limited timeline
setting_enable_local_timeline: Enable local timeline
setting_enable_personal_timeline: Enable personal timeline
setting_enable_reaction: Enable reaction
setting_enable_status_reference: Enable reference
setting_expand_spoilers: Always expand posts marked with content warnings
setting_follow_button_to_list_adder: Open list add dialog with follow button
setting_hexagon_avatar: Experience NFT Avatar
setting_hide_bot_on_public_timeline: Hide bot account on public timeline
setting_hide_direct_from_timeline: Hide direct messages from the timeline
setting_hide_followers_count: Hide your followers count
setting_hide_following_count: Hide your following count
setting_hide_network: Hide your social graph
setting_hide_personal_from_account: Hide personal posts from account posts
setting_hide_personal_from_timeline: Hide personal posts from the timeline
setting_hide_statuses_count: Hide your post count
setting_info_font_size: Information header font size
setting_match_visibility_of_references: Match the visibility of the post to the references

View File

@ -83,14 +83,18 @@ ja:
setting_enable_federated_timeline: 連合タイムラインを有効にします(デフォルト)
setting_enable_limited_timeline: フォロワー限定・サークル・ダイレクトメッセージを表示する限定ホームを有効にします
setting_enable_local_timeline: ローカルタイムラインを有効にします(デフォルト)
setting_enable_personal_timeline: 自分限定を表示する自分限定ホームを有効にします
setting_enable_reaction: タイムラインでリアクションの表示を有効にし、リアクションボタンを表示する
setting_enable_status_reference: 投稿が別の投稿を参照する機能を有効にします
setting_follow_button_to_list_adder: フォロー・購読ボタンの動作を変更し、フォロー・購読するリストを選択したり、ホームで受け取らないよう設定するダイアログを開きます
setting_hexagon_avatar: 全員のアバターアイコンを6角形にくりぬいて表示しますジョーク機能
setting_hide_bot_on_public_timeline: 連合・ハッシュタグ・ドメイン・グループタイムライン上にBotアカウントが表示されないようにします※カラム設定を優先
setting_hide_direct_from_timeline: ホームとリストタイムラインからダイレクトメッセージを隠します
setting_hide_followers_count: フォロワー数をプロフィールページで見られないようにします
setting_hide_following_count: フォロー数をプロフィールページで見られないようにします
setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします
setting_hide_personal_from_account: アカウントの投稿一覧から個人限定投稿を隠します
setting_hide_personal_from_timeline: ホームとリストタイムラインから個人限定投稿を隠します
setting_hide_statuses_count: 投稿数をプロフィールページで見られないようにします
setting_match_visibility_of_references: 参照先の投稿がフォロワー限定の場合、投稿の公開範囲をそれに合わせてフォロワー限定とする動作をデフォルトにします
setting_new_features_policy: Fedibirdに新しい機能が追加された時の受け入れポリシーを設定します。推奨設定は多くの新機能を有効にするので、望ましくない場合は無効に設定してください
@ -270,15 +274,19 @@ ja:
setting_enable_federated_timeline: 連合タイムラインを有効にする
setting_enable_limited_timeline: 限定ホームを有効にする
setting_enable_local_timeline: ローカルタイムラインを有効にする
setting_enable_personal_timeline: 自分限定ホームを有効にする
setting_enable_reaction: リアクションを有効にする
setting_enable_status_reference: 参照を有効にする
setting_expand_spoilers: 閲覧注意としてマークされた投稿を常に展開する
setting_follow_button_to_list_adder: フォローボタンでリスト追加ダイアログを開く
setting_hexagon_avatar: NFTアイコンを体験する
setting_hide_bot_on_public_timeline: 公開タイムラインのBotアカウントを非表示
setting_hide_direct_from_timeline: ダイレクトメッセージをタイムラインから隠す
setting_hide_followers_count: フォロワー数を隠す
setting_hide_following_count: フォロー数を隠す
setting_hide_network: 繋がりを隠す
setting_hide_personal_from_account: 自分限定投稿をアカウントの投稿一覧から隠す
setting_hide_personal_from_timeline: 自分限定投稿をタイムラインから隠す
setting_hide_statuses_count: 投稿数を隠す
setting_info_font_size: 情報ヘッダのフォントサイズ
setting_match_visibility_of_references: 投稿の公開範囲を参照先に合わせる

View File

@ -379,6 +379,7 @@ Rails.application.routes.draw do
resources :tag, only: :show
resources :list, only: :show
resources :group, only: :show
resource :personal, only: :show, controller: :personal
end
resources :streaming, only: [:index]

View File

@ -55,6 +55,8 @@ defaults: &defaults
show_tab_bar_label: false
enable_federated_timeline: true
enable_limited_timeline: false
enable_personal_timeline: false
enable_personal_account: false
enable_local_timeline: true
enable_reaction: true
compact_reaction: false
@ -129,6 +131,8 @@ defaults: &defaults
prohibited_visibilities: []
prohibited_words: ''
disable_relative_time: false
hide_direct_from_timeline: false
hide_personal_from_timeline: false
development:
<<: *defaults

View File

@ -0,0 +1,11 @@
class AddPersonalTimelineIndex < ActiveRecord::Migration[6.1]
disable_ddl_transaction!
def up
safety_assured { add_index :statuses, [:account_id, :id], where: 'visibility = 200 AND deleted_at IS NULL AND reblog_of_id IS NULL', order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_personal_timeline }
end
def down
remove_index :statuses, name: :index_statuses_personal_timeline
end
end

View File

@ -1007,6 +1007,7 @@ ActiveRecord::Schema.define(version: 2023_01_29_193248) do
t.index ["account_id", "id"], name: "index_statuses_private_searchable", order: { id: :desc }, where: "((deleted_at IS NULL) AND (expired_at IS NULL) AND (reblog_of_id IS NULL) AND (searchability = ANY (ARRAY[0, 1, 2])))"
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["id", "account_id"], name: "index_statuses_public_20200119", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["account_id", "id"], name: "index_statuses_personal_timeline", order: :desc, where: "((visibility = 200) AND (deleted_at IS NULL) AND (reblog_of_id IS NULL))"
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id"
t.index ["quote_id"], name: "index_statuses_on_quote_id"