From 614a6ca82a90f9c9328dcd7fa621474592214efe Mon Sep 17 00:00:00 2001 From: noellabo Date: Sat, 5 Sep 2020 16:33:17 +0900 Subject: [PATCH 1/9] Add feature circle Squashed commit of the following: commit 7b2ba61c4841e23081552fb79270e4e430dd1fe0 Author: noellabo Date: Sat Sep 5 16:03:52 2020 +0900 Add the ability to change to a new circle by replying to a circle commit 7013a228c65c7bd147885de458b50095f3c24334 Author: noellabo Date: Sat Sep 5 16:10:57 2020 +0900 fixup! add-limited-visibility-icon-to-status commit 679aa8a7f9bef42ee5d0b326d9ae4925a1999939 Author: noellabo Date: Sat Sep 5 15:12:53 2020 +0900 Fix 14666 commit b3addd8220d8bb3512ff345b32ca83c714dadd2a Author: noellabo Date: Sat Sep 5 11:44:12 2020 +0900 Add Japanese translation for circle commit b7f4b773a0cd554084d5ad6a5923adb06b3acfc4 Author: noellabo Date: Sat Sep 5 11:40:12 2020 +0900 Squashed commit of the following: commit b85a4685b27c49462288aba5f38723b91e936c4a Author: noellabo Date: Sat Sep 5 10:50:03 2020 +0900 Changed to remove restrictions on privacy options and allow users to switch circles when replying commit 0a8c0140c73d7c5333e4f8017964adb5061a7cf1 Author: noellabo Date: Sat Sep 5 09:33:07 2020 +0900 Change limited visibility icon commit b64adf19788d828249408454ec6afa9beb3d4872 Author: noellabo Date: Mon Aug 31 06:50:56 2020 +0900 Fix a change to limited-visibility-bearcaps replies commit ed361405b5e38857a2f42b0515a599ddcdd412cf Author: noellabo Date: Thu Aug 27 15:53:18 2020 +0900 Fix composer text when change visibility commit 4da3adddb6ffde43070d743e34c5b56e06579b30 Author: noellabo Date: Sat Aug 22 22:34:23 2020 +0900 Fix wrong circle_id when changing visibility commit 752d7fc2a3c9e34fab9993d767f83c6eae7ba55a Author: noellabo Date: Sun Aug 9 13:12:51 2020 +0900 Add circle reply and redraft commit 5978bc04a24695edce6717bda89dcf6f861ef2c4 Author: noellabo Date: Mon Jul 27 01:07:52 2020 +0900 Fix remove unused props commit 7970f69676c24b4aa9385fee8b1635c46ba52fcd Author: noellabo Date: Sun Jul 26 21:17:07 2020 +0900 Separate circle choice from privacy commit 36f6a684c0b0c895d4d0f1b9d09b05c91b104666 Author: noellabo Date: Thu Jul 23 10:54:25 2020 +0900 Add UI for posting to circles commit 7ef48003c1407275663dd603b124d292db2aa93a Author: noellabo Date: Fri Jul 24 12:55:10 2020 +0900 Fix silent mention by circle commit 7a1caed49333c3d3241301afb77639cdf1cabdc0 Author: noellabo Date: Sat Sep 5 11:38:10 2020 +0900 Squashed commit of the following: commit dca71fab86c830932ca760b7d8b3f89cc25c453e Author: noellabo Date: Sat Sep 5 09:31:26 2020 +0900 Revert "Add focus setting when opening the circle column" This reverts commit 3a93ac99312a13b68b7edc2b81313fb0ffb7bcdc. commit 0a1bc8307bb699c7eb3024072ce14a440df1fc87 Author: noellabo Date: Sat Sep 5 09:31:11 2020 +0900 Change limited visibility icon commit 9784f8b562f6592e9d9190ca29d2b2e870006d10 Author: noellabo Date: Thu Aug 13 21:52:07 2020 +0900 Add focus setting when opening the circle column commit a84f680c167fab9276550850c60f9108d251144e Author: noellabo Date: Thu Aug 13 15:55:27 2020 +0900 Fix message commit e3f11c4adac57b6e6a15c981ed6f4721a1634212 Author: noellabo Date: Mon Jul 27 01:01:23 2020 +0900 Fix light-theme commit d7d96eda5b86d3e3f654ce79888e7cf5aa535db5 Author: noellabo Date: Sun Jul 26 21:50:56 2020 +0900 Fix circles loading in share page and followers search commit 10b821f7b8c0a87cea3df51f09deeadc2cb40b32 Author: noellabo Date: Fri Jul 24 14:08:00 2020 +0900 Refactor list items commit e020072915572ce409039ccf799d08f8d8b5b393 Author: noellabo Date: Thu Jul 23 20:15:38 2020 +0900 Fixed a bug that circle name change is not reflected in the list commit 735bc41161b4c09a8dafe2c0064096b3ca79f2a0 Author: noellabo Date: Wed Jul 22 08:49:47 2020 +0900 Add UI for managing circle members commit d7c3145b8fa84be0631bf7f41bb229f3e6d03ff1 Author: noellabo Date: Wed Jul 22 07:34:52 2020 +0900 Add the followers option to AccountSearchSercive commit 65e2b0c4299b72ede440b50089c1bd6afa6c9c05 Author: noellabo Date: Wed Jul 22 07:05:56 2020 +0900 Add CircleSerializer commit a639e1803abf5590068846dbe98bc5edfaa2ad82 Author: noellabo Date: Sat Sep 5 11:37:30 2020 +0900 Squashed commit of the following: commit 9cb3fb9d980e3ee066083076f508c5ab1447176a Author: noellabo Date: Sat Sep 5 07:15:19 2020 +0900 Move the link to the mention list to the menu commit b32dd87b43f4e09b8e2c437f1fb5d3ebd6221215 Author: noellabo Date: Sat Sep 5 00:56:12 2020 +0900 Change limited visibility icon commit 8db0d024119d1c2cef8de849f2501496a166a2dd Author: noellabo Date: Tue Sep 1 01:42:13 2020 +0900 Fix to disallow getting the list of mentions in limited replies commit 490a9d65a59a3dd0d86e81f6780e879dc4313dff Author: noellabo Date: Fri Jul 24 11:36:24 2020 +0900 Add column to list mentioned accounts of limited status commit 62a423ac2729c16f26fafe111f257bc373218df2 Author: noellabo Date: Thu Jul 23 13:30:17 2020 +0900 Fix visibility compatibility more commit a5cfa54b259054f41e89037f299fa928a2361818 Author: noellabo Date: Mon Jul 20 05:39:49 2020 +0900 Fix visibility compatibility commit 7900ca5650c77565b86ddc594a221dfa3b5321b4 Author: noellabo Date: Mon Jul 20 02:01:27 2020 +0900 Add limited visibility icon to status commit 66b83965ef068e9ee8c940249c68bcbde15731fe Author: Eugen Rochko Date: Wed Aug 26 03:16:47 2020 +0200 Add conversation-based forwarding for limited visibility statuses through bearcaps commit 561abc65e0ace89318b3952047025b8d98515fbb Author: Eugen Rochko Date: Sun Jul 19 02:05:16 2020 +0200 Add REST API for managing and posting to circles Circles are the conceptual opposite of lists. A list is a subdivision of your follows, a circle is a subdivision of your followers. Posting to a circle means making content available to only some of your followers. Circles have been internally supported in Mastodon for the purposes of federation since #8950, this adds the REST API necessary for making use of them in Mastodon itsef. (cherry picked from commit 6e528cdb070d2d9912bf67613210f37bf76e65d9) --- .../activitypub/contexts_controller.rb | 16 + .../api/v1/accounts/circles_controller.rb | 18 + .../api/v1/accounts/search_controller.rb | 1 + .../api/v1/circles/accounts_controller.rb | 93 +++++ app/controllers/api/v1/circles_controller.rb | 73 ++++ .../mentioned_by_accounts_controller.rb | 71 ++++ app/controllers/api/v1/statuses_controller.rb | 9 + app/controllers/concerns/cache_concern.rb | 2 +- app/controllers/statuses_controller.rb | 7 +- app/helpers/application_helper.rb | 4 +- app/helpers/jsonld_helper.rb | 11 +- app/javascript/mastodon/actions/circles.js | 372 ++++++++++++++++++ app/javascript/mastodon/actions/compose.js | 9 + .../mastodon/actions/importer/normalizer.js | 2 + .../mastodon/actions/interactions.js | 39 ++ app/javascript/mastodon/actions/statuses.js | 6 +- app/javascript/mastodon/components/status.js | 13 +- .../mastodon/components/status_action_bar.js | 12 + .../mastodon/containers/compose_container.js | 2 + .../mastodon/containers/mastodon.js | 2 + .../mastodon/containers/status_container.js | 4 + .../features/account/components/header.js | 8 + .../account_timeline/components/header.js | 6 + .../containers/header_container.js | 6 + .../circle_adder/components/account.js | 43 ++ .../circle_adder/components/circle.js | 69 ++++ .../mastodon/features/circle_adder/index.js | 73 ++++ .../circle_editor/components/account.js | 77 ++++ .../components/edit_circle_form.js | 70 ++++ .../circle_editor/components/search.js | 76 ++++ .../mastodon/features/circle_editor/index.js | 79 ++++ .../features/circles/components/circle.js | 58 +++ .../circles/components/new_circle_form.js | 78 ++++ .../mastodon/features/circles/index.js | 84 ++++ .../features/compose/components/action_bar.js | 2 + .../compose/components/circle_dropdown.js | 80 ++++ .../compose/components/compose_form.js | 10 +- .../compose/components/privacy_dropdown.js | 3 + .../containers/circle_dropdown_container.js | 30 ++ .../containers/compose_form_container.js | 1 + .../compose/containers/warning_container.js | 8 +- .../features/getting_started/index.js | 4 +- .../mastodon/features/mentions/index.js | 77 ++++ .../features/status/components/action_bar.js | 12 + .../status/components/detailed_status.js | 24 +- .../mastodon/features/status/index.js | 5 + .../features/ui/components/boost_modal.js | 2 + .../features/ui/components/modal_root.js | 6 +- .../ui/components/navigation_panel.js | 1 + app/javascript/mastodon/features/ui/index.js | 4 + .../features/ui/util/async-components.js | 16 + app/javascript/mastodon/locales/en.json | 21 + app/javascript/mastodon/locales/ja.json | 21 + .../mastodon/reducers/circle_adder.js | 47 +++ .../mastodon/reducers/circle_editor.js | 96 +++++ app/javascript/mastodon/reducers/circles.js | 37 ++ app/javascript/mastodon/reducers/compose.js | 57 ++- app/javascript/mastodon/reducers/index.js | 6 + .../mastodon/reducers/user_lists.js | 4 + .../styles/mastodon-light/diff.scss | 8 +- .../styles/mastodon/components.scss | 189 +++++++++ app/javascript/styles/mastodon/rtl.scss | 14 + app/lib/activitypub/activity/create.rb | 57 ++- app/lib/activitypub/tag_manager.rb | 22 +- app/models/account.rb | 125 +++--- app/models/circle.rb | 22 ++ app/models/circle_account.rb | 28 ++ app/models/concerns/account_associations.rb | 3 + app/models/conversation.rb | 36 +- app/models/mention.rb | 2 + app/models/status.rb | 46 ++- app/models/status_capability_token.rb | 25 ++ app/policies/status_policy.rb | 10 + .../activitypub/activity_presenter.rb | 2 + .../activitypub/context_serializer.rb | 19 + .../activitypub/note_serializer.rb | 8 +- app/serializers/rest/circle_serializer.rb | 9 + app/serializers/rest/status_serializer.rb | 24 +- app/services/account_search_service.rb | 13 +- app/services/concerns/payloadable.rb | 2 +- app/services/post_status_service.rb | 32 +- app/services/process_mentions_service.rb | 21 +- .../activitypub/distribution_worker.rb | 46 ++- .../forward_distribution_worker.rb | 27 ++ config/initializers/doorkeeper.rb | 2 + config/locales/en.yml | 3 + config/locales/ja.yml | 3 + config/routes.rb | 7 + db/migrate/20200718225713_create_circles.rb | 10 + .../20200718225817_create_circle_accounts.rb | 14 + ...5232828_create_status_capability_tokens.rb | 10 + ...27204602_add_inbox_url_to_conversations.rb | 7 + ...05543_conversation_ids_to_timestamp_ids.rb | 15 + db/schema.rb | 39 +- spec/controllers/statuses_controller_spec.rb | 30 +- spec/fabricators/circle_account_fabricator.rb | 5 + spec/fabricators/circle_fabricator.rb | 4 + .../status_capability_token_fabricator.rb | 2 + spec/lib/activitypub/activity/create_spec.rb | 62 ++- spec/models/circle_account_spec.rb | 4 + spec/models/circle_spec.rb | 4 + spec/models/status_capability_token_spec.rb | 4 + spec/services/post_status_service_spec.rb | 22 +- .../activitypub/distribution_worker_spec.rb | 36 ++ 104 files changed, 2963 insertions(+), 177 deletions(-) create mode 100644 app/controllers/activitypub/contexts_controller.rb create mode 100644 app/controllers/api/v1/accounts/circles_controller.rb create mode 100644 app/controllers/api/v1/circles/accounts_controller.rb create mode 100644 app/controllers/api/v1/circles_controller.rb create mode 100644 app/controllers/api/v1/statuses/mentioned_by_accounts_controller.rb create mode 100644 app/javascript/mastodon/actions/circles.js create mode 100644 app/javascript/mastodon/features/circle_adder/components/account.js create mode 100644 app/javascript/mastodon/features/circle_adder/components/circle.js create mode 100644 app/javascript/mastodon/features/circle_adder/index.js create mode 100644 app/javascript/mastodon/features/circle_editor/components/account.js create mode 100644 app/javascript/mastodon/features/circle_editor/components/edit_circle_form.js create mode 100644 app/javascript/mastodon/features/circle_editor/components/search.js create mode 100644 app/javascript/mastodon/features/circle_editor/index.js create mode 100644 app/javascript/mastodon/features/circles/components/circle.js create mode 100644 app/javascript/mastodon/features/circles/components/new_circle_form.js create mode 100644 app/javascript/mastodon/features/circles/index.js create mode 100644 app/javascript/mastodon/features/compose/components/circle_dropdown.js create mode 100644 app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js create mode 100644 app/javascript/mastodon/features/mentions/index.js create mode 100644 app/javascript/mastodon/reducers/circle_adder.js create mode 100644 app/javascript/mastodon/reducers/circle_editor.js create mode 100644 app/javascript/mastodon/reducers/circles.js create mode 100644 app/models/circle.rb create mode 100644 app/models/circle_account.rb create mode 100644 app/models/status_capability_token.rb create mode 100644 app/serializers/activitypub/context_serializer.rb create mode 100644 app/serializers/rest/circle_serializer.rb create mode 100644 app/workers/activitypub/forward_distribution_worker.rb create mode 100644 db/migrate/20200718225713_create_circles.rb create mode 100644 db/migrate/20200718225817_create_circle_accounts.rb create mode 100644 db/migrate/20200825232828_create_status_capability_tokens.rb create mode 100644 db/migrate/20200827204602_add_inbox_url_to_conversations.rb create mode 100644 db/migrate/20200827205543_conversation_ids_to_timestamp_ids.rb create mode 100644 spec/fabricators/circle_account_fabricator.rb create mode 100644 spec/fabricators/circle_fabricator.rb create mode 100644 spec/fabricators/status_capability_token_fabricator.rb create mode 100644 spec/models/circle_account_spec.rb create mode 100644 spec/models/circle_spec.rb create mode 100644 spec/models/status_capability_token_spec.rb diff --git a/app/controllers/activitypub/contexts_controller.rb b/app/controllers/activitypub/contexts_controller.rb new file mode 100644 index 00000000000000..0d30349899444f --- /dev/null +++ b/app/controllers/activitypub/contexts_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ActivityPub::ContextsController < ActivityPub::BaseController + before_action :set_conversation + + def show + expires_in 3.minutes, public: public_fetch_mode? + render_with_cache json: @conversation, serializer: ActivityPub::ContextSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json' + end + + private + + def set_conversation + @conversation = Conversation.local.find(params[:id]) + end +end diff --git a/app/controllers/api/v1/accounts/circles_controller.rb b/app/controllers/api/v1/accounts/circles_controller.rb new file mode 100644 index 00000000000000..1a4e8b9c55659e --- /dev/null +++ b/app/controllers/api/v1/accounts/circles_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::CirclesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:circles' } + before_action :require_user! + before_action :set_account + + def index + @circles = @account.circles.where(account: current_account) + render json: @circles, each_serializer: REST::CircleSerializer + end + + private + + def set_account + @account = Account.find(params[:account_id]) + end +end diff --git a/app/controllers/api/v1/accounts/search_controller.rb b/app/controllers/api/v1/accounts/search_controller.rb index 3061fcb7e715c1..3c9a35cb31d458 100644 --- a/app/controllers/api/v1/accounts/search_controller.rb +++ b/app/controllers/api/v1/accounts/search_controller.rb @@ -17,6 +17,7 @@ def account_search current_account, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT), resolve: truthy_param?(:resolve), + followers: truthy_param?(:followers), following: truthy_param?(:following), offset: params[:offset] ) diff --git a/app/controllers/api/v1/circles/accounts_controller.rb b/app/controllers/api/v1/circles/accounts_controller.rb new file mode 100644 index 00000000000000..e0a67dbdfc2a43 --- /dev/null +++ b/app/controllers/api/v1/circles/accounts_controller.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Api::V1::Circles::AccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:circles' }, only: [:show] + before_action -> { doorkeeper_authorize! :write, :'write:circles' }, except: [:show] + + before_action :require_user! + before_action :set_circle + + after_action :insert_pagination_headers, only: :show + + def show + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + def create + ApplicationRecord.transaction do + circle_accounts.each do |account| + @circle.accounts << account + end + end + + render_empty + end + + def destroy + CircleAccount.where(circle: @circle, account_id: account_ids).destroy_all + render_empty + end + + private + + def set_circle + @circle = current_account.owned_circles.find(params[:circle_id]) + end + + def load_accounts + if unlimited? + @circle.accounts.includes(:account_stat).all + else + @circle.accounts.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + end + end + + def circle_accounts + Account.find(account_ids) + end + + def account_ids + Array(resource_params[:account_ids]) + end + + def resource_params + params.permit(account_ids: []) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + return if unlimited? + + api_v1_circle_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + return if unlimited? + + api_v1_circle_accounts_url(pagination_params(since_id: pagination_since_id)) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.id + end + + def pagination_since_id + @accounts.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end + + def unlimited? + params[:limit] == '0' + end +end diff --git a/app/controllers/api/v1/circles_controller.rb b/app/controllers/api/v1/circles_controller.rb new file mode 100644 index 00000000000000..e0bdc551866b4b --- /dev/null +++ b/app/controllers/api/v1/circles_controller.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +class Api::V1::CirclesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:circles' }, only: [:index, :show] + before_action -> { doorkeeper_authorize! :write, :'write:circles' }, except: [:index, :show] + + before_action :require_user! + before_action :set_circle, except: [:index, :create] + + after_action :insert_pagination_headers, only: :index + + def index + @circles = current_account.owned_circles.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) + render json: @circles, each_serializer: REST::CircleSerializer + end + + def show + render json: @circle, serializer: REST::CircleSerializer + end + + def create + @circle = current_account.owned_circles.create!(circle_params) + render json: @circle, serializer: REST::CircleSerializer + end + + def update + @circle.update!(circle_params) + render json: @circle, serializer: REST::CircleSerializer + end + + def destroy + @circle.destroy! + render_empty + end + + private + + def set_circle + @circle = current_account.owned_circles.find(params[:id]) + end + + def circle_params + params.permit(:title) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_circles_url(pagination_params(max_id: pagination_max_id)) if records_continue? + end + + def prev_path + api_v1_circles_url(pagination_params(since_id: pagination_since_id)) unless @circles.empty? + end + + def pagination_max_id + @circles.last.id + end + + def pagination_since_id + @circles.first.id + end + + def records_continue? + @circles.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/statuses/mentioned_by_accounts_controller.rb b/app/controllers/api/v1/statuses/mentioned_by_accounts_controller.rb new file mode 100644 index 00000000000000..e36a7dfd62cce8 --- /dev/null +++ b/app/controllers/api/v1/statuses/mentioned_by_accounts_controller.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::MentionedByAccountsController < Api::BaseController + include Authorization + + before_action -> { doorkeeper_authorize! :read, :'read:accounts' } + before_action :set_status + after_action :insert_pagination_headers + + def index + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def load_accounts + scope = default_accounts + scope.merge(paginated_mentions).to_a + end + + def default_accounts + Account + .includes(:mentions, :account_stat) + .references(:mentions) + .where(mentions: { status_id: @status.id, silent: true }) + end + + def paginated_mentions + Mention.paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + api_v1_status_mentioned_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_status_mentioned_by_index_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.mentions.last.id + end + + def pagination_since_id + @accounts.first.mentions.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show_mentions? + rescue Mastodon::NotPermittedError + not_found + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 106fc8224e2876..eb828e9420ffca 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -8,6 +8,7 @@ class Api::V1::StatusesController < Api::BaseController before_action :require_user!, except: [:show, :context] before_action :set_status, only: [:show, :context] before_action :set_thread, only: [:create] + before_action :set_circle, only: [:create] override_rate_limit_headers :create, family: :statuses @@ -38,6 +39,7 @@ def create @status = PostStatusService.new.call(current_user.account, text: status_params[:status], thread: @thread, + circle: @circle, media_ids: status_params[:media_ids], sensitive: status_params[:sensitive], spoiler_text: status_params[:spoiler_text], @@ -77,10 +79,17 @@ def set_thread render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 end + def set_circle + @circle = status_params[:circle_id].blank? ? nil : current_account.owned_circles.find(status_params[:circle_id]) + rescue ActiveRecord::RecordNotFound + render json: { error: I18n.t('statuses.errors.circle_not_found') }, status: 404 + end + def status_params params.permit( :status, :in_reply_to_id, + :circle_id, :sensitive, :spoiler_text, :visibility, diff --git a/app/controllers/concerns/cache_concern.rb b/app/controllers/concerns/cache_concern.rb index 05e431b19ac56f..d54b7a02a49066 100644 --- a/app/controllers/concerns/cache_concern.rb +++ b/app/controllers/concerns/cache_concern.rb @@ -25,7 +25,7 @@ def render_with_cache(**options) end def set_cache_headers - response.headers['Vary'] = public_fetch_mode? ? 'Accept' : 'Accept, Signature' + response.headers['Vary'] = public_fetch_mode? ? 'Accept, Authorization' : 'Accept, Signature, Authorization' end def cache_collection(raw, klass) diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index c52170d08188ac..33b8024368c899 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -65,7 +65,12 @@ def set_link_headers def set_status @status = @account.statuses.find(params[:id]) - authorize @status, :show? + + if request.authorization.present? && request.authorization.match(/^Bearer /i) + raise Mastodon::NotPermittedError unless @status.capability_tokens.find_by(token: request.authorization.gsub(/^Bearer /i, '')) + else + authorize @status, :show? + end rescue Mastodon::NotPermittedError not_found end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 34fc46615f613f..385dc6e3db4987 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -100,8 +100,10 @@ def visibility_icon(status) fa_icon('globe', title: I18n.t('statuses.visibilities.public')) elsif status.unlisted_visibility? fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted')) - elsif status.private_visibility? || status.limited_visibility? + elsif status.private_visibility? fa_icon('lock', title: I18n.t('statuses.visibilities.private')) + elsif status.limited_visibility? + fa_icon('user-circle', title: I18n.t('statuses.visibilities.limited')) elsif status.direct_visibility? fa_icon('envelope', title: I18n.t('statuses.visibilities.direct')) end diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb index 62eb50f786c4a6..035f0a1dc29c5f 100644 --- a/app/helpers/jsonld_helper.rb +++ b/app/helpers/jsonld_helper.rb @@ -49,13 +49,12 @@ def unsupported_uri_scheme?(uri) !uri.start_with?('http://', 'https://') end - def invalid_origin?(url) - return true if unsupported_uri_scheme?(url) - - needle = Addressable::URI.parse(url).host - haystack = Addressable::URI.parse(@account.uri).host + def same_origin?(url_a, url_b) + Addressable::URI.parse(url_a).host.casecmp(Addressable::URI.parse(url_b).host).zero? + end - !haystack.casecmp(needle).zero? + def invalid_origin?(url) + unsupported_uri_scheme?(url) || !same_origin?(url, @account.uri) end def canonicalize(json) diff --git a/app/javascript/mastodon/actions/circles.js b/app/javascript/mastodon/actions/circles.js new file mode 100644 index 00000000000000..8bd9634174de15 --- /dev/null +++ b/app/javascript/mastodon/actions/circles.js @@ -0,0 +1,372 @@ +import api from '../api'; +import { importFetchedAccounts } from './importer'; +import { showAlertForError } from './alerts'; + +export const CIRCLE_FETCH_REQUEST = 'CIRCLE_FETCH_REQUEST'; +export const CIRCLE_FETCH_SUCCESS = 'CIRCLE_FETCH_SUCCESS'; +export const CIRCLE_FETCH_FAIL = 'CIRCLE_FETCH_FAIL'; + +export const CIRCLES_FETCH_REQUEST = 'CIRCLES_FETCH_REQUEST'; +export const CIRCLES_FETCH_SUCCESS = 'CIRCLES_FETCH_SUCCESS'; +export const CIRCLES_FETCH_FAIL = 'CIRCLES_FETCH_FAIL'; + +export const CIRCLE_EDITOR_TITLE_CHANGE = 'CIRCLE_EDITOR_TITLE_CHANGE'; +export const CIRCLE_EDITOR_RESET = 'CIRCLE_EDITOR_RESET'; +export const CIRCLE_EDITOR_SETUP = 'CIRCLE_EDITOR_SETUP'; + +export const CIRCLE_CREATE_REQUEST = 'CIRCLE_CREATE_REQUEST'; +export const CIRCLE_CREATE_SUCCESS = 'CIRCLE_CREATE_SUCCESS'; +export const CIRCLE_CREATE_FAIL = 'CIRCLE_CREATE_FAIL'; + +export const CIRCLE_UPDATE_REQUEST = 'CIRCLE_UPDATE_REQUEST'; +export const CIRCLE_UPDATE_SUCCESS = 'CIRCLE_UPDATE_SUCCESS'; +export const CIRCLE_UPDATE_FAIL = 'CIRCLE_UPDATE_FAIL'; + +export const CIRCLE_DELETE_REQUEST = 'CIRCLE_DELETE_REQUEST'; +export const CIRCLE_DELETE_SUCCESS = 'CIRCLE_DELETE_SUCCESS'; +export const CIRCLE_DELETE_FAIL = 'CIRCLE_DELETE_FAIL'; + +export const CIRCLE_ACCOUNTS_FETCH_REQUEST = 'CIRCLE_ACCOUNTS_FETCH_REQUEST'; +export const CIRCLE_ACCOUNTS_FETCH_SUCCESS = 'CIRCLE_ACCOUNTS_FETCH_SUCCESS'; +export const CIRCLE_ACCOUNTS_FETCH_FAIL = 'CIRCLE_ACCOUNTS_FETCH_FAIL'; + +export const CIRCLE_EDITOR_SUGGESTIONS_CHANGE = 'CIRCLE_EDITOR_SUGGESTIONS_CHANGE'; +export const CIRCLE_EDITOR_SUGGESTIONS_READY = 'CIRCLE_EDITOR_SUGGESTIONS_READY'; +export const CIRCLE_EDITOR_SUGGESTIONS_CLEAR = 'CIRCLE_EDITOR_SUGGESTIONS_CLEAR'; + +export const CIRCLE_EDITOR_ADD_REQUEST = 'CIRCLE_EDITOR_ADD_REQUEST'; +export const CIRCLE_EDITOR_ADD_SUCCESS = 'CIRCLE_EDITOR_ADD_SUCCESS'; +export const CIRCLE_EDITOR_ADD_FAIL = 'CIRCLE_EDITOR_ADD_FAIL'; + +export const CIRCLE_EDITOR_REMOVE_REQUEST = 'CIRCLE_EDITOR_REMOVE_REQUEST'; +export const CIRCLE_EDITOR_REMOVE_SUCCESS = 'CIRCLE_EDITOR_REMOVE_SUCCESS'; +export const CIRCLE_EDITOR_REMOVE_FAIL = 'CIRCLE_EDITOR_REMOVE_FAIL'; + +export const CIRCLE_ADDER_RESET = 'CIRCLE_ADDER_RESET'; +export const CIRCLE_ADDER_SETUP = 'CIRCLE_ADDER_SETUP'; + +export const CIRCLE_ADDER_CIRCLES_FETCH_REQUEST = 'CIRCLE_ADDER_CIRCLES_FETCH_REQUEST'; +export const CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS = 'CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS'; +export const CIRCLE_ADDER_CIRCLES_FETCH_FAIL = 'CIRCLE_ADDER_CIRCLES_FETCH_FAIL'; + +export const fetchCircle = id => (dispatch, getState) => { + if (getState().getIn(['circles', id])) { + return; + } + + dispatch(fetchCircleRequest(id)); + + api(getState).get(`/api/v1/circles/${id}`) + .then(({ data }) => dispatch(fetchCircleSuccess(data))) + .catch(err => dispatch(fetchCircleFail(id, err))); +}; + +export const fetchCircleRequest = id => ({ + type: CIRCLE_FETCH_REQUEST, + id, +}); + +export const fetchCircleSuccess = circle => ({ + type: CIRCLE_FETCH_SUCCESS, + circle, +}); + +export const fetchCircleFail = (id, error) => ({ + type: CIRCLE_FETCH_FAIL, + id, + error, +}); + +export const fetchCircles = () => (dispatch, getState) => { + dispatch(fetchCirclesRequest()); + + api(getState).get('/api/v1/circles') + .then(({ data }) => dispatch(fetchCirclesSuccess(data))) + .catch(err => dispatch(fetchCirclesFail(err))); +}; + +export const fetchCirclesRequest = () => ({ + type: CIRCLES_FETCH_REQUEST, +}); + +export const fetchCirclesSuccess = circles => ({ + type: CIRCLES_FETCH_SUCCESS, + circles, +}); + +export const fetchCirclesFail = error => ({ + type: CIRCLES_FETCH_FAIL, + error, +}); + +export const submitCircleEditor = shouldReset => (dispatch, getState) => { + const circleId = getState().getIn(['circleEditor', 'circleId']); + const title = getState().getIn(['circleEditor', 'title']); + + if (circleId === null) { + dispatch(createCircle(title, shouldReset)); + } else { + dispatch(updateCircle(circleId, title, shouldReset)); + } +}; + +export const setupCircleEditor = circleId => (dispatch, getState) => { + dispatch({ + type: CIRCLE_EDITOR_SETUP, + circle: getState().getIn(['circles', circleId]), + }); + + dispatch(fetchCircleAccounts(circleId)); +}; + +export const changeCircleEditorTitle = value => ({ + type: CIRCLE_EDITOR_TITLE_CHANGE, + value, +}); + +export const createCircle = (title, shouldReset) => (dispatch, getState) => { + dispatch(createCircleRequest()); + + api(getState).post('/api/v1/circles', { title }).then(({ data }) => { + dispatch(createCircleSuccess(data)); + + if (shouldReset) { + dispatch(resetCircleEditor()); + } + }).catch(err => dispatch(createCircleFail(err))); +}; + +export const createCircleRequest = () => ({ + type: CIRCLE_CREATE_REQUEST, +}); + +export const createCircleSuccess = circle => ({ + type: CIRCLE_CREATE_SUCCESS, + circle, +}); + +export const createCircleFail = error => ({ + type: CIRCLE_CREATE_FAIL, + error, +}); + +export const updateCircle = (id, title, shouldReset) => (dispatch, getState) => { + dispatch(updateCircleRequest(id)); + + api(getState).put(`/api/v1/circles/${id}`, { title }).then(({ data }) => { + dispatch(updateCircleSuccess(data)); + + if (shouldReset) { + dispatch(resetCircleEditor()); + } + }).catch(err => dispatch(updateCircleFail(id, err))); +}; + +export const updateCircleRequest = id => ({ + type: CIRCLE_UPDATE_REQUEST, + id, +}); + +export const updateCircleSuccess = circle => ({ + type: CIRCLE_UPDATE_SUCCESS, + circle, +}); + +export const updateCircleFail = (id, error) => ({ + type: CIRCLE_UPDATE_FAIL, + id, + error, +}); + +export const resetCircleEditor = () => ({ + type: CIRCLE_EDITOR_RESET, +}); + +export const deleteCircle = id => (dispatch, getState) => { + dispatch(deleteCircleRequest(id)); + + api(getState).delete(`/api/v1/circles/${id}`) + .then(() => dispatch(deleteCircleSuccess(id))) + .catch(err => dispatch(deleteCircleFail(id, err))); +}; + +export const deleteCircleRequest = id => ({ + type: CIRCLE_DELETE_REQUEST, + id, +}); + +export const deleteCircleSuccess = id => ({ + type: CIRCLE_DELETE_SUCCESS, + id, +}); + +export const deleteCircleFail = (id, error) => ({ + type: CIRCLE_DELETE_FAIL, + id, + error, +}); + +export const fetchCircleAccounts = circleId => (dispatch, getState) => { + dispatch(fetchCircleAccountsRequest(circleId)); + + api(getState).get(`/api/v1/circles/${circleId}/accounts`, { params: { limit: 0 } }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchCircleAccountsSuccess(circleId, data)); + }).catch(err => dispatch(fetchCircleAccountsFail(circleId, err))); +}; + +export const fetchCircleAccountsRequest = id => ({ + type: CIRCLE_ACCOUNTS_FETCH_REQUEST, + id, +}); + +export const fetchCircleAccountsSuccess = (id, accounts, next) => ({ + type: CIRCLE_ACCOUNTS_FETCH_SUCCESS, + id, + accounts, + next, +}); + +export const fetchCircleAccountsFail = (id, error) => ({ + type: CIRCLE_ACCOUNTS_FETCH_FAIL, + id, + error, +}); + +export const fetchCircleSuggestions = q => (dispatch, getState) => { + const params = { + q, + resolve: false, + limit: 4, + followers: true, + }; + + api(getState).get('/api/v1/accounts/search', { params }).then(({ data }) => { + dispatch(importFetchedAccounts(data)); + dispatch(fetchCircleSuggestionsReady(q, data)); + }).catch(error => dispatch(showAlertForError(error))); +}; + +export const fetchCircleSuggestionsReady = (query, accounts) => ({ + type: CIRCLE_EDITOR_SUGGESTIONS_READY, + query, + accounts, +}); + +export const clearCircleSuggestions = () => ({ + type: CIRCLE_EDITOR_SUGGESTIONS_CLEAR, +}); + +export const changeCircleSuggestions = value => ({ + type: CIRCLE_EDITOR_SUGGESTIONS_CHANGE, + value, +}); + +export const addToCircleEditor = accountId => (dispatch, getState) => { + dispatch(addToCircle(getState().getIn(['circleEditor', 'circleId']), accountId)); +}; + +export const addToCircle = (circleId, accountId) => (dispatch, getState) => { + dispatch(addToCircleRequest(circleId, accountId)); + + api(getState).post(`/api/v1/circles/${circleId}/accounts`, { account_ids: [accountId] }) + .then(() => dispatch(addToCircleSuccess(circleId, accountId))) + .catch(err => dispatch(addToCircleFail(circleId, accountId, err))); +}; + +export const addToCircleRequest = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_ADD_REQUEST, + circleId, + accountId, +}); + +export const addToCircleSuccess = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_ADD_SUCCESS, + circleId, + accountId, +}); + +export const addToCircleFail = (circleId, accountId, error) => ({ + type: CIRCLE_EDITOR_ADD_FAIL, + circleId, + accountId, + error, +}); + +export const removeFromCircleEditor = accountId => (dispatch, getState) => { + dispatch(removeFromCircle(getState().getIn(['circleEditor', 'circleId']), accountId)); +}; + +export const removeFromCircle = (circleId, accountId) => (dispatch, getState) => { + dispatch(removeFromCircleRequest(circleId, accountId)); + + api(getState).delete(`/api/v1/circles/${circleId}/accounts`, { params: { account_ids: [accountId] } }) + .then(() => dispatch(removeFromCircleSuccess(circleId, accountId))) + .catch(err => dispatch(removeFromCircleFail(circleId, accountId, err))); +}; + +export const removeFromCircleRequest = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_REMOVE_REQUEST, + circleId, + accountId, +}); + +export const removeFromCircleSuccess = (circleId, accountId) => ({ + type: CIRCLE_EDITOR_REMOVE_SUCCESS, + circleId, + accountId, +}); + +export const removeFromCircleFail = (circleId, accountId, error) => ({ + type: CIRCLE_EDITOR_REMOVE_FAIL, + circleId, + accountId, + error, +}); + +export const resetCircleAdder = () => ({ + type: CIRCLE_ADDER_RESET, +}); + +export const setupCircleAdder = accountId => (dispatch, getState) => { + dispatch({ + type: CIRCLE_ADDER_SETUP, + account: getState().getIn(['accounts', accountId]), + }); + dispatch(fetchCircles()); + dispatch(fetchAccountCircles(accountId)); +}; + +export const fetchAccountCircles = accountId => (dispatch, getState) => { + dispatch(fetchAccountCirclesRequest(accountId)); + + api(getState).get(`/api/v1/accounts/${accountId}/circles`) + .then(({ data }) => dispatch(fetchAccountCirclesSuccess(accountId, data))) + .catch(err => dispatch(fetchAccountCirclesFail(accountId, err))); +}; + +export const fetchAccountCirclesRequest = id => ({ + type:CIRCLE_ADDER_CIRCLES_FETCH_REQUEST, + id, +}); + +export const fetchAccountCirclesSuccess = (id, circles) => ({ + type: CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS, + id, + circles, +}); + +export const fetchAccountCirclesFail = (id, err) => ({ + type: CIRCLE_ADDER_CIRCLES_FETCH_FAIL, + id, + err, +}); + +export const addToCircleAdder = circleId => (dispatch, getState) => { + dispatch(addToCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); +}; + +export const removeFromCircleAdder = circleId => (dispatch, getState) => { + dispatch(removeFromCircle(circleId, getState().getIn(['circleAdder', 'accountId']))); +}; + diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 9b37085cb39c46..8badd9eaf9b485 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -49,6 +49,7 @@ export const COMPOSE_SENSITIVITY_CHANGE = 'COMPOSE_SENSITIVITY_CHANGE'; export const COMPOSE_SPOILERNESS_CHANGE = 'COMPOSE_SPOILERNESS_CHANGE'; export const COMPOSE_SPOILER_TEXT_CHANGE = 'COMPOSE_SPOILER_TEXT_CHANGE'; export const COMPOSE_VISIBILITY_CHANGE = 'COMPOSE_VISIBILITY_CHANGE'; +export const COMPOSE_CIRCLE_CHANGE = 'COMPOSE_CIRCLE_CHANGE'; export const COMPOSE_LISTABILITY_CHANGE = 'COMPOSE_LISTABILITY_CHANGE'; export const COMPOSE_COMPOSING_CHANGE = 'COMPOSE_COMPOSING_CHANGE'; @@ -153,6 +154,7 @@ export function submitCompose(routerHistory) { sensitive: getState().getIn(['compose', 'sensitive']), spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '', visibility: getState().getIn(['compose', 'privacy']), + circle_id: getState().getIn(['compose', 'circle_id']), poll: getState().getIn(['compose', 'poll'], null), }, { headers: { @@ -637,6 +639,13 @@ export function changeComposeVisibility(value) { }; }; +export function changeComposeCircle(value) { + return { + type: COMPOSE_CIRCLE_CHANGE, + value, + }; +}; + export function insertEmojiCompose(position, emoji, needsSpace) { return { type: COMPOSE_EMOJI_INSERT, diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 6b79e1f16d5345..582afd14e12560 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -62,6 +62,7 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml'); normalStatus.spoiler_text = normalOldStatus.get('spoiler_text'); normalStatus.hidden = normalOldStatus.get('hidden'); + normalStatus.visibility = normalOldStatus.get('visibility'); } else { // If the status has a CW but no contents, treat the CW as if it were the // status' contents, to avoid having a CW toggle with seemingly no effect. @@ -78,6 +79,7 @@ export function normalizeStatus(status, normalOldStatus) { normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); normalStatus.hidden = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive; + normalStatus.visibility = normalStatus.limited ? 'limited' : normalStatus.visibility; } return normalStatus; diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index d60ccc1fb843ae..483f714d675a70 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -25,6 +25,10 @@ export const FAVOURITES_FETCH_REQUEST = 'FAVOURITES_FETCH_REQUEST'; export const FAVOURITES_FETCH_SUCCESS = 'FAVOURITES_FETCH_SUCCESS'; export const FAVOURITES_FETCH_FAIL = 'FAVOURITES_FETCH_FAIL'; +export const MENTIONS_FETCH_REQUEST = 'MENTIONS_FETCH_REQUEST'; +export const MENTIONS_FETCH_SUCCESS = 'MENTIONS_FETCH_SUCCESS'; +export const MENTIONS_FETCH_FAIL = 'MENTIONS_FETCH_FAIL'; + export const PIN_REQUEST = 'PIN_REQUEST'; export const PIN_SUCCESS = 'PIN_SUCCESS'; export const PIN_FAIL = 'PIN_FAIL'; @@ -337,6 +341,41 @@ export function fetchFavouritesFail(id, error) { }; }; +export function fetchMentions(id) { + return (dispatch, getState) => { + dispatch(fetchMentionsRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/mentioned_by`).then(response => { + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchMentionsSuccess(id, response.data)); + }).catch(error => { + dispatch(fetchMentionsFail(id, error)); + }); + }; +}; + +export function fetchMentionsRequest(id) { + return { + type: MENTIONS_FETCH_REQUEST, + id, + }; +}; + +export function fetchMentionsSuccess(id, accounts) { + return { + type: MENTIONS_FETCH_SUCCESS, + id, + accounts, + }; +}; + +export function fetchMentionsFail(id, error) { + return { + type: MENTIONS_FETCH_FAIL, + error, + }; +}; + export function pin(status) { return (dispatch, getState) => { dispatch(pinRequest(status)); diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js index 3fc7c07023d627..b83eb653d68087 100644 --- a/app/javascript/mastodon/actions/statuses.js +++ b/app/javascript/mastodon/actions/statuses.js @@ -76,10 +76,11 @@ export function fetchStatusFail(id, error, skipLoading) { }; }; -export function redraft(status, raw_text) { +export function redraft(status, replyStatus, raw_text) { return { type: REDRAFT, status, + replyStatus, raw_text, }; }; @@ -87,6 +88,7 @@ export function redraft(status, raw_text) { export function deleteStatus(id, routerHistory, withRedraft = false) { return (dispatch, getState) => { let status = getState().getIn(['statuses', id]); + const replyStatus = status.get('in_reply_to_id') ? getState().getIn(['statuses', status.get('in_reply_to_id')]) : null; if (status.get('poll')) { status = status.set('poll', getState().getIn(['polls', status.get('poll')])); @@ -100,7 +102,7 @@ export function deleteStatus(id, routerHistory, withRedraft = false) { dispatch(importFetchedAccount(response.data.account)); if (withRedraft) { - dispatch(redraft(status, response.data.text)); + dispatch(redraft(status, replyStatus, response.data.text)); ensureComposeIsVisible(getState, routerHistory); } }).catch(error => { diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 9955046c0481ea..31a62e0533ec91 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -15,8 +15,9 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { HotKeys } from 'react-hotkeys'; import classNames from 'classnames'; +import { Link } from 'react-router-dom'; import Icon from 'mastodon/components/icon'; -import { displayMedia } from '../initial_state'; +import { displayMedia, me } from '../initial_state'; import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; // We use the component (and not the container) since we do not want @@ -56,6 +57,7 @@ const messages = defineMessages({ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, }); @@ -76,6 +78,7 @@ class Status extends ImmutablePureComponent { onReblog: PropTypes.func, onDelete: PropTypes.func, onDirect: PropTypes.func, + onMemberList: PropTypes.func, onMention: PropTypes.func, onPin: PropTypes.func, onOpenMedia: PropTypes.func, @@ -467,10 +470,12 @@ class Status extends ImmutablePureComponent { 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) }, 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, + 'limited': { icon: 'user-circle', text: intl.formatMessage(messages.limited_short) }, 'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) }, }; const visibilityIcon = visibilityIconInfo[status.get('visibility')]; + const visibilityLink = ; return ( @@ -481,10 +486,8 @@ class Status extends ImmutablePureComponent {
- - - - + + {visibilityLink}
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index 85c76edee4bc1f..791aa45648d9be 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -13,6 +13,7 @@ const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, + showMemberList: { id: 'status.show_member_list', defaultMessage: 'Show member list' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' }, @@ -63,6 +64,7 @@ class StatusActionBar extends ImmutablePureComponent { onReblog: PropTypes.func, onDelete: PropTypes.func, onDirect: PropTypes.func, + onMemberList: PropTypes.func, onMention: PropTypes.func, onMute: PropTypes.func, onUnmute: PropTypes.func, @@ -149,6 +151,10 @@ class StatusActionBar extends ImmutablePureComponent { this.props.onDirect(this.props.status.get('account'), this.context.router.history); } + handleMemberListClick = () => { + this.props.onMemberList(this.props.status, this.context.router.history); + } + handleMuteClick = () => { const { status, relationship, onMute, onUnmute } = this.props; const account = status.get('account'); @@ -228,6 +234,7 @@ class StatusActionBar extends ImmutablePureComponent { const mutingConversation = status.get('muted'); const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; + const limitedByMe = status.get('visibility') === 'limited' && status.get('circle_id'); let menu = []; @@ -248,6 +255,11 @@ class StatusActionBar extends ImmutablePureComponent { menu.push(null); + if (writtenByMe && limitedByMe) { + menu.push({ text: intl.formatMessage(messages.showMemberList), action: this.handleMemberListClick }); + menu.push(null); + } + if (writtenByMe || withDismiss) { menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); diff --git a/app/javascript/mastodon/containers/compose_container.js b/app/javascript/mastodon/containers/compose_container.js index 7bc7bbaa4dc29d..01cf1974585bb2 100644 --- a/app/javascript/mastodon/containers/compose_container.js +++ b/app/javascript/mastodon/containers/compose_container.js @@ -8,6 +8,7 @@ import { getLocale } from '../locales'; import Compose from '../features/standalone/compose'; import initialState from '../initial_state'; import { fetchCustomEmojis } from '../actions/custom_emojis'; +import { fetchCircles } from '../actions/circles'; const { localeData, messages } = getLocale(); addLocaleData(localeData); @@ -19,6 +20,7 @@ if (initialState) { } store.dispatch(fetchCustomEmojis()); +store.dispatch(fetchCircles()); export default class TimelineContainer extends React.PureComponent { diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js index 0c3f6afa855c62..67d4807295e9f8 100644 --- a/app/javascript/mastodon/containers/mastodon.js +++ b/app/javascript/mastodon/containers/mastodon.js @@ -6,6 +6,7 @@ import { BrowserRouter, Route } from 'react-router-dom'; import { ScrollContext } from 'react-router-scroll-4'; import UI from '../features/ui'; import { fetchCustomEmojis } from '../actions/custom_emojis'; +import { fetchCircles } from '../actions/circles'; import { hydrateStore } from '../actions/store'; import { connectUserStream } from '../actions/streaming'; import { IntlProvider, addLocaleData } from 'react-intl'; @@ -21,6 +22,7 @@ const hydrateAction = hydrateStore(initialState); store.dispatch(hydrateAction); store.dispatch(fetchCustomEmojis()); +store.dispatch(fetchCircles()); const createIdentityContext = state => ({ signedIn: !!state.meta.me, diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index 9abdec138b8a4e..d260ae906a0f34 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -146,6 +146,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(directCompose(account, router)); }, + onMemberList (status, history) { + history.push(`/statuses/${status.get('id')}/mentions`); + }, + onMention (account, router) { dispatch(mentionCompose(account, router)); }, diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 48ec49d8148ee1..78fef007d5d217 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -43,12 +43,14 @@ const messages = defineMessages({ follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + circles: { id: 'navigation_bar.circles', defaultMessage: 'Circles' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' }, unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' }, add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' }, + add_or_remove_from_circle: { id: 'account.add_or_remove_from_circle', defaultMessage: 'Add or Remove from circles' }, admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' }, }); @@ -194,6 +196,7 @@ class Header extends ImmutablePureComponent { menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' }); menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); + menu.push({ text: intl.formatMessage(messages.circles), to: '/circles' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); @@ -213,6 +216,11 @@ class Header extends ImmutablePureComponent { menu.push(null); } + if (account.getIn(['relationship', 'followed_by'])) { + menu.push({ text: intl.formatMessage(messages.add_or_remove_from_circle), action: this.props.onAddToCircle }); + menu.push(null); + } + if (account.getIn(['relationship', 'muting'])) { menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); } else { diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index 33bea4c1799b9d..80a386f1bc94f4 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -22,6 +22,7 @@ export default class Header extends ImmutablePureComponent { onUnblockDomain: PropTypes.func.isRequired, onEndorseToggle: PropTypes.func.isRequired, onAddToList: PropTypes.func.isRequired, + onAddToCircle: PropTypes.func.isRequired, hideTabs: PropTypes.bool, domain: PropTypes.string.isRequired, }; @@ -86,6 +87,10 @@ export default class Header extends ImmutablePureComponent { this.props.onAddToList(this.props.account); } + handleAddToCircle = () => { + this.props.onAddToCircle(this.props.account); + } + handleEditAccountNote = () => { this.props.onEditAccountNote(this.props.account); } @@ -115,6 +120,7 @@ export default class Header extends ImmutablePureComponent { onUnblockDomain={this.handleUnblockDomain} onEndorseToggle={this.handleEndorseToggle} onAddToList={this.handleAddToList} + onAddToCircle={this.handleAddToCircle} onEditAccountNote={this.handleEditAccountNote} domain={this.props.domain} /> 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 b3f8521cb95665..f3083de723656d 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -126,6 +126,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ })); }, + onAddToCircle(account){ + dispatch(openModal('CIRCLE_ADDER', { + accountId: account.get('id'), + })); + }, + }); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header)); diff --git a/app/javascript/mastodon/features/circle_adder/components/account.js b/app/javascript/mastodon/features/circle_adder/components/account.js new file mode 100644 index 00000000000000..1369aac0742c43 --- /dev/null +++ b/app/javascript/mastodon/features/circle_adder/components/account.js @@ -0,0 +1,43 @@ +import React from 'react'; +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 { injectIntl } from 'react-intl'; + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId }) => ({ + account: getAccount(state, accountId), + }); + + return mapStateToProps; +}; + + +export default @connect(makeMapStateToProps) +@injectIntl +class Account extends ImmutablePureComponent { + + static propTypes = { + account: ImmutablePropTypes.map.isRequired, + }; + + render () { + const { account } = this.props; + return ( +
+
+
+
+ +
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/circle_adder/components/circle.js b/app/javascript/mastodon/features/circle_adder/components/circle.js new file mode 100644 index 00000000000000..735a7aa45682b9 --- /dev/null +++ b/app/javascript/mastodon/features/circle_adder/components/circle.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; +import { removeFromCircleAdder, addToCircleAdder } from '../../../actions/circles'; +import Icon from 'mastodon/components/icon'; + +const messages = defineMessages({ + remove: { id: 'circles.account.remove', defaultMessage: 'Remove from circle' }, + add: { id: 'circles.account.add', defaultMessage: 'Add to circle' }, +}); + +const MapStateToProps = (state, { circleId, added }) => ({ + circle: state.get('circles').get(circleId), + added: typeof added === 'undefined' ? state.getIn(['circleAdder', 'circles', 'items']).includes(circleId) : added, +}); + +const mapDispatchToProps = (dispatch, { circleId }) => ({ + onRemove: () => dispatch(removeFromCircleAdder(circleId)), + onAdd: () => dispatch(addToCircleAdder(circleId)), +}); + +export default @connect(MapStateToProps, mapDispatchToProps) +@injectIntl +class Circle extends ImmutablePureComponent { + + static propTypes = { + circle: ImmutablePropTypes.map.isRequired, + intl: PropTypes.object.isRequired, + onRemove: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + added: PropTypes.bool, + }; + + static defaultProps = { + added: false, + }; + + render () { + const { circle, intl, onRemove, onAdd, added } = this.props; + + let button; + + if (added) { + button = ; + } else { + button = ; + } + + return ( +
+
+
+ + {circle.get('title')} +
+ +
+ {button} +
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/circle_adder/index.js b/app/javascript/mastodon/features/circle_adder/index.js new file mode 100644 index 00000000000000..b55d7c4cca575a --- /dev/null +++ b/app/javascript/mastodon/features/circle_adder/index.js @@ -0,0 +1,73 @@ +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 { setupCircleAdder, resetCircleAdder } from '../../actions/circles'; +import { createSelector } from 'reselect'; +import Circle from './components/circle'; +import Account from './components/account'; +import NewCircleForm from '../circles/components/new_circle_form'; +// hack + +const getOrderedCircles = createSelector([state => state.get('circles')], circles => { + if (!circles) { + return circles; + } + + return circles.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); +}); + +const mapStateToProps = state => ({ + circleIds: getOrderedCircles(state).map(circle=>circle.get('id')), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: accountId => dispatch(setupCircleAdder(accountId)), + onReset: () => dispatch(resetCircleAdder()), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class CircleAdder extends ImmutablePureComponent { + + static propTypes = { + accountId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + circleIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize, accountId } = this.props; + onInitialize(accountId); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { accountId, circleIds } = this.props; + + return ( +
+
+ +
+ + + + +
+ {circleIds.map(CircleId => )} +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/circle_editor/components/account.js b/app/javascript/mastodon/features/circle_editor/components/account.js new file mode 100644 index 00000000000000..366db9735c7759 --- /dev/null +++ b/app/javascript/mastodon/features/circle_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 { removeFromCircleEditor, addToCircleEditor } from '../../../actions/circles'; + +const messages = defineMessages({ + remove: { id: 'circles.account.remove', defaultMessage: 'Remove from circle' }, + add: { id: 'circles.account.add', defaultMessage: 'Add to circle' }, +}); + +const makeMapStateToProps = () => { + const getAccount = makeGetAccount(); + + const mapStateToProps = (state, { accountId, added }) => ({ + account: getAccount(state, accountId), + added: typeof added === 'undefined' ? state.getIn(['circleEditor', 'accounts', 'items']).includes(accountId) : added, + }); + + return mapStateToProps; +}; + +const mapDispatchToProps = (dispatch, { accountId }) => ({ + onRemove: () => dispatch(removeFromCircleEditor(accountId)), + onAdd: () => dispatch(addToCircleEditor(accountId)), +}); + +export default @connect(makeMapStateToProps, mapDispatchToProps) +@injectIntl +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 = ; + } else { + button = ; + } + + return ( +
+
+
+
+ +
+ +
+ {button} +
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/circle_editor/components/edit_circle_form.js b/app/javascript/mastodon/features/circle_editor/components/edit_circle_form.js new file mode 100644 index 00000000000000..8c082c505b27c3 --- /dev/null +++ b/app/javascript/mastodon/features/circle_editor/components/edit_circle_form.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { changeCircleEditorTitle, submitCircleEditor } from '../../../actions/circles'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + title: { id: 'circles.edit.submit', defaultMessage: 'Change title' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['circleEditor', 'title']), + disabled: !state.getIn(['circleEditor', 'isChanged']) || !state.getIn(['circleEditor', 'title']), +}); + +const mapDispatchToProps = dispatch => ({ + onChange: value => dispatch(changeCircleEditorTitle(value)), + onSubmit: () => dispatch(submitCircleEditor(false)), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class CircleForm 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 title = intl.formatMessage(messages.title); + + return ( +
+ + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/circle_editor/components/search.js b/app/javascript/mastodon/features/circle_editor/components/search.js new file mode 100644 index 00000000000000..a45876c0104c6d --- /dev/null +++ b/app/javascript/mastodon/features/circle_editor/components/search.js @@ -0,0 +1,76 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; +import { fetchCircleSuggestions, clearCircleSuggestions, changeCircleSuggestions } from '../../../actions/circles'; +import classNames from 'classnames'; +import Icon from 'mastodon/components/icon'; + +const messages = defineMessages({ + search: { id: 'circles.search', defaultMessage: 'Search among people following you' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['circleEditor', 'suggestions', 'value']), +}); + +const mapDispatchToProps = dispatch => ({ + onSubmit: value => dispatch(fetchCircleSuggestions(value)), + onClear: () => dispatch(clearCircleSuggestions()), + onChange: value => dispatch(changeCircleSuggestions(value)), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +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 ( +
+ + +
+ + +
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/circle_editor/index.js b/app/javascript/mastodon/features/circle_editor/index.js new file mode 100644 index 00000000000000..8576ea7e095290 --- /dev/null +++ b/app/javascript/mastodon/features/circle_editor/index.js @@ -0,0 +1,79 @@ +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 { setupCircleEditor, clearCircleSuggestions, resetCircleEditor } from '../../actions/circles'; +import Account from './components/account'; +import Search from './components/search'; +import EditCircleForm from './components/edit_circle_form'; +import Motion from '../ui/util/optional_motion'; +import spring from 'react-motion/lib/spring'; + +const mapStateToProps = state => ({ + accountIds: state.getIn(['circleEditor', 'accounts', 'items']), + searchAccountIds: state.getIn(['circleEditor', 'suggestions', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + onInitialize: circleId => dispatch(setupCircleEditor(circleId)), + onClear: () => dispatch(clearCircleSuggestions()), + onReset: () => dispatch(resetCircleEditor()), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class CircleEditor extends ImmutablePureComponent { + + static propTypes = { + circleId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + onInitialize: PropTypes.func.isRequired, + onClear: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list.isRequired, + searchAccountIds: ImmutablePropTypes.list.isRequired, + }; + + componentDidMount () { + const { onInitialize, circleId } = this.props; + onInitialize(circleId); + } + + componentWillUnmount () { + const { onReset } = this.props; + onReset(); + } + + render () { + const { accountIds, searchAccountIds, onClear } = this.props; + const showSearch = searchAccountIds.size > 0; + + return ( +
+ + + + +
+
+ {accountIds.map(accountId => )} +
+ + {showSearch &&
} + + + {({ x }) => ( +
+ {searchAccountIds.map(accountId => )} +
+ )} +
+
+
+ ); + } + +} diff --git a/app/javascript/mastodon/features/circles/components/circle.js b/app/javascript/mastodon/features/circles/components/circle.js new file mode 100644 index 00000000000000..84003428bfbb21 --- /dev/null +++ b/app/javascript/mastodon/features/circles/components/circle.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { defineMessages, injectIntl } from 'react-intl'; +import Icon from '../../../components/icon'; +import { openModal } from '../../../actions/modal'; +import { deleteCircle } from '../../../actions/circles'; + +const messages = defineMessages({ + deleteMessage: { id: 'confirmations.delete_circle.message', defaultMessage: 'Are you sure you want to permanently delete this circle?' }, + deleteConfirm: { id: 'confirmations.delete_circle.confirm', defaultMessage: 'Delete' }, +}); + +export default @connect() +@injectIntl +class Circle extends React.PureComponent { + + static propTypes = { + id: PropTypes.string.isRequired, + text: PropTypes.string.isRequired, + dispatch: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleEditClick = () => { + this.props.dispatch(openModal('CIRCLE_EDITOR', { circleId: this.props.id })); + } + + handleDeleteClick = () => { + const { dispatch, intl } = this.props; + const { id } = this.props; + + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.deleteMessage), + confirm: intl.formatMessage(messages.deleteConfirm), + onConfirm: () => { + dispatch(deleteCircle(id)); + }, + })); + } + + render() { + const { text, intl } = this.props; + + return ( +
+ + +
+ ); + } + +} diff --git a/app/javascript/mastodon/features/circles/components/new_circle_form.js b/app/javascript/mastodon/features/circles/components/new_circle_form.js new file mode 100644 index 00000000000000..23232d087f2f9d --- /dev/null +++ b/app/javascript/mastodon/features/circles/components/new_circle_form.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import { changeCircleEditorTitle, submitCircleEditor } from '../../../actions/circles'; +import IconButton from '../../../components/icon_button'; +import { defineMessages, injectIntl } from 'react-intl'; + +const messages = defineMessages({ + label: { id: 'circles.new.title_placeholder', defaultMessage: 'New circle title' }, + title: { id: 'circles.new.create', defaultMessage: 'Add circle' }, +}); + +const mapStateToProps = state => ({ + value: state.getIn(['circleEditor', 'title']), + disabled: state.getIn(['circleEditor', 'isSubmitting']), +}); + +const mapDispatchToProps = dispatch => ({ + onChange: value => dispatch(changeCircleEditorTitle(value)), + onSubmit: () => dispatch(submitCircleEditor(true)), +}); + +export default @connect(mapStateToProps, mapDispatchToProps) +@injectIntl +class NewCircleForm 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 ( +
+ + + + + ); + } + +} diff --git a/app/javascript/mastodon/features/circles/index.js b/app/javascript/mastodon/features/circles/index.js new file mode 100644 index 00000000000000..c5afdb356c105f --- /dev/null +++ b/app/javascript/mastodon/features/circles/index.js @@ -0,0 +1,84 @@ +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 { fetchCircles } from '../../actions/circles'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import ColumnSubheading from '../ui/components/column_subheading'; +import NewCircleForm from './components/new_circle_form'; +import Circle from './components/circle'; +import { createSelector } from 'reselect'; +import ScrollableList from '../../components/scrollable_list'; + +const messages = defineMessages({ + heading: { id: 'column.circles', defaultMessage: 'Circles' }, + subheading: { id: 'circles.subheading', defaultMessage: 'Your circles' }, +}); + +const getOrderedCircles = createSelector([state => state.get('circles')], circles => { + if (!circles) { + return circles; + } + + return circles.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); +}); + +const mapStateToProps = state => ({ + circles: getOrderedCircles(state), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Circles extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + circles: ImmutablePropTypes.list, + intl: PropTypes.object.isRequired, + multiColumn: PropTypes.bool, + }; + + componentWillMount () { + this.props.dispatch(fetchCircles()); + } + + render () { + const { intl, shouldUpdateScroll, circles, multiColumn } = this.props; + + if (!circles) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + + + + + } + bindToDocument={!multiColumn} + > + {circles.map(circle => + , + )} + + + ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js index 07d92bb7e77d84..eea6b017baf8b0 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.js +++ b/app/javascript/mastodon/features/compose/components/action_bar.js @@ -11,6 +11,7 @@ const messages = defineMessages({ follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + circles: { id: 'navigation_bar.circles', defaultMessage: 'Circles' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, @@ -45,6 +46,7 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' }); menu.push({ text: intl.formatMessage(messages.bookmarks), to: '/bookmarks' }); menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' }); + menu.push({ text: intl.formatMessage(messages.circles), to: '/circles' }); menu.push(null); menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' }); menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' }); diff --git a/app/javascript/mastodon/features/compose/components/circle_dropdown.js b/app/javascript/mastodon/features/compose/components/circle_dropdown.js new file mode 100644 index 00000000000000..ed32e179646d5c --- /dev/null +++ b/app/javascript/mastodon/features/compose/components/circle_dropdown.js @@ -0,0 +1,80 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { injectIntl, defineMessages } from 'react-intl'; +import classNames from 'classnames'; +import IconButton from 'mastodon/components/icon_button'; +import { createSelector } from 'reselect'; + +const messages = defineMessages({ + circle_unselect: { id: 'circle.unselect', defaultMessage: '(Select circle)' }, + circle_reply: { id: 'circle.reply', defaultMessage: '(Reply to circle context)' }, + circle_open_circle_column: { id: 'circle.open_circle_column', defaultMessage: 'Open circle column' }, + circle_add_new_circle: { id: 'circle.add_new_circle', defaultMessage: '(Add new circle)' }, + circle_select: { id: 'circle.select', defaultMessage: 'Select circle' }, +}); + +const getOrderedCircles = createSelector([state => state.get('circles')], circles => { + if (!circles) { + return circles; + } + + return circles.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))); +}); + +const mapStateToProps = (state) => { + return { + circles: getOrderedCircles(state), + }; +}; + +export default @connect(mapStateToProps) +@injectIntl +class CircleDropdown extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + circles: ImmutablePropTypes.list, + value: PropTypes.string.isRequired, + visible: PropTypes.bool.isRequired, + limitedReply: PropTypes.bool.isRequired, + onChange: PropTypes.func.isRequired, + onOpenCircleColumn: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleChange = e => { + this.props.onChange(e.target.value); + }; + + handleOpenCircleColumn = () => { + this.props.onOpenCircleColumn(this.context.router ? this.context.router.history : null); + }; + + render () { + const { circles, value, visible, limitedReply, intl } = this.props; + + return ( +
+ + + {circles.isEmpty() && !limitedReply ? + + : + /* eslint-disable-next-line jsx-a11y/no-onchange */ + + } +
+ ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index ba2d20cc7e6c3a..618de8547b1645 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -11,6 +11,7 @@ import UploadButtonContainer from '../containers/upload_button_container'; import { defineMessages, injectIntl } from 'react-intl'; import SpoilerButtonContainer from '../containers/spoiler_button_container'; import PrivacyDropdownContainer from '../containers/privacy_dropdown_container'; +import CircleDropdownContainer from '../containers/circle_dropdown_container'; import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container'; import PollFormContainer from '../containers/poll_form_container'; import UploadFormContainer from '../containers/upload_form_container'; @@ -50,6 +51,7 @@ class ComposeForm extends ImmutablePureComponent { isSubmitting: PropTypes.bool, isChangingUpload: PropTypes.bool, isUploading: PropTypes.bool, + isCircleUnselected: PropTypes.bool, onChange: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired, onClearSuggestions: PropTypes.func.isRequired, @@ -82,11 +84,11 @@ class ComposeForm extends ImmutablePureComponent { } canSubmit = () => { - const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props; + const { isSubmitting, isChangingUpload, isUploading, isCircleUnselected, anyMedia } = this.props; const fulltext = this.getFulltextForCharacterCounting(); const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0; - return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia)); + return !(isSubmitting || isUploading || isChangingUpload || isCircleUnselected || length(fulltext) > 500 || (isOnlyWhitespace && !anyMedia)); } handleSubmit = () => { @@ -198,7 +200,7 @@ class ComposeForm extends ImmutablePureComponent { const disabled = this.props.isSubmitting; let publishText = ''; - if (this.props.privacy === 'private' || this.props.privacy === 'direct') { + if (this.props.privacy !== 'public' && this.props.privacy !== 'unlisted') { publishText = {intl.formatMessage(messages.publish)}; } else { publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish); @@ -260,6 +262,8 @@ class ComposeForm extends ImmutablePureComponent {
+ +
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index c0bef04812d5b8..e25dd3840bb908 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -18,6 +18,8 @@ const messages = defineMessages({ private_long: { id: 'privacy.private.long', defaultMessage: 'Visible for followers only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, direct_long: { id: 'privacy.direct.long', defaultMessage: 'Visible for mentioned users only' }, + limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' }, + limited_long: { id: 'privacy.limited.long', defaultMessage: 'Visible for circle users only' }, change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, }); @@ -235,6 +237,7 @@ class PrivacyDropdown extends React.PureComponent { this.options = [ { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, + { icon: 'user-circle', value: 'limited', text: formatMessage(messages.limited_short), meta: formatMessage(messages.limited_long) }, ]; if (!this.props.noDirect) { diff --git a/app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js new file mode 100644 index 00000000000000..958a903e27d760 --- /dev/null +++ b/app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js @@ -0,0 +1,30 @@ +import { connect } from 'react-redux'; +import CircleDropdown from '../components/circle_dropdown'; +import { changeComposeCircle } from '../../../actions/compose'; + +const mapStateToProps = state => { + let value = state.getIn(['compose', 'circle_id']); + value = value === null ? '' : value; + + return { + value: value, + visible: state.getIn(['compose', 'privacy']) === 'limited', + limitedReply: state.getIn(['compose', 'privacy']) === 'limited' && state.getIn(['compose', 'reply_status', 'visibility']) === 'limited', + }; +}; + +const mapDispatchToProps = dispatch => ({ + + onChange (value) { + dispatch(changeComposeCircle(value)); + }, + + onOpenCircleColumn (router) { + if(router && router.location.pathname !== '/circles') { + router.push('/circles'); + } + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(CircleDropdown); diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js index 37a0e8845b4f1b..c195245ee0ee27 100644 --- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js +++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js @@ -23,6 +23,7 @@ const mapStateToProps = state => ({ isSubmitting: state.getIn(['compose', 'is_submitting']), isChangingUpload: state.getIn(['compose', 'is_changing_upload']), isUploading: state.getIn(['compose', 'is_uploading']), + isCircleUnselected: state.getIn(['compose', 'privacy']) === 'limited' && state.getIn(['compose', 'reply_status', 'visibility']) !== 'limited' && !state.getIn(['compose', 'circle_id']), showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']), anyMedia: state.getIn(['compose', 'media_attachments']).size > 0, }); diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js index bf0660ea9dfeb6..79fe1c25afaee1 100644 --- a/app/javascript/mastodon/features/compose/containers/warning_container.js +++ b/app/javascript/mastodon/features/compose/containers/warning_container.js @@ -34,9 +34,10 @@ const mapStateToProps = state => ({ needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']), hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])), directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct', + limitedMessageWarning: state.getIn(['compose', 'privacy']) === 'limited', }); -const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => { +const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning, limitedMessageWarning }) => { if (needsLockWarning) { return
}} />} />; } @@ -55,6 +56,10 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning return ; } + if (limitedMessageWarning) { + return } />; + } + return null; }; @@ -62,6 +67,7 @@ WarningWrapper.propTypes = { needsLockWarning: PropTypes.bool, hashtagWarning: PropTypes.bool, directMessageWarning: PropTypes.bool, + limitedMessageWarning: PropTypes.bool, }; export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 5508adb80e1526..b538b862e8995f 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -31,6 +31,7 @@ const messages = defineMessages({ mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, + circles: { id: 'navigation_bar.circles', defaultMessage: 'Circles' }, discover: { id: 'navigation_bar.discover', defaultMessage: 'Discover' }, personal: { id: 'navigation_bar.personal', defaultMessage: 'Personal' }, security: { id: 'navigation_bar.security', defaultMessage: 'Security' }, @@ -137,9 +138,10 @@ class GettingStarted extends ImmutablePureComponent { , , , + , ); - height += 48*4; + height += 48*5; if (myAccount.get('locked') || unreadFollowRequests > 0) { navItems.push(); diff --git a/app/javascript/mastodon/features/mentions/index.js b/app/javascript/mastodon/features/mentions/index.js new file mode 100644 index 00000000000000..3f44b1471d0b07 --- /dev/null +++ b/app/javascript/mastodon/features/mentions/index.js @@ -0,0 +1,77 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import LoadingIndicator from '../../components/loading_indicator'; +import { fetchMentions } from '../../actions/interactions'; +import { injectIntl, FormattedMessage } from 'react-intl'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import ScrollableList from '../../components/scrollable_list'; +import ColumnHeader from '../../components/column_header'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'mentioned_by', props.params.statusId]), +}); + +export default @connect(mapStateToProps) +@injectIntl +class Mentions extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + shouldUpdateScroll: PropTypes.func, + accountIds: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + componentWillMount () { + if (!this.props.accountIds) { + this.props.dispatch(fetchMentions(this.props.params.statusId)); + } + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) { + this.props.dispatch(fetchMentions(nextProps.params.statusId)); + } + } + + render () { + const { shouldUpdateScroll, accountIds, multiColumn } = this.props; + + if (!accountIds) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + + + + {accountIds.map(id => + , + )} + + + ); + } + +} diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js index ffa2510c0db783..65565518061c70 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.js +++ b/app/javascript/mastodon/features/status/components/action_bar.js @@ -12,6 +12,7 @@ const messages = defineMessages({ delete: { id: 'status.delete', defaultMessage: 'Delete' }, redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' }, direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' }, + showMemberList: { id: 'status.show_member_list', defaultMessage: 'Show member list' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, @@ -60,6 +61,7 @@ class ActionBar extends React.PureComponent { onBookmark: PropTypes.func.isRequired, onDelete: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, + onMemberList: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onMute: PropTypes.func, onUnmute: PropTypes.func, @@ -102,6 +104,10 @@ class ActionBar extends React.PureComponent { this.props.onDirect(this.props.status.get('account'), this.context.router.history); } + handleMemberListClick = () => { + this.props.onMemberList(this.props.status, this.context.router.history); + } + handleMentionClick = () => { this.props.onMention(this.props.status.get('account'), this.context.router.history); } @@ -191,6 +197,7 @@ class ActionBar extends React.PureComponent { const mutingConversation = status.get('muted'); const account = status.get('account'); const writtenByMe = status.getIn(['account', 'id']) === me; + const limitedByMe = status.get('visibility') === 'limited' && status.get('circle_id'); let menu = []; @@ -206,6 +213,11 @@ class ActionBar extends React.PureComponent { menu.push(null); } + if (limitedByMe) { + menu.push({ text: intl.formatMessage(messages.showMemberList), action: this.handleMemberListClick }); + } + + menu.push(null); menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick }); diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 72ddeb2b24dc2c..24e1c8685ec671 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Avatar from '../../../components/avatar'; @@ -21,6 +21,7 @@ const messages = defineMessages({ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, }); @@ -176,44 +177,45 @@ class DetailedStatus extends ImmutablePureComponent { } if (status.get('application')) { - applicationLink = · {status.getIn(['application', 'name'])}; + applicationLink = · {status.getIn(['application', 'name'])}; } const visibilityIconInfo = { 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) }, 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, + 'limited': { icon: 'user-circle', text: intl.formatMessage(messages.limited_short) }, 'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) }, }; const visibilityIcon = visibilityIconInfo[status.get('visibility')]; - const visibilityLink = · ; + const visibilityLink = · ; - if (['private', 'direct'].includes(status.get('visibility'))) { + if (!(['public', 'unlisted'].includes(status.get('visibility')))) { reblogLink = ''; } else if (this.context.router) { reblogLink = ( - - · - + + · + - + ); } else { reblogLink = ( - - · + + · - + ); } diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index f342a3641cf11c..e84428a1f48d2c 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -277,6 +277,10 @@ class Status extends ImmutablePureComponent { this.props.dispatch(directCompose(account, router)); } + handleMemberListClick = (status, history) => { + history.push(`/statuses/${status.get('id')}/mentions`); + } + handleMentionClick = (account, router) => { this.props.dispatch(mentionCompose(account, router)); } @@ -568,6 +572,7 @@ class Status extends ImmutablePureComponent { onBookmark={this.handleBookmarkClick} onDelete={this.handleDeleteClick} onDirect={this.handleDirectClick} + onMemberList={this.handleMemberListClick} onMention={this.handleMentionClick} onMute={this.handleMuteClick} onUnmute={this.handleUnmuteClick} diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js index f8a344690e3d25..85edcb2f981303 100644 --- a/app/javascript/mastodon/features/ui/components/boost_modal.js +++ b/app/javascript/mastodon/features/ui/components/boost_modal.js @@ -21,6 +21,7 @@ const messages = defineMessages({ public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, + limited_short: { id: 'privacy.limited.short', defaultMessage: 'Circle' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, }); @@ -88,6 +89,7 @@ class BoostModal extends ImmutablePureComponent { 'public': { icon: 'globe', text: intl.formatMessage(messages.public_short) }, 'unlisted': { icon: 'unlock', text: intl.formatMessage(messages.unlisted_short) }, 'private': { icon: 'lock', text: intl.formatMessage(messages.private_short) }, + 'limited': { icon: 'user-circle', text: intl.formatMessage(messages.limited_short) }, 'direct': { icon: 'envelope', text: intl.formatMessage(messages.direct_short) }, }; diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 377cccda5a2517..037d4abdc7d7c2 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -19,6 +19,8 @@ import { EmbedModal, ListEditor, ListAdder, + CircleEditor, + CircleAdder, } from '../../../features/ui/util/async-components'; const MODAL_COMPONENTS = { @@ -34,7 +36,9 @@ const MODAL_COMPONENTS = { 'EMBED': EmbedModal, 'LIST_EDITOR': ListEditor, 'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }), - 'LIST_ADDER':ListAdder, + 'LIST_ADDER': ListAdder, + 'CIRCLE_EDITOR': CircleEditor, + 'CIRCLE_ADDER': CircleAdder, }; export default class ModalRoot extends React.PureComponent { diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js index 901dbdfcb4a97e..e5fc29d33fcfa3 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.js +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js @@ -19,6 +19,7 @@ const NavigationPanel = () => ( + {profile_directory && } diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 3feffa65643bee..61ce9d63778482 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -36,6 +36,7 @@ import { Following, Reblogs, Favourites, + Mentions, DirectTimeline, HashtagTimeline, Notifications, @@ -49,6 +50,7 @@ import { Mutes, PinnedStatuses, Lists, + Circles, Search, Directory, FollowRecommendations, @@ -186,12 +188,14 @@ class SwitchingColumnsArea extends React.PureComponent { + + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index aa90b226ad89d2..76e48a52d43c50 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -78,6 +78,10 @@ export function Favourites () { return import(/* webpackChunkName: "features/favourites" */'../../favourites'); } +export function Mentions () { + return import(/* webpackChunkName: "features/mentions" */'../../mentions'); +} + export function FollowRequests () { return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests'); } @@ -138,6 +142,18 @@ export function ListAdder () { return import(/*webpackChunkName: "features/list_adder" */'../../list_adder'); } +export function Circles () { + return import(/* webpackChunkName: "features/circles" */'../../circles'); +} + +export function CircleEditor () { + return import(/* webpackChunkName: "features/circle_editor" */'../../circle_editor'); +} + +export function CircleAdder () { + return import(/*webpackChunkName: "features/circle_adder" */'../../circle_adder'); +} + export function Search () { return import(/*webpackChunkName: "features/search" */'../../search'); } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index a069c1e2fc02d1..36231a16beb516 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -1,5 +1,6 @@ { "account.account_note_header": "Note", + "account.add_or_remove_from_circle": "Add or Remove from circles", "account.add_or_remove_from_list": "Add or Remove from lists", "account.badges.bot": "Bot", "account.badges.group": "Group", @@ -65,8 +66,21 @@ "bundle_modal_error.close": "Close", "bundle_modal_error.message": "Something went wrong while loading this component.", "bundle_modal_error.retry": "Try again", + "circles.account.remove": "Remove from circle", + "circles.account.add": "Add to circle", + "circles.edit.submit": "Change title", + "circles.new.create": "Add circle", + "circles.new.title_placeholder": "New circle title", + "circles.search": "Search among people following you", + "circles.subheading": "Your circles", + "circle.add_new_circle": "(Add new circle)", + "circle.open_circle_column": "Open circle column", + "circle.reply": "(Reply to circle context)", + "circle.select": "Select circle", + "circle.unselect": "(Select circle)", "column.blocks": "Blocked users", "column.bookmarks": "Bookmarks", + "column.circles": "Circles", "column.community": "Local timeline", "column.direct": "Direct messages", "column.directory": "Browse profiles", @@ -93,6 +107,7 @@ "compose_form.direct_message_warning": "This post will only be sent to the mentioned users.", "compose_form.direct_message_warning_learn_more": "Learn more", "compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.", + "compose_form.limited_message_warning": "This post will only be sent to users in the circle.", "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.", "compose_form.lock_disclaimer.lock": "locked", "compose_form.placeholder": "What's on your mind?", @@ -116,6 +131,8 @@ "confirmations.block.message": "Are you sure you want to block {name}?", "confirmations.delete.confirm": "Delete", "confirmations.delete.message": "Are you sure you want to delete this post?", + "confirmations.delete_circle.confirm": "Delete", + "confirmations.delete_circle.message": "Are you sure you want to permanently delete this circle?", "confirmations.delete_list.confirm": "Delete", "confirmations.delete_list.message": "Are you sure you want to permanently delete this list?", "confirmations.discard_edit_media.confirm": "Discard", @@ -278,6 +295,7 @@ "navigation_bar.apps": "Mobile apps", "navigation_bar.blocks": "Blocked users", "navigation_bar.bookmarks": "Bookmarks", + "navigation_bar.circles": "Circles", "navigation_bar.community_timeline": "Local timeline", "navigation_bar.compose": "Compose new post", "navigation_bar.direct": "Direct messages", @@ -353,6 +371,8 @@ "privacy.change": "Change post privacy", "privacy.direct.long": "Visible for mentioned users only", "privacy.direct.short": "Direct", + "privacy.limited.long": "Visible for circle users only", + "privacy.limited.short": "Circle", "privacy.private.long": "Visible for followers only", "privacy.private.short": "Followers-only", "privacy.public.long": "Visible for all, shown in public timelines", @@ -423,6 +443,7 @@ "status.share": "Share", "status.show_less": "Show less", "status.show_less_all": "Show less for all", + "status.show_member_list": "Show member list", "status.show_more": "Show more", "status.show_more_all": "Show more for all", "status.show_thread": "Show thread", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 43f01924cc8b62..9635dcac92eb51 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -1,5 +1,6 @@ { "account.account_note_header": "メモ", + "account.add_or_remove_from_circle": "サークルから追加または外す", "account.add_or_remove_from_list": "リストから追加または外す", "account.badges.bot": "Bot", "account.badges.group": "Group", @@ -65,8 +66,21 @@ "bundle_modal_error.close": "閉じる", "bundle_modal_error.message": "コンポーネントの読み込み中に問題が発生しました。", "bundle_modal_error.retry": "再試行", + "circle.add_new_circle": "(新しいサークルを追加)", + "circle.open_circle_column": "サークルカラムを開く", + "circle.reply": "(現在のサークルにリプライ)", + "circle.select": "対象のサークルを選択", + "circle.unselect": "(サークルを選択する)", + "circles.account.add": "サークルに追加", + "circles.account.remove": "サークルから外す", + "circles.edit.submit": "タイトルを変更", + "circles.new.create": "サークルを作成", + "circles.new.title_placeholder": "新規サークル名", + "circles.search": "フォローされている人の中から検索", + "circles.subheading": "あなたのサークル", "column.blocks": "ブロックしたユーザー", "column.bookmarks": "ブックマーク", + "column.circles": "サークル", "column.community": "ローカルタイムライン", "column.direct": "ダイレクトメッセージ", "column.directory": "ディレクトリ", @@ -93,6 +107,7 @@ "compose_form.direct_message_warning": "この投稿はメンションされた人にのみ送信されます。", "compose_form.direct_message_warning_learn_more": "もっと詳しく", "compose_form.hashtag_warning": "この投稿は公開設定ではないのでハッシュタグの一覧に表示されません。公開投稿だけがハッシュタグで検索できます。", + "compose_form.limited_message_warning": "この投稿はサークルに含まれるユーザーにのみ送信されます。", "compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。", "compose_form.lock_disclaimer.lock": "承認制", "compose_form.placeholder": "今なにしてる?", @@ -116,6 +131,8 @@ "confirmations.block.message": "本当に{name}さんをブロックしますか?", "confirmations.delete.confirm": "削除", "confirmations.delete.message": "本当に削除しますか?", + "confirmations.delete_circle.confirm": "削除", + "confirmations.delete_circle.message": "本当にこのサークルを完全に削除しますか?", "confirmations.delete_list.confirm": "削除", "confirmations.delete_list.message": "本当にこのリストを完全に削除しますか?", "confirmations.discard_edit_media.confirm": "Discard", @@ -278,6 +295,7 @@ "navigation_bar.apps": "アプリ", "navigation_bar.blocks": "ブロックしたユーザー", "navigation_bar.bookmarks": "ブックマーク", + "navigation_bar.circles": "サークル", "navigation_bar.community_timeline": "ローカルタイムライン", "navigation_bar.compose": "投稿の新規作成", "navigation_bar.direct": "ダイレクトメッセージ", @@ -352,6 +370,8 @@ "privacy.change": "公開範囲を変更", "privacy.direct.long": "送信した相手のみ閲覧可", "privacy.direct.short": "ダイレクト", + "privacy.limited.long": "サークルで指定したユーザーのみ閲覧可", + "privacy.limited.short": "サークル", "privacy.private.long": "フォロワーのみ閲覧可", "privacy.private.short": "フォロワー限定", "privacy.public.long": "誰でも閲覧可、公開TLに表示", @@ -422,6 +442,7 @@ "status.share": "共有", "status.show_less": "隠す", "status.show_less_all": "全て隠す", + "status.show_member_list": "メンバーリストを表示", "status.show_more": "もっと見る", "status.show_more_all": "全て見る", "status.show_thread": "スレッドを表示", diff --git a/app/javascript/mastodon/reducers/circle_adder.js b/app/javascript/mastodon/reducers/circle_adder.js new file mode 100644 index 00000000000000..41c9eacac791af --- /dev/null +++ b/app/javascript/mastodon/reducers/circle_adder.js @@ -0,0 +1,47 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + CIRCLE_ADDER_RESET, + CIRCLE_ADDER_SETUP, + CIRCLE_ADDER_CIRCLES_FETCH_REQUEST, + CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS, + CIRCLE_ADDER_CIRCLES_FETCH_FAIL, + CIRCLE_EDITOR_ADD_SUCCESS, + CIRCLE_EDITOR_REMOVE_SUCCESS, +} from '../actions/circles'; + +const initialState = ImmutableMap({ + accountId: null, + + circles: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), +}); + +export default function circleAdderReducer(state = initialState, action) { + switch(action.type) { + case CIRCLE_ADDER_RESET: + return initialState; + case CIRCLE_ADDER_SETUP: + return state.withMutations(map => { + map.set('accountId', action.account.get('id')); + }); + case CIRCLE_ADDER_CIRCLES_FETCH_REQUEST: + return state.setIn(['circles', 'isLoading'], true); + case CIRCLE_ADDER_CIRCLES_FETCH_FAIL: + return state.setIn(['circles', 'isLoading'], false); + case CIRCLE_ADDER_CIRCLES_FETCH_SUCCESS: + return state.update('circles', circles => circles.withMutations(map => { + map.set('isLoading', false); + map.set('loaded', true); + map.set('items', ImmutableList(action.circles.map(item => item.id))); + })); + case CIRCLE_EDITOR_ADD_SUCCESS: + return state.updateIn(['circles', 'items'], circle => circle.unshift(action.circleId)); + case CIRCLE_EDITOR_REMOVE_SUCCESS: + return state.updateIn(['circles', 'items'], circle => circle.filterNot(item => item === action.circleId)); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/circle_editor.js b/app/javascript/mastodon/reducers/circle_editor.js new file mode 100644 index 00000000000000..e730b972728791 --- /dev/null +++ b/app/javascript/mastodon/reducers/circle_editor.js @@ -0,0 +1,96 @@ +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; +import { + CIRCLE_CREATE_REQUEST, + CIRCLE_CREATE_FAIL, + CIRCLE_CREATE_SUCCESS, + CIRCLE_UPDATE_REQUEST, + CIRCLE_UPDATE_FAIL, + CIRCLE_UPDATE_SUCCESS, + CIRCLE_EDITOR_RESET, + CIRCLE_EDITOR_SETUP, + CIRCLE_EDITOR_TITLE_CHANGE, + CIRCLE_ACCOUNTS_FETCH_REQUEST, + CIRCLE_ACCOUNTS_FETCH_SUCCESS, + CIRCLE_ACCOUNTS_FETCH_FAIL, + CIRCLE_EDITOR_SUGGESTIONS_READY, + CIRCLE_EDITOR_SUGGESTIONS_CLEAR, + CIRCLE_EDITOR_SUGGESTIONS_CHANGE, + CIRCLE_EDITOR_ADD_SUCCESS, + CIRCLE_EDITOR_REMOVE_SUCCESS, +} from '../actions/circles'; + +const initialState = ImmutableMap({ + circleId: null, + isSubmitting: false, + isChanged: false, + title: '', + + accounts: ImmutableMap({ + items: ImmutableList(), + loaded: false, + isLoading: false, + }), + + suggestions: ImmutableMap({ + value: '', + items: ImmutableList(), + }), +}); + +export default function circleEditorReducer(state = initialState, action) { + switch(action.type) { + case CIRCLE_EDITOR_RESET: + return initialState; + case CIRCLE_EDITOR_SETUP: + return state.withMutations(map => { + map.set('circleId', action.circle.get('id')); + map.set('title', action.circle.get('title')); + map.set('isSubmitting', false); + }); + case CIRCLE_EDITOR_TITLE_CHANGE: + return state.withMutations(map => { + map.set('title', action.value); + map.set('isChanged', true); + }); + case CIRCLE_CREATE_REQUEST: + case CIRCLE_UPDATE_REQUEST: + return state.withMutations(map => { + map.set('isSubmitting', true); + map.set('isChanged', false); + }); + case CIRCLE_CREATE_FAIL: + case CIRCLE_UPDATE_FAIL: + return state.set('isSubmitting', false); + case CIRCLE_CREATE_SUCCESS: + case CIRCLE_UPDATE_SUCCESS: + return state.withMutations(map => { + map.set('isSubmitting', false); + map.set('circleId', action.circle.id); + }); + case CIRCLE_ACCOUNTS_FETCH_REQUEST: + return state.setIn(['accounts', 'isLoading'], true); + case CIRCLE_ACCOUNTS_FETCH_FAIL: + return state.setIn(['accounts', 'isLoading'], false); + case CIRCLE_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 CIRCLE_EDITOR_SUGGESTIONS_CHANGE: + return state.setIn(['suggestions', 'value'], action.value); + case CIRCLE_EDITOR_SUGGESTIONS_READY: + return state.setIn(['suggestions', 'items'], ImmutableList(action.accounts.map(item => item.id))); + case CIRCLE_EDITOR_SUGGESTIONS_CLEAR: + return state.update('suggestions', suggestions => suggestions.withMutations(map => { + map.set('items', ImmutableList()); + map.set('value', ''); + })); + case CIRCLE_EDITOR_ADD_SUCCESS: + return state.updateIn(['accounts', 'items'], circle => circle.unshift(action.accountId)); + case CIRCLE_EDITOR_REMOVE_SUCCESS: + return state.updateIn(['accounts', 'items'], circle => circle.filterNot(item => item === action.accountId)); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/circles.js b/app/javascript/mastodon/reducers/circles.js new file mode 100644 index 00000000000000..354bee8c1f6924 --- /dev/null +++ b/app/javascript/mastodon/reducers/circles.js @@ -0,0 +1,37 @@ +import { + CIRCLE_FETCH_SUCCESS, + CIRCLE_FETCH_FAIL, + CIRCLES_FETCH_SUCCESS, + CIRCLE_CREATE_SUCCESS, + CIRCLE_UPDATE_SUCCESS, + CIRCLE_DELETE_SUCCESS, +} from '../actions/circles'; +import { Map as ImmutableMap, fromJS } from 'immutable'; + +const initialState = ImmutableMap(); + +const normalizeCircle = (state, circle) => state.set(circle.id, fromJS(circle)); + +const normalizeCircles = (state, circles) => { + circles.forEach(circle => { + state = normalizeCircle(state, circle); + }); + + return state; +}; + +export default function circles(state = initialState, action) { + switch(action.type) { + case CIRCLE_FETCH_SUCCESS: + case CIRCLE_CREATE_SUCCESS: + case CIRCLE_UPDATE_SUCCESS: + return normalizeCircle(state, action.circle); + case CIRCLES_FETCH_SUCCESS: + return normalizeCircles(state, action.circles); + case CIRCLE_DELETE_SUCCESS: + case CIRCLE_FETCH_FAIL: + return state.set(action.id, false); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 06a908e9d46447..6f2a1fa8853c21 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -28,6 +28,7 @@ import { COMPOSE_SPOILERNESS_CHANGE, COMPOSE_SPOILER_TEXT_CHANGE, COMPOSE_VISIBILITY_CHANGE, + COMPOSE_CIRCLE_CHANGE, COMPOSE_COMPOSING_CHANGE, COMPOSE_EMOJI_INSERT, COMPOSE_UPLOAD_CHANGE_REQUEST, @@ -58,11 +59,13 @@ const initialState = ImmutableMap({ spoiler: false, spoiler_text: '', privacy: null, + circle_id: null, text: '', focusDate: null, caretPosition: null, preselectDate: null, in_reply_to: null, + reply_status: null, is_composing: false, is_submitting: false, is_changing_upload: false, @@ -95,17 +98,31 @@ const initialPoll = ImmutableMap({ multiple: false, }); -function statusToTextMentions(state, status) { - let set = ImmutableOrderedSet([]); +const statusToTextMentions = (text, privacy, status) => { + if(status === null) { + return text; + } + + let mentions = ImmutableOrderedSet(); if (status.getIn(['account', 'id']) !== me) { - set = set.add(`@${status.getIn(['account', 'acct'])} `); + mentions = mentions.add(`@${status.getIn(['account', 'acct'])} `); } - return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join(''); + mentions = mentions.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)); + + const match = /^(\s*(?:(?:@\S+)\s*)*)([\s\S]*)/.exec(text); + const extrctMentions = ImmutableOrderedSet(match[1].trim().split(/\s+/).filter(Boolean).map(mention => `${mention} `)); + const others = match[2]; + + if(privacy === 'limited') { + return extrctMentions.subtract(mentions).add(others).join(''); + } else { + return mentions.union(extrctMentions).add(others).join(''); + } }; -function clearAll(state) { +const clearAll = state => { return state.withMutations(map => { map.set('text', ''); map.set('spoiler', false); @@ -113,7 +130,9 @@ function clearAll(state) { map.set('is_submitting', false); map.set('is_changing_upload', false); map.set('in_reply_to', null); + map.set('reply_status', null); map.set('privacy', state.get('default_privacy')); + map.set('circle_id', null); map.set('sensitive', false); map.update('media_attachments', list => list.clear()); map.set('poll', null); @@ -121,7 +140,7 @@ function clearAll(state) { }); }; -function appendMedia(state, media, file) { +const appendMedia = (state, media, file) => { const prevSize = state.get('media_attachments').size; return state.withMutations(map => { @@ -140,7 +159,7 @@ function appendMedia(state, media, file) { }); }; -function removeMedia(state, mediaId) { +const removeMedia = (state, mediaId) => { const prevSize = state.get('media_attachments').size; return state.withMutations(map => { @@ -207,7 +226,7 @@ const insertEmoji = (state, position, emojiData, needsSpace) => { }; const privacyPreference = (a, b) => { - const order = ['public', 'unlisted', 'private', 'direct']; + const order = ['public', 'unlisted', 'private', 'limited', 'direct']; return order[Math.max(order.indexOf(a), order.indexOf(b), 0)]; }; @@ -302,8 +321,15 @@ export default function compose(state = initialState, action) { .set('spoiler_text', action.text) .set('idempotencyKey', uuid()); case COMPOSE_VISIBILITY_CHANGE: + return state.withMutations(map => { + map.set('text', statusToTextMentions(state.get('text'), action.value, state.get('reply_status'))); + map.set('privacy', action.value); + map.set('idempotencyKey', uuid()); + map.set('circle_id', null); + }); + case COMPOSE_CIRCLE_CHANGE: return state - .set('privacy', action.value) + .set('circle_id', action.value) .set('idempotencyKey', uuid()); case COMPOSE_CHANGE: return state @@ -312,10 +338,14 @@ export default function compose(state = initialState, action) { case COMPOSE_COMPOSING_CHANGE: return state.set('is_composing', action.value); case COMPOSE_REPLY: + const privacy = privacyPreference(action.status.get('visibility'), state.get('default_privacy')); + return state.withMutations(map => { map.set('in_reply_to', action.status.get('id')); - map.set('text', statusToTextMentions(state, action.status)); - map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); + map.set('reply_status', action.status); + map.set('text', statusToTextMentions('', privacy, action.status)); + map.set('privacy', privacy); + map.set('circle_id', null); map.set('focusDate', new Date()); map.set('caretPosition', null); map.set('preselectDate', new Date()); @@ -333,10 +363,12 @@ export default function compose(state = initialState, action) { case COMPOSE_RESET: return state.withMutations(map => { map.set('in_reply_to', null); + map.set('reply_status', null); map.set('text', ''); map.set('spoiler', false); map.set('spoiler_text', ''); map.set('privacy', state.get('default_privacy')); + map.set('circle_id', null); map.set('poll', null); map.set('idempotencyKey', uuid()); }); @@ -400,6 +432,7 @@ export default function compose(state = initialState, action) { return state.withMutations(map => { map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' ')); map.set('privacy', 'direct'); + map.set('circle_id', null); map.set('focusDate', new Date()); map.set('caretPosition', null); map.set('idempotencyKey', uuid()); @@ -439,7 +472,9 @@ export default function compose(state = initialState, action) { return state.withMutations(map => { map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status))); map.set('in_reply_to', action.status.get('in_reply_to_id')); + map.set('reply_status', action.replyStatus); map.set('privacy', action.status.get('visibility')); + map.set('circle_id', action.status.get('circle_id')); map.set('media_attachments', action.status.get('media_attachments')); map.set('focusDate', new Date()); map.set('caretPosition', null); diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 53e2dd681ee5f1..ff22cbb7718828 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -28,6 +28,9 @@ import custom_emojis from './custom_emojis'; import lists from './lists'; import listEditor from './list_editor'; import listAdder from './list_adder'; +import circles from './circles'; +import circleEditor from './circle_editor'; +import circleAdder from './circle_adder'; import filters from './filters'; import conversations from './conversations'; import suggestions from './suggestions'; @@ -71,6 +74,9 @@ const reducers = { lists, listEditor, listAdder, + circles, + circleEditor, + circleAdder, filters, conversations, suggestions, diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js index 10aaa2d682d6b0..76c6655a0ee61b 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -26,6 +26,7 @@ import { import { REBLOGS_FETCH_SUCCESS, FAVOURITES_FETCH_SUCCESS, + MENTIONS_FETCH_SUCCESS, } from '../actions/interactions'; import { BLOCKS_FETCH_REQUEST, @@ -64,6 +65,7 @@ const initialState = ImmutableMap({ following: initialListState, reblogged_by: initialListState, favourited_by: initialListState, + mentioned_by: initialListState, follow_requests: initialListState, blocks: initialListState, mutes: initialListState, @@ -115,6 +117,8 @@ export default function userLists(state = initialState, action) { return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id))); case FAVOURITES_FETCH_SUCCESS: return state.setIn(['favourited_by', action.id], ImmutableList(action.accounts.map(item => item.id))); + case MENTIONS_FETCH_SUCCESS: + return state.setIn(['mentioned_by', action.id], ImmutableList(action.accounts.map(item => item.id))); case NOTIFICATIONS_UPDATE: return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; case FOLLOW_REQUESTS_FETCH_SUCCESS: diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index 8e6b0cdd5b8cc8..7424a2f018b87c 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -120,7 +120,9 @@ html { .getting-started, .scrollable { - .column-link { + .column-link, + .circle-edit-button, + .circle-delete-button { background: $white; border-bottom: 1px solid lighten($ui-base-color, 8%); @@ -145,6 +147,7 @@ html { .poll__option input[type="text"], .compose-form .spoiler-input__input, .compose-form__poll-wrapper select, +.circle-dropdown, .search__input, .setting-text, .box-widget input[type="text"], @@ -168,7 +171,8 @@ html { border-bottom: 0; } -.compose-form__poll-wrapper select { +.compose-form__poll-wrapper select, +.circle-dropdown__menu { background: $simple-background-color url("data:image/svg+xml;utf8,") no-repeat right 8px center / auto 16px; } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 0a62e6b829bb1f..1389b3b63a8556 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -1085,6 +1085,11 @@ padding-bottom: 1px; } +.status__link { + color: inherit; + text-decoration: none; +} + .status__visibility-icon { padding: 0 4px; } @@ -6562,6 +6567,136 @@ noscript { } } +.circle-link { + display: flex; + + .circle-edit-button { + flex: 1 1 auto; + } + + .circle-delete-button { + flex: 0 0 auto; + } + + .circle-edit-button, + .circle-delete-button { + background: lighten($ui-base-color, 8%); + color: $primary-text-color; + padding: 15px; + margin: 0; + font-size: 16px; + text-align: left; + text-decoration: none; + cursor: pointer; + border: 0; + + &:hover, + &:focus, + &:active { + background: lighten($ui-base-color, 11%); + } + + &:focus { + outline: 0; + } + } +} + +.circle-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; + } +} + +.circle-adder { + 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%; + } + + &__account { + background: lighten($ui-base-color, 13%); + } + + &__lists { + background: lighten($ui-base-color, 13%); + height: 50vh; + border-radius: 0 0 8px 8px; + overflow-y: auto; + } + + .circle { + padding: 10px; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + + .circle__wrapper { + display: flex; + } + + .circle__display-name { + flex: 1 1 auto; + overflow: hidden; + text-decoration: none; + font-size: 16px; + padding: 10px; + } +} + .focal-point { position: relative; cursor: move; @@ -7443,3 +7578,57 @@ noscript { text-align: center; } } + +.circle-dropdown { + margin-top: 10px; + position: relative; + display: none; + background-color: $simple-background-color; + border: 1px solid darken($simple-background-color, 14%); + border-radius: 4px; + + &.circle-dropdown--visible { + display: flex; + } + + &__icon { + padding: 6px 4px; + flex: 0 0 auto; + font-size: 18px; + color: $inverted-text-color; + } + + &__menu { + flex: 1 1 auto; + appearance: none; + box-sizing: border-box; + font-size: 14px; + line-height: 14px; + text-align: left; + color: $inverted-text-color; + display: inline-block; + width: 100%; + outline: 0; + font-family: inherit; + background: $simple-background-color url("data:image/svg+xml;utf8,") no-repeat right 8px center / auto 16px; + border: 0; + padding: 9px 30px 9px 4px; + + cursor: pointer; + transition: all 100ms ease-in; + transition-property: background-color, color; + + &:hover, + &:active, + &:focus { + background-color: rgba($action-button-color, 0.15); + transition: all 200ms ease-out; + transition-property: background-color, color; + } + + &:focus { + background-color: rgba($action-button-color, 0.3); + } + + } +} diff --git a/app/javascript/styles/mastodon/rtl.scss b/app/javascript/styles/mastodon/rtl.scss index ea7bb5113ce658..d09e8c0d4d79a9 100644 --- a/app/javascript/styles/mastodon/rtl.scss +++ b/app/javascript/styles/mastodon/rtl.scss @@ -474,4 +474,18 @@ body.rtl { right: auto; left: 20px; } + + .circle-dropdown .circle-dropdown__menu { + background-position: left 8px center; + } + + .circle-dropdown__menu { + text-align: right; + padding: 9px 4px 9px 30px; + } + + .circle-link .circle-edit-button, + .circle-link .circle-delete-button { + text-align: right; + } } diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 8a0dc9d33d4239..0190e26b89d61e 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -85,6 +85,7 @@ def process_status resolve_thread(@status) fetch_replies(@status) distribute(@status) + forward_for_conversation forward_for_reply end @@ -109,7 +110,7 @@ def process_status_params sensitive: @account.sensitized? || @object['sensitive'] || false, visibility: visibility_from_audience, thread: replied_to_status, - conversation: conversation_from_uri(@object['conversation']), + conversation: conversation_from_context, media_attachment_ids: process_attachments.take(4).map(&:id), poll: process_poll, } @@ -117,8 +118,10 @@ def process_status_params end def process_audience + conversation_uri = value_or_id(@object['context']) + (audience_to + audience_cc).uniq.each do |audience| - next if ActivityPub::TagManager.instance.public_collection?(audience) + next if ActivityPub::TagManager.instance.public_collection?(audience) || audience == conversation_uri # Unlike with tags, there is no point in resolving accounts we don't already # know here, because silent mentions would only be used for local access @@ -340,15 +343,45 @@ def fetch_replies(status) ActivityPub::FetchRepliesWorker.perform_async(status.id, uri) unless uri.nil? end - def conversation_from_uri(uri) - return nil if uri.nil? - return Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if OStatus::TagManager.instance.local_id?(uri) + def conversation_from_context + atom_uri = @object['conversation'] + + conversation = begin + if atom_uri.present? && OStatus::TagManager.instance.local_id?(atom_uri) + Conversation.find_by(id: OStatus::TagManager.instance.unique_tag_to_local_id(atom_uri, 'Conversation')) + elsif atom_uri.present? && @object['context'].present? + Conversation.find_by(uri: atom_uri) + elsif atom_uri.present? + begin + Conversation.find_or_create_by!(uri: atom_uri) + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique + retry + end + end + end + + return conversation if @object['context'].nil? - begin - Conversation.find_or_create_by!(uri: uri) - rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique - retry + uri = value_or_id(@object['context']) + conversation ||= ActivityPub::TagManager.instance.uri_to_resource(uri, Conversation) + + return conversation if (conversation.present? && (conversation.local? || conversation.uri == uri)) || !uri.start_with?('https://') + + conversation_json = begin + if @object['context'].is_a?(Hash) && !invalid_origin?(uri) + @object['context'] + else + fetch_resource(uri, true) + end end + + return conversation if conversation_json.blank? + + conversation ||= Conversation.new + conversation.uri = uri + conversation.inbox_url = conversation_json['inbox'] + conversation.save! if conversation.changed? + conversation end def visibility_from_audience @@ -492,6 +525,12 @@ def tombstone_exists? Tombstone.exists?(uri: object_uri) end + def forward_for_conversation + return unless audience_to.include?(value_or_id(@object['context'])) && @json['signature'].present? && @status.conversation.local? + + ActivityPub::ForwardDistributionWorker.perform_async(@status.conversation_id, Oj.dump(@json)) + end + def forward_for_reply return unless @status.distributable? && @json['signature'].present? && reply_to_local? diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb index f6b9741fa1820e..bbf88f5013ae5a 100644 --- a/app/lib/activitypub/tag_manager.rb +++ b/app/lib/activitypub/tag_manager.rb @@ -25,8 +25,11 @@ def url_for(target) when :person target.instance_actor? ? about_more_url(instance_actor: true) : short_account_url(target) when :note, :comment, :activity - return activity_account_status_url(target.account, target) if target.reblog? - short_account_status_url(target.account, target) + if target.reblog? + activity_account_status_url(target.account, target) + else + short_account_status_url(target.account, target) + end end end @@ -37,10 +40,15 @@ def uri_for(target) when :person target.instance_actor? ? instance_actor_url : account_url(target) when :note, :comment, :activity - return activity_account_status_url(target.account, target) if target.reblog? - account_status_url(target.account, target) + if target.reblog? + activity_account_status_url(target.account, target) + else + account_status_url(target.account, target) + end when :emoji emoji_url(target) + when :conversation + context_url(target) end end @@ -78,7 +86,9 @@ def to(status) [COLLECTIONS[:public]] when 'unlisted', 'private' [account_followers_url(status.account)] - when 'direct', 'limited' + when 'limited' + status.conversation_id.present? ? [uri_for(status.conversation)] : [] + when 'direct' if status.account.silenced? # Only notify followers if the account is locally silenced account_ids = status.active_mentions.pluck(:account_id) @@ -116,7 +126,7 @@ def cc(status) cc << COLLECTIONS[:public] end - unless status.direct_visibility? || status.limited_visibility? + unless status.direct_visibility? if status.account.silenced? # Only notify followers if the account is locally silenced account_ids = status.active_mentions.pluck(:account_id) diff --git a/app/models/account.rb b/app/models/account.rb index 203ee7b612dd04..761c5b95ced6d1 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -449,59 +449,58 @@ def search_for(terms, limit = 10, offset = 0) AND accounts.suspended_at IS NULL AND accounts.moved_to_account_id IS NULL ORDER BY rank DESC - LIMIT ? OFFSET ? + LIMIT :limit OFFSET :offset SQL - records = find_by_sql([sql, limit, offset]) + records = find_by_sql([sql, { limit: limit, offset: offset }]) ActiveRecord::Associations::Preloader.new.preload(records, :account_stat) records end - def advanced_search_for(terms, account, limit = 10, following = false, offset = 0) + def advanced_search_for(terms, account, limit = 10, offset = 0, options = {}) textsearch, query = generate_query_for_search(terms) - if following - sql = <<-SQL.squish - WITH first_degree AS ( - SELECT target_account_id - FROM follows - WHERE account_id = ? - UNION ALL - SELECT ? - ) - 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 = ?) - WHERE accounts.id IN (SELECT * FROM first_degree) - AND #{query} @@ #{textsearch} - AND accounts.suspended_at IS NULL - AND accounts.moved_to_account_id IS NULL - GROUP BY accounts.id - ORDER BY rank DESC - LIMIT ? OFFSET ? - SQL - - records = find_by_sql([sql, account.id, account.id, account.id, limit, offset]) - 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_at IS NULL - AND accounts.moved_to_account_id IS NULL - GROUP BY accounts.id - ORDER BY rank DESC - LIMIT ? OFFSET ? - SQL - - records = find_by_sql([sql, account.id, account.id, limit, offset]) - end + sql_where_group = <<-SQL if options[:group] + AND accounts.actor_type = 'Group' + SQL + sql = if options[:following] || options[:followers] + sql_first_degree = first_degree(options) + + <<-SQL.squish + #{sql_first_degree} + 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 = :account_id) + WHERE accounts.id IN (SELECT * FROM first_degree) + AND #{query} @@ #{textsearch} + AND accounts.suspended_at IS NULL + AND accounts.moved_to_account_id IS NULL + #{sql_where_group} + GROUP BY accounts.id + ORDER BY rank DESC + LIMIT :limit OFFSET :offset + SQL + else + <<-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 = :account_id) OR (accounts.id = f.target_account_id AND f.account_id = :account_id) + WHERE #{query} @@ #{textsearch} + AND accounts.suspended_at IS NULL + AND accounts.moved_to_account_id IS NULL + #{sql_where_group} + GROUP BY accounts.id + ORDER BY rank DESC + LIMIT :limit OFFSET :offset + SQL + end + + records = find_by_sql([sql, { account_id: account.id, limit: limit, offset: offset }]) ActiveRecord::Associations::Preloader.new.preload(records, :account_stat) records end @@ -523,6 +522,44 @@ def from_text(text) private + def first_degree(options) + if options[:following] && options[:followers] + <<-SQL + WITH first_degree AS ( + SELECT target_account_id + FROM follows + WHERE account_id = :account_id + UNION ALL + SELECT account_id + FROM follows + WHERE target_account_id = :account_id + UNION ALL + SELECT :account_id + ) + SQL + elsif options[:following] + <<-SQL + WITH first_degree AS ( + SELECT target_account_id + FROM follows + WHERE account_id = :account_id + UNION ALL + SELECT :account_id + ) + SQL + elsif options[:followers] + <<-SQL + WITH first_degree AS ( + SELECT account_id + FROM follows + WHERE target_account_id = :account_id + UNION ALL + SELECT :account_id + ) + SQL + end + end + def generate_query_for_search(terms) terms = Arel.sql(connection.quote(terms.gsub(/['?\\:]/, ' '))) textsearch = "(setweight(to_tsvector('simple', accounts.display_name), 'A') || setweight(to_tsvector('simple', accounts.username), 'B') || setweight(to_tsvector('simple', coalesce(accounts.domain, '')), 'C'))" diff --git a/app/models/circle.rb b/app/models/circle.rb new file mode 100644 index 00000000000000..dcbf47a2e74a81 --- /dev/null +++ b/app/models/circle.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: circles +# +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# title :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# +class Circle < ApplicationRecord + include Paginable + + belongs_to :account + + has_many :circle_accounts, inverse_of: :circle, dependent: :destroy + has_many :accounts, through: :circle_accounts + + validates :title, presence: true +end diff --git a/app/models/circle_account.rb b/app/models/circle_account.rb new file mode 100644 index 00000000000000..6366d6efa26cd2 --- /dev/null +++ b/app/models/circle_account.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: circle_accounts +# +# id :bigint(8) not null, primary key +# circle_id :bigint(8) not null +# account_id :bigint(8) not null +# follow_id :bigint(8) not null +# created_at :datetime not null +# updated_at :datetime not null +# +class CircleAccount < ApplicationRecord + belongs_to :circle + belongs_to :account + belongs_to :follow, optional: true + + validates :account_id, uniqueness: { scope: :circle_id } + + before_validation :set_follow + + private + + def set_follow + self.follow = Follow.find_by!(target_account_id: circle.account_id, account_id: account.id) + end +end diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index f9e7a3bea7730b..4ea1fdbca6f242 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -47,9 +47,12 @@ module AccountAssociations # Lists (that the account is on, not owned by the account) has_many :list_accounts, inverse_of: :account, dependent: :destroy has_many :lists, through: :list_accounts + has_many :circle_accounts, inverse_of: :account, dependent: :destroy + has_many :circles, through: :circle_accounts # Lists (owned by the account) has_many :owned_lists, class_name: 'List', dependent: :destroy, inverse_of: :account + has_many :owned_circles, class_name: 'Circle', dependent: :destroy, inverse_of: :account # Account migrations belongs_to :moved_to_account, class_name: 'Account', optional: true diff --git a/app/models/conversation.rb b/app/models/conversation.rb index 4dfaea889da495..873600b0d0f4fa 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -3,18 +3,44 @@ # # Table name: conversations # -# id :bigint(8) not null, primary key -# uri :string -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint(8) not null, primary key +# uri :string +# created_at :datetime not null +# updated_at :datetime not null +# parent_status_id :bigint(8) +# parent_account_id :bigint(8) +# inbox_url :string # class Conversation < ApplicationRecord validates :uri, uniqueness: true, if: :uri? - has_many :statuses + belongs_to :parent_status, class_name: 'Status', optional: true, inverse_of: :conversation + belongs_to :parent_account, class_name: 'Account', optional: true + + has_many :statuses, inverse_of: :conversation + + scope :local, -> { where(uri: nil) } + + before_validation :set_parent_account, on: :create + + after_create :set_conversation_on_parent_status def local? uri.nil? end + + def object_type + :conversation + end + + private + + def set_parent_account + self.parent_account = parent_status.account if parent_status.present? + end + + def set_conversation_on_parent_status + parent_status.update_column(:conversation_id, id) if parent_status.present? + end end diff --git a/app/models/mention.rb b/app/models/mention.rb index d01a88e32eb976..7667d748dc7757 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -12,6 +12,8 @@ # class Mention < ApplicationRecord + include Paginable + belongs_to :account, inverse_of: :mentions belongs_to :status diff --git a/app/models/status.rb b/app/models/status.rb index 749a23718f207a..5c696dafaa4229 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -33,6 +33,7 @@ class Status < ApplicationRecord include Cacheable include StatusThreadingConcern include RateLimitable + include Redisable rate_limit by: :account, family: :statuses @@ -42,17 +43,21 @@ class Status < ApplicationRecord # will be based on current time instead of `created_at` attr_accessor :override_timestamps - update_index('statuses', :proper) + attr_accessor :circle - enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility + update_index('statuses#status', :proper) + + enum visibility: [:public, :unlisted, :private, :mutual, :direct, :limited], _suffix: :visibility belongs_to :application, class_name: 'Doorkeeper::Application', optional: true belongs_to :account, inverse_of: :statuses belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true - belongs_to :conversation, optional: true + belongs_to :conversation, optional: true, inverse_of: :statuses belongs_to :preloadable_poll, class_name: 'Poll', foreign_key: 'poll_id', optional: true + has_one :owned_conversation, class_name: 'Conversation', foreign_key: 'parent_status_id', inverse_of: :parent_status + belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true @@ -63,6 +68,7 @@ class Status < ApplicationRecord has_many :mentions, dependent: :destroy, inverse_of: :status has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status has_many :media_attachments, dependent: :nullify + has_many :capability_tokens, class_name: 'StatusCapabilityToken', inverse_of: :status, dependent: :destroy has_and_belongs_to_many :tags has_and_belongs_to_many :preview_cards @@ -209,7 +215,9 @@ def distributable? public_visibility? || unlisted_visibility? end - alias sign? distributable? + def sign? + distributable? || limited_visibility? + end def with_media? media_attachments.any? @@ -260,13 +268,14 @@ def decrement_count!(key) around_create Mastodon::Snowflake::Callbacks - before_validation :prepare_contents, if: :local? - before_validation :set_reblog - before_validation :set_visibility - before_validation :set_conversation - before_validation :set_local + before_validation :prepare_contents, on: :create, if: :local? + before_validation :set_reblog, on: :create + before_validation :set_visibility, on: :create + before_validation :set_conversation, on: :create + before_validation :set_local, on: :create after_create :set_poll_id + after_create :set_circle class << self def selectable_visibilities @@ -393,10 +402,23 @@ def set_conversation if reply? && !thread.nil? self.in_reply_to_account_id = carried_over_reply_to_account_id - self.conversation_id = thread.conversation_id if conversation_id.nil? - elsif conversation_id.nil? - self.conversation = Conversation.new end + + if conversation_id.nil? + if reply? && !thread.nil? && circle.nil? + self.conversation_id = thread.conversation_id + else + build_owned_conversation + end + end + end + + def set_circle + redis.setex(circle_id_key, 3.days.seconds, circle.id) if circle.present? + end + + def circle_id_key + "statuses/#{id}/circle_id" end def carried_over_reply_to_account_id diff --git a/app/models/status_capability_token.rb b/app/models/status_capability_token.rb new file mode 100644 index 00000000000000..1613569ded4c1b --- /dev/null +++ b/app/models/status_capability_token.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: status_capability_tokens +# +# id :bigint(8) not null, primary key +# status_id :bigint(8) +# token :string +# created_at :datetime not null +# updated_at :datetime not null +# +class StatusCapabilityToken < ApplicationRecord + belongs_to :status + + validates :token, presence: true + + before_validation :generate_token, on: :create + + private + + def generate_token + self.token = Doorkeeper::OAuth::Helpers::UniqueToken.generate + end +end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index bcf9c3395ca974..2bb07958640eaf 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -7,6 +7,8 @@ def initialize(current_account, record, preloaded_relations = {}) @preloaded_relations = preloaded_relations end + delegate :reply?, to: :record + def index? staff? end @@ -41,6 +43,10 @@ def update? staff? end + def show_mentions? + limited? && owned? && (!reply? || record.thread.conversation_id != record.conversation_id) + end + private def requires_mention? @@ -55,6 +61,10 @@ def private? record.private_visibility? end + def limited? + record.limited_visibility? + end + def mention_exists? return false if current_account.nil? diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb index 5d174767fea38d..1198375b48a39c 100644 --- a/app/presenters/activitypub/activity_presenter.rb +++ b/app/presenters/activitypub/activity_presenter.rb @@ -20,6 +20,8 @@ def from_status(status) else ActivityPub::TagManager.instance.uri_for(status.proper) end + elsif status.limited_visibility? + "bear:?#{{ u: ActivityPub::TagManager.instance.uri_for(status.proper), t: status.capability_tokens.first.token }.to_query}" else status.proper end diff --git a/app/serializers/activitypub/context_serializer.rb b/app/serializers/activitypub/context_serializer.rb new file mode 100644 index 00000000000000..99ef9a73b9a8e2 --- /dev/null +++ b/app/serializers/activitypub/context_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ActivityPub::ContextSerializer < ActivityPub::Serializer + include RoutingHelper + + attributes :id, :type, :inbox + + def id + ActivityPub::TagManager.instance.uri_for(object) + end + + def type + 'Group' + end + + def inbox + account_inbox_url(object.parent_account) + end +end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 7c52b634dd12cb..515ac301ad96d5 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -7,7 +7,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer :in_reply_to, :published, :url, :attributed_to, :to, :cc, :sensitive, :atom_uri, :in_reply_to_atom_uri, - :conversation + :conversation, :context attribute :content attribute :content_map, if: :language? @@ -125,6 +125,12 @@ def conversation end end + def context + return if object.conversation.nil? + + ActivityPub::TagManager.instance.uri_for(object.conversation) + end + def local? object.account.local? end diff --git a/app/serializers/rest/circle_serializer.rb b/app/serializers/rest/circle_serializer.rb new file mode 100644 index 00000000000000..f2c9c510fc01bf --- /dev/null +++ b/app/serializers/rest/circle_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class REST::CircleSerializer < ActiveModel::Serializer + attributes :id, :title + + def id + object.id.to_s + end +end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index bb6df90b7a61f5..cc40a5f3dc1515 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -4,13 +4,14 @@ class REST::StatusSerializer < ActiveModel::Serializer attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id, :sensitive, :spoiler_text, :visibility, :language, :uri, :url, :replies_count, :reblogs_count, - :favourites_count + :favourites_count, :limited attribute :favourited, if: :current_user? attribute :reblogged, if: :current_user? attribute :muted, if: :current_user? attribute :bookmarked, if: :current_user? attribute :pinned, if: :pinnable? + attribute :circle_id, if: :limited_owned_parent_status? attribute :content, unless: :source_requested? attribute :text, if: :source_requested? @@ -43,8 +44,12 @@ def current_user? !current_user.nil? end + def owned_status? + current_user? && current_user.account_id == object.account_id + end + def show_application? - object.account.user_shows_application? || (current_user? && current_user.account_id == object.account_id) + object.account.user_shows_application? || owned_status? end def visibility @@ -66,6 +71,18 @@ def sensitive end end + def limited + object.limited_visibility? + end + + def limited_owned_parent_status? + object.limited_visibility? && owned_status? && (!object.reply? || object.thread.conversation_id != object.conversation_id) + end + + def circle_id + Redis.current.get("statuses/#{object.id}/circle_id") + end + def uri ActivityPub::TagManager.instance.uri_for(object) end @@ -119,8 +136,7 @@ def pinned end def pinnable? - current_user? && - current_user.account_id == object.account_id && + owned_status? && !object.reblog? && %w(public unlisted).include?(object.visibility) end diff --git a/app/services/account_search_service.rb b/app/services/account_search_service.rb index 6fe4b6593afbbe..e9217a1dd36ebd 100644 --- a/app/services/account_search_service.rb +++ b/app/services/account_search_service.rb @@ -61,7 +61,7 @@ def from_database end def advanced_search_results - Account.advanced_search_for(terms_for_query, account, limit_for_non_exact_results, options[:following], offset) + Account.advanced_search_for(terms_for_query, account, limit_for_non_exact_results, offset, options) end def simple_search_results @@ -74,12 +74,19 @@ def from_elasticsearch if account return [] if options[:following] && following_ids.empty? + return [] if options[:followers] && followers_ids.empty? if options[:following] must_clauses << { terms: { id: following_ids } } elsif following_ids.any? should_clauses << { terms: { id: following_ids, boost: 100 } } end + + must_clauses << { terms: { id: followers_ids } } if options[:followers] + + if options[:group_only] + must_clauses << { term: { actor_type: 'group' } } + end end query = { bool: { must: must_clauses, should: should_clauses } } @@ -134,6 +141,10 @@ def following_ids @following_ids ||= account.active_relationships.pluck(:target_account_id) + [account.id] end + def followers_ids + @followers_ids ||= account.passive_relationships.pluck(:account_id) + [account.id] + end + def limit_for_non_exact_results if exact_match? limit - 1 diff --git a/app/services/concerns/payloadable.rb b/app/services/concerns/payloadable.rb index 3e45570c3468a2..2302534fa16e26 100644 --- a/app/services/concerns/payloadable.rb +++ b/app/services/concerns/payloadable.rb @@ -7,7 +7,7 @@ def serialize_payload(record, serializer, options = {}) payload = ActiveModelSerializers::SerializableResource.new(record, options.merge(serializer: serializer, adapter: ActivityPub::Adapter)).as_json object = record.respond_to?(:virtual_object) ? record.virtual_object : record - if (object.respond_to?(:sign?) && object.sign?) && signer && signing_enabled? + if (object.respond_to?(:sign?) && object.sign?) && signer && signing_enabled? || object.is_a?(String) ActivityPub::LinkedDataSignature.new(payload).sign!(signer, sign_with: sign_with) else payload diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 294ae43eb2f9b7..be0ac9622691fd 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -15,6 +15,9 @@ class PostStatusService < BaseService # @option [String] :spoiler_text # @option [String] :language # @option [String] :scheduled_at + # @option [String] :expires_at + # @option [String] :expires_action + # @option [Circle] :circle Optional circle to target the status to # @option [Hash] :poll Optional poll to attach # @option [Enumerable] :media_ids Optional array of media IDs to attach # @option [Doorkeeper::Application] :application @@ -26,6 +29,7 @@ def call(account, options = {}) @options = options @text = @options[:text] || '' @in_reply_to = @options[:thread] + @circle = @options[:circle] return idempotency_duplicate if idempotency_given? && idempotency_duplicate? @@ -48,12 +52,14 @@ def call(account, options = {}) private def preprocess_attributes! - @sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present? - @text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present? - @visibility = @options[:visibility] || @account.user&.setting_default_privacy - @visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced? - @scheduled_at = @options[:scheduled_at]&.to_datetime - @scheduled_at = nil if scheduled_in_the_past? + @sensitive = (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present? + @text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present? + @visibility = @options[:visibility] || @account.user&.setting_default_privacy + @visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced? + @visibility = :limited if @circle.present? + @visibility = :limited if @visibility&.to_sym != :direct && @in_reply_to&.limited_visibility? + @scheduled_at = @options[:scheduled_at]&.to_datetime + @scheduled_at = nil if scheduled_in_the_past? rescue ArgumentError raise ActiveRecord::RecordInvalid end @@ -64,10 +70,11 @@ def process_status! ApplicationRecord.transaction do @status = @account.statuses.create!(status_attributes) + @status.capability_tokens.create! if @status.limited_visibility? end - process_hashtags_service.call(@status) - process_mentions_service.call(@status) + ProcessHashtagsService.new.call(@status) + ProcessMentionsService.new.call(@status, @circle) end def schedule_status! @@ -113,14 +120,6 @@ def language_from_option(str) ISO_639.find(str)&.alpha2 end - def process_mentions_service - ProcessMentionsService.new - end - - def process_hashtags_service - ProcessHashtagsService.new - end - def scheduled? @scheduled_at.present? end @@ -165,6 +164,7 @@ def status_attributes sensitive: @sensitive, spoiler_text: @options[:spoiler_text] || '', visibility: @visibility, + circle: @circle, language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account), application: @options[:application], rate_limit: @options[:with_rate_limit], diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 73dbb18345a7e6..50351a0e17bb70 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -7,7 +7,8 @@ class ProcessMentionsService < BaseService # local mention pointers, send Salmon notifications to mentioned # remote users # @param [Status] status - def call(status) + # @param [Circle] circle + def call(status, circle = nil) return unless status.local? @status = status @@ -42,8 +43,26 @@ def call(status) "@#{mentioned_account.acct}" end + if circle.present? + circle.accounts.find_each do |target_account| + status.mentions.create(silent: true, account: target_account) + end + elsif status.limited_visibility? && status.thread&.limited_visibility? + # If we are replying to a local status, then we'll have the complete + # audience copied here, both local and remote. If we are replying + # to a remote status, only local audience will be copied. Then we + # need to send our reply to the remote author's inbox for distribution + + status.thread.mentions.includes(:account).find_each do |mention| + status.mentions.create(silent: true, account: mention.account) unless status.account_id == mention.account_id + end + + status.mentions.create(silent: true, account: status.thread.account) unless status.account_id == status.thread.account_id + end + status.save! + # Silent mentions need to be delivered separately mentions.each { |mention| create_notification(mention) } end diff --git a/app/workers/activitypub/distribution_worker.rb b/app/workers/activitypub/distribution_worker.rb index 09898ca49eece1..e3d3a0851bb00d 100644 --- a/app/workers/activitypub/distribution_worker.rb +++ b/app/workers/activitypub/distribution_worker.rb @@ -12,8 +12,10 @@ def perform(status_id) return if skip_distribution? - ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| - [payload, @account.id, inbox_url, { synchronize_followers: !@status.distributable? }] + if delegate_distribution? + deliver_to_parent! + else + deliver_to_inboxes! end relay! if relayable? @@ -24,22 +26,44 @@ def perform(status_id) private def skip_distribution? - @status.direct_visibility? || @status.limited_visibility? + @status.direct_visibility? + end + + def delegate_distribution? + @status.limited_visibility? && @status.reply? && !@status.conversation.local? end def relayable? @status.public_visibility? end + def deliver_to_parent! + return if @status.conversation.inbox_url.blank? + + ActivityPub::DeliveryWorker.perform_async(payload, @account.id, @status.conversation.inbox_url) + end + + def deliver_to_inboxes! + ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url| + [payload, @account.id, inbox_url, { synchronize_followers: !@status.distributable? }] + end + end + def inboxes - # Deliver the status to all followers. - # If the status is a reply to another local status, also forward it to that - # status' authors' followers. - @inboxes ||= if @status.in_reply_to_local_account? && @status.distributable? - @account.followers.or(@status.thread.account.followers).inboxes - else - @account.followers.inboxes - end + # Deliver the status to all followers. If the status is a reply + # to another local status, also forward it to that status' + # authors' followers. If the status has limited visibility, + # deliver it to inboxes of people mentioned (no shared ones) + + @inboxes ||= begin + if @status.limited_visibility? + DeliveryFailureTracker.without_unavailable(Account.remote.joins(:mentions).merge(@status.mentions).pluck(:inbox_url)) + elsif @status.in_reply_to_local_account? && @status.distributable? + @account.delivery_followers.or(@status.thread.account.delivery_followers).inboxes + else + @account.delivery_followers.inboxes + end + end end def payload diff --git a/app/workers/activitypub/forward_distribution_worker.rb b/app/workers/activitypub/forward_distribution_worker.rb new file mode 100644 index 00000000000000..994da978be1c56 --- /dev/null +++ b/app/workers/activitypub/forward_distribution_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class ActivityPub::ForwardDistributionWorker < ActivityPub::DistributionWorker + include Sidekiq::Worker + + sidekiq_options queue: 'push' + + def perform(conversation_id, json) + conversation = Conversation.find(conversation_id) + + @status = conversation.parent_status + @account = conversation.parent_account + @json = json + + return if @status.nil? || @account.nil? + + deliver_to_inboxes! + rescue ActiveRecord::RecordNotFound + true + end + + private + + def payload + @json + end +end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index f78db865341679..f1efcf18bbde08 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -71,6 +71,7 @@ :'write:accounts', :'write:blocks', :'write:bookmarks', + :'write:circles', :'write:conversations', :'write:favourites', :'write:filters', @@ -85,6 +86,7 @@ :'read:accounts', :'read:blocks', :'read:bookmarks', + :'read:circles', :'read:favourites', :'read:filters', :'read:follows', diff --git a/config/locales/en.yml b/config/locales/en.yml index 8cecfa2a4dc521..c1191483fa86a2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1323,6 +1323,9 @@ en: title: '%{name}: "%{quote}"' visibilities: direct: Direct + direct_long: Only show to mentioned users + limited: Circle + limited_long: Only show to circle users private: Followers-only private_long: Only show to followers public: Public diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 85c22a0f4de1bb..ecdad279ed30c1 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -1239,6 +1239,9 @@ ja: title: '%{name}: "%{quote}"' visibilities: direct: ダイレクト + direct_long: 送信した相手のみ閲覧可 + limited: サークル + limited_long: サークルで指定したユーザーのみ閲覧可 private: フォロワー限定 private_long: フォロワーにのみ表示されます public: 公開 diff --git a/config/routes.rb b/config/routes.rb index 2357ab6c74b47e..c022101b42d3f7 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -84,6 +84,7 @@ end resource :inbox, only: [:create], module: :activitypub + resources :contexts, only: [:show], module: :activitypub get '/@:username', to: 'accounts#show', as: :short_account get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies @@ -332,6 +333,7 @@ scope module: :statuses do resources :reblogged_by, controller: :reblogged_by_accounts, only: :index resources :favourited_by, controller: :favourited_by_accounts, only: :index + resources :mentioned_by, controller: :mentioned_by_accounts, only: :index resource :reblog, only: :create post :unreblog, to: 'reblogs#destroy' @@ -464,6 +466,7 @@ resources :followers, only: :index, controller: 'accounts/follower_accounts' resources :following, only: :index, controller: 'accounts/following_accounts' resources :lists, only: :index, controller: 'accounts/lists' + resources :circles, only: :index, controller: 'accounts/circles' resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs' resources :featured_tags, only: :index, controller: 'accounts/featured_tags' @@ -486,6 +489,10 @@ resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts' end + resources :circles, only: [:index, :create, :show, :update, :destroy] do + resource :accounts, only: [:show, :create, :destroy], controller: 'circles/accounts' + end + namespace :featured_tags do get :suggestions, to: 'suggestions#index' end diff --git a/db/migrate/20200718225713_create_circles.rb b/db/migrate/20200718225713_create_circles.rb new file mode 100644 index 00000000000000..210d878cf8859f --- /dev/null +++ b/db/migrate/20200718225713_create_circles.rb @@ -0,0 +1,10 @@ +class CreateCircles < ActiveRecord::Migration[5.2] + def change + create_table :circles do |t| + t.belongs_to :account, foreign_key: { on_delete: :cascade }, null: false + t.string :title, default: '', null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20200718225817_create_circle_accounts.rb b/db/migrate/20200718225817_create_circle_accounts.rb new file mode 100644 index 00000000000000..988479b83508d6 --- /dev/null +++ b/db/migrate/20200718225817_create_circle_accounts.rb @@ -0,0 +1,14 @@ +class CreateCircleAccounts < ActiveRecord::Migration[5.2] + def change + create_table :circle_accounts do |t| + t.belongs_to :circle, foreign_key: { on_delete: :cascade }, null: false + t.belongs_to :account, foreign_key: { on_delete: :cascade }, null: false + t.belongs_to :follow, foreign_key: { on_delete: :cascade }, null: false + + t.timestamps + end + + add_index :circle_accounts, [:account_id, :circle_id], unique: true + add_index :circle_accounts, [:circle_id, :account_id] + end +end diff --git a/db/migrate/20200825232828_create_status_capability_tokens.rb b/db/migrate/20200825232828_create_status_capability_tokens.rb new file mode 100644 index 00000000000000..926e75f1d8c4c7 --- /dev/null +++ b/db/migrate/20200825232828_create_status_capability_tokens.rb @@ -0,0 +1,10 @@ +class CreateStatusCapabilityTokens < ActiveRecord::Migration[5.2] + def change + create_table :status_capability_tokens do |t| + t.belongs_to :status, foreign_key: true + t.string :token + + t.timestamps + end + end +end diff --git a/db/migrate/20200827204602_add_inbox_url_to_conversations.rb b/db/migrate/20200827204602_add_inbox_url_to_conversations.rb new file mode 100644 index 00000000000000..8f152b2957c1fc --- /dev/null +++ b/db/migrate/20200827204602_add_inbox_url_to_conversations.rb @@ -0,0 +1,7 @@ +class AddInboxURLToConversations < ActiveRecord::Migration[5.2] + def change + add_column :conversations, :parent_status_id, :bigint, null: true, default: nil + add_column :conversations, :parent_account_id, :bigint, null: true, default: nil + add_column :conversations, :inbox_url, :string, null: true, default: nil + end +end diff --git a/db/migrate/20200827205543_conversation_ids_to_timestamp_ids.rb b/db/migrate/20200827205543_conversation_ids_to_timestamp_ids.rb new file mode 100644 index 00000000000000..32eae1f4f9e0c6 --- /dev/null +++ b/db/migrate/20200827205543_conversation_ids_to_timestamp_ids.rb @@ -0,0 +1,15 @@ +class ConversationIdsToTimestampIds < ActiveRecord::Migration[5.2] + def up + safety_assured do + execute("ALTER TABLE conversations ALTER COLUMN id SET DEFAULT timestamp_id('conversations')") + end + + Mastodon::Snowflake.ensure_id_sequences_exist + end + + def down + execute("LOCK conversations") + execute("SELECT setval('conversations_id_seq', (SELECT MAX(id) FROM conversations))") + execute("ALTER TABLE conversations ALTER COLUMN id SET DEFAULT nextval('conversations_id_seq')") + end +end diff --git a/db/schema.rb b/db/schema.rb index ee5eb5c7b72a9b..9560db27c14d0c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -280,16 +280,40 @@ t.index ["reference_account_id"], name: "index_canonical_email_blocks_on_reference_account_id" end + create_table "circle_accounts", force: :cascade do |t| + t.bigint "circle_id", null: false + t.bigint "account_id", null: false + t.bigint "follow_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "circle_id"], name: "index_circle_accounts_on_account_id_and_circle_id", unique: true + t.index ["account_id"], name: "index_circle_accounts_on_account_id" + t.index ["circle_id", "account_id"], name: "index_circle_accounts_on_circle_id_and_account_id" + t.index ["circle_id"], name: "index_circle_accounts_on_circle_id" + t.index ["follow_id"], name: "index_circle_accounts_on_follow_id" + end + + create_table "circles", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "title", default: "", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_circles_on_account_id" + end + create_table "conversation_mutes", force: :cascade do |t| t.bigint "conversation_id", null: false t.bigint "account_id", null: false t.index ["account_id", "conversation_id"], name: "index_conversation_mutes_on_account_id_and_conversation_id", unique: true end - create_table "conversations", force: :cascade do |t| + create_table "conversations", id: :bigint, default: -> { "timestamp_id('conversations'::text)" }, force: :cascade do |t| t.string "uri" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "parent_status_id" + t.bigint "parent_account_id" + t.string "inbox_url" t.index ["uri"], name: "index_conversations_on_uri", unique: true end @@ -812,6 +836,14 @@ t.index ["var"], name: "index_site_uploads_on_var", unique: true end + create_table "status_capability_tokens", force: :cascade do |t| + t.bigint "status_id" + t.string "token" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["status_id"], name: "index_status_capability_tokens_on_status_id" + end + create_table "status_pins", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "status_id", null: false @@ -1024,6 +1056,10 @@ add_foreign_key "bookmarks", "accounts", on_delete: :cascade add_foreign_key "bookmarks", "statuses", on_delete: :cascade add_foreign_key "canonical_email_blocks", "accounts", column: "reference_account_id", on_delete: :cascade + add_foreign_key "circle_accounts", "accounts", on_delete: :cascade + add_foreign_key "circle_accounts", "circles", on_delete: :cascade + add_foreign_key "circle_accounts", "follows", on_delete: :cascade + add_foreign_key "circles", "accounts", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade @@ -1078,6 +1114,7 @@ add_foreign_key "scheduled_statuses", "accounts", on_delete: :cascade add_foreign_key "session_activations", "oauth_access_tokens", column: "access_token_id", name: "fk_957e5bda89", on_delete: :cascade add_foreign_key "session_activations", "users", name: "fk_e5fda67334", on_delete: :cascade + add_foreign_key "status_capability_tokens", "statuses" add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade add_foreign_key "status_pins", "statuses", on_delete: :cascade add_foreign_key "status_stats", "statuses", on_delete: :cascade diff --git a/spec/controllers/statuses_controller_spec.rb b/spec/controllers/statuses_controller_spec.rb index 9986efa51d27d9..d32da1b76f2251 100644 --- a/spec/controllers/statuses_controller_spec.rb +++ b/spec/controllers/statuses_controller_spec.rb @@ -80,7 +80,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns public Cache-Control header' do @@ -105,7 +105,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it_behaves_like 'cachable response' @@ -204,7 +204,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns no Cache-Control header' do @@ -229,7 +229,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns public Cache-Control header' do @@ -268,7 +268,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns no Cache-Control header' do @@ -293,7 +293,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns private Cache-Control header' do @@ -355,7 +355,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns no Cache-Control header' do @@ -380,7 +380,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns private Cache-Control header' do @@ -468,7 +468,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns no Cache-Control header' do @@ -493,7 +493,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it_behaves_like 'cachable response' @@ -530,7 +530,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns no Cache-Control header' do @@ -555,7 +555,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns private Cache-Control header' do @@ -617,7 +617,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns no Cache-Control header' do @@ -642,7 +642,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns private Cache-Control header' do @@ -823,7 +823,7 @@ end it 'returns Vary header' do - expect(response.headers['Vary']).to eq 'Accept' + expect(response.headers['Vary']).to eq 'Accept, Authorization' end it 'returns public Cache-Control header' do diff --git a/spec/fabricators/circle_account_fabricator.rb b/spec/fabricators/circle_account_fabricator.rb new file mode 100644 index 00000000000000..7f461ad6cae88f --- /dev/null +++ b/spec/fabricators/circle_account_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:circle_account) do + circle nil + account nil + follow nil +end diff --git a/spec/fabricators/circle_fabricator.rb b/spec/fabricators/circle_fabricator.rb new file mode 100644 index 00000000000000..96ba0137c49f03 --- /dev/null +++ b/spec/fabricators/circle_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:circle) do + account + title "Family" +end diff --git a/spec/fabricators/status_capability_token_fabricator.rb b/spec/fabricators/status_capability_token_fabricator.rb new file mode 100644 index 00000000000000..51c1d4120524a4 --- /dev/null +++ b/spec/fabricators/status_capability_token_fabricator.rb @@ -0,0 +1,2 @@ +Fabricator(:status_capability_token) do +end diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 2703c18f39a0e7..8e88d238f30d0f 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -13,17 +13,22 @@ }.with_indifferent_access end + let(:delivered_to_account_id) { nil } + + let(:dereferenced_object_json) { nil } + before do sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender)) stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png')) stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' }) + stub_request(:get, 'http://example.com/object/123').to_return(body: Oj.dump(dereferenced_object_json), headers: { 'Content-Type' => 'application/activitypub+json' }) end describe '#perform' do context 'when fetching' do - subject { described_class.new(json, sender) } + subject { described_class.new(json, sender, delivered_to_account_id: delivered_to_account_id) } before do subject.perform @@ -43,6 +48,54 @@ end end + context 'when object is a URI' do + let(:object_json) { 'http://example.com/object/123' } + + let(:dereferenced_object_json) do + { + id: 'http://example.com/object/123', + type: 'Note', + content: 'Lorem ipsum', + to: 'https://www.w3.org/ns/activitystreams#Public', + } + end + + it 'dereferences object from URI' do + expect(a_request(:get, 'http://example.com/object/123')).to have_been_made.once + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.visibility).to eq 'public' + end + end + + context 'when object is a bearcap' do + let(:object_json) { 'bear:?u=http://example.com/object/123&t=hoge' } + + let(:dereferenced_object_json) do + { + id: 'http://example.com/object/123', + type: 'Note', + content: 'Lorem ipsum', + } + end + + it 'dereferences object from URI' do + expect(a_request(:get, 'http://example.com/object/123').with(headers: { 'Authorization' => 'Bearer hoge' })).to have_been_made.once + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.uri).to eq 'http://example.com/object/123' + expect(status.visibility).to eq 'direct' + end + end + context 'standalone' do let(:object_json) do { @@ -218,12 +271,15 @@ context 'limited' do let(:recipient) { Fabricate(:account) } + let(:delivered_to_account_id) { recipient.id } + let(:object_json) do { id: [ActivityPub::TagManager.instance.uri_for(sender), '#bar'].join, type: 'Note', content: 'Lorem ipsum', - to: ActivityPub::TagManager.instance.uri_for(recipient), + to: [], + cc: [], } end @@ -236,7 +292,7 @@ it 'creates silent mention' do status = sender.statuses.first - expect(status.mentions.first).to be_silent + expect(status.mentions.find_by(account: recipient)).to be_silent end end diff --git a/spec/models/circle_account_spec.rb b/spec/models/circle_account_spec.rb new file mode 100644 index 00000000000000..23c11dd42317e5 --- /dev/null +++ b/spec/models/circle_account_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe CircleAccount, type: :model do +end diff --git a/spec/models/circle_spec.rb b/spec/models/circle_spec.rb new file mode 100644 index 00000000000000..4e0c151fd50c32 --- /dev/null +++ b/spec/models/circle_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe Circle, type: :model do +end diff --git a/spec/models/status_capability_token_spec.rb b/spec/models/status_capability_token_spec.rb new file mode 100644 index 00000000000000..50657ef2718e3d --- /dev/null +++ b/spec/models/status_capability_token_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe StatusCapabilityToken, type: :model do +end diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index d21270c7938946..6ed4366994f802 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -54,6 +54,26 @@ end end + it 'creates a new status for a given circle' do + account = Fabricate(:account) + circle = Fabricate(:circle, account: account) + + circle_accounts = Fabricate.times(4, :account) + + circle_accounts.each do |target_account| + target_account.follow!(account) + circle.accounts << target_account + end + + text = 'Circle... hello' + status = subject.call(account, text: text, circle: circle) + + expect(status).to be_persisted + expect(status.text).to eq text + expect(status.visibility).to eq 'limited' + expect(status.mentions.map(&:account)).to include(*circle_accounts) + end + it 'creates response to the original status of boost' do boosted_status = Fabricate(:status) in_reply_to_status = Fabricate(:status, reblog: boosted_status) @@ -138,7 +158,7 @@ status = subject.call(account, text: "test status update") expect(ProcessMentionsService).to have_received(:new) - expect(mention_service).to have_received(:call).with(status) + expect(mention_service).to have_received(:call).with(status, nil) end it 'processes hashtags' do diff --git a/spec/workers/activitypub/distribution_worker_spec.rb b/spec/workers/activitypub/distribution_worker_spec.rb index 368ca025a0dba5..4701f75ce9af71 100644 --- a/spec/workers/activitypub/distribution_worker_spec.rb +++ b/spec/workers/activitypub/distribution_worker_spec.rb @@ -9,6 +9,8 @@ describe '#perform' do before do allow(ActivityPub::DeliveryWorker).to receive(:push_bulk) + allow(ActivityPub::DeliveryWorker).to receive(:perform_async) + follower.follow!(status.account) end @@ -34,6 +36,40 @@ end end + context 'with limited status' do + before do + status.update(visibility: :limited) + status.capability_tokens.create! + end + + context 'standalone' do + before do + 2.times do |i| + status.mentions.create!(silent: true, account: Fabricate(:account, username: "bob#{i}", domain: "example#{i}.com", inbox_url: "https://example#{i}.com/inbox")) + end + end + + it 'delivers to personal inboxes' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['https://example0.com/inbox', 'https://example1.com/inbox']) + end + end + + context 'when it\'s a reply' do + let(:conversation) { Fabricate(:conversation, uri: 'https://example.com/123', inbox_url: 'https://example.com/123/inbox') } + let(:parent) { Fabricate(:status, visibility: :limited, account: Fabricate(:account, username: 'alice', domain: 'example.com', inbox_url: 'https://example.com/inbox'), conversation: conversation) } + + before do + status.update(thread: parent, conversation: conversation) + end + + it 'delivers to inbox of conversation only' do + subject.perform(status.id) + expect(ActivityPub::DeliveryWorker).to have_received(:perform_async).once + end + end + end + context 'with direct status' do before do status.update(visibility: :direct) From 00d79ec79680b6dd8f7ec2f10e04db74862cf159 Mon Sep 17 00:00:00 2001 From: noellabo Date: Sat, 17 Jul 2021 11:44:59 +0900 Subject: [PATCH 2/9] fixup! Add the ability to change to a new circle by replying to a circle (cherry picked from commit 8588361ca4f962729966cad9e5f9dd36eeb473a9) --- app/javascript/mastodon/features/circles/index.js | 3 +-- app/javascript/mastodon/features/mentions/index.js | 4 +--- app/lib/activitypub/activity/create.rb | 6 ++++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/app/javascript/mastodon/features/circles/index.js b/app/javascript/mastodon/features/circles/index.js index c5afdb356c105f..2c1329f3d9a969 100644 --- a/app/javascript/mastodon/features/circles/index.js +++ b/app/javascript/mastodon/features/circles/index.js @@ -48,7 +48,7 @@ class Circles extends ImmutablePureComponent { } render () { - const { intl, shouldUpdateScroll, circles, multiColumn } = this.props; + const { intl, circles, multiColumn } = this.props; if (!circles) { return ( @@ -68,7 +68,6 @@ class Circles extends ImmutablePureComponent { } bindToDocument={!multiColumn} diff --git a/app/javascript/mastodon/features/mentions/index.js b/app/javascript/mastodon/features/mentions/index.js index 3f44b1471d0b07..d30cac0221a77e 100644 --- a/app/javascript/mastodon/features/mentions/index.js +++ b/app/javascript/mastodon/features/mentions/index.js @@ -22,7 +22,6 @@ class Mentions extends ImmutablePureComponent { static propTypes = { params: PropTypes.object.isRequired, dispatch: PropTypes.func.isRequired, - shouldUpdateScroll: PropTypes.func, accountIds: ImmutablePropTypes.list, multiColumn: PropTypes.bool, intl: PropTypes.object.isRequired, @@ -41,7 +40,7 @@ class Mentions extends ImmutablePureComponent { } render () { - const { shouldUpdateScroll, accountIds, multiColumn } = this.props; + const { accountIds, multiColumn } = this.props; if (!accountIds) { return ( @@ -62,7 +61,6 @@ class Mentions extends ImmutablePureComponent { diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 0190e26b89d61e..c24a382e922351 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -362,8 +362,9 @@ def conversation_from_context return conversation if @object['context'].nil? - uri = value_or_id(@object['context']) - conversation ||= ActivityPub::TagManager.instance.uri_to_resource(uri, Conversation) + uri = value_or_id(@object['context']) + context_conversation = ActivityPub::TagManager.instance.uri_to_resource(uri, Conversation) + conversation ||= context_conversation return conversation if (conversation.present? && (conversation.local? || conversation.uri == uri)) || !uri.start_with?('https://') @@ -377,6 +378,7 @@ def conversation_from_context return conversation if conversation_json.blank? + conversation = context_conversation if context_conversation.present? conversation ||= Conversation.new conversation.uri = uri conversation.inbox_url = conversation_json['inbox'] From 2b68170f60c10c014084dbf264e3be0849cccce3 Mon Sep 17 00:00:00 2001 From: noellabo Date: Sat, 26 Sep 2020 20:06:00 +0900 Subject: [PATCH 3/9] Fix limited_owned_parent_status? (cherry picked from commit a70fe3d8b2dc6a7faae3aec28b7152f6c2094223) --- app/serializers/rest/status_serializer.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index cc40a5f3dc1515..270919aff1bf4c 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -76,7 +76,7 @@ def limited end def limited_owned_parent_status? - object.limited_visibility? && owned_status? && (!object.reply? || object.thread.conversation_id != object.conversation_id) + object.limited_visibility? && owned_status? && (!object.reply? || object.thread&.conversation_id != object.conversation_id) end def circle_id From e99272d6444d6e2c76698324757ccf06d0be924b Mon Sep 17 00:00:00 2001 From: noellabo Date: Wed, 11 Nov 2020 01:35:31 +0900 Subject: [PATCH 4/9] Fix to not use bearcap when backing up status (cherry picked from commit d9560f668cd43f6870b0c71a79834223f5466d83) --- app/presenters/activitypub/activity_presenter.rb | 4 ++-- app/services/backup_service.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/presenters/activitypub/activity_presenter.rb b/app/presenters/activitypub/activity_presenter.rb index 1198375b48a39c..1f7e8dc430ee3b 100644 --- a/app/presenters/activitypub/activity_presenter.rb +++ b/app/presenters/activitypub/activity_presenter.rb @@ -4,7 +4,7 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model attributes :id, :type, :actor, :published, :to, :cc, :virtual_object class << self - def from_status(status) + def from_status(status, use_bearcap: true) new.tap do |presenter| presenter.id = ActivityPub::TagManager.instance.activity_uri_for(status) presenter.type = status.reblog? ? 'Announce' : 'Create' @@ -20,7 +20,7 @@ def from_status(status) else ActivityPub::TagManager.instance.uri_for(status.proper) end - elsif status.limited_visibility? + elsif status.limited_visibility? && use_bearcap "bear:?#{{ u: ActivityPub::TagManager.instance.uri_for(status.proper), t: status.capability_tokens.first.token }.to_query}" else status.proper diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 037f519d3e6c1f..fc03560226e926 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -22,7 +22,7 @@ def build_json! account.statuses.with_includes.reorder(nil).find_in_batches do |statuses| statuses.each do |status| - item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status), ActivityPub::ActivitySerializer, signer: @account) + item = serialize_payload(ActivityPub::ActivityPresenter.from_status(status, use_bearcap: false), ActivityPub::ActivitySerializer, signer: @account) item.delete(:'@context') unless item[:type] == 'Announce' || item[:object][:attachment].blank? From e59572cfdc22f0aef157d03ff54eb41245d8fe8a Mon Sep 17 00:00:00 2001 From: noellabo Date: Sat, 13 Feb 2021 14:47:19 +0900 Subject: [PATCH 5/9] Remove limited post in boost modal (cherry picked from commit 43d39e85eac864ccc40b9a8b4119dd68d67c9e59) --- .../mastodon/features/compose/components/privacy_dropdown.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index e25dd3840bb908..6d7247ca3d44c1 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -237,11 +237,11 @@ class PrivacyDropdown extends React.PureComponent { this.options = [ { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) }, - { icon: 'user-circle', value: 'limited', text: formatMessage(messages.limited_short), meta: formatMessage(messages.limited_long) }, ]; if (!this.props.noDirect) { this.options.push( + { icon: 'user-circle', value: 'limited', text: formatMessage(messages.limited_short), meta: formatMessage(messages.limited_long) }, { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) }, ); } From a5fef77f6c915566106011b497637542236a4f75 Mon Sep 17 00:00:00 2001 From: noellabo Date: Wed, 14 Apr 2021 10:34:45 +0900 Subject: [PATCH 6/9] Fix an error in circle posts containing mentions (cherry picked from commit 9811bb66eee6e2d5992a9e61b5145c7a2602ffa8) --- app/services/process_mentions_service.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 50351a0e17bb70..1aa07601e002e7 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -43,9 +43,11 @@ def call(status, circle = nil) "@#{mentioned_account.acct}" end + mentioned_account_ids = mentions.pluck(:account_id) + if circle.present? circle.accounts.find_each do |target_account| - status.mentions.create(silent: true, account: target_account) + status.mentions.find_or_create_by(silent: true, account: target_account) unless mentioned_account_ids.include?(target_account.id) end elsif status.limited_visibility? && status.thread&.limited_visibility? # If we are replying to a local status, then we'll have the complete @@ -54,10 +56,10 @@ def call(status, circle = nil) # need to send our reply to the remote author's inbox for distribution status.thread.mentions.includes(:account).find_each do |mention| - status.mentions.create(silent: true, account: mention.account) unless status.account_id == mention.account_id + status.mentions.create(silent: true, account: mention.account) unless status.account_id == mention.account_id && mentioned_account_ids.include?(mention.account.id) end - status.mentions.create(silent: true, account: status.thread.account) unless status.account_id == status.thread.account_id + status.mentions.create(silent: true, account: status.thread.account) unless status.account_id == status.thread.account_id && mentioned_account_ids.include?(status.thread.account.id) end status.save! From 49f362305153a9871ab8f9dcab7e4c4b9248a7d1 Mon Sep 17 00:00:00 2001 From: noellabo Date: Fri, 7 May 2021 11:07:04 +0900 Subject: [PATCH 7/9] Fix to delete StatusCapabilityToken when deleting status batch (cherry picked from commit 0e3d3e02b82a4f4a15a68fcb90c4698a6d14bf67) --- app/services/batched_remove_status_service.rb | 4 ++++ spec/services/batched_remove_status_service_spec.rb | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 5000062e41a503..0ee05fc19c4bc1 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -23,6 +23,10 @@ def call(statuses, **options) status.send(:unlink_from_conversations) end + status_ids_with_capability_tokens = statuses.local.joins(:capability_tokens).where.not(capability_tokens: { id: nil }).pluck(:id) + status_ids_with_capability_tokens += Status.where(reblog_of_id: statuses).local.joins(:capability_tokens).where.not(capability_tokens: { id: nil }).pluck(:id) + status_ids_with_capability_tokens.each_slice(50) { |slice| StatusCapabilityToken.where(status_id: slice).delete_all } + # We do not batch all deletes into one to avoid having a long-running # transaction lock the database, but we use the delete method instead # of destroy to avoid all callbacks. We rely on foreign keys to diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb index 4203952c60f313..65acfd3b60d4bc 100644 --- a/spec/services/batched_remove_status_service_spec.rb +++ b/spec/services/batched_remove_status_service_spec.rb @@ -23,7 +23,9 @@ status1 status2 - subject.call([status1, status2]) + statuses = Status.where(id: [status1.id, status2.id]) + + subject.call(statuses) end it 'removes statuses' do From 4f02e90a31874db0ffbba934200447ed93f23f20 Mon Sep 17 00:00:00 2001 From: noellabo Date: Fri, 30 Jul 2021 15:32:05 +0900 Subject: [PATCH 8/9] Fix CircleDropdown (cherry picked from commit 11e61bf89b2452e0594bc53801deb43ddd8d09f8) --- .../containers/circle_dropdown_container.js | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js index 958a903e27d760..9e2fb864537570 100644 --- a/app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js +++ b/app/javascript/mastodon/features/compose/containers/circle_dropdown_container.js @@ -2,16 +2,11 @@ import { connect } from 'react-redux'; import CircleDropdown from '../components/circle_dropdown'; import { changeComposeCircle } from '../../../actions/compose'; -const mapStateToProps = state => { - let value = state.getIn(['compose', 'circle_id']); - value = value === null ? '' : value; - - return { - value: value, - visible: state.getIn(['compose', 'privacy']) === 'limited', - limitedReply: state.getIn(['compose', 'privacy']) === 'limited' && state.getIn(['compose', 'reply_status', 'visibility']) === 'limited', - }; -}; +const mapStateToProps = state => ({ + value: state.getIn(['compose', 'circle_id']) ?? '', + visible: state.getIn(['compose', 'privacy']) === 'limited', + limitedReply: state.getIn(['compose', 'privacy']) === 'limited' && state.getIn(['compose', 'reply_status', 'visibility']) === 'limited', +}); const mapDispatchToProps = dispatch => ({ From 0462288009b89a8f6424390464501844cd2f0584 Mon Sep 17 00:00:00 2001 From: noellabo Date: Sun, 15 Aug 2021 14:06:47 +0900 Subject: [PATCH 9/9] Add translations for the missing empty circles (cherry picked from commit f8b8d2d33b878e3c3150493b36524252f035efc3) --- app/javascript/mastodon/locales/en.json | 1 + app/javascript/mastodon/locales/ja.json | 1 + 2 files changed, 2 insertions(+) diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 36231a16beb516..72ae5558de6125 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -179,6 +179,7 @@ "empty_column.account_unavailable": "Profile unavailable", "empty_column.blocks": "You haven't blocked any users yet.", "empty_column.bookmarked_statuses": "You don't have any bookmarked posts yet. When you bookmark one, it will show up here.", + "empty_column.circles": "You don't have any circles yet. When you create one, it will show up here.", "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.", "empty_column.domain_blocks": "There are no blocked domains yet.", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 9635dcac92eb51..61a7105d231f27 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -179,6 +179,7 @@ "empty_column.account_unavailable": "プロフィールは利用できません", "empty_column.blocks": "まだ誰もブロックしていません。", "empty_column.bookmarked_statuses": "まだ何もブックマーク登録していません。ブックマーク登録するとここに表示されます。", + "empty_column.circles": "サークルはまだありません。作成するとここに表示されます。", "empty_column.community": "ローカルタイムラインはまだ使われていません。何か書いてみましょう!", "empty_column.direct": "ダイレクトメッセージはまだありません。ダイレクトメッセージをやりとりすると、ここに表示されます。", "empty_column.domain_blocks": "ブロックしているドメインはありません。",