Add feature circle
Squashed commit of the following: commit 7b2ba61c4841e23081552fb79270e4e430dd1fe0 Author: noellabo <noel.yoshiba@gmail.com> 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 <noel.yoshiba@gmail.com> Date: Sat Sep 5 16:10:57 2020 +0900 fixup! add-limited-visibility-icon-to-status commit 679aa8a7f9bef42ee5d0b326d9ae4925a1999939 Author: noellabo <noel.yoshiba@gmail.com> Date: Sat Sep 5 15:12:53 2020 +0900 Fix 14666 commit b3addd8220d8bb3512ff345b32ca83c714dadd2a Author: noellabo <noel.yoshiba@gmail.com> Date: Sat Sep 5 11:44:12 2020 +0900 Add Japanese translation for circle commit b7f4b773a0cd554084d5ad6a5923adb06b3acfc4 Author: noellabo <noel.yoshiba@gmail.com> Date: Sat Sep 5 11:40:12 2020 +0900 Squashed commit of the following: commit b85a4685b27c49462288aba5f38723b91e936c4a Author: noellabo <noel.yoshiba@gmail.com> 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 <noel.yoshiba@gmail.com> Date: Sat Sep 5 09:33:07 2020 +0900 Change limited visibility icon commit b64adf19788d828249408454ec6afa9beb3d4872 Author: noellabo <noel.yoshiba@gmail.com> Date: Mon Aug 31 06:50:56 2020 +0900 Fix a change to limited-visibility-bearcaps replies commit ed361405b5e38857a2f42b0515a599ddcdd412cf Author: noellabo <noel.yoshiba@gmail.com> Date: Thu Aug 27 15:53:18 2020 +0900 Fix composer text when change visibility commit 4da3adddb6ffde43070d743e34c5b56e06579b30 Author: noellabo <noel.yoshiba@gmail.com> Date: Sat Aug 22 22:34:23 2020 +0900 Fix wrong circle_id when changing visibility commit 752d7fc2a3c9e34fab9993d767f83c6eae7ba55a Author: noellabo <noel.yoshiba@gmail.com> Date: Sun Aug 9 13:12:51 2020 +0900 Add circle reply and redraft commit 5978bc04a24695edce6717bda89dcf6f861ef2c4 Author: noellabo <noel.yoshiba@gmail.com> Date: Mon Jul 27 01:07:52 2020 +0900 Fix remove unused props commit 7970f69676c24b4aa9385fee8b1635c46ba52fcd Author: noellabo <noel.yoshiba@gmail.com> Date: Sun Jul 26 21:17:07 2020 +0900 Separate circle choice from privacy commit 36f6a684c0b0c895d4d0f1b9d09b05c91b104666 Author: noellabo <noel.yoshiba@gmail.com> Date: Thu Jul 23 10:54:25 2020 +0900 Add UI for posting to circles commit 7ef48003c1407275663dd603b124d292db2aa93a Author: noellabo <noel.yoshiba@gmail.com> Date: Fri Jul 24 12:55:10 2020 +0900 Fix silent mention by circle commit 7a1caed49333c3d3241301afb77639cdf1cabdc0 Author: noellabo <noel.yoshiba@gmail.com> Date: Sat Sep 5 11:38:10 2020 +0900 Squashed commit of the following: commit dca71fab86c830932ca760b7d8b3f89cc25c453e Author: noellabo <noel.yoshiba@gmail.com> 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 <noel.yoshiba@gmail.com> Date: Sat Sep 5 09:31:11 2020 +0900 Change limited visibility icon commit 9784f8b562f6592e9d9190ca29d2b2e870006d10 Author: noellabo <noel.yoshiba@gmail.com> Date: Thu Aug 13 21:52:07 2020 +0900 Add focus setting when opening the circle column commit a84f680c167fab9276550850c60f9108d251144e Author: noellabo <noel.yoshiba@gmail.com> Date: Thu Aug 13 15:55:27 2020 +0900 Fix message commit e3f11c4adac57b6e6a15c981ed6f4721a1634212 Author: noellabo <noel.yoshiba@gmail.com> Date: Mon Jul 27 01:01:23 2020 +0900 Fix light-theme commit d7d96eda5b86d3e3f654ce79888e7cf5aa535db5 Author: noellabo <noel.yoshiba@gmail.com> Date: Sun Jul 26 21:50:56 2020 +0900 Fix circles loading in share page and followers search commit 10b821f7b8c0a87cea3df51f09deeadc2cb40b32 Author: noellabo <noel.yoshiba@gmail.com> Date: Fri Jul 24 14:08:00 2020 +0900 Refactor list items commit e020072915572ce409039ccf799d08f8d8b5b393 Author: noellabo <noel.yoshiba@gmail.com> 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 <noel.yoshiba@gmail.com> Date: Wed Jul 22 08:49:47 2020 +0900 Add UI for managing circle members commit d7c3145b8fa84be0631bf7f41bb229f3e6d03ff1 Author: noellabo <noel.yoshiba@gmail.com> Date: Wed Jul 22 07:34:52 2020 +0900 Add the followers option to AccountSearchSercive commit 65e2b0c4299b72ede440b50089c1bd6afa6c9c05 Author: noellabo <noel.yoshiba@gmail.com> Date: Wed Jul 22 07:05:56 2020 +0900 Add CircleSerializer commit a639e1803abf5590068846dbe98bc5edfaa2ad82 Author: noellabo <noel.yoshiba@gmail.com> Date: Sat Sep 5 11:37:30 2020 +0900 Squashed commit of the following: commit 9cb3fb9d980e3ee066083076f508c5ab1447176a Author: noellabo <noel.yoshiba@gmail.com> Date: Sat Sep 5 07:15:19 2020 +0900 Move the link to the mention list to the menu commit b32dd87b43f4e09b8e2c437f1fb5d3ebd6221215 Author: noellabo <noel.yoshiba@gmail.com> Date: Sat Sep 5 00:56:12 2020 +0900 Change limited visibility icon commit 8db0d024119d1c2cef8de849f2501496a166a2dd Author: noellabo <noel.yoshiba@gmail.com> Date: Tue Sep 1 01:42:13 2020 +0900 Fix to disallow getting the list of mentions in limited replies commit 490a9d65a59a3dd0d86e81f6780e879dc4313dff Author: noellabo <noel.yoshiba@gmail.com> Date: Fri Jul 24 11:36:24 2020 +0900 Add column to list mentioned accounts of limited status commit 62a423ac2729c16f26fafe111f257bc373218df2 Author: noellabo <noel.yoshiba@gmail.com> Date: Thu Jul 23 13:30:17 2020 +0900 Fix visibility compatibility more commit a5cfa54b259054f41e89037f299fa928a2361818 Author: noellabo <noel.yoshiba@gmail.com> Date: Mon Jul 20 05:39:49 2020 +0900 Fix visibility compatibility commit 7900ca5650c77565b86ddc594a221dfa3b5321b4 Author: noellabo <noel.yoshiba@gmail.com> Date: Mon Jul 20 02:01:27 2020 +0900 Add limited visibility icon to status commit 66b83965ef068e9ee8c940249c68bcbde15731fe Author: Eugen Rochko <eugen@zeonfederated.com> Date: Wed Aug 26 03:16:47 2020 +0200 Add conversation-based forwarding for limited visibility statuses through bearcaps commit 561abc65e0ace89318b3952047025b8d98515fbb Author: Eugen Rochko <eugen@zeonfederated.com> 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.
This commit is contained in:
parent
020074c188
commit
a27fcf5e30
104 changed files with 2947 additions and 170 deletions
16
app/controllers/activitypub/contexts_controller.rb
Normal file
16
app/controllers/activitypub/contexts_controller.rb
Normal file
|
@ -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
|
18
app/controllers/api/v1/accounts/circles_controller.rb
Normal file
18
app/controllers/api/v1/accounts/circles_controller.rb
Normal file
|
@ -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
|
|
@ -17,6 +17,7 @@ class Api::V1::Accounts::SearchController < Api::BaseController
|
|||
current_account,
|
||||
limit: limit_param(DEFAULT_ACCOUNTS_LIMIT),
|
||||
resolve: truthy_param?(:resolve),
|
||||
followers: truthy_param?(:followers),
|
||||
following: truthy_param?(:following),
|
||||
group_only: truthy_param?(:group_only),
|
||||
offset: params[:offset]
|
||||
|
|
93
app/controllers/api/v1/circles/accounts_controller.rb
Normal file
93
app/controllers/api/v1/circles/accounts_controller.rb
Normal file
|
@ -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
|
73
app/controllers/api/v1/circles_controller.rb
Normal file
73
app/controllers/api/v1/circles_controller.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
@ -39,6 +40,7 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
@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],
|
||||
|
@ -81,10 +83,17 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
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,
|
||||
|
|
|
@ -25,7 +25,7 @@ module CacheConcern
|
|||
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)
|
||||
|
|
|
@ -65,7 +65,12 @@ class StatusesController < ApplicationController
|
|||
|
||||
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
|
||||
|
|
|
@ -100,8 +100,10 @@ module ApplicationHelper
|
|||
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
|
||||
|
|
|
@ -49,13 +49,12 @@ module JsonLdHelper
|
|||
!uri.start_with?('http://', 'https://')
|
||||
end
|
||||
|
||||
def same_origin?(url_a, url_b)
|
||||
Addressable::URI.parse(url_a).host.casecmp(Addressable::URI.parse(url_b).host).zero?
|
||||
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
|
||||
|
||||
!haystack.casecmp(needle).zero?
|
||||
unsupported_uri_scheme?(url) || !same_origin?(url, @account.uri)
|
||||
end
|
||||
|
||||
def canonicalize(json)
|
||||
|
|
372
app/javascript/mastodon/actions/circles.js
Normal file
372
app/javascript/mastodon/actions/circles.js
Normal file
|
@ -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'])));
|
||||
};
|
||||
|
|
@ -50,6 +50,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';
|
||||
|
||||
|
@ -171,6 +172,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),
|
||||
quote_id: getState().getIn(['compose', 'quote_from'], null),
|
||||
}, {
|
||||
|
@ -645,6 +647,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,
|
||||
|
|
|
@ -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');
|
||||
normalStatus.quote = normalOldStatus.get('quote');
|
||||
normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
|
||||
} else {
|
||||
|
@ -80,6 +81,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;
|
||||
|
||||
if (status.quote && status.quote.id) {
|
||||
const quote_spoilerText = status.quote.spoiler_text || '';
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -79,10 +79,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,
|
||||
};
|
||||
};
|
||||
|
@ -90,6 +91,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')]));
|
||||
|
@ -103,7 +105,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 => {
|
||||
|
|
|
@ -17,8 +17,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
|
||||
|
@ -81,6 +82,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' },
|
||||
});
|
||||
|
||||
|
@ -112,6 +114,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,
|
||||
|
@ -537,10 +540,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 = <Icon id={visibilityIcon.icon} title={visibilityIcon.text} />;
|
||||
|
||||
let quote = null;
|
||||
if (status.get('quote', null) !== null && typeof status.get('quote') === 'object') {
|
||||
|
@ -683,7 +688,7 @@ class Status extends ImmutablePureComponent {
|
|||
<div className='status__info'>
|
||||
{status.get('expires_at') && <span className='status__expiration-time'><time dateTime={expires_at} title={intl.formatDate(expires_date, dateFormatOptions)}><i className="fa fa-clock-o" aria-hidden="true"></i></time></span>}
|
||||
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
|
||||
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
|
||||
<span className='status__visibility-icon'>{visibilityLink}</span>
|
||||
|
||||
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} data-group={status.getIn(['account', 'group'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
|
||||
<div className='status__avatar'>
|
||||
|
|
|
@ -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}' },
|
||||
|
@ -68,6 +69,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
|||
onQuote: PropTypes.func,
|
||||
onDelete: PropTypes.func,
|
||||
onDirect: PropTypes.func,
|
||||
onMemberList: PropTypes.func,
|
||||
onMention: PropTypes.func,
|
||||
onMute: PropTypes.func,
|
||||
onUnmute: PropTypes.func,
|
||||
|
@ -162,6 +164,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');
|
||||
|
@ -248,6 +254,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 = [];
|
||||
|
||||
|
@ -269,6 +276,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) && !expired) {
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
menu.push(null);
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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());
|
||||
|
||||
export default class Mastodon extends React.PureComponent {
|
||||
|
||||
|
|
|
@ -174,6 +174,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));
|
||||
},
|
||||
|
|
|
@ -45,12 +45,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}' },
|
||||
});
|
||||
|
||||
|
@ -218,6 +220,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' });
|
||||
|
@ -238,6 +241,11 @@ class Header extends ImmutablePureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
|
||||
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 {
|
||||
|
|
|
@ -26,6 +26,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,
|
||||
};
|
||||
|
@ -102,6 +103,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);
|
||||
}
|
||||
|
@ -133,6 +138,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}
|
||||
/>
|
||||
|
|
|
@ -153,6 +153,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
}));
|
||||
},
|
||||
|
||||
onAddToCircle(account){
|
||||
dispatch(openModal('CIRCLE_ADDER', {
|
||||
accountId: account.get('id'),
|
||||
}));
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
|
||||
|
|
|
@ -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 (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
|
||||
} else {
|
||||
button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='circle'>
|
||||
<div className='circle__wrapper'>
|
||||
<div className='circle__display-name'>
|
||||
<Icon id='user-circle' className='column-link__icon' fixedWidth />
|
||||
{circle.get('title')}
|
||||
</div>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
73
app/javascript/mastodon/features/circle_adder/index.js
Normal file
73
app/javascript/mastodon/features/circle_adder/index.js
Normal file
|
@ -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 (
|
||||
<div className='modal-root__modal circle-adder'>
|
||||
<div className='circle-adder__account'>
|
||||
<Account accountId={accountId} />
|
||||
</div>
|
||||
|
||||
<NewCircleForm />
|
||||
|
||||
|
||||
<div className='circle-adder__circles'>
|
||||
{circleIds.map(CircleId => <Circle key={CircleId} circleId={CircleId} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
|
||||
} else {
|
||||
button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account'>
|
||||
<div className='account__wrapper'>
|
||||
<div className='account__display-name'>
|
||||
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
|
||||
<DisplayName account={account} />
|
||||
</div>
|
||||
|
||||
<div className='account__relationship'>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 (
|
||||
<form className='column-inline-form' onSubmit={this.handleSubmit}>
|
||||
<input
|
||||
className='setting-text'
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
icon='check'
|
||||
title={title}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 (
|
||||
<div className='circle-editor__search search'>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
|
||||
|
||||
<input
|
||||
className='search__input'
|
||||
type='text'
|
||||
value={value}
|
||||
onChange={this.handleChange}
|
||||
onKeyUp={this.handleKeyUp}
|
||||
placeholder={intl.formatMessage(messages.search)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
|
||||
<Icon id='search' className={classNames({ active: !hasValue })} />
|
||||
<Icon id='times-circle' aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
79
app/javascript/mastodon/features/circle_editor/index.js
Normal file
79
app/javascript/mastodon/features/circle_editor/index.js
Normal file
|
@ -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 (
|
||||
<div className='modal-root__modal circle-editor'>
|
||||
<EditCircleForm />
|
||||
|
||||
<Search />
|
||||
|
||||
<div className='drawer__pager'>
|
||||
<div className='drawer__inner circle-editor__accounts'>
|
||||
{accountIds.map(accountId => <Account key={accountId} accountId={accountId} added />)}
|
||||
</div>
|
||||
|
||||
{showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />}
|
||||
|
||||
<Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
|
||||
{({ x }) => (
|
||||
<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
|
||||
{searchAccountIds.map(accountId => <Account key={accountId} accountId={accountId} />)}
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 (
|
||||
<div className='circle-link'>
|
||||
<button className='circle-edit-button' onClick={this.handleEditClick}>
|
||||
<Icon id='user-circle' className='column-link__icon' fixedWidth />
|
||||
{text}
|
||||
</button>
|
||||
<button className='circle-delete-button' title={intl.formatMessage(messages.deleteConfirm)} onClick={this.handleDeleteClick}>
|
||||
<Icon id='trash' className='column-link__icon' fixedWidth />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 (
|
||||
<form className='column-inline-form' onSubmit={this.handleSubmit}>
|
||||
<label>
|
||||
<span style={{ display: 'none' }}>{label}</span>
|
||||
|
||||
<input
|
||||
className='setting-text'
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={this.handleChange}
|
||||
placeholder={label}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<IconButton
|
||||
disabled={disabled || !value}
|
||||
icon='plus'
|
||||
title={title}
|
||||
onClick={this.handleClick}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
84
app/javascript/mastodon/features/circles/index.js
Normal file
84
app/javascript/mastodon/features/circles/index.js
Normal file
|
@ -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 (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.circles' defaultMessage="You don't have any circles yet. When you create one, it will show up here." />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} icon='user-circle' heading={intl.formatMessage(messages.heading)}>
|
||||
<ColumnBackButtonSlim />
|
||||
|
||||
<NewCircleForm />
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='circles'
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
prepend={<ColumnSubheading text={intl.formatMessage(messages.subheading)} />}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{circles.map(circle =>
|
||||
<Circle key={`${circle.get('id')}-${circle.get('title')}`} id={circle.get('id')} text={circle.get('title')} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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' });
|
||||
|
|
|
@ -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 (
|
||||
<div className={classNames('circle-dropdown', { 'circle-dropdown--visible': visible })}>
|
||||
<IconButton icon='user-circle' className='circle-dropdown__icon' title={intl.formatMessage(messages.circle_open_circle_column)} style={{ width: 'auto', height: 'auto' }} onClick={this.handleOpenCircleColumn} />
|
||||
|
||||
{circles.isEmpty() && !limitedReply ?
|
||||
<button className='circle-dropdown__menu' onClick={this.handleOpenCircleColumn}>{intl.formatMessage(messages.circle_add_new_circle)}</button>
|
||||
:
|
||||
/* eslint-disable-next-line jsx-a11y/no-onchange */
|
||||
<select className='circle-dropdown__menu' title={intl.formatMessage(messages.circle_select)} value={value} onChange={this.handleChange}>
|
||||
<option value='' key='unselect'>{intl.formatMessage(limitedReply ? messages.circle_reply : messages.circle_unselect)}</option>
|
||||
{circles.map(circle =>
|
||||
<option value={circle.get('id')} key={circle.get('id')}>{circle.get('title')}</option>,
|
||||
)}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -12,6 +12,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';
|
||||
|
@ -51,6 +52,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,
|
||||
|
@ -83,11 +85,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 = () => {
|
||||
|
@ -199,7 +201,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 = <span className='compose-form__publish-private'><Icon id='lock' /> {intl.formatMessage(messages.publish)}</span>;
|
||||
} else {
|
||||
publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
|
||||
|
@ -262,6 +264,8 @@ class ComposeForm extends ImmutablePureComponent {
|
|||
<div className='character-counter__wrapper'><CharacterCounter max={500} text={this.getFulltextForCharacterCounting()} /></div>
|
||||
</div>
|
||||
|
||||
<CircleDropdownContainer />
|
||||
|
||||
<div className='compose-form__publish'>
|
||||
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block /></div>
|
||||
</div>
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
|
||||
|
@ -237,6 +239,7 @@ class PrivacyDropdown extends React.PureComponent {
|
|||
{ icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
|
||||
{ icon: 'unlock', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
|
||||
{ icon: '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) {
|
||||
|
|
|
@ -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);
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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 <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
|
||||
}
|
||||
|
@ -55,6 +56,10 @@ const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning
|
|||
return <Warning message={message} />;
|
||||
}
|
||||
|
||||
if (limitedMessageWarning) {
|
||||
return <Warning message={<FormattedMessage id='compose_form.limited_message_warning' defaultMessage='This toot will only be sent to users in the circle.' />} />;
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
@ -30,6 +30,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' },
|
||||
|
@ -150,9 +151,10 @@ class GettingStarted extends ImmutablePureComponent {
|
|||
<ColumnLink key='bookmark' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />,
|
||||
<ColumnLink key='favourites' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
|
||||
<ColumnLink key='lists' icon='list-ul' text={intl.formatMessage(messages.lists)} to='/lists' />,
|
||||
<ColumnLink key='circles' icon='user-circle' text={intl.formatMessage(messages.circles)} to='/circles' />,
|
||||
);
|
||||
|
||||
height += 48*4;
|
||||
height += 48*5;
|
||||
|
||||
if (myAccount.get('locked') || unreadFollowRequests > 0) {
|
||||
navItems.push(<ColumnLink key='follow_requests' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
|
||||
|
|
77
app/javascript/mastodon/features/mentions/index.js
Normal file
77
app/javascript/mastodon/features/mentions/index.js
Normal file
|
@ -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 (
|
||||
<Column>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyMessage = <FormattedMessage id='empty_column.mentions' defaultMessage='No one has mentioned this toot.' />;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnHeader
|
||||
showBackButton
|
||||
multiColumn={multiColumn}
|
||||
/>
|
||||
|
||||
<ScrollableList
|
||||
scrollKey='mentions'
|
||||
shouldUpdateScroll={shouldUpdateScroll}
|
||||
emptyMessage={emptyMessage}
|
||||
bindToDocument={!multiColumn}
|
||||
>
|
||||
{accountIds.map(id =>
|
||||
<AccountContainer key={id} id={id} withNote={false} />,
|
||||
)}
|
||||
</ScrollableList>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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' },
|
||||
|
@ -64,6 +65,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,
|
||||
|
@ -110,6 +112,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);
|
||||
}
|
||||
|
@ -206,6 +212,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');
|
||||
|
||||
const expires_at = status.get('expires_at')
|
||||
const expires_date = expires_at && new Date(expires_at)
|
||||
|
@ -225,6 +232,11 @@ class ActionBar extends React.PureComponent {
|
|||
menu.push(null);
|
||||
}
|
||||
|
||||
if (limitedByMe) {
|
||||
menu.push({ text: intl.formatMessage(messages.showMemberList), action: this.handleMemberListClick });
|
||||
}
|
||||
|
||||
menu.push(null);
|
||||
if (!expired) {
|
||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||
menu.push(null);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { Fragment } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
|
@ -22,6 +22,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' },
|
||||
});
|
||||
|
||||
|
@ -330,44 +331,45 @@ class DetailedStatus extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (status.get('application')) {
|
||||
applicationLink = <React.Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></React.Fragment>;
|
||||
applicationLink = <Fragment> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></Fragment>;
|
||||
}
|
||||
|
||||
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 = <React.Fragment> · <Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></React.Fragment>;
|
||||
const visibilityLink = <Fragment> · <Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></Fragment>;
|
||||
|
||||
if (['private', 'direct'].includes(status.get('visibility'))) {
|
||||
if (!(['public', 'unlisted'].includes(status.get('visibility')))) {
|
||||
reblogLink = '';
|
||||
} else if (this.context.router) {
|
||||
reblogLink = (
|
||||
<React.Fragment>
|
||||
<React.Fragment> · </React.Fragment>
|
||||
<Fragment>
|
||||
<Fragment> · </Fragment>
|
||||
<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
|
||||
<Icon id={reblogIcon} />
|
||||
<span className='detailed-status__reblogs'>
|
||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||
</span>
|
||||
</Link>
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
);
|
||||
} else {
|
||||
reblogLink = (
|
||||
<React.Fragment>
|
||||
<React.Fragment> · </React.Fragment>
|
||||
<Fragment>
|
||||
<Fragment> · </Fragment>
|
||||
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
|
||||
<Icon id={reblogIcon} />
|
||||
<span className='detailed-status__reblogs'>
|
||||
<AnimatedNumber value={status.get('reblogs_count')} />
|
||||
</span>
|
||||
</a>
|
||||
</React.Fragment>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -301,6 +301,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));
|
||||
}
|
||||
|
@ -614,6 +618,7 @@ class Status extends ImmutablePureComponent {
|
|||
onQuote={this.handleQuoteClick}
|
||||
onDelete={this.handleDeleteClick}
|
||||
onDirect={this.handleDirectClick}
|
||||
onMemberList={this.handleMemberListClick}
|
||||
onMention={this.handleMentionClick}
|
||||
onMute={this.handleMuteClick}
|
||||
onUnmute={this.handleUnmuteClick}
|
||||
|
|
|
@ -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' },
|
||||
});
|
||||
|
||||
|
@ -93,6 +94,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) },
|
||||
};
|
||||
|
||||
|
|
|
@ -19,6 +19,8 @@ import {
|
|||
EmbedModal,
|
||||
ListEditor,
|
||||
ListAdder,
|
||||
CircleEditor,
|
||||
CircleAdder,
|
||||
} from '../../../features/ui/util/async-components';
|
||||
|
||||
const MODAL_COMPONENTS = {
|
||||
|
@ -35,6 +37,8 @@ const MODAL_COMPONENTS = {
|
|||
'LIST_EDITOR': ListEditor,
|
||||
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
|
||||
'LIST_ADDER': ListAdder,
|
||||
'CIRCLE_EDITOR': CircleEditor,
|
||||
'CIRCLE_ADDER': CircleAdder,
|
||||
};
|
||||
|
||||
export default class ModalRoot extends React.PureComponent {
|
||||
|
|
|
@ -20,6 +20,7 @@ const NavigationPanel = () => (
|
|||
<NavLink className='column-link column-link--transparent' to='/favourites'><Icon className='column-link__icon' id='star' fixedWidth /><FormattedMessage id='navigation_bar.favourites' defaultMessage='Favourites' /></NavLink>
|
||||
<NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
|
||||
<NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
|
||||
<NavLink className='column-link column-link--transparent' to='/circles'><Icon className='column-link__icon' id='user-circle' fixedWidth /><FormattedMessage id='navigation_bar.circles' defaultMessage='Circles' /></NavLink>
|
||||
<NavLink className='column-link column-link--transparent' to='/group_directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.group_directory' defaultMessage='Group directory' /></NavLink>
|
||||
{profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>}
|
||||
|
||||
|
|
|
@ -38,6 +38,7 @@ import {
|
|||
Subscribing,
|
||||
Reblogs,
|
||||
Favourites,
|
||||
Mentions,
|
||||
DirectTimeline,
|
||||
HashtagTimeline,
|
||||
Notifications,
|
||||
|
@ -51,6 +52,7 @@ import {
|
|||
Mutes,
|
||||
PinnedStatuses,
|
||||
Lists,
|
||||
Circles,
|
||||
Search,
|
||||
GroupDirectory,
|
||||
Directory,
|
||||
|
@ -176,6 +178,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
<WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
|
||||
<WrappedRoute path='/statuses/:statusId/mentions' component={Mentions} content={children} />
|
||||
|
||||
<WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
|
||||
<WrappedRoute path='/accounts/:accountId/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
|
||||
|
@ -189,6 +192,7 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
<WrappedRoute path='/domain_blocks' component={DomainBlocks} content={children} />
|
||||
<WrappedRoute path='/mutes' component={Mutes} content={children} />
|
||||
<WrappedRoute path='/lists' component={Lists} content={children} />
|
||||
<WrappedRoute path='/circles' component={Circles} content={children} />
|
||||
|
||||
<WrappedRoute component={GenericNotFound} content={children} />
|
||||
</WrappedSwitch>
|
||||
|
|
|
@ -86,6 +86,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');
|
||||
}
|
||||
|
@ -146,6 +150,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');
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
@ -69,8 +70,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",
|
||||
|
@ -99,6 +113,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?",
|
||||
|
@ -122,6 +137,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.domain_block.confirm": "Block entire domain",
|
||||
|
@ -295,6 +312,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",
|
||||
|
@ -368,6 +386,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",
|
||||
|
@ -442,6 +462,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_poll": "Show poll",
|
||||
|
|
|
@ -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",
|
||||
|
@ -69,8 +70,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": "ディレクトリ",
|
||||
|
@ -99,6 +113,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": "今なにしてる?",
|
||||
|
@ -122,6 +137,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.domain_block.confirm": "ドメイン全体をブロック",
|
||||
|
@ -295,6 +312,7 @@
|
|||
"navigation_bar.apps": "アプリ",
|
||||
"navigation_bar.blocks": "ブロックしたユーザー",
|
||||
"navigation_bar.bookmarks": "ブックマーク",
|
||||
"navigation_bar.circles": "サークル",
|
||||
"navigation_bar.community_timeline": "ローカルタイムライン",
|
||||
"navigation_bar.compose": "投稿の新規作成",
|
||||
"navigation_bar.direct": "ダイレクトメッセージ",
|
||||
|
@ -368,6 +386,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に表示",
|
||||
|
@ -442,6 +462,7 @@
|
|||
"status.share": "共有",
|
||||
"status.show_less": "隠す",
|
||||
"status.show_less_all": "全て隠す",
|
||||
"status.show_member_list": "メンバーリストを表示",
|
||||
"status.show_more": "もっと見る",
|
||||
"status.show_more_all": "全て見る",
|
||||
"status.show_poll": "アンケートを表示",
|
||||
|
|
47
app/javascript/mastodon/reducers/circle_adder.js
Normal file
47
app/javascript/mastodon/reducers/circle_adder.js
Normal file
|
@ -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;
|
||||
}
|
||||
};
|
96
app/javascript/mastodon/reducers/circle_editor.js
Normal file
96
app/javascript/mastodon/reducers/circle_editor.js
Normal file
|
@ -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;
|
||||
}
|
||||
};
|
37
app/javascript/mastodon/reducers/circles.js
Normal file
37
app/javascript/mastodon/reducers/circles.js
Normal file
|
@ -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;
|
||||
}
|
||||
};
|
|
@ -29,6 +29,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,
|
||||
|
@ -59,6 +60,7 @@ const initialState = ImmutableMap({
|
|||
spoiler: false,
|
||||
spoiler_text: '',
|
||||
privacy: null,
|
||||
circle_id: null,
|
||||
text: '',
|
||||
focusDate: null,
|
||||
caretPosition: null,
|
||||
|
@ -66,6 +68,7 @@ const initialState = ImmutableMap({
|
|||
in_reply_to: null,
|
||||
quote_from: null,
|
||||
quote_from_url: null,
|
||||
reply_status: null,
|
||||
is_composing: false,
|
||||
is_submitting: false,
|
||||
is_changing_upload: false,
|
||||
|
@ -98,17 +101,31 @@ const initialPoll = ImmutableMap({
|
|||
multiple: false,
|
||||
});
|
||||
|
||||
function statusToTextMentions(state, status) {
|
||||
let set = ImmutableOrderedSet([]);
|
||||
|
||||
if (status.getIn(['account', 'id']) !== me) {
|
||||
set = set.add(`@${status.getIn(['account', 'acct'])} `);
|
||||
const statusToTextMentions = (text, privacy, status) => {
|
||||
if(status === null) {
|
||||
return text;
|
||||
}
|
||||
|
||||
return set.union(status.get('mentions').filterNot(mention => mention.get('id') === me).map(mention => `@${mention.get('acct')} `)).join('');
|
||||
let mentions = ImmutableOrderedSet();
|
||||
|
||||
if (status.getIn(['account', 'id']) !== me) {
|
||||
mentions = mentions.add(`@${status.getIn(['account', 'acct'])} `);
|
||||
}
|
||||
|
||||
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);
|
||||
|
@ -117,7 +134,9 @@ function clearAll(state) {
|
|||
map.set('is_changing_upload', false);
|
||||
map.set('in_reply_to', null);
|
||||
map.set('quote_from', 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);
|
||||
|
@ -125,7 +144,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 => {
|
||||
|
@ -144,7 +163,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 => {
|
||||
|
@ -200,7 +219,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)];
|
||||
};
|
||||
|
||||
|
@ -306,8 +325,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
|
||||
|
@ -316,12 +342,16 @@ 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('quote_from', null);
|
||||
map.set('quote_from_url', null);
|
||||
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());
|
||||
|
@ -361,10 +391,12 @@ export default function compose(state = initialState, action) {
|
|||
map.set('in_reply_to', null);
|
||||
map.set('quote_from', null);
|
||||
map.set('quote_from_url', 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());
|
||||
});
|
||||
|
@ -428,6 +460,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());
|
||||
|
@ -467,7 +500,9 @@ export default function compose(state = initialState, action) {
|
|||
map.set('in_reply_to', action.status.get('in_reply_to_id'));
|
||||
map.set('quote_from', action.status.getIn(['quote', 'id']));
|
||||
map.set('quote_from_url', action.status.getIn(['quote', 'url']));
|
||||
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);
|
||||
|
|
|
@ -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';
|
||||
|
@ -73,6 +76,9 @@ const reducers = {
|
|||
lists,
|
||||
listEditor,
|
||||
listAdder,
|
||||
circles,
|
||||
circleEditor,
|
||||
circleAdder,
|
||||
filters,
|
||||
conversations,
|
||||
suggestions,
|
||||
|
|
|
@ -32,6 +32,7 @@ import {
|
|||
import {
|
||||
REBLOGS_FETCH_SUCCESS,
|
||||
FAVOURITES_FETCH_SUCCESS,
|
||||
MENTIONS_FETCH_SUCCESS,
|
||||
} from '../actions/interactions';
|
||||
import {
|
||||
BLOCKS_FETCH_REQUEST,
|
||||
|
@ -79,6 +80,7 @@ const initialState = ImmutableMap({
|
|||
subscribing: initialListState,
|
||||
reblogged_by: initialListState,
|
||||
favourited_by: initialListState,
|
||||
mentioned_by: initialListState,
|
||||
follow_requests: initialListState,
|
||||
blocks: initialListState,
|
||||
mutes: initialListState,
|
||||
|
@ -140,6 +142,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:
|
||||
|
|
|
@ -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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(lighten($ui-base-color, 8%))}'/></svg>") no-repeat right 8px center / auto 16px;
|
||||
}
|
||||
|
||||
|
|
|
@ -1192,6 +1192,11 @@
|
|||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.status__link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.status__visibility-icon {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
@ -6753,6 +6758,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;
|
||||
|
@ -7594,3 +7729,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,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14.933 18.467' height='19.698' width='15.929'><path d='M3.467 14.967l-3.393-3.5H14.86l-3.392 3.5c-1.866 1.925-3.666 3.5-4 3.5-.335 0-2.135-1.575-4-3.5zm.266-11.234L7.467 0 11.2 3.733l3.733 3.734H0l3.733-3.734z' fill='#{hex-color(darken($simple-background-color, 14%))}'/></svg>") 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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -475,4 +475,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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -86,6 +86,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
resolve_thread(@status)
|
||||
fetch_replies(@status)
|
||||
distribute(@status)
|
||||
forward_for_conversation
|
||||
forward_for_reply
|
||||
end
|
||||
|
||||
|
@ -110,7 +111,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
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,
|
||||
quote: quote,
|
||||
|
@ -120,8 +121,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
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
|
||||
|
@ -338,15 +341,45 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
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']
|
||||
|
||||
begin
|
||||
Conversation.find_or_create_by!(uri: uri)
|
||||
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotUnique
|
||||
retry
|
||||
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?
|
||||
|
||||
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 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
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?
|
||||
|
||||
|
|
|
@ -25,8 +25,11 @@ class ActivityPub::TagManager
|
|||
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 @@ class ActivityPub::TagManager
|
|||
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
|
||||
|
||||
|
@ -74,7 +82,9 @@ class ActivityPub::TagManager
|
|||
[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)
|
||||
|
@ -112,7 +122,7 @@ class ActivityPub::TagManager
|
|||
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)
|
||||
|
|
|
@ -465,65 +465,58 @@ class Account < ApplicationRecord
|
|||
AND accounts.moved_to_account_id IS NULL
|
||||
#{sql_where_group}
|
||||
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, group = false, offset = 0)
|
||||
def advanced_search_for(terms, account, limit = 10, offset = 0, options = {})
|
||||
textsearch, query = generate_query_for_search(terms)
|
||||
|
||||
sql_where_group = <<-SQL if group
|
||||
sql_where_group = <<-SQL if options[:group]
|
||||
AND accounts.actor_type = 'Group'
|
||||
SQL
|
||||
|
||||
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
|
||||
#{sql_where_group}
|
||||
GROUP BY accounts.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT ? OFFSET ?
|
||||
SQL
|
||||
sql = if options[:following] || options[:followers]
|
||||
sql_first_degree = first_degree(options)
|
||||
|
||||
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
|
||||
#{sql_where_group}
|
||||
GROUP BY accounts.id
|
||||
ORDER BY rank DESC
|
||||
LIMIT ? OFFSET ?
|
||||
SQL
|
||||
|
||||
records = find_by_sql([sql, account.id, account.id, limit, offset])
|
||||
end
|
||||
<<-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
|
||||
|
@ -545,6 +538,44 @@ class Account < ApplicationRecord
|
|||
|
||||
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'))"
|
||||
|
|
22
app/models/circle.rb
Normal file
22
app/models/circle.rb
Normal file
|
@ -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
|
28
app/models/circle_account.rb
Normal file
28
app/models/circle_account.rb
Normal file
|
@ -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
|
|
@ -48,9 +48,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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -12,6 +12,8 @@
|
|||
#
|
||||
|
||||
class Mention < ApplicationRecord
|
||||
include Paginable
|
||||
|
||||
belongs_to :account, inverse_of: :mentions
|
||||
belongs_to :status
|
||||
|
||||
|
|
|
@ -37,6 +37,7 @@ class Status < ApplicationRecord
|
|||
include StatusThreadingConcern
|
||||
include RateLimitable
|
||||
include Expireable
|
||||
include Redisable
|
||||
|
||||
rate_limit by: :account, family: :statuses
|
||||
|
||||
|
@ -46,18 +47,22 @@ class Status < ApplicationRecord
|
|||
# will be based on current time instead of `created_at`
|
||||
attr_accessor :override_timestamps
|
||||
|
||||
attr_accessor :circle
|
||||
|
||||
update_index('statuses#status', :proper)
|
||||
|
||||
enum visibility: [:public, :unlisted, :private, :direct, :limited], _suffix: :visibility
|
||||
enum visibility: [:public, :unlisted, :private, :mutual, :direct, :limited], _suffix: :visibility
|
||||
enum expires_action: [:delete, :hint], _prefix: :expires
|
||||
|
||||
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
|
||||
belongs_to :quote, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quoted, optional: true
|
||||
|
@ -70,6 +75,7 @@ class Status < ApplicationRecord
|
|||
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
|
||||
has_many :media_attachments, dependent: :nullify
|
||||
has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote, 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
|
||||
|
@ -264,7 +270,9 @@ class Status < ApplicationRecord
|
|||
public_visibility? || unlisted_visibility?
|
||||
end
|
||||
|
||||
alias sign? distributable?
|
||||
def sign?
|
||||
distributable? || limited_visibility?
|
||||
end
|
||||
|
||||
def with_media?
|
||||
media_attachments.any?
|
||||
|
@ -319,13 +327,14 @@ class Status < ApplicationRecord
|
|||
|
||||
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
|
||||
|
@ -452,10 +461,23 @@ class Status < ApplicationRecord
|
|||
|
||||
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
|
||||
|
|
25
app/models/status_capability_token.rb
Normal file
25
app/models/status_capability_token.rb
Normal file
|
@ -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
|
|
@ -7,6 +7,8 @@ class StatusPolicy < ApplicationPolicy
|
|||
@preloaded_relations = preloaded_relations
|
||||
end
|
||||
|
||||
delegate :reply?, to: :record
|
||||
|
||||
def index?
|
||||
staff?
|
||||
end
|
||||
|
@ -41,6 +43,10 @@ class StatusPolicy < ApplicationPolicy
|
|||
staff?
|
||||
end
|
||||
|
||||
def show_mentions?
|
||||
limited? && owned? && (!reply? || record.thread.conversation_id != record.conversation_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def requires_mention?
|
||||
|
@ -55,6 +61,10 @@ class StatusPolicy < ApplicationPolicy
|
|||
record.private_visibility?
|
||||
end
|
||||
|
||||
def limited?
|
||||
record.limited_visibility?
|
||||
end
|
||||
|
||||
def mention_exists?
|
||||
return false if current_account.nil?
|
||||
|
||||
|
|
|
@ -20,6 +20,8 @@ class ActivityPub::ActivityPresenter < ActiveModelSerializers::Model
|
|||
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
|
||||
|
|
19
app/serializers/activitypub/context_serializer.rb
Normal file
19
app/serializers/activitypub/context_serializer.rb
Normal file
|
@ -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
|
|
@ -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 :quote_url, if: -> { object.quote? }
|
||||
attribute :misskey_quote, key: :_misskey_quote, if: -> { object.quote? }
|
||||
|
@ -144,6 +144,12 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
|||
object.text if object.quote?
|
||||
end
|
||||
|
||||
def context
|
||||
return if object.conversation.nil?
|
||||
|
||||
ActivityPub::TagManager.instance.uri_for(object.conversation)
|
||||
end
|
||||
|
||||
def local?
|
||||
object.account.local?
|
||||
end
|
||||
|
|
9
app/serializers/rest/circle_serializer.rb
Normal file
9
app/serializers/rest/circle_serializer.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class REST::CircleSerializer < ActiveModel::Serializer
|
||||
attributes :id, :title
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
end
|
||||
end
|
|
@ -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?
|
||||
|
@ -51,8 +52,12 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
!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
|
||||
|
@ -74,6 +79,18 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
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
|
||||
|
@ -127,8 +144,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
|||
end
|
||||
|
||||
def pinnable?
|
||||
current_user? &&
|
||||
current_user.account_id == object.account_id &&
|
||||
owned_status? &&
|
||||
!object.reblog? &&
|
||||
%w(public unlisted).include?(object.visibility)
|
||||
end
|
||||
|
|
|
@ -68,7 +68,7 @@ class AccountSearchService < BaseService
|
|||
end
|
||||
|
||||
def advanced_search_results
|
||||
Account.advanced_search_for(terms_for_query, account, limit_for_non_exact_results, options[:following], options[:group_only], offset)
|
||||
Account.advanced_search_for(terms_for_query, account, limit_for_non_exact_results, offset, options)
|
||||
end
|
||||
|
||||
def simple_search_results
|
||||
|
@ -81,12 +81,16 @@ class AccountSearchService < BaseService
|
|||
|
||||
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
|
||||
|
@ -144,6 +148,10 @@ class AccountSearchService < BaseService
|
|||
@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
|
||||
|
|
|
@ -7,7 +7,7 @@ module Payloadable
|
|||
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
|
||||
|
|
|
@ -17,6 +17,7 @@ class PostStatusService < BaseService
|
|||
# @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
|
||||
|
@ -29,6 +30,7 @@ class PostStatusService < BaseService
|
|||
@text = @options[:text] || ''
|
||||
@in_reply_to = @options[:thread]
|
||||
@quote_id = @options[:quote_id]
|
||||
@circle = @options[:circle]
|
||||
|
||||
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
|
||||
|
||||
|
@ -69,10 +71,12 @@ class PostStatusService < BaseService
|
|||
@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?
|
||||
@expires_at = @options[:expires_at]&.to_datetime
|
||||
@expires_action = @options[:expires_action]
|
||||
@scheduled_at = nil if scheduled_in_the_past?
|
||||
if @quote_id.nil? && md = @text.match(/QT:\s*\[\s*(https:\/\/.+?)\s*\]/)
|
||||
@quote_id = quote_from_url(md[1])&.id
|
||||
@text.sub!(/QT:\s*\[.*?\]/, '')
|
||||
|
@ -94,10 +98,11 @@ class PostStatusService < BaseService
|
|||
|
||||
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!
|
||||
|
@ -140,14 +145,6 @@ class PostStatusService < BaseService
|
|||
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
|
||||
|
@ -192,6 +189,7 @@ class PostStatusService < BaseService
|
|||
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],
|
||||
|
|
|
@ -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 @@ class ProcessMentionsService < BaseService
|
|||
"@#{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
|
||||
|
||||
|
|
|
@ -12,8 +12,10 @@ class ActivityPub::DistributionWorker
|
|||
|
||||
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 @@ class ActivityPub::DistributionWorker
|
|||
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.delivery_followers.or(@status.thread.account.delivery_followers).inboxes
|
||||
else
|
||||
@account.delivery_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
|
||||
|
|
27
app/workers/activitypub/forward_distribution_worker.rb
Normal file
27
app/workers/activitypub/forward_distribution_worker.rb
Normal file
|
@ -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
|
|
@ -71,6 +71,7 @@ Doorkeeper.configure do
|
|||
:'write:accounts',
|
||||
:'write:blocks',
|
||||
:'write:bookmarks',
|
||||
:'write:circles',
|
||||
:'write:conversations',
|
||||
:'write:favourites',
|
||||
:'write:filters',
|
||||
|
@ -85,6 +86,7 @@ Doorkeeper.configure do
|
|||
:'read:accounts',
|
||||
:'read:blocks',
|
||||
:'read:bookmarks',
|
||||
:'read:circles',
|
||||
:'read:favourites',
|
||||
:'read:filters',
|
||||
:'read:follows',
|
||||
|
|
|
@ -1385,6 +1385,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
|
||||
|
|
|
@ -1322,6 +1322,9 @@ ja:
|
|||
title: '%{name}: "%{quote}"'
|
||||
visibilities:
|
||||
direct: ダイレクト
|
||||
direct_long: 送信した相手のみ閲覧可
|
||||
limited: サークル
|
||||
limited_long: サークルで指定したユーザーのみ閲覧可
|
||||
private: フォロワー限定
|
||||
private_long: フォロワーにのみ表示されます
|
||||
public: 公開
|
||||
|
|
|
@ -85,6 +85,7 @@ Rails.application.routes.draw do
|
|||
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 @@ Rails.application.routes.draw do
|
|||
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'
|
||||
|
||||
|
@ -463,6 +465,7 @@ Rails.application.routes.draw do
|
|||
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'
|
||||
|
||||
|
@ -487,6 +490,10 @@ Rails.application.routes.draw do
|
|||
resource :subscribes, only: [:show, :create, :destroy], controller: 'lists/subscribes'
|
||||
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
|
||||
|
|
10
db/migrate/20200718225713_create_circles.rb
Normal file
10
db/migrate/20200718225713_create_circles.rb
Normal file
|
@ -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
|
14
db/migrate/20200718225817_create_circle_accounts.rb
Normal file
14
db/migrate/20200718225817_create_circle_accounts.rb
Normal file
|
@ -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
|
10
db/migrate/20200825232828_create_status_capability_tokens.rb
Normal file
10
db/migrate/20200825232828_create_status_capability_tokens.rb
Normal file
|
@ -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
|
|
@ -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
|
|
@ -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
|
39
db/schema.rb
39
db/schema.rb
|
@ -305,16 +305,40 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
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
|
||||
|
||||
|
@ -897,6 +921,14 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
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
|
||||
|
@ -1117,6 +1149,10 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
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
|
||||
|
@ -1182,6 +1218,7 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do
|
|||
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
|
||||
|
|
|
@ -80,7 +80,7 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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 @@ describe StatusesController do
|
|||
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
|
||||
|
|
5
spec/fabricators/circle_account_fabricator.rb
Normal file
5
spec/fabricators/circle_account_fabricator.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
Fabricator(:circle_account) do
|
||||
circle nil
|
||||
account nil
|
||||
follow nil
|
||||
end
|
4
spec/fabricators/circle_fabricator.rb
Normal file
4
spec/fabricators/circle_fabricator.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
Fabricator(:circle) do
|
||||
account
|
||||
title "Family"
|
||||
end
|
2
spec/fabricators/status_capability_token_fabricator.rb
Normal file
2
spec/fabricators/status_capability_token_fabricator.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
Fabricator(:status_capability_token) do
|
||||
end
|
|
@ -13,17 +13,22 @@ RSpec.describe ActivityPub::Activity::Create do
|
|||
}.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 @@ RSpec.describe ActivityPub::Activity::Create do
|
|||
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 @@ RSpec.describe ActivityPub::Activity::Create do
|
|||
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 @@ RSpec.describe ActivityPub::Activity::Create do
|
|||
|
||||
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
|
||||
|
||||
|
|
4
spec/models/circle_account_spec.rb
Normal file
4
spec/models/circle_account_spec.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CircleAccount, type: :model do
|
||||
end
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue