Merge pull request #1500 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes
th-downstream
Claire 4 years ago committed by GitHub
commit cef72ebd50

@ -30,7 +30,7 @@ plugins:
channel: eslint-7 channel: eslint-7
rubocop: rubocop:
enabled: true enabled: true
channel: rubocop-1-8-1 channel: rubocop-1-9-1
sass-lint: sass-lint:
enabled: true enabled: true
exclude_patterns: exclude_patterns:

@ -17,7 +17,7 @@ gem 'makara', '~> 0.5'
gem 'pghero', '~> 2.7' gem 'pghero', '~> 2.7'
gem 'dotenv-rails', '~> 2.7' gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.87', require: false gem 'aws-sdk-s3', '~> 1.88', require: false
gem 'fog-core', '<= 2.1.0' gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0' gem 'paperclip', '~> 6.0'
@ -27,7 +27,7 @@ gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.7' gem 'addressable', '~> 2.7'
gem 'bootsnap', '~> 1.5', require: false gem 'bootsnap', '~> 1.6.0', require: false
gem 'browser' gem 'browser'
gem 'charlock_holmes', '~> 0.7.7' gem 'charlock_holmes', '~> 0.7.7'
gem 'iso-639' gem 'iso-639'
@ -73,7 +73,7 @@ gem 'parallel', '~> 1.20'
gem 'posix-spawn' gem 'posix-spawn'
gem 'pundit', '~> 2.1' gem 'pundit', '~> 2.1'
gem 'premailer-rails' gem 'premailer-rails'
gem 'rack-attack', '~> 6.4' gem 'rack-attack', '~> 6.5'
gem 'rack-cors', '~> 1.1', require: 'rack/cors' gem 'rack-cors', '~> 1.1', require: 'rack/cors'
gem 'rails-i18n', '~> 5.1' gem 'rails-i18n', '~> 5.1'
gem 'rails-settings-cached', '~> 0.6' gem 'rails-settings-cached', '~> 0.6'
@ -140,7 +140,7 @@ group :development do
gem 'letter_opener', '~> 1.7' gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.4' gem 'letter_opener_web', '~> 1.4'
gem 'memory_profiler' gem 'memory_profiler'
gem 'rubocop', '~> 1.8', require: false gem 'rubocop', '~> 1.9', require: false
gem 'rubocop-rails', '~> 2.9', require: false gem 'rubocop-rails', '~> 2.9', require: false
gem 'brakeman', '~> 4.10', require: false gem 'brakeman', '~> 4.10', require: false
gem 'bundler-audit', '~> 0.7', require: false gem 'bundler-audit', '~> 0.7', require: false

@ -72,24 +72,24 @@ GEM
activerecord (>= 3.2, < 7.0) activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0) rake (>= 10.4, < 14.0)
arel (9.0.0) arel (9.0.0)
ast (2.4.1) ast (2.4.2)
attr_encrypted (3.1.0) attr_encrypted (3.1.0)
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
av (0.9.0) av (0.9.0)
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
awrence (1.1.1) awrence (1.1.1)
aws-eventstream (1.1.0) aws-eventstream (1.1.0)
aws-partitions (1.413.0) aws-partitions (1.424.0)
aws-sdk-core (3.110.0) aws-sdk-core (3.112.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0) aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-kms (1.40.0) aws-sdk-kms (1.42.0)
aws-sdk-core (~> 3, >= 3.109.0) aws-sdk-core (~> 3, >= 3.112.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.87.0) aws-sdk-s3 (1.88.0)
aws-sdk-core (~> 3, >= 3.109.0) aws-sdk-core (~> 3, >= 3.112.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.2) aws-sigv4 (1.2.2)
@ -104,7 +104,7 @@ GEM
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
blurhash (0.1.4) blurhash (0.1.4)
ffi (~> 1.10.0) ffi (~> 1.10.0)
bootsnap (1.5.1) bootsnap (1.6.0)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.10.1) brakeman (4.10.1)
browser (4.2.0) browser (4.2.0)
@ -275,7 +275,7 @@ GEM
httplog (1.4.3) httplog (1.4.3)
rack (>= 1.0) rack (>= 1.0)
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.8.7) i18n (1.8.8)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
i18n-tasks (0.9.33) i18n-tasks (0.9.33)
activesupport (>= 4.0.2) activesupport (>= 4.0.2)
@ -293,7 +293,7 @@ GEM
jmespath (1.4.0) jmespath (1.4.0)
json (2.3.1) json (2.3.1)
json-canonicalization (0.2.0) json-canonicalization (0.2.0)
json-ld (3.1.7) json-ld (3.1.8)
htmlentities (~> 4.3) htmlentities (~> 4.3)
json-canonicalization (~> 0.2) json-canonicalization (~> 0.2)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
@ -354,7 +354,7 @@ GEM
mini_mime (1.0.2) mini_mime (1.0.2)
mini_portile2 (2.5.0) mini_portile2 (2.5.0)
minitest (5.14.3) minitest (5.14.3)
msgpack (1.3.3) msgpack (1.4.2)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.1.1) multipart-post (2.1.1)
net-ldap (0.17.0) net-ldap (0.17.0)
@ -408,9 +408,9 @@ GEM
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.2.3) pg (1.2.3)
pghero (2.7.3) pghero (2.7.4)
activerecord (>= 5) activerecord (>= 5)
pkg-config (1.4.4) pkg-config (1.4.5)
pluck_each (0.1.3) pluck_each (0.1.3)
activerecord (> 3.2.0) activerecord (> 3.2.0)
activesupport (> 3.0.0) activesupport (> 3.0.0)
@ -439,7 +439,7 @@ GEM
raabro (1.3.3) raabro (1.3.3)
racc (1.5.2) racc (1.5.2)
rack (2.2.3) rack (2.2.3)
rack-attack (6.4.0) rack-attack (6.5.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-cors (1.1.1) rack-cors (1.1.1)
rack (>= 2.0.0) rack (>= 2.0.0)
@ -482,7 +482,7 @@ GEM
thor (>= 0.19.0, < 2.0) thor (>= 0.19.0, < 2.0)
rainbow (3.0.0) rainbow (3.0.0)
rake (13.0.3) rake (13.0.3)
rdf (3.1.8) rdf (3.1.10)
hamster (~> 3.0) hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.4.0) rdf-normalize (0.4.0)
@ -496,7 +496,7 @@ GEM
redis-activesupport (5.2.0) redis-activesupport (5.2.0)
activesupport (>= 3, < 7) activesupport (>= 3, < 7)
redis-store (>= 1.3, < 2) redis-store (>= 1.3, < 2)
redis-namespace (1.8.0) redis-namespace (1.8.1)
redis (>= 3.0.4) redis (>= 3.0.4)
redis-rack (2.1.3) redis-rack (2.1.3)
rack (>= 2.0.8, < 3) rack (>= 2.0.8, < 3)
@ -542,7 +542,7 @@ GEM
rspec-support (3.10.1) rspec-support (3.10.1)
rspec_junit_formatter (0.4.1) rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.8.1) rubocop (1.9.1)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.0.0.0) parser (>= 3.0.0.0)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
@ -551,7 +551,7 @@ GEM
rubocop-ast (>= 1.2.0, < 2.0) rubocop-ast (>= 1.2.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0) unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.4.0) rubocop-ast (1.4.1)
parser (>= 2.7.1.5) parser (>= 2.7.1.5)
rubocop-rails (2.9.1) rubocop-rails (2.9.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -693,11 +693,11 @@ DEPENDENCIES
active_record_query_trace (~> 1.8) active_record_query_trace (~> 1.8)
addressable (~> 2.7) addressable (~> 2.7)
annotate (~> 3.1) annotate (~> 3.1)
aws-sdk-s3 (~> 1.87) aws-sdk-s3 (~> 1.88)
better_errors (~> 2.9) better_errors (~> 2.9)
binding_of_caller (~> 1.0) binding_of_caller (~> 1.0)
blurhash (~> 0.1) blurhash (~> 0.1)
bootsnap (~> 1.5) bootsnap (~> 1.6.0)
brakeman (~> 4.10) brakeman (~> 4.10)
browser browser
bullet (~> 6.1) bullet (~> 6.1)
@ -777,7 +777,7 @@ DEPENDENCIES
puma (~> 5.1) puma (~> 5.1)
pundit (~> 2.1) pundit (~> 2.1)
rack (~> 2.2.3) rack (~> 2.2.3)
rack-attack (~> 6.4) rack-attack (~> 6.5)
rack-cors (~> 1.1) rack-cors (~> 1.1)
rails (~> 5.2.4.4) rails (~> 5.2.4.4)
rails-controller-testing (~> 1.0) rails-controller-testing (~> 1.0)
@ -792,7 +792,7 @@ DEPENDENCIES
rspec-rails (~> 4.0) rspec-rails (~> 4.0)
rspec-sidekiq (~> 3.1) rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.4) rspec_junit_formatter (~> 0.4)
rubocop (~> 1.8) rubocop (~> 1.9)
rubocop-rails (~> 2.9) rubocop-rails (~> 2.9)
ruby-progressbar (~> 1.11) ruby-progressbar (~> 1.11)
sanitize (~> 5.2) sanitize (~> 5.2)

