Browse Source

Merge branch 'master' into live

master
Zac 1 week ago
parent
commit
bbe142f4d6
58 changed files with 1055 additions and 611 deletions
  1. 1
    0
      .circleci/config.yml
  2. 3
    0
      .gitignore
  3. 1
    1
      CONTRIBUTING.md
  4. 1
    0
      Dockerfile
  5. 7
    7
      Gemfile
  6. 38
    38
      Gemfile.lock
  7. 5
    3
      app/javascript/flavours/glitch/actions/compose.js
  8. 1
    0
      app/javascript/flavours/glitch/components/modal_root.js
  9. 2
    1
      app/javascript/flavours/glitch/components/poll.js
  10. 16
    6
      app/javascript/flavours/glitch/components/status_prepend.js
  11. 2
    1
      app/javascript/flavours/glitch/features/compose/containers/options_container.js
  12. 10
    0
      app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
  13. 4
    3
      app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
  14. 3
    1
      app/javascript/flavours/glitch/features/ui/index.js
  15. 7
    3
      app/javascript/flavours/glitch/packs/public.js
  16. 7
    3
      app/javascript/flavours/glitch/packs/settings.js
  17. 4
    2
      app/javascript/flavours/glitch/reducers/compose.js
  18. 1
    0
      app/javascript/flavours/glitch/styles/components/composer.scss
  19. 16
    0
      app/javascript/flavours/glitch/util/load_keyboard_extensions.js
  20. 5
    3
      app/javascript/mastodon/actions/compose.js
  21. 6
    0
      app/javascript/mastodon/actions/importer/normalizer.js
  22. 2
    1
      app/javascript/mastodon/actions/notifications.js
  23. 1
    0
      app/javascript/mastodon/components/modal_root.js
  24. 2
    1
      app/javascript/mastodon/components/poll.js
  25. 1
    3
      app/javascript/mastodon/features/compose/components/poll_form.js
  26. 1
    1
      app/javascript/mastodon/features/compose/containers/upload_button_container.js
  27. 29
    14
      app/javascript/mastodon/features/notifications/components/notification.js
  28. 10
    0
      app/javascript/mastodon/features/ui/components/focal_point_modal.js
  29. 4
    3
      app/javascript/mastodon/features/ui/containers/status_list_container.js
  30. 3
    1
      app/javascript/mastodon/features/ui/index.js
  31. 16
    0
      app/javascript/mastodon/load_keyboard_extensions.js
  32. 14
    6
      app/javascript/mastodon/locales/defaultMessages.json
  33. 2
    0
      app/javascript/mastodon/locales/en.json
  34. 4
    2
      app/javascript/mastodon/reducers/compose.js
  35. 7
    3
      app/javascript/packs/public.js
  36. 1
    0
      app/javascript/styles/mastodon/components.scss
  37. 1
    1
      app/lib/activitypub/activity.rb
  38. 3
    1
      app/models/account.rb
  39. 3
    3
      app/models/list_account.rb
  40. 4
    2
      app/models/media_attachment.rb
  41. 1
    1
      app/presenters/account_relationships_presenter.rb
  42. 1
    1
      app/services/account_search_service.rb
  43. 2
    2
      app/services/follow_service.rb
  44. 5
    1
      app/views/public_timelines/show.html.haml
  45. 6
    2
      app/workers/move_worker.rb
  46. 5
    2
      app/workers/unfollow_follow_worker.rb
  47. 1
    0
      config/locales/en.yml
  48. 3
    1
      config/webpack/rules/css.js
  49. 3
    0
      config/webpack/test.js
  50. 5
    0
      db/migrate/20191031163205_change_list_account_follow_nullable.rb
  51. 2
    2
      db/schema.rb
  52. 22
    0
      lib/mastodon/media_cli.rb
  53. 24
    22
      package.json
  54. 26
    0
      spec/lib/activitypub/activity/create_spec.rb
  55. 2
    2
      spec/models/media_attachment_spec.rb
  56. 63
    0
      spec/workers/move_worker_spec.rb
  57. 50
    0
      spec/workers/unfollow_follow_worker_spec.rb
  58. 586
    461
      yarn.lock

+ 1
- 0
.circleci/config.yml View File

@@ -9,6 +9,7 @@ aliases:
DB_HOST: localhost
DB_USER: root
RAILS_ENV: test
NODE_ENV: test
PARALLEL_TEST_PROCESSORS: 4
ALLOW_NOPAM: true
CONTINUOUS_INTEGRATION: true

+ 3
- 0
.gitignore View File

@@ -55,6 +55,9 @@ npm-debug.log
yarn-error.log
yarn-debug.log

# Ignore vagrant log files
ubuntu-xenial-16.04-cloudimg-console.log

# Ignore Docker option files
docker-compose.override.yml


+ 1
- 1
CONTRIBUTING.md View File

@@ -54,7 +54,7 @@ Bug reports and feature suggestions can be submitted to [GitHub Issues](https://

You can submit translations via [Crowdin](https://crowdin.com/project/mastodon). They are periodically merged into the codebase.

[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)][crowdin]
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/mastodon/localized.svg)](https://crowdin.com/project/mastodon)

## Pull requests


+ 1
- 0
Dockerfile View File

@@ -123,3 +123,4 @@ RUN cd ~ && \
# Set the work dir and the container entry point
WORKDIR /opt/mastodon
ENTRYPOINT ["/tini", "--"]
EXPOSE 3000 4000

+ 7
- 7
Gemfile View File

@@ -3,7 +3,7 @@
source 'https://rubygems.org'
ruby '>= 2.4.0', '< 2.7.0'

gem 'pkg-config', '~> 1.3'
gem 'pkg-config', '~> 1.4'

gem 'puma', '~> 4.2'
gem 'rails', '~> 5.2.3'
@@ -15,7 +15,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.3'
gem 'dotenv-rails', '~> 2.7'

gem 'aws-sdk-s3', '~> 1.48', require: false
gem 'aws-sdk-s3', '~> 1.52', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
@@ -86,7 +86,7 @@ gem 'sidekiq-scheduler', '~> 3.0'
gem 'sidekiq-unique-jobs', '~> 6.0'
gem 'sidekiq-bulk', '~>0.2.0'
gem 'simple-navigation', '~> 4.1'
gem 'simple_form', '~> 4.1'
gem 'simple_form', '~> 5.0'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.1.3'
gem 'strong_migrations', '~> 0.4'
@@ -109,7 +109,7 @@ group :development, :test do
gem 'i18n-tasks', '~> 0.9', require: false
gem 'pry-byebug', '~> 3.7'
gem 'pry-rails', '~> 0.3'
gem 'rspec-rails', '~> 3.8'
gem 'rspec-rails', '~> 3.9'
end

group :production, :test do
@@ -119,7 +119,7 @@ end
group :test do
gem 'capybara', '~> 3.29'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.5'
gem 'faker', '~> 2.6'
gem 'microformats', '~> 4.1'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0'
@@ -129,8 +129,8 @@ group :test do
end

group :development do
gem 'active_record_query_trace', '~> 1.6'
gem 'annotate', '~> 2.7'
gem 'active_record_query_trace', '~> 1.7'
gem 'annotate', '~> 3.0'
gem 'better_errors', '~> 2.5'
gem 'binding_of_caller', '~> 0.7'
gem 'bullet', '~> 6.0'

+ 38
- 38
Gemfile.lock View File

@@ -72,7 +72,7 @@ GEM
activemodel (>= 4.1, < 6.1)
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.6.2)
active_record_query_trace (1.7)
activejob (5.2.3)
activesupport (= 5.2.3)
globalid (>= 0.3.6)
@@ -95,7 +95,7 @@ GEM
public_suffix (>= 2.0.2, < 5.0)
airbrussh (1.3.4)
sshkit (>= 1.6.1, != 1.7.0)
annotate (2.7.5)
annotate (3.0.2)
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 13.0)
arel (9.0.0)
@@ -105,17 +105,17 @@ GEM
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.0.3)
aws-partitions (1.207.0)
aws-sdk-core (3.65.1)
aws-partitions (1.230.0)
aws-sdk-core (3.72.0)
aws-eventstream (~> 1.0, >= 1.0.2)
aws-partitions (~> 1.0)
aws-partitions (~> 1, >= 1.228.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.24.0)
aws-sdk-core (~> 3, >= 3.61.1)
aws-sdk-kms (1.25.0)
aws-sdk-core (~> 3, >= 3.71.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.48.0)
aws-sdk-core (~> 3, >= 3.61.1)
aws-sdk-s3 (1.52.0)
aws-sdk-core (~> 3, >= 3.71.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.1.0)
@@ -235,13 +235,13 @@ GEM
multi_json
encryptor (3.0.0)
equatable (0.6.1)
erubi (1.8.0)
erubi (1.9.0)
et-orbi (1.1.6)
tzinfo
excon (0.62.0)
fabrication (2.20.2)
faker (2.5.0)
i18n (~> 1.6.0)
faker (2.6.0)
i18n (>= 1.6, < 1.8)
faraday (0.15.4)
multipart-post (>= 1.2, < 3)
fast_blank (1.0.0)
@@ -307,7 +307,7 @@ GEM
httplog (1.3.2)
rack (>= 1.0)
rainbow (>= 2.0.0)
i18n (1.6.0)
i18n (1.7.0)
concurrent-ruby (~> 1.0)
i18n-tasks (0.9.29)
activesupport (>= 4.0.2)
@@ -380,7 +380,7 @@ GEM
mimemagic (0.3.3)
mini_mime (1.0.2)
mini_portile2 (2.4.0)
minitest (5.12.0)
minitest (5.12.2)
msgpack (1.3.1)
multi_json (1.13.1)
multipart-post (2.1.1)
@@ -437,7 +437,7 @@ GEM
pg (1.1.4)
pghero (2.3.0)
activerecord (>= 5)
pkg-config (1.3.9)
pkg-config (1.4.0)
premailer (1.11.1)
addressable
css_parser (>= 1.6.0)
@@ -490,8 +490,8 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.2.0)
loofah (~> 2.2, >= 2.2.2)
rails-html-sanitizer (1.3.0)
loofah (~> 2.3)
rails-i18n (5.1.3)
i18n (>= 0.7, < 2)
railties (>= 5.0, < 6)
@@ -540,26 +540,26 @@ GEM
rpam2 (4.0.2)
rqrcode (0.10.1)
chunky_png (~> 1.0)
rspec-core (3.8.0)
rspec-support (~> 3.8.0)
rspec-expectations (3.8.2)
rspec-core (3.9.0)
rspec-support (~> 3.9.0)
rspec-expectations (3.9.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.8.0)
rspec-mocks (3.8.0)
rspec-support (~> 3.9.0)
rspec-mocks (3.9.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.8.0)
rspec-rails (3.8.2)
rspec-support (~> 3.9.0)
rspec-rails (3.9.0)
actionpack (>= 3.0)
activesupport (>= 3.0)
railties (>= 3.0)
rspec-core (~> 3.8.0)
rspec-expectations (~> 3.8.0)
rspec-mocks (~> 3.8.0)
rspec-support (~> 3.8.0)
rspec-core (~> 3.9.0)
rspec-expectations (~> 3.9.0)
rspec-mocks (~> 3.9.0)
rspec-support (~> 3.9.0)
rspec-sidekiq (3.0.3)
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.8.0)
rspec-support (3.9.0)
rubocop (0.75.1)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
@@ -599,7 +599,7 @@ GEM
thor (~> 0)
simple-navigation (4.1.0)
activesupport (>= 2.3.2)
simple_form (4.1.0)
simple_form (5.0.1)
actionpack (>= 5.0)
activemodel (>= 5.0)
simplecov (0.17.1)
@@ -622,7 +622,7 @@ GEM
stoplight (2.1.3)
streamio-ffmpeg (3.0.2)
multi_json (~> 1.8)
strong_migrations (0.4.1)
strong_migrations (0.4.2)
activerecord (>= 5)
temple (0.8.1)
terminal-table (1.8.0)
@@ -681,10 +681,10 @@ PLATFORMS

DEPENDENCIES
active_model_serializers (~> 0.10)
active_record_query_trace (~> 1.6)
active_record_query_trace (~> 1.7)
addressable (~> 2.7)
annotate (~> 2.7)
aws-sdk-s3 (~> 1.48)
annotate (~> 3.0)
aws-sdk-s3 (~> 1.52)
better_errors (~> 2.5)
binding_of_caller (~> 0.7)
blurhash (~> 0.1)
@@ -712,7 +712,7 @@ DEPENDENCIES
doorkeeper (~> 5.2)
dotenv-rails (~> 2.7)
fabrication (~> 2.20)
faker (~> 2.5)
faker (~> 2.6)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
@@ -760,7 +760,7 @@ DEPENDENCIES
parslet
pg (~> 1.1)
pghero (~> 2.3)
pkg-config (~> 1.3)
pkg-config (~> 1.4)
posix-spawn!
premailer-rails
private_address_check (~> 0.5)
@@ -780,7 +780,7 @@ DEPENDENCIES
redis-namespace (~> 1.5)
redis-rails (~> 5.0)
rqrcode (~> 0.10)
rspec-rails (~> 3.8)
rspec-rails (~> 3.9)
rspec-sidekiq (~> 3.0)
rubocop (~> 0.75)
rubocop-rails (~> 2.3)
@@ -791,7 +791,7 @@ DEPENDENCIES
sidekiq-scheduler (~> 3.0)
sidekiq-unique-jobs (~> 6.0)
simple-navigation (~> 4.1)
simple_form (~> 4.1)
simple_form (~> 5.0)
simplecov (~> 0.17)
sprockets-rails (~> 3.2)
stackprof

+ 5
- 3
app/javascript/flavours/glitch/actions/compose.js View File

@@ -232,10 +232,11 @@ export function uploadCompose(files) {
return function (dispatch, getState) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);

if (files.length + media.size > uploadLimit) {
if (files.length + media.size + pending > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
return;
}
@@ -262,7 +263,7 @@ export function uploadCompose(files) {
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ data }) => dispatch(uploadComposeSuccess(data, f)));
}).catch(error => dispatch(uploadComposeFail(error)));
}).catch(error => dispatch(uploadComposeFail(error, true)));
};
};
};
@@ -293,10 +294,11 @@ export function changeUploadComposeSuccess(media) {
};
};

export function changeUploadComposeFail(error) {
export function changeUploadComposeFail(error, decrement = false) {
return {
type: COMPOSE_UPLOAD_CHANGE_FAIL,
error: error,
decrement: decrement,
skipLoading: true,
};
};

+ 1
- 0
app/javascript/flavours/glitch/components/modal_root.js View File

@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import 'wicg-inert';
import createHistory from 'history/createBrowserHistory';

export default class ModalRoot extends React.PureComponent {

+ 2
- 1
app/javascript/flavours/glitch/components/poll.js View File

@@ -39,7 +39,8 @@ class Poll extends ImmutablePureComponent {

static getDerivedStateFromProps (props, state) {
const { poll, intl } = props;
const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now();
const expires_at = poll.get('expires_at');
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
return (expired === state.expired) ? null : { expired };
}


+ 16
- 6
app/javascript/flavours/glitch/components/status_prepend.js View File

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage } from 'react-intl';
import Icon from 'flavours/glitch/components/icon';
import { me } from 'flavours/glitch/util/initial_state';

export default class StatusPrepend extends React.PureComponent {

@@ -64,12 +65,21 @@ export default class StatusPrepend extends React.PureComponent {
/>
);
case 'poll':
return (
<FormattedMessage
id='notification.poll'
defaultMessage='A poll you have voted in has ended'
/>
);
if (me === account.get('id')) {
return (
<FormattedMessage
id='notification.own_poll'
defaultMessage='Your poll has ended'
/>
);
} else {
return (
<FormattedMessage
id='notification.poll'
defaultMessage='A poll you have voted in has ended'
/>
);
}
}
return null;
}

+ 2
- 1
app/javascript/flavours/glitch/features/compose/containers/options_container.js View File

@@ -12,11 +12,12 @@ function mapStateToProps (state) {
const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
const poll = state.getIn(['compose', 'poll']);
const media = state.getIn(['compose', 'media_attachments']);
const pending_media = state.getIn(['compose', 'pending_media_attachments']);
return {
acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
resetFileKey: state.getIn(['compose', 'resetFileKey']),
hasPoll: !!poll,
allowMedia: !poll && (media ? media.size < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : true),
allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4),
hasMedia: media && !!media.size,
allowPoll: !(media && !!media.size),
showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']),

+ 10
- 0
app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js View File

@@ -184,6 +184,15 @@ class FocalPointModal extends ImmutablePureComponent {
this.setState({ description: e.target.value, dirty: true });
}

handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
this.setState({ description: e.target.value, dirty: true });
this.handleSubmit();
}
}

