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:
noellabo 2020-09-05 16:33:17 +09:00
parent 020074c188
commit a27fcf5e30
104 changed files with 2947 additions and 170 deletions

View 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

View 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

View file

@ -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]

View 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

View 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

View file

@ -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

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View 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'])));
};

View file

@ -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,

View file

@ -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 || '';

View file

@ -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));

View file

@ -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 => {

View file

@ -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'>

View file

@ -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);

View file

@ -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 {

View file

@ -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 {

View file

@ -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));
},

View file

@ -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 {

View file

@ -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}
/>

View file

@ -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));

View file

@ -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>
);
}
}

View file

@ -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>
);
}
}

View 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>
);
}
}

View file

@ -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>
);
}
}

View file

@ -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>
);
}
}

View file

@ -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>
);
}
}

View 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>
);
}
}

View file

@ -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>
);
}
}

View file

@ -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>
);
}
}

View 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>
);
}
}

View file

@ -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' });

View file

@ -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>
);
}
}

View file

@ -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>

View file

@ -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) {

View file

@ -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);

View file

@ -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,
});

View file

@ -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);

View file

@ -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' />);

View 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>
);
}
}

View file

@ -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);

View file

@ -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>
);
}

View file

@ -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}

View file

@ -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) },
};

View file

@ -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 {

View file

@ -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>}

View file

@ -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>

View file

@ -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');
}

View file

@ -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",

View file

@ -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": "アンケートを表示",

View 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;
}
};

View 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;
}
};

View 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;
}
};

View file

@ -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);

View file

@ -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,

View file

@ -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:

View file

@ -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;
}

View file

@ -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);
}
}
}

View file

@ -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;
}
}

View file

@ -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?

View file

@ -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)

View file

@ -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
View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -12,6 +12,8 @@
#
class Mention < ApplicationRecord
include Paginable
belongs_to :account, inverse_of: :mentions
belongs_to :status

View file

@ -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

View 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

View file

@ -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?

View file

@ -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

View 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

View file

@ -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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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],

View file

@ -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

View file

@ -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

View 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

View file

@ -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',

View file

@ -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

View file

@ -1322,6 +1322,9 @@ ja:
title: '%{name}: "%{quote}"'
visibilities:
direct: ダイレクト
direct_long: 送信した相手のみ閲覧可
limited: サークル
limited_long: サークルで指定したユーザーのみ閲覧可
private: フォロワー限定
private_long: フォロワーにのみ表示されます
public: 公開

View file

@ -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

View 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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,5 @@
Fabricator(:circle_account) do
circle nil
account nil
follow nil
end

View file

@ -0,0 +1,4 @@
Fabricator(:circle) do
account
title "Family"
end

View file

@ -0,0 +1,2 @@
Fabricator(:status_capability_token) do
end

View file

@ -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

View 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