2
Vagrantfile vendored

@ -72,10 +72,12 @@ bundle install
yarn install yarn install
# Build Mastodon # Build Mastodon
export RAILS_ENV=development
export $(cat ".env.vagrant" | xargs) export $(cat ".env.vagrant" | xargs)
bundle exec rails db:setup bundle exec rails db:setup
# Configure automatic loading of environment variable # Configure automatic loading of environment variable
echo 'export RAILS_ENV=development' >> ~/.bash_profile
echo 'export $(cat "/vagrant/.env.vagrant" | xargs)' >> ~/.bash_profile echo 'export $(cat "/vagrant/.env.vagrant" | xargs)' >> ~/.bash_profile
SCRIPT SCRIPT

@ -2,17 +2,16 @@
class Api::Web::SettingsController < Api::Web::BaseController class Api::Web::SettingsController < Api::Web::BaseController
before_action :require_user! before_action :require_user!
before_action :set_setting
def update def update
setting.data = params[:data] @setting.update!(data: params[:data])
setting.save!
render_empty render_empty
end end
private private
def setting def set_setting
@_setting ||= ::Web::Setting.where(user: current_user).first_or_initialize(user: current_user) @setting = ::Web::Setting.where(user: current_user).first_or_initialize(user: current_user)
end end
end end

@ -44,7 +44,7 @@ class ApplicationController < ActionController::Base
private private
def https_enabled? def https_enabled?
Rails.env.production? && !request.path.start_with?('/health') Rails.env.production? && !request.path.start_with?('/health') && !request.headers["Host"].ends_with?(".onion")
end end
def authorized_fetch_mode? def authorized_fetch_mode?

@ -13,7 +13,7 @@ class InstanceActorsController < ApplicationController
private private
def set_account def set_account
@account = Account.find(-99) @account = Account.representative
end end
def restrict_fields_to def restrict_fields_to

@ -0,0 +1,13 @@
# frozen_string_literal: true
module MascotHelper
def mascot_url
full_asset_url(instance_presenter.mascot&.file&.url || asset_pack_path('media/images/elephant_ui_plane.svg'))
end
private
def instance_presenter
@instance_presenter ||= InstancePresenter.new
end
end

@ -0,0 +1,29 @@
import { openModal } from './modal';
export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL';
export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY';
export function initBoostModal(props) {
return (dispatch, getState) => {
const default_privacy = getState().getIn(['compose', 'default_privacy']);
const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy;
dispatch({
type: BOOSTS_INIT_MODAL,
privacy
});
dispatch(openModal('BOOST', props));
};
}
export function changeBoostPrivacy(privacy) {
return dispatch => {
dispatch({
type: BOOSTS_CHANGE_PRIVACY,
privacy,
});
};
}

@ -41,11 +41,11 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
export function reblog(status) { export function reblog(status, visibility) {
return function (dispatch, getState) { return function (dispatch, getState) {
dispatch(reblogRequest(status)); dispatch(reblogRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`, { visibility }).then(function (response) {
// The reblog API method returns a new status wrapped around the original. In this case we are only // The reblog API method returns a new status wrapped around the original. In this case we are only
// interested in how the original is modified, hence passing it skipping the wrapper // interested in how the original is modified, hence passing it skipping the wrapper
dispatch(importFetchedStatus(response.data.reblog)); dispatch(importFetchedStatus(response.data.reblog));

@ -177,7 +177,6 @@ export default class Dropdown extends React.PureComponent {
disabled: PropTypes.bool, disabled: PropTypes.bool,
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
isUserTouching: PropTypes.func, isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onOpen: PropTypes.func.isRequired, onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
dropdownPlacement: PropTypes.string, dropdownPlacement: PropTypes.string,

@ -193,9 +193,10 @@ class StatusActionBar extends ImmutablePureComponent {
render () { render () {
const { status, intl, withDismiss, showReplyCount, directMessage, scrollKey } = this.props; const { status, intl, withDismiss, showReplyCount, directMessage, scrollKey } = this.props;
const mutingConversation = status.get('muted');
const anonymousAccess = !me; const anonymousAccess = !me;
const mutingConversation = status.get('muted');
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const writtenByMe = status.getIn(['account', 'id']) === me;
let menu = []; let menu = [];
let reblogIcon = 'retweet'; let reblogIcon = 'retweet';
@ -211,16 +212,17 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push(null); menu.push(null);
if (status.getIn(['account', 'id']) === me || withDismiss) { if (writtenByMe && publicStatus) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null); menu.push(null);
} }
if (status.getIn(['account', 'id']) === me) { if (writtenByMe || withDismiss) {
if (publicStatus) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); menu.push(null);
} }
if (writtenByMe) {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
} else { } else {

@ -5,7 +5,6 @@ import DropdownMenu from 'flavours/glitch/components/dropdown_menu';
import { isUserTouching } from 'flavours/glitch/util/is_mobile'; import { isUserTouching } from 'flavours/glitch/util/is_mobile';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
isModalOpen: state.get('modal').modalType === 'ACTIONS',
dropdownPlacement: state.getIn(['dropdown_menu', 'placement']), dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
openDropdownId: state.getIn(['dropdown_menu', 'openId']), openDropdownId: state.getIn(['dropdown_menu', 'openId']),
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),

@ -21,6 +21,7 @@ import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/
import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initBlockModal } from 'flavours/glitch/actions/blocks';
import { initReport } from 'flavours/glitch/actions/reports'; import { initReport } from 'flavours/glitch/actions/reports';
import { initBoostModal } from 'flavours/glitch/actions/boosts';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture'; import { deployPictureInPicture } from 'flavours/glitch/actions/picture_in_picture';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings'; import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
@ -96,11 +97,11 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
}); });
}, },
onModalReblog (status) { onModalReblog (status, privacy) {
if (status.get('reblogged')) { if (status.get('reblogged')) {
dispatch(unreblog(status)); dispatch(unreblog(status));
} else { } else {
dispatch(reblog(status)); dispatch(reblog(status, privacy));
} }
}, },
@ -108,11 +109,11 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
dispatch((_, getState) => { dispatch((_, getState) => {
let state = getState(); let state = getState();
if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) { if (state.getIn(['local_settings', 'confirm_boost_missing_media_description']) && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog, missingMediaDescription: true })); dispatch(initBoostModal({ status, onReblog: this.onModalReblog, missingMediaDescription: true }));
} else if (e.shiftKey || !boostModal) { } else if (e.shiftKey || !boostModal) {
this.onModalReblog(status); this.onModalReblog(status);
} else { } else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
} }
}); });
}, },

@ -31,6 +31,8 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
title: PropTypes.string, title: PropTypes.string,
value: PropTypes.string, value: PropTypes.string,
onChange: PropTypes.func, onChange: PropTypes.func,
noModal: PropTypes.bool,
container: PropTypes.func,
}; };
state = { state = {
@ -42,10 +44,10 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
// Toggles opening and closing the dropdown. // Toggles opening and closing the dropdown.
handleToggle = ({ target, type }) => { handleToggle = ({ target, type }) => {
const { onModalOpen } = this.props; const { onModalOpen, noModal } = this.props;
const { open } = this.state; const { open } = this.state;
if (isUserTouching()) { if (!noModal && isUserTouching()) {
if (this.state.open) { if (this.state.open) {
this.props.onModalClose(); this.props.onModalClose();
} else { } else {
@ -183,6 +185,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
items, items,
onChange, onChange,
value, value,
container,
} = this.props; } = this.props;
const { open, placement } = this.state; const { open, placement } = this.state;
const computedClass = classNames('composer--options--dropdown', { const computedClass = classNames('composer--options--dropdown', {
@ -219,6 +222,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
placement={placement} placement={placement}
show={open} show={open}
target={this} target={this}
container={container}
> >
<DropdownMenu <DropdownMenu
items={items} items={items}

@ -9,6 +9,7 @@ import spring from 'react-motion/lib/spring';
import IconButton from 'flavours/glitch/components/icon_button'; import IconButton from 'flavours/glitch/components/icon_button';
import TextIconButton from './text_icon_button'; import TextIconButton from './text_icon_button';
import Dropdown from './dropdown'; import Dropdown from './dropdown';
import PrivacyDropdown from './privacy_dropdown';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
// Utils. // Utils.
@ -25,22 +26,10 @@ const messages = defineMessages({
defaultMessage: 'Attach...', defaultMessage: 'Attach...',
id: 'compose.attach', id: 'compose.attach',
}, },
change_privacy: {
defaultMessage: 'Adjust status privacy',
id: 'privacy.change',
},
content_type: { content_type: {
defaultMessage: 'Content type', defaultMessage: 'Content type',
id: 'content-type.change', id: 'content-type.change',
}, },
direct_long: {
defaultMessage: 'Visible for mentioned users only',
id: 'privacy.direct.long',
},
direct_short: {
defaultMessage: 'Direct',
id: 'privacy.direct.short',
},
doodle: { doodle: {
defaultMessage: 'Draw something', defaultMessage: 'Draw something',
id: 'compose.attach.doodle', id: 'compose.attach.doodle',
@ -65,22 +54,6 @@ const messages = defineMessages({
defaultMessage: 'Plain text', defaultMessage: 'Plain text',
id: 'compose.content-type.plain', id: 'compose.content-type.plain',
}, },
private_long: {
defaultMessage: 'Visible for followers only',
id: 'privacy.private.long',
},
private_short: {
defaultMessage: 'Followers-only',
id: 'privacy.private.short',
},
public_long: {
defaultMessage: 'Visible for all, shown in public timelines',
id: 'privacy.public.long',
},
public_short: {
defaultMessage: 'Public',
id: 'privacy.public.short',
},
spoiler: { spoiler: {
defaultMessage: 'Hide text behind warning', defaultMessage: 'Hide text behind warning',
id: 'compose_form.spoiler', id: 'compose_form.spoiler',
@ -93,14 +66,6 @@ const messages = defineMessages({
defaultMessage: 'Threaded mode', defaultMessage: 'Threaded mode',
id: 'advanced_options.threaded_mode.short', id: 'advanced_options.threaded_mode.short',
}, },
unlisted_long: {
defaultMessage: 'Visible for all, but not in public timelines',
id: 'privacy.unlisted.long',
},
unlisted_short: {
defaultMessage: 'Unlisted',
id: 'privacy.unlisted.short',
},
upload: { upload: {
defaultMessage: 'Upload a file', defaultMessage: 'Upload a file',
id: 'compose.attach.upload', id: 'compose.attach.upload',
@ -201,35 +166,6 @@ class ComposerOptions extends ImmutablePureComponent {
showContentTypeChoice, showContentTypeChoice,
} = this.props; } = this.props;
// We predefine our privacy items so that we can easily pick the
// dropdown icon later.
const privacyItems = {
direct: {
icon: 'envelope',
meta: <FormattedMessage {...messages.direct_long} />,
name: 'direct',
text: <FormattedMessage {...messages.direct_short} />,
},
private: {
icon: 'lock',
meta: <FormattedMessage {...messages.private_long} />,
name: 'private',
text: <FormattedMessage {...messages.private_short} />,
},
public: {
icon: 'globe',
meta: <FormattedMessage {...messages.public_long} />,
name: 'public',
text: <FormattedMessage {...messages.public_short} />,
},
unlisted: {
icon: 'unlock',
meta: <FormattedMessage {...messages.unlisted_long} />,
name: 'unlisted',
text: <FormattedMessage {...messages.unlisted_short} />,
},
};
const contentTypeItems = { const contentTypeItems = {
plain: { plain: {
icon: 'file-text', icon: 'file-text',
@ -297,19 +233,11 @@ class ComposerOptions extends ImmutablePureComponent {
/> />
)} )}
<hr /> <hr />
<Dropdown <PrivacyDropdown
disabled={disabled} disabled={disabled}
icon={(privacyItems[privacy] || {}).icon}
items={[
privacyItems.public,
privacyItems.unlisted,
privacyItems.private,
privacyItems.direct,
]}
onChange={onChangeVisibility} onChange={onChangeVisibility}
onModalClose={onModalClose} onModalClose={onModalClose}
onModalOpen={onModalOpen} onModalOpen={onModalOpen}
title={intl.formatMessage(messages.change_privacy)}
value={privacy} value={privacy}
/> />
{showContentTypeChoice && ( {showContentTypeChoice && (

@ -0,0 +1,115 @@
import PropTypes from 'prop-types';
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import Dropdown from './dropdown';
const messages = defineMessages({
change_privacy: {
defaultMessage: 'Adjust status privacy',
id: 'privacy.change',
},
direct_long: {
defaultMessage: 'Visible for mentioned users only',
id: 'privacy.direct.long',
},
direct_short: {
defaultMessage: 'Direct',
id: 'privacy.direct.short',
},
private_long: {
defaultMessage: 'Visible for followers only',
id: 'privacy.private.long',
},
private_short: {
defaultMessage: 'Followers-only',
id: 'privacy.private.short',
},
public_long: {
defaultMessage: 'Visible for all, shown in public timelines',
id: 'privacy.public.long',
},
public_short: {
defaultMessage: 'Public',
id: 'privacy.public.short',
},
unlisted_long: {
defaultMessage: 'Visible for all, but not in public timelines',
id: 'privacy.unlisted.long',
},
unlisted_short: {
defaultMessage: 'Unlisted',
id: 'privacy.unlisted.short',
},
});
export default @injectIntl
class PrivacyDropdown extends React.PureComponent {
static propTypes = {
isUserTouching: PropTypes.func,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
noDirect: PropTypes.bool,
noModal: PropTypes.bool,
container: PropTypes.func,
intl: PropTypes.object.isRequired,
};
render () {
const { value, onChange, onModalOpen, onModalClose, disabled, noDirect, noModal, container, intl } = this.props;
// We predefine our privacy items so that we can easily pick the
// dropdown icon later.
const privacyItems = {
direct: {
icon: 'envelope',
meta: <FormattedMessage {...messages.direct_long} />,
name: 'direct',
text: <FormattedMessage {...messages.direct_short} />,
},
private: {
icon: 'lock',
meta: <FormattedMessage {...messages.private_long} />,
name: 'private',
text: <FormattedMessage {...messages.private_short} />,
},
public: {
icon: 'globe',
meta: <FormattedMessage {...messages.public_long} />,
name: 'public',
text: <FormattedMessage {...messages.public_short} />,
},
unlisted: {
icon: 'unlock',
meta: <FormattedMessage {...messages.unlisted_long} />,
name: 'unlisted',
text: <FormattedMessage {...messages.unlisted_short} />,
},
};
const items = [privacyItems.public, privacyItems.unlisted, privacyItems.private];
if (!noDirect) {
items.push(privacyItems.direct);
}
return (
<Dropdown
disabled={disabled}
icon={(privacyItems[value] || {}).icon}
items={items}
onChange={onChange}
onModalClose={onModalClose}
onModalOpen={onModalOpen}
title={intl.formatMessage(messages.change_privacy)}
container={container}
noModal={noModal}
value={value}
/>
);
}
}

@ -11,6 +11,7 @@ import { replyCompose } from 'flavours/glitch/actions/compose';
import { reblog, favourite, unreblog, unfavourite } from 'flavours/glitch/actions/interactions'; import { reblog, favourite, unreblog, unfavourite } from 'flavours/glitch/actions/interactions';
import { makeGetStatus } from 'flavours/glitch/selectors'; import { makeGetStatus } from 'flavours/glitch/selectors';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { initBoostModal } from 'flavours/glitch/actions/boosts';
const messages = defineMessages({ const messages = defineMessages({
reply: { id: 'status.reply', defaultMessage: 'Reply' }, reply: { id: 'status.reply', defaultMessage: 'Reply' },
@ -82,9 +83,9 @@ class Footer extends ImmutablePureComponent {
} }
}; };
_performReblog = () => { _performReblog = (privacy) => {
const { dispatch, status } = this.props; const { dispatch, status } = this.props;
dispatch(reblog(status)); dispatch(reblog(status, privacy));
} }
handleReblogClick = e => { handleReblogClick = e => {
@ -95,7 +96,7 @@ class Footer extends ImmutablePureComponent {
} else if ((e && e.shiftKey) || !boostModal) { } else if ((e && e.shiftKey) || !boostModal) {
this._performReblog(); this._performReblog();
} else { } else {
dispatch(openModal('BOOST', { status, onReblog: this._performReblog })); dispatch(initBoostModal({ status, onReblog: this._performReblog }));
} }
}; };

@ -147,6 +147,7 @@ class ActionBar extends React.PureComponent {
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const mutingConversation = status.get('muted'); const mutingConversation = status.get('muted');
const writtenByMe = status.getIn(['account', 'id']) === me;
let menu = []; let menu = [];
@ -156,12 +157,12 @@ class ActionBar extends React.PureComponent {
menu.push(null); menu.push(null);
} }
if (me === status.getIn(['account', 'id'])) { if (writtenByMe) {
if (publicStatus) { if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
} }
menu.push(null);
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });

@ -24,6 +24,7 @@ import {
import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initBlockModal } from 'flavours/glitch/actions/blocks';
import { initReport } from 'flavours/glitch/actions/reports'; import { initReport } from 'flavours/glitch/actions/reports';
import { initBoostModal } from 'flavours/glitch/actions/boosts';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import { boostModal, deleteModal } from 'flavours/glitch/util/initial_state'; import { boostModal, deleteModal } from 'flavours/glitch/util/initial_state';
@ -67,8 +68,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}); });
}, },
onModalReblog (status) { onModalReblog (status, privacy) {
dispatch(reblog(status)); dispatch(reblog(status, privacy));
}, },
onReblog (status, e) { onReblog (status, e) {
@ -78,7 +79,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (e.shiftKey || !boostModal) { if (e.shiftKey || !boostModal) {
this.onModalReblog(status); this.onModalReblog(status);
} else { } else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
} }
} }
}, },

@ -30,6 +30,7 @@ import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/
import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initMuteModal } from 'flavours/glitch/actions/mutes';
import { initBlockModal } from 'flavours/glitch/actions/blocks'; import { initBlockModal } from 'flavours/glitch/actions/blocks';
import { initReport } from 'flavours/glitch/actions/reports'; import { initReport } from 'flavours/glitch/actions/reports';
import { initBoostModal } from 'flavours/glitch/actions/boosts';
import { makeGetStatus } from 'flavours/glitch/selectors'; import { makeGetStatus } from 'flavours/glitch/selectors';
import { ScrollContainer } from 'react-router-scroll-4'; import { ScrollContainer } from 'react-router-scroll-4';
import ColumnBackButton from 'flavours/glitch/components/column_back_button'; import ColumnBackButton from 'flavours/glitch/components/column_back_button';
@ -262,13 +263,13 @@ class Status extends ImmutablePureComponent {
} }
} }
handleModalReblog = (status) => { handleModalReblog = (status, privacy) => {
const { dispatch } = this.props; const { dispatch } = this.props;
if (status.get('reblogged')) { if (status.get('reblogged')) {
dispatch(unreblog(status)); dispatch(unreblog(status));
} else { } else {
dispatch(reblog(status)); dispatch(reblog(status, privacy));
} }
} }
@ -276,11 +277,11 @@ class Status extends ImmutablePureComponent {
const { settings, dispatch } = this.props; const { settings, dispatch } = this.props;
if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) { if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog, missingMediaDescription: true })); dispatch(initBoostModal({ status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
} else if ((e && e.shiftKey) || !boostModal) { } else if ((e && e.shiftKey) || !boostModal) {
this.handleModalReblog(status); this.handleModalReblog(status);
} else { } else {
dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog })); dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
} }
} }

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@ -10,7 +11,9 @@ import DisplayName from 'flavours/glitch/components/display_name';
import AttachmentList from 'flavours/glitch/components/attachment_list'; import AttachmentList from 'flavours/glitch/components/attachment_list';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import PrivacyDropdown from 'flavours/glitch/features/compose/components/privacy_dropdown';
import classNames from 'classnames'; import classNames from 'classnames';
import { changeBoostPrivacy } from 'flavours/glitch/actions/boosts';
const messages = defineMessages({ const messages = defineMessages({
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
@ -21,7 +24,22 @@ const messages = defineMessages({
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
}); });
export default @injectIntl const mapStateToProps = state => {
return {
privacy: state.getIn(['boosts', 'new', 'privacy']),
};
};
const mapDispatchToProps = dispatch => {
return {
onChangeBoostPrivacy(value) {
dispatch(changeBoostPrivacy(value));
},
};
};
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class BoostModal extends ImmutablePureComponent { class BoostModal extends ImmutablePureComponent {
static contextTypes = { static contextTypes = {
@ -41,7 +59,7 @@ class BoostModal extends ImmutablePureComponent {
} }
handleReblog = () => { handleReblog = () => {
this.props.onReblog(this.props.status); this.props.onReblog(this.props.status, this.props.privacy);
this.props.onClose(); this.props.onClose();
} }
@ -55,12 +73,16 @@ class BoostModal extends ImmutablePureComponent {
} }
} }
_findContainer = () => {
return document.getElementsByClassName('modal-root__container')[0];
};
setRef = (c) => { setRef = (c) => {
this.button = c; this.button = c;
} }
render () { render () {
const { status, missingMediaDescription, intl } = this.props; const { status, missingMediaDescription, privacy, intl } = this.props;
const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog; const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
const visibilityIconInfo = { const visibilityIconInfo = {
@ -111,6 +133,15 @@ class BoostModal extends ImmutablePureComponent {
<FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /> <FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} />
} }
</div> </div>
{status.get('visibility') !== 'private' && !status.get('reblogged') && (
<PrivacyDropdown
noDirect
value={privacy}
container={this._findContainer}
onChange={this.props.onChangeBoostPrivacy}
/>
)}
<Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} /> <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} />
</div> </div>
</div> </div>

@ -0,0 +1,25 @@
import Immutable from 'immutable';
import {
BOOSTS_INIT_MODAL,
BOOSTS_CHANGE_PRIVACY,
} from 'flavours/glitch/actions/boosts';
const initialState = Immutable.Map({
new: Immutable.Map({
privacy: 'public',
}),
});
export default function mutes(state = initialState, action) {
switch (action.type) {
case BOOSTS_INIT_MODAL:
return state.withMutations((state) => {
state.setIn(['new', 'privacy'], action.privacy);
});
case BOOSTS_CHANGE_PRIVACY:
return state.setIn(['new', 'privacy'], action.privacy);
default:
return state;
}
}

@ -18,6 +18,7 @@ import status_lists from './status_lists';
import mutes from './mutes'; import mutes from './mutes';
import blocks from './blocks'; import blocks from './blocks';
import reports from './reports'; import reports from './reports';
import boosts from './boosts';
import contexts from './contexts'; import contexts from './contexts';
import compose from './compose'; import compose from './compose';
import search from './search'; import search from './search';
@ -61,6 +62,7 @@ const reducers = {
mutes, mutes,
blocks, blocks,
reports, reports,
boosts,
contexts, contexts,
compose, compose,
search, search,

@ -1,4 +1,5 @@
import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal'; import { MODAL_OPEN, MODAL_CLOSE } from 'flavours/glitch/actions/modal';
import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
const initialState = { const initialState = {
modalType: null, modalType: null,
@ -11,6 +12,8 @@ export default function modal(state = initialState, action) {
return { modalType: action.modalType, modalProps: action.modalProps }; return { modalType: action.modalType, modalProps: action.modalProps };
case MODAL_CLOSE: case MODAL_CLOSE:
return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state; return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
case TIMELINE_DELETE:
return (state.modalProps.statusId === action.id) ? initialState : state;
default: default:
return state; return state;
} }

@ -1031,3 +1031,12 @@
} }
} }
} }
.modal-root__container .composer--options--dropdown {
flex-grow: 0;
}
.modal-root__container .composer--options--dropdown--content {
pointer-events: auto;
z-index: 9999;
}

@ -12,10 +12,19 @@
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
> * { > div {
flex: 1; flex: 1;
max-height: 235px; max-height: 235px;
background: url('~images/elephant_ui_plane.svg') no-repeat left bottom / contain; position: relative;
img {
max-height: 100%;
max-width: 100%;
height: 100%;
position: absolute;
bottom: 0;
left: 0;
}
} }
} }

@ -1,6 +1,6 @@
import EXIF from 'exif-js'; import EXIF from 'exif-js';
const MAX_IMAGE_PIXELS = 1638400; // 1280x1280px const MAX_IMAGE_PIXELS = 2073600; // 1920x1080px
const _browser_quirks = {}; const _browser_quirks = {};

@ -0,0 +1,29 @@
import { openModal } from './modal';
export const BOOSTS_INIT_MODAL = 'BOOSTS_INIT_MODAL';
export const BOOSTS_CHANGE_PRIVACY = 'BOOSTS_CHANGE_PRIVACY';
export function initBoostModal(props) {
return (dispatch, getState) => {
const default_privacy = getState().getIn(['compose', 'default_privacy']);
const privacy = props.status.get('visibility') === 'private' ? 'private' : default_privacy;
dispatch({
type: BOOSTS_INIT_MODAL,
privacy
});
dispatch(openModal('BOOST', props));
};
}
export function changeBoostPrivacy(privacy) {
return dispatch => {
dispatch({
type: BOOSTS_CHANGE_PRIVACY,
privacy,
});
};
}

@ -41,11 +41,11 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST';
export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS';
export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL';
export function reblog(status) { export function reblog(status, visibility) {
return function (dispatch, getState) { return function (dispatch, getState) {
dispatch(reblogRequest(status)); dispatch(reblogRequest(status));
api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`).then(function (response) { api(getState).post(`/api/v1/statuses/${status.get('id')}/reblog`, { visibility }).then(function (response) {
// The reblog API method returns a new status wrapped around the original. In this case we are only // The reblog API method returns a new status wrapped around the original. In this case we are only
// interested in how the original is modified, hence passing it skipping the wrapper // interested in how the original is modified, hence passing it skipping the wrapper
dispatch(importFetchedStatus(response.data.reblog)); dispatch(importFetchedStatus(response.data.reblog));

@ -177,7 +177,6 @@ export default class Dropdown extends React.PureComponent {
disabled: PropTypes.bool, disabled: PropTypes.bool,
status: ImmutablePropTypes.map, status: ImmutablePropTypes.map,
isUserTouching: PropTypes.func, isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onOpen: PropTypes.func.isRequired, onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
dropdownPlacement: PropTypes.string, dropdownPlacement: PropTypes.string,

@ -223,10 +223,11 @@ class StatusActionBar extends ImmutablePureComponent {
render () { render () {
const { status, relationship, intl, withDismiss, scrollKey } = this.props; const { status, relationship, intl, withDismiss, scrollKey } = this.props;
const mutingConversation = status.get('muted');
const anonymousAccess = !me; const anonymousAccess = !me;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const mutingConversation = status.get('muted');
const account = status.get('account'); const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
let menu = []; let menu = [];
@ -237,19 +238,22 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed }); menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
} }
menu.push(null);
menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick }); menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
if (writtenByMe && publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
}
menu.push(null); menu.push(null);
if (status.getIn(['account', 'id']) === me || withDismiss) { if (writtenByMe || withDismiss) {
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null); menu.push(null);
} }
if (status.getIn(['account', 'id']) === me) { if (writtenByMe) {
if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
}
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick }); menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
} else { } else {

@ -6,7 +6,6 @@ import DropdownMenu from '../components/dropdown_menu';
import { isUserTouching } from '../is_mobile'; import { isUserTouching } from '../is_mobile';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
isModalOpen: state.get('modal').modalType === 'ACTIONS',
dropdownPlacement: state.getIn(['dropdown_menu', 'placement']), dropdownPlacement: state.getIn(['dropdown_menu', 'placement']),
openDropdownId: state.getIn(['dropdown_menu', 'openId']), openDropdownId: state.getIn(['dropdown_menu', 'openId']),
openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']), openedViaKeyboard: state.getIn(['dropdown_menu', 'keyboard']),

@ -35,6 +35,7 @@ import {
} from '../actions/domain_blocks'; } from '../actions/domain_blocks';
import { initMuteModal } from '../actions/mutes'; import { initMuteModal } from '../actions/mutes';
import { initBlockModal } from '../actions/blocks'; import { initBlockModal } from '../actions/blocks';
import { initBoostModal } from '../actions/boosts';
import { initReport } from '../actions/reports'; import { initReport } from '../actions/reports';
import { openModal } from '../actions/modal'; import { openModal } from '../actions/modal';
import { deployPictureInPicture } from '../actions/picture_in_picture'; import { deployPictureInPicture } from '../actions/picture_in_picture';
@ -82,11 +83,11 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}); });
}, },
onModalReblog (status) { onModalReblog (status, privacy) {
if (status.get('reblogged')) { if (status.get('reblogged')) {
dispatch(unreblog(status)); dispatch(unreblog(status));
} else { } else {
dispatch(reblog(status)); dispatch(reblog(status, privacy));
} }
}, },
@ -94,7 +95,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if ((e && e.shiftKey) || !boostModal) { if ((e && e.shiftKey) || !boostModal) {
this.onModalReblog(status); this.onModalReblog(status);
} else { } else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
} }
}, },

@ -127,7 +127,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
// It should not be transformed when mounting because the resulting // It should not be transformed when mounting because the resulting
// size will be used to determine the coordinate of the menu by // size will be used to determine the coordinate of the menu by
// react-overlays // react-overlays
<div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null, zIndex: 2 }} role='listbox' ref={this.setRef}> <div className={`privacy-dropdown__dropdown ${placement}`} style={{ ...style, opacity: opacity, transform: mounted ? `scale(${scaleX}, ${scaleY})` : null }} role='listbox' ref={this.setRef}>
{items.map(item => ( {items.map(item => (
<div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}> <div role='option' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleKeyDown} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })} aria-selected={item.value === value} ref={item.value === value ? this.setFocusRef : null}>
<div className='privacy-dropdown__option__icon'> <div className='privacy-dropdown__option__icon'>
@ -153,11 +153,12 @@ class PrivacyDropdown extends React.PureComponent {
static propTypes = { static propTypes = {
isUserTouching: PropTypes.func, isUserTouching: PropTypes.func,
isModalOpen: PropTypes.bool.isRequired,
onModalOpen: PropTypes.func, onModalOpen: PropTypes.func,
onModalClose: PropTypes.func, onModalClose: PropTypes.func,
value: PropTypes.string.isRequired, value: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
noDirect: PropTypes.bool,
container: PropTypes.func,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -167,7 +168,7 @@ class PrivacyDropdown extends React.PureComponent {
}; };
handleToggle = ({ target }) => { handleToggle = ({ target }) => {
if (this.props.isUserTouching()) { if (this.props.isUserTouching && this.props.isUserTouching()) {
if (this.state.open) { if (this.state.open) {
this.props.onModalClose(); this.props.onModalClose();
} else { } else {
@ -236,12 +237,17 @@ class PrivacyDropdown extends React.PureComponent {
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) }, { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
{ icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) }, { icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
{ icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
]; ];
if (!this.props.noDirect) {
this.options.push(
{ icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
);
}
} }
render () { render () {
const { value, intl } = this.props; const { value, container, intl } = this.props;
const { open, placement } = this.state; const { open, placement } = this.state;
const valueOption = this.options.find(item => item.value === value); const valueOption = this.options.find(item => item.value === value);
@ -264,7 +270,7 @@ class PrivacyDropdown extends React.PureComponent {
/> />
</div> </div>
<Overlay show={open} placement={placement} target={this}> <Overlay show={open} placement={placement} target={this} container={container}>
<PrivacyDropdownMenu <PrivacyDropdownMenu
items={this.options} items={this.options}
value={value} value={value}

@ -5,7 +5,6 @@ import { openModal, closeModal } from '../../../actions/modal';
import { isUserTouching } from '../../../is_mobile'; import { isUserTouching } from '../../../is_mobile';
const mapStateToProps = state => ({ const mapStateToProps = state => ({
isModalOpen: state.get('modal').modalType === 'ACTIONS',
value: state.getIn(['compose', 'privacy']), value: state.getIn(['compose', 'privacy']),
}); });

@ -1,6 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { makeGetNotification, makeGetStatus } from '../../../selectors'; import { makeGetNotification, makeGetStatus } from '../../../selectors';
import Notification from '../components/notification'; import Notification from '../components/notification';
import { initBoostModal } from '../../../actions/boosts';
import { openModal } from '../../../actions/modal'; import { openModal } from '../../../actions/modal';
import { mentionCompose } from '../../../actions/compose'; import { mentionCompose } from '../../../actions/compose';
import { import {
@ -35,8 +36,8 @@ const mapDispatchToProps = dispatch => ({
dispatch(mentionCompose(account, router)); dispatch(mentionCompose(account, router));
}, },
onModalReblog (status) { onModalReblog (status, privacy) {
dispatch(reblog(status)); dispatch(reblog(status, privacy));
}, },
onReblog (status, e) { onReblog (status, e) {
@ -46,7 +47,7 @@ const mapDispatchToProps = dispatch => ({
if (e.shiftKey || !boostModal) { if (e.shiftKey || !boostModal) {
this.onModalReblog(status); this.onModalReblog(status);
} else { } else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
} }
} }
}, },

@ -10,6 +10,7 @@ import { defineMessages, injectIntl } from 'react-intl';
import { replyCompose } from 'mastodon/actions/compose'; import { replyCompose } from 'mastodon/actions/compose';
import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions'; import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
import { makeGetStatus } from 'mastodon/selectors'; import { makeGetStatus } from 'mastodon/selectors';
import { initBoostModal } from 'mastodon/actions/boosts';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
const messages = defineMessages({ const messages = defineMessages({
@ -89,9 +90,9 @@ class Footer extends ImmutablePureComponent {
} }
}; };
_performReblog = () => { _performReblog = (status, privacy) => {
const { dispatch, status } = this.props; const { dispatch } = this.props;
dispatch(reblog(status)); dispatch(reblog(status, privacy));
} }
handleReblogClick = e => { handleReblogClick = e => {
@ -100,9 +101,9 @@ class Footer extends ImmutablePureComponent {
if (status.get('reblogged')) { if (status.get('reblogged')) {
dispatch(unreblog(status)); dispatch(unreblog(status));
} else if ((e && e.shiftKey) || !boostModal) { } else if ((e && e.shiftKey) || !boostModal) {
this._performReblog(); this._performReblog(status);
} else { } else {
dispatch(openModal('BOOST', { status, onReblog: this._performReblog })); dispatch(initBoostModal({ status, onReblog: this._performReblog }));
} }
}; };

@ -190,6 +190,7 @@ class ActionBar extends React.PureComponent {
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const mutingConversation = status.get('muted'); const mutingConversation = status.get('muted');
const account = status.get('account'); const account = status.get('account');
const writtenByMe = status.getIn(['account', 'id']) === me;
let menu = []; let menu = [];
@ -199,12 +200,12 @@ class ActionBar extends React.PureComponent {
menu.push(null); menu.push(null);
} }
if (me === status.getIn(['account', 'id'])) { if (writtenByMe) {
if (publicStatus) { if (publicStatus) {
menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick }); menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
menu.push(null);
} }
menu.push(null);
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
menu.push(null); menu.push(null);
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });

@ -23,6 +23,7 @@ import {
} from '../../../actions/statuses'; } from '../../../actions/statuses';
import { initMuteModal } from '../../../actions/mutes'; import { initMuteModal } from '../../../actions/mutes';
import { initBlockModal } from '../../../actions/blocks'; import { initBlockModal } from '../../../actions/blocks';
import { initBoostModal } from '../../../actions/boosts';
import { initReport } from '../../../actions/reports'; import { initReport } from '../../../actions/reports';
import { openModal } from '../../../actions/modal'; import { openModal } from '../../../actions/modal';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
@ -68,8 +69,8 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}); });
}, },
onModalReblog (status) { onModalReblog (status, privacy) {
dispatch(reblog(status)); dispatch(reblog(status, privacy));
}, },
onReblog (status, e) { onReblog (status, e) {
@ -79,7 +80,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
if (e.shiftKey || !boostModal) { if (e.shiftKey || !boostModal) {
this.onModalReblog(status); this.onModalReblog(status);
} else { } else {
dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog })); dispatch(initBoostModal({ status, onReblog: this.onModalReblog }));
} }
} }
}, },

@ -42,6 +42,7 @@ import {
} from '../../actions/domain_blocks'; } from '../../actions/domain_blocks';
import { initMuteModal } from '../../actions/mutes'; import { initMuteModal } from '../../actions/mutes';
import { initBlockModal } from '../../actions/blocks'; import { initBlockModal } from '../../actions/blocks';
import { initBoostModal } from '../../actions/boosts';
import { initReport } from '../../actions/reports'; import { initReport } from '../../actions/reports';
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors'; import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
import { ScrollContainer } from 'react-router-scroll-4'; import { ScrollContainer } from 'react-router-scroll-4';
@ -234,8 +235,8 @@ class Status extends ImmutablePureComponent {
} }
} }
handleModalReblog = (status) => { handleModalReblog = (status, privacy) => {
this.props.dispatch(reblog(status)); this.props.dispatch(reblog(status, privacy));
} }
handleReblogClick = (status, e) => { handleReblogClick = (status, e) => {
@ -245,7 +246,7 @@ class Status extends ImmutablePureComponent {
if ((e && e.shiftKey) || !boostModal) { if ((e && e.shiftKey) || !boostModal) {
this.handleModalReblog(status); this.handleModalReblog(status);
} else { } else {
this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog })); this.props.dispatch(initBoostModal({ status, onReblog: this.handleModalReblog }));
} }
} }
} }

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@ -10,7 +11,9 @@ import DisplayName from '../../../components/display_name';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import AttachmentList from 'mastodon/components/attachment_list'; import AttachmentList from 'mastodon/components/attachment_list';
import PrivacyDropdown from 'mastodon/features/compose/components/privacy_dropdown';
import classNames from 'classnames'; import classNames from 'classnames';
import { changeBoostPrivacy } from 'mastodon/actions/boosts';
const messages = defineMessages({ const messages = defineMessages({
cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
@ -21,7 +24,22 @@ const messages = defineMessages({
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
}); });
export default @injectIntl const mapStateToProps = state => {
return {
privacy: state.getIn(['boosts', 'new', 'privacy']),
};
};
const mapDispatchToProps = dispatch => {
return {
onChangeBoostPrivacy(value) {
dispatch(changeBoostPrivacy(value));
},
};
};
export default @connect(mapStateToProps, mapDispatchToProps)
@injectIntl
class BoostModal extends ImmutablePureComponent { class BoostModal extends ImmutablePureComponent {
static contextTypes = { static contextTypes = {
@ -32,6 +50,8 @@ class BoostModal extends ImmutablePureComponent {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
onReblog: PropTypes.func.isRequired, onReblog: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onChangeBoostPrivacy: PropTypes.func.isRequired,
privacy: PropTypes.string.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -40,7 +60,7 @@ class BoostModal extends ImmutablePureComponent {
} }
handleReblog = () => { handleReblog = () => {
this.props.onReblog(this.props.status); this.props.onReblog(this.props.status, this.props.privacy);
this.props.onClose(); this.props.onClose();
} }
@ -52,12 +72,16 @@ class BoostModal extends ImmutablePureComponent {
} }
} }
_findContainer = () => {
return document.getElementsByClassName('modal-root__container')[0];
};
setRef = (c) => { setRef = (c) => {
this.button = c; this.button = c;
} }
render () { render () {
const { status, intl } = this.props; const { status, privacy, intl } = this.props;
const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog; const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
const visibilityIconInfo = { const visibilityIconInfo = {
@ -102,6 +126,14 @@ class BoostModal extends ImmutablePureComponent {
<div className='boost-modal__action-bar'> <div className='boost-modal__action-bar'>
<div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /></div> <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /></div>
{status.get('visibility') !== 'private' && !status.get('reblogged') && (
<PrivacyDropdown
noDirect
value={privacy}
container={this._findContainer}
onChange={this.props.onChangeBoostPrivacy}
/>
)}
<Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} /> <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} />
</div> </div>
</div> </div>

@ -0,0 +1,25 @@
import Immutable from 'immutable';
import {
BOOSTS_INIT_MODAL,
BOOSTS_CHANGE_PRIVACY,
} from 'mastodon/actions/boosts';
const initialState = Immutable.Map({
new: Immutable.Map({
privacy: 'public',
}),
});
export default function mutes(state = initialState, action) {
switch (action.type) {
case BOOSTS_INIT_MODAL:
return state.withMutations((state) => {
state.setIn(['new', 'privacy'], action.privacy);
});
case BOOSTS_CHANGE_PRIVACY:
return state.setIn(['new', 'privacy'], action.privacy);
default:
return state;
}
}

@ -16,6 +16,7 @@ import push_notifications from './push_notifications';
import status_lists from './status_lists'; import status_lists from './status_lists';
import mutes from './mutes'; import mutes from './mutes';
import blocks from './blocks'; import blocks from './blocks';
import boosts from './boosts';
import reports from './reports'; import reports from './reports';
import contexts from './contexts'; import contexts from './contexts';
import compose from './compose'; import compose from './compose';
@ -57,6 +58,7 @@ const reducers = {
push_notifications, push_notifications,
mutes, mutes,
blocks, blocks,
boosts,
reports, reports,
contexts, contexts,
compose, compose,

@ -1,4 +1,5 @@
import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal'; import { MODAL_OPEN, MODAL_CLOSE } from '../actions/modal';
import { TIMELINE_DELETE } from '../actions/timelines';
const initialState = { const initialState = {
modalType: null, modalType: null,
@ -11,6 +12,8 @@ export default function modal(state = initialState, action) {
return { modalType: action.modalType, modalProps: action.modalProps }; return { modalType: action.modalType, modalProps: action.modalProps };
case MODAL_CLOSE: case MODAL_CLOSE:
return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state; return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
case TIMELINE_DELETE:
return (state.modalProps.statusId === action.id) ? initialState : state;
default: default:
return state; return state;
} }

@ -1,6 +1,6 @@
import EXIF from 'exif-js'; import EXIF from 'exif-js';
const MAX_IMAGE_PIXELS = 1638400; // 1280x1280px const MAX_IMAGE_PIXELS = 2073600; // 1920x1080px
const _browser_quirks = {}; const _browser_quirks = {};

@ -4209,6 +4209,7 @@ a.status-card.compact:hover {
border-radius: 4px; border-radius: 4px;
margin-left: 40px; margin-left: 40px;
overflow: hidden; overflow: hidden;
z-index: 2;
&.top { &.top {
transform-origin: 50% 100%; transform-origin: 50% 100%;
@ -4219,6 +4220,15 @@ a.status-card.compact:hover {
} }
} }
.modal-root__container .privacy-dropdown {
flex-grow: 0;
}
.modal-root__container .privacy-dropdown__dropdown {
pointer-events: auto;
z-index: 9999;
}
.privacy-dropdown__option { .privacy-dropdown__option {
color: $inverted-text-color; color: $inverted-text-color;
padding: 10px; padding: 10px;

@ -12,10 +12,19 @@
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
> * { > div {
flex: 1; flex: 1;
max-height: 235px; max-height: 235px;
background: url('~images/elephant_ui_plane.svg') no-repeat left bottom / contain; position: relative;
img {
max-height: 100%;
max-width: 100%;
height: 100%;
position: absolute;
bottom: 0;
left: 0;
}
} }
} }

@ -6,7 +6,14 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity
def perform def perform
target_account = account_from_uri(object_uri) target_account = account_from_uri(object_uri)
return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account) return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id'])
# Update id of already-existing follow requests
existing_follow_request = ::FollowRequest.find_by(account: @account, target_account: target_account)
unless existing_follow_request.nil?
existing_follow_request.update!(uri: @json['id'])
return
end
if target_account.blocking?(@account) || target_account.domain_blocking?(@account.domain) || target_account.moved? || target_account.instance_actor? if target_account.blocking?(@account) || target_account.domain_blocking?(@account.domain) || target_account.moved? || target_account.instance_actor?
reject_follow_request!(target_account) reject_follow_request!(target_account)
@ -14,7 +21,9 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity
end end
# Fast-forward repeat follow requests # Fast-forward repeat follow requests
if @account.following?(target_account) existing_follow = ::Follow.find_by(account: @account, target_account: target_account)
unless existing_follow.nil?
existing_follow.update!(uri: @json['id'])
AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true, follow_request_uri: @json['id']) AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true, follow_request_uri: @json['id'])
return return
end end

@ -88,10 +88,18 @@ class Webfinger
end end
def standard_url def standard_url
if @domain.ends_with? ".onion"
"http://#{@domain}/.well-known/webfinger?resource=#{@uri}"
else
"https://#{@domain}/.well-known/webfinger?resource=#{@uri}" "https://#{@domain}/.well-known/webfinger?resource=#{@uri}"
end end
end
def host_meta_url def host_meta_url
if @domain.ends_with? ".onion"
"http://#{@domain}/.well-known/host-meta"
else
"https://#{@domain}/.well-known/host-meta" "https://#{@domain}/.well-known/host-meta"
end end
end
end end

@ -14,6 +14,8 @@ module AccountFinderConcern
def representative def representative
Account.find(-99) Account.find(-99)
rescue ActiveRecord::RecordNotFound
Account.create!(id: -99, actor_type: 'Application', locked: true, username: Rails.configuration.x.local_domain)
end end
def find_local(username) def find_local(username)

@ -59,7 +59,7 @@ class MediaAttachment < ApplicationRecord
IMAGE_STYLES = { IMAGE_STYLES = {
original: { original: {
pixels: 1_638_400, # 1280x1280px pixels: 2_073_600, # 1920x1080px
file_geometry_parser: FastGeometryParser, file_geometry_parser: FastGeometryParser,
}.freeze, }.freeze,

@ -38,7 +38,17 @@ class FetchOEmbedService
return if @endpoint_url.blank? return if @endpoint_url.blank?
@endpoint_url = (Addressable::URI.parse(@url) + @endpoint_url).to_s @endpoint_url = begin
base_url = Addressable::URI.parse(@url)
# If the OEmbed endpoint is given as http but the URL we opened
# was served over https, we can assume OEmbed will be available
# through https as well
(base_url + @endpoint_url).tap do |absolute_url|
absolute_url.scheme = base_url.scheme if base_url.scheme == 'https'
end.to_s
end
cache_endpoint! cache_endpoint!
rescue Addressable::URI::InvalidURIError rescue Addressable::URI::InvalidURIError

@ -11,5 +11,6 @@
.container-alt= yield .container-alt= yield
.modal-layout__mastodon .modal-layout__mastodon
%div %div
%img{alt:'', draggable:'false', src:"#{mascot_url}"}
= render template: 'layouts/application' = render template: 'layouts/application'

@ -25,6 +25,8 @@ require_relative '../lib/devise/two_factor_pam_authenticatable'
require_relative '../lib/chewy/strategy/custom_sidekiq' require_relative '../lib/chewy/strategy/custom_sidekiq'
require_relative '../lib/webpacker/manifest_extensions' require_relative '../lib/webpacker/manifest_extensions'
require_relative '../lib/webpacker/helper_extensions' require_relative '../lib/webpacker/helper_extensions'
require_relative '../lib/action_dispatch/cookie_jar_extensions'
require_relative '../lib/rails/engine_extensions'
Dotenv::Railtie.load Dotenv::Railtie.load

@ -0,0 +1,15 @@
# frozen_string_literal: true
module ActionDispatch
module CookieJarExtensions
private
# Monkey-patch ActionDispatch to serve secure cookies to Tor Hidden Service
# users. Otherwise, ActionDispatch would drop the cookie over HTTP.
def write_cookie?(*)
request.headers['Host'].ends_with?('.onion') || super
end
end
end
ActionDispatch::Cookies::CookieJar.prepend(ActionDispatch::CookieJarExtensions)

@ -0,0 +1,11 @@
module Rails
module EngineExtensions
# Rewrite task loading code to filter digitalocean.rake task
def run_tasks_blocks(app)
Railtie.instance_method(:run_tasks_blocks).bind(self).call(app)
paths["lib/tasks"].existent.reject { |ext| ext.end_with?('digitalocean.rake') }.sort.each { |ext| load(ext) }
end
end
end
Rails::Engine.prepend(Rails::EngineExtensions)

@ -60,18 +60,18 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@babel/core": "^7.12.10", "@babel/core": "^7.12.16",
"@babel/plugin-proposal-class-properties": "^7.8.3", "@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-decorators": "^7.12.12", "@babel/plugin-proposal-decorators": "^7.12.13",
"@babel/plugin-transform-react-inline-elements": "^7.12.1", "@babel/plugin-transform-react-inline-elements": "^7.12.13",
"@babel/plugin-transform-runtime": "^7.12.10", "@babel/plugin-transform-runtime": "^7.12.15",
"@babel/preset-env": "^7.12.11", "@babel/preset-env": "^7.12.16",
"@babel/preset-react": "^7.12.10", "@babel/preset-react": "^7.12.13",
"@babel/runtime": "^7.12.5", "@babel/runtime": "^7.12.13",
"@clusterws/cws": "^3.0.0", "@clusterws/cws": "^3.0.0",
"@gamestdio/websocket": "^0.3.2", "@gamestdio/websocket": "^0.3.2",
"@github/webauthn-json": "^0.5.7", "@github/webauthn-json": "^0.5.7",
"@rails/ujs": "^6.1.1", "@rails/ujs": "^6.1.2",
"array-includes": "^3.1.2", "array-includes": "^3.1.2",
"atrament": "0.2.4", "atrament": "0.2.4",
"arrow-key-navigation": "^1.2.0", "arrow-key-navigation": "^1.2.0",
@ -88,7 +88,7 @@
"color-blend": "^3.0.1", "color-blend": "^3.0.1",
"compression-webpack-plugin": "^6.1.1", "compression-webpack-plugin": "^6.1.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"css-loader": "^5.0.1", "css-loader": "^5.0.2",
"cssnano": "^4.1.10", "cssnano": "^4.1.10",
"detect-passive-events": "^2.0.2", "detect-passive-events": "^2.0.2",
"dotenv": "^8.2.0", "dotenv": "^8.2.0",
@ -114,7 +114,7 @@
"lodash": "^4.17.19", "lodash": "^4.17.19",
"mark-loader": "^0.1.6", "mark-loader": "^0.1.6",
"marky": "^1.2.1", "marky": "^1.2.1",
"mini-css-extract-plugin": "^1.3.5", "mini-css-extract-plugin": "^1.3.6",
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
"npmlog": "^4.1.2", "npmlog": "^4.1.2",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
@ -145,7 +145,7 @@
"react-select": "^3.2.0", "react-select": "^3.2.0",
"react-sparklines": "^1.7.0", "react-sparklines": "^1.7.0",
"react-swipeable-views": "^0.13.9", "react-swipeable-views": "^0.13.9",
"react-textarea-autosize": "^8.3.0", "react-textarea-autosize": "^8.3.1",
"react-toggle": "^4.1.1", "react-toggle": "^4.1.1",
"redis": "^3.0.2", "redis": "^3.0.2",
"redux": "^4.0.5", "redux": "^4.0.5",
@ -156,7 +156,7 @@
"requestidlecallback": "^0.3.0", "requestidlecallback": "^0.3.0",
"reselect": "^4.0.0", "reselect": "^4.0.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"sass": "^1.32.5", "sass": "^1.32.7",
"sass-loader": "^10.1.1", "sass-loader": "^10.1.1",
"stacktrace-js": "^2.0.2", "stacktrace-js": "^2.0.2",
"stringz": "^2.1.0", "stringz": "^2.1.0",
@ -175,13 +175,13 @@
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^5.11.9", "@testing-library/jest-dom": "^5.11.9",
"@testing-library/react": "^11.2.3", "@testing-library/react": "^11.2.5",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-jest": "^26.6.3", "babel-jest": "^26.6.3",
"eslint": "^7.18.0", "eslint": "^7.19.0",
"eslint-plugin-import": "~2.22.1", "eslint-plugin-import": "~2.22.1",
"eslint-plugin-jsx-a11y": "~6.4.1", "eslint-plugin-jsx-a11y": "~6.4.1",
"eslint-plugin-promise": "~4.2.1", "eslint-plugin-promise": "~4.3.1",
"eslint-plugin-react": "~7.22.0", "eslint-plugin-react": "~7.22.0",
"jest": "^26.6.3", "jest": "^26.6.3",
"raf": "^3.4.1", "raf": "^3.4.1",

@ -17,6 +17,7 @@ RSpec.describe ActivityPub::Activity::Follow do
describe '#perform' do describe '#perform' do
subject { described_class.new(json, sender) } subject { described_class.new(json, sender) }
context 'with no prior follow' do
context 'unlocked account' do context 'unlocked account' do
before do before do
subject.perform subject.perform
@ -24,6 +25,7 @@ RSpec.describe ActivityPub::Activity::Follow do
it 'creates a follow from sender to recipient' do it 'creates a follow from sender to recipient' do
expect(sender.following?(recipient)).to be true expect(sender.following?(recipient)).to be true
expect(sender.active_relationships.find_by(target_account: recipient).uri).to eq 'foo'
end end
it 'does not create a follow request' do it 'does not create a follow request' do
@ -43,6 +45,7 @@ RSpec.describe ActivityPub::Activity::Follow do
it 'creates a follow request' do it 'creates a follow request' do
expect(sender.requested?(recipient)).to be true expect(sender.requested?(recipient)).to be true
expect(sender.follow_requests.find_by(target_account: recipient).uri).to eq 'foo'
end end
end end
@ -54,6 +57,7 @@ RSpec.describe ActivityPub::Activity::Follow do
it 'creates a follow from sender to recipient' do it 'creates a follow from sender to recipient' do
expect(sender.following?(recipient)).to be true expect(sender.following?(recipient)).to be true
expect(sender.active_relationships.find_by(target_account: recipient).uri).to eq 'foo'
end end
it 'does not create a follow request' do it 'does not create a follow request' do
@ -73,6 +77,111 @@ RSpec.describe ActivityPub::Activity::Follow do
it 'creates a follow request' do it 'creates a follow request' do
expect(sender.requested?(recipient)).to be true expect(sender.requested?(recipient)).to be true
expect(sender.follow_requests.find_by(target_account: recipient).uri).to eq 'foo'
end
end
end
context 'when a follow relationship already exists' do
before do
sender.active_relationships.create!(target_account: recipient, uri: 'bar')
end
context 'unlocked account' do
before do
subject.perform
end
it 'correctly sets the new URI' do
expect(sender.active_relationships.find_by(target_account: recipient).uri).to eq 'foo'
end
it 'does not create a follow request' do
expect(sender.requested?(recipient)).to be false
end
end
context 'silenced account following an unlocked account' do
before do
sender.touch(:silenced_at)
subject.perform
end
it 'correctly sets the new URI' do
expect(sender.active_relationships.find_by(target_account: recipient).uri).to eq 'foo'
end
it 'does not create a follow request' do
expect(sender.requested?(recipient)).to be false
end
end
context 'unlocked account muting the sender' do
before do
recipient.mute!(sender)
subject.perform
end
it 'correctly sets the new URI' do
expect(sender.active_relationships.find_by(target_account: recipient).uri).to eq 'foo'
end
it 'does not create a follow request' do
expect(sender.requested?(recipient)).to be false
end
end
context 'locked account' do
before do
recipient.update(locked: true)
subject.perform
end
it 'correctly sets the new URI' do
expect(sender.active_relationships.find_by(target_account: recipient).uri).to eq 'foo'
end
it 'does not create a follow request' do
expect(sender.requested?(recipient)).to be false
end
end
end
context 'when a follow request already exists' do
before do
sender.follow_requests.create!(target_account: recipient, uri: 'bar')
end
context 'silenced account following an unlocked account' do
before do
sender.touch(:silenced_at)
subject.perform
end
it 'does not create a follow from sender to recipient' do
expect(sender.following?(recipient)).to be false
end
it 'correctly sets the new URI' do
expect(sender.requested?(recipient)).to be true
expect(sender.follow_requests.find_by(target_account: recipient).uri).to eq 'foo'
end
end
context 'locked account' do
before do
recipient.update(locked: true)
subject.perform
end
it 'does not create a follow from sender to recipient' do
expect(sender.following?(recipient)).to be false
end
it 'correctly sets the new URI' do
expect(sender.requested?(recipient)).to be true
expect(sender.follow_requests.find_by(target_account: recipient).uri).to eq 'foo'
end
end end
end end
end end

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save