handleSubmit = () => {
this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
this.props.onClose();
@@ -254,6 +263,7 @@ class FocalPointModal extends ImmutablePureComponent {
className='setting-text light'
value={detecting ? '…' : description}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
disabled={detecting}
autoFocus
/>

+ 4
- 3
app/javascript/flavours/glitch/features/ui/containers/status_list_container.js View File

@@ -19,9 +19,9 @@ const getRegex = createSelector([
return regex;
});

const makeGetStatusIds = () => createSelector([
const makeGetStatusIds = (pending = false) => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()),
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
(state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()),
(state) => state.get('statuses'),
getRegex,
], (columnSettings, statusIds, statuses, regex) => {
@@ -56,13 +56,14 @@ const makeGetStatusIds = () => createSelector([

const makeMapStateToProps = () => {
const getStatusIds = makeGetStatusIds();
const getPendingStatusIds = makeGetStatusIds(true);

const mapStateToProps = (state, { timelineId }) => ({
statusIds: getStatusIds(state, { type: timelineId }),
isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
hasMore: state.getIn(['timelines', timelineId, 'hasMore']),
numPending: state.getIn(['timelines', timelineId, 'pendingItems'], ImmutableList()).size,
numPending: getPendingStatusIds(state, { type: timelineId }).size,
});

return mapStateToProps;

+ 3
- 1
app/javascript/flavours/glitch/features/ui/index.js View File

@@ -174,7 +174,9 @@ class SwitchingColumnsArea extends React.PureComponent {
}

setRef = c => {
this.node = c.getWrappedInstance();
if (c) {
this.node = c.getWrappedInstance();
}
}

render () {

+ 7
- 3
app/javascript/flavours/glitch/packs/public.js View File

@@ -1,5 +1,6 @@
import loadPolyfills from 'flavours/glitch/util/load_polyfills';
import ready from 'flavours/glitch/util/ready';
import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';

function main() {
const IntlMessageFormat = require('intl-messageformat').default;
@@ -118,6 +119,9 @@ function main() {
});
}

loadPolyfills().then(main).catch(error => {
console.error(error);
});
loadPolyfills()
.then(main)
.then(loadKeyboardExtensions)
.catch(error => {
console.error(error);
});

+ 7
- 3
app/javascript/flavours/glitch/packs/settings.js View File

@@ -1,5 +1,6 @@
import loadPolyfills from 'flavours/glitch/util/load_polyfills';
import ready from 'flavours/glitch/util/ready';
import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';

function main() {
const { delegate } = require('rails-ujs');
@@ -15,6 +16,9 @@ function main() {
});
}

loadPolyfills().then(main).catch(error => {
console.error(error);
});
loadPolyfills()
.then(main)
.then(loadKeyboardExtensions)
.catch(error => {
console.error(error);
});

+ 4
- 2
app/javascript/flavours/glitch/reducers/compose.js View File

@@ -78,6 +78,7 @@ const initialState = ImmutableMap({
is_changing_upload: false,
progress: 0,
media_attachments: ImmutableList(),
pending_media_attachments: 0,
poll: null,
suggestion_token: null,
suggestions: ImmutableList(),
@@ -201,6 +202,7 @@ function appendMedia(state, media, file) {
map.set('is_uploading', false);
map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
map.set('idempotencyKey', uuid());
map.update('pending_media_attachments', n => n - 1);

if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
map.set('sensitive', true);
@@ -423,11 +425,11 @@ export default function compose(state = initialState, action) {
case COMPOSE_UPLOAD_CHANGE_FAIL:
return state.set('is_changing_upload', false);
case COMPOSE_UPLOAD_REQUEST:
return state.set('is_uploading', true);
return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1);
case COMPOSE_UPLOAD_SUCCESS:
return appendMedia(state, fromJS(action.media), action.file);
case COMPOSE_UPLOAD_FAIL:
return state.set('is_uploading', false);
return state.set('is_uploading', false).update('pending_media_attachments', n => action.decrement ? n - 1 : n);
case COMPOSE_UPLOAD_UNDO:
return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS:

+ 1
- 0
app/javascript/flavours/glitch/styles/components/composer.scss View File

@@ -249,6 +249,7 @@
.compose-form__autosuggest-wrapper,
.autosuggest-input {
position: relative;
width: 100%;

label {
.autosuggest-textarea__textarea {

+ 16
- 0
app/javascript/flavours/glitch/util/load_keyboard_extensions.js View File

@@ -0,0 +1,16 @@
// On KaiOS, we may not be able to use a mouse cursor or navigate using Tab-based focus, so we install
// special left/right focus navigation keyboard listeners, at least on public pages (i.e. so folks
// can at least log in using KaiOS devices).

function importArrowKeyNavigation() {
return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation');
}

export default function loadKeyboardExtensions() {
if (/KAIOS/.test(navigator.userAgent)) {
return importArrowKeyNavigation().then(arrowKeyNav => {
arrowKeyNav.register();
});
}
return Promise.resolve();
}

+ 5
- 3
app/javascript/mastodon/actions/compose.js View File

@@ -205,10 +205,11 @@ export function uploadCompose(files) {
return function (dispatch, getState) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const pending = getState().getIn(['compose', 'pending_media_attachments']);
const progress = new Array(files.length).fill(0);
let total = Array.from(files).reduce((a, v) => a + v.size, 0);

if (files.length + media.size > uploadLimit) {
if (files.length + media.size + pending > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
return;
}
@@ -235,7 +236,7 @@ export function uploadCompose(files) {
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ data }) => dispatch(uploadComposeSuccess(data, f)));
}).catch(error => dispatch(uploadComposeFail(error)));
}).catch(error => dispatch(uploadComposeFail(error, true)));
};
};
};
@@ -266,10 +267,11 @@ export function changeUploadComposeSuccess(media) {
};
};

export function changeUploadComposeFail(error) {
export function changeUploadComposeFail(error, decrement = false) {
return {
type: COMPOSE_UPLOAD_CHANGE_FAIL,
error: error,
decrement: decrement,
skipLoading: true,
};
};

+ 6
- 0
app/javascript/mastodon/actions/importer/normalizer.js View File

@@ -10,6 +10,12 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
return obj;
}, {});

export function searchTextFromRawStatus (status) {
const spoilerText = status.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
}

export function normalizeAccount(account) {
account = { ...account };


+ 2
- 1
app/javascript/mastodon/actions/notifications.js View File

@@ -14,6 +14,7 @@ import { unescapeHTML } from '../utils/html';
import { getFiltersRegex } from '../selectors';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import compareId from 'mastodon/compare_id';
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';

export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -60,7 +61,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
if (notification.type === 'mention') {
const dropRegex = filters[0];
const regex = filters[1];
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
const searchIndex = searchTextFromRawStatus(notification.status);

if (dropRegex && dropRegex.test(searchIndex)) {
return;

+ 1
- 0
app/javascript/mastodon/components/modal_root.js View File

@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import 'wicg-inert';

export default class ModalRoot extends React.PureComponent {


+ 2
- 1
app/javascript/mastodon/components/poll.js View File

@@ -39,7 +39,8 @@ class Poll extends ImmutablePureComponent {

static getDerivedStateFromProps (props, state) {
const { poll, intl } = props;
const expired = poll.get('expired') || (new Date(poll.get('expires_at'))).getTime() < intl.now();
const expires_at = poll.get('expires_at');
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now();
return (expired === state.expired) ? null : { expired };
}


+ 1
- 3
app/javascript/mastodon/features/compose/components/poll_form.js View File

@@ -142,9 +142,7 @@ class PollForm extends ImmutablePureComponent {
</ul>

<div className='poll__footer'>
{options.size < 5 && (
<button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
)}
<button disabled={options.size >= 5} className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>

<select value={expiresIn} onChange={this.handleSelectDuration}>
<option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>

+ 1
- 1
app/javascript/mastodon/features/compose/containers/upload_button_container.js View File

@@ -3,7 +3,7 @@ import UploadButton from '../components/upload_button';
import { uploadCompose } from '../../../actions/compose';

const mapStateToProps = state => ({
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size + state.getIn(['compose', 'pending_media_attachments']) > 3 || state.getIn(['compose', 'media_attachments']).some(m => ['video', 'audio'].includes(m.get('type')))),
unavailable: state.getIn(['compose', 'poll']) !== null,
resetFileKey: state.getIn(['compose', 'resetFileKey']),
});

+ 29
- 14
app/javascript/mastodon/features/notifications/components/notification.js View File

@@ -1,13 +1,22 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import StatusContainer from '../../../containers/status_container';
import AccountContainer from '../../../containers/account_container';
import { injectIntl, FormattedMessage } from 'react-intl';
import Permalink from '../../../components/permalink';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import { HotKeys } from 'react-hotkeys';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from 'mastodon/initial_state';
import StatusContainer from 'mastodon/containers/status_container';
import AccountContainer from 'mastodon/containers/account_container';
import Icon from 'mastodon/components/icon';
import Permalink from 'mastodon/components/permalink';

const messages = defineMessages({
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
follow: { id: 'notification.follow', defaultMessage: '{name} followed you' },
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
});

const notificationForScreenReader = (intl, message, timestamp) => {
const output = [message];
@@ -107,7 +116,7 @@ class Notification extends ImmutablePureComponent {

return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow', defaultMessage: '{name} followed you' }, { name: account.get('acct') }), notification.get('created_at'))}>
<div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='user-plus' fixedWidth />
@@ -146,7 +155,7 @@ class Notification extends ImmutablePureComponent {

return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='star' className='star-icon' fixedWidth />
@@ -178,7 +187,7 @@ class Notification extends ImmutablePureComponent {

return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='retweet' fixedWidth />
@@ -205,25 +214,31 @@ class Notification extends ImmutablePureComponent {
);
}

renderPoll (notification) {
renderPoll (notification, account) {
const { intl } = this.props;
const ownPoll = me === account.get('id');
const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll);

return (
<HotKeys handlers={this.getHandlers()}>
<div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }), notification.get('created_at'))}>
<div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='tasks' fixedWidth />
</div>

<span title={notification.get('created_at')}>
<FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' />
{ownPoll ? (
<FormattedMessage id='notification.own_poll' defaultMessage='Your poll has ended' />
) : (
<FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' />
)}
</span>
</div>

<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
account={account}
muted
withDismiss
hidden={this.props.hidden}
@@ -253,7 +268,7 @@ class Notification extends ImmutablePureComponent {
case 'reblog':
return this.renderReblog(notification, link);
case 'poll':
return this.renderPoll(notification);
return this.renderPoll(notification, account);
}

return null;

+ 10
- 0
app/javascript/mastodon/features/ui/components/focal_point_modal.js View File

@@ -184,6 +184,15 @@ class FocalPointModal extends ImmutablePureComponent {
this.setState({ description: e.target.value, dirty: true });
}

handleKeyDown = (e) => {
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
e.stopPropagation();
this.setState({ description: e.target.value, dirty: true });
this.handleSubmit();
}
}

handleSubmit = () => {
this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
this.props.onClose();
@@ -254,6 +263,7 @@ class FocalPointModal extends ImmutablePureComponent {
className='setting-text light'
value={detecting ? '…' : description}
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
disabled={detecting}
autoFocus
/>

+ 4
- 3
app/javascript/mastodon/features/ui/containers/status_list_container.js View File

@@ -6,9 +6,9 @@ import { createSelector } from 'reselect';
import { debounce } from 'lodash';
import { me } from '../../../initial_state';

const makeGetStatusIds = () => createSelector([
const makeGetStatusIds = (pending = false) => createSelector([
(state, { type }) => state.getIn(['settings', type], ImmutableMap()),
(state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
(state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()),
(state) => state.get('statuses'),
], (columnSettings, statusIds, statuses) => {
return statusIds.filter(id => {
@@ -31,13 +31,14 @@ const makeGetStatusIds = () => createSelector([

const makeMapStateToProps = () => {
const getStatusIds = makeGetStatusIds();
const getPendingStatusIds = makeGetStatusIds(true);

const mapStateToProps = (state, { timelineId }) => ({
statusIds: getStatusIds(state, { type: timelineId }),
isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
hasMore: state.getIn(['timelines', timelineId, 'hasMore']),
numPending: state.getIn(['timelines', timelineId, 'pendingItems'], ImmutableList()).size,
numPending: getPendingStatusIds(state, { type: timelineId }).size,
});

return mapStateToProps;

+ 3
- 1
app/javascript/mastodon/features/ui/index.js View File

@@ -164,7 +164,9 @@ class SwitchingColumnsArea extends React.PureComponent {
}

setRef = c => {
this.node = c.getWrappedInstance();
if (c) {
this.node = c.getWrappedInstance();
}
}

render () {

+ 16
- 0
app/javascript/mastodon/load_keyboard_extensions.js View File

@@ -0,0 +1,16 @@
// On KaiOS, we may not be able to use a mouse cursor or navigate using Tab-based focus, so we install
// special left/right focus navigation keyboard listeners, at least on public pages (i.e. so folks
// can at least log in using KaiOS devices).

function importArrowKeyNavigation() {
return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation');
}

export default function loadKeyboardExtensions() {
if (/KAIOS/.test(navigator.userAgent)) {
return importArrowKeyNavigation().then(arrowKeyNav => {
arrowKeyNav.register();
});
}
return Promise.resolve();
}

+ 14
- 6
app/javascript/mastodon/locales/defaultMessages.json View File

@@ -2079,20 +2079,24 @@
{
"descriptors": [
{
"defaultMessage": "{name} followed you",
"id": "notification.follow"
},
{
"defaultMessage": "{name} favourited your status",
"id": "notification.favourite"
},
{
"defaultMessage": "{name} boosted your status",
"id": "notification.reblog"
"defaultMessage": "{name} followed you",
"id": "notification.follow"
},
{
"defaultMessage": "Your poll has ended",
"id": "notification.own_poll"
},
{
"defaultMessage": "A poll you have voted in has ended",
"id": "notification.poll"
},
{
"defaultMessage": "{name} boosted your status",
"id": "notification.reblog"
}
],
"path": "app/javascript/mastodon/features/notifications/components/notification.json"
@@ -2772,6 +2776,10 @@
"id": "video.exit_fullscreen"
},
{
"defaultMessage": "Download file",
"id": "video.download"
},
{
"defaultMessage": "Sensitive content",
"id": "status.sensitive_warning"
},

+ 2
- 0
app/javascript/mastodon/locales/en.json View File

@@ -280,6 +280,7 @@
"notification.favourite": "{name} favourited your status",
"notification.follow": "{name} followed you",
"notification.mention": "{name} mentioned you",
"notification.own_poll": "Your poll has ended",
"notification.poll": "A poll you have voted in has ended",
"notification.reblog": "{name} boosted your status",
"notifications.clear": "Clear notifications",
@@ -418,6 +419,7 @@
"upload_modal.preview_label": "Preview ({ratio})",
"upload_progress.label": "Uploading...",
"video.close": "Close video",
"video.download": "Download file",
"video.exit_fullscreen": "Exit full screen",
"video.expand": "Expand video",
"video.fullscreen": "Full screen",

+ 4
- 2
app/javascript/mastodon/reducers/compose.js View File

@@ -61,6 +61,7 @@ const initialState = ImmutableMap({
is_uploading: false,
progress: 0,
media_attachments: ImmutableList(),
pending_media_attachments: 0,
poll: null,
suggestion_token: null,
suggestions: ImmutableList(),
@@ -114,6 +115,7 @@ function appendMedia(state, media, file) {
map.set('is_uploading', false);
map.set('resetFileKey', Math.floor((Math.random() * 0x10000)));
map.set('idempotencyKey', uuid());
map.update('pending_media_attachments', n => n - 1);

if (prevSize === 0 && (state.get('default_sensitive') || state.get('spoiler'))) {
map.set('sensitive', true);
@@ -322,11 +324,11 @@ export default function compose(state = initialState, action) {
case COMPOSE_UPLOAD_CHANGE_FAIL:
return state.set('is_changing_upload', false);
case COMPOSE_UPLOAD_REQUEST:
return state.set('is_uploading', true);
return state.set('is_uploading', true).update('pending_media_attachments', n => n + 1);
case COMPOSE_UPLOAD_SUCCESS:
return appendMedia(state, fromJS(action.media), action.file);
case COMPOSE_UPLOAD_FAIL:
return state.set('is_uploading', false);
return state.set('is_uploading', false).update('pending_media_attachments', n => action.decrement ? n - 1 : n);
case COMPOSE_UPLOAD_UNDO:
return removeMedia(state, action.media_id);
case COMPOSE_UPLOAD_PROGRESS:

+ 7
- 3
app/javascript/packs/public.js View File

@@ -1,6 +1,7 @@
import loadPolyfills from '../mastodon/load_polyfills';
import ready from '../mastodon/ready';
import { start } from '../mastodon/common';
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';

start();

@@ -122,6 +123,9 @@ function main() {
});
}

loadPolyfills().then(main).catch(error => {
console.error(error);
});
loadPolyfills()
.then(main)
.then(loadKeyboardExtensions)
.catch(error => {
console.error(error);
});

+ 1
- 0
app/javascript/styles/mastodon/components.scss View File

@@ -392,6 +392,7 @@
.autosuggest-input,
.spoiler-input {
position: relative;
width: 100%;
}

.spoiler-input {

+ 1
- 1
app/lib/activitypub/activity.rb View File

@@ -158,7 +158,7 @@ class ActivityPub::Activity
end

def follow_from_object
@follow ||= Follow.find_by(target_account: @account, uri: object_uri) unless object_uri.nil?
@follow ||= ::Follow.find_by(target_account: @account, uri: object_uri) unless object_uri.nil?
end

def fetch_remote_original_status

+ 3
- 1
app/models/account.rb View File

@@ -457,6 +457,8 @@ class Account < ApplicationRecord
SELECT target_account_id
FROM follows
WHERE account_id = ?
UNION ALL
SELECT ?
)
SELECT
accounts.*,
@@ -472,7 +474,7 @@ class Account < ApplicationRecord
LIMIT ? OFFSET ?
SQL

records = find_by_sql([sql, account.id, account.id, account.id, limit, offset])
records = find_by_sql([sql, account.id, account.id, account.id, account.id, limit, offset])
else
sql = <<-SQL.squish
SELECT

+ 3
- 3
app/models/list_account.rb View File

@@ -6,13 +6,13 @@
# id :bigint(8) not null, primary key
# list_id :bigint(8) not null
# account_id :bigint(8) not null
# follow_id :bigint(8) not null
# follow_id :bigint(8)
#

class ListAccount < ApplicationRecord
belongs_to :list
belongs_to :account
belongs_to :follow
belongs_to :follow, optional: true

validates :account_id, uniqueness: { scope: :list_id }

@@ -21,6 +21,6 @@ class ListAccount < ApplicationRecord
private

def set_follow
self.follow = Follow.find_by(account_id: list.account_id, target_account_id: account.id)
self.follow = Follow.find_by!(account_id: list.account_id, target_account_id: account.id) unless list.account_id == account.id
end
end

+ 4
- 2
app/models/media_attachment.rb View File

@@ -26,6 +26,8 @@ class MediaAttachment < ApplicationRecord

enum type: [:image, :gifv, :video, :unknown, :audio]

MAX_DESCRIPTION_LENGTH = 1_500

IMAGE_FILE_EXTENSIONS = %w(.jpg .jpeg .png .gif).freeze
VIDEO_FILE_EXTENSIONS = %w(.webm .mp4 .m4v .mov).freeze
AUDIO_FILE_EXTENSIONS = %w(.ogg .oga .mp3 .wav .flac .opus .aac .m4a .3gp .wma).freeze
@@ -137,7 +139,7 @@ class MediaAttachment < ApplicationRecord
include Attachmentable

validates :account, presence: true
validates :description, length: { maximum: 1_500 }, if: :local?
validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local?

scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
@@ -250,7 +252,7 @@ class MediaAttachment < ApplicationRecord
end

def prepare_description
self.description = description.strip[0...420] unless description.nil?
self.description = description.strip[0...MAX_DESCRIPTION_LENGTH] unless description.nil?
end

def set_file_name

+ 1
- 1
app/presenters/account_relationships_presenter.rb View File

@@ -6,7 +6,7 @@ class AccountRelationshipsPresenter
:endorsed

def initialize(account_ids, current_account_id, **options)
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a }
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i }
@current_account_id = current_account_id

@following = cached[:following].merge(Account.following_map(@uncached_account_ids, @current_account_id))

+ 1
- 1
app/services/account_search_service.rb View File

@@ -127,7 +127,7 @@ class AccountSearchService < BaseService
end

def following_ids
@following_ids ||= account.active_relationships.pluck(:target_account_id)
@following_ids ||= account.active_relationships.pluck(:target_account_id) + [account.id]
end

def limit_for_non_exact_results

+ 2
- 2
app/services/follow_service.rb View File

@@ -8,7 +8,7 @@ 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, target_account, reblogs: nil)
def call(source_account, target_account, reblogs: nil, bypass_locked: false)
reblogs = true if reblogs.nil?
target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)

@@ -30,7 +30,7 @@ class FollowService < BaseService

ActivityTracker.increment('activity:interactions')

if target_account.locked? || source_account.silenced? || target_account.activitypub?
if (target_account.locked? && !bypass_locked) || source_account.silenced? || target_account.activitypub?
request_follow(source_account, target_account, reblogs: reblogs)
elsif target_account.local?
direct_follow(source_account, target_account, reblogs: reblogs)

+ 5
- 1
app/views/public_timelines/show.html.haml View File

@@ -6,7 +6,11 @@

.page-header
%h1= t('about.see_whats_happening')
%p= t('about.browse_public_posts')

- if Setting.show_known_fediverse_at_about_page
%p= t('about.browse_public_posts')
- else
%p= t('about.browse_local_posts')

#mastodon-timeline{ data: { props: Oj.dump(default_props) }}
#modal-container

+ 6
- 2
app/workers/move_worker.rb View File

@@ -7,7 +7,7 @@ class MoveWorker
@source_account = Account.find(source_account_id)
@target_account = Account.find(target_account_id)

if @target_account.local?
if @target_account.local? && @source_account.local?
rewrite_follows!
else
queue_follow_unfollows!
@@ -21,13 +21,17 @@ class MoveWorker
def rewrite_follows!
@source_account.passive_relationships
.where(account: Account.local)
.where.not(account: @target_account.followers.local)
.where.not(account_id: @target_account.id)
.in_batches
.update_all(target_account_id: @target_account.id)
end

def queue_follow_unfollows!
bypass_locked = @target_account.local?

@source_account.followers.local.select(:id).find_in_batches do |accounts|
UnfollowFollowWorker.push_bulk(accounts.map(&:id)) { |follower_id| [follower_id, @source_account.id, @target_account.id] }
UnfollowFollowWorker.push_bulk(accounts.map(&:id)) { |follower_id| [follower_id, @source_account.id, @target_account.id, bypass_locked] }
end
end
end

+ 5
- 2
app/workers/unfollow_follow_worker.rb View File

@@ -5,12 +5,15 @@ class UnfollowFollowWorker

sidekiq_options queue: 'pull'

def perform(follower_account_id, old_target_account_id, new_target_account_id)
def perform(follower_account_id, old_target_account_id, new_target_account_id, bypass_locked = false)
follower_account = Account.find(follower_account_id)
old_target_account = Account.find(old_target_account_id)
new_target_account = Account.find(new_target_account_id)

FollowService.new.call(follower_account, new_target_account)
follow = follower_account.active_relationships.find_by(target_account: old_target_account)
reblogs = follow&.show_reblogs?

FollowService.new.call(follower_account, new_target_account, reblogs: reblogs, bypass_locked: bypass_locked)
UnfollowService.new.call(follower_account, old_target_account, skip_unmerge: true)
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
true

+ 1
- 0
config/locales/en.yml View File

@@ -11,6 +11,7 @@ en:
apps: Mobile apps
apps_platforms: Use Mastodon from iOS, Android and other platforms
browse_directory: Browse a profile directory and filter by interests
browse_local_posts: Browse a live stream of public posts from this server
browse_public_posts: Browse a live stream of public posts on Mastodon
contact: Contact
contact_missing: Not set

+ 3
- 1
config/webpack/rules/css.js View File

@@ -20,7 +20,9 @@ module.exports = {
{
loader: 'sass-loader',
options: {
includePaths: ['app/javascript'],
sassOptions: {
includePaths: ['app/javascript'],
},
implementation: require('sass'),
sourceMap: true,
},

+ 3
- 0
config/webpack/test.js View File

@@ -5,4 +5,7 @@ const sharedConfig = require('./shared.js');

module.exports = merge(sharedConfig, {
mode: 'development',
optimization: {
minimize: false,
},
});

+ 5
- 0
db/migrate/20191031163205_change_list_account_follow_nullable.rb View File

@@ -0,0 +1,5 @@
class ChangeListAccountFollowNullable < ActiveRecord::Migration[5.1]
def change
change_column_null :list_accounts, :follow_id, true
end
end

+ 2
- 2
db/schema.rb View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2019_10_07_013357) do
ActiveRecord::Schema.define(version: 2019_10_31_163205) do

# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -395,7 +395,7 @@ ActiveRecord::Schema.define(version: 2019_10_07_013357) do
create_table "list_accounts", force: :cascade do |t|
t.bigint "list_id", null: false
t.bigint "account_id", null: false
t.bigint "follow_id", null: false
t.bigint "follow_id"
t.index ["account_id", "list_id"], name: "index_list_accounts_on_account_id_and_list_id", unique: true
t.index ["follow_id"], name: "index_list_accounts_on_follow_id"
t.index ["list_id", "account_id"], name: "index_list_accounts_on_list_id_and_account_id"

+ 22
- 0
lib/mastodon/media_cli.rb View File

@@ -113,5 +113,27 @@ module Mastodon
say("Imports:\t#{number_to_human_size(Import.sum(:data_file_size))}")
say("Settings:\t#{number_to_human_size(SiteUpload.sum(:file_file_size))}")
end

desc 'lookup', 'Lookup where media is displayed by passing a media URL'
def lookup
prompt = TTY::Prompt.new

url = prompt.ask('Please enter a URL to the media to lookup:', required: true)

attachment_id = url
.split('/')[0..-2]
.grep(/\A\d+\z/)
.join('')

if url.split('/')[0..-2].include? 'media_attachments'
model = MediaAttachment.find(attachment_id).status
prompt.say(ActivityPub::TagManager.instance.url_for(model))
elsif url.split('/')[0..-2].include? 'accounts'
model = Account.find(attachment_id)
prompt.say(ActivityPub::TagManager.instance.url_for(model))
else
prompt.say('Not found')
end
end
end
end

+ 24
- 22
package.json View File

@@ -62,19 +62,20 @@
"dependencies": {
"@babel/core": "^7.4.5",
"@babel/plugin-proposal-class-properties": "^7.5.5",
"@babel/plugin-proposal-decorators": "^7.4.4",
"@babel/plugin-proposal-object-rest-spread": "^7.4.4",
"@babel/plugin-proposal-decorators": "^7.6.0",
"@babel/plugin-proposal-object-rest-spread": "^7.6.2",
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
"@babel/plugin-transform-react-inline-elements": "^7.2.0",
"@babel/plugin-transform-react-jsx-self": "^7.2.0",
"@babel/plugin-transform-react-jsx-source": "^7.5.0",
"@babel/plugin-transform-runtime": "^7.5.5",
"@babel/preset-env": "^7.6.0",
"@babel/preset-react": "^7.0.0",
"@babel/runtime": "^7.5.4",
"@babel/preset-env": "^7.7.1",
"@babel/preset-react": "^7.6.3",
"@babel/runtime": "^7.7.1",
"@clusterws/cws": "^0.15.2",
"array-includes": "^3.0.3",
"atrament": "^0.2.3",
"arrow-key-navigation": "^1.0.2",
"autoprefixer": "^9.6.1",
"axios": "^0.19.0",
"babel-loader": "^8.0.6",
@@ -91,7 +92,7 @@
"css-loader": "^3.2.0",
"cssnano": "^4.1.10",
"detect-passive-events": "^1.0.2",
"dotenv": "^8.0.0",
"dotenv": "^8.2.0",
"emoji-mart": "Gargron/emoji-mart#build",
"es6-symbol": "^3.1.2",
"escape-html": "^1.0.3",
@@ -100,7 +101,7 @@
"file-loader": "^4.2.0",
"favico.js": "^0.3.10",
"font-awesome": "^4.7.0",
"glob": "^7.1.1",
"glob": "^7.1.5",
"history": "^4.10.1",
"http-link-header": "^1.0.2",
"immutable": "^3.8.2",
@@ -129,7 +130,7 @@
"punycode": "^2.1.0",
"rails-ujs": "^5.2.3",
"react": "^16.10.2",
"react-dom": "^16.10.2",
"react-dom": "^16.11.0",
"react-hotkeys": "^1.1.4",
"react-immutable-proptypes": "^2.1.0",
"react-immutable-pure-component": "^1.1.1",
@@ -137,7 +138,7 @@
"react-masonry-infinite": "^1.2.2",
"react-motion": "^0.5.2",
"react-notification": "^6.8.4",
"react-overlays": "^0.8.3",
"react-overlays": "^0.9.1",
"react-redux": "^7.1.1",
"react-redux-loading-bar": "^4.0.8",
"react-router-dom": "^4.1.1",
@@ -146,7 +147,7 @@
"react-sparklines": "^1.7.0",
"react-swipeable-views": "^0.13.3",
"react-textarea-autosize": "^7.1.0",
"react-toggle": "^4.0.1",
"react-toggle": "^4.1.1",
"redis": "^2.7.1",
"redux": "^4.0.4",
"redux-immutable": "^4.0.0",
@@ -155,22 +156,23 @@
"requestidlecallback": "^0.3.0",
"reselect": "^4.0.0",
"rimraf": "^3.0.0",
"sass": "^1.23.0",
"sass-loader": "^7.0.3",
"sass": "^1.23.3",
"sass-loader": "^8.0.0",
"stringz": "^2.0.0",
"substring-trie": "^1.0.2",
"terser-webpack-plugin": "^1.4.1",
"tesseract.js": "^2.0.0-alpha.16",
"terser-webpack-plugin": "^2.2.1",
"tesseract.js": "^2.0.0-beta.2",
"throng": "^4.0.0",
"tiny-queue": "^0.2.1",
"uuid": "^3.1.0",
"wavesurfer.js": "^3.0.0",
"webpack": "^4.35.3",
"wavesurfer.js": "^3.2.0",
"webpack": "^4.41.2",
"webpack-assets-manifest": "^3.1.1",
"webpack-bundle-analyzer": "^3.3.2",
"webpack-cli": "^3.3.7",
"webpack-bundle-analyzer": "^3.6.0",
"webpack-cli": "^3.3.10",
"webpack-merge": "^4.2.1",
"websocket.js": "^0.1.12"
"websocket.js": "^0.1.12",
"wicg-inert": "^3.0.0"
},
"devDependencies": {
"babel-eslint": "^10.0.3",
@@ -181,13 +183,13 @@
"eslint-plugin-import": "~2.18.2",
"eslint-plugin-jsx-a11y": "~6.2.3",
"eslint-plugin-promise": "~4.2.1",
"eslint-plugin-react": "~7.14.3",
"eslint-plugin-react": "~7.16.0",
"jest": "^24.9.0",
"raf": "^3.4.1",
"react-intl-translations-manager": "^5.0.3",
"react-test-renderer": "^16.10.2",
"react-test-renderer": "^16.11.0",
"sass-lint": "^1.13.1",
"webpack-dev-server": "^3.8.0",
"webpack-dev-server": "^3.9.0",
"yargs": "^13.3.0"
}
}

+ 26
- 0
spec/lib/activitypub/activity/create_spec.rb View File

@@ -261,6 +261,32 @@ RSpec.describe ActivityPub::Activity::Create do
end
end


context 'with media attachments with long description' do
let(:object_json) do
{
id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join,
type: 'Note',
content: 'Lorem ipsum',
attachment: [
{
type: 'Document',
mediaType: 'image/png',
url: 'http://example.com/attachment.png',
name: '*' * 1500,
},
],
}
end

it 'creates status' do
status = sender.statuses.first

expect(status).to_not be_nil
expect(status.media_attachments.map(&:description)).to include('*' * 1500)
end
end

context 'with media attachments with focal points' do
let(:object_json) do
{

+ 2
- 2
spec/models/media_attachment_spec.rb View File

@@ -136,10 +136,10 @@ RSpec.describe MediaAttachment, type: :model do
end

describe 'descriptions for remote attachments' do
it 'are cut off at 140 characters' do
it 'are cut off at 1500 characters' do
media = Fabricate(:media_attachment, description: 'foo' * 1000, remote_url: 'http://example.com/blah.jpg')

expect(media.description.size).to be <= 420
expect(media.description.size).to be <= 1_500
end
end
end

+ 63
- 0
spec/workers/move_worker_spec.rb View File

@@ -0,0 +1,63 @@
# frozen_string_literal: true

require 'rails_helper'

describe MoveWorker do
let(:local_follower) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
let(:source_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') }
let(:target_account) { Fabricate(:account, protocol: :activitypub, domain: 'example.com') }

subject { described_class.new }

before do
local_follower.follow!(source_account)
end

context 'both accounts are distant' do
describe 'perform' do
it 'calls UnfollowFollowWorker' do
allow(UnfollowFollowWorker).to receive(:push_bulk)
subject.perform(source_account.id, target_account.id)
expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
end
end
end

context 'target account is local' do
let(:target_account) { Fabricate(:user, email: 'alice@example.com', account: Fabricate(:account, username: 'alice')).account }

describe 'perform' do
it 'calls UnfollowFollowWorker' do
allow(UnfollowFollowWorker).to receive(:push_bulk)
subject.perform(source_account.id, target_account.id)
expect(UnfollowFollowWorker).to have_received(:push_bulk).with([local_follower.id])
end
end
end

context 'both target and source accounts are local' do
let(:target_account) { Fabricate(:user, email: 'alice@example.com', account: Fabricate(:account, username: 'alice')).account }
let(:source_account) { Fabricate(:user, email: 'alice_@example.com', account: Fabricate(:account, username: 'alice_')).account }

describe 'perform' do
it 'calls makes local followers follow the target account' do
subject.perform(source_account.id, target_account.id)
expect(local_follower.following?(target_account)).to be true
end

it 'does not fail when a local user is already following both accounts' do
double_follower = Fabricate(:user, email: 'eve@example.com', account: Fabricate(:account, username: 'eve')).account
double_follower.follow!(source_account)
double_follower.follow!(target_account)
subject.perform(source_account.id, target_account.id)
expect(local_follower.following?(target_account)).to be true
end

it 'does not allow the moved account to follow themselves' do
source_account.follow!(target_account)
subject.perform(source_account.id, target_account.id)
expect(target_account.following?(target_account)).to be false
end
end
end
end

+ 50
- 0
spec/workers/unfollow_follow_worker_spec.rb View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true

require 'rails_helper'

describe UnfollowFollowWorker do
let(:local_follower) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account }
let(:source_account) { Fabricate(:account) }
let(:target_account) { Fabricate(:account) }
let(:show_reblogs) { true }

subject { described_class.new }

before do
local_follower.follow!(source_account, reblogs: show_reblogs)
end

context 'when show_reblogs is true' do
let(:show_reblogs) { true }

describe 'perform' do
it 'unfollows source account and follows target account' do
subject.perform(local_follower.id, source_account.id, target_account.id)
expect(local_follower.following?(source_account)).to be false
expect(local_follower.following?(target_account)).to be true
end

it 'preserves show_reblogs' do
subject.perform(local_follower.id, source_account.id, target_account.id)
expect(Follow.find_by(account: local_follower, target_account: target_account).show_reblogs?).to be show_reblogs
end
end
end

context 'when show_reblogs is false' do
let(:show_reblogs) { false }

describe 'perform' do
it 'unfollows source account and follows target account' do
subject.perform(local_follower.id, source_account.id, target_account.id)
expect(local_follower.following?(source_account)).to be false
expect(local_follower.following?(target_account)).to be true
end

it 'preserves show_reblogs' do
subject.perform(local_follower.id, source_account.id, target_account.id)
expect(Follow.find_by(account: local_follower, target_account: target_account).show_reblogs?).to be show_reblogs
end
end
end
end

+ 586
- 461
yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save