Merge remote-tracking branch 'glitch-soc/main' into merge/glitch/20230627

This commit is contained in:
neatchee 2023-06-27 11:03:08 -07:00
commit b1deb8a167
144 changed files with 1742 additions and 1133 deletions

View file

@ -1,5 +1,5 @@
# For details, see https://github.com/devcontainers/images/tree/main/src/ruby
FROM mcr.microsoft.com/devcontainers/ruby:0-3.2-bullseye
FROM mcr.microsoft.com/devcontainers/ruby:1-3.2-bullseye
# Install Rails
# RUN gem install rails webdrivers

View file

@ -322,7 +322,6 @@ RSpec/LetSetup:
- 'spec/controllers/api/v1/admin/accounts_controller_spec.rb'
- 'spec/controllers/api/v1/filters_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/filters/keywords_controller_spec.rb'
- 'spec/controllers/api/v2/filters/statuses_controller_spec.rb'
@ -445,45 +444,6 @@ RSpec/SubjectStub:
- 'spec/services/unallow_domain_service_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).
Rails/ApplicationController:
Exclude:
@ -764,7 +724,6 @@ Rails/WhereExists:
- 'app/workers/move_worker.rb'
- 'db/migrate/20190529143559_preserve_old_layout_for_existing_users.rb'
- 'lib/tasks/tests.rake'
- 'spec/controllers/api/v1/tags_controller_spec.rb'
- 'spec/models/account_spec.rb'
- 'spec/services/activitypub/process_collection_service_spec.rb'
- 'spec/services/purge_domain_service_spec.rb'

View file

@ -18,40 +18,40 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.7.3)
actionpack (= 6.1.7.3)
activesupport (= 6.1.7.3)
actioncable (6.1.7.4)
actionpack (= 6.1.7.4)
activesupport (= 6.1.7.4)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.7.3)
actionpack (= 6.1.7.3)
activejob (= 6.1.7.3)
activerecord (= 6.1.7.3)
activestorage (= 6.1.7.3)
activesupport (= 6.1.7.3)
actionmailbox (6.1.7.4)
actionpack (= 6.1.7.4)
activejob (= 6.1.7.4)
activerecord (= 6.1.7.4)
activestorage (= 6.1.7.4)
activesupport (= 6.1.7.4)
mail (>= 2.7.1)
actionmailer (6.1.7.3)
actionpack (= 6.1.7.3)
actionview (= 6.1.7.3)
activejob (= 6.1.7.3)
activesupport (= 6.1.7.3)
actionmailer (6.1.7.4)
actionpack (= 6.1.7.4)
actionview (= 6.1.7.4)
activejob (= 6.1.7.4)
activesupport (= 6.1.7.4)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.7.3)
actionview (= 6.1.7.3)
activesupport (= 6.1.7.3)
actionpack (6.1.7.4)
actionview (= 6.1.7.4)
activesupport (= 6.1.7.4)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.7.3)
actionpack (= 6.1.7.3)
activerecord (= 6.1.7.3)
activestorage (= 6.1.7.3)
activesupport (= 6.1.7.3)
actiontext (6.1.7.4)
actionpack (= 6.1.7.4)
activerecord (= 6.1.7.4)
activestorage (= 6.1.7.4)
activesupport (= 6.1.7.4)
nokogiri (>= 1.8.5)
actionview (6.1.7.3)
activesupport (= 6.1.7.3)
actionview (6.1.7.4)
activesupport (= 6.1.7.4)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
@ -61,22 +61,22 @@ GEM
activemodel (>= 4.1, < 7.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (6.1.7.3)
activesupport (= 6.1.7.3)
activejob (6.1.7.4)
activesupport (= 6.1.7.4)
globalid (>= 0.3.6)
activemodel (6.1.7.3)
activesupport (= 6.1.7.3)
activerecord (6.1.7.3)
activemodel (= 6.1.7.3)
activesupport (= 6.1.7.3)
activestorage (6.1.7.3)
actionpack (= 6.1.7.3)
activejob (= 6.1.7.3)
activerecord (= 6.1.7.3)
activesupport (= 6.1.7.3)
activemodel (6.1.7.4)
activesupport (= 6.1.7.4)
activerecord (6.1.7.4)
activemodel (= 6.1.7.4)
activesupport (= 6.1.7.4)
activestorage (6.1.7.4)
actionpack (= 6.1.7.4)
activejob (= 6.1.7.4)
activerecord (= 6.1.7.4)
activesupport (= 6.1.7.4)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (6.1.7.3)
activesupport (6.1.7.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -106,7 +106,7 @@ GEM
aws-sdk-kms (1.67.0)
aws-sdk-core (~> 3, >= 3.174.0)
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-kms (~> 1)
aws-sigv4 (~> 1.4)
@ -412,7 +412,7 @@ GEM
mime-types-data (3.2023.0218.1)
mini_mime (1.1.2)
mini_portile2 (2.8.2)
minitest (5.18.0)
minitest (5.18.1)
msgpack (1.7.1)
multi_json (1.15.0)
multipart-post (2.3.0)
@ -511,20 +511,20 @@ GEM
rack
rack-test (2.1.0)
rack (>= 1.3)
rails (6.1.7.3)
actioncable (= 6.1.7.3)
actionmailbox (= 6.1.7.3)
actionmailer (= 6.1.7.3)
actionpack (= 6.1.7.3)
actiontext (= 6.1.7.3)
actionview (= 6.1.7.3)
activejob (= 6.1.7.3)
activemodel (= 6.1.7.3)
activerecord (= 6.1.7.3)
activestorage (= 6.1.7.3)
activesupport (= 6.1.7.3)
rails (6.1.7.4)
actioncable (= 6.1.7.4)
actionmailbox (= 6.1.7.4)
actionmailer (= 6.1.7.4)
actionpack (= 6.1.7.4)
actiontext (= 6.1.7.4)
actionview (= 6.1.7.4)
activejob (= 6.1.7.4)
activemodel (= 6.1.7.4)
activerecord (= 6.1.7.4)
activestorage (= 6.1.7.4)
activesupport (= 6.1.7.4)
bundler (>= 1.15.0)
railties (= 6.1.7.3)
railties (= 6.1.7.4)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
@ -539,9 +539,9 @@ GEM
rails-i18n (6.0.0)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 7)
railties (6.1.7.3)
actionpack (= 6.1.7.3)
activesupport (= 6.1.7.3)
railties (6.1.7.4)
actionpack (= 6.1.7.4)
activesupport (= 6.1.7.4)
method_source
rake (>= 12.2)
thor (~> 1.0)

View file

@ -28,6 +28,7 @@ module Admin
authorize :webhook, :create?
@webhook = Webhook.new(resource_params)
@webhook.current_account = current_account
if @webhook.save
redirect_to admin_webhook_path(@webhook)
@ -39,10 +40,12 @@ module Admin
def update
authorize @webhook, :update?
@webhook.current_account = current_account
if @webhook.update(resource_params)
redirect_to admin_webhook_path(@webhook)
else
render :show
render :edit
end
end

View file

@ -19,6 +19,11 @@ class Api::V1::ConversationsController < Api::BaseController
render json: @conversation, serializer: REST::ConversationSerializer
end
def unread
@conversation.update!(unread: true)
render json: @conversation, serializer: REST::ConversationSerializer
end
def destroy
@conversation.destroy!
render_empty

View file

@ -8,11 +8,15 @@ class Api::V1::Statuses::HistoriesController < Api::BaseController
def show
cache_if_unauthenticated!
render json: @status.edits.includes(:account, status: [:account]), each_serializer: REST::StatusEditSerializer
render json: status_edits, each_serializer: REST::StatusEditSerializer
end
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
@status = Status.find(params[:status_id])
authorize @status, :show?

View file

@ -18,6 +18,14 @@ class Api::V2::Admin::AccountsController < Api::V1::Admin::AccountsController
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
AccountFilter.new(translated_filter_params).results
end

View file

@ -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')], ' ')
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

View file

@ -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>
);
}
}

View file

@ -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>
);
};

View file

@ -8,9 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestHashtag from './autosuggest_hashtag';
import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;

View file

@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestHashtag from './autosuggest_hashtag';
import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;

View file

@ -15,13 +15,14 @@ import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/col
import ColumnHeader from 'flavours/glitch/components/column_header';
import StatusList from 'flavours/glitch/components/status_list';
import Column from 'flavours/glitch/features/ui/components/column';
import { getStatusList } from 'flavours/glitch/selectors';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
statusIds: getStatusList(state, 'bookmarks'),
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
});

View file

@ -142,11 +142,8 @@ class CommunityTimeline extends PureComponent {
<ColumnSettingsContainer columnId={columnId} />
</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
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}
scrollKey={`community_timeline-${columnId}`}
timelineId={`community${onlyMedia ? ':media' : ''}`}

View file

@ -392,7 +392,7 @@ class EmojiPickerDropdown extends PureComponent {
{button || <img
className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂'
src={`${assetHost}/emoji/1f602.svg`}
src={`${assetHost}/emoji/1f642.svg`}
/>}
</div>

View file

@ -35,7 +35,7 @@ class Links extends PureComponent {
const banner = (
<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>
);

View file

@ -11,9 +11,10 @@ import { debounce } from 'lodash';
import { fetchTrendingStatuses, expandTrendingStatuses } from 'flavours/glitch/actions/trends';
import DismissableBanner from 'flavours/glitch/components/dismissable_banner';
import StatusList from 'flavours/glitch/components/status_list';
import { getStatusList } from 'flavours/glitch/selectors';
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'trending', 'items']),
statusIds: getStatusList(state, 'trending'),
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
});
@ -46,7 +47,7 @@ class Statuses extends PureComponent {
return (
<>
<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>
<StatusList

View file

@ -36,7 +36,7 @@ class Tags extends PureComponent {
const banner = (
<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>
);

View file

@ -15,13 +15,14 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/glit
import ColumnHeader from 'flavours/glitch/components/column_header';
import StatusList from 'flavours/glitch/components/status_list';
import Column from 'flavours/glitch/features/ui/components/column';
import { getStatusList } from 'flavours/glitch/selectors';
const messages = defineMessages({
heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
statusIds: getStatusList(state, 'favourites'),
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
});

View file

@ -0,0 +1,25 @@
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__wrapper'>
<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>
</div>
</DismissableBanner>
);

View file

@ -5,35 +5,66 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
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 { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
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';
const messages = defineMessages({
title: { id: 'column.home', defaultMessage: 'Home' },
show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
});
const getHomeFeedSpeed = createSelector([
state => state.getIn(['timelines', 'home', 'items'], ImmutableList()),
state => state.getIn(['timelines', 'home', 'pendingItems'], ImmutableList()),
state => state.get('statuses'),
], (statusIds, pendingStatusIds, statusMap) => {
const recentStatusIds = pendingStatusIds.size > 0 ? pendingStatusIds : statusIds;
const statuses = recentStatusIds.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([
state => state.getIn(['timelines', 'home', 'isLoading']),
state => state.getIn(['timelines', 'home', 'isPartial']),
getHomeFeedSpeed,
], (isLoading, isPartial, speed) =>
!isLoading && !isPartial // Only if the home feed has finished loading
&& (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 => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
showAnnouncements: state.getIn(['announcements', 'show']),
tooSlow: homeTooSlow(state),
regex: state.getIn(['settings', 'home', 'regex', 'body']),
});
@ -53,6 +84,7 @@ class HomeTimeline extends PureComponent {
hasAnnouncements: PropTypes.bool,
unreadAnnouncements: PropTypes.number,
showAnnouncements: PropTypes.bool,
tooSlow: PropTypes.bool,
regex: PropTypes.string,
};
@ -123,11 +155,11 @@ class HomeTimeline extends PureComponent {
};
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 { signedIn } = this.context.identity;
let announcementsButton = null;
let announcementsButton, banner;
if (hasAnnouncements) {
announcementsButton = (
@ -142,6 +174,10 @@ class HomeTimeline extends PureComponent {
);
}
if (tooSlow) {
banner = <ExplorePrompt />;
}
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}>
<ColumnHeader
@ -161,11 +197,13 @@ class HomeTimeline extends PureComponent {
{signedIn ? (
<StatusListContainer
prepend={banner}
alwaysPrepend
trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`}
onLoadMore={this.handleLoadMore}
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}
regex={this.props.regex}
/>

View file

@ -8,17 +8,19 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { fetchPinnedStatuses } from 'flavours/glitch/actions/pin_statuses';
import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
import StatusList from 'flavours/glitch/components/status_list';
import Column from 'flavours/glitch/features/ui/components/column';
import { getStatusList } from 'flavours/glitch/selectors';
import { fetchPinnedStatuses } from '../../actions/pin_statuses';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import StatusList from '../../components/status_list';
import Column from '../ui/components/column';
const messages = defineMessages({
heading: { id: 'column.pins', defaultMessage: 'Pinned post' },
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'pins', 'items']),
statusIds: getStatusList(state, 'pins'),
hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
});

View file

@ -146,11 +146,8 @@ class PublicTimeline extends PureComponent {
<ColumnSettingsContainer columnId={columnId} />
</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
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' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}

View file

@ -1,14 +1,16 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Link, withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { openModal } from 'flavours/glitch/actions/modal';
import { fetchServer } from 'flavours/glitch/actions/server';
import { Avatar } from 'flavours/glitch/components/avatar';
import { Icon } from 'flavours/glitch/components/icon';
import { WordmarkLogo, SymbolLogo } from 'flavours/glitch/components/logo';
import Permalink from 'flavours/glitch/components/permalink';
import { registrationsOpen, me } from 'flavours/glitch/initial_state';
@ -21,6 +23,10 @@ const Account = connect(state => ({
</Permalink>
));
const messages = defineMessages({
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
});
const mapStateToProps = (state) => ({
signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up',
});
@ -29,6 +35,9 @@ const mapDispatchToProps = (dispatch) => ({
openClosedRegistrationsModal() {
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
},
dispatchServer() {
dispatch(fetchServer());
}
});
class Header extends PureComponent {
@ -41,18 +50,26 @@ class Header extends PureComponent {
openClosedRegistrationsModal: PropTypes.func,
location: PropTypes.object,
signupUrl: PropTypes.string.isRequired,
dispatchServer: PropTypes.func,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
const { dispatchServer } = this.props;
dispatchServer();
}
render () {
const { signedIn } = this.context.identity;
const { location, openClosedRegistrationsModal, signupUrl } = this.props;
const { location, openClosedRegistrationsModal, signupUrl, intl } = this.props;
let content;
if (signedIn) {
content = (
<>
{location.pathname !== '/publish' && <Link to='/publish' className='button'><FormattedMessage id='compose_form.publish_form' defaultMessage='Publish' /></Link>}
{location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' /></Link>}
{location.pathname !== '/publish' && <Link to='/publish' className='button button-secondary'><FormattedMessage id='compose_form.publish_form' defaultMessage='New post' /></Link>}
<Account />
</>
);
@ -75,6 +92,7 @@ class Header extends PureComponent {
content = (
<>
{location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' /></Link>}
{signupButton}
<a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
</>
@ -97,4 +115,4 @@ class Header extends PureComponent {
}
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Header));
export default injectIntl(withRouter(connect(mapStateToProps, mapDispatchToProps)(Header)));

View file

@ -148,3 +148,7 @@ export const getAccountHidden = createSelector([
], (hidden, followingOrRequested, isSelf) => {
return hidden && !(isSelf || followingOrRequested);
});
export const getStatusList = createSelector([
(state, type) => state.getIn(['status_lists', type, 'items']),
], (items) => items.toList());

View file

@ -966,26 +966,79 @@ $ui-header-height: 55px;
}
.dismissable-banner {
background: $ui-base-color;
border-bottom: 1px solid lighten($ui-base-color, 8%);
display: flex;
align-items: center;
gap: 30px;
position: relative;
margin: 10px;
margin-bottom: 5px;
border-radius: 8px;
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 {
flex: 1 1 auto;
padding: 20px 15px;
cursor: default;
font-size: 14px;
line-height: 18px;
padding: 15px;
font-size: 15px;
line-height: 22px;
font-weight: 500;
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;
flex-wrap: wrap;
gap: 4px;
&__wrapper {
display: flex;
margin-top: 30px;
}
.button {
display: block;
flex-grow: 1;
}
}
.button-tertiary {
background: rgba($ui-base-color, 0.15);
backdrop-filter: blur(8px);
}
}
&__action {
padding: 15px;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
inset-inline-end: 0;
top: 0;
padding: 10px;
.icon-button {
color: $highlight-text-color;
}
}
}

View file

@ -108,12 +108,13 @@
text-transform: none;
background: transparent;
padding: 6px 17px;
border: 1px solid $ui-primary-color;
border: 1px solid lighten($ui-base-color, 12%);
&:active,
&:focus,
&:hover {
border-color: lighten($ui-primary-color, 4%);
background: lighten($ui-base-color, 4%);
border-color: lighten($ui-base-color, 16%);
color: lighten($darker-text-color, 4%);
text-decoration: none;
}

View file

@ -653,11 +653,6 @@ html {
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,
.reply-indicator__content {
a {

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

View file

@ -129,13 +129,13 @@ export function resetCompose() {
};
}
export const focusCompose = (routerHistory, defaultText) => dispatch => {
export const focusCompose = (routerHistory, defaultText) => (dispatch, getState) => {
dispatch({
type: COMPOSE_FOCUS,
defaultText,
});
ensureComposeIsVisible(routerHistory);
ensureComposeIsVisible(getState, routerHistory);
};
export function mentionCompose(account, routerHistory) {

View file

@ -1,6 +1,6 @@
import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
@ -49,6 +49,7 @@ class Account extends ImmutablePureComponent {
actionTitle: PropTypes.string,
defaultAction: PropTypes.string,
onActionClick: PropTypes.func,
withBio: PropTypes.bool,
};
static defaultProps = {
@ -80,7 +81,7 @@ class Account extends ImmutablePureComponent {
};
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) {
return <EmptyAccount size={size} minimal={minimal} />;
@ -171,6 +172,15 @@ class Account extends ImmutablePureComponent {
</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>
);
}

View file

@ -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>
);
}
}

View 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>
);
};

View file

@ -8,7 +8,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestHashtag from './autosuggest_hashtag';
import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
let word;

View file

@ -10,7 +10,7 @@ import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestHashtag from './autosuggest_hashtag';
import { AutosuggestHashtag } from './autosuggest_hashtag';
const textAtCursorMatchesToken = (str, caretPosition) => {
let word;

View file

@ -1,11 +1,27 @@
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 {
link: string;
}
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
<span className='verified-badge'>
<Icon id='check' className='verified-badge__mark' />
<span dangerouslySetInnerHTML={{ __html: link }} />
<span dangerouslySetInnerHTML={stripRelMe(link)} />
</span>
);

View file

@ -15,13 +15,14 @@ import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import Column from 'mastodon/features/ui/components/column';
import { getStatusList } from 'mastodon/selectors';
const messages = defineMessages({
heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
statusIds: getStatusList(state, 'bookmarks'),
isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
});

View file

@ -140,11 +140,8 @@ class CommunityTimeline extends PureComponent {
<ColumnSettingsContainer columnId={columnId} />
</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
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}
scrollKey={`community_timeline-${columnId}`}
timelineId={`community${onlyMedia ? ':media' : ''}`}

View file

@ -390,7 +390,7 @@ class EmojiPickerDropdown extends PureComponent {
{button || <img
className={classNames('emojione', { 'pulse-loading': active && loading })}
alt='🙂'
src={`${assetHost}/emoji/1f602.svg`}
src={`${assetHost}/emoji/1f642.svg`}
/>}
</div>

View file

@ -35,7 +35,7 @@ class Links extends PureComponent {
const banner = (
<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>
);

View file

@ -11,9 +11,10 @@ import { debounce } from 'lodash';
import { fetchTrendingStatuses, expandTrendingStatuses } from 'mastodon/actions/trends';
import DismissableBanner from 'mastodon/components/dismissable_banner';
import StatusList from 'mastodon/components/status_list';
import { getStatusList } from 'mastodon/selectors';
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'trending', 'items']),
statusIds: getStatusList(state, 'trending'),
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'trending', 'next']),
});
@ -46,7 +47,7 @@ class Statuses extends PureComponent {
return (
<>
<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>
<StatusList

View file

@ -34,7 +34,7 @@ class Tags extends PureComponent {
const banner = (
<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>
);

View file

@ -15,13 +15,14 @@ import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'mastodon/acti
import ColumnHeader from 'mastodon/components/column_header';
import StatusList from 'mastodon/components/status_list';
import Column from 'mastodon/features/ui/components/column';
import { getStatusList } from 'mastodon/selectors';
const messages = defineMessages({
heading: { id: 'column.favourites', defaultMessage: 'Favourites' },
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'favourites', 'items']),
statusIds: getStatusList(state, 'favourites'),
isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
});

View file

@ -0,0 +1,26 @@
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__wrapper'>
<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>
</div>
</DismissableBanner>
);

View file

@ -5,14 +5,16 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { Link } from 'react-router-dom';
import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
import { me } from 'mastodon/initial_state';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { expandHomeTimeline } from '../../actions/timelines';
@ -20,6 +22,7 @@ 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';
const messages = defineMessages({
@ -28,12 +31,40 @@ const messages = defineMessages({
hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
});
const getHomeFeedSpeed = createSelector([
state => state.getIn(['timelines', 'home', 'items'], ImmutableList()),
state => state.getIn(['timelines', 'home', 'pendingItems'], ImmutableList()),
state => state.get('statuses'),
], (statusIds, pendingStatusIds, statusMap) => {
const recentStatusIds = pendingStatusIds.size > 0 ? pendingStatusIds : statusIds;
const statuses = recentStatusIds.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([
state => state.getIn(['timelines', 'home', 'isLoading']),
state => state.getIn(['timelines', 'home', 'isPartial']),
getHomeFeedSpeed,
], (isLoading, isPartial, speed) =>
!isLoading && !isPartial // Only if the home feed has finished loading
&& (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 => ({
hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
isPartial: state.getIn(['timelines', 'home', 'isPartial']),
hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
showAnnouncements: state.getIn(['announcements', 'show']),
tooSlow: homeTooSlow(state),
});
class HomeTimeline extends PureComponent {
@ -52,6 +83,7 @@ class HomeTimeline extends PureComponent {
hasAnnouncements: PropTypes.bool,
unreadAnnouncements: PropTypes.number,
showAnnouncements: PropTypes.bool,
tooSlow: PropTypes.bool,
};
handlePin = () => {
@ -121,11 +153,11 @@ class HomeTimeline extends PureComponent {
};
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 { signedIn } = this.context.identity;
let announcementsButton = null;
let announcementsButton, banner;
if (hasAnnouncements) {
announcementsButton = (
@ -141,6 +173,10 @@ class HomeTimeline extends PureComponent {
);
}
if (tooSlow) {
banner = <ExplorePrompt />;
}
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
@ -160,11 +196,13 @@ class HomeTimeline extends PureComponent {
{signedIn ? (
<StatusListContainer
prepend={banner}
alwaysPrepend
trackScroll={!pinned}
scrollKey={`home_timeline-${columnId}`}
onLoadMore={this.handleLoadMore}
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}
/>
) : <NotSignedInIndicator />}

View file

@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import { Check } from 'mastodon/components/check';
import { Icon } from 'mastodon/components/icon';
import ArrowSmallRight from './arrow_small_right';
const Step = ({ label, description, icon, completed, onClick, href }) => {
const content = (
<>
@ -15,11 +17,9 @@ const Step = ({ label, description, icon, completed, onClick, href }) => {
<p>{description}</p>
</div>
{completed && (
<div className='onboarding__steps__item__progress'>
<Check />
</div>
)}
<div className={completed ? 'onboarding__steps__item__progress' : 'onboarding__steps__item__go'}>
{completed ? <Check /> : <ArrowSmallRight />}
</div>
</>
);

View file

@ -12,20 +12,11 @@ import Column from 'mastodon/components/column';
import ColumnBackButton from 'mastodon/components/column_back_button';
import { EmptyAccount } from 'mastodon/components/empty_account';
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 = () => {
const getAccount = makeGetAccount();
return state => ({
account: getAccount(state, me),
suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']),
});
};
const mapStateToProps = state => ({
suggestions: state.getIn(['suggestions', 'items']),
isLoading: state.getIn(['suggestions', 'isLoading']),
});
class Follows extends PureComponent {
@ -33,7 +24,6 @@ class Follows extends PureComponent {
onBack: PropTypes.func,
dispatch: PropTypes.func.isRequired,
suggestions: ImmutablePropTypes.list,
account: ImmutablePropTypes.map,
isLoading: PropTypes.bool,
multiColumn: PropTypes.bool,
};
@ -49,7 +39,7 @@ class Follows extends PureComponent {
}
render () {
const { onBack, isLoading, suggestions, account, multiColumn } = this.props;
const { onBack, isLoading, suggestions, multiColumn } = this.props;
let loadedContent;
@ -58,7 +48,7 @@ class Follows extends PureComponent {
} 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>;
} 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 (
@ -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>
</div>
<ProgressIndicator steps={7} completed={account.get('following_count') * 1} />
<div className='follow-recommendations'>
{loadedContent}
</div>

View file

@ -19,6 +19,7 @@ import { closeOnboarding } from 'mastodon/actions/onboarding';
import Column from 'mastodon/features/ui/components/column';
import { me } from 'mastodon/initial_state';
import { makeGetAccount } from 'mastodon/selectors';
import { assetHost } from 'mastodon/utils/config';
import ArrowSmallRight from './components/arrow_small_right';
import Step from './components/step';
@ -122,21 +123,22 @@ class Onboarding extends ImmutablePureComponent {
<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.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!' />} />
</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'>
<Link to='/explore' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
<ArrowSmallRight />
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
</Link>
</div>
<div className='onboarding__footer'>
<button className='link-button' onClick={this.handleClose}><FormattedMessage id='onboarding.actions.close' defaultMessage="Don't show this screen again" /></button>
<Link to='/home' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
<ArrowSmallRight />
</Link>
</div>
</div>

View file

@ -177,13 +177,13 @@ class Share extends PureComponent {
<div className='onboarding__links'>
<Link to='/home' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Take me to my home feed' />
<ArrowSmallRight />
<FormattedMessage id='onboarding.actions.go_to_home' defaultMessage='Go to your home feed' />
</Link>
<Link to='/explore' className='onboarding__link'>
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage='Take me to trending' />
<ArrowSmallRight />
<FormattedMessage id='onboarding.actions.go_to_explore' defaultMessage="See what's trending" />
</Link>
</div>

View file

@ -8,6 +8,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { getStatusList } from 'mastodon/selectors';
import { fetchPinnedStatuses } from '../../actions/pin_statuses';
import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import StatusList from '../../components/status_list';
@ -18,7 +20,7 @@ const messages = defineMessages({
});
const mapStateToProps = state => ({
statusIds: state.getIn(['status_lists', 'pins', 'items']),
statusIds: getStatusList(state, 'pins'),
hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
});

View file

@ -142,11 +142,8 @@ class PublicTimeline extends PureComponent {
<ColumnSettingsContainer columnId={columnId} />
</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
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' : ''}`}
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}

View file

@ -1,14 +1,16 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Link, withRouter } from 'react-router-dom';
import { connect } from 'react-redux';
import { openModal } from 'mastodon/actions/modal';
import { fetchServer } from 'mastodon/actions/server';
import { Avatar } from 'mastodon/components/avatar';
import { Icon } from 'mastodon/components/icon';
import { WordmarkLogo, SymbolLogo } from 'mastodon/components/logo';
import { registrationsOpen, me } from 'mastodon/initial_state';
@ -20,6 +22,10 @@ const Account = connect(state => ({
</Link>
));
const messages = defineMessages({
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
});
const mapStateToProps = (state) => ({
signupUrl: state.getIn(['server', 'server', 'registrations', 'url'], null) || '/auth/sign_up',
});
@ -28,6 +34,9 @@ const mapDispatchToProps = (dispatch) => ({
openClosedRegistrationsModal() {
dispatch(openModal({ modalType: 'CLOSED_REGISTRATIONS' }));
},
dispatchServer() {
dispatch(fetchServer());
}
});
class Header extends PureComponent {
@ -40,18 +49,26 @@ class Header extends PureComponent {
openClosedRegistrationsModal: PropTypes.func,
location: PropTypes.object,
signupUrl: PropTypes.string.isRequired,
dispatchServer: PropTypes.func,
intl: PropTypes.object.isRequired,
};
componentDidMount () {
const { dispatchServer } = this.props;
dispatchServer();
}
render () {
const { signedIn } = this.context.identity;
const { location, openClosedRegistrationsModal, signupUrl } = this.props;
const { location, openClosedRegistrationsModal, signupUrl, intl } = this.props;
let content;
if (signedIn) {
content = (
<>
{location.pathname !== '/publish' && <Link to='/publish' className='button'><FormattedMessage id='compose_form.publish_form' defaultMessage='Publish' /></Link>}
{location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' /></Link>}
{location.pathname !== '/publish' && <Link to='/publish' className='button button-secondary'><FormattedMessage id='compose_form.publish_form' defaultMessage='New post' /></Link>}
<Account />
</>
);
@ -74,6 +91,7 @@ class Header extends PureComponent {
content = (
<>
{location.pathname !== '/search' && <Link to='/search' className='button button-secondary' aria-label={intl.formatMessage(messages.search)}><Icon id='search' /></Link>}
{signupButton}
<a href='/auth/sign_in' className='button button-tertiary'><FormattedMessage id='sign_in_banner.sign_in' defaultMessage='Login' /></a>
</>
@ -96,4 +114,4 @@ class Header extends PureComponent {
}
export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Header));
export default injectIntl(withRouter(connect(mapStateToProps, mapDispatchToProps)(Header)));

View file

@ -52,6 +52,7 @@
"account.mute_notifications_short": "Mute notifications",
"account.mute_short": "Mute",
"account.muted": "Muted",
"account.no_bio": "No description provided.",
"account.open_original_page": "Open original page",
"account.posts": "Posts",
"account.posts_with_replies": "Posts and replies",
@ -146,7 +147,7 @@
"compose_form.poll.switch_to_multiple": "Change poll to allow multiple choices",
"compose_form.poll.switch_to_single": "Change poll to allow for a single choice",
"compose_form.publish": "Publish",
"compose_form.publish_form": "Publish",
"compose_form.publish_form": "New post",
"compose_form.publish_loud": "{publish}!",
"compose_form.save_changes": "Save changes",
"compose_form.sensitive.hide": "{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}",
@ -197,9 +198,9 @@
"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.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_statuses": "These posts from this and other servers in the decentralized network are gaining traction on this server right now.",
"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_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 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 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.",
"embed.instructions": "Embed this post on your website by copying the code below.",
"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.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.home": "Your home timeline is empty! Follow more people to fill it up. {suggestions}",
"empty_column.home.suggestions": "See some suggestions",
"empty_column.home": "Your home timeline is empty! Follow more people to fill it up.",
"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.mutes": "You haven't muted any users yet.",
@ -292,9 +292,13 @@
"hashtag.column_settings.tag_toggle": "Include additional tags for this column",
"hashtag.follow": "Follow 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.show_reblogs": "Show boosts",
"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.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.",
@ -451,28 +455,27 @@
"notifications_permission_banner.title": "Never miss a thing",
"onboarding.action.back": "Take me back",
"onboarding.actions.back": "Take me back",
"onboarding.actions.close": "Don't show this screen again",
"onboarding.actions.go_to_explore": "See what's trending",
"onboarding.actions.go_to_home": "Go to your home feed",
"onboarding.actions.go_to_explore": "Take me to trending",
"onboarding.actions.go_to_home": "Take me to my home feed",
"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.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.title": "Popular on Mastodon",
"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": "Personalize your home feed",
"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.next_steps": "Possible next steps:",
"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.skip": "Want to skip right ahead?",
"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": "Don't need help getting started?",
"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.title": "Find at least {count, plural, one {one person} other {# people}} to follow",
"onboarding.steps.publish_status.body": "Say hello to the world.",
"onboarding.steps.follow_people.body": "Following interesting people is what Mastodon is all about.",
"onboarding.steps.follow_people.title": "Personalize your home feed",
"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.setup_profile.body": "Others are more likely to interact with you with a filled out profile.",
"onboarding.steps.setup_profile.title": "Customize your profile",
"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.setup_profile.body": "Boost your interactions by having a comprehensive 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.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.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!",

View file

@ -137,3 +137,7 @@ export const getAccountHidden = createSelector([
], (hidden, followingOrRequested, isSelf) => {
return hidden && !(isSelf || followingOrRequested);
});
export const getStatusList = createSelector([
(state, type) => state.getIn(['status_lists', type, 'items']),
], (items) => items.toList());

View file

@ -653,11 +653,6 @@ html {
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,
.reply-indicator__content {
a {

View file

@ -133,12 +133,13 @@
color: $darker-text-color;
background: transparent;
padding: 6px 17px;
border: 1px solid $ui-primary-color;
border: 1px solid lighten($ui-base-color, 12%);
&:active,
&:focus,
&:hover {
border-color: lighten($ui-primary-color, 4%);
background: lighten($ui-base-color, 4%);
border-color: lighten($ui-base-color, 16%);
color: lighten($darker-text-color, 4%);
text-decoration: none;
}
@ -1533,12 +1534,37 @@ body > [data-popper-placement] {
}
&__note {
font-size: 14px;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 1;
-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;
}
}
}
}
@ -2636,13 +2662,15 @@ $ui-header-height: 55px;
.onboarding__link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: $highlight-text-color;
background: lighten($ui-base-color, 4%);
border-radius: 8px;
padding: 10px;
padding: 10px 15px;
box-sizing: border-box;
font-size: 17px;
font-size: 14px;
font-weight: 500;
height: 56px;
text-decoration: none;
@ -2704,6 +2732,7 @@ $ui-header-height: 55px;
align-items: center;
gap: 10px;
padding: 10px;
padding-inline-end: 15px;
margin-bottom: 2px;
text-decoration: none;
text-align: start;
@ -2716,14 +2745,14 @@ $ui-header-height: 55px;
&__icon {
flex: 0 0 auto;
background: $ui-base-color;
border-radius: 50%;
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
color: $dark-text-color;
color: $highlight-text-color;
font-size: 1.2rem;
@media screen and (width >= 600px) {
display: flex;
@ -2747,16 +2776,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 {
flex: 1 1 auto;
line-height: 18px;
line-height: 20px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
h6 {
color: $primary-text-color;
font-weight: 700;
color: $highlight-text-color;
font-weight: 500;
font-size: 14px;
overflow: hidden;
text-overflow: ellipsis;
}
@ -3120,7 +3166,7 @@ $ui-header-height: 55px;
.column-back-button {
box-sizing: border-box;
width: 100%;
background: lighten($ui-base-color, 4%);
background: $ui-base-color;
border-radius: 4px 4px 0 0;
color: $highlight-text-color;
cursor: pointer;
@ -3128,6 +3174,7 @@ $ui-header-height: 55px;
font-size: 16px;
line-height: inherit;
border: 0;
border-bottom: 1px solid lighten($ui-base-color, 8%);
text-align: unset;
padding: 15px;
margin: 0;
@ -3140,7 +3187,7 @@ $ui-header-height: 55px;
}
.column-header__back-button {
background: lighten($ui-base-color, 4%);
background: $ui-base-color;
border: 0;
font-family: inherit;
color: $highlight-text-color;
@ -3175,7 +3222,7 @@ $ui-header-height: 55px;
padding: 15px;
position: absolute;
inset-inline-end: 0;
top: -48px;
top: -50px;
}
.react-toggle {
@ -3856,7 +3903,8 @@ a.status-card.compact:hover {
.column-header {
display: flex;
font-size: 16px;
background: lighten($ui-base-color, 4%);
background: $ui-base-color;
border-bottom: 1px solid lighten($ui-base-color, 8%);
border-radius: 4px 4px 0 0;
flex: 0 0 auto;
cursor: pointer;
@ -3911,7 +3959,7 @@ a.status-card.compact:hover {
}
.column-header__button {
background: lighten($ui-base-color, 4%);
background: $ui-base-color;
border: 0;
color: $darker-text-color;
cursor: pointer;
@ -3919,16 +3967,15 @@ a.status-card.compact:hover {
padding: 0 15px;
&:hover {
color: lighten($darker-text-color, 7%);
color: lighten($darker-text-color, 4%);
}
&.active {
color: $primary-text-color;
background: lighten($ui-base-color, 8%);
background: lighten($ui-base-color, 4%);
&:hover {
color: $primary-text-color;
background: lighten($ui-base-color, 8%);
}
}
@ -3942,6 +3989,7 @@ a.status-card.compact:hover {
max-height: 70vh;
overflow: hidden;
overflow-y: auto;
border-bottom: 1px solid lighten($ui-base-color, 8%);
color: $darker-text-color;
transition: max-height 150ms ease-in-out, opacity 300ms linear;
opacity: 1;
@ -3961,13 +4009,13 @@ a.status-card.compact:hover {
height: 0;
background: transparent;
border: 0;
border-top: 1px solid lighten($ui-base-color, 12%);
border-top: 1px solid lighten($ui-base-color, 8%);
margin: 10px 0;
}
}
.column-header__collapsible-inner {
background: lighten($ui-base-color, 8%);
background: $ui-base-color;
padding: 15px;
}
@ -4384,17 +4432,13 @@ a.status-card.compact:hover {
color: $primary-text-color;
margin-bottom: 4px;
display: block;
background-color: $base-overlay-background;
text-transform: uppercase;
background-color: rgba($black, 0.45);
backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
font-size: 11px;
font-weight: 500;
padding: 4px;
text-transform: uppercase;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
.setting-toggle {
@ -4454,6 +4498,7 @@ a.status-card.compact:hover {
.follow_requests-unlocked_explanation {
background: darken($ui-base-color, 4%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
contain: initial;
flex-grow: 0;
}
@ -6138,6 +6183,7 @@ a.status-card.compact:hover {
display: block;
color: $white;
background: rgba($black, 0.65);
backdrop-filter: blur(10px) saturate(180%) contrast(75%) brightness(70%);
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
@ -6815,24 +6861,6 @@ a.status-card.compact:hover {
}
}
}
&.directory__section-headline {
background: darken($ui-base-color, 2%);
border-bottom-color: transparent;
a,
button {
&.active {
&::before {
display: none;
}
&::after {
border-color: transparent transparent darken($ui-base-color, 7%);
}
}
}
}
}
.filter-form {
@ -7347,7 +7375,6 @@ noscript {
.account__header {
overflow: hidden;
background: lighten($ui-base-color, 4%);
&.inactive {
opacity: 0.5;
@ -7369,6 +7396,7 @@ noscript {
height: 145px;
position: relative;
background: darken($ui-base-color, 4%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
img {
object-fit: cover;
@ -7382,7 +7410,7 @@ noscript {
&__bar {
position: relative;
padding: 0 20px;
border-bottom: 1px solid lighten($ui-base-color, 12%);
border-bottom: 1px solid lighten($ui-base-color, 8%);
.avatar {
display: block;
@ -7391,7 +7419,7 @@ noscript {
.account__avatar {
background: darken($ui-base-color, 8%);
border: 2px solid lighten($ui-base-color, 4%);
border: 2px solid $ui-base-color;
}
}
}
@ -8718,27 +8746,80 @@ noscript {
}
.dismissable-banner {
background: $ui-base-color;
border-bottom: 1px solid lighten($ui-base-color, 8%);
display: flex;
align-items: center;
gap: 30px;
position: relative;
margin: 10px;
margin-bottom: 5px;
border-radius: 8px;
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 {
flex: 1 1 auto;
padding: 20px 15px;
cursor: default;
font-size: 14px;
line-height: 18px;
padding: 15px;
font-size: 15px;
line-height: 22px;
font-weight: 500;
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;
flex-wrap: wrap;
gap: 4px;
&__wrapper {
display: flex;
margin-top: 30px;
}
.button {
display: block;
flex-grow: 1;
}
}
.button-tertiary {
background: rgba($ui-base-color, 0.15);
backdrop-filter: blur(8px);
}
}
&__action {
padding: 15px;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
position: absolute;
inset-inline-end: 0;
top: 0;
padding: 10px;
.icon-button {
color: $highlight-text-color;
}
}
}

111
app/lib/attachment_batch.rb Normal file
View file

@ -0,0 +1,111 @@
# frozen_string_literal: true
class AttachmentBatch
# Maximum amount of objects you can delete in an S3 API call. It's
# important to remember that this does not correspond to the number
# of records in the batch, since records can have multiple attachments
LIMIT = 1_000
# Attributes generated and maintained by Paperclip (not all of them
# are always used on every class, however)
NULLABLE_ATTRIBUTES = %w(
file_name
content_type
file_size
fingerprint
created_at
updated_at
).freeze
# Styles that are always present even when not explicitly defined
BASE_STYLES = %i(original).freeze
attr_reader :klass, :records, :storage_mode
def initialize(klass, records)
@klass = klass
@records = records
@storage_mode = Paperclip::Attachment.default_options[:storage]
@attachment_names = klass.attachment_definitions.keys
end
def delete
remove_files
batch.delete_all
end
def clear
remove_files
batch.update_all(nullified_attributes) # rubocop:disable Rails/SkipsModelValidations
end
private
def batch
klass.where(id: records.map(&:id))
end
def remove_files
keys = []
logger.debug { "Preparing to delete attachments for #{records.size} records" }
records.each do |record|
@attachment_names.each do |attachment_name|
attachment = record.public_send(attachment_name)
styles = BASE_STYLES | attachment.styles.keys
next if attachment.blank?
styles.each do |style|
case @storage_mode
when :s3
logger.debug { "Adding #{attachment.path(style)} to batch for deletion" }
keys << attachment.style_name_as_path(style)
when :filesystem
logger.debug { "Deleting #{attachment.path(style)}" }
path = attachment.path(style)
FileUtils.remove_file(path, true)
begin
FileUtils.rmdir(File.dirname(path), parents: true)
rescue Errno::EEXIST, Errno::ENOTEMPTY, Errno::ENOENT, Errno::EINVAL, Errno::ENOTDIR, Errno::EACCES
# Ignore failure to delete a directory, with the same ignored errors
# as Paperclip
end
when :fog
logger.debug { "Deleting #{attachment.path(style)}" }
attachment.directory.files.new(key: attachment.path(style)).destroy
end
end
end
end
return unless storage_mode == :s3
# We can batch deletes over S3, but there is a limit of how many
# objects can be processed at once, so we have to potentially
# separate them into multiple calls.
keys.each_slice(LIMIT) do |keys_slice|
logger.debug { "Deleting #{keys_slice.size} objects" }
bucket.delete_objects(delete: {
objects: keys_slice.map { |key| { key: key } },
quiet: true,
})
end
end
def bucket
@bucket ||= records.first.public_send(@attachment_names.first).s3_bucket
end
def nullified_attributes
@attachment_names.flat_map { |attachment_name| NULLABLE_ATTRIBUTES.map { |attribute| "#{attachment_name}_#{attribute}" } & klass.column_names }.index_with(nil)
end
def logger
Rails.logger
end
end

View file

@ -28,8 +28,9 @@ class RequestPool
end
MAX_IDLE_TIME = 30
WAIT_TIMEOUT = 5
MAX_POOL_SIZE = ENV.fetch('MAX_REQUEST_POOL_SIZE', 512).to_i
REAPER_FREQUENCY = 30
WAIT_TIMEOUT = 5
class Connection
attr_reader :site, :last_used_at, :created_at, :in_use, :dead, :fresh
@ -98,7 +99,7 @@ class RequestPool
def initialize
@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
end

View file

@ -79,7 +79,7 @@ class TextFormatter
cutoff = url[prefix.length..-1].length > 30
<<~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
rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
h(entity[:url])
@ -122,7 +122,7 @@ class TextFormatter
display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username
<<~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
end

View file

@ -15,15 +15,15 @@ class Vacuum::MediaAttachmentsVacuum
private
def vacuum_cached_files!
media_attachments_past_retention_period.find_each do |media_attachment|
media_attachment.file.destroy
media_attachment.thumbnail.destroy
media_attachment.save
media_attachments_past_retention_period.find_in_batches do |media_attachments|
AttachmentBatch.new(MediaAttachment, media_attachments).clear
end
end
def vacuum_orphaned_records!
orphaned_media_attachments.in_batches.destroy_all
orphaned_media_attachments.find_in_batches do |media_attachments|
AttachmentBatch.new(MediaAttachment, media_attachments).delete
end
end
def media_attachments_past_retention_period

View file

@ -32,14 +32,8 @@ class AccountConversation < ApplicationRecord
end
def participant_accounts
@participant_accounts ||= begin
if participant_account_ids.empty?
[account]
else
participants = Account.where(id: participant_account_ids).to_a
participants.empty? ? [account] : participants
end
end
@participant_accounts ||= Account.where(id: participant_account_ids).to_a
@participant_accounts.presence || [account]
end
class << self

View file

@ -15,7 +15,7 @@ class UserSettings
setting :show_application, default: true
setting :default_language, default: nil
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 :hide_followers_count, default: false
setting :visible_reactions, default: 6
@ -80,7 +80,10 @@ class UserSettings
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?
@original_hash.delete(key)

View file

@ -24,6 +24,8 @@ class Webhook < ApplicationRecord
status.updated
).freeze
attr_writer :current_account
scope :enabled, -> { where(enabled: true) }
validates :url, presence: true, url: true
@ -31,6 +33,7 @@ class Webhook < ApplicationRecord
validates :events, presence: true
validate :validate_events
validate :validate_permissions
validate :validate_template
before_validation :strip_events
@ -48,12 +51,31 @@ class Webhook < ApplicationRecord
update!(enabled: false)
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
def validate_events
errors.add(:events, :invalid) if events.any? { |e| EVENTS.exclude?(e) }
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
return if template.blank?

View file

@ -14,7 +14,7 @@ class WebhookPolicy < ApplicationPolicy
end
def update?
role.can?(:manage_webhooks)
role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) }
end
def enable?
@ -30,6 +30,6 @@ class WebhookPolicy < ApplicationPolicy
end
def destroy?
role.can?(:manage_webhooks)
role.can?(:manage_webhooks) && record.required_permissions.all? { |permission| role.can?(permission) }
end
end

View file

@ -10,14 +10,6 @@ class ClearDomainMediaService < BaseService
private
def invalidate_association_caches!(status_ids)
# Normally, associated models of a status are immutable (except for accounts)
# so they are aggressively cached. After updating the media attachments to no
# longer point to a local file, we need to clear the cache to make those
# changes appear in the API and UI
Rails.cache.delete_multi(status_ids.map { |id| "statuses/#{id}" })
end
def clear_media!
clear_account_images!
clear_account_attachments!
@ -25,31 +17,21 @@ class ClearDomainMediaService < BaseService
end
def clear_account_images!
blocked_domain_accounts.reorder(nil).find_each do |account|
account.avatar.destroy if account.avatar&.exists?
account.header.destroy if account.header&.exists?
account.save
blocked_domain_accounts.reorder(nil).find_in_batches do |accounts|
AttachmentBatch.new(Account, accounts).clear
end
end
def clear_account_attachments!
media_from_blocked_domain.reorder(nil).find_in_batches do |attachments|
affected_status_ids = []
attachments.each do |attachment|
affected_status_ids << attachment.status_id if attachment.status_id.present?
attachment.file.destroy if attachment.file&.exists?
attachment.type = :unknown
attachment.save
end
invalidate_association_caches!(affected_status_ids) unless affected_status_ids.empty?
AttachmentBatch.new(MediaAttachment, attachments).clear
end
end
def clear_emojos!
emojis_from_blocked_domains.destroy_all
emojis_from_blocked_domains.find_in_batches do |custom_emojis|
AttachmentBatch.new(CustomEmoji, custom_emojis).delete
end
end
def blocked_domain

View file

@ -12,6 +12,7 @@ class RemoveStatusService < BaseService
# @option [Boolean] :immediate
# @option [Boolean] :preserve
# @option [Boolean] :original_removed
# @option [Boolean] :skip_streaming
def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
@status = status
@ -53,6 +54,9 @@ class RemoveStatusService < BaseService
private
# The following FeedManager calls all do not result in redis publishes for
# streaming, as the `:update` option is false
def remove_from_self
FeedManager.instance.unpush_from_home(@account, @status)
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
# that may not follow the account
return if skip_streaming?
@status.active_mentions.find_each do |mention|
redis.publish("timeline:#{mention.account_id}", @payload)
end
@ -105,7 +111,7 @@ class RemoveStatusService < BaseService
# 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|
RemoveStatusService.new.call(reblog, original_removed: true)
RemoveStatusService.new.call(reblog, original_removed: true, skip_streaming: skip_streaming?)
end
end
@ -116,6 +122,8 @@ class RemoveStatusService < BaseService
return unless @status.public_visibility?
return if skip_streaming?
@status.tags.map(&:name).each do |hashtag|
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload)
redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local?
@ -125,6 +133,8 @@ class RemoveStatusService < BaseService
def remove_from_public
return unless @status.public_visibility?
return if skip_streaming?
redis.publish('timeline:public', @payload)
redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload)
end
@ -132,6 +142,8 @@ class RemoveStatusService < BaseService
def remove_from_media
return unless @status.public_visibility?
return if skip_streaming?
redis.publish('timeline:public:media', @payload)
redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload)
end
@ -151,4 +163,8 @@ class RemoveStatusService < BaseService
def permanently?
@options[:immediate] || !(@options[:preserve] || @status.reported?)
end
def skip_streaming?
!!@options[:skip_streaming]
end
end

View file

@ -1,7 +1,7 @@
- content_for :page_title do
= t('.title', domain: Addressable::IDNA.to_unicode(@domain_block.domain))
= simple_form_for @domain_block, url: admin_domain_blocks_path(@domain_block) do |f|
= simple_form_for @domain_block, url: admin_domain_blocks_path, method: :post do |f|
%p.hint= t('.preamble_html', domain: Addressable::IDNA.to_unicode(@domain_block.domain))
%ul.hint

View file

@ -5,7 +5,7 @@
= f.input :url, wrapper: :with_block_label, input_html: { placeholder: 'https://' }
.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
= f.input :template, wrapper: :with_block_label, input_html: { placeholder: '{ "content": "Hello {{object.username}}" }' }

View file

@ -24,7 +24,7 @@ class Scheduler::UserCleanupScheduler
def clean_discarded_statuses!
Status.unscoped.discarded.where('deleted_at <= ?', 30.days.ago).find_in_batches do |statuses|
RemovalWorker.push_bulk(statuses) do |status|
[status.id, { 'immediate' => true }]
[status.id, { 'immediate' => true, 'skip_streaming' => true }]
end
end
end

View file

@ -6,12 +6,4 @@ end
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
require 'bundler/setup' # Set up gems listed in the Gemfile.
require 'bootsnap' # 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
)
require 'bootsnap/setup' # Speed up boot time by caching expensive operations.

View file

@ -53,3 +53,7 @@ en:
position:
elevated: cannot be higher than 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

View file

@ -87,6 +87,7 @@ namespace :api, format: false do
resources :conversations, only: [:index, :destroy] do
member do
post :read
post :unread
end
end

View file

@ -55,6 +55,11 @@ class Sanitize
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|
return unless env[:node_name] == 'a'
@ -82,9 +87,10 @@ 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),
attributes: {
'a' => %w(href rel class title),
'a' => %w(href rel class title translate),
'abbr' => %w(title),
'span' => %w(class),
'span' => %w(class translate),
'blockquote' => %w(cite),
'ol' => %w(start reversed),
'li' => %w(value),
@ -105,6 +111,7 @@ class Sanitize
transformers: [
CLASS_WHITELIST_TRANSFORMER,
IMG_TAG_TRANSFORMER,
TRANSLATE_TRANSFORMER,
UNSUPPORTED_HREF_TRANSFORMER,
]
)
@ -160,7 +167,7 @@ class Sanitize
MASTODON_OUTGOING ||= freeze_config MASTODON_STRICT.merge(
attributes: merge(
MASTODON_STRICT[:attributes],
'a' => %w(href rel class title target)
'a' => %w(href rel class title target translate)
),
add_attributes: {},
@ -168,6 +175,7 @@ class Sanitize
transformers: [
CLASS_WHITELIST_TRANSFORMER,
IMG_TAG_TRANSFORMER,
TRANSLATE_TRANSFORMER,
UNSUPPORTED_HREF_TRANSFORMER,
LINK_REL_TRANSFORMER,
LINK_TARGET_TRANSFORMER,

View file

@ -23,7 +23,8 @@ RSpec.describe Admin::ChangeEmailsController do
describe 'GET #update' 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
it 'returns http success' do

View file

@ -38,7 +38,7 @@ RSpec.describe Admin::ConfirmationsController do
let!(:user) { Fabricate(:user, confirmed_at: confirmed_at) }
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
context 'when email is not confirmed' do

View file

@ -19,7 +19,8 @@ RSpec.describe Admin::Disputes::AppealsController do
let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
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 }
end
@ -40,7 +41,8 @@ RSpec.describe Admin::Disputes::AppealsController do
let(:current_user) { Fabricate(:user, role: UserRole.find_by(name: 'Admin')) }
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 }
end

View file

@ -37,7 +37,7 @@ RSpec.describe Admin::DomainAllowsController do
describe 'DELETE #destroy' do
it 'disallows the domain' do
service = double(call: true)
service = instance_double(UnallowDomainService, call: true)
allow(UnallowDomainService).to receive(:new).and_return(service)
domain_allow = Fabricate(:domain_allow)
delete :destroy, params: { id: domain_allow.id }

View file

@ -213,7 +213,7 @@ RSpec.describe Admin::DomainBlocksController do
describe 'DELETE #destroy' do
it 'unblocks the domain' do
service = double(call: true)
service = instance_double(UnblockDomainService, call: true)
allow(UnblockDomainService).to receive(:new).and_return(service)
domain_block = Fabricate(:domain_block)
delete :destroy, params: { id: domain_block.id }

View file

@ -62,17 +62,10 @@ describe Admin::Reports::ActionsController do
end
shared_examples 'common behavior' do
it 'closes the report' do
expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
end
it 'closes the report and redirects' do
expect { subject }.to mark_report_action_taken.and create_target_account_strike
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
end
it 'redirects' do
subject
expect(response).to redirect_to(admin_reports_path)
end
@ -81,20 +74,21 @@ describe Admin::Reports::ActionsController do
{ report_id: report.id }
end
it 'closes the report' do
expect { subject }.to change { report.reload.action_taken? }.from(false).to(true)
end
it 'closes the report and redirects' do
expect { subject }.to mark_report_action_taken.and create_target_account_strike
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 ''
end
it 'redirects' do
subject
expect(response).to redirect_to(admin_reports_path)
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
shared_examples 'all action types' do

View file

@ -48,7 +48,7 @@ describe Admin::WebhooksController do
end
context 'with an existing record' do
let!(:webhook) { Fabricate :webhook }
let!(:webhook) { Fabricate(:webhook, events: ['account.created', 'report.created']) }
describe 'GET #show' do
it 'returns http success and renders view' do
@ -82,7 +82,7 @@ describe Admin::WebhooksController do
end.to_not change(webhook, :url)
expect(response).to have_http_status(:success)
expect(response).to render_template(:show)
expect(response).to render_template(:edit)
end
end

View file

@ -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

View file

@ -18,6 +18,7 @@ RSpec.describe Api::V1::ConversationsController do
before do
PostStatusService.new.call(other.account, text: 'Hey @alice', visibility: 'direct')
PostStatusService.new.call(user.account, text: 'Hey, nobody here', visibility: 'direct')
end
it 'returns http success' do
@ -33,7 +34,8 @@ RSpec.describe Api::V1::ConversationsController do
it 'returns conversations' do
get :index
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
context 'with since_id' do
@ -41,7 +43,7 @@ RSpec.describe Api::V1::ConversationsController do
it 'returns conversations' do
get :index, params: { since_id: Mastodon::Snowflake.id_at(1.hour.ago, with_random: false) }
json = body_as_json
expect(json.size).to eq 1
expect(json.size).to eq 2
end
end

View file

@ -67,24 +67,13 @@ RSpec.describe Api::V1::NotificationsController do
get :index
end
it 'returns http success' do
it 'returns expected notification types', :aggregate_failures do
expect(response).to have_http_status(200)
end
it 'includes reblog' do
expect(body_as_json.pluck(:type)).to include 'reblog'
end
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'
expect(body_json_types).to include 'reblog'
expect(body_json_types).to include 'mention'
expect(body_json_types).to include 'favourite'
expect(body_json_types).to include 'follow'
end
end
@ -93,12 +82,14 @@ RSpec.describe Api::V1::NotificationsController do
get :index, params: { account_id: third.account.id }
end
it 'returns http success' do
it 'returns only notifications from specified user', :aggregate_failures do
expect(response).to have_http_status(200)
expect(body_json_account_ids.uniq).to eq [third.account.id.to_s]
end
it 'returns only notifications from specified user' do
expect(body_as_json.map { |x| x[:account][:id] }.uniq).to eq [third.account.id.to_s]
def body_json_account_ids
body_as_json.map { |x| x[:account][:id] }
end
end
@ -107,27 +98,23 @@ RSpec.describe Api::V1::NotificationsController do
get :index, params: { account_id: 'foo' }
end
it 'returns http success' do
it 'returns nothing', :aggregate_failures do
expect(response).to have_http_status(200)
end
it 'returns nothing' do
expect(body_as_json.size).to eq 0
end
end
describe 'with excluded_types param' do
describe 'with exclude_types param' do
before do
get :index, params: { exclude_types: %w(mention) }
end
it 'returns http success' do
it 'returns everything but excluded type', :aggregate_failures do
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.pluck(:type).uniq).to_not include 'mention'
expect(body_json_types.uniq).to_not include 'mention'
end
end
@ -136,13 +123,15 @@ RSpec.describe Api::V1::NotificationsController do
get :index, params: { types: %w(mention) }
end
it 'returns http success' do
it 'returns only requested type', :aggregate_failures do
expect(response).to have_http_status(200)
end
it 'returns only requested type' do
expect(body_as_json.pluck(:type).uniq).to eq ['mention']
expect(body_json_types.uniq).to eq ['mention']
end
end
def body_json_types
body_as_json.pluck(:type)
end
end
end

View file

@ -23,7 +23,8 @@ RSpec.describe Api::V1::ReportsController do
let(:rule_ids) { nil }
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 }
end

View file

@ -23,6 +23,7 @@ describe Api::V1::Statuses::HistoriesController do
it 'returns http success' do
expect(response).to have_http_status(200)
expect(body_as_json.size).to_not be 0
end
end
end

View file

@ -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

View file

@ -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

View file

@ -55,5 +55,13 @@ RSpec.describe Api::V2::Admin::AccountsController do
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

View file

@ -26,7 +26,7 @@ describe Api::Web::EmbedsController do
context 'when fails to find status' do
let(:url) { 'https://host.test/oembed.html' }
let(:service_instance) { double('fetch_oembed_service') }
let(:service_instance) { instance_double(FetchOEmbedService) }
before do
allow(FetchOEmbedService).to receive(:new) { service_instance }

View file

@ -127,7 +127,8 @@ RSpec.describe Auth::SessionsController do
before do
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)
post :create, params: { user: { email: user.email, password: user.password } }
end

View file

@ -28,7 +28,7 @@ describe AuthorizeInteractionsController do
end
it 'renders error when account cant be found' do
service = double
service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('missing@hostname').and_return(nil)
@ -40,7 +40,7 @@ describe AuthorizeInteractionsController do
it 'sets resource from url' do
account = Fabricate(:account)
service = double
service = instance_double(ResolveURLService)
allow(ResolveURLService).to receive(:new).and_return(service)
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
account = Fabricate(:account)
service = double
service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('found@hostname').and_return(account)
@ -82,7 +82,7 @@ describe AuthorizeInteractionsController do
end
it 'shows error when account not found' do
service = double
service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('user@hostname').and_return(nil)
@ -94,7 +94,7 @@ describe AuthorizeInteractionsController do
it 'follows account when found' do
target_account = Fabricate(:account)
service = double
service = instance_double(ResolveAccountService)
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('user@hostname').and_return(target_account)

View file

@ -14,7 +14,8 @@ RSpec.describe Disputes::AppealsController do
let(:strike) { Fabricate(:account_warning, target_account: current_user.account) }
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' } }
end

View file

@ -75,23 +75,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
it 'returns http success' do
it 'renders status successfully', :aggregate_failures 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 'returns public Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'public'
end
it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@ -100,25 +88,13 @@ describe StatusesController do
context 'with JSON' do
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 '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'
end
it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@ -199,23 +175,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
it 'returns http success' do
it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@ -224,27 +188,12 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
it 'returns http success' do
it 'renders ActivityPub Note object successfully', :aggregate_failures 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 'returns private Cache-Control header' do
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'
end
it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@ -263,23 +212,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
it 'returns http success' do
it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@ -288,27 +225,12 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
it 'returns http success' do
it 'renders ActivityPub Note object successfully', :aggregate_failures 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 'returns private Cache-Control header' do
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'
end
it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@ -350,23 +272,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
it 'returns http success' do
it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@ -375,27 +285,12 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
it 'returns http success' do
it 'renders ActivityPub Note object successfully' 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 'returns private Cache-Control header' do
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'
end
it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@ -463,23 +358,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
it 'returns http success' do
it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@ -488,25 +371,13 @@ describe StatusesController do
context 'with JSON' do
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 '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'
end
it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@ -525,23 +396,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
it 'returns http success' do
it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@ -550,27 +409,12 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
it 'returns http success' do
it 'renders ActivityPub Note object successfully' 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 'returns private Cache-Control header' do
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'
end
it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@ -612,23 +456,11 @@ describe StatusesController do
context 'with HTML' do
let(:format) { 'html' }
it 'returns http success' do
it 'renders status successfully', :aggregate_failures 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 'returns private Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'private'
end
it 'renders status' do
expect(response).to render_template(:show)
expect(response.body).to include status.text
end
@ -637,27 +469,12 @@ describe StatusesController do
context 'with JSON' do
let(:format) { 'json' }
it 'returns http success' do
it 'renders ActivityPub Note object', :aggregate_failures 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 'returns private Cache-Control header' do
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'
end
it 'renders ActivityPub Note object' do
json = body_as_json
expect(json[:content]).to include status.text
end
@ -933,23 +750,11 @@ describe StatusesController do
get :embed, params: { account_username: status.account.username, id: status.id }
end
it 'returns http success' do
it 'renders status successfully', :aggregate_failures 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 'returns public Cache-Control header' do
expect(response.headers['Cache-Control']).to include 'public'
end
it 'renders status' do
expect(response).to render_template(:embed)
expect(response.body).to include status.text
end

View file

@ -53,7 +53,7 @@ describe 'blocking domains through the moderation interface' do
# Confirming updates the block
click_on I18n.t('admin.domain_blocks.confirm_suspension.confirm')
expect(domain_block.reload.severity).to eq 'silence'
expect(domain_block.reload.severity).to eq 'suspend'
end
end
@ -72,7 +72,7 @@ describe 'blocking domains through the moderation interface' do
# Confirming updates the block
click_on I18n.t('admin.domain_blocks.confirm_suspension.confirm')
expect(domain_block.reload.severity).to eq 'silence'
expect(domain_block.reload.severity).to eq 'suspend'
end
end
end

View file

@ -117,42 +117,42 @@ describe StatusesHelper do
describe '#style_classes' do
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, false, false, false)
expect(classes).to eq 'entry'
end
it do
status = double(reblog?: true)
status = instance_double(Status, reblog?: true)
classes = helper.style_classes(status, false, false, false)
expect(classes).to eq 'entry entry-reblog'
end
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, true, false, false)
expect(classes).to eq 'entry entry-predecessor'
end
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, false, true, false)
expect(classes).to eq 'entry entry-successor'
end
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
classes = helper.style_classes(status, false, false, true)
expect(classes).to eq 'entry entry-center'
end
it do
status = double(reblog?: true)
status = instance_double(Status, reblog?: true)
classes = helper.style_classes(status, true, true, true)
expect(classes).to eq 'entry entry-predecessor entry-reblog entry-successor entry-center'
@ -161,35 +161,35 @@ describe StatusesHelper do
describe '#microformats_classes' do
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
classes = helper.microformats_classes(status, false, false)
expect(classes).to eq ''
end
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
classes = helper.microformats_classes(status, true, false)
expect(classes).to eq 'p-in-reply-to'
end
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
classes = helper.microformats_classes(status, false, true)
expect(classes).to eq 'p-comment'
end
it do
status = double(reblog?: true)
status = instance_double(Status, reblog?: true)
classes = helper.microformats_classes(status, true, false)
expect(classes).to eq 'p-in-reply-to p-repost-of'
end
it do
status = double(reblog?: true)
status = instance_double(Status, reblog?: true)
classes = helper.microformats_classes(status, true, true)
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
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, false, false, false)
expect(css_class).to eq 'h-entry'
end
it do
status = double(reblog?: true)
status = instance_double(Status, reblog?: true)
css_class = helper.microformats_h_class(status, false, false, false)
expect(css_class).to eq 'h-cite'
end
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, true, false, false)
expect(css_class).to eq 'h-cite'
end
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, false, true, false)
expect(css_class).to eq 'h-cite'
end
it do
status = double(reblog?: false)
status = instance_double(Status, reblog?: false)
css_class = helper.microformats_h_class(status, false, false, true)
expect(css_class).to eq ''
end
it do
status = double(reblog?: true)
status = instance_double(Status, reblog?: true)
css_class = helper.microformats_h_class(status, true, true, true)
expect(css_class).to eq 'h-cite'

View file

@ -26,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Add do
end
context 'when status was not known before' do
let(:service_stub) { double }
let(:service_stub) { instance_double(ActivityPub::FetchRemoteStatusService) }
let(:json) do
{

View file

@ -26,7 +26,7 @@ RSpec.describe ActivityPub::Activity::Move do
stub_request(:post, old_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(service_stub).to receive(:call).and_return(returned_account)
end

View file

@ -48,16 +48,25 @@ describe RequestPool do
expect(subject.size).to be > 1
end
it 'closes idle connections' do
stub_request(:get, 'http://example.com/').to_return(status: 200, body: 'Hello!')
subject.with('http://example.com') do |http_client|
http_client.get('/').flush
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!')
end
expect(subject.size).to eq 1
sleep RequestPool::MAX_IDLE_TIME + 30 + 1
expect(subject.size).to eq 0
it 'closes the connections' do
subject.with('http://example.com') do |http_client|
http_client.get('/').flush
end
expect { reaper_observes_idle_timeout }.to change(subject, :size).from(1).to(0)
end
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

View file

@ -48,7 +48,7 @@ describe Request do
end
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(:timeouts=).and_return(nil)
@ -83,7 +83,7 @@ describe Request do
end
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(:timeouts=).and_return(nil)

View file

@ -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>'
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
expect(Sanitize.fragment('<a href=" https://google.fr">Test</a>', subject)).to eq 'Test'
end

Some files were not shown because too many files have changed in this diff Show more