diff --git a/Gemfile b/Gemfile
index 49d78d2732a..c1f5d1b999b 100644
--- a/Gemfile
+++ b/Gemfile
@@ -123,7 +123,7 @@ group :development do
gem 'annotate', '~> 2.7'
gem 'better_errors', '~> 2.5'
gem 'binding_of_caller', '~> 0.7'
- gem 'bullet', '~> 5.7'
+ gem 'bullet', '~> 5.8'
gem 'letter_opener', '~> 1.4'
gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
diff --git a/Gemfile.lock b/Gemfile.lock
index 8c0e0575620..3737f2299a9 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -76,8 +76,8 @@ GEM
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.0.1)
- aws-partitions (1.106.0)
- aws-sdk-core (3.35.0)
+ aws-partitions (1.107.0)
+ aws-sdk-core (3.36.0)
aws-eventstream (~> 1.0)
aws-partitions (~> 1.0)
aws-sigv4 (~> 1.0)
@@ -85,7 +85,7 @@ GEM
aws-sdk-kms (1.11.0)
aws-sdk-core (~> 3, >= 3.26.0)
aws-sigv4 (~> 1.0)
- aws-sdk-s3 (1.23.0)
+ aws-sdk-s3 (1.23.1)
aws-sdk-core (~> 3, >= 3.26.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.0)
@@ -103,9 +103,9 @@ GEM
brakeman (4.3.1)
browser (2.5.3)
builder (3.2.3)
- bullet (5.7.6)
+ bullet (5.8.1)
activesupport (>= 3.0.0)
- uniform_notifier (~> 1.11.0)
+ uniform_notifier (~> 1.11)
bundler-audit (0.6.0)
bundler (~> 1.2)
thor (~> 0.18)
@@ -126,7 +126,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
- capybara (3.10.0)
+ capybara (3.10.1)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@@ -254,8 +254,7 @@ GEM
hashie (3.5.7)
heapy (0.1.4)
highline (2.0.0)
- hiredis (0.6.1)
- hitimes (1.3.0)
+ hiredis (0.6.3)
hkdf (0.3.0)
html2text (0.2.1)
nokogiri (~> 1.6)
@@ -333,7 +332,7 @@ GEM
mario-redis-lock (1.2.1)
redis (>= 3.0.5)
memory_profiler (0.9.12)
- method_source (0.9.0)
+ method_source (0.9.1)
microformats (4.0.7)
json
nokogiri
@@ -389,7 +388,7 @@ GEM
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.12.1)
- parallel_tests (2.26.0)
+ parallel_tests (2.26.2)
parallel
parser (2.5.3.0)
ast (~> 2.4.0)
@@ -399,7 +398,7 @@ GEM
pg (1.1.3)
pghero (2.2.0)
activerecord
- pkg-config (1.3.1)
+ pkg-config (1.3.2)
powerpack (0.1.2)
premailer (1.11.1)
addressable
@@ -409,13 +408,13 @@ GEM
actionmailer (>= 3, < 6)
premailer (~> 1.7, >= 1.7.9)
private_address_check (0.5.0)
- pry (0.11.3)
+ pry (0.12.0)
coderay (~> 1.1.0)
method_source (~> 0.9.0)
pry-byebug (3.6.0)
byebug (~> 10.0)
pry (~> 0.10)
- pry-rails (0.3.6)
+ pry-rails (0.3.7)
pry (>= 0.10.4)
public_suffix (3.0.3)
puma (3.12.0)
@@ -550,7 +549,7 @@ GEM
scss_lint (0.57.1)
rake (>= 0.9, < 13)
sass (~> 3.5, >= 3.5.5)
- sidekiq (5.2.2)
+ sidekiq (5.2.3)
connection_pool (~> 2.2, >= 2.2.2)
rack-protection (>= 1.5.0)
redis (>= 3.3.5, < 5)
@@ -600,13 +599,12 @@ GEM
thor (0.20.0)
thread_safe (0.3.6)
tilt (2.0.8)
- timers (4.1.2)
- hitimes
+ timers (4.2.0)
tty-color (0.4.3)
tty-command (0.8.2)
pastel (~> 0.7.0)
tty-cursor (0.6.0)
- tty-prompt (0.17.1)
+ tty-prompt (0.17.2)
necromancer (~> 0.4.0)
pastel (~> 0.7.0)
timers (~> 4.0)
@@ -627,7 +625,7 @@ GEM
unf_ext
unf_ext (0.0.7.5)
unicode-display_width (1.4.0)
- uniform_notifier (1.11.0)
+ uniform_notifier (1.12.1)
warden (1.2.7)
rack (>= 1.0)
webmock (3.4.2)
@@ -662,7 +660,7 @@ DEPENDENCIES
bootsnap (~> 1.3)
brakeman (~> 4.3)
browser
- bullet (~> 5.7)
+ bullet (~> 5.8)
bundler-audit (~> 0.6)
capistrano (~> 3.11)
capistrano-rails (~> 1.4)
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 1d5372a8cdd..f711c467675 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -17,7 +17,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def follow
- FollowService.new.call(current_user.account, @account.acct, reblogs: truthy_param?(:reblogs))
+ FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs))
options = @account.locked? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } }
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index e5d5e2ca619..7e491641b33 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -43,7 +43,12 @@ module SignatureVerification
return
end
- account = account_from_key_id(signature_params['keyId'])
+ account_stoplight = Stoplight("source:#{request.ip}") { account_from_key_id(signature_params['keyId']) }
+ .with_fallback { nil }
+ .with_threshold(1)
+ .with_cool_off_time(5.minutes.seconds)
+
+ account = account_stoplight.run
if account.nil?
@signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js
index cbae62a0f4c..d4a824e2c9d 100644
--- a/app/javascript/mastodon/actions/accounts.js
+++ b/app/javascript/mastodon/actions/accounts.js
@@ -145,12 +145,14 @@ export function fetchAccountFail(id, error) {
export function followAccount(id, reblogs = true) {
return (dispatch, getState) => {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
- dispatch(followAccountRequest(id));
+ const locked = getState().getIn(['accounts', id, 'locked'], false);
+
+ dispatch(followAccountRequest(id, locked));
api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => {
dispatch(followAccountSuccess(response.data, alreadyFollowing));
}).catch(error => {
- dispatch(followAccountFail(error));
+ dispatch(followAccountFail(error, locked));
});
};
};
@@ -167,10 +169,12 @@ export function unfollowAccount(id) {
};
};
-export function followAccountRequest(id) {
+export function followAccountRequest(id, locked) {
return {
type: ACCOUNT_FOLLOW_REQUEST,
id,
+ locked,
+ skipLoading: true,
};
};
@@ -179,13 +183,16 @@ export function followAccountSuccess(relationship, alreadyFollowing) {
type: ACCOUNT_FOLLOW_SUCCESS,
relationship,
alreadyFollowing,
+ skipLoading: true,
};
};
-export function followAccountFail(error) {
+export function followAccountFail(error, locked) {
return {
type: ACCOUNT_FOLLOW_FAIL,
error,
+ locked,
+ skipLoading: true,
};
};
@@ -193,6 +200,7 @@ export function unfollowAccountRequest(id) {
return {
type: ACCOUNT_UNFOLLOW_REQUEST,
id,
+ skipLoading: true,
};
};
@@ -201,6 +209,7 @@ export function unfollowAccountSuccess(relationship, statuses) {
type: ACCOUNT_UNFOLLOW_SUCCESS,
relationship,
statuses,
+ skipLoading: true,
};
};
@@ -208,6 +217,7 @@ export function unfollowAccountFail(error) {
return {
type: ACCOUNT_UNFOLLOW_FAIL,
error,
+ skipLoading: true,
};
};
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index 2b7962a6e11..8cb06c15760 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -97,13 +97,12 @@ export const expandAccountTimeline = (accountId, { maxId, withReplies }
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
-
export const expandHashtagTimeline = (hashtag, { maxId, tags } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId,
- any: parseTags(tags, 'any'),
- all: parseTags(tags, 'all'),
- none: parseTags(tags, 'none'),
+ any: parseTags(tags, 'any'),
+ all: parseTags(tags, 'all'),
+ none: parseTags(tags, 'none'),
}, done);
};
@@ -111,6 +110,7 @@ export function expandTimelineRequest(timeline) {
return {
type: TIMELINE_EXPAND_REQUEST,
timeline,
+ skipLoading: true,
};
};
@@ -121,6 +121,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial) {
statuses,
next,
partial,
+ skipLoading: true,
};
};
@@ -129,6 +130,7 @@ export function expandTimelineFail(timeline, error) {
type: TIMELINE_EXPAND_FAIL,
timeline,
error,
+ skipLoading: true,
};
};
diff --git a/app/javascript/mastodon/components/scrollable_list.js b/app/javascript/mastodon/components/scrollable_list.js
index e51c83c2b81..91a895bce5e 100644
--- a/app/javascript/mastodon/components/scrollable_list.js
+++ b/app/javascript/mastodon/components/scrollable_list.js
@@ -8,6 +8,7 @@ import { throttle } from 'lodash';
import { List as ImmutableList } from 'immutable';
import classNames from 'classnames';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
+import LoadingIndicator from './loading_indicator';
const MOUSE_IDLE_DELAY = 300;
@@ -25,6 +26,7 @@ export default class ScrollableList extends PureComponent {
trackScroll: PropTypes.bool,
shouldUpdateScroll: PropTypes.func,
isLoading: PropTypes.bool,
+ showLoading: PropTypes.bool,
hasMore: PropTypes.bool,
prepend: PropTypes.node,
alwaysPrepend: PropTypes.bool,
@@ -39,8 +41,6 @@ export default class ScrollableList extends PureComponent {
state = {
fullscreen: null,
- mouseMovedRecently: false,
- scrollToTopOnMouseIdle: false,
};
intersectionObserverWrapper = new IntersectionObserverWrapper();
@@ -65,11 +65,14 @@ export default class ScrollableList extends PureComponent {
});
mouseIdleTimer = null;
+ mouseMovedRecently = false;
+ scrollToTopOnMouseIdle = false;
clearMouseIdleTimer = () => {
if (this.mouseIdleTimer === null) {
return;
}
+
clearTimeout(this.mouseIdleTimer);
this.mouseIdleTimer = null;
};
@@ -77,37 +80,36 @@ export default class ScrollableList extends PureComponent {
handleMouseMove = throttle(() => {
// As long as the mouse keeps moving, clear and restart the idle timer.
this.clearMouseIdleTimer();
- this.mouseIdleTimer =
- setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
+ this.mouseIdleTimer = setTimeout(this.handleMouseIdle, MOUSE_IDLE_DELAY);
- this.setState(({
- mouseMovedRecently,
- scrollToTopOnMouseIdle,
- }) => ({
- mouseMovedRecently: true,
- // Only set scrollToTopOnMouseIdle if we just started moving and were
- // scrolled to the top. Otherwise, just retain the previous state.
- scrollToTopOnMouseIdle:
- mouseMovedRecently
- ? scrollToTopOnMouseIdle
- : (this.node.scrollTop === 0),
- }));
+ if (!this.mouseMovedRecently && this.node.scrollTop === 0) {
+ // Only set if we just started moving and are scrolled to the top.
+ this.scrollToTopOnMouseIdle = true;
+ }
+
+ // Save setting this flag for last, so we can do the comparison above.
+ this.mouseMovedRecently = true;
}, MOUSE_IDLE_DELAY / 2);
+ handleWheel = throttle(() => {
+ this.scrollToTopOnMouseIdle = false;
+ }, 150, {
+ trailing: true,
+ });
+
handleMouseIdle = () => {
- if (this.state.scrollToTopOnMouseIdle) {
+ if (this.scrollToTopOnMouseIdle) {
this.node.scrollTop = 0;
- this.props.onScrollToTop();
}
- this.setState({
- mouseMovedRecently: false,
- scrollToTopOnMouseIdle: false,
- });
+
+ this.mouseMovedRecently = false;
+ this.scrollToTopOnMouseIdle = false;
}
componentDidMount () {
this.attachScrollListener();
this.attachIntersectionObserver();
+
attachFullscreenListener(this.onFullScreenChange);
// Handle initial scroll posiiton
@@ -118,7 +120,8 @@ export default class ScrollableList extends PureComponent {
const someItemInserted = React.Children.count(prevProps.children) > 0 &&
React.Children.count(prevProps.children) < React.Children.count(this.props.children) &&
this.getFirstChildKey(prevProps) !== this.getFirstChildKey(this.props);
- if ((someItemInserted && this.node.scrollTop > 0) || this.state.mouseMovedRecently) {
+
+ if ((someItemInserted && this.node.scrollTop > 0) || this.mouseMovedRecently) {
return this.node.scrollHeight - this.node.scrollTop;
} else {
return null;
@@ -161,20 +164,24 @@ export default class ScrollableList extends PureComponent {
attachScrollListener () {
this.node.addEventListener('scroll', this.handleScroll);
+ this.node.addEventListener('wheel', this.handleWheel);
}
detachScrollListener () {
this.node.removeEventListener('scroll', this.handleScroll);
+ this.node.removeEventListener('wheel', this.handleWheel);
}
getFirstChildKey (props) {
const { children } = props;
- let firstChild = children;
+ let firstChild = children;
+
if (children instanceof ImmutableList) {
firstChild = children.get(0);
} else if (Array.isArray(children)) {
firstChild = children[0];
}
+
return firstChild && firstChild.key;
}
@@ -182,20 +189,32 @@ export default class ScrollableList extends PureComponent {
this.node = c;
}
- handleLoadMore = (e) => {
+ handleLoadMore = e => {
e.preventDefault();
this.props.onLoadMore();
}
render () {
- const { children, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, alwaysPrepend, alwaysShowScrollbar, emptyMessage, onLoadMore } = this.props;
+ const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, alwaysShowScrollbar, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state;
const childrenCount = React.Children.count(children);
const loadMore = (hasMore && childrenCount > 0 && onLoadMore) ? : null;
let scrollableArea = null;
- if (isLoading || childrenCount > 0 || !emptyMessage) {
+ if (showLoading) {
+ scrollableArea = (
+
+
+ {prepend}
+
+
+
+
+
+
+ );
+ } else if (isLoading || childrenCount > 0 || !emptyMessage) {
scrollableArea = (
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 9fa8cc00846..fd0780025a5 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -67,6 +67,7 @@ class Status extends ImmutablePureComponent {
unread: PropTypes.bool,
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
+ showThread: PropTypes.bool,
};
// Avoid checking props that are functions (and whose equality will always
@@ -168,7 +169,7 @@ class Status extends ImmutablePureComponent {
let media = null;
let statusAvatar, prepend, rebloggedByText;
- const { intl, hidden, featured, otherAccounts, unread } = this.props;
+ const { intl, hidden, featured, otherAccounts, unread, showThread } = this.props;
let { status, account, ...other } = this.props;
@@ -309,6 +310,12 @@ class Status extends ImmutablePureComponent {
{media}
+ {showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
+
+ )}
+
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index e7e5b0a6c02..68a1fda2445 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -148,7 +148,6 @@ class StatusActionBar extends ImmutablePureComponent {
let menu = [];
let reblogIcon = 'retweet';
- let replyIcon;
let replyTitle;
menu.push({ text: intl.formatMessage(messages.open), action: this.handleOpen });
@@ -191,10 +190,8 @@ class StatusActionBar extends ImmutablePureComponent {
}
if (status.get('in_reply_to_id', null) === null) {
- replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
} else {
- replyIcon = 'reply-all';
replyTitle = intl.formatMessage(messages.replyAll);
}
@@ -204,7 +201,7 @@ class StatusActionBar extends ImmutablePureComponent {
return (
-
{obfuscatedCount(status.get('replies_count'))}
+
{obfuscatedCount(status.get('replies_count'))}
{shareButton}
diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js
index 37f21fb4406..01cc0566104 100644
--- a/app/javascript/mastodon/components/status_list.js
+++ b/app/javascript/mastodon/components/status_list.js
@@ -25,7 +25,7 @@ export default class StatusList extends ImmutablePureComponent {
prepend: PropTypes.node,
emptyMessage: PropTypes.node,
alwaysPrepend: PropTypes.bool,
- timelineId: PropTypes.string.isRequired,
+ timelineId: PropTypes.string,
};
static defaultProps = {
@@ -104,6 +104,7 @@ export default class StatusList extends ImmutablePureComponent {
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
+ showThread
/>
))
) : null;
@@ -117,12 +118,13 @@ export default class StatusList extends ImmutablePureComponent {
onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown}
contextType={timelineId}
+ showThread
/>
)).concat(scrollableContent);
}
return (
-
+
{scrollableContent}
);
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
index 6055af51d03..afc484c607b 100644
--- a/app/javascript/mastodon/features/account_timeline/index.js
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -11,6 +11,7 @@ import HeaderContainer from './containers/header_container';
import ColumnBackButton from '../../components/column_back_button';
import { List as ImmutableList } from 'immutable';
import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
const path = withReplies ? `${accountId}:with_replies` : accountId;
@@ -78,6 +79,7 @@ class AccountTimeline extends ImmutablePureComponent {
}
+ alwaysPrepend
scrollKey='account_timeline'
statusIds={statusIds}
featuredStatusIds={featuredStatusIds}
@@ -85,6 +87,7 @@ class AccountTimeline extends ImmutablePureComponent {
hasMore={hasMore}
onLoadMore={this.handleLoadMore}
shouldUpdateScroll={shouldUpdateScroll}
+ emptyMessage={}
/>
);
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index fa6fd56e545..565009be2f5 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -159,7 +159,7 @@ class ActionBar extends React.PureComponent {
return (
-
+
{shareButton}
diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js
index f4604929723..8322780de56 100644
--- a/app/javascript/mastodon/reducers/relationships.js
+++ b/app/javascript/mastodon/reducers/relationships.js
@@ -1,6 +1,10 @@
import {
ACCOUNT_FOLLOW_SUCCESS,
+ ACCOUNT_FOLLOW_REQUEST,
+ ACCOUNT_FOLLOW_FAIL,
ACCOUNT_UNFOLLOW_SUCCESS,
+ ACCOUNT_UNFOLLOW_REQUEST,
+ ACCOUNT_UNFOLLOW_FAIL,
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
@@ -37,6 +41,14 @@ const initialState = ImmutableMap();
export default function relationships(state = initialState, action) {
switch(action.type) {
+ case ACCOUNT_FOLLOW_REQUEST:
+ return state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
+ case ACCOUNT_FOLLOW_FAIL:
+ return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
+ case ACCOUNT_UNFOLLOW_REQUEST:
+ return state.setIn([action.id, 'following'], false);
+ case ACCOUNT_UNFOLLOW_FAIL:
+ return state.setIn([action.id, 'following'], true);
case ACCOUNT_FOLLOW_SUCCESS:
case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS:
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index e669bf2e2e1..0ee0b002fcb 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -1847,7 +1847,7 @@ a.account__display-name {
}
.column {
- width: 330px;
+ width: 350px;
position: relative;
box-sizing: border-box;
display: flex;
@@ -2092,6 +2092,16 @@ a.account__display-name {
@supports(display: grid) { // hack to fix Chrome <57
contain: strict;
}
+
+ &--flex {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &__append {
+ flex: 1 1 auto;
+ position: relative;
+ }
}
.scrollable.fullscreen {
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 8c4c934ea45..46ef8577499 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -330,9 +330,12 @@ code {
}
input[type=text],
+ input[type=number],
input[type=email],
- input[type=password] {
- border-bottom-color: $valid-value-color;
+ input[type=password],
+ textarea,
+ select {
+ border-color: lighten($error-red, 12%);
}
.error {
diff --git a/app/lib/request.rb b/app/lib/request.rb
index 36c211dbfe5..73b495ce19f 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -94,7 +94,7 @@ class Request
end
def timeout
- { write: 10, connect: 10, read: 10 }
+ { connect: 1, read: 10, write: 10 }
end
def http_client
diff --git a/app/services/concerns/author_extractor.rb b/app/services/concerns/author_extractor.rb
index 1e00eb803b7..c2419e9ecb2 100644
--- a/app/services/concerns/author_extractor.rb
+++ b/app/services/concerns/author_extractor.rb
@@ -18,6 +18,6 @@ module AuthorExtractor
acct = "#{username}@#{domain}"
end
- ResolveAccountService.new.call(acct, update_profile)
+ ResolveAccountService.new.call(acct, update_profile: update_profile)
end
end
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index f6888a68d4f..0020bc9fec7 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -7,9 +7,9 @@ class FollowService < BaseService
# @param [Account] source_account From which to follow
# @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
# @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true
- def call(source_account, uri, reblogs: nil)
+ def call(source_account, target_account, reblogs: nil)
reblogs = true if reblogs.nil?
- target_account = uri.is_a?(Account) ? uri : ResolveAccountService.new.call(uri)
+ target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account)
@@ -42,7 +42,7 @@ class FollowService < BaseService
follow_request = FollowRequest.create!(account: source_account, target_account: target_account, show_reblogs: reblogs)
if target_account.local?
- NotifyService.new.call(target_account, follow_request)
+ LocalNotificationWorker.perform_async(target_account.id, follow_request.id, follow_request.class.name)
elsif target_account.ostatus?
NotificationWorker.perform_async(build_follow_request_xml(follow_request), source_account.id, target_account.id)
AfterRemoteFollowRequestWorker.perform_async(follow_request.id)
@@ -57,7 +57,7 @@ class FollowService < BaseService
follow = source_account.follow!(target_account, reblogs: reblogs)
if target_account.local?
- NotifyService.new.call(target_account, follow)
+ LocalNotificationWorker.perform_async(target_account.id, follow.id, follow.class.name)
else
Pubsubhubbub::SubscribeWorker.perform_async(target_account.id) unless target_account.subscribed?
NotificationWorker.perform_async(build_follow_xml(follow), source_account.id, target_account.id)
diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb
index b4641c4b4ab..ec7d33b1d82 100644
--- a/app/services/process_mentions_service.rb
+++ b/app/services/process_mentions_service.rb
@@ -47,7 +47,7 @@ class ProcessMentionsService < BaseService
mentioned_account = mention.account
if mentioned_account.local?
- LocalNotificationWorker.perform_async(mention.id)
+ LocalNotificationWorker.perform_async(mentioned_account.id, mention.id, mention.class.name)
elsif mentioned_account.ostatus? && !@status.stream_entry.hidden?
NotificationWorker.perform_async(ostatus_xml, @status.account_id, mentioned_account.id)
elsif mentioned_account.activitypub?
diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb
index 4323e7f06d5..c3064211dcf 100644
--- a/app/services/resolve_account_service.rb
+++ b/app/services/resolve_account_service.rb
@@ -9,17 +9,27 @@ class ResolveAccountService < BaseService
# Find or create a local account for a remote user.
# When creating, look up the user's webfinger and fetch all
# important information from their feed
- # @param [String] uri User URI in the form of username@domain
+ # @param [String, Account] uri User URI in the form of username@domain
+ # @param [Hash] options
# @return [Account]
- def call(uri, update_profile = true, redirected = nil)
- @username, @domain = uri.split('@')
- @update_profile = update_profile
+ def call(uri, options = {})
+ @options = options
- return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
+ if uri.is_a?(Account)
+ @account = uri
+ @username = @account.username
+ @domain = @account.domain
- @account = Account.find_remote(@username, @domain)
+ return @account if @account.local? || !webfinger_update_due?
+ else
+ @username, @domain = uri.split('@')
- return @account unless webfinger_update_due?
+ return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
+
+ @account = Account.find_remote(@username, @domain)
+
+ return @account unless webfinger_update_due?
+ end
Rails.logger.debug "Looking up webfinger for #{uri}"
@@ -30,8 +40,8 @@ class ResolveAccountService < BaseService
if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
@username = confirmed_username
@domain = confirmed_domain
- elsif redirected.nil?
- return call("#{confirmed_username}@#{confirmed_domain}", update_profile, true)
+ elsif options[:redirected].nil?
+ return call("#{confirmed_username}@#{confirmed_domain}", options.merge(redirected: true))
else
Rails.logger.debug 'Requested and returned acct URIs do not match'
return
@@ -76,7 +86,7 @@ class ResolveAccountService < BaseService
end
def webfinger_update_due?
- @account.nil? || @account.possibly_stale?
+ @account.nil? || ((!@options[:skip_webfinger] || @account.ostatus?) && @account.possibly_stale?)
end
def activitypub_ready?
@@ -93,7 +103,7 @@ class ResolveAccountService < BaseService
end
def update_profile?
- @update_profile
+ @options[:update_profile]
end
def handle_activitypub
diff --git a/app/validators/follow_limit_validator.rb b/app/validators/follow_limit_validator.rb
index eb083ed854b..409bf01763b 100644
--- a/app/validators/follow_limit_validator.rb
+++ b/app/validators/follow_limit_validator.rb
@@ -14,7 +14,7 @@ class FollowLimitValidator < ActiveModel::Validator
if account.following_count < LIMIT
LIMIT
else
- account.followers_count * RATIO
+ [(account.followers_count * RATIO).round, LIMIT].max
end
end
end
diff --git a/app/views/shared/_error_messages.html.haml b/app/views/shared/_error_messages.html.haml
index b73890216f5..28becd6c448 100644
--- a/app/views/shared/_error_messages.html.haml
+++ b/app/views/shared/_error_messages.html.haml
@@ -1,3 +1,3 @@
- if object.errors.any?
- .flash-message#error_explanation
+ .flash-message.alert#error_explanation
%strong= t('generic.validation_errors', count: object.errors.count)
diff --git a/app/workers/local_notification_worker.rb b/app/workers/local_notification_worker.rb
index 748270563c5..48635e498ff 100644
--- a/app/workers/local_notification_worker.rb
+++ b/app/workers/local_notification_worker.rb
@@ -3,9 +3,16 @@
class LocalNotificationWorker
include Sidekiq::Worker
- def perform(mention_id)
- mention = Mention.find(mention_id)
- NotifyService.new.call(mention.account, mention)
+ def perform(receiver_account_id, activity_id = nil, activity_class_name = nil)
+ if activity_id.nil? && activity_class_name.nil?
+ activity = Mention.find(receiver_account_id)
+ receiver = activity.account
+ else
+ receiver = Account.find(receiver_account_id)
+ activity = activity_class_name.constantize.find(activity_id)
+ end
+
+ NotifyService.new.call(receiver, activity)
rescue ActiveRecord::RecordNotFound
true
end
diff --git a/config/puma.rb b/config/puma.rb
index 5ebf5ed192a..1afdb1c6dfb 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -13,7 +13,9 @@ workers ENV.fetch('WEB_CONCURRENCY') { 2 }
preload_app!
on_worker_boot do
- ActiveRecord::Base.establish_connection if defined?(ActiveRecord)
+ ActiveSupport.on_load(:active_record) do
+ ActiveRecord::Base.establish_connection
+ end
end
plugin :tmp_restart
diff --git a/lib/mastodon/media_cli.rb b/lib/mastodon/media_cli.rb
index 179d1b6b537..99660dd1d9a 100644
--- a/lib/mastodon/media_cli.rb
+++ b/lib/mastodon/media_cli.rb
@@ -6,6 +6,8 @@ require_relative 'cli_helper'
module Mastodon
class MediaCLI < Thor
+ include ActionView::Helpers::NumberHelper
+
def self.exit_on_failure?
true
end
@@ -36,11 +38,13 @@ module Mastodon
time_ago = options[:days].days.ago
queued = 0
processed = 0
- dry_run = options[:dry_run] ? '(DRY RUN)' : ''
+ size = 0
+ dry_run = options[:dry_run] ? '(DRY RUN)' : ''
if options[:background]
- MediaAttachment.where.not(remote_url: '').where.not(file_file_name: nil).where('created_at < ?', time_ago).select(:id).reorder(nil).find_in_batches do |media_attachments|
+ MediaAttachment.where.not(remote_url: '').where.not(file_file_name: nil).where('created_at < ?', time_ago).select(:id, :file_file_size).reorder(nil).find_in_batches do |media_attachments|
queued += media_attachments.size
+ size += media_attachments.reduce(0) { |sum, m| sum + (m.file_file_size || 0) }
Maintenance::UncacheMediaWorker.push_bulk(media_attachments.map(&:id)) unless options[:dry_run]
end
else
@@ -49,6 +53,7 @@ module Mastodon
Maintenance::UncacheMediaWorker.new.perform(m) unless options[:dry_run]
options[:verbose] ? say(m.id) : say('.', :green, false)
processed += 1
+ size += m.file_file_size || 0
end
end
end
@@ -56,9 +61,9 @@ module Mastodon
say
if options[:background]
- say("Scheduled the deletion of #{queued} media attachments #{dry_run}", :green, true)
+ say("Scheduled the deletion of #{queued} media attachments (approx. #{number_to_human_size(size)}) #{dry_run}", :green, true)
else
- say("Removed #{processed} media attachments #{dry_run}", :green, true)
+ say("Removed #{processed} media attachments (approx. #{number_to_human_size(size)}) #{dry_run}", :green, true)
end
end
end
diff --git a/spec/controllers/authorize_interactions_controller_spec.rb b/spec/controllers/authorize_interactions_controller_spec.rb
index 81fd9ceb766..ce4257b68dc 100644
--- a/spec/controllers/authorize_interactions_controller_spec.rb
+++ b/spec/controllers/authorize_interactions_controller_spec.rb
@@ -99,10 +99,12 @@ describe AuthorizeInteractionsController do
allow(ResolveAccountService).to receive(:new).and_return(service)
allow(service).to receive(:call).with('user@hostname').and_return(target_account)
+ allow(service).to receive(:call).with(target_account, skip_webfinger: true).and_return(target_account)
+
post :create, params: { acct: 'acct:user@hostname' }
- expect(service).to have_received(:call).with('user@hostname')
+ expect(service).to have_received(:call).with(target_account, skip_webfinger: true)
expect(account.following?(target_account)).to be true
expect(response).to render_template(:success)
end