mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-11-30 09:49:03 +01:00
Merge pull request #2256 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
commit
b052a7ed95
136 changed files with 1434 additions and 959 deletions
|
@ -318,7 +318,6 @@ RSpec/LetSetup:
|
||||||
- 'spec/controllers/api/v1/admin/accounts_controller_spec.rb'
|
- 'spec/controllers/api/v1/admin/accounts_controller_spec.rb'
|
||||||
- 'spec/controllers/api/v1/filters_controller_spec.rb'
|
- 'spec/controllers/api/v1/filters_controller_spec.rb'
|
||||||
- 'spec/controllers/api/v1/followed_tags_controller_spec.rb'
|
- 'spec/controllers/api/v1/followed_tags_controller_spec.rb'
|
||||||
- 'spec/controllers/api/v1/tags_controller_spec.rb'
|
|
||||||
- 'spec/controllers/api/v2/admin/accounts_controller_spec.rb'
|
- 'spec/controllers/api/v2/admin/accounts_controller_spec.rb'
|
||||||
- 'spec/controllers/api/v2/filters/keywords_controller_spec.rb'
|
- 'spec/controllers/api/v2/filters/keywords_controller_spec.rb'
|
||||||
- 'spec/controllers/api/v2/filters/statuses_controller_spec.rb'
|
- 'spec/controllers/api/v2/filters/statuses_controller_spec.rb'
|
||||||
|
@ -440,45 +439,6 @@ RSpec/SubjectStub:
|
||||||
- 'spec/services/unallow_domain_service_spec.rb'
|
- 'spec/services/unallow_domain_service_spec.rb'
|
||||||
- 'spec/validators/blacklisted_email_validator_spec.rb'
|
- 'spec/validators/blacklisted_email_validator_spec.rb'
|
||||||
|
|
||||||
# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames.
|
|
||||||
RSpec/VerifiedDoubles:
|
|
||||||
Exclude:
|
|
||||||
- 'spec/controllers/admin/change_emails_controller_spec.rb'
|
|
||||||
- 'spec/controllers/admin/confirmations_controller_spec.rb'
|
|
||||||
- 'spec/controllers/admin/disputes/appeals_controller_spec.rb'
|
|
||||||
- 'spec/controllers/admin/domain_allows_controller_spec.rb'
|
|
||||||
- 'spec/controllers/admin/domain_blocks_controller_spec.rb'
|
|
||||||
- 'spec/controllers/api/v1/reports_controller_spec.rb'
|
|
||||||
- 'spec/controllers/api/web/embeds_controller_spec.rb'
|
|
||||||
- 'spec/controllers/auth/sessions_controller_spec.rb'
|
|
||||||
- 'spec/controllers/disputes/appeals_controller_spec.rb'
|
|
||||||
- 'spec/helpers/statuses_helper_spec.rb'
|
|
||||||
- 'spec/lib/suspicious_sign_in_detector_spec.rb'
|
|
||||||
- 'spec/models/account/field_spec.rb'
|
|
||||||
- 'spec/models/session_activation_spec.rb'
|
|
||||||
- 'spec/models/setting_spec.rb'
|
|
||||||
- 'spec/services/account_search_service_spec.rb'
|
|
||||||
- 'spec/services/post_status_service_spec.rb'
|
|
||||||
- 'spec/services/search_service_spec.rb'
|
|
||||||
- 'spec/validators/blacklisted_email_validator_spec.rb'
|
|
||||||
- 'spec/validators/disallowed_hashtags_validator_spec.rb'
|
|
||||||
- 'spec/validators/email_mx_validator_spec.rb'
|
|
||||||
- 'spec/validators/follow_limit_validator_spec.rb'
|
|
||||||
- 'spec/validators/note_length_validator_spec.rb'
|
|
||||||
- 'spec/validators/poll_validator_spec.rb'
|
|
||||||
- 'spec/validators/status_length_validator_spec.rb'
|
|
||||||
- 'spec/validators/status_pin_validator_spec.rb'
|
|
||||||
- 'spec/validators/unique_username_validator_spec.rb'
|
|
||||||
- 'spec/validators/unreserved_username_validator_spec.rb'
|
|
||||||
- 'spec/validators/url_validator_spec.rb'
|
|
||||||
- 'spec/views/statuses/show.html.haml_spec.rb'
|
|
||||||
- 'spec/workers/activitypub/processing_worker_spec.rb'
|
|
||||||
- 'spec/workers/admin/domain_purge_worker_spec.rb'
|
|
||||||
- 'spec/workers/domain_block_worker_spec.rb'
|
|
||||||
- 'spec/workers/domain_clear_media_worker_spec.rb'
|
|
||||||
- 'spec/workers/feed_insert_worker_spec.rb'
|
|
||||||
- 'spec/workers/regeneration_worker_spec.rb'
|
|
||||||
|
|
||||||
# This cop supports unsafe autocorrection (--autocorrect-all).
|
# This cop supports unsafe autocorrection (--autocorrect-all).
|
||||||
Rails/ApplicationController:
|
Rails/ApplicationController:
|
||||||
Exclude:
|
Exclude:
|
||||||
|
@ -759,7 +719,6 @@ Rails/WhereExists:
|
||||||
- 'app/workers/move_worker.rb'
|
- 'app/workers/move_worker.rb'
|
||||||
- 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb'
|
- 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb'
|
||||||
- 'lib/tasks/tests.rake'
|
- 'lib/tasks/tests.rake'
|
||||||
- 'spec/controllers/api/v1/tags_controller_spec.rb'
|
|
||||||
- 'spec/models/account_spec.rb'
|
- 'spec/models/account_spec.rb'
|
||||||
- 'spec/services/activitypub/process_collection_service_spec.rb'
|
- 'spec/services/activitypub/process_collection_service_spec.rb'
|
||||||
- 'spec/services/purge_domain_service_spec.rb'
|
- 'spec/services/purge_domain_service_spec.rb'
|
||||||
|
|
|
@ -106,7 +106,7 @@ GEM
|
||||||
aws-sdk-kms (1.67.0)
|
aws-sdk-kms (1.67.0)
|
||||||
aws-sdk-core (~> 3, >= 3.174.0)
|
aws-sdk-core (~> 3, >= 3.174.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.125.0)
|
aws-sdk-s3 (1.126.0)
|
||||||
aws-sdk-core (~> 3, >= 3.174.0)
|
aws-sdk-core (~> 3, >= 3.174.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.4)
|
aws-sigv4 (~> 1.4)
|
||||||
|
|
|
@ -28,6 +28,7 @@ module Admin
|
||||||
authorize :webhook, :create?
|
authorize :webhook, :create?
|
||||||
|
|
||||||
@webhook = Webhook.new(resource_params)
|
@webhook = Webhook.new(resource_params)
|
||||||
|
@webhook.current_account = current_account
|
||||||
|
|
||||||
if @webhook.save
|
if @webhook.save
|
||||||
redirect_to admin_webhook_path(@webhook)
|
redirect_to admin_webhook_path(@webhook)
|
||||||
|
@ -39,10 +40,12 @@ module Admin
|
||||||
def update
|
def update
|
||||||
authorize @webhook, :update?
|
authorize @webhook, :update?
|
||||||
|
|
||||||
|
@webhook.current_account = current_account
|
||||||
|
|
||||||
if @webhook.update(resource_params)
|
if @webhook.update(resource_params)
|
||||||
redirect_to admin_webhook_path(@webhook)
|
redirect_to admin_webhook_path(@webhook)
|
||||||
else
|
else
|
||||||
render :show
|
render :edit
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,11 @@ class Api::V1::ConversationsController < Api::BaseController
|
||||||
render json: @conversation, serializer: REST::ConversationSerializer
|
render json: @conversation, serializer: REST::ConversationSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def unread
|
||||||
|
@conversation.update!(unread: true)
|
||||||
|
render json: @conversation, serializer: REST::ConversationSerializer
|
||||||
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@conversation.destroy!
|
@conversation.destroy!
|
||||||
render_empty
|
render_empty
|
||||||
|
|
|
@ -8,11 +8,15 @@ class Api::V1::Statuses::HistoriesController < Api::BaseController
|
||||||
|
|
||||||
def show
|
def show
|
||||||
cache_if_unauthenticated!
|
cache_if_unauthenticated!
|
||||||
render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer
|
render json: status_edits, each_serializer: REST::StatusEditSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def status_edits
|
||||||
|
@status.edits.includes(:account, status: [:account]).to_a.presence || [@status.build_snapshot(at_time: @status.edited_at || @status.created_at)]
|
||||||
|
end
|
||||||
|
|
||||||
def set_status
|
def set_status
|
||||||
@status = Status.find(params[:status_id])
|
@status = Status.find(params[:status_id])
|
||||||
authorize @status, :show?
|
authorize @status, :show?
|
||||||
|
|
|
@ -18,6 +18,14 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def next_path
|
||||||
|
api_v2_admin_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue?
|
||||||
|
end
|
||||||
|
|
||||||
|
def prev_path
|
||||||
|
api_v2_admin_accounts_url(pagination_params(min_id: pagination_since_id)) unless @accounts.empty?
|
||||||
|
end
|
||||||
|
|
||||||
def filtered_accounts
|
def filtered_accounts
|
||||||
AccountFilter.new(translated_filter_params).results
|
AccountFilter.new(translated_filter_params).results
|
||||||
end
|
end
|
||||||
|
|
|
@ -24,13 +24,4 @@ module SettingsHelper
|
||||||
safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ')
|
safe_join([image_tag(account.avatar.url, width: 15, height: 15, alt: display_name(account), class: 'avatar'), content_tag(:span, account.acct, class: 'username')], ' ')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def picture_hint(hint, picture)
|
|
||||||
if picture.original_filename.nil?
|
|
||||||
hint
|
|
||||||
else
|
|
||||||
link = link_to t('generic.delete'), settings_profile_picture_path(picture.name.to_s), data: { method: :delete }
|
|
||||||
safe_join([hint, link], '<br/>'.html_safe)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import ShortNumber from 'flavours/glitch/components/short_number';
|
|
||||||
|
|
||||||
export default class AutosuggestHashtag extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
tag: PropTypes.shape({
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
url: PropTypes.string,
|
|
||||||
history: PropTypes.array,
|
|
||||||
}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { tag } = this.props;
|
|
||||||
const weeklyUses = tag.history && (
|
|
||||||
<ShortNumber
|
|
||||||
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='autosuggest-hashtag'>
|
|
||||||
<div className='autosuggest-hashtag__name'>
|
|
||||||
#<strong>{tag.name}</strong>
|
|
||||||
</div>
|
|
||||||
{tag.history !== undefined && (
|
|
||||||
<div className='autosuggest-hashtag__uses'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='autosuggest_hashtag.per_week'
|
|
||||||
defaultMessage='{count} per week'
|
|
||||||
values={{ count: weeklyUses }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import ShortNumber from 'flavours/glitch/components/short_number';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tag: {
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
history?: Array<{
|
||||||
|
uses: number;
|
||||||
|
accounts: string;
|
||||||
|
day: string;
|
||||||
|
}>;
|
||||||
|
following?: boolean;
|
||||||
|
type: 'hashtag';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
|
||||||
|
const weeklyUses = tag.history && (
|
||||||
|
<ShortNumber
|
||||||
|
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='autosuggest-hashtag'>
|
||||||
|
<div className='autosuggest-hashtag__name'>
|
||||||
|
#<strong>{tag.name}</strong>
|
||||||
|
</div>
|
||||||
|
{tag.history !== undefined && (
|
||||||
|
<div className='autosuggest-hashtag__uses'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='autosuggest_hashtag.per_week'
|
||||||
|
defaultMessage='{count} per week'
|
||||||
|
values={{ count: weeklyUses }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -8,9 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
|
||||||
|
|
||||||
import AutosuggestEmoji from './autosuggest_emoji';
|
import AutosuggestEmoji from './autosuggest_emoji';
|
||||||
import AutosuggestHashtag from './autosuggest_hashtag';
|
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
||||||
let word;
|
let word;
|
||||||
|
|
|
@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize';
|
||||||
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
|
||||||
|
|
||||||
import AutosuggestEmoji from './autosuggest_emoji';
|
import AutosuggestEmoji from './autosuggest_emoji';
|
||||||
import AutosuggestHashtag from './autosuggest_hashtag';
|
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||||
|
|
||||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||||
let word;
|
let word;
|
||||||
|
|
|
@ -15,13 +15,14 @@ import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/col
|
||||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||||
import StatusList from 'flavours/glitch/components/status_list';
|
import StatusList from 'flavours/glitch/components/status_list';
|
||||||
import Column from 'flavours/glitch/features/ui/components/column';
|
import Column from 'flavours/glitch/features/ui/components/column';
|
||||||
|
import { getStatusList } from 'flavours/glitch/selectors';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
|
statusIds: getStatusList(state, 'bookmarks'),
|
||||||
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
|
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
|
||||||
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
|
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
|
||||||
});
|
});
|
||||||
|
|
|
@ -142,11 +142,8 @@ class CommunityTimeline extends PureComponent {
|
||||||
<ColumnSettingsContainer columnId={columnId} />
|
<ColumnSettingsContainer columnId={columnId} />
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|
||||||
<DismissableBanner id='community_timeline'>
|
|
||||||
<FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} />
|
|
||||||
</DismissableBanner>
|
|
||||||
|
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
|
prepend={<DismissableBanner id='community_timeline'><FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} /></DismissableBanner>}
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`community_timeline-${columnId}`}
|
scrollKey={`community_timeline-${columnId}`}
|
||||||
timelineId={`community${onlyMedia ? ':media' : ''}`}
|
timelineId={`community${onlyMedia ? ':media' : ''}`}
|
||||||
|
|
|
@ -391,7 +391,7 @@ class EmojiPickerDropdown extends PureComponent {
|
||||||
{button || <img
|
{button || <img
|
||||||
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
||||||
alt='🙂'
|
alt='🙂'
|
||||||
src={`${assetHost}/emoji/1f602.svg`}
|
src={`${assetHost}/emoji/1f642.svg`}
|
||||||
/>}
|
/>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,7 @@ class Links extends PureComponent {
|
||||||
|
|
||||||
const banner = (
|
const banner = (
|
||||||
<DismissableBanner id='explore/links'>
|
<DismissableBanner id='explore/links'>
|
||||||
<FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These news stories are being talked about by people on this and other servers of the decentralized network right now.' />
|
<FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.' />
|
||||||
</DismissableBanner>
|
</DismissableBanner>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -11,9 +11,10 @@ import { debounce } from 'lodash';
|
||||||
import { fetchTrendingStatuses, expandTrendingStatuses } from 'flavours/glitch/actions/trends';
|
import { fetchTrendingStatuses, expandTrendingStatuses } from 'flavours/glitch/actions/trends';
|
||||||
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
|
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
|
||||||
import StatusList from 'flavours/glitch/components/status_list';
|
import StatusList from 'flavours/glitch/components/status_list';
|
||||||
|
import { getStatusList } from 'flavours/glitch/selectors';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
statusIds: state.getIn(['status_lists', 'trending', 'items']),
|
statusIds: getStatusList(state, 'trending'),
|
||||||
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
|
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
|
||||||
hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
|
hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
|
||||||
});
|
});
|
||||||
|
@ -46,7 +47,7 @@ class Statuses extends PureComponent {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DismissableBanner id='explore/statuses'>
|
<DismissableBanner id='explore/statuses'>
|
||||||
<FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These posts from this and other servers in the decentralized network are gaining traction on this server right now.' />
|
<FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favourites are ranked higher.' />
|
||||||
</DismissableBanner>
|
</DismissableBanner>
|
||||||
|
|
||||||
<StatusList
|
<StatusList
|
||||||
|
|
|
@ -36,7 +36,7 @@ class Tags extends PureComponent {
|
||||||
|
|
||||||
const banner = (
|
const banner = (
|
||||||
<DismissableBanner id='explore/tags'>
|
<DismissableBanner id='explore/tags'>
|
||||||
<FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These hashtags are gaining traction among people on this and other servers of the decentralized network right now.' />
|
<FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.' />
|
||||||
</DismissableBanner>
|
</DismissableBanner>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -15,13 +15,14 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/glit
|
||||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
import ColumnHeader from 'flavours/glitch/components/column_header';
|
||||||
import StatusList from 'flavours/glitch/components/status_list';
|
import StatusList from 'flavours/glitch/components/status_list';
|
||||||
import Column from 'flavours/glitch/features/ui/components/column';
|
import Column from 'flavours/glitch/features/ui/components/column';
|
||||||
|
import { getStatusList } from 'flavours/glitch/selectors';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
|
heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
|
statusIds: getStatusList(state, 'favourites'),
|
||||||
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
|
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
|
||||||
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
|
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
|
||||||
|
import background from 'mastodon/../images/friends-cropped.png';
|
||||||
|
|
||||||
|
|
||||||
|
export const ExplorePrompt = () => (
|
||||||
|
<DismissableBanner id='home.explore_prompt'>
|
||||||
|
<img src={background} alt='' className='dismissable-banner__background-image' />
|
||||||
|
|
||||||
|
<h1><FormattedMessage id='home.explore_prompt.title' defaultMessage='This is your home base within Mastodon.' /></h1>
|
||||||
|
<p><FormattedMessage id='home.explore_prompt.body' defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:" /></p>
|
||||||
|
|
||||||
|
<div className='dismissable-banner__message__actions'>
|
||||||
|
<Link to='/explore' className='button'><FormattedMessage id='home.actions.go_to_explore' defaultMessage="See what's trending" /></Link>
|
||||||
|
<Link to='/explore/suggestions' className='button button-tertiary'><FormattedMessage id='home.actions.go_to_suggestions' defaultMessage='Find people to follow' /></Link>
|
||||||
|
</div>
|
||||||
|
</DismissableBanner>
|
||||||
|
);
|
|
@ -5,35 +5,59 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/actions/announcements';
|
import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/actions/announcements';
|
||||||
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
|
|
||||||
import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
|
|
||||||
import Column from 'flavours/glitch/components/column';
|
|
||||||
import ColumnHeader from 'flavours/glitch/components/column_header';
|
|
||||||
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
|
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
|
||||||
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
|
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
|
||||||
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
|
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
|
||||||
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
|
import { me } from 'flavours/glitch/initial_state';
|
||||||
|
|
||||||
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
|
import { expandHomeTimeline } from '../../actions/timelines';
|
||||||
|
import Column from '../../components/column';
|
||||||
|
import ColumnHeader from '../../components/column_header';
|
||||||
|
import StatusListContainer from '../ui/containers/status_list_container';
|
||||||
|
|
||||||
|
import { ExplorePrompt } from './components/explore_prompt';
|
||||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||||
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||||
show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
|
show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
|
||||||
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
|
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getHomeFeedSpeed = createSelector([
|
||||||
|
state => state.getIn(['timelines', 'home', 'items'], ImmutableList()),
|
||||||
|
state => state.get('statuses'),
|
||||||
|
], (statusIds, statusMap) => {
|
||||||
|
const statuses = statusIds.map(id => statusMap.get(id)).filter(status => status.get('account') !== me).take(20);
|
||||||
|
const oldest = new Date(statuses.getIn([statuses.size - 1, 'created_at'], 0));
|
||||||
|
const newest = new Date(statuses.getIn([0, 'created_at'], 0));
|
||||||
|
const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds
|
||||||
|
|
||||||
|
return {
|
||||||
|
gap: averageGap,
|
||||||
|
newest,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const homeTooSlow = createSelector(getHomeFeedSpeed, speed =>
|
||||||
|
speed.gap > (30 * 60) // If the average gap between posts is more than 20 minutes
|
||||||
|
|| (Date.now() - speed.newest) > (1000 * 3600) // If the most recent post is from over an hour ago
|
||||||
|
);
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
|
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
|
||||||
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
|
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
|
||||||
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
|
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
|
||||||
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
|
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
|
||||||
showAnnouncements: state.getIn(['announcements', 'show']),
|
showAnnouncements: state.getIn(['announcements', 'show']),
|
||||||
|
tooSlow: homeTooSlow(state),
|
||||||
regex: state.getIn(['settings', 'home', 'regex', 'body']),
|
regex: state.getIn(['settings', 'home', 'regex', 'body']),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -53,6 +77,7 @@ class HomeTimeline extends PureComponent {
|
||||||
hasAnnouncements: PropTypes.bool,
|
hasAnnouncements: PropTypes.bool,
|
||||||
unreadAnnouncements: PropTypes.number,
|
unreadAnnouncements: PropTypes.number,
|
||||||
showAnnouncements: PropTypes.bool,
|
showAnnouncements: PropTypes.bool,
|
||||||
|
tooSlow: PropTypes.bool,
|
||||||
regex: PropTypes.string,
|
regex: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -123,11 +148,11 @@ class HomeTimeline extends PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
const { signedIn } = this.context.identity;
|
const { signedIn } = this.context.identity;
|
||||||
|
|
||||||
let announcementsButton = null;
|
let announcementsButton, banner;
|
||||||
|
|
||||||
if (hasAnnouncements) {
|
if (hasAnnouncements) {
|
||||||
announcementsButton = (
|
announcementsButton = (
|
||||||
|
@ -142,6 +167,10 @@ class HomeTimeline extends PureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tooSlow) {
|
||||||
|
banner = <ExplorePrompt />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}>
|
<Column bindToDocument={!multiColumn} ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
|
@ -161,11 +190,13 @@ class HomeTimeline extends PureComponent {
|
||||||
|
|
||||||
{signedIn ? (
|
{signedIn ? (
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
|
prepend={banner}
|
||||||
|
alwaysPrepend
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`home_timeline-${columnId}`}
|
scrollKey={`home_timeline-${columnId}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
timelineId='home'
|
timelineId='home'
|
||||||
emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />}
|
emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up.' />}
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
regex={this.props.regex}
|
regex={this.props.regex}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -8,17 +8,19 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { fetchPinnedStatuses } from 'flavours/glitch/actions/pin_statuses';
|
import { getStatusList } from 'flavours/glitch/selectors';
|
||||||
import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
|
|
||||||
import StatusList from 'flavours/glitch/components/status_list';
|
import { fetchPinnedStatuses } from '../../actions/pin_statuses';
|
||||||
import Column from 'flavours/glitch/features/ui/components/column';
|
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
||||||
|
import StatusList from '../../components/status_list';
|
||||||
|
import Column from '../ui/components/column';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.pins', defaultMessage: 'Pinned post' },
|
heading: { id: 'column.pins', defaultMessage: 'Pinned post' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
statusIds: state.getIn(['status_lists', 'pins', 'items']),
|
statusIds: getStatusList(state, 'pins'),
|
||||||
hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
|
hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -146,11 +146,8 @@ class PublicTimeline extends PureComponent {
|
||||||
<ColumnSettingsContainer columnId={columnId} />
|
<ColumnSettingsContainer columnId={columnId} />
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|
||||||
<DismissableBanner id='public_timeline'>
|
|
||||||
<FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' />
|
|
||||||
</DismissableBanner>
|
|
||||||
|
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
|
prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' /></DismissableBanner>}
|
||||||
timelineId={`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`}
|
timelineId={`public${onlyRemote ? ':remote' : (allowLocalOnly ? ':allow_local_only' : '')}${onlyMedia ? ':media' : ''}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { Link, withRouter } from 'react-router-dom';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
|
import { fetchServer } from 'flavours/glitch/actions/server';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { WordmarkLogo, SymbolLogo } from 'flavours/glitch/components/logo';
|
import { WordmarkLogo, SymbolLogo } from 'flavours/glitch/components/logo';
|
||||||
import Permalink from 'flavours/glitch/components/permalink';
|
import Permalink from 'flavours/glitch/components/permalink';
|
||||||
|
@ -29,6 +30,9 @@ const mapDispatchToProps = (dispatch) => ({
|
||||||
openClosedRegistrationsModal() {
|
openClosedRegistrationsModal() {
|
||||||
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
|
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
|
||||||
},
|
},
|
||||||
|
dispatchServer() {
|
||||||
|
dispatch(fetchServer());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
class Header extends PureComponent {
|
class Header extends PureComponent {
|
||||||
|
@ -41,8 +45,14 @@ class Header extends PureComponent {
|
||||||
openClosedRegistrationsModal: PropTypes.func,
|
openClosedRegistrationsModal: PropTypes.func,
|
||||||
location: PropTypes.object,
|
location: PropTypes.object,
|
||||||
signupUrl: PropTypes.string.isRequired,
|
signupUrl: PropTypes.string.isRequired,
|
||||||
|
dispatchServer: PropTypes.func
|
||||||
};
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatchServer } = this.props;
|
||||||
|
dispatchServer();
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { signedIn } = this.context.identity;
|
const { signedIn } = this.context.identity;
|
||||||
const { location, openClosedRegistrationsModal, signupUrl } = this.props;
|
const { location, openClosedRegistrationsModal, signupUrl } = this.props;
|
||||||
|
|
|
@ -137,3 +137,7 @@ export const getAccountHidden = createSelector([
|
||||||
], (hidden, followingOrRequested, isSelf) => {
|
], (hidden, followingOrRequested, isSelf) => {
|
||||||
return hidden && !(isSelf || followingOrRequested);
|
return hidden && !(isSelf || followingOrRequested);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getStatusList = createSelector([
|
||||||
|
(state, type) => state.getIn(['status_lists', type, 'items']),
|
||||||
|
], (items) => items.toList());
|
||||||
|
|
|
@ -960,26 +960,70 @@ $ui-header-height: 55px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dismissable-banner {
|
.dismissable-banner {
|
||||||
background: $ui-base-color;
|
position: relative;
|
||||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
margin: 10px;
|
||||||
display: flex;
|
margin-bottom: 5px;
|
||||||
align-items: center;
|
border-radius: 8px;
|
||||||
gap: 30px;
|
border: 1px solid $highlight-text-color;
|
||||||
|
background: rgba($highlight-text-color, 0.15);
|
||||||
|
padding-inline-end: 45px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__background-image {
|
||||||
|
width: 125%;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -25%;
|
||||||
|
inset-inline-end: -25%;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.15;
|
||||||
|
mix-blend-mode: luminosity;
|
||||||
|
}
|
||||||
|
|
||||||
&__message {
|
&__message {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
padding: 20px 15px;
|
padding: 15px;
|
||||||
cursor: default;
|
font-size: 15px;
|
||||||
font-size: 14px;
|
line-height: 22px;
|
||||||
line-height: 18px;
|
font-weight: 500;
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 33px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-tertiary {
|
||||||
|
background: rgba($ui-base-color, 0.15);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__action {
|
&__action {
|
||||||
padding: 15px;
|
position: absolute;
|
||||||
flex: 0 0 auto;
|
inset-inline-end: 0;
|
||||||
display: flex;
|
top: 0;
|
||||||
align-items: center;
|
padding: 10px;
|
||||||
justify-content: center;
|
|
||||||
|
.icon-button {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -653,11 +653,6 @@ html {
|
||||||
border: 1px solid lighten($ui-base-color, 8%);
|
border: 1px solid lighten($ui-base-color, 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dismissable-banner {
|
|
||||||
border-left: 1px solid lighten($ui-base-color, 8%);
|
|
||||||
border-right: 1px solid lighten($ui-base-color, 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__content,
|
.status__content,
|
||||||
.reply-indicator__content {
|
.reply-indicator__content {
|
||||||
a {
|
a {
|
||||||
|
|
BIN
app/javascript/images/friends-cropped.png
Executable file
BIN
app/javascript/images/friends-cropped.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 189 KiB |
|
@ -1,6 +1,6 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
@ -49,6 +49,7 @@ class Account extends ImmutablePureComponent {
|
||||||
actionTitle: PropTypes.string,
|
actionTitle: PropTypes.string,
|
||||||
defaultAction: PropTypes.string,
|
defaultAction: PropTypes.string,
|
||||||
onActionClick: PropTypes.func,
|
onActionClick: PropTypes.func,
|
||||||
|
withBio: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -80,7 +81,7 @@ class Account extends ImmutablePureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props;
|
const { account, intl, hidden, withBio, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal } = this.props;
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return <EmptyAccount size={size} minimal={minimal} />;
|
return <EmptyAccount size={size} minimal={minimal} />;
|
||||||
|
@ -171,6 +172,15 @@ class Account extends ImmutablePureComponent {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{withBio && (account.get('note').length > 0 ? (
|
||||||
|
<div
|
||||||
|
className='account__note translate'
|
||||||
|
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='account__note account__note--missing'><FormattedMessage id='account.no_bio' defaultMessage='No description provided.' /></div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import ShortNumber from 'mastodon/components/short_number';
|
|
||||||
|
|
||||||
export default class AutosuggestHashtag extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
tag: PropTypes.shape({
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
url: PropTypes.string,
|
|
||||||
history: PropTypes.array,
|
|
||||||
}).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { tag } = this.props;
|
|
||||||
const weeklyUses = tag.history && (
|
|
||||||
<ShortNumber
|
|
||||||
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='autosuggest-hashtag'>
|
|
||||||
<div className='autosuggest-hashtag__name'>
|
|
||||||
#<strong>{tag.name}</strong>
|
|
||||||
</div>
|
|
||||||
{tag.history !== undefined && (
|
|
||||||
<div className='autosuggest-hashtag__uses'>
|
|
||||||
<FormattedMessage
|
|
||||||
id='autosuggest_hashtag.per_week'
|
|
||||||
defaultMessage='{count} per week'
|
|
||||||
values={{ count: weeklyUses }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
42
app/javascript/mastodon/components/autosuggest_hashtag.tsx
Normal file
42
app/javascript/mastodon/components/autosuggest_hashtag.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tag: {
|
||||||
|
name: string;
|
||||||
|
url?: string;
|
||||||
|
history?: Array<{
|
||||||
|
uses: number;
|
||||||
|
accounts: string;
|
||||||
|
day: string;
|
||||||
|
}>;
|
||||||
|
following?: boolean;
|
||||||
|
type: 'hashtag';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AutosuggestHashtag: React.FC<Props> = ({ tag }) => {
|
||||||
|
const weeklyUses = tag.history && (
|
||||||
|
<ShortNumber
|
||||||
|
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='autosuggest-hashtag'>
|
||||||
|
<div className='autosuggest-hashtag__name'>
|
||||||
|
#<strong>{tag.name}</strong>
|
||||||
|
</div>
|
||||||
|
{tag.history !== undefined && (
|
||||||
|
<div className='autosuggest-hashtag__uses'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='autosuggest_hashtag.per_week'
|
||||||
|
defaultMessage='{count} per week'
|
||||||
|
values={{ count: weeklyUses }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -8,7 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||||
|
|
||||||
import AutosuggestEmoji from './autosuggest_emoji';
|
import AutosuggestEmoji from './autosuggest_emoji';
|
||||||
import AutosuggestHashtag from './autosuggest_hashtag';
|
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||||
|
|
||||||
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
|
||||||
let word;
|
let word;
|
||||||
|
|
|
@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize';
|
||||||
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
|
||||||
|
|
||||||
import AutosuggestEmoji from './autosuggest_emoji';
|
import AutosuggestEmoji from './autosuggest_emoji';
|
||||||
import AutosuggestHashtag from './autosuggest_hashtag';
|
import { AutosuggestHashtag } from './autosuggest_hashtag';
|
||||||
|
|
||||||
const textAtCursorMatchesToken = (str, caretPosition) => {
|
const textAtCursorMatchesToken = (str, caretPosition) => {
|
||||||
let word;
|
let word;
|
||||||
|
|
|
@ -1,11 +1,27 @@
|
||||||
import { Icon } from './icon';
|
import { Icon } from './icon';
|
||||||
|
|
||||||
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
|
const stripRelMe = (html: string) => {
|
||||||
|
const document = domParser.parseFromString(html, 'text/html').documentElement;
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
|
||||||
|
link.rel = link.rel
|
||||||
|
.split(' ')
|
||||||
|
.filter((x: string) => x !== 'me')
|
||||||
|
.join(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = document.querySelector('body');
|
||||||
|
return body ? { __html: body.innerHTML } : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
link: string;
|
link: string;
|
||||||
}
|
}
|
||||||
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
||||||
<span className='verified-badge'>
|
<span className='verified-badge'>
|
||||||
<Icon id='check' className='verified-badge__mark' />
|
<Icon id='check' className='verified-badge__mark' />
|
||||||
<span dangerouslySetInnerHTML={{ __html: link }} />
|
<span dangerouslySetInnerHTML={stripRelMe(link)} />
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
@ -15,13 +15,14 @@ import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
|
||||||
import ColumnHeader from 'mastodon/components/column_header';
|
import ColumnHeader from 'mastodon/components/column_header';
|
||||||
import StatusList from 'mastodon/components/status_list';
|
import StatusList from 'mastodon/components/status_list';
|
||||||
import Column from 'mastodon/features/ui/components/column';
|
import Column from 'mastodon/features/ui/components/column';
|
||||||
|
import { getStatusList } from 'mastodon/selectors';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
|
statusIds: getStatusList(state, 'bookmarks'),
|
||||||
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
|
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
|
||||||
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
|
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
|
||||||
});
|
});
|
||||||
|
|
|
@ -140,11 +140,8 @@ class CommunityTimeline extends PureComponent {
|
||||||
<ColumnSettingsContainer columnId={columnId} />
|
<ColumnSettingsContainer columnId={columnId} />
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|
||||||
<DismissableBanner id='community_timeline'>
|
|
||||||
<FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} />
|
|
||||||
</DismissableBanner>
|
|
||||||
|
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
|
prepend={<DismissableBanner id='community_timeline'><FormattedMessage id='dismissable_banner.community_timeline' defaultMessage='These are the most recent public posts from people whose accounts are hosted by {domain}.' values={{ domain }} /></DismissableBanner>}
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`community_timeline-${columnId}`}
|
scrollKey={`community_timeline-${columnId}`}
|
||||||
timelineId={`community${onlyMedia ? ':media' : ''}`}
|
timelineId={`community${onlyMedia ? ':media' : ''}`}
|
||||||
|
|
|
@ -389,7 +389,7 @@ class EmojiPickerDropdown extends PureComponent {
|
||||||
{button || <img
|
{button || <img
|
||||||
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
className={classNames('emojione', { 'pulse-loading': active && loading })}
|
||||||
alt='🙂'
|
alt='🙂'
|
||||||
src={`${assetHost}/emoji/1f602.svg`}
|
src={`${assetHost}/emoji/1f642.svg`}
|
||||||
/>}
|
/>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,7 @@ class Links extends PureComponent {
|
||||||
|
|
||||||
const banner = (
|
const banner = (
|
||||||
<DismissableBanner id='explore/links'>
|
<DismissableBanner id='explore/links'>
|
||||||
<FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These news stories are being talked about by people on this and other servers of the decentralized network right now.' />
|
<FormattedMessage id='dismissable_banner.explore_links' defaultMessage='These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.' />
|
||||||
</DismissableBanner>
|
</DismissableBanner>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -11,9 +11,10 @@ import { debounce } from 'lodash';
|
||||||
import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
|
import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
|
||||||
import DismissableBanner from 'mastodon/components/dismissable_banner';
|
import DismissableBanner from 'mastodon/components/dismissable_banner';
|
||||||
import StatusList from 'mastodon/components/status_list';
|
import StatusList from 'mastodon/components/status_list';
|
||||||
|
import { getStatusList } from 'mastodon/selectors';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
statusIds: state.getIn(['status_lists', 'trending', 'items']),
|
statusIds: getStatusList(state, 'trending'),
|
||||||
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
|
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
|
||||||
hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
|
hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
|
||||||
});
|
});
|
||||||
|
@ -46,7 +47,7 @@ class Statuses extends PureComponent {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DismissableBanner id='explore/statuses'>
|
<DismissableBanner id='explore/statuses'>
|
||||||
<FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These posts from this and other servers in the decentralized network are gaining traction on this server right now.' />
|
<FormattedMessage id='dismissable_banner.explore_statuses' defaultMessage='These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favourites are ranked higher.' />
|
||||||
</DismissableBanner>
|
</DismissableBanner>
|
||||||
|
|
||||||
<StatusList
|
<StatusList
|
||||||
|
|
|
@ -34,7 +34,7 @@ class Tags extends PureComponent {
|
||||||
|
|
||||||
const banner = (
|
const banner = (
|
||||||
<DismissableBanner id='explore/tags'>
|
<DismissableBanner id='explore/tags'>
|
||||||
<FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These hashtags are gaining traction among people on this and other servers of the decentralized network right now.' />
|
<FormattedMessage id='dismissable_banner.explore_tags' defaultMessage='These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.' />
|
||||||
</DismissableBanner>
|
</DismissableBanner>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -15,13 +15,14 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/acti
|
||||||
import ColumnHeader from 'mastodon/components/column_header';
|
import ColumnHeader from 'mastodon/components/column_header';
|
||||||
import StatusList from 'mastodon/components/status_list';
|
import StatusList from 'mastodon/components/status_list';
|
||||||
import Column from 'mastodon/features/ui/components/column';
|
import Column from 'mastodon/features/ui/components/column';
|
||||||
|
import { getStatusList } from 'mastodon/selectors';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
|
heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
|
statusIds: getStatusList(state, 'favourites'),
|
||||||
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
|
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
|
||||||
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
|
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import background from 'mastodon/../images/friends-cropped.png';
|
||||||
|
|
||||||
|
import DismissableBanner from 'mastodon/components/dismissable_banner';
|
||||||
|
|
||||||
|
|
||||||
|
export const ExplorePrompt = () => (
|
||||||
|
<DismissableBanner id='home.explore_prompt'>
|
||||||
|
<img src={background} alt='' className='dismissable-banner__background-image' />
|
||||||
|
|
||||||
|
<h1><FormattedMessage id='home.explore_prompt.title' defaultMessage='This is your home base within Mastodon.' /></h1>
|
||||||
|
<p><FormattedMessage id='home.explore_prompt.body' defaultMessage="Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:" /></p>
|
||||||
|
|
||||||
|
<div className='dismissable-banner__message__actions'>
|
||||||
|
<Link to='/explore' className='button'><FormattedMessage id='home.actions.go_to_explore' defaultMessage="See what's trending" /></Link>
|
||||||
|
<Link to='/explore/suggestions' className='button button-tertiary'><FormattedMessage id='home.actions.go_to_suggestions' defaultMessage='Find people to follow' /></Link>
|
||||||
|
</div>
|
||||||
|
</DismissableBanner>
|
||||||
|
);
|
|
@ -5,14 +5,16 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
|
||||||
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
|
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
|
||||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||||
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
||||||
|
import { me } from 'mastodon/initial_state';
|
||||||
|
|
||||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import { expandHomeTimeline } from '../../actions/timelines';
|
import { expandHomeTimeline } from '../../actions/timelines';
|
||||||
|
@ -20,6 +22,7 @@ import Column from '../../components/column';
|
||||||
import ColumnHeader from '../../components/column_header';
|
import ColumnHeader from '../../components/column_header';
|
||||||
import StatusListContainer from '../ui/containers/status_list_container';
|
import StatusListContainer from '../ui/containers/status_list_container';
|
||||||
|
|
||||||
|
import { ExplorePrompt } from './components/explore_prompt';
|
||||||
import ColumnSettingsContainer from './containers/column_settings_container';
|
import ColumnSettingsContainer from './containers/column_settings_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -28,12 +31,33 @@ const messages = defineMessages({
|
||||||
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
|
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getHomeFeedSpeed = createSelector([
|
||||||
|
state => state.getIn(['timelines', 'home', 'items'], ImmutableList()),
|
||||||
|
state => state.get('statuses'),
|
||||||
|
], (statusIds, statusMap) => {
|
||||||
|
const statuses = statusIds.map(id => statusMap.get(id)).filter(status => status.get('account') !== me).take(20);
|
||||||
|
const oldest = new Date(statuses.getIn([statuses.size - 1, 'created_at'], 0));
|
||||||
|
const newest = new Date(statuses.getIn([0, 'created_at'], 0));
|
||||||
|
const averageGap = (newest - oldest) / (1000 * (statuses.size + 1)); // Average gap between posts on first page in seconds
|
||||||
|
|
||||||
|
return {
|
||||||
|
gap: averageGap,
|
||||||
|
newest,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const homeTooSlow = createSelector(getHomeFeedSpeed, speed =>
|
||||||
|
speed.gap > (30 * 60) // If the average gap between posts is more than 20 minutes
|
||||||
|
|| (Date.now() - speed.newest) > (1000 * 3600) // If the most recent post is from over an hour ago
|
||||||
|
);
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
|
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
|
||||||
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
|
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
|
||||||
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
|
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
|
||||||
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
|
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
|
||||||
showAnnouncements: state.getIn(['announcements', 'show']),
|
showAnnouncements: state.getIn(['announcements', 'show']),
|
||||||
|
tooSlow: homeTooSlow(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
class HomeTimeline extends PureComponent {
|
class HomeTimeline extends PureComponent {
|
||||||
|
@ -52,6 +76,7 @@ class HomeTimeline extends PureComponent {
|
||||||
hasAnnouncements: PropTypes.bool,
|
hasAnnouncements: PropTypes.bool,
|
||||||
unreadAnnouncements: PropTypes.number,
|
unreadAnnouncements: PropTypes.number,
|
||||||
showAnnouncements: PropTypes.bool,
|
showAnnouncements: PropTypes.bool,
|
||||||
|
tooSlow: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
handlePin = () => {
|
handlePin = () => {
|
||||||
|
@ -121,11 +146,11 @@ class HomeTimeline extends PureComponent {
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
const { intl, hasUnread, columnId, multiColumn, tooSlow, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
const { signedIn } = this.context.identity;
|
const { signedIn } = this.context.identity;
|
||||||
|
|
||||||
let announcementsButton = null;
|
let announcementsButton, banner;
|
||||||
|
|
||||||
if (hasAnnouncements) {
|
if (hasAnnouncements) {
|
||||||
announcementsButton = (
|
announcementsButton = (
|
||||||
|
@ -141,6 +166,10 @@ class HomeTimeline extends PureComponent {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tooSlow) {
|
||||||
|
banner = <ExplorePrompt />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
|
@ -160,11 +189,13 @@ class HomeTimeline extends PureComponent {
|
||||||
|
|
||||||
{signedIn ? (
|
{signedIn ? (
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
|
prepend={banner}
|
||||||
|
alwaysPrepend
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`home_timeline-${columnId}`}
|
scrollKey={`home_timeline-${columnId}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
timelineId='home'
|
timelineId='home'
|
||||||
emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up. {suggestions}' values={{ suggestions: <Link to='/start'><FormattedMessage id='empty_column.home.suggestions' defaultMessage='See some suggestions' /></Link> }} />}
|
emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! Follow more people to fill it up.' />}
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
/>
|
/>
|
||||||
) : <NotSignedInIndicator />}
|
) : <NotSignedInIndicator />}
|
||||||
|
|
|
@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
|
||||||
import { Check } from 'mastodon/components/check';
|
import { Check } from 'mastodon/components/check';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
|
||||||
|
import ArrowSmallRight from './arrow_small_right';
|
||||||
|
|
||||||
const Step = ({ label, description, icon, completed, onClick, href }) => {
|
const Step = ({ label, description, icon, completed, onClick, href }) => {
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
|
@ -15,11 +17,9 @@ const Step = ({ label, description, icon, completed, onClick, href }) => {
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{completed && (
|
<div className={completed ? 'onboarding__steps__item__progress' : 'onboarding__steps__item__go'}>
|
||||||
<div className='onboarding__steps__item__progress'>
|
{completed ? <Check /> : <ArrowSmallRight />}
|
||||||
<Check />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -12,20 +12,11 @@ import Column from 'mastodon/components/column';
|
||||||
import ColumnBackButton from 'mastodon/components/column_back_button';
|
import ColumnBackButton from 'mastodon/components/column_back_button';
|
||||||
import { EmptyAccount } from 'mastodon/components/empty_account';
|
import { EmptyAccount } from 'mastodon/components/empty_account';
|
||||||
import Account from 'mastodon/containers/account_container';
|
import Account from 'mastodon/containers/account_container';
|
||||||
import { me } from 'mastodon/initial_state';
|
|
||||||
import { makeGetAccount } from 'mastodon/selectors';
|
|
||||||
|
|
||||||
import ProgressIndicator from './components/progress_indicator';
|
const mapStateToProps = state => ({
|
||||||
|
|
||||||
const mapStateToProps = () => {
|
|
||||||
const getAccount = makeGetAccount();
|
|
||||||
|
|
||||||
return state => ({
|
|
||||||
account: getAccount(state, me),
|
|
||||||
suggestions: state.getIn(['suggestions', 'items']),
|
suggestions: state.getIn(['suggestions', 'items']),
|
||||||
isLoading: state.getIn(['suggestions', 'isLoading']),
|
isLoading: state.getIn(['suggestions', 'isLoading']),
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
class Follows extends PureComponent {
|
class Follows extends PureComponent {
|
||||||
|
|
||||||
|
@ -33,7 +24,6 @@ class Follows extends PureComponent {
|
||||||
onBack: PropTypes.func,
|
onBack: PropTypes.func,
|
||||||
dispatch: PropTypes.func.isRequired,
|
dispatch: PropTypes.func.isRequired,
|
||||||
suggestions: ImmutablePropTypes.list,
|
suggestions: ImmutablePropTypes.list,
|
||||||
account: ImmutablePropTypes.map,
|
|
||||||
isLoading: PropTypes.bool,
|
isLoading: PropTypes.bool,
|
||||||
multiColumn: PropTypes.bool,
|
multiColumn: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
@ -49,7 +39,7 @@ class Follows extends PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { onBack, isLoading, suggestions, account, multiColumn } = this.props;
|
const { onBack, isLoading, suggestions, multiColumn } = this.props;
|
||||||
|
|
||||||
let loadedContent;
|
let loadedContent;
|
||||||
|
|
||||||
|
@ -58,7 +48,7 @@ class Follows extends PureComponent {
|
||||||
} else if (suggestions.isEmpty()) {
|
} else if (suggestions.isEmpty()) {
|
||||||
loadedContent = <div className='follow-recommendations__empty'><FormattedMessage id='onboarding.follows.empty' defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.' /></div>;
|
loadedContent = <div className='follow-recommendations__empty'><FormattedMessage id='onboarding.follows.empty' defaultMessage='Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.' /></div>;
|
||||||
} else {
|
} else {
|
||||||
loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} />);
|
loadedContent = suggestions.map(suggestion => <Account id={suggestion.get('account')} key={suggestion.get('account')} withBio />);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -71,8 +61,6 @@ class Follows extends PureComponent {
|
||||||
<p><FormattedMessage id='onboarding.follows.lead' defaultMessage='You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!' /></p>
|
<p><FormattedMessage id='onboarding.follows.lead' defaultMessage='You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!' /></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProgressIndicator steps={7} completed={account.get('following_count') * 1} />
|
|
||||||
|
|
||||||
<div className='follow-recommendations'>
|
<div className='follow-recommendations'>
|
||||||
{loadedContent}
|
{loadedContent}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { closeOnboarding } from 'mastodon/actions/onboarding';
|
||||||
import Column from 'mastodon/features/ui/components/column';
|
import Column from 'mastodon/features/ui/components/column';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import { makeGetAccount } from 'mastodon/selectors';
|
import { makeGetAccount } from 'mastodon/selectors';
|
||||||
|
import { assetHost } from 'mastodon/utils/config';
|
||||||
|
|
||||||
import ArrowSmallRight from './components/arrow_small_right';
|
import ArrowSmallRight from './components/arrow_small_right';
|
||||||
import Step from './components/step';
|
import Step from './components/step';
|
||||||
|
@ -122,21 +123,22 @@ class Onboarding extends ImmutablePureComponent {
|
||||||
<div className='onboarding__steps'>
|
<div className='onboarding__steps'>
|
||||||
<Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
|
<Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
|
||||||
<Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
|
<Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
|
||||||
<Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' />} />
|
<Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' values={{ emoji: <img className='emojione' alt='🐘' src={`${assetHost}/emoji/1f418.svg`} /> }} />} />
|
||||||
<Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
|
<Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage='Want to skip right ahead?' /></p>
|
<p className='onboarding__lead'><FormattedMessage id='onboarding.start.skip' defaultMessage="Don't need help getting started?" /></p>
|
||||||
|
|
||||||
<div className='onboarding__links'>
|
<div className='onboarding__links'>
|
||||||
<Link to='/explore' className='onboarding__link'>
|
<Link to='/explore' className='onboarding__link'>
|
||||||
|
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
|
||||||
<ArrowSmallRight />
|
<ArrowSmallRight />
|
||||||
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='onboarding__footer'>
|
<Link to='/home' className='onboarding__link'>
|
||||||
<button className='link-button' onClick={this.handleClose}><FormattedMessage id='onboarding.actions.close' defaultMessage="Don't show this screen again" /></button>
|
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
|
||||||
|
<ArrowSmallRight />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -177,13 +177,13 @@ class Share extends PureComponent {
|
||||||
|
|
||||||
<div className='onboarding__links'>
|
<div className='onboarding__links'>
|
||||||
<Link to='/home' className='onboarding__link'>
|
<Link to='/home' className='onboarding__link'>
|
||||||
|
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
|
||||||
<ArrowSmallRight />
|
<ArrowSmallRight />
|
||||||
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Go to your home feed' />
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link to='/explore' className='onboarding__link'>
|
<Link to='/explore' className='onboarding__link'>
|
||||||
|
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
|
||||||
<ArrowSmallRight />
|
<ArrowSmallRight />
|
||||||
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
|
import { getStatusList } from 'mastodon/selectors';
|
||||||
|
|
||||||
import { fetchPinnedStatuses } from '../../actions/pin_statuses';
|
import { fetchPinnedStatuses } from '../../actions/pin_statuses';
|
||||||
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
|
||||||
import StatusList from '../../components/status_list';
|
import StatusList from '../../components/status_list';
|
||||||
|
@ -18,7 +20,7 @@ const messages = defineMessages({
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
statusIds: state.getIn(['status_lists', 'pins', 'items']),
|
statusIds: getStatusList(state, 'pins'),
|
||||||
hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
|
hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -142,11 +142,8 @@ class PublicTimeline extends PureComponent {
|
||||||
<ColumnSettingsContainer columnId={columnId} />
|
<ColumnSettingsContainer columnId={columnId} />
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|
||||||
<DismissableBanner id='public_timeline'>
|
|
||||||
<FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' />
|
|
||||||
</DismissableBanner>
|
|
||||||
|
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
|
prepend={<DismissableBanner id='public_timeline'><FormattedMessage id='dismissable_banner.public_timeline' defaultMessage='These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.' /></DismissableBanner>}
|
||||||
timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`}
|
timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`}
|
||||||
onLoadMore={this.handleLoadMore}
|
onLoadMore={this.handleLoadMore}
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { Link, withRouter } from 'react-router-dom';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
import { fetchServer } from 'mastodon/actions/server';
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo';
|
import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo';
|
||||||
import { registrationsOpen, me } from 'mastodon/initial_state';
|
import { registrationsOpen, me } from 'mastodon/initial_state';
|
||||||
|
@ -28,6 +29,9 @@ const mapDispatchToProps = (dispatch) => ({
|
||||||
openClosedRegistrationsModal() {
|
openClosedRegistrationsModal() {
|
||||||
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
|
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
|
||||||
},
|
},
|
||||||
|
dispatchServer() {
|
||||||
|
dispatch(fetchServer());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
class Header extends PureComponent {
|
class Header extends PureComponent {
|
||||||
|
@ -40,8 +44,14 @@ class Header extends PureComponent {
|
||||||
openClosedRegistrationsModal: PropTypes.func,
|
openClosedRegistrationsModal: PropTypes.func,
|
||||||
location: PropTypes.object,
|
location: PropTypes.object,
|
||||||
signupUrl: PropTypes.string.isRequired,
|
signupUrl: PropTypes.string.isRequired,
|
||||||
|
dispatchServer: PropTypes.func
|
||||||
};
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatchServer } = this.props;
|
||||||
|
dispatchServer();
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { signedIn } = this.context.identity;
|
const { signedIn } = this.context.identity;
|
||||||
const { location, openClosedRegistrationsModal, signupUrl } = this.props;
|
const { location, openClosedRegistrationsModal, signupUrl } = this.props;
|
||||||
|
|
|
@ -52,6 +52,7 @@
|
||||||
"account.mute_notifications_short": "Mute notifications",
|
"account.mute_notifications_short": "Mute notifications",
|
||||||
"account.mute_short": "Mute",
|
"account.mute_short": "Mute",
|
||||||
"account.muted": "Muted",
|
"account.muted": "Muted",
|
||||||
|
"account.no_bio": "No description provided.",
|
||||||
"account.open_original_page": "Open original page",
|
"account.open_original_page": "Open original page",
|
||||||
"account.posts": "Posts",
|
"account.posts": "Posts",
|
||||||
"account.posts_with_replies": "Posts and replies",
|
"account.posts_with_replies": "Posts and replies",
|
||||||
|
@ -197,9 +198,9 @@
|
||||||
"disabled_account_banner.text": "Your account {disabledAccount} is currently disabled.",
|
"disabled_account_banner.text": "Your account {disabledAccount} is currently disabled.",
|
||||||
"dismissable_banner.community_timeline": "These are the most recent public posts from people whose accounts are hosted by {domain}.",
|
"dismissable_banner.community_timeline": "These are the most recent public posts from people whose accounts are hosted by {domain}.",
|
||||||
"dismissable_banner.dismiss": "Dismiss",
|
"dismissable_banner.dismiss": "Dismiss",
|
||||||
"dismissable_banner.explore_links": "These news stories are being talked about by people on this and other servers of the decentralized network right now.",
|
"dismissable_banner.explore_links": "These are news stories being shared the most on the social web today. Newer news stories posted by more different people are ranked higher.",
|
||||||
"dismissable_banner.explore_statuses": "These posts from this and other servers in the decentralized network are gaining traction on this server right now.",
|
"dismissable_banner.explore_statuses": "These are posts from across the social web that are gaining traction today. Newer posts with more boosts and favourites are ranked higher.",
|
||||||
"dismissable_banner.explore_tags": "These hashtags are gaining traction among people on this and other servers of the decentralized network right now.",
|
"dismissable_banner.explore_tags": "These are hashtags that are gaining traction on the social web today. Hashtags that are used by more different people are ranked higher.",
|
||||||
"dismissable_banner.public_timeline": "These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.",
|
"dismissable_banner.public_timeline": "These are the most recent public posts from people on this and other servers of the decentralized network that this server knows about.",
|
||||||
"embed.instructions": "Embed this post on your website by copying the code below.",
|
"embed.instructions": "Embed this post on your website by copying the code below.",
|
||||||
"embed.preview": "Here is what it will look like:",
|
"embed.preview": "Here is what it will look like:",
|
||||||
|
@ -232,8 +233,7 @@
|
||||||
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
|
||||||
"empty_column.followed_tags": "You have not followed any hashtags yet. When you do, they will show up here.",
|
"empty_column.followed_tags": "You have not followed any hashtags yet. When you do, they will show up here.",
|
||||||
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
"empty_column.hashtag": "There is nothing in this hashtag yet.",
|
||||||
"empty_column.home": "Your home timeline is empty! Follow more people to fill it up. {suggestions}",
|
"empty_column.home": "Your home timeline is empty! Follow more people to fill it up.",
|
||||||
"empty_column.home.suggestions": "See some suggestions",
|
|
||||||
"empty_column.list": "There is nothing in this list yet. When members of this list publish new posts, they will appear here.",
|
"empty_column.list": "There is nothing in this list yet. When members of this list publish new posts, they will appear here.",
|
||||||
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
|
"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.mutes": "You haven't muted any users yet.",
|
||||||
|
@ -292,9 +292,13 @@
|
||||||
"hashtag.column_settings.tag_toggle": "Include additional tags for this column",
|
"hashtag.column_settings.tag_toggle": "Include additional tags for this column",
|
||||||
"hashtag.follow": "Follow hashtag",
|
"hashtag.follow": "Follow hashtag",
|
||||||
"hashtag.unfollow": "Unfollow hashtag",
|
"hashtag.unfollow": "Unfollow hashtag",
|
||||||
|
"home.actions.go_to_explore": "See what's trending",
|
||||||
|
"home.actions.go_to_suggestions": "Find people to follow",
|
||||||
"home.column_settings.basic": "Basic",
|
"home.column_settings.basic": "Basic",
|
||||||
"home.column_settings.show_reblogs": "Show boosts",
|
"home.column_settings.show_reblogs": "Show boosts",
|
||||||
"home.column_settings.show_replies": "Show replies",
|
"home.column_settings.show_replies": "Show replies",
|
||||||
|
"home.explore_prompt.body": "Your home feed will have a mix of posts from the hashtags you've chosen to follow, the people you've chosen to follow, and the posts they boost. It's looking pretty quiet right now, so how about:",
|
||||||
|
"home.explore_prompt.title": "This is your home base within Mastodon.",
|
||||||
"home.hide_announcements": "Hide announcements",
|
"home.hide_announcements": "Hide announcements",
|
||||||
"home.show_announcements": "Show announcements",
|
"home.show_announcements": "Show announcements",
|
||||||
"interaction_modal.description.favourite": "With an account on Mastodon, you can favourite this post to let the author know you appreciate it and save it for later.",
|
"interaction_modal.description.favourite": "With an account on Mastodon, you can favourite this post to let the author know you appreciate it and save it for later.",
|
||||||
|
@ -449,28 +453,27 @@
|
||||||
"notifications_permission_banner.title": "Never miss a thing",
|
"notifications_permission_banner.title": "Never miss a thing",
|
||||||
"onboarding.action.back": "Take me back",
|
"onboarding.action.back": "Take me back",
|
||||||
"onboarding.actions.back": "Take me back",
|
"onboarding.actions.back": "Take me back",
|
||||||
"onboarding.actions.close": "Don't show this screen again",
|
"onboarding.actions.go_to_explore": "Take me to trending",
|
||||||
"onboarding.actions.go_to_explore": "See what's trending",
|
"onboarding.actions.go_to_home": "Take me to my home feed",
|
||||||
"onboarding.actions.go_to_home": "Go to your home feed",
|
|
||||||
"onboarding.compose.template": "Hello #Mastodon!",
|
"onboarding.compose.template": "Hello #Mastodon!",
|
||||||
"onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.",
|
"onboarding.follows.empty": "Unfortunately, no results can be shown right now. You can try using search or browsing the explore page to find people to follow, or try again later.",
|
||||||
"onboarding.follows.lead": "You curate your own home feed. The more people you follow, the more active and interesting it will be. These profiles may be a good starting point—you can always unfollow them later!",
|
"onboarding.follows.lead": "Your home feed is the primary way to experience Mastodon. The more people you follow, the more active and interesting it will be. To get you started, here are some suggestions:",
|
||||||
"onboarding.follows.title": "Popular on Mastodon",
|
"onboarding.follows.title": "Personalize your home feed",
|
||||||
"onboarding.share.lead": "Let people know how they can find you on Mastodon!",
|
"onboarding.share.lead": "Let people know how they can find you on Mastodon!",
|
||||||
"onboarding.share.message": "I'm {username} on #Mastodon! Come follow me at {url}",
|
"onboarding.share.message": "I'm {username} on #Mastodon! Come follow me at {url}",
|
||||||
"onboarding.share.next_steps": "Possible next steps:",
|
"onboarding.share.next_steps": "Possible next steps:",
|
||||||
"onboarding.share.title": "Share your profile",
|
"onboarding.share.title": "Share your profile",
|
||||||
"onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:",
|
"onboarding.start.lead": "You're now part of Mastodon, a unique, decentralized social media platform where you—not an algorithm—curate your own experience. Let's get you started on this new social frontier:",
|
||||||
"onboarding.start.skip": "Want to skip right ahead?",
|
"onboarding.start.skip": "Don't need help getting started?",
|
||||||
"onboarding.start.title": "You've made it!",
|
"onboarding.start.title": "You've made it!",
|
||||||
"onboarding.steps.follow_people.body": "You curate your own home feed. Let's fill it with interesting people.",
|
"onboarding.steps.follow_people.body": "Following interesting people is what Mastodon is all about.",
|
||||||
"onboarding.steps.follow_people.title": "Find at least {count, plural, one {one person} other {# people}} to follow",
|
"onboarding.steps.follow_people.title": "Personalize your home feed",
|
||||||
"onboarding.steps.publish_status.body": "Say hello to the world.",
|
"onboarding.steps.publish_status.body": "Say hello to the world with text, photos, videos, or polls {emoji}",
|
||||||
"onboarding.steps.publish_status.title": "Make your first post",
|
"onboarding.steps.publish_status.title": "Make your first post",
|
||||||
"onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.",
|
"onboarding.steps.setup_profile.body": "Boost your interactions by having a comprehensive profile.",
|
||||||
"onboarding.steps.setup_profile.title": "Customize your profile",
|
"onboarding.steps.setup_profile.title": "Personalize your profile",
|
||||||
"onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon!",
|
"onboarding.steps.share_profile.body": "Let your friends know how to find you on Mastodon",
|
||||||
"onboarding.steps.share_profile.title": "Share your profile",
|
"onboarding.steps.share_profile.title": "Share your Mastodon profile",
|
||||||
"onboarding.tips.2fa": "<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!",
|
"onboarding.tips.2fa": "<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!",
|
||||||
"onboarding.tips.accounts_from_other_servers": "<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!",
|
"onboarding.tips.accounts_from_other_servers": "<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!",
|
||||||
"onboarding.tips.migration": "<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!",
|
"onboarding.tips.migration": "<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!",
|
||||||
|
|
|
@ -137,3 +137,7 @@ export const getAccountHidden = createSelector([
|
||||||
], (hidden, followingOrRequested, isSelf) => {
|
], (hidden, followingOrRequested, isSelf) => {
|
||||||
return hidden && !(isSelf || followingOrRequested);
|
return hidden && !(isSelf || followingOrRequested);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getStatusList = createSelector([
|
||||||
|
(state, type) => state.getIn(['status_lists', type, 'items']),
|
||||||
|
], (items) => items.toList());
|
||||||
|
|
|
@ -653,11 +653,6 @@ html {
|
||||||
border: 1px solid lighten($ui-base-color, 8%);
|
border: 1px solid lighten($ui-base-color, 8%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dismissable-banner {
|
|
||||||
border-left: 1px solid lighten($ui-base-color, 8%);
|
|
||||||
border-right: 1px solid lighten($ui-base-color, 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.status__content,
|
.status__content,
|
||||||
.reply-indicator__content {
|
.reply-indicator__content {
|
||||||
a {
|
a {
|
||||||
|
|
|
@ -1514,12 +1514,37 @@ body > [data-popper-placement] {
|
||||||
}
|
}
|
||||||
|
|
||||||
&__note {
|
&__note {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 1;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
color: $ui-secondary-color;
|
margin-top: 10px;
|
||||||
|
color: $darker-text-color;
|
||||||
|
|
||||||
|
&--missing {
|
||||||
|
color: $dark-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&:active {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2617,13 +2642,15 @@ $ui-header-height: 55px;
|
||||||
.onboarding__link {
|
.onboarding__link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
color: $highlight-text-color;
|
color: $highlight-text-color;
|
||||||
background: lighten($ui-base-color, 4%);
|
background: lighten($ui-base-color, 4%);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px;
|
padding: 10px 15px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-size: 17px;
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
|
@ -2685,6 +2712,7 @@ $ui-header-height: 55px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
padding-inline-end: 15px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-align: start;
|
text-align: start;
|
||||||
|
@ -2697,14 +2725,14 @@ $ui-header-height: 55px;
|
||||||
|
|
||||||
&__icon {
|
&__icon {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
background: $ui-base-color;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
color: $dark-text-color;
|
color: $highlight-text-color;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
|
||||||
@media screen and (width >= 600px) {
|
@media screen and (width >= 600px) {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -2728,16 +2756,33 @@ $ui-header-height: 55px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__go {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
color: $highlight-text-color;
|
||||||
|
font-size: 17px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
height: 1.5em;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__description {
|
&__description {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
line-height: 18px;
|
line-height: 20px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
h6 {
|
h6 {
|
||||||
color: $primary-text-color;
|
color: $highlight-text-color;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
@ -8695,27 +8740,71 @@ noscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
.dismissable-banner {
|
.dismissable-banner {
|
||||||
background: $ui-base-color;
|
position: relative;
|
||||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
margin: 10px;
|
||||||
display: flex;
|
margin-bottom: 5px;
|
||||||
align-items: center;
|
border-radius: 8px;
|
||||||
gap: 30px;
|
border: 1px solid $highlight-text-color;
|
||||||
|
background: rgba($highlight-text-color, 0.15);
|
||||||
|
padding-inline-end: 45px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&__background-image {
|
||||||
|
width: 125%;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -25%;
|
||||||
|
inset-inline-end: -25%;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.15;
|
||||||
|
mix-blend-mode: luminosity;
|
||||||
|
}
|
||||||
|
|
||||||
&__message {
|
&__message {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
padding: 20px 15px;
|
padding: 15px;
|
||||||
cursor: default;
|
font-size: 15px;
|
||||||
font-size: 14px;
|
line-height: 22px;
|
||||||
line-height: 18px;
|
font-weight: 500;
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 33px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-tertiary {
|
||||||
|
background: rgba($ui-base-color, 0.15);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__action {
|
&__action {
|
||||||
padding: 15px;
|
position: absolute;
|
||||||
flex: 0 0 auto;
|
inset-inline-end: 0;
|
||||||
display: flex;
|
top: 0;
|
||||||
align-items: center;
|
padding: 10px;
|
||||||
justify-content: center;
|
|
||||||
|
.icon-button {
|
||||||
|
color: $highlight-text-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -28,8 +28,9 @@ class RequestPool
|
||||||
end
|
end
|
||||||
|
|
||||||
MAX_IDLE_TIME = 30
|
MAX_IDLE_TIME = 30
|
||||||
WAIT_TIMEOUT = 5
|
|
||||||
MAX_POOL_SIZE = ENV.fetch('MAX_REQUEST_POOL_SIZE', 512).to_i
|
MAX_POOL_SIZE = ENV.fetch('MAX_REQUEST_POOL_SIZE', 512).to_i
|
||||||
|
REAPER_FREQUENCY = 30
|
||||||
|
WAIT_TIMEOUT = 5
|
||||||
|
|
||||||
class Connection
|
class Connection
|
||||||
attr_reader :site, :last_used_at, :created_at, :in_use, :dead, :fresh
|
attr_reader :site, :last_used_at, :created_at, :in_use, :dead, :fresh
|
||||||
|
@ -98,7 +99,7 @@ class RequestPool
|
||||||
|
|
||||||
def initialize
|
def initialize
|
||||||
@pool = ConnectionPool::SharedConnectionPool.new(size: MAX_POOL_SIZE, timeout: WAIT_TIMEOUT) { |site| Connection.new(site) }
|
@pool = ConnectionPool::SharedConnectionPool.new(size: MAX_POOL_SIZE, timeout: WAIT_TIMEOUT) { |site| Connection.new(site) }
|
||||||
@reaper = Reaper.new(self, 30)
|
@reaper = Reaper.new(self, REAPER_FREQUENCY)
|
||||||
@reaper.run
|
@reaper.run
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -79,7 +79,7 @@ class TextFormatter
|
||||||
cutoff = url[prefix.length..-1].length > 30
|
cutoff = url[prefix.length..-1].length > 30
|
||||||
|
|
||||||
<<~HTML.squish
|
<<~HTML.squish
|
||||||
<a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
|
<a href="#{h(url)}" target="_blank" rel="#{rel.join(' ')}" translate="no"><span class="invisible">#{h(prefix)}</span><span class="#{cutoff ? 'ellipsis' : ''}">#{h(display_url)}</span><span class="invisible">#{h(suffix)}</span></a>
|
||||||
HTML
|
HTML
|
||||||
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
|
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
|
||||||
h(entity[:url])
|
h(entity[:url])
|
||||||
|
@ -122,7 +122,7 @@ class TextFormatter
|
||||||
display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username
|
display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username
|
||||||
|
|
||||||
<<~HTML.squish
|
<<~HTML.squish
|
||||||
<span class="h-card"><a href="#{h(url)}" class="u-url mention">@<span>#{h(display_username)}</span></a></span>
|
<span class="h-card" translate="no"><a href="#{h(url)}" class="u-url mention">@<span>#{h(display_username)}</span></a></span>
|
||||||
HTML
|
HTML
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -32,14 +32,8 @@ class AccountConversation < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def participant_accounts
|
def participant_accounts
|
||||||
@participant_accounts ||= begin
|
@participant_accounts ||= Account.where(id: participant_account_ids).to_a
|
||||||
if participant_account_ids.empty?
|
@participant_accounts.presence || [account]
|
||||||
[account]
|
|
||||||
else
|
|
||||||
participants = Account.where(id: participant_account_ids).to_a
|
|
||||||
participants.empty? ? [account] : participants
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
|
|
@ -15,7 +15,7 @@ class UserSettings
|
||||||
setting :show_application, default: true
|
setting :show_application, default: true
|
||||||
setting :default_language, default: nil
|
setting :default_language, default: nil
|
||||||
setting :default_sensitive, default: false
|
setting :default_sensitive, default: false
|
||||||
setting :default_privacy, default: nil
|
setting :default_privacy, default: nil, in: %w(public unlisted private)
|
||||||
setting :default_content_type, default: 'text/plain'
|
setting :default_content_type, default: 'text/plain'
|
||||||
setting :hide_followers_count, default: false
|
setting :hide_followers_count, default: false
|
||||||
|
|
||||||
|
@ -79,7 +79,10 @@ class UserSettings
|
||||||
|
|
||||||
raise KeyError, "Undefined setting: #{key}" unless self.class.definition_for?(key)
|
raise KeyError, "Undefined setting: #{key}" unless self.class.definition_for?(key)
|
||||||
|
|
||||||
typecast_value = self.class.definition_for(key).type_cast(value)
|
setting_definition = self.class.definition_for(key)
|
||||||
|
typecast_value = setting_definition.type_cast(value)
|
||||||
|
|
||||||
|
raise ArgumentError, "Invalid value for setting #{key}: #{typecast_value}" if setting_definition.in.present? && setting_definition.in.exclude?(typecast_value)
|
||||||
|
|
||||||
if typecast_value.nil?
|
if typecast_value.nil?
|
||||||
@original_hash.delete(key)
|
@original_hash.delete(key)
|
||||||
|
|
|
@ -24,6 +24,8 @@ class Webhook < ApplicationRecord
|
||||||
status.updated
|
status.updated
|
||||||
).freeze
|
).freeze
|
||||||
|
|
||||||
|
attr_writer :current_account
|
||||||
|
|
||||||
scope :enabled, -> { where(enabled: true) }
|
scope :enabled, -> { where(enabled: true) }
|
||||||
|
|
||||||
validates :url, presence: true, url: true
|
validates :url, presence: true, url: true
|
||||||
|
@ -31,6 +33,7 @@ class Webhook < ApplicationRecord
|
||||||
validates :events, presence: true
|
validates :events, presence: true
|
||||||
|
|
||||||
validate :validate_events
|
validate :validate_events
|
||||||
|
validate :validate_permissions
|
||||||
validate :validate_template
|
validate :validate_template
|
||||||
|
|
||||||
before_validation :strip_events
|
before_validation :strip_events
|
||||||
|
@ -48,12 +51,31 @@ class Webhook < ApplicationRecord
|
||||||
update!(enabled: false)
|
update!(enabled: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def required_permissions
|
||||||
|
events.map { |event| Webhook.permission_for_event(event) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.permission_for_event(event)
|
||||||
|
case event
|
||||||
|
when 'account.approved', 'account.created', 'account.updated'
|
||||||
|
:manage_users
|
||||||
|
when 'report.created'
|
||||||
|
:manage_reports
|
||||||
|
when 'status.created', 'status.updated'
|
||||||
|
:view_devops
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def validate_events
|
def validate_events
|
||||||
errors.add(:events, :invalid) if events.any? { |e| EVENTS.exclude?(e) }
|
errors.add(:events, :invalid) if events.any? { |e| EVENTS.exclude?(e) }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def validate_permissions
|
||||||
|
errors.add(:events, :invalid_permissions) if defined?(@current_account) && required_permissions.any? { |permission| !@current_account.user_role.can?(permission) }
|
||||||
|
end
|
||||||
|
|
||||||
def validate_template
|
def validate_template
|
||||||
return if template.blank?
|
return if template.blank?
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@ class WebhookPolicy < ApplicationPolicy
|
||||||
end
|
end
|
||||||
|
|
||||||
def update?
|
def update?
|
||||||
role.can?(:manage_webhooks)
|
role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def enable?
|
def enable?
|
||||||
|
@ -30,6 +30,6 @@ class WebhookPolicy < ApplicationPolicy
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy?
|
def destroy?
|
||||||
role.can?(:manage_webhooks)
|
role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,6 +12,7 @@ class RemoveStatusService < BaseService
|
||||||
# @option [Boolean] :immediate
|
# @option [Boolean] :immediate
|
||||||
# @option [Boolean] :preserve
|
# @option [Boolean] :preserve
|
||||||
# @option [Boolean] :original_removed
|
# @option [Boolean] :original_removed
|
||||||
|
# @option [Boolean] :skip_streaming
|
||||||
def call(status, **options)
|
def call(status, **options)
|
||||||
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
|
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
|
||||||
@status = status
|
@status = status
|
||||||
|
@ -53,6 +54,9 @@ class RemoveStatusService < BaseService
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# The following FeedManager calls all do not result in redis publishes for
|
||||||
|
# streaming, as the `:update` option is false
|
||||||
|
|
||||||
def remove_from_self
|
def remove_from_self
|
||||||
FeedManager.instance.unpush_from_home(@account, @status)
|
FeedManager.instance.unpush_from_home(@account, @status)
|
||||||
FeedManager.instance.unpush_from_direct(@account, @status) if @status.direct_visibility?
|
FeedManager.instance.unpush_from_direct(@account, @status) if @status.direct_visibility?
|
||||||
|
@ -77,6 +81,8 @@ class RemoveStatusService < BaseService
|
||||||
# followers. Here we send a delete to actively mentioned accounts
|
# followers. Here we send a delete to actively mentioned accounts
|
||||||
# that may not follow the account
|
# that may not follow the account
|
||||||
|
|
||||||
|
return if skip_streaming?
|
||||||
|
|
||||||
@status.active_mentions.find_each do |mention|
|
@status.active_mentions.find_each do |mention|
|
||||||
redis.publish("timeline:#{mention.account_id}", @payload)
|
redis.publish("timeline:#{mention.account_id}", @payload)
|
||||||
end
|
end
|
||||||
|
@ -105,7 +111,7 @@ class RemoveStatusService < BaseService
|
||||||
# without us being able to do all the fancy stuff
|
# without us being able to do all the fancy stuff
|
||||||
|
|
||||||
@status.reblogs.rewhere(deleted_at: [nil, @status.deleted_at]).includes(:account).reorder(nil).find_each do |reblog|
|
@status.reblogs.rewhere(deleted_at: [nil, @status.deleted_at]).includes(:account).reorder(nil).find_each do |reblog|
|
||||||
RemoveStatusService.new.call(reblog, original_removed: true)
|
RemoveStatusService.new.call(reblog, original_removed: true, skip_streaming: skip_streaming?)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -116,6 +122,8 @@ class RemoveStatusService < BaseService
|
||||||
|
|
||||||
return unless @status.public_visibility?
|
return unless @status.public_visibility?
|
||||||
|
|
||||||
|
return if skip_streaming?
|
||||||
|
|
||||||
@status.tags.map(&:name).each do |hashtag|
|
@status.tags.map(&:name).each do |hashtag|
|
||||||
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
|
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
|
||||||
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
|
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
|
||||||
|
@ -125,6 +133,8 @@ class RemoveStatusService < BaseService
|
||||||
def remove_from_public
|
def remove_from_public
|
||||||
return unless @status.public_visibility?
|
return unless @status.public_visibility?
|
||||||
|
|
||||||
|
return if skip_streaming?
|
||||||
|
|
||||||
redis.publish('timeline:public', @payload)
|
redis.publish('timeline:public', @payload)
|
||||||
redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload)
|
redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload)
|
||||||
end
|
end
|
||||||
|
@ -132,6 +142,8 @@ class RemoveStatusService < BaseService
|
||||||
def remove_from_media
|
def remove_from_media
|
||||||
return unless @status.public_visibility?
|
return unless @status.public_visibility?
|
||||||
|
|
||||||
|
return if skip_streaming?
|
||||||
|
|
||||||
redis.publish('timeline:public:media', @payload)
|
redis.publish('timeline:public:media', @payload)
|
||||||
redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload)
|
redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload)
|
||||||
end
|
end
|
||||||
|
@ -151,4 +163,8 @@ class RemoveStatusService < BaseService
|
||||||
def permanently?
|
def permanently?
|
||||||
@options[:immediate] || !(@options[:preserve] || @status.reported?)
|
@options[:immediate] || !(@options[:preserve] || @status.reported?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def skip_streaming?
|
||||||
|
!!@options[:skip_streaming]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
= f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' }
|
= f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' }
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
= f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
|
= f.input :events, collection: Webhook::EVENTS, wrapper: :with_block_label, include_blank: false, as: :check_boxes, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li', disabled: Webhook::EVENTS.filter { |event| !current_user.role.can?(Webhook.permission_for_event(event)) }
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
= f.input :template, wrapper: :with_block_label, input_html: { placeholder: '{ "content": "Hello {{object.username}}" }' }
|
= f.input :template, wrapper: :with_block_label, input_html: { placeholder: '{ "content": "Hello {{object.username}}" }' }
|
||||||
|
|
|
@ -24,7 +24,7 @@ class Scheduler::UserCleanupScheduler
|
||||||
def clean_discarded_statuses!
|
def clean_discarded_statuses!
|
||||||
Status.unscoped.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses|
|
Status.unscoped.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses|
|
||||||
RemovalWorker.push_bulk(statuses) do |status|
|
RemovalWorker.push_bulk(statuses) do |status|
|
||||||
[status.id, { 'immediate' => true }]
|
[status.id, { 'immediate' => true, 'skip_streaming' => true }]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,12 +6,4 @@ end
|
||||||
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
|
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
|
||||||
|
|
||||||
require 'bundler/setup' # Set up gems listed in the Gemfile.
|
require 'bundler/setup' # Set up gems listed in the Gemfile.
|
||||||
require 'bootsnap' # Speed up boot time by caching expensive operations.
|
require 'bootsnap/setup' # Speed up boot time by caching expensive operations.
|
||||||
|
|
||||||
Bootsnap.setup(
|
|
||||||
cache_dir: File.expand_path('../tmp/cache', __dir__),
|
|
||||||
development_mode: ENV.fetch('RAILS_ENV', 'development') == 'development',
|
|
||||||
load_path_cache: true,
|
|
||||||
compile_cache_iseq: false,
|
|
||||||
compile_cache_yaml: false
|
|
||||||
)
|
|
||||||
|
|
|
@ -53,3 +53,7 @@ en:
|
||||||
position:
|
position:
|
||||||
elevated: cannot be higher than your current role
|
elevated: cannot be higher than your current role
|
||||||
own_role: cannot be changed with your current role
|
own_role: cannot be changed with your current role
|
||||||
|
webhook:
|
||||||
|
attributes:
|
||||||
|
events:
|
||||||
|
invalid_permissions: cannot include events you don't have the rights to
|
||||||
|
|
|
@ -82,6 +82,7 @@ namespace :api, format: false do
|
||||||
resources :conversations, only: [:index, :destroy] do
|
resources :conversations, only: [:index, :destroy] do
|
||||||
member do
|
member do
|
||||||
post :read
|
post :read
|
||||||
|
post :unread
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -55,6 +55,11 @@ class Sanitize
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
TRANSLATE_TRANSFORMER = lambda do |env|
|
||||||
|
node = env[:node]
|
||||||
|
node.remove_attribute('translate') unless node['translate'] == 'no'
|
||||||
|
end
|
||||||
|
|
||||||
UNSUPPORTED_HREF_TRANSFORMER = lambda do |env|
|
UNSUPPORTED_HREF_TRANSFORMER = lambda do |env|
|
||||||
return unless env[:node_name] == 'a'
|
return unless env[:node_name] == 'a'
|
||||||
|
|
||||||
|
@ -73,9 +78,9 @@ class Sanitize
|
||||||
elements: %w(p br span a abbr del pre blockquote code b strong u sub sup i em h1 h2 h3 h4 h5 ul ol li),
|
elements: %w(p br span a abbr del pre blockquote code b strong u sub sup i em h1 h2 h3 h4 h5 ul ol li),
|
||||||
|
|
||||||
attributes: {
|
attributes: {
|
||||||
'a' => %w(href rel class title),
|
'a' => %w(href rel class title translate),
|
||||||
'abbr' => %w(title),
|
'abbr' => %w(title),
|
||||||
'span' => %w(class),
|
'span' => %w(class translate),
|
||||||
'blockquote' => %w(cite),
|
'blockquote' => %w(cite),
|
||||||
'ol' => %w(start reversed),
|
'ol' => %w(start reversed),
|
||||||
'li' => %w(value),
|
'li' => %w(value),
|
||||||
|
@ -96,6 +101,7 @@ class Sanitize
|
||||||
transformers: [
|
transformers: [
|
||||||
CLASS_WHITELIST_TRANSFORMER,
|
CLASS_WHITELIST_TRANSFORMER,
|
||||||
IMG_TAG_TRANSFORMER,
|
IMG_TAG_TRANSFORMER,
|
||||||
|
TRANSLATE_TRANSFORMER,
|
||||||
UNSUPPORTED_HREF_TRANSFORMER,
|
UNSUPPORTED_HREF_TRANSFORMER,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -151,7 +157,7 @@ class Sanitize
|
||||||
MASTODON_OUTGOING ||= freeze_config MASTODON_STRICT.merge(
|
MASTODON_OUTGOING ||= freeze_config MASTODON_STRICT.merge(
|
||||||
attributes: merge(
|
attributes: merge(
|
||||||
MASTODON_STRICT[:attributes],
|
MASTODON_STRICT[:attributes],
|
||||||
'a' => %w(href rel class title target)
|
'a' => %w(href rel class title target translate)
|
||||||
),
|
),
|
||||||
|
|
||||||
add_attributes: {},
|
add_attributes: {},
|
||||||
|
@ -159,6 +165,7 @@ class Sanitize
|
||||||
transformers: [
|
transformers: [
|
||||||
CLASS_WHITELIST_TRANSFORMER,
|
CLASS_WHITELIST_TRANSFORMER,
|
||||||
IMG_TAG_TRANSFORMER,
|
IMG_TAG_TRANSFORMER,
|
||||||
|
TRANSLATE_TRANSFORMER,
|
||||||
UNSUPPORTED_HREF_TRANSFORMER,
|
UNSUPPORTED_HREF_TRANSFORMER,
|
||||||
LINK_REL_TRANSFORMER,
|
LINK_REL_TRANSFORMER,
|
||||||
LINK_TARGET_TRANSFORMER,
|
LINK_TARGET_TRANSFORMER,
|
||||||
|
|
|
@ -23,7 +23,8 @@ RSpec.describe Admin::ChangeEmailsController do
|
||||||
|
|
||||||
describe 'GET #update' do
|
describe 'GET #update' do
|
||||||
before do
|
before do
|
||||||
allow(UserMailer).to receive(:confirmation_instructions).and_return(double('email', deliver_later: nil))
|
allow(UserMailer).to receive(:confirmation_instructions)
|
||||||
|
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'returns http success' do
|
||||||
|
|
|
@ -38,7 +38,7 @@ RSpec.describe Admin::ConfirmationsController do
|
||||||
let!(:user) { Fabricate(:user, confirmed_at: confirmed_at) }
|
let!(:user) { Fabricate(:user, confirmed_at: confirmed_at) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(UserMailer).to receive(:confirmation_instructions) { double(:email, deliver_later: nil) }
|
allow(UserMailer).to receive(:confirmation_instructions) { instance_double(ActionMailer::MessageDelivery, deliver_later: nil) }
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when email is not confirmed' do
|
context 'when email is not confirmed' do
|
||||||
|
|
|
@ -19,7 +19,8 @@ RSpec.describe Admin::Disputes::AppealsController do
|
||||||
let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
|
let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(UserMailer).to receive(:appeal_approved).and_return(double('email', deliver_later: nil))
|
allow(UserMailer).to receive(:appeal_approved)
|
||||||
|
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
|
||||||
post :approve, params: { id: appeal.id }
|
post :approve, params: { id: appeal.id }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -40,7 +41,8 @@ RSpec.describe Admin::Disputes::AppealsController do
|
||||||
let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
|
let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(UserMailer).to receive(:appeal_rejected).and_return(double('email', deliver_later: nil))
|
allow(UserMailer).to receive(:appeal_rejected)
|
||||||
|
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
|
||||||
post :reject, params: { id: appeal.id }
|
post :reject, params: { id: appeal.id }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -37,7 +37,7 @@ RSpec.describe Admin::DomainAllowsController do
|
||||||
|
|
||||||
describe 'DELETE #destroy' do
|
describe 'DELETE #destroy' do
|
||||||
it 'disallows the domain' do
|
it 'disallows the domain' do
|
||||||
service = double(call: true)
|
service = instance_double(UnallowDomainService, call: true)
|
||||||
allow(UnallowDomainService).to receive(:new).and_return(service)
|
allow(UnallowDomainService).to receive(:new).and_return(service)
|
||||||
domain_allow = Fabricate(:domain_allow)
|
domain_allow = Fabricate(:domain_allow)
|
||||||
delete :destroy, params: { id: domain_allow.id }
|
delete :destroy, params: { id: domain_allow.id }
|
||||||
|
|
|
@ -213,7 +213,7 @@ RSpec.describe Admin::DomainBlocksController do
|
||||||
|
|
||||||
describe 'DELETE #destroy' do
|
describe 'DELETE #destroy' do
|
||||||
it 'unblocks the domain' do
|
it 'unblocks the domain' do
|
||||||
service = double(call: true)
|
service = instance_double(UnblockDomainService, call: true)
|
||||||
allow(UnblockDomainService).to receive(:new).and_return(service)
|
allow(UnblockDomainService).to receive(:new).and_return(service)
|
||||||
domain_block = Fabricate(:domain_block)
|
domain_block = Fabricate(:domain_block)
|
||||||
delete :destroy, params: { id: domain_block.id }
|
delete :destroy, params: { id: domain_block.id }
|
||||||
|
|
|
@ -62,17 +62,10 @@ describe Admin::Reports::ActionsController do
|
||||||
end
|
end
|
||||||
|
|
||||||
shared_examples 'common behavior' do
|
shared_examples 'common behavior' do
|
||||||
it 'closes the report' do
|
it 'closes the report and redirects' do
|
||||||
expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
|
expect { subject }.to mark_report_action_taken.and create_target_account_strike
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates a strike with the expected text' do
|
|
||||||
expect { subject }.to change { report.target_account.strikes.count }.by(1)
|
|
||||||
expect(report.target_account.strikes.last.text).to eq text
|
expect(report.target_account.strikes.last.text).to eq text
|
||||||
end
|
|
||||||
|
|
||||||
it 'redirects' do
|
|
||||||
subject
|
|
||||||
expect(response).to redirect_to(admin_reports_path)
|
expect(response).to redirect_to(admin_reports_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -81,20 +74,21 @@ describe Admin::Reports::ActionsController do
|
||||||
{ report_id: report.id }
|
{ report_id: report.id }
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'closes the report' do
|
it 'closes the report and redirects' do
|
||||||
expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
|
expect { subject }.to mark_report_action_taken.and create_target_account_strike
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates a strike with the expected text' do
|
|
||||||
expect { subject }.to change { report.target_account.strikes.count }.by(1)
|
|
||||||
expect(report.target_account.strikes.last.text).to eq ''
|
expect(report.target_account.strikes.last.text).to eq ''
|
||||||
end
|
|
||||||
|
|
||||||
it 'redirects' do
|
|
||||||
subject
|
|
||||||
expect(response).to redirect_to(admin_reports_path)
|
expect(response).to redirect_to(admin_reports_path)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def mark_report_action_taken
|
||||||
|
change { report.reload.action_taken? }.from(false).to(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_target_account_strike
|
||||||
|
change { report.target_account.strikes.count }.by(1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
shared_examples 'all action types' do
|
shared_examples 'all action types' do
|
||||||
|
|
|
@ -48,7 +48,7 @@ describe Admin::WebhooksController do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with an existing record' do
|
context 'with an existing record' do
|
||||||
let!(:webhook) { Fabricate :webhook }
|
let!(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) }
|
||||||
|
|
||||||
describe 'GET #show' do
|
describe 'GET #show' do
|
||||||
it 'returns http success and renders view' do
|
it 'returns http success and renders view' do
|
||||||
|
@ -82,7 +82,7 @@ describe Admin::WebhooksController do
|
||||||
end.to_not change(webhook, :url)
|
end.to_not change(webhook, :url)
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
expect(response).to have_http_status(:success)
|
||||||
expect(response).to render_template(:show)
|
expect(response).to render_template(:edit)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,55 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Api::V1::Admin::AccountActionsController do
|
|
||||||
render_views
|
|
||||||
|
|
||||||
let(:role) { UserRole.find_by(name: 'Moderator') }
|
|
||||||
let(:user) { Fabricate(:user, role: role) }
|
|
||||||
let(:scopes) { 'admin:read admin:write' }
|
|
||||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
|
|
||||||
let(:account) { Fabricate(:account) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
allow(controller).to receive(:doorkeeper_token) { token }
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'POST #create' do
|
|
||||||
context 'with type of disable' do
|
|
||||||
before do
|
|
||||||
post :create, params: { account_id: account.id, type: 'disable' }
|
|
||||||
end
|
|
||||||
|
|
||||||
it_behaves_like 'forbidden for wrong scope', 'write:statuses'
|
|
||||||
it_behaves_like 'forbidden for wrong role', ''
|
|
||||||
|
|
||||||
it 'returns http success' do
|
|
||||||
expect(response).to have_http_status(200)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'performs action against account' do
|
|
||||||
expect(account.reload.user_disabled?).to be true
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'logs action' do
|
|
||||||
log_item = Admin::ActionLog.last
|
|
||||||
|
|
||||||
expect(log_item).to_not be_nil
|
|
||||||
expect(log_item.action).to eq :disable
|
|
||||||
expect(log_item.account_id).to eq user.account_id
|
|
||||||
expect(log_item.target_id).to eq account.user.id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with no type' do
|
|
||||||
before do
|
|
||||||
post :create, params: { account_id: account.id }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns http unprocessable entity' do
|
|
||||||
expect(response).to have_http_status(422)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -18,6 +18,7 @@ RSpec.describe Api::V1::ConversationsController do
|
||||||
|
|
||||||
before do
|
before do
|
||||||
PostStatusService.new.call(other.account, text: 'Hey @alice', visibility: 'direct')
|
PostStatusService.new.call(other.account, text: 'Hey @alice', visibility: 'direct')
|
||||||
|
PostStatusService.new.call(user.account, text: 'Hey, nobody here', visibility: 'direct')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'returns http success' do
|
||||||
|
@ -33,7 +34,8 @@ RSpec.describe Api::V1::ConversationsController do
|
||||||
it 'returns conversations' do
|
it 'returns conversations' do
|
||||||
get :index
|
get :index
|
||||||
json = body_as_json
|
json = body_as_json
|
||||||
expect(json.size).to eq 1
|
expect(json.size).to eq 2
|
||||||
|
expect(json[0][:accounts].size).to eq 1
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with since_id' do
|
context 'with since_id' do
|
||||||
|
@ -41,7 +43,7 @@ RSpec.describe Api::V1::ConversationsController do
|
||||||
it 'returns conversations' do
|
it 'returns conversations' do
|
||||||
get :index, params: { since_id: Mastodon::Snowflake.id_at(1.hour.ago, with_random: false) }
|
get :index, params: { since_id: Mastodon::Snowflake.id_at(1.hour.ago, with_random: false) }
|
||||||
json = body_as_json
|
json = body_as_json
|
||||||
expect(json.size).to eq 1
|
expect(json.size).to eq 2
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -67,24 +67,13 @@ RSpec.describe Api::V1::NotificationsController do
|
||||||
get :index
|
get :index
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'returns expected notification types', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'includes reblog' do
|
expect(body_json_types).to include 'reblog'
|
||||||
expect(body_as_json.pluck(:type)).to include 'reblog'
|
expect(body_json_types).to include 'mention'
|
||||||
end
|
expect(body_json_types).to include 'favourite'
|
||||||
|
expect(body_json_types).to include 'follow'
|
||||||
it 'includes mention' do
|
|
||||||
expect(body_as_json.pluck(:type)).to include 'mention'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'includes favourite' do
|
|
||||||
expect(body_as_json.pluck(:type)).to include 'favourite'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'includes follow' do
|
|
||||||
expect(body_as_json.pluck(:type)).to include 'follow'
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -93,12 +82,14 @@ RSpec.describe Api::V1::NotificationsController do
|
||||||
get :index, params: { account_id: third.account.id }
|
get :index, params: { account_id: third.account.id }
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'returns only notifications from specified user', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
|
|
||||||
|
expect(body_json_account_ids.uniq).to eq [third.account.id.to_s]
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns only notifications from specified user' do
|
def body_json_account_ids
|
||||||
expect(body_as_json.map { |x| x[:account][:id] }.uniq).to eq [third.account.id.to_s]
|
body_as_json.map { |x| x[:account][:id] }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -107,27 +98,23 @@ RSpec.describe Api::V1::NotificationsController do
|
||||||
get :index, params: { account_id: 'foo' }
|
get :index, params: { account_id: 'foo' }
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'returns nothing', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns nothing' do
|
|
||||||
expect(body_as_json.size).to eq 0
|
expect(body_as_json.size).to eq 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'with excluded_types param' do
|
describe 'with exclude_types param' do
|
||||||
before do
|
before do
|
||||||
get :index, params: { exclude_types: %w(mention) }
|
get :index, params: { exclude_types: %w(mention) }
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'returns everything but excluded type', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns everything but excluded type' do
|
|
||||||
expect(body_as_json.size).to_not eq 0
|
expect(body_as_json.size).to_not eq 0
|
||||||
expect(body_as_json.pluck(:type).uniq).to_not include 'mention'
|
expect(body_json_types.uniq).to_not include 'mention'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -136,13 +123,15 @@ RSpec.describe Api::V1::NotificationsController do
|
||||||
get :index, params: { types: %w(mention) }
|
get :index, params: { types: %w(mention) }
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'returns only requested type', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
|
|
||||||
|
expect(body_json_types.uniq).to eq ['mention']
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns only requested type' do
|
def body_json_types
|
||||||
expect(body_as_json.pluck(:type).uniq).to eq ['mention']
|
body_as_json.pluck(:type)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -23,7 +23,8 @@ RSpec.describe Api::V1::ReportsController do
|
||||||
let(:rule_ids) { nil }
|
let(:rule_ids) { nil }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(AdminMailer).to receive(:new_report).and_return(double('email', deliver_later: nil))
|
allow(AdminMailer).to receive(:new_report)
|
||||||
|
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
|
||||||
post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward }
|
post :create, params: { status_ids: [status.id], account_id: target_account.id, comment: 'reasons', category: category, rule_ids: rule_ids, forward: forward }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ describe Api::V1::Statuses::HistoriesController do
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'returns http success' do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
|
expect(body_as_json.size).to_not be 0
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Api::V1::SuggestionsController do
|
|
||||||
render_views
|
|
||||||
|
|
||||||
let(:user) { Fabricate(:user) }
|
|
||||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: 'read write') }
|
|
||||||
|
|
||||||
before do
|
|
||||||
allow(controller).to receive(:doorkeeper_token) { token }
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'GET #index' do
|
|
||||||
let(:bob) { Fabricate(:account) }
|
|
||||||
let(:jeff) { Fabricate(:account) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
PotentialFriendshipTracker.record(user.account_id, bob.id, :reblog)
|
|
||||||
PotentialFriendshipTracker.record(user.account_id, jeff.id, :favourite)
|
|
||||||
|
|
||||||
get :index
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns http success' do
|
|
||||||
expect(response).to have_http_status(200)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns accounts' do
|
|
||||||
json = body_as_json
|
|
||||||
|
|
||||||
expect(json.size).to be >= 1
|
|
||||||
expect(json.pluck(:id)).to include(*[bob, jeff].map { |i| i.id.to_s })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -1,88 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
require 'rails_helper'
|
|
||||||
|
|
||||||
RSpec.describe Api::V1::TagsController do
|
|
||||||
render_views
|
|
||||||
|
|
||||||
let(:user) { Fabricate(:user) }
|
|
||||||
let(:scopes) { 'write:follows' }
|
|
||||||
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
|
|
||||||
|
|
||||||
before { allow(controller).to receive(:doorkeeper_token) { token } }
|
|
||||||
|
|
||||||
describe 'GET #show' do
|
|
||||||
before do
|
|
||||||
get :show, params: { id: name }
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with existing tag' do
|
|
||||||
let!(:tag) { Fabricate(:tag) }
|
|
||||||
let(:name) { tag.name }
|
|
||||||
|
|
||||||
it 'returns http success' do
|
|
||||||
expect(response).to have_http_status(:success)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with non-existing tag' do
|
|
||||||
let(:name) { 'hoge' }
|
|
||||||
|
|
||||||
it 'returns http success' do
|
|
||||||
expect(response).to have_http_status(:success)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'POST #follow' do
|
|
||||||
let!(:unrelated_tag) { Fabricate(:tag) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
TagFollow.create!(account: user.account, tag: unrelated_tag)
|
|
||||||
|
|
||||||
post :follow, params: { id: name }
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with existing tag' do
|
|
||||||
let!(:tag) { Fabricate(:tag) }
|
|
||||||
let(:name) { tag.name }
|
|
||||||
|
|
||||||
it 'returns http success' do
|
|
||||||
expect(response).to have_http_status(:success)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates follow' do
|
|
||||||
expect(TagFollow.where(tag: tag, account: user.account).exists?).to be true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'with non-existing tag' do
|
|
||||||
let(:name) { 'hoge' }
|
|
||||||
|
|
||||||
it 'returns http success' do
|
|
||||||
expect(response).to have_http_status(:success)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'creates follow' do
|
|
||||||
expect(TagFollow.where(tag: Tag.find_by!(name: name), account: user.account).exists?).to be true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe 'POST #unfollow' do
|
|
||||||
let!(:tag) { Fabricate(:tag, name: 'foo') }
|
|
||||||
let!(:tag_follow) { Fabricate(:tag_follow, account: user.account, tag: tag) }
|
|
||||||
|
|
||||||
before do
|
|
||||||
post :unfollow, params: { id: tag.name }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns http success' do
|
|
||||||
expect(response).to have_http_status(:success)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'removes the follow' do
|
|
||||||
expect(TagFollow.where(tag: tag, account: user.account).exists?).to be false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -55,5 +55,13 @@ RSpec.describe Api::V2::Admin::AccountsController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'with limit param' do
|
||||||
|
let(:params) { { limit: 1 } }
|
||||||
|
|
||||||
|
it 'sets the correct pagination headers' do
|
||||||
|
expect(response.headers['Link'].find_link(%w(rel next)).href).to eq api_v2_admin_accounts_url(limit: 1, max_id: admin_account.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -26,7 +26,7 @@ describe Api::Web::EmbedsController do
|
||||||
|
|
||||||
context 'when fails to find status' do
|
context 'when fails to find status' do
|
||||||
let(:url) { 'https://host.test/oembed.html' }
|
let(:url) { 'https://host.test/oembed.html' }
|
||||||
let(:service_instance) { double('fetch_oembed_service') }
|
let(:service_instance) { instance_double(FetchOEmbedService) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(FetchOEmbedService).to receive(:new) { service_instance }
|
allow(FetchOEmbedService).to receive(:new) { service_instance }
|
||||||
|
|
|
@ -127,7 +127,8 @@ RSpec.describe Auth::SessionsController do
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return(current_ip)
|
allow_any_instance_of(ActionDispatch::Request).to receive(:remote_ip).and_return(current_ip)
|
||||||
allow(UserMailer).to receive(:suspicious_sign_in).and_return(double('email', deliver_later!: nil))
|
allow(UserMailer).to receive(:suspicious_sign_in)
|
||||||
|
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later!: nil))
|
||||||
user.update(current_sign_in_at: 1.month.ago)
|
user.update(current_sign_in_at: 1.month.ago)
|
||||||
post :create, params: { user: { email: user.email, password: user.password } }
|
post :create, params: { user: { email: user.email, password: user.password } }
|
||||||
end
|
end
|
||||||
|
|
|
@ -28,7 +28,7 @@ describe AuthorizeInteractionsController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'renders error when account cant be found' do
|
it 'renders error when account cant be found' do
|
||||||
service = double
|
service = instance_double(ResolveAccountService)
|
||||||
allow(ResolveAccountService).to receive(:new).and_return(service)
|
allow(ResolveAccountService).to receive(:new).and_return(service)
|
||||||
allow(service).to receive(:call).with('missing@hostname').and_return(nil)
|
allow(service).to receive(:call).with('missing@hostname').and_return(nil)
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ describe AuthorizeInteractionsController do
|
||||||
|
|
||||||
it 'sets resource from url' do
|
it 'sets resource from url' do
|
||||||
account = Fabricate(:account)
|
account = Fabricate(:account)
|
||||||
service = double
|
service = instance_double(ResolveURLService)
|
||||||
allow(ResolveURLService).to receive(:new).and_return(service)
|
allow(ResolveURLService).to receive(:new).and_return(service)
|
||||||
allow(service).to receive(:call).with('http://example.com').and_return(account)
|
allow(service).to receive(:call).with('http://example.com').and_return(account)
|
||||||
|
|
||||||
|
@ -52,7 +52,7 @@ describe AuthorizeInteractionsController do
|
||||||
|
|
||||||
it 'sets resource from acct uri' do
|
it 'sets resource from acct uri' do
|
||||||
account = Fabricate(:account)
|
account = Fabricate(:account)
|
||||||
service = double
|
service = instance_double(ResolveAccountService)
|
||||||
allow(ResolveAccountService).to receive(:new).and_return(service)
|
allow(ResolveAccountService).to receive(:new).and_return(service)
|
||||||
allow(service).to receive(:call).with('found@hostname').and_return(account)
|
allow(service).to receive(:call).with('found@hostname').and_return(account)
|
||||||
|
|
||||||
|
@ -82,7 +82,7 @@ describe AuthorizeInteractionsController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'shows error when account not found' do
|
it 'shows error when account not found' do
|
||||||
service = double
|
service = instance_double(ResolveAccountService)
|
||||||
|
|
||||||
allow(ResolveAccountService).to receive(:new).and_return(service)
|
allow(ResolveAccountService).to receive(:new).and_return(service)
|
||||||
allow(service).to receive(:call).with('user@hostname').and_return(nil)
|
allow(service).to receive(:call).with('user@hostname').and_return(nil)
|
||||||
|
@ -94,7 +94,7 @@ describe AuthorizeInteractionsController do
|
||||||
|
|
||||||
it 'follows account when found' do
|
it 'follows account when found' do
|
||||||
target_account = Fabricate(:account)
|
target_account = Fabricate(:account)
|
||||||
service = double
|
service = instance_double(ResolveAccountService)
|
||||||
|
|
||||||
allow(ResolveAccountService).to receive(:new).and_return(service)
|
allow(ResolveAccountService).to receive(:new).and_return(service)
|
||||||
allow(service).to receive(:call).with('user@hostname').and_return(target_account)
|
allow(service).to receive(:call).with('user@hostname').and_return(target_account)
|
||||||
|
|
|
@ -14,7 +14,8 @@ RSpec.describe Disputes::AppealsController do
|
||||||
let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
|
let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
allow(AdminMailer).to receive(:new_appeal).and_return(double('email', deliver_later: nil))
|
allow(AdminMailer).to receive(:new_appeal)
|
||||||
|
.and_return(instance_double(ActionMailer::MessageDelivery, deliver_later: nil))
|
||||||
post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } }
|
post :create, params: { strike_id: strike.id, appeal: { text: 'Foo' } }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -75,23 +75,11 @@ describe StatusesController do
|
||||||
context 'with HTML' do
|
context 'with HTML' do
|
||||||
let(:format) { 'html' }
|
let(:format) { 'html' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders status successfully', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns public Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'public'
|
expect(response.headers['Cache-Control']).to include 'public'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders status' do
|
|
||||||
expect(response).to render_template(:show)
|
expect(response).to render_template(:show)
|
||||||
expect(response.body).to include status.text
|
expect(response.body).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -100,25 +88,13 @@ describe StatusesController do
|
||||||
context 'with JSON' do
|
context 'with JSON' do
|
||||||
let(:format) { 'json' }
|
let(:format) { 'json' }
|
||||||
|
|
||||||
it 'returns http success' do
|
|
||||||
expect(response).to have_http_status(200)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
|
||||||
end
|
|
||||||
|
|
||||||
it_behaves_like 'cacheable response'
|
it_behaves_like 'cacheable response'
|
||||||
|
|
||||||
it 'returns Content-Type header' do
|
it 'renders ActivityPub Note object successfully', :aggregate_failures do
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders ActivityPub Note object' do
|
|
||||||
json = body_as_json
|
json = body_as_json
|
||||||
expect(json[:content]).to include status.text
|
expect(json[:content]).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -199,23 +175,11 @@ describe StatusesController do
|
||||||
context 'with HTML' do
|
context 'with HTML' do
|
||||||
let(:format) { 'html' }
|
let(:format) { 'html' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders status successfully', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders status' do
|
|
||||||
expect(response).to render_template(:show)
|
expect(response).to render_template(:show)
|
||||||
expect(response.body).to include status.text
|
expect(response.body).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -224,27 +188,12 @@ describe StatusesController do
|
||||||
context 'with JSON' do
|
context 'with JSON' do
|
||||||
let(:format) { 'json' }
|
let(:format) { 'json' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders ActivityPub Note object successfully', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Content-Type header' do
|
|
||||||
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders ActivityPub Note object' do
|
|
||||||
json = body_as_json
|
json = body_as_json
|
||||||
expect(json[:content]).to include status.text
|
expect(json[:content]).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -263,23 +212,11 @@ describe StatusesController do
|
||||||
context 'with HTML' do
|
context 'with HTML' do
|
||||||
let(:format) { 'html' }
|
let(:format) { 'html' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders status successfully', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders status' do
|
|
||||||
expect(response).to render_template(:show)
|
expect(response).to render_template(:show)
|
||||||
expect(response.body).to include status.text
|
expect(response.body).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -288,27 +225,12 @@ describe StatusesController do
|
||||||
context 'with JSON' do
|
context 'with JSON' do
|
||||||
let(:format) { 'json' }
|
let(:format) { 'json' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders ActivityPub Note object successfully', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Content-Type header' do
|
|
||||||
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders ActivityPub Note object' do
|
|
||||||
json = body_as_json
|
json = body_as_json
|
||||||
expect(json[:content]).to include status.text
|
expect(json[:content]).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -350,23 +272,11 @@ describe StatusesController do
|
||||||
context 'with HTML' do
|
context 'with HTML' do
|
||||||
let(:format) { 'html' }
|
let(:format) { 'html' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders status successfully', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders status' do
|
|
||||||
expect(response).to render_template(:show)
|
expect(response).to render_template(:show)
|
||||||
expect(response.body).to include status.text
|
expect(response.body).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -375,27 +285,12 @@ describe StatusesController do
|
||||||
context 'with JSON' do
|
context 'with JSON' do
|
||||||
let(:format) { 'json' }
|
let(:format) { 'json' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders ActivityPub Note object successfully' do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Content-Type header' do
|
|
||||||
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders ActivityPub Note object' do
|
|
||||||
json = body_as_json
|
json = body_as_json
|
||||||
expect(json[:content]).to include status.text
|
expect(json[:content]).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -463,23 +358,11 @@ describe StatusesController do
|
||||||
context 'with HTML' do
|
context 'with HTML' do
|
||||||
let(:format) { 'html' }
|
let(:format) { 'html' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders status successfully', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders status' do
|
|
||||||
expect(response).to render_template(:show)
|
expect(response).to render_template(:show)
|
||||||
expect(response.body).to include status.text
|
expect(response.body).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -488,25 +371,13 @@ describe StatusesController do
|
||||||
context 'with JSON' do
|
context 'with JSON' do
|
||||||
let(:format) { 'json' }
|
let(:format) { 'json' }
|
||||||
|
|
||||||
it 'returns http success' do
|
|
||||||
expect(response).to have_http_status(200)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
|
||||||
end
|
|
||||||
|
|
||||||
it_behaves_like 'cacheable response'
|
it_behaves_like 'cacheable response'
|
||||||
|
|
||||||
it 'returns Content-Type header' do
|
it 'renders ActivityPub Note object successfully', :aggregate_failures do
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders ActivityPub Note object' do
|
|
||||||
json = body_as_json
|
json = body_as_json
|
||||||
expect(json[:content]).to include status.text
|
expect(json[:content]).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -525,23 +396,11 @@ describe StatusesController do
|
||||||
context 'with HTML' do
|
context 'with HTML' do
|
||||||
let(:format) { 'html' }
|
let(:format) { 'html' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders status successfully', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders status' do
|
|
||||||
expect(response).to render_template(:show)
|
expect(response).to render_template(:show)
|
||||||
expect(response.body).to include status.text
|
expect(response.body).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -550,27 +409,12 @@ describe StatusesController do
|
||||||
context 'with JSON' do
|
context 'with JSON' do
|
||||||
let(:format) { 'json' }
|
let(:format) { 'json' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders ActivityPub Note object successfully' do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Content-Type header' do
|
|
||||||
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders ActivityPub Note object' do
|
|
||||||
json = body_as_json
|
json = body_as_json
|
||||||
expect(json[:content]).to include status.text
|
expect(json[:content]).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -612,23 +456,11 @@ describe StatusesController do
|
||||||
context 'with HTML' do
|
context 'with HTML' do
|
||||||
let(:format) { 'html' }
|
let(:format) { 'html' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders status successfully', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders status' do
|
|
||||||
expect(response).to render_template(:show)
|
expect(response).to render_template(:show)
|
||||||
expect(response.body).to include status.text
|
expect(response.body).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -637,27 +469,12 @@ describe StatusesController do
|
||||||
context 'with JSON' do
|
context 'with JSON' do
|
||||||
let(:format) { 'json' }
|
let(:format) { 'json' }
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders ActivityPub Note object', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns private Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'private'
|
expect(response.headers['Cache-Control']).to include 'private'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Content-Type header' do
|
|
||||||
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
expect(response.headers['Content-Type']).to include 'application/activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders ActivityPub Note object' do
|
|
||||||
json = body_as_json
|
json = body_as_json
|
||||||
expect(json[:content]).to include status.text
|
expect(json[:content]).to include status.text
|
||||||
end
|
end
|
||||||
|
@ -933,23 +750,11 @@ describe StatusesController do
|
||||||
get :embed, params: { account_username: status.account.username, id: status.id }
|
get :embed, params: { account_username: status.account.username, id: status.id }
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http success' do
|
it 'renders status successfully', :aggregate_failures do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Link header' do
|
|
||||||
expect(response.headers['Link'].to_s).to include 'activity+json'
|
expect(response.headers['Link'].to_s).to include 'activity+json'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns Vary header' do
|
|
||||||
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
expect(response.headers['Vary']).to eq 'Accept, Accept-Language, Cookie'
|
||||||
end
|
|
||||||
|
|
||||||
it 'returns public Cache-Control header' do
|
|
||||||
expect(response.headers['Cache-Control']).to include 'public'
|
expect(response.headers['Cache-Control']).to include 'public'
|
||||||
end
|
|
||||||
|
|
||||||
it 'renders status' do
|
|
||||||
expect(response).to render_template(:embed)
|
expect(response).to render_template(:embed)
|
||||||
expect(response.body).to include status.text
|
expect(response.body).to include status.text
|
||||||
end
|
end
|
||||||
|
|
|
@ -117,42 +117,42 @@ describe StatusesHelper do
|
||||||
|
|
||||||
describe '#style_classes' do
|
describe '#style_classes' do
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
classes = helper.style_classes(status, false, false, false)
|
classes = helper.style_classes(status, false, false, false)
|
||||||
|
|
||||||
expect(classes).to eq 'entry'
|
expect(classes).to eq 'entry'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: true)
|
status = instance_double(Status, reblog?: true)
|
||||||
classes = helper.style_classes(status, false, false, false)
|
classes = helper.style_classes(status, false, false, false)
|
||||||
|
|
||||||
expect(classes).to eq 'entry entry-reblog'
|
expect(classes).to eq 'entry entry-reblog'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
classes = helper.style_classes(status, true, false, false)
|
classes = helper.style_classes(status, true, false, false)
|
||||||
|
|
||||||
expect(classes).to eq 'entry entry-predecessor'
|
expect(classes).to eq 'entry entry-predecessor'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
classes = helper.style_classes(status, false, true, false)
|
classes = helper.style_classes(status, false, true, false)
|
||||||
|
|
||||||
expect(classes).to eq 'entry entry-successor'
|
expect(classes).to eq 'entry entry-successor'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
classes = helper.style_classes(status, false, false, true)
|
classes = helper.style_classes(status, false, false, true)
|
||||||
|
|
||||||
expect(classes).to eq 'entry entry-center'
|
expect(classes).to eq 'entry entry-center'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: true)
|
status = instance_double(Status, reblog?: true)
|
||||||
classes = helper.style_classes(status, true, true, true)
|
classes = helper.style_classes(status, true, true, true)
|
||||||
|
|
||||||
expect(classes).to eq 'entry entry-predecessor entry-reblog entry-successor entry-center'
|
expect(classes).to eq 'entry entry-predecessor entry-reblog entry-successor entry-center'
|
||||||
|
@ -161,35 +161,35 @@ describe StatusesHelper do
|
||||||
|
|
||||||
describe '#microformats_classes' do
|
describe '#microformats_classes' do
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
classes = helper.microformats_classes(status, false, false)
|
classes = helper.microformats_classes(status, false, false)
|
||||||
|
|
||||||
expect(classes).to eq ''
|
expect(classes).to eq ''
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
classes = helper.microformats_classes(status, true, false)
|
classes = helper.microformats_classes(status, true, false)
|
||||||
|
|
||||||
expect(classes).to eq 'p-in-reply-to'
|
expect(classes).to eq 'p-in-reply-to'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
classes = helper.microformats_classes(status, false, true)
|
classes = helper.microformats_classes(status, false, true)
|
||||||
|
|
||||||
expect(classes).to eq 'p-comment'
|
expect(classes).to eq 'p-comment'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: true)
|
status = instance_double(Status, reblog?: true)
|
||||||
classes = helper.microformats_classes(status, true, false)
|
classes = helper.microformats_classes(status, true, false)
|
||||||
|
|
||||||
expect(classes).to eq 'p-in-reply-to p-repost-of'
|
expect(classes).to eq 'p-in-reply-to p-repost-of'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: true)
|
status = instance_double(Status, reblog?: true)
|
||||||
classes = helper.microformats_classes(status, true, true)
|
classes = helper.microformats_classes(status, true, true)
|
||||||
|
|
||||||
expect(classes).to eq 'p-in-reply-to p-repost-of p-comment'
|
expect(classes).to eq 'p-in-reply-to p-repost-of p-comment'
|
||||||
|
@ -198,42 +198,42 @@ describe StatusesHelper do
|
||||||
|
|
||||||
describe '#microformats_h_class' do
|
describe '#microformats_h_class' do
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
css_class = helper.microformats_h_class(status, false, false, false)
|
css_class = helper.microformats_h_class(status, false, false, false)
|
||||||
|
|
||||||
expect(css_class).to eq 'h-entry'
|
expect(css_class).to eq 'h-entry'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: true)
|
status = instance_double(Status, reblog?: true)
|
||||||
css_class = helper.microformats_h_class(status, false, false, false)
|
css_class = helper.microformats_h_class(status, false, false, false)
|
||||||
|
|
||||||
expect(css_class).to eq 'h-cite'
|
expect(css_class).to eq 'h-cite'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
css_class = helper.microformats_h_class(status, true, false, false)
|
css_class = helper.microformats_h_class(status, true, false, false)
|
||||||
|
|
||||||
expect(css_class).to eq 'h-cite'
|
expect(css_class).to eq 'h-cite'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
css_class = helper.microformats_h_class(status, false, true, false)
|
css_class = helper.microformats_h_class(status, false, true, false)
|
||||||
|
|
||||||
expect(css_class).to eq 'h-cite'
|
expect(css_class).to eq 'h-cite'
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: false)
|
status = instance_double(Status, reblog?: false)
|
||||||
css_class = helper.microformats_h_class(status, false, false, true)
|
css_class = helper.microformats_h_class(status, false, false, true)
|
||||||
|
|
||||||
expect(css_class).to eq ''
|
expect(css_class).to eq ''
|
||||||
end
|
end
|
||||||
|
|
||||||
it do
|
it do
|
||||||
status = double(reblog?: true)
|
status = instance_double(Status, reblog?: true)
|
||||||
css_class = helper.microformats_h_class(status, true, true, true)
|
css_class = helper.microformats_h_class(status, true, true, true)
|
||||||
|
|
||||||
expect(css_class).to eq 'h-cite'
|
expect(css_class).to eq 'h-cite'
|
||||||
|
|
|
@ -26,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Add do
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'when status was not known before' do
|
context 'when status was not known before' do
|
||||||
let(:service_stub) { double }
|
let(:service_stub) { instance_double(ActivityPub::FetchRemoteStatusService) }
|
||||||
|
|
||||||
let(:json) do
|
let(:json) do
|
||||||
{
|
{
|
||||||
|
|
|
@ -26,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Move do
|
||||||
stub_request(:post, old_account.inbox_url).to_return(status: 200)
|
stub_request(:post, old_account.inbox_url).to_return(status: 200)
|
||||||
stub_request(:post, new_account.inbox_url).to_return(status: 200)
|
stub_request(:post, new_account.inbox_url).to_return(status: 200)
|
||||||
|
|
||||||
service_stub = double
|
service_stub = instance_double(ActivityPub::FetchRemoteAccountService)
|
||||||
allow(ActivityPub::FetchRemoteAccountService).to receive(:new).and_return(service_stub)
|
allow(ActivityPub::FetchRemoteAccountService).to receive(:new).and_return(service_stub)
|
||||||
allow(service_stub).to receive(:call).and_return(returned_account)
|
allow(service_stub).to receive(:call).and_return(returned_account)
|
||||||
end
|
end
|
||||||
|
|
|
@ -48,16 +48,25 @@ describe RequestPool do
|
||||||
expect(subject.size).to be > 1
|
expect(subject.size).to be > 1
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'closes idle connections' do
|
context 'with an idle connection' do
|
||||||
|
before do
|
||||||
|
stub_const('RequestPool::MAX_IDLE_TIME', 1) # Lower idle time limit to 1 seconds
|
||||||
|
stub_const('RequestPool::REAPER_FREQUENCY', 0.1) # Run reaper every 0.1 seconds
|
||||||
stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!')
|
stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'closes the connections' do
|
||||||
subject.with('http://example.com') do |http_client|
|
subject.with('http://example.com') do |http_client|
|
||||||
http_client.get('/').flush
|
http_client.get('/').flush
|
||||||
end
|
end
|
||||||
|
|
||||||
expect(subject.size).to eq 1
|
expect { reaper_observes_idle_timeout }.to change(subject, :size).from(1).to(0)
|
||||||
sleep RequestPool::MAX_IDLE_TIME + 30 + 1
|
end
|
||||||
expect(subject.size).to eq 0
|
|
||||||
|
def reaper_observes_idle_timeout
|
||||||
|
# One full idle period and 2 reaper cycles more
|
||||||
|
sleep RequestPool::MAX_IDLE_TIME + (RequestPool::REAPER_FREQUENCY * 2)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -48,7 +48,7 @@ describe Request do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'executes a HTTP request when the first address is private' do
|
it 'executes a HTTP request when the first address is private' do
|
||||||
resolver = double
|
resolver = instance_double(Resolv::DNS)
|
||||||
|
|
||||||
allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:4860:4860::8844))
|
allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:4860:4860::8844))
|
||||||
allow(resolver).to receive(:timeouts=).and_return(nil)
|
allow(resolver).to receive(:timeouts=).and_return(nil)
|
||||||
|
@ -83,7 +83,7 @@ describe Request do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'raises Mastodon::ValidationError' do
|
it 'raises Mastodon::ValidationError' do
|
||||||
resolver = double
|
resolver = instance_double(Resolv::DNS)
|
||||||
|
|
||||||
allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:db8::face))
|
allow(resolver).to receive(:getaddresses).with('example.com').and_return(%w(0.0.0.0 2001:db8::face))
|
||||||
allow(resolver).to receive(:timeouts=).and_return(nil)
|
allow(resolver).to receive(:timeouts=).and_return(nil)
|
||||||
|
|
|
@ -36,6 +36,14 @@ describe Sanitize::Config do
|
||||||
expect(Sanitize.fragment('<a href="http://example.com">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
|
expect(Sanitize.fragment('<a href="http://example.com">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'keeps a with translate="no"' do
|
||||||
|
expect(Sanitize.fragment('<a href="http://example.com" translate="no">Test</a>', subject)).to eq '<a href="http://example.com" translate="no" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'removes "translate" attribute with invalid value' do
|
||||||
|
expect(Sanitize.fragment('<a href="http://example.com" translate="foo">Test</a>', subject)).to eq '<a href="http://example.com" rel="nofollow noopener noreferrer" target="_blank">Test</a>'
|
||||||
|
end
|
||||||
|
|
||||||
it 'removes a with unparsable href' do
|
it 'removes a with unparsable href' do
|
||||||
expect(Sanitize.fragment('<a href=" https://google.fr">Test</a>', subject)).to eq 'Test'
|
expect(Sanitize.fragment('<a href=" https://google.fr">Test</a>', subject)).to eq 'Test'
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,7 @@ RSpec.describe SuspiciousSignInDetector do
|
||||||
subject { described_class.new(user).suspicious?(request) }
|
subject { described_class.new(user).suspicious?(request) }
|
||||||
|
|
||||||
let(:user) { Fabricate(:user, current_sign_in_at: 1.day.ago) }
|
let(:user) { Fabricate(:user, current_sign_in_at: 1.day.ago) }
|
||||||
let(:request) { double(remote_ip: remote_ip) }
|
let(:request) { instance_double(ActionDispatch::Request, remote_ip: remote_ip) }
|
||||||
let(:remote_ip) { nil }
|
let(:remote_ip) { nil }
|
||||||
|
|
||||||
context 'when user has 2FA enabled' do
|
context 'when user has 2FA enabled' do
|
||||||
|
|
|
@ -142,4 +142,59 @@ describe UserMailer do
|
||||||
expect(mail.body.encoded).to include I18n.t('user_mailer.appeal_rejected.title')
|
expect(mail.body.encoded).to include I18n.t('user_mailer.appeal_rejected.title')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'two_factor_enabled' do
|
||||||
|
let(:mail) { described_class.two_factor_enabled(receiver) }
|
||||||
|
|
||||||
|
it 'renders two_factor_enabled mail' do
|
||||||
|
expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_enabled.subject')
|
||||||
|
expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_enabled.explanation')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'two_factor_disabled' do
|
||||||
|
let(:mail) { described_class.two_factor_disabled(receiver) }
|
||||||
|
|
||||||
|
it 'renders two_factor_disabled mail' do
|
||||||
|
expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_disabled.subject')
|
||||||
|
expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_disabled.explanation')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'webauthn_enabled' do
|
||||||
|
let(:mail) { described_class.webauthn_enabled(receiver) }
|
||||||
|
|
||||||
|
it 'renders webauthn_enabled mail' do
|
||||||
|
expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_enabled.subject')
|
||||||
|
expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_enabled.explanation')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'webauthn_disabled' do
|
||||||
|
let(:mail) { described_class.webauthn_disabled(receiver) }
|
||||||
|
|
||||||
|
it 'renders webauthn_disabled mail' do
|
||||||
|
expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_disabled.subject')
|
||||||
|
expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_disabled.explanation')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'two_factor_recovery_codes_changed' do
|
||||||
|
let(:mail) { described_class.two_factor_recovery_codes_changed(receiver) }
|
||||||
|
|
||||||
|
it 'renders two_factor_recovery_codes_changed mail' do
|
||||||
|
expect(mail.subject).to eq I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject')
|
||||||
|
expect(mail.body.encoded).to include I18n.t('devise.mailer.two_factor_recovery_codes_changed.explanation')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'webauthn_credential_added' do
|
||||||
|
let(:credential) { Fabricate.build(:webauthn_credential) }
|
||||||
|
let(:mail) { described_class.webauthn_credential_added(receiver, credential) }
|
||||||
|
|
||||||
|
it 'renders webauthn_credential_added mail' do
|
||||||
|
expect(mail.subject).to eq I18n.t('devise.mailer.webauthn_credential.added.subject')
|
||||||
|
expect(mail.body.encoded).to include I18n.t('devise.mailer.webauthn_credential.added.explanation')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,7 +6,7 @@ RSpec.describe Account::Field do
|
||||||
describe '#verified?' do
|
describe '#verified?' do
|
||||||
subject { described_class.new(account, 'name' => 'Foo', 'value' => 'Bar', 'verified_at' => verified_at) }
|
subject { described_class.new(account, 'name' => 'Foo', 'value' => 'Bar', 'verified_at' => verified_at) }
|
||||||
|
|
||||||
let(:account) { double('Account', local?: true) }
|
let(:account) { instance_double(Account, local?: true) }
|
||||||
|
|
||||||
context 'when verified_at is set' do
|
context 'when verified_at is set' do
|
||||||
let(:verified_at) { Time.now.utc.iso8601 }
|
let(:verified_at) { Time.now.utc.iso8601 }
|
||||||
|
@ -28,7 +28,7 @@ RSpec.describe Account::Field do
|
||||||
describe '#mark_verified!' do
|
describe '#mark_verified!' do
|
||||||
subject { described_class.new(account, original_hash) }
|
subject { described_class.new(account, original_hash) }
|
||||||
|
|
||||||
let(:account) { double('Account', local?: true) }
|
let(:account) { instance_double(Account, local?: true) }
|
||||||
let(:original_hash) { { 'name' => 'Foo', 'value' => 'Bar' } }
|
let(:original_hash) { { 'name' => 'Foo', 'value' => 'Bar' } }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
@ -47,7 +47,7 @@ RSpec.describe Account::Field do
|
||||||
describe '#verifiable?' do
|
describe '#verifiable?' do
|
||||||
subject { described_class.new(account, 'name' => 'Foo', 'value' => value) }
|
subject { described_class.new(account, 'name' => 'Foo', 'value' => value) }
|
||||||
|
|
||||||
let(:account) { double('Account', local?: local) }
|
let(:account) { instance_double(Account, local?: local) }
|
||||||
|
|
||||||
context 'with local accounts' do
|
context 'with local accounts' do
|
||||||
let(:local) { true }
|
let(:local) { true }
|
||||||
|
|
|
@ -15,7 +15,7 @@ RSpec.describe AccountMigration do
|
||||||
before do
|
before do
|
||||||
target_account.aliases.create!(acct: source_account.acct)
|
target_account.aliases.create!(acct: source_account.acct)
|
||||||
|
|
||||||
service_double = double
|
service_double = instance_double(ResolveAccountService)
|
||||||
allow(ResolveAccountService).to receive(:new).and_return(service_double)
|
allow(ResolveAccountService).to receive(:new).and_return(service_double)
|
||||||
allow(service_double).to receive(:call).with(target_acct, anything).and_return(target_account)
|
allow(service_double).to receive(:call).with(target_acct, anything).and_return(target_account)
|
||||||
end
|
end
|
||||||
|
@ -29,7 +29,7 @@ RSpec.describe AccountMigration do
|
||||||
let(:target_acct) { 'target@remote' }
|
let(:target_acct) { 'target@remote' }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
service_double = double
|
service_double = instance_double(ResolveAccountService)
|
||||||
allow(ResolveAccountService).to receive(:new).and_return(service_double)
|
allow(ResolveAccountService).to receive(:new).and_return(service_double)
|
||||||
allow(service_double).to receive(:call).with(target_acct, anything).and_return(nil)
|
allow(service_double).to receive(:call).with(target_acct, anything).and_return(nil)
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,7 +16,7 @@ RSpec.describe SessionActivation do
|
||||||
allow(session_activation).to receive(:detection).and_return(detection)
|
allow(session_activation).to receive(:detection).and_return(detection)
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:detection) { double(id: 1) }
|
let(:detection) { instance_double(Browser::Chrome, id: 1) }
|
||||||
let(:session_activation) { Fabricate(:session_activation) }
|
let(:session_activation) { Fabricate(:session_activation) }
|
||||||
|
|
||||||
it 'returns detection.id' do
|
it 'returns detection.id' do
|
||||||
|
@ -30,7 +30,7 @@ RSpec.describe SessionActivation do
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:session_activation) { Fabricate(:session_activation) }
|
let(:session_activation) { Fabricate(:session_activation) }
|
||||||
let(:detection) { double(platform: double(id: 1)) }
|
let(:detection) { instance_double(Browser::Chrome, platform: instance_double(Browser::Platform, id: 1)) }
|
||||||
|
|
||||||
it 'returns detection.platform.id' do
|
it 'returns detection.platform.id' do
|
||||||
expect(session_activation.platform).to be 1
|
expect(session_activation.platform).to be 1
|
||||||
|
|
|
@ -62,7 +62,7 @@ RSpec.describe Setting do
|
||||||
|
|
||||||
context 'when RailsSettings::Settings.object returns truthy' do
|
context 'when RailsSettings::Settings.object returns truthy' do
|
||||||
let(:object) { db_val }
|
let(:object) { db_val }
|
||||||
let(:db_val) { double(value: 'db_val') }
|
let(:db_val) { instance_double(described_class, value: 'db_val') }
|
||||||
|
|
||||||
context 'when default_value is a Hash' do
|
context 'when default_value is a Hash' do
|
||||||
let(:default_value) { { default_value: 'default_value' } }
|
let(:default_value) { { default_value: 'default_value' } }
|
||||||
|
|
|
@ -49,6 +49,16 @@ RSpec.describe UserSettings do
|
||||||
expect(subject[:always_send_emails]).to be true
|
expect(subject[:always_send_emails]).to be true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when the setting has a closed set of values' do
|
||||||
|
it 'updates the attribute when given a valid value' do
|
||||||
|
expect { subject[:'web.display_media'] = :show_all }.to change { subject[:'web.display_media'] }.from('default').to('show_all')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises an error when given an invalid value' do
|
||||||
|
expect { subject[:'web.display_media'] = 'invalid value' }.to raise_error ArgumentError
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#update' do
|
describe '#update' do
|
||||||
|
|
|
@ -8,16 +8,32 @@ describe WebhookPolicy do
|
||||||
let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
|
let(:admin) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')).account }
|
||||||
let(:john) { Fabricate(:account) }
|
let(:john) { Fabricate(:account) }
|
||||||
|
|
||||||
permissions :index?, :create?, :show?, :update?, :enable?, :disable?, :rotate_secret?, :destroy? do
|
permissions :index?, :create? do
|
||||||
context 'with an admin' do
|
context 'with an admin' do
|
||||||
it 'permits' do
|
it 'permits' do
|
||||||
expect(policy).to permit(admin, Tag)
|
expect(policy).to permit(admin, Webhook)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
context 'with a non-admin' do
|
context 'with a non-admin' do
|
||||||
it 'denies' do
|
it 'denies' do
|
||||||
expect(policy).to_not permit(john, Tag)
|
expect(policy).to_not permit(john, Webhook)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
permissions :show?, :update?, :enable?, :disable?, :rotate_secret?, :destroy? do
|
||||||
|
let(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) }
|
||||||
|
|
||||||
|
context 'with an admin' do
|
||||||
|
it 'permits' do
|
||||||
|
expect(policy).to permit(admin, webhook)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with a non-admin' do
|
||||||
|
it 'denies' do
|
||||||
|
expect(policy).to_not permit(john, webhook)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue