diff options
author | David Yip <yipdw@member.fsf.org> | 2017-12-06 15:40:59 -0600 |
---|---|---|
committer | David Yip <yipdw@member.fsf.org> | 2017-12-06 15:40:59 -0600 |
commit | f1cbea77a4a52929244198dcbde26d63d837489a (patch) | |
tree | 82645dbc9eec65f870ce7a211355deb6bdc29f29 | |
parent | 21e28a5caa5e92165322c4127c89a6f88e68198a (diff) | |
parent | 8ca91cef45417947607079118b1af07c9774ae58 (diff) |
Merge remote-tracking branch 'personal/merge/tootsuite/master' into gs-master
78 files changed, 1383 insertions, 200 deletions
diff --git a/.travis.yml b/.travis.yml index 5c2c2c889..777ca581c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,11 +27,14 @@ addons: apt: sources: - trusty-media + - sourceline: deb https://dl.yarnpkg.com/debian/ stable main + key_url: https://dl.yarnpkg.com/debian/pubkey.gpg packages: - ffmpeg + - libicu-dev - libprotobuf-dev - protobuf-compiler - - libicu-dev + - yarn rvm: - 2.3.4 @@ -42,7 +45,6 @@ services: install: - nvm install - - npm install -g yarn - bundle install --path=vendor/bundle --without development production --retry=3 --jobs=16 - yarn install diff --git a/Dockerfile b/Dockerfile index f9354ac46..7cca02ecf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,8 @@ ENV UID=991 GID=991 \ RAILS_SERVE_STATIC_FILES=true \ RAILS_ENV=production NODE_ENV=production -ARG YARN_VERSION=1.1.0 -ARG YARN_DOWNLOAD_SHA256=171c1f9ee93c488c0d774ac6e9c72649047c3f896277d88d0f805266519430f3 +ARG YARN_VERSION=1.3.2 +ARG YARN_DOWNLOAD_SHA256=6cfe82e530ef0837212f13e45c1565ba53f5199eec2527b85ecbcd88bf26821d ARG LIBICONV_VERSION=1.15 ARG LIBICONV_DOWNLOAD_SHA256=ccf536620a45458d26ba83887a983b96827001e92a13847b45e4925cc8913178 diff --git a/Gemfile b/Gemfile index d0b7aaef1..e43b1a256 100644 --- a/Gemfile +++ b/Gemfile @@ -49,7 +49,6 @@ gem 'oj', '~> 3.3' gem 'ostatus2', '~> 2.0' gem 'ox', '~> 2.8' gem 'pundit', '~> 1.1' -gem 'rabl', '~> 0.13' gem 'rack-attack', '~> 5.0' gem 'rack-cors', '~> 0.4', require: 'rack/cors' gem 'rack-timeout', '~> 0.4' diff --git a/Gemfile.lock b/Gemfile.lock index 37fc77fdf..5f050d031 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -334,8 +334,6 @@ GEM puma (3.11.0) pundit (1.1.0) activesupport (>= 3.0.0) - rabl (0.13.1) - activesupport (>= 2.3.14) rack (2.0.3) rack-attack (5.0.1) rack @@ -606,7 +604,6 @@ DEPENDENCIES pry-rails (~> 0.3) puma (~> 3.10) pundit (~> 1.1) - rabl (~> 0.13) rack-attack (~> 5.0) rack-cors (~> 0.4) rack-timeout (~> 0.4) diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 2a5cac547..11e647c3c 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -17,12 +17,13 @@ class Api::V1::Accounts::SearchController < Api::BaseController AccountSearchService.new.call( params[:q], limit_param(DEFAULT_ACCOUNTS_LIMIT), - resolving_search?, - current_account + current_account, + resolve: truthy_param?(:resolve), + following: truthy_param?(:following) ) end - def resolving_search? - params[:resolve] == 'true' + def truthy_param?(key) + params[key] == 'true' end end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index b1a2ed573..4e73e9e8b 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -51,7 +51,7 @@ class Api::V1::AccountsController < Api::BaseController @account = Account.find(params[:id]) end - def relationships(options = {}) + def relationships(**options) AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options) end end diff --git a/app/controllers/api/v1/lists/accounts_controller.rb b/app/controllers/api/v1/lists/accounts_controller.rb index 40c485e8d..c29c73b3e 100644 --- a/app/controllers/api/v1/lists/accounts_controller.rb +++ b/app/controllers/api/v1/lists/accounts_controller.rb @@ -10,7 +10,7 @@ class Api::V1::Lists::AccountsController < Api::BaseController after_action :insert_pagination_headers, only: :show def show - @accounts = @list.accounts.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + @accounts = load_accounts render json: @accounts, each_serializer: REST::AccountSerializer end @@ -35,6 +35,14 @@ class Api::V1::Lists::AccountsController < Api::BaseController @list = List.where(account: current_account).find(params[:list_id]) end + def load_accounts + if unlimited? + @list.accounts.all + else + @list.accounts.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + end + end + def list_accounts Account.find(account_ids) end @@ -52,12 +60,16 @@ class Api::V1::Lists::AccountsController < Api::BaseController end def next_path + return if unlimited? + if records_continue? api_v1_list_accounts_url pagination_params(max_id: pagination_max_id) end end def prev_path + return if unlimited? + unless @accounts.empty? api_v1_list_accounts_url pagination_params(since_id: pagination_since_id) end @@ -78,4 +90,8 @@ class Api::V1::Lists::AccountsController < Api::BaseController def pagination_params(core_params) params.permit(:limit).merge(core_params) end + + def unlimited? + params[:limit] == '0' + end end diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 1c27b2b18..5cc606808 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -6,12 +6,10 @@ module WellKnown def show @account = Account.find_local!(username_from_resource) - @canonical_account_uri = @account.to_webfinger_s - @magic_key = pem_to_magic_key(@account.keypair.public_key) respond_to do |format| format.any(:json, :html) do - render formats: :json, content_type: 'application/jrd+json' + render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json' end format.xml do @@ -35,21 +33,6 @@ module WellKnown WebfingerResource.new(resource_user).username end - def pem_to_magic_key(public_key) - modulus, exponent = [public_key.n, public_key.e].map do |component| - result = [] - - until component.zero? - result << [component % 256].pack('C') - component >>= 8 - end - - result.reverse.join - end - - (['RSA'] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.') - end - def resource_param params.require(:resource) end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 73250cbf5..9443934b3 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -13,7 +13,7 @@ module Admin::FilterHelper link_to text, new_url, class: filter_link_class(new_class) end - def table_link_to(icon, text, path, options = {}) + def table_link_to(icon, text, path, **options) link_to safe_join([fa_icon(icon), text]), path, options.merge(class: 'table-action-link') end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 7dfab1df1..8ed5c8bda 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -5,7 +5,7 @@ module ApplicationHelper current_page?(path) ? 'active' : '' end - def active_link_to(label, path, options = {}) + def active_link_to(label, path, **options) link_to label, path, options.merge(class: active_nav_class(path)) end diff --git a/app/helpers/routing_helper.rb b/app/helpers/routing_helper.rb index f4693358c..11894a895 100644 --- a/app/helpers/routing_helper.rb +++ b/app/helpers/routing_helper.rb @@ -11,7 +11,7 @@ module RoutingHelper end end - def full_asset_url(source, options = {}) + def full_asset_url(source, **options) source = ActionController::Base.helpers.asset_url(source, options) unless use_storage? URI.join(root_url, source).to_s diff --git a/app/javascript/mastodon/actions/lists.js b/app/javascript/mastodon/actions/lists.js index 332e42166..4c8f9b186 100644 --- a/app/javascript/mastodon/actions/lists.js +++ b/app/javascript/mastodon/actions/lists.js @@ -4,12 +4,52 @@ export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; export const LIST_FETCH_FAIL = 'LIST_FETCH_FAIL'; +export const LISTS_FETCH_REQUEST = 'LISTS_FETCH_REQUEST'; +export const LISTS_FETCH_SUCCESS = 'LISTS_FETCH_SUCCESS'; +export const LISTS_FETCH_FAIL = 'LISTS_FETCH_FAIL'; + +export const LIST_EDITOR_TITLE_CHANGE = 'LIST_EDITOR_TITLE_CHANGE'; +export const LIST_EDITOR_RESET = 'LIST_EDITOR_RESET'; +export const LIST_EDITOR_SETUP = 'LIST_EDITOR_SETUP'; + +export const LIST_CREATE_REQUEST = 'LIST_CREATE_REQUEST'; +export const LIST_CREATE_SUCCESS = 'LIST_CREATE_SUCCESS'; +export const LIST_CREATE_FAIL = 'LIST_CREATE_FAIL'; + +export const LIST_UPDATE_REQUEST = 'LIST_UPDATE_REQUEST'; +export const LIST_UPDATE_SUCCESS = 'LIST_UPDATE_SUCCESS'; +export const LIST_UPDATE_FAIL = 'LIST_UPDATE_FAIL'; + +export const LIST_DELETE_REQUEST = 'LIST_DELETE_REQUEST'; +export const LIST_DELETE_SUCCESS = 'LIST_DELETE_SUCCESS'; +export const LIST_DELETE_FAIL = 'LIST_DELETE_FAIL'; + +export const LIST_ACCOUNTS_FETCH_REQUEST = 'LIST_ACCOUNTS_FETCH_REQUEST'; +export const LIST_ACCOUNTS_FETCH_SUCCESS = 'LIST_ACCOUNTS_FETCH_SUCCESS'; +export const LIST_ACCOUNTS_FETCH_FAIL = 'LIST_ACCOUNTS_FETCH_FAIL'; + +export const LIST_EDITOR_SUGGESTIONS_CHANGE = 'LIST_EDITOR_SUGGESTIONS_CHANGE'; +export const LIST_EDITOR_SUGGESTIONS_READY = 'LIST_EDITOR_SUGGESTIONS_READY'; +export const LIST_EDITOR_SUGGESTIONS_CLEAR = 'LIST_EDITOR_SUGGESTIONS_CLEAR'; + +export const LIST_EDITOR_ADD_REQUEST = 'LIST_EDITOR_ADD_REQUEST'; +export const LIST_EDITOR_ADD_SUCCESS = 'LIST_EDITOR_ADD_SUCCESS'; +export const LIST_EDITOR_ADD_FAIL = 'LIST_EDITOR_ADD_FAIL'; + +export const LIST_EDITOR_REMOVE_REQUEST = 'LIST_EDITOR_REMOVE_REQUEST'; +export const LIST_EDITOR_REMOVE_SUCCESS = 'LIST_EDITOR_REMOVE_SUCCESS'; +export const LIST_EDITOR_REMOVE_FAIL = 'LIST_EDITOR_REMOVE_FAIL'; + export const fetchList = id => (dispatch, getState) => { + if (getState().getIn(['lists', id])) { + return; + } + dispatch(fetchListRequest(id)); api(getState).get(`/api/v1/lists/${id}`) .then(({ data }) => dispatch(fetchListSuccess(data))) - .catch(err => dispatch(fetchListFail(err))); + .catch(err => dispatch(fetchListFail(id, err))); }; export const fetchListRequest = id => ({ @@ -22,7 +62,252 @@ export const fetchListSuccess = list => ({ list, }); -export const fetchListFail = error => ({ +export const fetchListFail = (id, error) => ({ type: LIST_FETCH_FAIL, + id, + error, +}); + +export const fetchLists = () => (dispatch, getState) => { + dispatch(fetchListsRequest()); + + api(getState).get('/api/v1/lists') + .then(({ data }) => dispatch(fetchListsSuccess(data))) + .catch(err => dispatch(fetchListsFail(err))); +}; + +export const fetchListsRequest = () => ({ + type: LISTS_FETCH_REQUEST, +}); + +export const fetchListsSuccess = lists => ({ + type: LISTS_FETCH_SUCCESS, + lists, +}); + +export const fetchListsFail = error => ({ + type: LISTS_FETCH_FAIL, + error, +}); + +export const submitListEditor = shouldReset => (dispatch, getState) => { + const listId = getState().getIn(['listEditor', 'listId']); + const title = getState().getIn(['listEditor', 'title']); + + if (listId === null) { + dispatch(createList(title, shouldReset)); + } else { + dispatch(updateList(listId, title, shouldReset)); + } +}; + +export const setupListEditor = listId => (dispatch, getState) => { + dispatch({ + type: LIST_EDITOR_SETUP, + list: getState().getIn(['lists', listId]), + }); + + dispatch(fetchListAccounts(listId)); +}; + +export const changeListEditorTitle = value => ({ + type: LIST_EDITOR_TITLE_CHANGE, + value, +}); + +export const createList = (title, shouldReset) => (dispatch, getState) => { + dispatch(createListRequest()); + + api(getState).post('/api/v1/lists', { title }).then(({ data }) => { + dispatch(createListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(createListFail(err))); +}; + +export const createListRequest = () => ({ + type: LIST_CREATE_REQUEST, +}); + +export const createListSuccess = list => ({ + type: LIST_CREATE_SUCCESS, + list, +}); + +export const createListFail = error => ({ + type: LIST_CREATE_FAIL, + error, +}); + +export const updateList = (id, title, shouldReset) => (dispatch, getState) => { + dispatch(updateListRequest(id)); + + api(getState).put(`/api/v1/lists/${id}`, { title }).then(({ data }) => { + dispatch(updateListSuccess(data)); + + if (shouldReset) { + dispatch(resetListEditor()); + } + }).catch(err => dispatch(updateListFail(id, err))); +}; + +export const updateListRequest = id => ({ + type: LIST_UPDATE_REQUEST, + id, +}); + +export const updateListSuccess = list => ({ + type: LIST_UPDATE_SUCCESS, + list, +}); + +export const updateListFail = (id, error) => ({ + type: LIST_UPDATE_FAIL, + id, + error, +}); + +export const resetListEditor = () => ({ + type: LIST_EDITOR_RESET, +}); + +export const deleteList = id => (dispatch, getState) => { + dispatch(deleteListRequest(id)); + + api(getState).delete(`/api/v1/lists/${id}`) + .then(() => dispatch(deleteListSuccess(id))) + .catch(err => dispatch(deleteListFail(id, err))); +}; + +export const deleteListRequest = id => ({ + type: LIST_DELETE_REQUEST, + id, +}); + +export const deleteListSuccess = id => ({ + type: LIST_DELETE_SUCCESS, + id, +}); + +export const deleteListFail = (id, error) => ({ + type: LIST_DELETE_FAIL, + id, + error, +}); + +export const fetchListAccounts = listId => (dispatch, getState) => { + dispatch(fetchListAccountsRequest(listId)); + + api(getState).get(`/api/v1/lists/${listId}/accounts`, { params: { limit: 0 } }) + .then(({ data }) => dispatch(fetchListAccountsSuccess(listId, data))) + .catch(err => dispatch(fetchListAccountsFail(listId, err))); +}; + +export const fetchListAccountsRequest = id => ({ + type: LIST_ACCOUNTS_FETCH_REQUEST, + id, +}); + +export const fetchListAccountsSuccess = (id, accounts, next) => ({ + type: LIST_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +export const fetchListAccountsFail = (id, error) => ({ + type: LIST_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +export const fetchListSuggestions = q => (dispatch, getState) => { + const params = { + q, + resolve: false, + limit: 4, + following: true, + }; + + api(getState).get('/api/v1/accounts/search', { params }) + .then(({ data }) => dispatch(fetchListSuggestionsReady(q, data))); +}; + +export const fetchListSuggestionsReady = (query, accounts) => ({ + type: LIST_EDITOR_SUGGESTIONS_READY, + query, + accounts, +}); + +export const clearListSuggestions = () => ({ + type: LIST_EDITOR_SUGGESTIONS_CLEAR, +}); + +export const changeListSuggestions = value => ({ + type: LIST_EDITOR_SUGGESTIONS_CHANGE, + value, +}); + +export const addToListEditor = accountId => (dispatch, getState) => { + dispatch(addToList(getState().getIn(['listEditor', 'listId']), accountId)); +}; + +export const addToList = (listId, accountId) => (dispatch, getState) => { + dispatch(addToListRequest(listId, accountId)); + + api(getState).post(`/api/v1/lists/${listId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addToListSuccess(listId, accountId))) + .catch(err => dispatch(addToListFail(listId, accountId, err))); +}; + +export const addToListRequest = (listId, accountId) => ({ + type: LIST_EDITOR_ADD_REQUEST, + listId, + accountId, +}); + +export const addToListSuccess = (listId, accountId) => ({ + type: LIST_EDITOR_ADD_SUCCESS, + listId, + accountId, +}); + +export const addToListFail = (listId, accountId, error) => ({ + type: LIST_EDITOR_ADD_FAIL, + listId, + accountId, + error, +}); + +export const removeFromListEditor = accountId => (dispatch, getState) => { + dispatch(removeFromList(getState().getIn(['listEditor', 'listId']), accountId)); +}; + +export const removeFromList = (listId, accountId) => (dispatch, getState) => { + dispatch(removeFromListRequest(listId, accountId)); + + api(getState).delete(`/api/v1/lists/${listId}/accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeFromListSuccess(listId, accountId))) + .catch(err => dispatch(removeFromListFail(listId, accountId, err))); +}; + +export const removeFromListRequest = (listId, accountId) => ({ + type: LIST_EDITOR_REMOVE_REQUEST, + listId, + accountId, +}); + +export const removeFromListSuccess = (listId, accountId) => ({ + type: LIST_EDITOR_REMOVE_SUCCESS, + listId, + accountId, +}); + +export const removeFromListFail = (listId, accountId, error) => ({ + type: LIST_EDITOR_REMOVE_FAIL, + listId, + accountId, error, }); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 1f2d7690f..0cca586ac 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -81,7 +81,7 @@ export default class Account extends ImmutablePureComponent { buttons = <IconButton active icon='unlock-alt' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; } else if (muting) { let hidingNotificationsButton; - if (muting.get('notifications')) { + if (account.getIn(['relationship', 'muting_notifications'])) { hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />; } else { hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />; @@ -93,7 +93,7 @@ export default class Account extends ImmutablePureComponent { </div> ); } else { - buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following ? true : false} />; + buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; } } diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 14a8d4c38..6a16e2fc7 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -209,6 +209,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { onBlur={this.onBlur} onPaste={this.onPaste} style={style} + aria-autocomplete='list' /> </label> diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index 389296c42..cb849fa5d 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -63,9 +63,8 @@ export default class ActionBar extends React.PureComponent { if (account.get('id') === me) { menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); } else { - const following = account.getIn(['relationship', 'following']); - if (following) { - if (following.get('reblogs')) { + if (account.getIn(['relationship', 'following'])) { + if (account.getIn(['relationship', 'showing_reblogs'])) { menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); } else { menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index b41eb19d4..775610032 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -68,7 +68,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ }, onReblogToggle (account) { - if (account.getIn(['relationship', 'following', 'reblogs'])) { + if (account.getIn(['relationship', 'show_reblogs'])) { dispatch(followAccount(account.get('id'), false)); } else { dispatch(followAccount(account.get('id'), true)); diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index df4284207..4b4c02bcc 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -25,6 +25,7 @@ const messages = defineMessages({ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }, pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, + lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' }, }); @@ -70,6 +71,7 @@ export default class GettingStarted extends ImmutablePureComponent { navItems = navItems.concat([ <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />, + <ColumnLink key='9' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />, ]); if (myAccount.get('locked')) { @@ -79,7 +81,7 @@ export default class GettingStarted extends ImmutablePureComponent { navItems = navItems.concat([ <ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, <ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, - <ColumnLink key='9' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' hideOnMobile />, + <ColumnLink key='10' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' hideOnMobile />, ]); return ( diff --git a/app/javascript/mastodon/features/list_editor/components/account.js b/app/javascript/mastodon/features/list_editor/components/account.js new file mode 100644 index 000000000..c78c58e24 --- /dev/null +++ b/app/javascript/mastodon/features/list_editor/components/account.js @@ -0,0 +1,77 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { makeGetAccount } from '../../../selectors'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Avatar from '../../../components/avatar'; +import DisplayName from '../../../components/display_name'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import { removeFromListEditor, addToListEditor } from '../../../actions/lists'; + +const messages = defineMessages({ + remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' }, + add: { id: 'lists.account.add', defaultMessage: 'Add to list' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId, added }) => ({ + account: getAccount(state, accountId), + added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { accountId }) => ({ + onRemove: () => dispatch(removeFromListEditor(accountId)), + onAdd: () => dispatch(addToListEditor(accountId)), +}); + +@connect(makeMapStateToProps, mapDispatchToProps) +@injectIntl +export default class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onRemove: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + added: PropTypes.bool, + }; + + static defaultProps = { + added: false, + }; + + render () { + const { account, intl, onRemove, onAdd, added } = this.props; + + let button; + + if (added) { + button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />; + } else { + button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />; + } + + return ( + <div className='account'> + <div className='account__wrapper'> + <div className='account__display-name'> + <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> + <DisplayName account={account} /> + </div> + + <div className='account__relationship'> + {button} + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/list_editor/components/search.js b/app/javascript/mastodon/features/list_editor/components/search.js new file mode 100644 index 000000000..45c4d0f2e --- /dev/null +++ b/app/javascript/mastodon/features/list_editor/components/search.js @@ -0,0 +1,75 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists'; +import classNames from 'classnames'; + +const messages = defineMessages({ + search: { id: 'lists.search', defaultMessage: 'Search among people you follow' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['listEditor', 'suggestions', 'value']), +}); + +const mapDispatchToProps = dispatch => ({ + onSubmit: value => dispatch(fetchListSuggestions(value)), + onClear: () => dispatch(clearListSuggestions()), + onChange: value => dispatch(changeListSuggestions(value)), +}); + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class Search extends React.PureComponent { + + static propTypes = { + intl: PropTypes.object.isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + }; + + handleChange = e => { + this.props.onChange(e.target.value); + } + + handleKeyUp = e => { + if (e.keyCode === 13) { + this.props.onSubmit(this.props.value); + } + } + + handleClear = () => { + this.props.onClear(); + } + + render () { + const { value, intl } = this.props; + const hasValue = value.length > 0; + + return ( + <div className='list-editor__search search'> + <label> + <span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span> + + <input + className='search__input' + type='text' + value={value} + onChange={this.handleChange} + onKeyUp={this.handleKeyUp} + placeholder={intl.formatMessage(messages.search)} + /> + </label> + + <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}> + <i className={classNames('fa fa-search', { active: !hasValue })} /> + <i aria-label={intl.formatMessage(messages.search)} className={classNames('fa fa-times-circle', { active: hasValue })} /> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/list_editor/index.js b/app/javascript/mastodon/features/list_editor/index.js new file mode 100644 index 000000000..a3b60e447 --- /dev/null +++ b/app/javascript/mastodon/features/list_editor/index.js @@ -0,0 +1,80 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { injectIntl } from 'react-intl'; +import { setupListEditor, clearListSuggestions, resetListEditor } from '../../actions/lists'; +import Account from './components/account'; +import Search from './components/search'; +import Motion from '../ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; + +const mapStateToProps = state => ({ + title: state.getIn(['listEditor', 'title']), + accountIds: state.getIn(['listEditor', 'accounts', 'items']), + searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: listId => dispatch(setupListEditor(listId)), + onClear: () => dispatch(clearListSuggestions()), + onReset: () => dispatch(resetListEditor()), +}); + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class ListEditor extends ImmutablePureComponent { + + static propTypes = { + listId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + title: PropTypes.string.isRequired, + accountIds: ImmutablePropTypes.list.isRequired, + searchAccountIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize, listId } = this.props; + onInitialize(listId); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { title, accountIds, searchAccountIds, onClear } = this.props; + const showSearch = searchAccountIds.size > 0; + + return ( + <div className='modal-root__modal list-editor'> + <h4>{title}</h4> + + <Search /> + + <div className='drawer__pager'> + <div className='drawer__inner list-editor__accounts'> + {accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)} + </div> + + {showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />} + + <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}> + {({ x }) => + <div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}> + {searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)} + </div> + } + </Motion> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js index 71f6e36a8..1dcd4de14 100644 --- a/app/javascript/mastodon/features/list_timeline/index.js +++ b/app/javascript/mastodon/features/list_timeline/index.js @@ -6,10 +6,18 @@ import StatusListContainer from '../ui/containers/status_list_container'; import Column from '../../components/column'; import ColumnHeader from '../../components/column_header'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { connectListStream } from '../../actions/streaming'; import { refreshListTimeline, expandListTimeline } from '../../actions/timelines'; -import { fetchList } from '../../actions/lists'; +import { fetchList, deleteList } from '../../actions/lists'; +import { openModal } from '../../actions/modal'; +import MissingIndicator from '../../components/missing_indicator'; +import LoadingIndicator from '../../components/loading_indicator'; + +const messages = defineMessages({ + deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, + deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, +}); const mapStateToProps = (state, props) => ({ list: state.getIn(['lists', props.params.id]), @@ -17,15 +25,21 @@ const mapStateToProps = (state, props) => ({ }); @connect(mapStateToProps) +@injectIntl export default class ListTimeline extends React.PureComponent { + static contextTypes = { + router: PropTypes.object, + }; + static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, columnId: PropTypes.string, hasUnread: PropTypes.bool, multiColumn: PropTypes.bool, - list: ImmutablePropTypes.map, + list: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]), + intl: PropTypes.object.isRequired, }; handlePin = () => { @@ -35,6 +49,7 @@ export default class ListTimeline extends React.PureComponent { dispatch(removeColumn(columnId)); } else { dispatch(addColumn('LIST', { id: this.props.params.id })); + this.context.router.history.push('/'); } } @@ -73,12 +88,49 @@ export default class ListTimeline extends React.PureComponent { this.props.dispatch(expandListTimeline(id)); } + handleEditClick = () => { + this.props.dispatch(openModal('LIST_EDITOR', { listId: this.props.params.id })); + } + + handleDeleteClick = () => { + const { dispatch, columnId, intl } = this.props; + const { id } = this.props.params; + + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => { + dispatch(deleteList(id)); + + if (!!columnId) { + dispatch(removeColumn(columnId)); + } else { + this.context.router.history.push('/lists'); + } + }, + })); + } + render () { const { hasUnread, columnId, multiColumn, list } = this.props; const { id } = this.props.params; const pinned = !!columnId; const title = list ? list.get('title') : id; + if (typeof list === 'undefined') { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } else if (list === false) { + return ( + <Column> + <MissingIndicator /> + </Column> + ); + } + return ( <Column ref={this.setRef}> <ColumnHeader @@ -90,7 +142,19 @@ export default class ListTimeline extends React.PureComponent { onClick={this.handleHeaderClick} pinned={pinned} multiColumn={multiColumn} - /> + > + <div className='column-header__links'> + <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleEditClick}> + <i className='fa fa-pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' /> + </button> + + <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleDeleteClick}> + <i className='fa fa-trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' /> + </button> + </div> + + <hr /> + </ColumnHeader> <StatusListContainer trackScroll={!pinned} diff --git a/app/javascript/mastodon/features/lists/components/new_list_form.js b/app/javascript/mastodon/features/lists/components/new_list_form.js new file mode 100644 index 000000000..eed6efc25 --- /dev/null +++ b/app/javascript/mastodon/features/lists/components/new_list_form.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { changeListEditorTitle, submitListEditor } from '../../../actions/lists'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' }, + title: { id: 'lists.new.create', defaultMessage: 'Add list' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['listEditor', 'title']), + disabled: state.getIn(['listEditor', 'isSubmitting']), +}); + +const mapDispatchToProps = dispatch => ({ + onChange: value => dispatch(changeListEditorTitle(value)), + onSubmit: () => dispatch(submitListEditor(true)), +}); + +@connect(mapStateToProps, mapDispatchToProps) +@injectIntl +export default class NewListForm extends React.PureComponent { + + static propTypes = { + value: PropTypes.string.isRequired, + disabled: PropTypes.bool, + intl: PropTypes.object.isRequired, + onChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + }; + + handleChange = e => { + this.props.onChange(e.target.value); + } + + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + } + + handleClick = () => { + this.props.onSubmit(); + } + + render () { + const { value, disabled, intl } = this.props; + + const label = intl.formatMessage(messages.label); + const title = intl.formatMessage(messages.title); + + return ( + <form className='column-inline-form' onSubmit={this.handleSubmit}> + <label> + <span style={{ display: 'none' }}>{label}</span> + + <input + className='setting-text' + value={value} + disabled={disabled} + onChange={this.handleChange} + placeholder={label} + /> + </label> + + <IconButton + disabled={disabled} + icon='plus' + title={title} + onClick={this.handleClick} + /> + </form> + ); + } + +} diff --git a/app/javascript/mastodon/features/lists/index.js b/app/javascript/mastodon/features/lists/index.js new file mode 100644 index 000000000..28026c434 --- /dev/null +++ b/app/javascript/mastodon/features/lists/index.js @@ -0,0 +1,76 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import Column from '../ui/components/column'; +import ColumnBackButtonSlim from '../../components/column_back_button_slim'; +import { fetchLists } from '../../actions/lists'; +import { defineMessages, injectIntl } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ColumnLink from '../ui/components/column_link'; +import ColumnSubheading from '../ui/components/column_subheading'; +import NewListForm from './components/new_list_form'; +import { createSelector } from 'reselect'; + +const messages = defineMessages({ + heading: { id: 'column.lists', defaultMessage: 'Lists' }, + subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' }, +}); + +const getOrderedLists = createSelector([state => state.get('lists')], lists => { + if (!lists) { + return lists; + } + + return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); +}); + +const mapStateToProps = state => ({ + lists: getOrderedLists(state), +}); + +@connect(mapStateToProps) +@injectIntl +export default class Lists extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + lists: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + this.props.dispatch(fetchLists()); + } + + render () { + const { intl, lists } = this.props; + + if (!lists) { + return ( + <Column> + <LoadingIndicator /> + </Column> + ); + } + + return ( + <Column icon='bars' heading={intl.formatMessage(messages.heading)}> + <ColumnBackButtonSlim /> + + <NewListForm /> + + <div className='scrollable'> + <ColumnSubheading text={intl.formatMessage(messages.subheading)} /> + + {lists.map(list => + <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='bars' text={list.get('title')} /> + )} + </div> + </Column> + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 79d86370e..ebbff6b5a 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -13,6 +13,7 @@ import { MuteModal, ReportModal, EmbedModal, + ListEditor, } from '../../../features/ui/util/async-components'; const MODAL_COMPONENTS = { @@ -25,6 +26,7 @@ const MODAL_COMPONENTS = { 'REPORT': ReportModal, 'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'EMBED': EmbedModal, + 'LIST_EDITOR': ListEditor, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 361326961..5b0d7246a 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -38,6 +38,7 @@ import { Blocks, Mutes, PinnedStatuses, + Lists, } from './util/async-components'; import { HotKeys } from 'react-hotkeys'; import { me } from '../../initial_state'; @@ -404,6 +405,7 @@ export default class UI extends React.Component { <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} /> <WrappedRoute path='/blocks' component={Blocks} content={children} /> <WrappedRoute path='/mutes' component={Mutes} content={children} /> + <WrappedRoute path='/lists' component={Lists} content={children} /> <WrappedRoute component={GenericNotFound} content={children} /> </WrappedSwitch> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index b741f668e..d6586680b 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -30,6 +30,10 @@ export function ListTimeline () { return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline'); } +export function Lists () { + return import(/* webpackChunkName: "features/lists" */'../../lists'); +} + export function Status () { return import(/* webpackChunkName: "features/status" */'../../status'); } @@ -113,3 +117,7 @@ export function Video () { export function EmbedModal () { return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal'); } + +export function ListEditor () { + return import(/* webpackChunkName: "features/list_editor" */'../../list_editor'); +} diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 388dd57e8..e015c41c2 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -7,22 +7,22 @@ "account.followers": "フォロワー", "account.follows": "フォロー", "account.follows_you": "フォローされています", - "account.hide_reblogs": "Hide boosts from @{name}", + "account.hide_reblogs": "@{name}さんからのブーストを非表示", "account.media": "メディア", "account.mention": "返信", "account.moved_to": "{name}さんは引っ越しました:", "account.mute": "ミュート", - "account.mute_notifications": "@{name}からの通知を受け取る", + "account.mute_notifications": "@{name}さんからの通知を受け取る", "account.posts": "投稿", "account.report": "通報", "account.requested": "承認待ち", - "account.share": "@{name} のプロフィールを共有する", - "account.show_reblogs": "Show boosts from @{name}", + "account.share": "@{name}さんのプロフィールを共有する", + "account.show_reblogs": "@{name}さんからのブーストを表示", "account.unblock": "ブロック解除", "account.unblock_domain": "{domain}を表示", "account.unfollow": "フォロー解除", "account.unmute": "ミュート解除", - "account.unmute_notifications": "@{name}からの通知を受け取らない", + "account.unmute_notifications": "@{name}さんからの通知を受け取らない", "account.view_full_profile": "全ての情報を見る", "boost_modal.combo": "次からは{combo}を押せば、これをスキップできます。", "bundle_column_error.body": "コンポーネントの読み込み中に問題が発生しました。", @@ -36,6 +36,7 @@ "column.favourites": "お気に入り", "column.follow_requests": "フォローリクエスト", "column.home": "ホーム", + "column.lists": "リスト", "column.mutes": "ミュートしたユーザー", "column.notifications": "通知", "column.pins": "固定されたトゥート", @@ -59,15 +60,17 @@ "compose_form.spoiler_placeholder": "ここに警告を書いてください", "confirmation_modal.cancel": "キャンセル", "confirmations.block.confirm": "ブロック", - "confirmations.block.message": "本当に{name}をブロックしますか?", + "confirmations.block.message": "本当に{name}さんをブロックしますか?", "confirmations.delete.confirm": "削除", "confirmations.delete.message": "本当に削除しますか?", + "confirmations.delete_list.confirm": "削除", + "confirmations.delete_list.message": "本当に削除しますか?", "confirmations.domain_block.confirm": "ドメイン全体を非表示", "confirmations.domain_block.message": "本当に{domain}全体を非表示にしますか? 多くの場合は個別にブロックやミュートするだけで充分であり、また好ましいです。", "confirmations.mute.confirm": "ミュート", - "confirmations.mute.message": "本当に{name}をミュートしますか?", + "confirmations.mute.message": "本当に{name}さんをミュートしますか?", "confirmations.unfollow.confirm": "フォロー解除", - "confirmations.unfollow.message": "本当に{name}をフォロー解除しますか?", + "confirmations.unfollow.message": "本当に{name}さんをフォロー解除しますか?", "embed.instructions": "下記のコードをコピーしてウェブサイトに埋め込みます。", "embed.preview": "表示例:", "emoji_button.activity": "活動", @@ -112,7 +115,7 @@ "keyboard_shortcuts.down": "カラム内一つ下に移動", "keyboard_shortcuts.enter": "トゥートの詳細を表示", "keyboard_shortcuts.favourite": "お気に入り", - "keyboard_shortcuts.heading": "キーボードショートカット一覧", + "keyboard_shortcuts.heading": "キーボードショートカット", "keyboard_shortcuts.hotkey": "ホットキー", "keyboard_shortcuts.legend": "この一覧を表示", "keyboard_shortcuts.mention": "メンション", @@ -124,6 +127,14 @@ "lightbox.close": "閉じる", "lightbox.next": "次", "lightbox.previous": "前", + "lists.account.add": "リストに追加", + "lists.account.remove": "リストから外す", + "lists.delete": "リストを削除", + "lists.edit": "リストを編集", + "lists.new.create": "リストを作成", + "lists.new.title_placeholder": "新規リスト名", + "lists.search": "フォローしている人の中から検索", + "lists.subheading": "あなたのリスト", "loading_indicator.label": "読み込み中...", "media_gallery.toggle_visible": "表示切り替え", "missing_indicator.label": "見つかりません", @@ -135,6 +146,7 @@ "navigation_bar.follow_requests": "フォローリクエスト", "navigation_bar.info": "このインスタンスについて", "navigation_bar.keyboard_shortcuts": "キーボードショートカット", + "navigation_bar.lists": "リスト", "navigation_bar.logout": "ログアウト", "navigation_bar.mutes": "ミュートしたユーザー", "navigation_bar.pins": "固定されたトゥート", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index 907999940..0667aeeee 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -36,6 +36,7 @@ "column.favourites": "Ulubione", "column.follow_requests": "Prośby o śledzenie", "column.home": "Strona główna", + "column.lists": "Listy", "column.mutes": "Wyciszeni użytkownicy", "column.notifications": "Powiadomienia", "column.pins": "Przypięte wpisy", @@ -60,6 +61,8 @@ "confirmation_modal.cancel": "Anuluj", "confirmations.block.confirm": "Zablokuj", "confirmations.block.message": "Czy na pewno chcesz zablokować {name}?", + "confirmations.delete_list.confirm": "Usuń", + "confirmations.delete_list.message": "Czy na pewno chcesz bezpowrotnie usunąć tą listę?", "confirmations.delete.confirm": "Usuń", "confirmations.delete.message": "Czy na pewno chcesz usunąć ten wpis?", "confirmations.domain_block.confirm": "Ukryj wszysyko z domeny", @@ -124,6 +127,14 @@ "lightbox.close": "Zamknij", "lightbox.next": "Następne", "lightbox.previous": "Poprzednie", + "lists.account.add": "Dodaj do listy", + "lists.account.delete": "Usuń z listy", + "lists.delete": "Usuń listę", + "lists.edit": "Edytuj listę", + "lists.new.create": "Utwórz listę", + "lists.new.title_placeholder": "Wprowadź tytuł listy…", + "lists.search": "Szukaj wśród osób które śledzisz", + "lists.subheading": "Twoje listy", "loading_indicator.label": "Ładowanie…", "media_gallery.toggle_visible": "Przełącz widoczność", "missing_indicator.label": "Nie znaleziono", @@ -135,6 +146,7 @@ "navigation_bar.follow_requests": "Prośby o śledzenie", "navigation_bar.info": "Szczegółowe informacje", "navigation_bar.keyboard_shortcuts": "Skróty klawiszowe", + "navigation_bar.lists": "Listy", "navigation_bar.logout": "Wyloguj", "navigation_bar.mutes": "Wyciszeni użytkownicy", "navigation_bar.pins": "Przypięte wpisy", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index c23868cda..e2d2489dc 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -7,7 +7,7 @@ "account.followers": "关注者", "account.follows": "正在关注", "account.follows_you": "关注了你", - "account.hide_reblogs": "Hide boosts from @{name}", + "account.hide_reblogs": "隐藏来自 @{name} 的转嘟", "account.media": "媒体", "account.mention": "提及 @{name}", "account.moved_to": "{name} 已经迁移到:", @@ -17,7 +17,7 @@ "account.report": "举报 @{name}", "account.requested": "正在等待对方同意。点击以取消发送关注请求", "account.share": "分享 @{name} 的个人资料", - "account.show_reblogs": "Show boosts from @{name}", + "account.show_reblogs": "显示来自 @{name} 的转嘟", "account.unblock": "不再屏蔽 @{name}", "account.unblock_domain": "不再隐藏来自 {domain} 的内容", "account.unfollow": "取消关注", @@ -36,6 +36,7 @@ "column.favourites": "收藏过的嘟文", "column.follow_requests": "关注请求", "column.home": "主页", + "column.lists": "列表", "column.mutes": "被隐藏的用户", "column.notifications": "通知", "column.pins": "置顶嘟文", @@ -62,6 +63,8 @@ "confirmations.block.message": "想好了,真的要屏蔽 {name}?", "confirmations.delete.confirm": "删除", "confirmations.delete.message": "想好了,真的要删除这条嘟文?", + "confirmations.delete_list.confirm": "删除", + "confirmations.delete_list.message": "你确定要永久删除这个列表吗?", "confirmations.domain_block.confirm": "隐藏整个网站的内容", "confirmations.domain_block.message": "你真的真的确定要隐藏所有来自 {domain} 的内容吗?多数情况下,屏蔽或隐藏几个特定的用户就应该能满足你的需要了。", "confirmations.mute.confirm": "隐藏", @@ -104,26 +107,34 @@ "home.column_settings.show_reblogs": "显示转嘟", "home.column_settings.show_replies": "显示回复", "home.settings": "栏目设置", - "keyboard_shortcuts.back": "to navigate back", - "keyboard_shortcuts.boost": "to boost", - "keyboard_shortcuts.column": "to focus a status in one of the columns", - "keyboard_shortcuts.compose": "to focus the compose textarea", - "keyboard_shortcuts.description": "Description", - "keyboard_shortcuts.down": "to move down in the list", - "keyboard_shortcuts.enter": "to open status", - "keyboard_shortcuts.favourite": "to favourite", - "keyboard_shortcuts.heading": "Keyboard Shortcuts", - "keyboard_shortcuts.hotkey": "Hotkey", - "keyboard_shortcuts.legend": "to display this legend", - "keyboard_shortcuts.mention": "to mention author", - "keyboard_shortcuts.reply": "to reply", - "keyboard_shortcuts.search": "to focus search", - "keyboard_shortcuts.toot": "to start a brand new toot", - "keyboard_shortcuts.unfocus": "to un-focus compose textarea/search", - "keyboard_shortcuts.up": "to move up in the list", + "keyboard_shortcuts.back": "返回上一页", + "keyboard_shortcuts.boost": "转嘟", + "keyboard_shortcuts.column": "选择第 X 栏中的嘟文", + "keyboard_shortcuts.compose": "选择嘟文撰写框", + "keyboard_shortcuts.description": "说明", + "keyboard_shortcuts.down": "在列表中让光标下移", + "keyboard_shortcuts.enter": "展开嘟文", + "keyboard_shortcuts.favourite": "收藏嘟文", + "keyboard_shortcuts.heading": "快捷键列表", + "keyboard_shortcuts.hotkey": "快捷键", + "keyboard_shortcuts.legend": "显示此列表", + "keyboard_shortcuts.mention": "提及嘟文作者", + "keyboard_shortcuts.reply": "回复嘟文", + "keyboard_shortcuts.search": "选择搜索框", + "keyboard_shortcuts.toot": "发送新嘟文", + "keyboard_shortcuts.unfocus": "取消输入", + "keyboard_shortcuts.up": "在列表中让光标上移", "lightbox.close": "关闭", "lightbox.next": "下一步", "lightbox.previous": "上一步", + "lists.account.add": "添加到列表", + "lists.account.remove": "从列表中删除", + "lists.delete": "删除列表", + "lists.edit": "编辑列表", + "lists.new.create": "新建列表", + "lists.new.title_placeholder": "新列表的标题", + "lists.search": "搜索你关注的人", + "lists.subheading": "你的列表", "loading_indicator.label": "加载中……", "media_gallery.toggle_visible": "切换显示/隐藏", "missing_indicator.label": "找不到内容", @@ -134,7 +145,8 @@ "navigation_bar.favourites": "收藏的内容", "navigation_bar.follow_requests": "关注请求", "navigation_bar.info": "关于本站", - "navigation_bar.keyboard_shortcuts": "Keyboard shortcuts", + "navigation_bar.lists": "列表", + "navigation_bar.keyboard_shortcuts": "快捷键列表", "navigation_bar.logout": "注销", "navigation_bar.mutes": "被隐藏的用户", "navigation_bar.pins": "置顶嘟文", diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js index 5891d0fdd..f77061dfa 100644 --- a/app/javascript/mastodon/reducers/accounts.js +++ b/app/javascript/mastodon/reducers/accounts.js @@ -43,6 +43,10 @@ import { FAVOURITED_STATUSES_FETCH_SUCCESS, FAVOURITED_STATUSES_EXPAND_SUCCESS, } from '../actions/favourites'; +import { + LIST_ACCOUNTS_FETCH_SUCCESS, + LIST_EDITOR_SUGGESTIONS_READY, +} from '../actions/lists'; import { STORE_HYDRATE } from '../actions/store'; import emojify from '../features/emoji/emoji'; import { Map as ImmutableMap, fromJS } from 'immutable'; @@ -115,6 +119,8 @@ export default function accounts(state = initialState, action) { case BLOCKS_EXPAND_SUCCESS: case MUTES_FETCH_SUCCESS: case MUTES_EXPAND_SUCCESS: + case LIST_ACCOUNTS_FETCH_SUCCESS: + case LIST_EDITOR_SUGGESTIONS_READY: return action.accounts ? normalizeAccounts(state, action.accounts) : state; case NOTIFICATIONS_REFRESH_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS: diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js index 2a78a9f34..a93fa4245 100644 --- a/app/javascript/mastodon/reducers/accounts_counters.js +++ b/app/javascript/mastodon/reducers/accounts_counters.js @@ -45,6 +45,10 @@ import { FAVOURITED_STATUSES_FETCH_SUCCESS, FAVOURITED_STATUSES_EXPAND_SUCCESS, } from '../actions/favourites'; +import { + LIST_ACCOUNTS_FETCH_SUCCESS, + LIST_EDITOR_SUGGESTIONS_READY, +} from '../actions/lists'; import { STORE_HYDRATE } from '../actions/store'; import { Map as ImmutableMap, fromJS } from 'immutable'; @@ -106,6 +110,8 @@ export default function accountsCounters(state = initialState, action) { case BLOCKS_EXPAND_SUCCESS: case MUTES_FETCH_SUCCESS: case MUTES_EXPAND_SUCCESS: + case LIST_ACCOUNTS_FETCH_SUCCESS: + case LIST_EDITOR_SUGGESTIONS_READY: return action.accounts ? normalizeAccounts(state, action.accounts) : state; case NOTIFICATIONS_REFRESH_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS: diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 425a2acdd..a028e989c 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -23,6 +23,7 @@ import notifications from './notifications'; import height_cache from './height_cache'; import custom_emojis from './custom_emojis'; import lists from './lists'; +import listEditor from './list_editor'; const reducers = { timelines, @@ -49,6 +50,7 @@ const reducers = { height_cache, custom_emojis, lists, + listEditor, }; export default combineReducers(reducers); diff --git a/app/javascript/mastodon/reducers/list_editor.js b/app/javascript/mastodon/reducers/list_editor.js new file mode 100644 index 000000000..02a0dabb1 --- /dev/null +++ b/app/javascript/mastodon/reducers/list_editor.js @@ -0,0 +1,89 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + LIST_CREATE_REQUEST, + LIST_CREATE_FAIL, + LIST_CREATE_SUCCESS, + LIST_UPDATE_REQUEST, + LIST_UPDATE_FAIL, + LIST_UPDATE_SUCCESS, + LIST_EDITOR_RESET, + LIST_EDITOR_SETUP, + LIST_EDITOR_TITLE_CHANGE, + LIST_ACCOUNTS_FETCH_REQUEST, + LIST_ACCOUNTS_FETCH_SUCCESS, + LIST_ACCOUNTS_FETCH_FAIL, + LIST_EDITOR_SUGGESTIONS_READY, + LIST_EDITOR_SUGGESTIONS_CLEAR, + LIST_EDITOR_SUGGESTIONS_CHANGE, + LIST_EDITOR_ADD_SUCCESS, + LIST_EDITOR_REMOVE_SUCCESS, +} from '../actions/lists'; + +const initialState = ImmutableMap({ + listId: null, + isSubmitting: false, + title: '', + + accounts: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), + + suggestions: ImmutableMap({ + value: '', + items: ImmutableList(), + }), +}); + +export default function listEditorReducer(state = initialState, action) { + switch(action.type) { + case LIST_EDITOR_RESET: + return initialState; + case LIST_EDITOR_SETUP: + return state.withMutations(map => { + map.set('listId', action.list.get('id')); + map.set('title', action.list.get('title')); + map.set('isSubmitting', false); + }); + case LIST_EDITOR_TITLE_CHANGE: + return state.set('title', action.value); + case LIST_CREATE_REQUEST: + case LIST_UPDATE_REQUEST: + return state.set('isSubmitting', true); + case LIST_CREATE_FAIL: + case LIST_UPDATE_FAIL: + return state.set('isSubmitting', false); + case LIST_CREATE_SUCCESS: + case LIST_UPDATE_SUCCESS: + return state.withMutations(map => { + map.set('isSubmitting', false); + map.set('listId', action.list.id); + }); + case LIST_ACCOUNTS_FETCH_REQUEST: + return state.setIn(['accounts', 'isLoading'], true); + case LIST_ACCOUNTS_FETCH_FAIL: + return state.setIn(['accounts', 'isLoading'], false); + case LIST_ACCOUNTS_FETCH_SUCCESS: + return state.update('accounts', accounts => accounts.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.accounts.map(item => item.id))); + })); + case LIST_EDITOR_SUGGESTIONS_CHANGE: + return state.setIn(['suggestions', 'value'], action.value); + case LIST_EDITOR_SUGGESTIONS_READY: + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + case LIST_EDITOR_SUGGESTIONS_CLEAR: + return state.update('suggestions', suggestions => suggestions.withMutations(map => { + map.set('items', ImmutableList()); + map.set('value', ''); + })); + case LIST_EDITOR_ADD_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.unshift(action.accountId)); + case LIST_EDITOR_REMOVE_SUCCESS: + return state.updateIn(['accounts', 'items'], list => list.filterNot(item => item === action.accountId)); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/lists.js b/app/javascript/mastodon/reducers/lists.js index 3e3908869..f30ffbcbd 100644 --- a/app/javascript/mastodon/reducers/lists.js +++ b/app/javascript/mastodon/reducers/lists.js @@ -1,14 +1,36 @@ -import { LIST_FETCH_SUCCESS } from '../actions/lists'; +import { + LIST_FETCH_SUCCESS, + LIST_FETCH_FAIL, + LISTS_FETCH_SUCCESS, + LIST_CREATE_SUCCESS, + LIST_UPDATE_SUCCESS, + LIST_DELETE_SUCCESS, +} from '../actions/lists'; import { Map as ImmutableMap, fromJS } from 'immutable'; const initialState = ImmutableMap(); const normalizeList = (state, list) => state.set(list.id, fromJS(list)); +const normalizeLists = (state, lists) => { + lists.forEach(list => { + state = normalizeList(state, list); + }); + + return state; +}; + export default function lists(state = initialState, action) { switch(action.type) { case LIST_FETCH_SUCCESS: + case LIST_CREATE_SUCCESS: + case LIST_UPDATE_SUCCESS: return normalizeList(state, action.list); + case LISTS_FETCH_SUCCESS: + return normalizeLists(state, action.lists); + case LIST_DELETE_SUCCESS: + case LIST_FETCH_FAIL: + return state.set(action.id, false); default: return state; } diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index a9f3f9529..5817cf49b 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -2,6 +2,7 @@ import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings'; import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns'; import { STORE_HYDRATE } from '../actions/store'; import { EMOJI_USE } from '../actions/emojis'; +import { LIST_DELETE_SUCCESS, LIST_FETCH_FAIL } from '../actions/lists'; import { Map as ImmutableMap, fromJS } from 'immutable'; import uuid from '../uuid'; @@ -84,6 +85,8 @@ const moveColumn = (state, uuid, direction) => { const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false); +const filterDeadListColumns = (state, listId) => state.update('columns', columns => columns.filterNot(column => column.get('id') === 'LIST' && column.get('params').get('id') === listId)); + export default function settings(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: @@ -106,6 +109,10 @@ export default function settings(state = initialState, action) { return updateFrequentEmojis(state, action.emoji); case SETTING_SAVE: return state.set('saved', true); + case LIST_FETCH_FAIL: + return action.error.response.status === 404 ? filterDeadListColumns(state, action.id) : state; + case LIST_DELETE_SUCCESS: + return filterDeadListColumns(state, action.id); default: return state; } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 26ff8bf00..d72f53f36 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -440,11 +440,6 @@ position: absolute; right: 5px; top: 5px; - - ::-webkit-scrollbar-track:hover, - ::-webkit-scrollbar-track:active { - background-color: rgba($base-overlay-background, 0.3); - } } } @@ -517,7 +512,6 @@ font-weight: 400; overflow: hidden; white-space: pre-wrap; - padding-top: 5px; &.status__content--with-spoiler { white-space: normal; @@ -530,7 +524,7 @@ .emojione { width: 20px; height: 20px; - margin: -5px 0 0; + margin: -3px 0 0; } p { @@ -766,7 +760,7 @@ .status__action-bar { align-items: center; display: flex; - margin-top: 5px; + margin-top: 10px; } .status__action-bar-button { @@ -799,7 +793,7 @@ .emojione { width: 24px; height: 24px; - margin: -5px 0 0; + margin: -3px 0 0; } } @@ -2359,6 +2353,10 @@ button.icon-button.active i.fa-retweet { margin-left: 0; } +.column-header__links .text-btn { + margin-right: 10px; +} + .column-header__button { background: lighten($ui-base-color, 4%); border: 0; @@ -2398,6 +2396,14 @@ button.icon-button.active i.fa-retweet { &.animating { overflow-y: hidden; } + + hr { + height: 0; + background: transparent; + border: 0; + border-top: 1px solid lighten($ui-base-color, 12%); + margin: 10px 0; + } } .column-header__collapsible-inner { @@ -4440,3 +4446,94 @@ noscript { margin-bottom: 0; } } + +.column-inline-form { + padding: 7px 15px; + padding-right: 5px; + display: flex; + justify-content: flex-start; + align-items: center; + background: lighten($ui-base-color, 4%); + + label { + flex: 1 1 auto; + + input { + width: 100%; + margin-bottom: 6px; + + &:focus { + outline: 0; + } + } + } + + .icon-button { + flex: 0 0 auto; + margin-left: 5px; + } +} + +.drawer__backdrop { + cursor: pointer; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba($base-overlay-background, 0.5); +} + +.list-editor { + background: $ui-base-color; + flex-direction: column; + border-radius: 8px; + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + width: 380px; + overflow: hidden; + + @media screen and (max-width: 420px) { + width: 90%; + } + + h4 { + padding: 15px 0; + background: lighten($ui-base-color, 13%); + font-weight: 500; + font-size: 16px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .drawer__pager { + height: 50vh; + } + + .drawer__inner { + border-radius: 0 0 8px 8px; + + &.backdrop { + width: calc(100% - 60px); + box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + border-radius: 0 0 0 8px; + } + } + + &__accounts { + overflow-y: auto; + } + + .account__display-name { + &:hover strong { + text-decoration: none; + } + } + + .account__avatar { + cursor: default; + } + + .search { + margin-bottom: 0; + } +} diff --git a/app/javascript/styles/mastodon/emoji_picker.scss b/app/javascript/styles/mastodon/emoji_picker.scss index 2b46d30fc..4161cc045 100644 --- a/app/javascript/styles/mastodon/emoji_picker.scss +++ b/app/javascript/styles/mastodon/emoji_picker.scss @@ -95,6 +95,11 @@ padding: 0 6px 6px; background: $simple-background-color; will-change: transform; + + &::-webkit-scrollbar-track:hover, + &::-webkit-scrollbar-track:active { + background-color: rgba($base-overlay-background, 0.3); + } } .emoji-mart-search { diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 01144f595..820189d29 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -3,7 +3,7 @@ class ActivityPub::Activity include JsonLdHelper - def initialize(json, account, options = {}) + def initialize(json, account, **options) @json = json @account = account @object = @json['object'] @@ -15,7 +15,7 @@ class ActivityPub::Activity end class << self - def factory(json, account, options = {}) + def factory(json, account, **options) @json = json klass&.new(json, account, options) end diff --git a/app/lib/extractor.rb b/app/lib/extractor.rb index 738ec89a0..479689d60 100644 --- a/app/lib/extractor.rb +++ b/app/lib/extractor.rb @@ -32,7 +32,7 @@ module Extractor possible_entries end - def extract_hashtags_with_indices(text, _options = {}) + def extract_hashtags_with_indices(text, **) return [] unless text =~ /#/ tags = [] diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 9d8bc52db..f5bf64cc7 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -9,7 +9,7 @@ class Formatter include ActionView::Helpers::TextHelper - def format(status, options = {}) + def format(status, **options) if status.reblog? prepend_reblog = status.reblog.account.acct status = status.proper diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb index 8b27b124f..c5933f3ad 100644 --- a/app/lib/ostatus/activity/base.rb +++ b/app/lib/ostatus/activity/base.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class OStatus::Activity::Base - def initialize(xml, account = nil, options = {}) + def initialize(xml, account = nil, **options) @xml = xml @account = account @options = options diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb index 3ca6c5943..656e45822 100644 --- a/app/lib/ostatus/atom_serializer.rb +++ b/app/lib/ostatus/atom_serializer.rb @@ -319,7 +319,7 @@ class OStatus::AtomSerializer private - def append_element(parent, name, content = nil, attributes = {}) + def append_element(parent, name, content = nil, **attributes) element = Ox::Element.new(name) attributes.each { |k, v| element[k] = sanitize_str(v) } element << sanitize_str(content) unless content.nil? diff --git a/app/lib/provider_discovery.rb b/app/lib/provider_discovery.rb index 5e02e6806..bcc4ed500 100644 --- a/app/lib/provider_discovery.rb +++ b/app/lib/provider_discovery.rb @@ -2,7 +2,7 @@ class ProviderDiscovery < OEmbed::ProviderDiscovery class << self - def discover_provider(url, options = {}) + def discover_provider(url, **options) res = Request.new(:get, url).perform format = options[:format] diff --git a/app/lib/request.rb b/app/lib/request.rb index 30ea0e7ee..7671f4ffc 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -5,7 +5,7 @@ class Request include RoutingHelper - def initialize(verb, url, options = {}) + def initialize(verb, url, **options) @verb = verb @url = Addressable::URI.parse(url).normalize @options = options diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index d79f26366..fd2b0649a 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -63,7 +63,7 @@ class NotificationMailer < ApplicationMailer end end - def digest(recipient, opts = {}) + def digest(recipient, **opts) @me = recipient @since = opts[:since] || @me.user.last_emailed_at || @me.user.current_sign_in_at @notifications = Notification.where(account: @me, activity_type: 'Mention').where('created_at > ?', @since) diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index bdb29ebad..5a062dc25 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -5,7 +5,7 @@ class UserMailer < Devise::Mailer helper :instance - def confirmation_instructions(user, token, _opts = {}) + def confirmation_instructions(user, token, **) @resource = user @token = token @instance = Rails.configuration.x.local_domain @@ -17,7 +17,7 @@ class UserMailer < Devise::Mailer end end - def reset_password_instructions(user, token, _opts = {}) + def reset_password_instructions(user, token, **) @resource = user @token = token @instance = Rails.configuration.x.local_domain @@ -29,7 +29,7 @@ class UserMailer < Devise::Mailer end end - def password_change(user, _opts = {}) + def password_change(user, **) @resource = user @instance = Rails.configuration.x.local_domain diff --git a/app/models/account.rb b/app/models/account.rb index ffd19fa52..48b17bbb8 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -186,6 +186,21 @@ class Account < ApplicationRecord @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key) end + def magic_key + modulus, exponent = [keypair.public_key.n, keypair.public_key.e].map do |component| + result = [] + + until component.zero? + result << [component % 256].pack('C') + component >>= 8 + end + + result.reverse.join + end + + (['RSA'] + [modulus, exponent].map { |n| Base64.urlsafe_encode64(n) }).join('.') + end + def subscription(webhook_url) @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url) end @@ -279,23 +294,46 @@ class Account < ApplicationRecord find_by_sql([sql, limit]) end - def advanced_search_for(terms, account, limit = 10) + def advanced_search_for(terms, account, limit = 10, following = false) textsearch, query = generate_query_for_search(terms) - sql = <<-SQL.squish - SELECT - accounts.*, - (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank - FROM accounts - LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?) - WHERE #{query} @@ #{textsearch} - AND accounts.suspended = false - GROUP BY accounts.id - ORDER BY rank DESC - LIMIT ? - SQL - - find_by_sql([sql, account.id, account.id, limit]) + if following + sql = <<-SQL.squish + WITH first_degree AS ( + SELECT target_account_id + FROM follows + WHERE account_id = ? + ) + SELECT + accounts.*, + (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank + FROM accounts + LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?) + WHERE accounts.id IN (SELECT * FROM first_degree) + AND #{query} @@ #{textsearch} + AND accounts.suspended = false + GROUP BY accounts.id + ORDER BY rank DESC + LIMIT ? + SQL + + find_by_sql([sql, account.id, account.id, account.id, limit]) + else + sql = <<-SQL.squish + SELECT + accounts.*, + (count(f.id) + 1) * ts_rank_cd(#{textsearch}, #{query}, 32) AS rank + FROM accounts + LEFT OUTER JOIN follows AS f ON (accounts.id = f.account_id AND f.target_account_id = ?) OR (accounts.id = f.target_account_id AND f.account_id = ?) + WHERE #{query} @@ #{textsearch} + AND accounts.suspended = false + GROUP BY accounts.id + ORDER BY rank DESC + LIMIT ? + SQL + + find_by_sql([sql, account.id, account.id, limit]) + end end private diff --git a/app/models/list.rb b/app/models/list.rb index 5d7ba0065..910864b26 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -19,4 +19,23 @@ class List < ApplicationRecord has_many :accounts, through: :list_accounts validates :title, presence: true + + before_destroy :clean_feed_manager + + private + + def clean_feed_manager + reblog_key = FeedManager.instance.key(:list, id, 'reblogs') + reblogged_id_set = Redis.current.zrange(reblog_key, 0, -1) + + Redis.current.pipelined do + Redis.current.del(FeedManager.instance.key(:list, id)) + Redis.current.del(reblog_key) + + reblogged_id_set.each do |reblogged_id| + reblog_set_key = FeedManager.instance.key(:list, id, "reblogs:#{reblogged_id}") + Redis.current.del(reblog_set_key) + end + end + end end diff --git a/app/models/list_account.rb b/app/models/list_account.rb index c08239aa0..253932590 100644 --- a/app/models/list_account.rb +++ b/app/models/list_account.rb @@ -14,6 +14,8 @@ class ListAccount < ApplicationRecord belongs_to :account, required: true belongs_to :follow, required: true + validates :account_id, uniqueness: { scope: :list_id } + before_validation :set_follow private diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb index c3f867743..070144e2d 100644 --- a/app/models/remote_follow.rb +++ b/app/models/remote_follow.rb @@ -7,8 +7,8 @@ class RemoteFollow validates :acct, presence: true - def initialize(attrs = {}) - @acct = attrs[:acct].gsub(/\A@/, '').strip unless attrs[:acct].nil? + def initialize(attrs = nil) + @acct = attrs[:acct].gsub(/\A@/, '').strip if !attrs.nil? && !attrs[:acct].nil? end def valid? diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb index d19489b36..8b711d0d6 100644 --- a/app/models/session_activation.rb +++ b/app/models/session_activation.rb @@ -53,7 +53,7 @@ class SessionActivation < ApplicationRecord id && where(session_id: id).exists? end - def activate(options = {}) + def activate(**options) activation = create!(options) purge_old activation diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb index a30558bac..bf1ba3716 100644 --- a/app/presenters/account_relationships_presenter.rb +++ b/app/presenters/account_relationships_presenter.rb @@ -4,7 +4,7 @@ class AccountRelationshipsPresenter attr_reader :following, :followed_by, :blocking, :muting, :requested, :domain_blocking - def initialize(account_ids, current_account_id, options = {}) + def initialize(account_ids, current_account_id, **options) @following = Account.following_map(account_ids, current_account_id).merge(options[:following_map] || {}) @followed_by = Account.followed_by_map(account_ids, current_account_id).merge(options[:followed_by_map] || {}) @blocking = Account.blocking_map(account_ids, current_account_id).merge(options[:blocking_map] || {}) diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index bc3887a44..b04e10e2f 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -3,7 +3,7 @@ class StatusRelationshipsPresenter attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map - def initialize(statuses, current_account_id = nil, options = {}) + def initialize(statuses, current_account_id = nil, **options) if current_account_id.nil? @reblogs_map = {} @favourites_map = {} diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb index 998727e37..45bfd4d6e 100644 --- a/app/serializers/rest/relationship_serializer.rb +++ b/app/serializers/rest/relationship_serializer.rb @@ -1,15 +1,21 @@ # frozen_string_literal: true class REST::RelationshipSerializer < ActiveModel::Serializer - attributes :id, :following, :followed_by, :blocking, - :muting, :requested, :domain_blocking + attributes :id, :following, :showing_reblogs, :followed_by, :blocking, + :muting, :muting_notifications, :requested, :domain_blocking def id object.id.to_s end def following - instance_options[:relationships].following[object.id] || false + instance_options[:relationships].following[object.id] ? true : false + end + + def showing_reblogs + (instance_options[:relationships].following[object.id] || {})[:reblogs] || + (instance_options[:relationships].requested[object.id] || {})[:reblogs] || + false end def followed_by @@ -21,11 +27,15 @@ class REST::RelationshipSerializer < ActiveModel::Serializer end def muting - instance_options[:relationships].muting[object.id] || false + instance_options[:relationships].muting[object.id] ? true : false + end + + def muting_notifications + (instance_options[:relationships].muting[object.id] || {})[:notifications] || false end def requested - instance_options[:relationships].requested[object.id] || false + instance_options[:relationships].requested[object.id] ? true : false end def domain_blocking diff --git a/app/serializers/webfinger_serializer.rb b/app/serializers/webfinger_serializer.rb new file mode 100644 index 000000000..f80d12c02 --- /dev/null +++ b/app/serializers/webfinger_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class WebfingerSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :subject, :aliases, :links + + def subject + object.to_webfinger_s + end + + def aliases + [short_account_url(object), account_url(object)] + end + + def links + [ + { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) }, + { rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(object, format: 'atom') }, + { rel: 'self', type: 'application/activity+json', href: account_url(object) }, + { rel: 'salmon', href: api_salmon_url(object.id) }, + { rel: 'magic-public-key', href: "data:application/magic-public-key,#{object.magic_key}" }, + { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_follow_url}?acct={uri}" }, + ] + end +end diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb index b0c663d02..a289ceac4 100644 --- a/app/services/account_search_service.rb +++ b/app/services/account_search_service.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true class AccountSearchService < BaseService - attr_reader :query, :limit, :resolve, :account + attr_reader :query, :limit, :options, :account - def call(query, limit, resolve = false, account = nil) - @query = query - @limit = limit - @resolve = resolve + def call(query, limit, account = nil, options = {}) + @query = query + @limit = limit + @options = options @account = account search_service_results @@ -25,7 +25,7 @@ class AccountSearchService < BaseService end def resolving_non_matching_remote_account? - resolve && !exact_match && !domain_is_local? + options[:resolve] && !exact_match && !domain_is_local? end def search_results_and_exact_match @@ -58,12 +58,16 @@ class AccountSearchService < BaseService @_domain_is_local ||= TagManager.instance.local_domain?(query_domain) end + def search_from + options[:following] && account ? account.following : Account + end + def exact_match @_exact_match ||= begin if domain_is_local? - Account.find_local(query_username) + search_from.find_local(query_username) else - Account.find_remote(query_username, query_domain) + search_from.find_remote(query_username, query_domain) end end end @@ -79,7 +83,7 @@ class AccountSearchService < BaseService end def advanced_search_results - Account.advanced_search_for(terms_for_query, account, limit) + Account.advanced_search_for(terms_for_query, account, limit, options[:following]) end def simple_search_results diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb index db4d1b4bc..eb93329e9 100644 --- a/app/services/activitypub/process_collection_service.rb +++ b/app/services/activitypub/process_collection_service.rb @@ -3,7 +3,7 @@ class ActivityPub::ProcessCollectionService < BaseService include JsonLdHelper - def call(body, account, options = {}) + def call(body, account, **options) @account = account @json = Oj.load(body, mode: :strict) @options = options diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb index b1bff8962..f47d488f1 100644 --- a/app/services/authorize_follow_service.rb +++ b/app/services/authorize_follow_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class AuthorizeFollowService < BaseService - def call(source_account, target_account, options = {}) + def call(source_account, target_account, **options) if options[:skip_follow_request] follow_request = FollowRequest.new(account: source_account, target_account: target_account) else diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 974c586f2..59531a76c 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -13,7 +13,7 @@ class PostStatusService < BaseService # @option [Doorkeeper::Application] :application # @option [String] :idempotency Optional idempotency key # @return [Status] - def call(account, text, in_reply_to = nil, options = {}) + def call(account, text, in_reply_to = nil, **options) if options[:idempotency].present? existing_id = redis.get("idempotency:status:#{account.id}:#{options[:idempotency]}") return Status.find(existing_id) if existing_id diff --git a/app/services/process_feed_service.rb b/app/services/process_feed_service.rb index 60eff135e..30a9dd85e 100644 --- a/app/services/process_feed_service.rb +++ b/app/services/process_feed_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ProcessFeedService < BaseService - def call(body, account, options = {}) + def call(body, account, **options) @options = options xml = Nokogiri::XML(body) diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 7789bd441..e164c03ab 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -3,7 +3,7 @@ class RemoveStatusService < BaseService include StreamEntryRenderer - def call(status, options = {}) + def call(status, **options) @payload = Oj.dump(event: :delete, payload: status.id.to_s) @status = status @account = status.account diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 1ed3f0032..85ad94463 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -10,7 +10,7 @@ class SearchService < BaseService if url_query? results.merge!(remote_resource_results) unless remote_resource.nil? elsif query.present? - results[:accounts] = AccountSearchService.new.call(query, limit, resolve, account) + results[:accounts] = AccountSearchService.new.call(query, limit, account, resolve: resolve) results[:hashtags] = Tag.search_for(query.gsub(/\A#/, ''), limit) unless query.start_with?('@') end end diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index 5b37ba9ba..958b28cdc 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SuspendAccountService < BaseService - def call(account, options = {}) + def call(account, **options) @account = account @options = options diff --git a/app/views/well_known/webfinger/show.json.rabl b/app/views/well_known/webfinger/show.json.rabl deleted file mode 100644 index 762d1860d..000000000 --- a/app/views/well_known/webfinger/show.json.rabl +++ /dev/null @@ -1,18 +0,0 @@ -object @account - -node(:subject) { @canonical_account_uri } - -node(:aliases) do - [short_account_url(@account), account_url(@account)] -end - -node(:links) do - [ - { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(@account) }, - { rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }, - { rel: 'self', type: 'application/activity+json', href: account_url(@account) }, - { rel: 'salmon', href: api_salmon_url(@account.id) }, - { rel: 'magic-public-key', href: "data:application/magic-public-key,#{@magic_key}" }, - { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_follow_url}?acct={uri}" }, - ] -end diff --git a/app/views/well_known/webfinger/show.xml.ruby b/app/views/well_known/webfinger/show.xml.ruby index b0158b8bd..0c7289d6a 100644 --- a/app/views/well_known/webfinger/show.xml.ruby +++ b/app/views/well_known/webfinger/show.xml.ruby @@ -1,13 +1,13 @@ Nokogiri::XML::Builder.new do |xml| xml.XRD(xmlns: 'http://docs.oasis-open.org/ns/xri/xrd-1.0') do - xml.Subject @canonical_account_uri + xml.Subject @account.to_webfinger_s xml.Alias short_account_url(@account) xml.Alias account_url(@account) xml.Link(rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(@account)) xml.Link(rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(@account, format: 'atom')) xml.Link(rel: 'self', type: 'application/activity+json', href: account_url(@account)) xml.Link(rel: 'salmon', href: api_salmon_url(@account.id)) - xml.Link(rel: 'magic-public-key', href: "data:application/magic-public-key,#{@magic_key}") + xml.Link(rel: 'magic-public-key', href: "data:application/magic-public-key,#{@account.magic_key}") xml.Link(rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_follow_url}?acct={uri}") end end.to_xml diff --git a/app/workers/scheduler/feed_cleanup_scheduler.rb b/app/workers/scheduler/feed_cleanup_scheduler.rb index cfa2d31a4..23fa7672b 100644 --- a/app/workers/scheduler/feed_cleanup_scheduler.rb +++ b/app/workers/scheduler/feed_cleanup_scheduler.rb @@ -5,16 +5,30 @@ class Scheduler::FeedCleanupScheduler include Sidekiq::Worker def perform + clean_home_feeds! + clean_list_feeds! + end + + private + + def clean_home_feeds! + clean_feeds!(inactive_account_ids, :home) + end + + def clean_list_feeds! + clean_feeds!(inactive_list_ids, :list) + end + + def clean_feeds!(ids, type) reblogged_id_sets = {} - feedmanager = FeedManager.instance redis.pipelined do - inactive_user_ids.each do |account_id| - redis.del(feedmanager.key(:home, account_id)) - reblog_key = feedmanager.key(:home, account_id, 'reblogs') + ids.each do |feed_id| + redis.del(feed_manager.key(type, feed_id)) + reblog_key = feed_manager.key(type, feed_id, 'reblogs') # We collect a future for this: we don't block while getting # it, but we can iterate over it later. - reblogged_id_sets[account_id] = redis.zrange(reblog_key, 0, -1) + reblogged_id_sets[feed_id] = redis.zrange(reblog_key, 0, -1) redis.del(reblog_key) end end @@ -22,19 +36,25 @@ class Scheduler::FeedCleanupScheduler # Remove all of the reblog tracking keys we just removed the # references to. redis.pipelined do - reblogged_id_sets.each do |account_id, future| + reblogged_id_sets.each do |feed_id, future| future.value.each do |reblogged_id| - reblog_set_key = feedmanager.key(:home, account_id, "reblogs:#{reblogged_id}") + reblog_set_key = feed_manager.key(type, feed_id, "reblogs:#{reblogged_id}") redis.del(reblog_set_key) end end end end - private + def inactive_account_ids + @inactive_account_ids ||= User.confirmed.inactive.pluck(:account_id) + end + + def inactive_list_ids + List.where(account_id: inactive_account_ids).pluck(:id) + end - def inactive_user_ids - @inactive_user_ids ||= User.confirmed.inactive.pluck(:account_id) + def feed_manager + FeedManager.instance end def redis diff --git a/config/initializers/oj.rb b/config/initializers/oj.rb new file mode 100644 index 000000000..de3e17f2e --- /dev/null +++ b/config/initializers/oj.rb @@ -0,0 +1 @@ +Oj.default_options = { mode: :compat, time_format: :ruby, use_to_json: true } diff --git a/config/initializers/rabl_init.rb b/config/initializers/rabl_init.rb deleted file mode 100644 index 132a42144..000000000 --- a/config/initializers/rabl_init.rb +++ /dev/null @@ -1,7 +0,0 @@ -Rabl.configure do |config| - config.json_engine = Oj - config.cache_all_output = false - config.cache_sources = Rails.env.production? - config.include_json_root = false - config.view_paths = [Rails.root.join('app/views')] -end diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 19ee154ab..353ad3a54 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -277,6 +277,9 @@ pl: deletion: desc_html: Pozwól każdemu na usunięcie konta title: Możliwość usunięcia + min_invite_role: + disabled: Nikt + title: Kto może zapraszać użytkowników open: desc_html: Pozwól każdemu na założenie konta title: Otwarta rejestracja diff --git a/config/locales/simple_form.zh-CN.yml b/config/locales/simple_form.zh-CN.yml index 23a1f59da..b1888f4e3 100644 --- a/config/locales/simple_form.zh-CN.yml +++ b/config/locales/simple_form.zh-CN.yml @@ -26,10 +26,12 @@ zh-CN: data: 数据文件 display_name: 昵称 email: 电子邮件地址 + expires_in: 失效时间 filtered_languages: 语言过滤 header: 个人资料页横幅图片 locale: 语言 locked: 保护你的帐户(锁嘟) + max_uses: 最大使用次数 new_password: 新密码 note: 简介 otp_attempt: 双重认证代码 diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index ec913113f..04d8b1758 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -116,7 +116,7 @@ zh-CN: roles: admin: 管理员 moderator: 协管 - user: 用户 + user: 普通用户 salmon_url: Salmon URL search: 搜索 shared_inbox_url: 公用收件箱(Shared Inbox)URL @@ -133,6 +133,32 @@ zh-CN: unsubscribe: 取消订阅 username: 用户名 web: 站内页面 + action_logs: + actions: + confirm_user: "%{name} 确认了用户 %{target} 的电子邮件地址" + create_custom_emoji: "%{name} 添加了新的自定义表情 %{target}" + create_domain_block: "%{name} 屏蔽了域名 %{target}" + create_email_domain_block: "%{name} 屏蔽了电子邮件域名 %{target}" + demote_user: "%{name} 对用户 %{target} 进行了降任操作" + destroy_domain_block: "%{name} 解除了对域名 %{target} 的屏蔽" + destroy_email_domain_block: "%{name} 解除了对电子邮件域名 %{target} 的屏蔽" + destroy_status: "%{name} 删除了 %{target} 的嘟文" + disable_2fa_user: "%{name} 停用了用户 %{target} 的双重认证" + disable_custom_emoji: "%{name} 停用了自定义表情 %{target}" + disable_user: "%{name} 将用户 %{target} 设置为禁止登录" + enable_custom_emoji: "%{name} 启用了自定义表情 %{target}" + enable_user: "%{name} 将用户 %{target} 设置为允许登录" + memorialize_account: "%{name} 将 %{target} 的帐户设置为追悼帐户" + promote_user: "%{name} 对用户 %{target} 进行了升任操作" + reset_password_user: "%{name} 重置了用户 %{target} 的密码" + resolve_report: "%{name} 处理了举报 %{target}" + silence_account: "%{name} 隐藏了用户 %{target}" + suspend_account: "%{name} 封禁了用户 %{target}" + unsilence_account: "%{name} 解除了用户 %{target} 的隐藏状态" + unsuspend_account: "%{name} 解除了用户 %{target} 的封禁状态" + update_custom_emoji: "%{name} 更新了自定义表情 %{target}" + update_status: "%{name} 刷新了 %{target} 的嘟文" + title: 管理日志 custom_emojis: copied_msg: 成功将表情复制到本地 copy: 复制 @@ -156,14 +182,14 @@ zh-CN: unlisted: 已隐藏 update_failed_msg: 表情更新失败! updated_msg: 表情更新成功! - upload: 上传 + upload: 上传新表情 domain_blocks: add_new: 添加新条目 created_msg: 正在进行域名屏蔽 destroyed_msg: 域名屏蔽已撤销 domain: 域名 new: - create: 添加域名屏蔽 + create: 添加屏蔽 hint: 域名屏蔽不会阻止该域名下的帐户进入本站的数据库,但是会对来自这个域名的帐户自动进行预先设置的管理操作。 severity: desc_html: 选择<strong>自动隐藏</strong>会将该域名下帐户发送的嘟文设置为仅关注者可见;选择<strong>自动封禁</strong>会将该域名下帐户发送的嘟文、媒体文件以及个人资料数据从本实例上删除;如果你只是想拒绝接收来自该域名的任何媒体文件,请选择<strong>无</strong>。 @@ -194,7 +220,7 @@ zh-CN: destroyed_msg: 电子邮件域名屏蔽删除成功 domain: 域名 new: - create: 添加屏蔽 + create: 添加域名 title: 添加电子邮件域名屏蔽 title: 电子邮件域名屏蔽 instances: @@ -203,6 +229,13 @@ zh-CN: reset: 重置 search: 搜索 title: 已知实例 + invites: + filter: + all: 全部 + available: 可用 + expired: 已失效 + title: 筛选 + title: 邀请用户 reports: action_taken_by: 操作执行者 are_you_sure: 你确定吗? @@ -241,6 +274,9 @@ zh-CN: deletion: desc_html: 允许所有人删除自己的帐户 title: 开放删除帐户权限 + min_invite_role: + disabled: 没有人 + title: 允许发送邀请的用户组 open: desc_html: 允许任何人建立一个帐户 title: 开放注册 @@ -314,6 +350,8 @@ zh-CN: invalid_reset_password_token: 密码重置令牌无效或已过期。请重新发起重置密码请求。 login: 登录 logout: 登出 + migrate_account: 迁移到另一个帐户 + migrate_account_html: 如果你希望引导其他人关注另一个帐户,请<a href="%{path}">点击这里设置</a>。 register: 注册 resend_confirmation: 重新发送确认邮件 reset_password: 重置密码 @@ -394,12 +432,37 @@ zh-CN: muting: 隐藏列表 upload: 上传 in_memoriam_html: 谨此悼念。 + invites: + delete: 停用 + expired: 已失效 + expires_in: + '1800': 30 分钟后 + '21600': 6 小时后 + '3600': 1 小时后 + '43200': 12 小时后 + '86400': 1 天后 + expires_in_prompt: 永不过期 + generate: 生成邀请链接 + max_uses: "%{count} 次" + max_uses_prompt: 无限制 + prompt: 生成可供分享的链接以便邀请他人注册本实例 + table: + expires_at: 失效时间 + uses: 已使用次数 + title: 邀请用户 landing_strip_html: "<strong>%{name}</strong> 是一位来自 %{link_to_root_path} 的用户。如果你想关注他们或者与他们互动,你需要在任意一个 Mastodon 实例或与其兼容的网站上拥有一个帐户。" landing_strip_signup_html: 还没有这种帐户?你可以<a href="%{sign_up_path}">在本站注册一个</a>。 media_attachments: validations: images_and_video: 无法在嘟文中同时插入视频和图片 too_many: 最多只能添加 4 张图片 + migrations: + acct: 新帐户的 用户名@域名 + currently_redirecting: 目前你的个人资料页显示的新帐户是: + proceed: 保存 + updated_msg: 帐户迁移设置更新成功! + moderation: + title: 管理 notification_mailer: digest: body: 自从你最后一次(时间是%{since})登录 %{instance} 以来,你错过了这些嘟嘟滴滴: @@ -510,6 +573,7 @@ zh-CN: export: 导出 followers: 授权的关注者 import: 导入 + migrate: 帐户迁移 notifications: 通知 preferences: 首选项 settings: 设置 diff --git a/lib/mastodon/migration_helpers.rb b/lib/mastodon/migration_helpers.rb index 2b5a6cd42..6f6f99f63 100644 --- a/lib/mastodon/migration_helpers.rb +++ b/lib/mastodon/migration_helpers.rb @@ -99,7 +99,7 @@ module Mastodon # default - The default value for the column. # null - When set to `true` the column will allow NULL values. # The default is to not allow NULL values. - def add_timestamps_with_timezone(table_name, options = {}) + def add_timestamps_with_timezone(table_name, **options) options[:null] = false if options[:null].nil? [:created_at, :updated_at].each do |column_name| @@ -134,7 +134,7 @@ module Mastodon # add_concurrent_index :users, :some_column # # See Rails' `add_index` for more info on the available arguments. - def add_concurrent_index(table_name, column_name, options = {}) + def add_concurrent_index(table_name, column_name, **options) if transaction_open? raise 'add_concurrent_index can not be run inside a transaction, ' \ 'you can disable transactions by calling disable_ddl_transaction! ' \ @@ -158,7 +158,7 @@ module Mastodon # remove_concurrent_index :users, :some_column # # See Rails' `remove_index` for more info on the available arguments. - def remove_concurrent_index(table_name, column_name, options = {}) + def remove_concurrent_index(table_name, column_name, **options) if transaction_open? raise 'remove_concurrent_index can not be run inside a transaction, ' \ 'you can disable transactions by calling disable_ddl_transaction! ' \ @@ -182,7 +182,7 @@ module Mastodon # remove_concurrent_index :users, "index_X_by_Y" # # See Rails' `remove_index` for more info on the available arguments. - def remove_concurrent_index_by_name(table_name, index_name, options = {}) + def remove_concurrent_index_by_name(table_name, index_name, **options) if transaction_open? raise 'remove_concurrent_index_by_name can not be run inside a transaction, ' \ 'you can disable transactions by calling disable_ddl_transaction! ' \ diff --git a/lib/mastodon/version.rb b/lib/mastodon/version.rb index f10ace13e..3904b7593 100644 --- a/lib/mastodon/version.rb +++ b/lib/mastodon/version.rb @@ -9,7 +9,7 @@ module Mastodon end def minor - 0 + 1 end def patch @@ -17,7 +17,7 @@ module Mastodon end def pre - nil + 'rc1' end def flags diff --git a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb index f25b86ac1..508415fc8 100644 --- a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb @@ -32,7 +32,7 @@ describe Api::V1::Accounts::RelationshipsController do json = body_as_json expect(json).to be_a Enumerable - expect(json.first[:following]).to be_truthy + expect(json.first[:following]).to be true expect(json.first[:followed_by]).to be false end end @@ -51,7 +51,8 @@ describe Api::V1::Accounts::RelationshipsController do expect(json).to be_a Enumerable expect(json.first[:id]).to eq simon.id.to_s - expect(json.first[:following]).to be_truthy + expect(json.first[:following]).to be true + expect(json.first[:showing_reblogs]).to be true expect(json.first[:followed_by]).to be false expect(json.first[:muting]).to be false expect(json.first[:requested]).to be false @@ -59,6 +60,7 @@ describe Api::V1::Accounts::RelationshipsController do expect(json.second[:id]).to eq lewis.id.to_s expect(json.second[:following]).to be false + expect(json.second[:showing_reblogs]).to be false expect(json.second[:followed_by]).to be true expect(json.second[:muting]).to be false expect(json.second[:requested]).to be false diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index f3b879421..053c53e5a 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -31,10 +31,10 @@ RSpec.describe Api::V1::AccountsController, type: :controller do expect(response).to have_http_status(:success) end - it 'returns JSON with following=truthy and requested=false' do + it 'returns JSON with following=true and requested=false' do json = body_as_json - expect(json[:following]).to be_truthy + expect(json[:following]).to be true expect(json[:requested]).to be false end @@ -50,11 +50,11 @@ RSpec.describe Api::V1::AccountsController, type: :controller do expect(response).to have_http_status(:success) end - it 'returns JSON with following=false and requested=truthy' do + it 'returns JSON with following=false and requested=true' do json = body_as_json expect(json[:following]).to be false - expect(json[:requested]).to be_truthy + expect(json[:requested]).to be true end it 'creates a follow request relation between user and target user' do diff --git a/spec/services/account_search_service_spec.rb b/spec/services/account_search_service_spec.rb index 25bb56bca..4685e619f 100644 --- a/spec/services/account_search_service_spec.rb +++ b/spec/services/account_search_service_spec.rb @@ -72,7 +72,7 @@ describe AccountSearchService do describe 'and there is no account provided' do it 'uses search_for to find matches' do allow(Account).to receive(:search_for) - subject.call('two@example.com', 10, false, nil) + subject.call('two@example.com', 10, nil, resolve: false) expect(Account).to have_received(:search_for).with('two example.com', 10) end @@ -82,9 +82,9 @@ describe AccountSearchService do it 'uses advanced_search_for to find matches' do account = Fabricate(:account) allow(Account).to receive(:advanced_search_for) - subject.call('two@example.com', 10, false, account) + subject.call('two@example.com', 10, account, resolve: false) - expect(Account).to have_received(:advanced_search_for).with('two example.com', account, 10) + expect(Account).to have_received(:advanced_search_for).with('two example.com', account, 10, nil) end end end @@ -125,7 +125,7 @@ describe AccountSearchService do service = double(call: nil) allow(ResolveRemoteAccountService).to receive(:new).and_return(service) - results = subject.call('newuser@remote.com', 10, true) + results = subject.call('newuser@remote.com', 10, nil, resolve: true) expect(service).to have_received(:call).with('newuser@remote.com') end @@ -133,7 +133,7 @@ describe AccountSearchService do service = double(call: nil) allow(ResolveRemoteAccountService).to receive(:new).and_return(service) - results = subject.call('newuser@remote.com', 10, false) + results = subject.call('newuser@remote.com', 10, nil, resolve: false) expect(service).not_to have_received(:call) end end diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 91902ff69..92fbc73cd 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -182,7 +182,7 @@ RSpec.describe PostStatusService do expect(status2.id).to eq status1.id end - def create_status_with_options(options = {}) + def create_status_with_options(**options) subject.call(Fabricate(:account), 'test', nil, options) end end diff --git a/spec/services/search_service_spec.rb b/spec/services/search_service_spec.rb index 00475c699..3ffcc389b 100644 --- a/spec/services/search_service_spec.rb +++ b/spec/services/search_service_spec.rb @@ -68,7 +68,7 @@ describe SearchService do allow(AccountSearchService).to receive(:new).and_return(service) results = subject.call(query, 10) - expect(service).to have_received(:call).with(query, 10, false, nil) + expect(service).to have_received(:call).with(query, 10, nil, resolve: false) expect(results).to eq empty_results.merge(accounts: [account]) end end |