Merge branch 'master' into glitch-soc/merge-upstream
Conflicts: - `package.json`: Not really a conflict, just some glitch-soc-specific dependency too close to an upstream-updated one.
This commit is contained in:
commit
c2347f6cf6
40 changed files with 1762 additions and 1363 deletions
2
Gemfile
2
Gemfile
|
@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
|
||||||
gem 'pghero', '~> 2.5'
|
gem 'pghero', '~> 2.5'
|
||||||
gem 'dotenv-rails', '~> 2.7'
|
gem 'dotenv-rails', '~> 2.7'
|
||||||
|
|
||||||
gem 'aws-sdk-s3', '~> 1.72', require: false
|
gem 'aws-sdk-s3', '~> 1.73', require: false
|
||||||
gem 'fog-core', '<= 2.1.0'
|
gem 'fog-core', '<= 2.1.0'
|
||||||
gem 'fog-openstack', '~> 0.3', require: false
|
gem 'fog-openstack', '~> 0.3', require: false
|
||||||
gem 'paperclip', '~> 6.0'
|
gem 'paperclip', '~> 6.0'
|
||||||
|
|
20
Gemfile.lock
20
Gemfile.lock
|
@ -92,16 +92,16 @@ GEM
|
||||||
av (0.9.0)
|
av (0.9.0)
|
||||||
cocaine (~> 0.5.3)
|
cocaine (~> 0.5.3)
|
||||||
aws-eventstream (1.1.0)
|
aws-eventstream (1.1.0)
|
||||||
aws-partitions (1.336.0)
|
aws-partitions (1.338.0)
|
||||||
aws-sdk-core (3.102.1)
|
aws-sdk-core (3.103.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
aws-partitions (~> 1, >= 1.239.0)
|
aws-partitions (~> 1, >= 1.239.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
jmespath (~> 1.0)
|
jmespath (~> 1.0)
|
||||||
aws-sdk-kms (1.35.0)
|
aws-sdk-kms (1.36.0)
|
||||||
aws-sdk-core (~> 3, >= 3.99.0)
|
aws-sdk-core (~> 3, >= 3.99.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.72.0)
|
aws-sdk-s3 (1.73.0)
|
||||||
aws-sdk-core (~> 3, >= 3.102.1)
|
aws-sdk-core (~> 3, >= 3.102.1)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
|
@ -189,7 +189,7 @@ GEM
|
||||||
devise_pam_authenticatable2 (9.2.0)
|
devise_pam_authenticatable2 (9.2.0)
|
||||||
devise (>= 4.0.0)
|
devise (>= 4.0.0)
|
||||||
rpam2 (~> 4.0)
|
rpam2 (~> 4.0)
|
||||||
diff-lcs (1.4.3)
|
diff-lcs (1.4.4)
|
||||||
discard (1.2.0)
|
discard (1.2.0)
|
||||||
activerecord (>= 4.2, < 7)
|
activerecord (>= 4.2, < 7)
|
||||||
docile (1.3.2)
|
docile (1.3.2)
|
||||||
|
@ -302,7 +302,7 @@ GEM
|
||||||
ipaddress (0.8.3)
|
ipaddress (0.8.3)
|
||||||
iso-639 (0.3.5)
|
iso-639 (0.3.5)
|
||||||
jmespath (1.4.0)
|
jmespath (1.4.0)
|
||||||
json (2.3.0)
|
json (2.3.1)
|
||||||
json-canonicalization (0.2.0)
|
json-canonicalization (0.2.0)
|
||||||
json-ld (3.1.4)
|
json-ld (3.1.4)
|
||||||
htmlentities (~> 4.3)
|
htmlentities (~> 4.3)
|
||||||
|
@ -391,9 +391,9 @@ GEM
|
||||||
addressable (~> 2.3)
|
addressable (~> 2.3)
|
||||||
nokogiri (~> 1.5)
|
nokogiri (~> 1.5)
|
||||||
omniauth (~> 1.2)
|
omniauth (~> 1.2)
|
||||||
omniauth-saml (1.10.1)
|
omniauth-saml (1.10.2)
|
||||||
omniauth (~> 1.3, >= 1.3.2)
|
omniauth (~> 1.3, >= 1.3.2)
|
||||||
ruby-saml (~> 1.7)
|
ruby-saml (~> 1.9)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
ox (2.13.2)
|
ox (2.13.2)
|
||||||
paperclip (6.0.0)
|
paperclip (6.0.0)
|
||||||
|
@ -484,7 +484,7 @@ GEM
|
||||||
thor (>= 0.19.0, < 2.0)
|
thor (>= 0.19.0, < 2.0)
|
||||||
rainbow (3.0.0)
|
rainbow (3.0.0)
|
||||||
rake (13.0.1)
|
rake (13.0.1)
|
||||||
rdf (3.1.3)
|
rdf (3.1.4)
|
||||||
hamster (~> 3.0)
|
hamster (~> 3.0)
|
||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
rdf-normalize (0.4.0)
|
rdf-normalize (0.4.0)
|
||||||
|
@ -673,7 +673,7 @@ DEPENDENCIES
|
||||||
active_record_query_trace (~> 1.7)
|
active_record_query_trace (~> 1.7)
|
||||||
addressable (~> 2.7)
|
addressable (~> 2.7)
|
||||||
annotate (~> 3.1)
|
annotate (~> 3.1)
|
||||||
aws-sdk-s3 (~> 1.72)
|
aws-sdk-s3 (~> 1.73)
|
||||||
better_errors (~> 2.7)
|
better_errors (~> 2.7)
|
||||||
binding_of_caller (~> 0.7)
|
binding_of_caller (~> 0.7)
|
||||||
blurhash (~> 0.1)
|
blurhash (~> 0.1)
|
||||||
|
|
|
@ -9,7 +9,10 @@ class Auth::PasswordsController < Devise::PasswordsController
|
||||||
|
|
||||||
def update
|
def update
|
||||||
super do |resource|
|
super do |resource|
|
||||||
resource.session_activations.destroy_all if resource.errors.empty?
|
if resource.errors.empty?
|
||||||
|
resource.session_activations.destroy_all
|
||||||
|
resource.forget_me!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Auth::RegistrationsController < Devise::RegistrationsController
|
class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
|
include Devise::Controllers::Rememberable
|
||||||
|
|
||||||
layout :determine_layout
|
layout :determine_layout
|
||||||
|
|
||||||
before_action :set_invite, only: [:new, :create]
|
before_action :set_invite, only: [:new, :create]
|
||||||
|
@ -25,7 +27,11 @@ class Auth::RegistrationsController < Devise::RegistrationsController
|
||||||
|
|
||||||
def update
|
def update
|
||||||
super do |resource|
|
super do |resource|
|
||||||
resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password?
|
if resource.saved_change_to_encrypted_password?
|
||||||
|
resource.clear_other_sessions(current_session.session_id)
|
||||||
|
resource.forget_me!
|
||||||
|
remember_me(resource)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class HomeController < ApplicationController
|
class HomeController < ApplicationController
|
||||||
|
before_action :redirect_unauthenticated_to_permalinks!
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
|
|
||||||
before_action :set_pack
|
before_action :set_pack
|
||||||
|
@ -12,7 +13,7 @@ class HomeController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def authenticate_user!
|
def redirect_unauthenticated_to_permalinks!
|
||||||
return if user_signed_in?
|
return if user_signed_in?
|
||||||
|
|
||||||
matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/)
|
matches = request.path.match(/\A\/web\/(statuses|accounts)\/([\d]+)\z/)
|
||||||
|
@ -37,6 +38,7 @@ class HomeController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
matches = request.path.match(%r{\A/web/timelines/tag/(?<tag>.+)\z})
|
matches = request.path.match(%r{\A/web/timelines/tag/(?<tag>.+)\z})
|
||||||
|
|
||||||
redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path)
|
redirect_to(matches ? tag_path(CGI.unescape(matches[:tag])) : default_redirect_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class MediaProxyController < ApplicationController
|
class MediaProxyController < ApplicationController
|
||||||
include RoutingHelper
|
include RoutingHelper
|
||||||
|
include Authorization
|
||||||
|
|
||||||
skip_before_action :store_current_location
|
skip_before_action :store_current_location
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
@ -10,12 +11,14 @@ class MediaProxyController < ApplicationController
|
||||||
|
|
||||||
rescue_from ActiveRecord::RecordInvalid, with: :not_found
|
rescue_from ActiveRecord::RecordInvalid, with: :not_found
|
||||||
rescue_from Mastodon::UnexpectedResponseError, with: :not_found
|
rescue_from Mastodon::UnexpectedResponseError, with: :not_found
|
||||||
|
rescue_from Mastodon::NotPermittedError, with: :not_found
|
||||||
rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error
|
rescue_from HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError, with: :internal_server_error
|
||||||
|
|
||||||
def show
|
def show
|
||||||
RedisLock.acquire(lock_options) do |lock|
|
RedisLock.acquire(lock_options) do |lock|
|
||||||
if lock.acquired?
|
if lock.acquired?
|
||||||
@media_attachment = MediaAttachment.remote.find(params[:id])
|
@media_attachment = MediaAttachment.remote.attached.find(params[:id])
|
||||||
|
authorize @media_attachment.status, :show?
|
||||||
redownload! if @media_attachment.needs_redownload? && !reject_media?
|
redownload! if @media_attachment.needs_redownload? && !reject_media?
|
||||||
else
|
else
|
||||||
raise Mastodon::RaceConditionError
|
raise Mastodon::RaceConditionError
|
||||||
|
|
|
@ -4,19 +4,12 @@ export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
|
||||||
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
|
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
|
||||||
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
|
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
|
||||||
|
|
||||||
export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
|
export function submitAccountNote(id, value) {
|
||||||
export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL';
|
|
||||||
|
|
||||||
export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
|
|
||||||
|
|
||||||
export function submitAccountNote() {
|
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(submitAccountNoteRequest());
|
dispatch(submitAccountNoteRequest());
|
||||||
|
|
||||||
const id = getState().getIn(['account_notes', 'edit', 'account_id']);
|
|
||||||
|
|
||||||
api(getState).post(`/api/v1/accounts/${id}/note`, {
|
api(getState).post(`/api/v1/accounts/${id}/note`, {
|
||||||
comment: getState().getIn(['account_notes', 'edit', 'comment']),
|
comment: value,
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
dispatch(submitAccountNoteSuccess(response.data));
|
dispatch(submitAccountNoteSuccess(response.data));
|
||||||
}).catch(error => dispatch(submitAccountNoteFail(error)));
|
}).catch(error => dispatch(submitAccountNoteFail(error)));
|
||||||
|
@ -42,28 +35,3 @@ export function submitAccountNoteFail(error) {
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function initEditAccountNote(account) {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
const comment = getState().getIn(['relationships', account.get('id'), 'note']);
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: ACCOUNT_NOTE_INIT_EDIT,
|
|
||||||
account,
|
|
||||||
comment,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function cancelAccountNote() {
|
|
||||||
return {
|
|
||||||
type: ACCOUNT_NOTE_CANCEL,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function changeAccountNoteComment(comment) {
|
|
||||||
return {
|
|
||||||
type: ACCOUNT_NOTE_CHANGE_COMMENT,
|
|
||||||
comment,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
|
@ -28,6 +28,11 @@ export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
|
||||||
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
|
||||||
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
|
||||||
|
|
||||||
|
export const THUMBNAIL_UPLOAD_REQUEST = 'THUMBNAIL_UPLOAD_REQUEST';
|
||||||
|
export const THUMBNAIL_UPLOAD_SUCCESS = 'THUMBNAIL_UPLOAD_SUCCESS';
|
||||||
|
export const THUMBNAIL_UPLOAD_FAIL = 'THUMBNAIL_UPLOAD_FAIL';
|
||||||
|
export const THUMBNAIL_UPLOAD_PROGRESS = 'THUMBNAIL_UPLOAD_PROGRESS';
|
||||||
|
|
||||||
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
|
||||||
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
|
||||||
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
|
export const COMPOSE_SUGGESTION_SELECT = 'COMPOSE_SUGGESTION_SELECT';
|
||||||
|
@ -260,6 +265,49 @@ export function uploadCompose(files) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const uploadThumbnail = (id, file) => (dispatch, getState) => {
|
||||||
|
dispatch(uploadThumbnailRequest());
|
||||||
|
|
||||||
|
const total = file.size;
|
||||||
|
const data = new FormData();
|
||||||
|
|
||||||
|
data.append('thumbnail', file);
|
||||||
|
|
||||||
|
api(getState).put(`/api/v1/media/${id}`, data, {
|
||||||
|
onUploadProgress: ({ loaded }) => {
|
||||||
|
dispatch(uploadThumbnailProgress(loaded, total));
|
||||||
|
},
|
||||||
|
}).then(({ data }) => {
|
||||||
|
dispatch(uploadThumbnailSuccess(data));
|
||||||
|
}).catch(error => {
|
||||||
|
dispatch(uploadThumbnailFail(id, error));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadThumbnailRequest = () => ({
|
||||||
|
type: THUMBNAIL_UPLOAD_REQUEST,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uploadThumbnailProgress = (loaded, total) => ({
|
||||||
|
type: THUMBNAIL_UPLOAD_PROGRESS,
|
||||||
|
loaded,
|
||||||
|
total,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uploadThumbnailSuccess = media => ({
|
||||||
|
type: THUMBNAIL_UPLOAD_SUCCESS,
|
||||||
|
media,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const uploadThumbnailFail = error => ({
|
||||||
|
type: THUMBNAIL_UPLOAD_FAIL,
|
||||||
|
error,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
export function changeUploadCompose(id, params) {
|
export function changeUploadCompose(id, params) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch, getState) => {
|
||||||
dispatch(changeUploadComposeRequest());
|
dispatch(changeUploadComposeRequest());
|
||||||
|
@ -278,6 +326,7 @@ export function changeUploadComposeRequest() {
|
||||||
skipLoading: true,
|
skipLoading: true,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function changeUploadComposeSuccess(media) {
|
export function changeUploadComposeSuccess(media) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
type: COMPOSE_UPLOAD_CHANGE_SUCCESS,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
export default class AutosuggestHashtag extends React.PureComponent {
|
export default class AutosuggestHashtag extends React.PureComponent {
|
||||||
|
@ -13,14 +13,28 @@ export default class AutosuggestHashtag extends React.PureComponent {
|
||||||
}).isRequired,
|
}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render() {
|
||||||
const { tag } = this.props;
|
const { tag } = this.props;
|
||||||
const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
|
const weeklyUses = tag.history && (
|
||||||
|
<ShortNumber
|
||||||
|
value={tag.history.reduce((total, day) => total + day.uses * 1, 0)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='autosuggest-hashtag'>
|
<div className='autosuggest-hashtag'>
|
||||||
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
|
<div className='autosuggest-hashtag__name'>
|
||||||
{tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>}
|
#<strong>{tag.name}</strong>
|
||||||
|
</div>
|
||||||
|
{tag.history !== undefined && (
|
||||||
|
<div className='autosuggest-hashtag__uses'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='autosuggest_hashtag.per_week'
|
||||||
|
defaultMessage='{count} per week'
|
||||||
|
values={{ count: weeklyUses }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
62
app/javascript/mastodon/components/common_counter.js
Normal file
62
app/javascript/mastodon/components/common_counter.js
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
// @ts-check
|
||||||
|
import React from 'react';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns custom renderer for one of the common counter types
|
||||||
|
*
|
||||||
|
* @param {"statuses" | "following" | "followers"} counterType
|
||||||
|
* Type of the counter
|
||||||
|
* @param {boolean} isBold Whether display number must be displayed in bold
|
||||||
|
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
|
||||||
|
* Renderer function
|
||||||
|
* @throws If counterType is not covered by this function
|
||||||
|
*/
|
||||||
|
export function counterRenderer(counterType, isBold = true) {
|
||||||
|
/**
|
||||||
|
* @type {(displayNumber: JSX.Element) => JSX.Element}
|
||||||
|
*/
|
||||||
|
const renderCounter = isBold
|
||||||
|
? (displayNumber) => <strong>{displayNumber}</strong>
|
||||||
|
: (displayNumber) => displayNumber;
|
||||||
|
|
||||||
|
switch (counterType) {
|
||||||
|
case 'statuses': {
|
||||||
|
return (displayNumber, pluralReady) => (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.statuses_counter'
|
||||||
|
defaultMessage='{count, plural, one {{counter} Toot} other {{counter} Toots}}'
|
||||||
|
values={{
|
||||||
|
count: pluralReady,
|
||||||
|
counter: renderCounter(displayNumber),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'following': {
|
||||||
|
return (displayNumber, pluralReady) => (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.following_counter'
|
||||||
|
defaultMessage='{count, plural, other {{counter} Following}}'
|
||||||
|
values={{
|
||||||
|
count: pluralReady,
|
||||||
|
counter: renderCounter(displayNumber),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'followers': {
|
||||||
|
return (displayNumber, pluralReady) => (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.followers_counter'
|
||||||
|
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Followers}}'
|
||||||
|
values={{
|
||||||
|
count: pluralReady,
|
||||||
|
counter: renderCounter(displayNumber),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default: throw Error(`Incorrect counter name: ${counterType}. Ensure it accepted by commonCounter function`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,26 +1,65 @@
|
||||||
|
// @ts-check
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
import { Sparklines, SparklinesCurve } from 'react-sparklines';
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import Permalink from './permalink';
|
import Permalink from './permalink';
|
||||||
import { shortNumberFormat } from '../utils/numbers';
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Used to render counter of how much people are talking about hashtag
|
||||||
|
*
|
||||||
|
* @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
|
||||||
|
*/
|
||||||
|
const accountsCountRenderer = (displayNumber, pluralReady) => (
|
||||||
|
<FormattedMessage
|
||||||
|
id='trends.counter_by_accounts'
|
||||||
|
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking'
|
||||||
|
values={{
|
||||||
|
count: pluralReady,
|
||||||
|
counter: <strong>{displayNumber}</strong>,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const Hashtag = ({ hashtag }) => (
|
const Hashtag = ({ hashtag }) => (
|
||||||
<div className='trends__item'>
|
<div className='trends__item'>
|
||||||
<div className='trends__item__name'>
|
<div className='trends__item__name'>
|
||||||
<Permalink href={hashtag.get('url')} to={`/timelines/tag/${hashtag.get('name')}`}>
|
<Permalink
|
||||||
|
href={hashtag.get('url')}
|
||||||
|
to={`/timelines/tag/${hashtag.get('name')}`}
|
||||||
|
>
|
||||||
#<span>{hashtag.get('name')}</span>
|
#<span>{hashtag.get('name')}</span>
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
||||||
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1, count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']) * 1 + hashtag.getIn(['history', 1, 'accounts']) * 1)}</strong> }} />
|
<ShortNumber
|
||||||
|
value={
|
||||||
|
hashtag.getIn(['history', 0, 'accounts']) * 1 +
|
||||||
|
hashtag.getIn(['history', 1, 'accounts']) * 1
|
||||||
|
}
|
||||||
|
renderer={accountsCountRenderer}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__current'>
|
<div className='trends__item__current'>
|
||||||
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']) * 1 + hashtag.getIn(['history', 1, 'uses']) * 1)}
|
<ShortNumber
|
||||||
|
value={
|
||||||
|
hashtag.getIn(['history', 0, 'uses']) * 1 +
|
||||||
|
hashtag.getIn(['history', 1, 'uses']) * 1
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='trends__item__sparkline'>
|
<div className='trends__item__sparkline'>
|
||||||
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
|
<Sparklines
|
||||||
|
width={50}
|
||||||
|
height={28}
|
||||||
|
data={hashtag
|
||||||
|
.get('history')
|
||||||
|
.reverse()
|
||||||
|
.map((day) => day.get('uses'))
|
||||||
|
.toArray()}
|
||||||
|
>
|
||||||
<SparklinesCurve style={{ fill: 'none' }} />
|
<SparklinesCurve style={{ fill: 'none' }} />
|
||||||
</Sparklines>
|
</Sparklines>
|
||||||
</div>
|
</div>
|
||||||
|
|
117
app/javascript/mastodon/components/short_number.js
Normal file
117
app/javascript/mastodon/components/short_number.js
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { toShortNumber, pluralReady, DECIMAL_UNITS } from '../utils/numbers';
|
||||||
|
import { FormattedMessage, FormattedNumber } from 'react-intl';
|
||||||
|
// @ts-check
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @callback ShortNumberRenderer
|
||||||
|
* @param {JSX.Element} displayNumber Number to display
|
||||||
|
* @param {number} pluralReady Number used for pluralization
|
||||||
|
* @returns {JSX.Element} Final render of number
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} ShortNumberProps
|
||||||
|
* @property {number} value Number to display in short variant
|
||||||
|
* @property {ShortNumberRenderer} [renderer]
|
||||||
|
* Custom renderer for numbers, provided as a prop. If another renderer
|
||||||
|
* passed as a child of this component, this prop won't be used.
|
||||||
|
* @property {ShortNumberRenderer} [children]
|
||||||
|
* Custom renderer for numbers, provided as a child. If another renderer
|
||||||
|
* passed as a prop of this component, this one will be used instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component that renders short big number to a shorter version
|
||||||
|
*
|
||||||
|
* @param {ShortNumberProps} param0 Props for the component
|
||||||
|
* @returns {JSX.Element} Rendered number
|
||||||
|
*/
|
||||||
|
function ShortNumber({ value, renderer, children }) {
|
||||||
|
const shortNumber = toShortNumber(value);
|
||||||
|
const [, division] = shortNumber;
|
||||||
|
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
if (children != null && renderer != null) {
|
||||||
|
console.warn('Both renderer prop and renderer as a child provided. This is a mistake and you really should fix that. Only renderer passed as a child will be used.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
const customRenderer = children != null ? children : renderer;
|
||||||
|
|
||||||
|
const displayNumber = <ShortNumberCounter value={shortNumber} />;
|
||||||
|
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
return customRenderer != null
|
||||||
|
? customRenderer(displayNumber, pluralReady(value, division))
|
||||||
|
: displayNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortNumber.propTypes = {
|
||||||
|
value: PropTypes.number.isRequired,
|
||||||
|
renderer: PropTypes.func,
|
||||||
|
children: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} ShortNumberCounterProps
|
||||||
|
* @property {import('../utils/number').ShortNumber} value Short number
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders short number into corresponding localizable react fragment
|
||||||
|
*
|
||||||
|
* @param {ShortNumberCounterProps} param0 Props for the component
|
||||||
|
* @returns {JSX.Element} FormattedMessage ready to be embedded in code
|
||||||
|
*/
|
||||||
|
function ShortNumberCounter({ value }) {
|
||||||
|
const [rawNumber, unit, maxFractionDigits = 0] = value;
|
||||||
|
|
||||||
|
const count = (
|
||||||
|
<FormattedNumber
|
||||||
|
value={rawNumber}
|
||||||
|
maximumFractionDigits={maxFractionDigits}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
let values = { count, rawNumber };
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case DECIMAL_UNITS.THOUSAND: {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='units.short.thousand'
|
||||||
|
defaultMessage='{count}K'
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case DECIMAL_UNITS.MILLION: {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='units.short.million'
|
||||||
|
defaultMessage='{count}M'
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case DECIMAL_UNITS.BILLION: {
|
||||||
|
return (
|
||||||
|
<FormattedMessage
|
||||||
|
id='units.short.billion'
|
||||||
|
defaultMessage='{count}B'
|
||||||
|
values={values}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Not sure if we should go farther - @Sasha-Sorokin
|
||||||
|
default: return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShortNumberCounter.propTypes = {
|
||||||
|
value: PropTypes.arrayOf(PropTypes.number),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ShortNumber);
|
|
@ -3,99 +3,166 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Icon from 'mastodon/components/icon';
|
|
||||||
import Textarea from 'react-textarea-autosize';
|
import Textarea from 'react-textarea-autosize';
|
||||||
|
import { is } from 'immutable';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
|
placeholder: { id: 'account_note.placeholder', defaultMessage: 'Click to add a note' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @injectIntl
|
class InlineAlert extends React.PureComponent {
|
||||||
class Header extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
account: ImmutablePropTypes.map.isRequired,
|
show: PropTypes.bool,
|
||||||
isEditing: PropTypes.bool,
|
|
||||||
isSubmitting: PropTypes.bool,
|
|
||||||
accountNote: PropTypes.string,
|
|
||||||
onEditAccountNote: PropTypes.func.isRequired,
|
|
||||||
onCancelAccountNote: PropTypes.func.isRequired,
|
|
||||||
onSaveAccountNote: PropTypes.func.isRequired,
|
|
||||||
onChangeAccountNote: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleChangeAccountNote = (e) => {
|
state = {
|
||||||
this.props.onChangeAccountNote(e.target.value);
|
mountMessage: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentWillUnmount () {
|
static TRANSITION_DELAY = 200;
|
||||||
if (this.props.isEditing) {
|
|
||||||
this.props.onCancelAccountNote();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleKeyDown = e => {
|
componentWillReceiveProps (nextProps) {
|
||||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
if (!this.props.show && nextProps.show) {
|
||||||
this.props.onSaveAccountNote();
|
this.setState({ mountMessage: true });
|
||||||
} else if (e.keyCode === 27) {
|
} else if (this.props.show && !nextProps.show) {
|
||||||
this.props.onCancelAccountNote();
|
setTimeout(() => this.setState({ mountMessage: false }), InlineAlert.TRANSITION_DELAY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
|
const { show } = this.props;
|
||||||
|
const { mountMessage } = this.state;
|
||||||
|
|
||||||
if (!account || (!accountNote && !isEditing)) {
|
return (
|
||||||
|
<span aria-live='polite' role='status' className='inline-alert' style={{ opacity: show ? 1 : 0 }}>
|
||||||
|
{mountMessage && <FormattedMessage id='generic.saved' defaultMessage='Saved' />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default @injectIntl
|
||||||
|
class AccountNote extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
|
value: PropTypes.string,
|
||||||
|
onSave: PropTypes.func.isRequired,
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
value: null,
|
||||||
|
saving: false,
|
||||||
|
saved: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentWillMount () {
|
||||||
|
this._reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps (nextProps) {
|
||||||
|
const accountWillChange = !is(this.props.account, nextProps.account);
|
||||||
|
const newState = {};
|
||||||
|
|
||||||
|
if (accountWillChange && this._isDirty()) {
|
||||||
|
this._save(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountWillChange || nextProps.value === this.state.value) {
|
||||||
|
newState.saving = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.value !== nextProps.value) {
|
||||||
|
newState.value = nextProps.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState(newState);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount () {
|
||||||
|
if (this._isDirty()) {
|
||||||
|
this._save(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTextareaRef = c => {
|
||||||
|
this.textarea = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange = e => {
|
||||||
|
this.setState({ value: e.target.value, saving: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
handleKeyDown = e => {
|
||||||
|
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this._save();
|
||||||
|
|
||||||
|
if (this.textarea) {
|
||||||
|
this.textarea.blur();
|
||||||
|
}
|
||||||
|
} else if (e.keyCode === 27) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
this._reset(() => {
|
||||||
|
if (this.textarea) {
|
||||||
|
this.textarea.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBlur = () => {
|
||||||
|
if (this._isDirty()) {
|
||||||
|
this._save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_save (showMessage = true) {
|
||||||
|
this.setState({ saving: true }, () => this.props.onSave(this.state.value));
|
||||||
|
|
||||||
|
if (showMessage) {
|
||||||
|
this.setState({ saved: true }, () => setTimeout(() => this.setState({ saved: false }), 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_reset (callback) {
|
||||||
|
this.setState({ value: this.props.value }, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isDirty () {
|
||||||
|
return !this.state.saving && this.props.value !== null && this.state.value !== null && this.state.value !== this.props.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { account, intl } = this.props;
|
||||||
|
const { value, saved } = this.state;
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let action_buttons = null;
|
|
||||||
if (isEditing) {
|
|
||||||
action_buttons = (
|
|
||||||
<div className='account__header__account-note__buttons'>
|
|
||||||
<button className='text-btn' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
|
|
||||||
<Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
|
|
||||||
</button>
|
|
||||||
<div className='flex-spacer' />
|
|
||||||
<button className='text-btn' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
|
|
||||||
<Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let note_container = null;
|
|
||||||
if (isEditing) {
|
|
||||||
note_container = (
|
|
||||||
<Textarea
|
|
||||||
className='account__header__account-note__content'
|
|
||||||
disabled={isSubmitting}
|
|
||||||
placeholder={intl.formatMessage(messages.placeholder)}
|
|
||||||
value={accountNote}
|
|
||||||
onChange={this.handleChangeAccountNote}
|
|
||||||
onKeyDown={this.handleKeyDown}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account__header__account-note'>
|
<div className='account__header__account-note'>
|
||||||
<div className='account__header__account-note__header'>
|
<label htmlFor={`account-note-${account.get('id')}`}>
|
||||||
<strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong>
|
<FormattedMessage id='account.account_note_header' defaultMessage='Note' /> <InlineAlert show={saved} />
|
||||||
{!isEditing && (
|
</label>
|
||||||
<div>
|
|
||||||
<button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
|
<Textarea
|
||||||
<Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
|
id={`account-note-${account.get('id')}`}
|
||||||
</button>
|
className='account__header__account-note__content'
|
||||||
</div>
|
disabled={this.props.value === null || value === null}
|
||||||
)}
|
placeholder={intl.formatMessage(messages.placeholder)}
|
||||||
</div>
|
value={value || ''}
|
||||||
{note_container}
|
onChange={this.handleChange}
|
||||||
{action_buttons}
|
onKeyDown={this.handleKeyDown}
|
||||||
|
onBlur={this.handleBlur}
|
||||||
|
ref={this.setTextareaRef}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,8 @@ import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
import Avatar from 'mastodon/components/avatar';
|
import Avatar from 'mastodon/components/avatar';
|
||||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
import { counterRenderer } from 'mastodon/components/common_counter';
|
||||||
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||||
import AccountNoteContainer from '../containers/account_note_container';
|
import AccountNoteContainer from '../containers/account_note_container';
|
||||||
|
@ -66,7 +67,6 @@ class Header extends ImmutablePureComponent {
|
||||||
identity_props: ImmutablePropTypes.list,
|
identity_props: ImmutablePropTypes.list,
|
||||||
onFollow: PropTypes.func.isRequired,
|
onFollow: PropTypes.func.isRequired,
|
||||||
onBlock: PropTypes.func.isRequired,
|
onBlock: PropTypes.func.isRequired,
|
||||||
onEditAccountNote: PropTypes.func.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
domain: PropTypes.string.isRequired,
|
domain: PropTypes.string.isRequired,
|
||||||
};
|
};
|
||||||
|
@ -131,8 +131,6 @@ class Header extends ImmutablePureComponent {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountNote = account.getIn(['relationship', 'note']);
|
|
||||||
|
|
||||||
let info = [];
|
let info = [];
|
||||||
let actionBtn = '';
|
let actionBtn = '';
|
||||||
let lockedIcon = '';
|
let lockedIcon = '';
|
||||||
|
@ -183,10 +181,6 @@ class Header extends ImmutablePureComponent {
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountNote === null) {
|
|
||||||
menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.get('id') === me) {
|
if (account.get('id') === me) {
|
||||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
||||||
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
|
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
|
||||||
|
@ -293,8 +287,6 @@ class Header extends ImmutablePureComponent {
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AccountNoteContainer account={account} />
|
|
||||||
|
|
||||||
<div className='account__header__extra'>
|
<div className='account__header__extra'>
|
||||||
<div className='account__header__bio'>
|
<div className='account__header__bio'>
|
||||||
{ (fields.size > 0 || identity_proofs.size > 0) && (
|
{ (fields.size > 0 || identity_proofs.size > 0) && (
|
||||||
|
@ -323,20 +315,31 @@ class Header extends ImmutablePureComponent {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{account.get('id') !== me && <AccountNoteContainer account={account} />}
|
||||||
|
|
||||||
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
|
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='account__header__extra__links'>
|
<div className='account__header__extra__links'>
|
||||||
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
|
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
|
||||||
<strong>{shortNumberFormat(account.get('statuses_count'))}</strong> <FormattedMessage id='account.posts' defaultMessage='Toots' />
|
<ShortNumber
|
||||||
|
value={account.get('statuses_count')}
|
||||||
|
renderer={counterRenderer('statuses')}
|
||||||
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
|
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
|
||||||
<strong>{shortNumberFormat(account.get('following_count'))}</strong> <FormattedMessage id='account.follows' defaultMessage='Follows' />
|
<ShortNumber
|
||||||
|
value={account.get('following_count')}
|
||||||
|
renderer={counterRenderer('following')}
|
||||||
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
||||||
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
|
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
|
||||||
<strong>{shortNumberFormat(account.get('followers_count'))}</strong> <FormattedMessage id='account.followers' defaultMessage='Followers' />
|
<ShortNumber
|
||||||
|
value={account.get('followers_count')}
|
||||||
|
renderer={counterRenderer('followers')}
|
||||||
|
/>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,34 +1,17 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'mastodon/actions/account_notes';
|
import { submitAccountNote } from 'mastodon/actions/account_notes';
|
||||||
import AccountNote from '../components/account_note';
|
import AccountNote from '../components/account_note';
|
||||||
|
|
||||||
const mapStateToProps = (state, { account }) => {
|
const mapStateToProps = (state, { account }) => ({
|
||||||
const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
|
value: account.getIn(['relationship', 'note']),
|
||||||
|
});
|
||||||
return {
|
|
||||||
isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
|
|
||||||
accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
|
|
||||||
isEditing,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { account }) => ({
|
const mapDispatchToProps = (dispatch, { account }) => ({
|
||||||
|
|
||||||
onEditAccountNote() {
|
onSave (value) {
|
||||||
dispatch(initEditAccountNote(account));
|
dispatch(submitAccountNote(account.get('id'), value));
|
||||||
},
|
},
|
||||||
|
|
||||||
onSaveAccountNote() {
|
|
||||||
dispatch(submitAccountNote());
|
|
||||||
},
|
|
||||||
|
|
||||||
onCancelAccountNote() {
|
|
||||||
dispatch(cancelAccountNote());
|
|
||||||
},
|
|
||||||
|
|
||||||
onChangeAccountNote(comment) {
|
|
||||||
dispatch(changeAccountNoteComment(comment));
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
|
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
|
||||||
|
|
|
@ -19,7 +19,6 @@ import { initBlockModal } from '../../../actions/blocks';
|
||||||
import { initReport } from '../../../actions/reports';
|
import { initReport } from '../../../actions/reports';
|
||||||
import { openModal } from '../../../actions/modal';
|
import { openModal } from '../../../actions/modal';
|
||||||
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
|
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
|
||||||
import { initEditAccountNote } from 'mastodon/actions/account_notes';
|
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import { unfollowModal } from '../../../initial_state';
|
import { unfollowModal } from '../../../initial_state';
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
@ -103,10 +102,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onEditAccountNote (account) {
|
|
||||||
dispatch(initEditAccountNote(account));
|
|
||||||
},
|
|
||||||
|
|
||||||
onBlockDomain (domain) {
|
onBlockDomain (domain) {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(openModal('CONFIRM', {
|
||||||
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
|
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
|
||||||
|
|
|
@ -11,8 +11,14 @@ import RelativeTimestamp from 'mastodon/components/relative_timestamp';
|
||||||
import IconButton from 'mastodon/components/icon_button';
|
import IconButton from 'mastodon/components/icon_button';
|
||||||
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
|
||||||
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
|
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
|
||||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts';
|
import {
|
||||||
|
followAccount,
|
||||||
|
unfollowAccount,
|
||||||
|
blockAccount,
|
||||||
|
unblockAccount,
|
||||||
|
unmuteAccount,
|
||||||
|
} from 'mastodon/actions/accounts';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { initMuteModal } from 'mastodon/actions/mutes';
|
import { initMuteModal } from 'mastodon/actions/mutes';
|
||||||
|
|
||||||
|
@ -22,7 +28,10 @@ const messages = defineMessages({
|
||||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
|
||||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||||
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
|
||||||
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
|
unfollowConfirm: {
|
||||||
|
id: 'confirmations.unfollow.confirm',
|
||||||
|
defaultMessage: 'Unfollow',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
|
@ -36,15 +45,25 @@ const makeMapStateToProps = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
|
onFollow(account) {
|
||||||
onFollow (account) {
|
if (
|
||||||
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
|
account.getIn(['relationship', 'following']) ||
|
||||||
|
account.getIn(['relationship', 'requested'])
|
||||||
|
) {
|
||||||
if (unfollowModal) {
|
if (unfollowModal) {
|
||||||
dispatch(openModal('CONFIRM', {
|
dispatch(
|
||||||
message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
|
openModal('CONFIRM', {
|
||||||
|
message: (
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.unfollow.message'
|
||||||
|
defaultMessage='Are you sure you want to unfollow {name}?'
|
||||||
|
values={{ name: <strong>@{account.get('acct')}</strong> }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
confirm: intl.formatMessage(messages.unfollowConfirm),
|
confirm: intl.formatMessage(messages.unfollowConfirm),
|
||||||
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(unfollowAccount(account.get('id')));
|
dispatch(unfollowAccount(account.get('id')));
|
||||||
}
|
}
|
||||||
|
@ -53,7 +72,7 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onBlock (account) {
|
onBlock(account) {
|
||||||
if (account.getIn(['relationship', 'blocking'])) {
|
if (account.getIn(['relationship', 'blocking'])) {
|
||||||
dispatch(unblockAccount(account.get('id')));
|
dispatch(unblockAccount(account.get('id')));
|
||||||
} else {
|
} else {
|
||||||
|
@ -61,17 +80,17 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onMute (account) {
|
onMute(account) {
|
||||||
if (account.getIn(['relationship', 'muting'])) {
|
if (account.getIn(['relationship', 'muting'])) {
|
||||||
dispatch(unmuteAccount(account.get('id')));
|
dispatch(unmuteAccount(account.get('id')));
|
||||||
} else {
|
} else {
|
||||||
dispatch(initMuteModal(account));
|
dispatch(initMuteModal(account));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @injectIntl
|
export default
|
||||||
|
@injectIntl
|
||||||
@connect(makeMapStateToProps, mapDispatchToProps)
|
@connect(makeMapStateToProps, mapDispatchToProps)
|
||||||
class AccountCard extends ImmutablePureComponent {
|
class AccountCard extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
@ -83,7 +102,7 @@ class AccountCard extends ImmutablePureComponent {
|
||||||
onMute: PropTypes.func.isRequired,
|
onMute: PropTypes.func.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
_updateEmojis () {
|
_updateEmojis() {
|
||||||
const node = this.node;
|
const node = this.node;
|
||||||
|
|
||||||
if (!node || autoPlayGif) {
|
if (!node || autoPlayGif) {
|
||||||
|
@ -104,68 +123,113 @@ class AccountCard extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount() {
|
||||||
this._updateEmojis();
|
this._updateEmojis();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate () {
|
componentDidUpdate() {
|
||||||
this._updateEmojis();
|
this._updateEmojis();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEmojiMouseEnter = ({ target }) => {
|
handleEmojiMouseEnter = ({ target }) => {
|
||||||
target.src = target.getAttribute('data-original');
|
target.src = target.getAttribute('data-original');
|
||||||
}
|
};
|
||||||
|
|
||||||
handleEmojiMouseLeave = ({ target }) => {
|
handleEmojiMouseLeave = ({ target }) => {
|
||||||
target.src = target.getAttribute('data-static');
|
target.src = target.getAttribute('data-static');
|
||||||
}
|
};
|
||||||
|
|
||||||
handleFollow = () => {
|
handleFollow = () => {
|
||||||
this.props.onFollow(this.props.account);
|
this.props.onFollow(this.props.account);
|
||||||
}
|
};
|
||||||
|
|
||||||
handleBlock = () => {
|
handleBlock = () => {
|
||||||
this.props.onBlock(this.props.account);
|
this.props.onBlock(this.props.account);
|
||||||
}
|
};
|
||||||
|
|
||||||
handleMute = () => {
|
handleMute = () => {
|
||||||
this.props.onMute(this.props.account);
|
this.props.onMute(this.props.account);
|
||||||
}
|
};
|
||||||
|
|
||||||
setRef = (c) => {
|
setRef = (c) => {
|
||||||
this.node = c;
|
this.node = c;
|
||||||
}
|
};
|
||||||
|
|
||||||
render () {
|
render() {
|
||||||
const { account, intl } = this.props;
|
const { account, intl } = this.props;
|
||||||
|
|
||||||
let buttons;
|
let buttons;
|
||||||
|
|
||||||
if (account.get('id') !== me && account.get('relationship', null) !== null) {
|
if (
|
||||||
|
account.get('id') !== me &&
|
||||||
|
account.get('relationship', null) !== null
|
||||||
|
) {
|
||||||
const following = account.getIn(['relationship', 'following']);
|
const following = account.getIn(['relationship', 'following']);
|
||||||
const requested = account.getIn(['relationship', 'requested']);
|
const requested = account.getIn(['relationship', 'requested']);
|
||||||
const blocking = account.getIn(['relationship', 'blocking']);
|
const blocking = account.getIn(['relationship', 'blocking']);
|
||||||
const muting = account.getIn(['relationship', 'muting']);
|
const muting = account.getIn(['relationship', 'muting']);
|
||||||
|
|
||||||
if (requested) {
|
if (requested) {
|
||||||
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
|
buttons = (
|
||||||
|
<IconButton
|
||||||
|
disabled
|
||||||
|
icon='hourglass'
|
||||||
|
title={intl.formatMessage(messages.requested)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (blocking) {
|
} else if (blocking) {
|
||||||
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
|
buttons = (
|
||||||
|
<IconButton
|
||||||
|
active
|
||||||
|
icon='unlock'
|
||||||
|
title={intl.formatMessage(messages.unblock, {
|
||||||
|
name: account.get('username'),
|
||||||
|
})}
|
||||||
|
onClick={this.handleBlock}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (muting) {
|
} else if (muting) {
|
||||||
buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
|
buttons = (
|
||||||
|
<IconButton
|
||||||
|
active
|
||||||
|
icon='volume-up'
|
||||||
|
title={intl.formatMessage(messages.unmute, {
|
||||||
|
name: account.get('username'),
|
||||||
|
})}
|
||||||
|
onClick={this.handleMute}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (!account.get('moved') || following) {
|
} else if (!account.get('moved') || following) {
|
||||||
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
|
buttons = (
|
||||||
|
<IconButton
|
||||||
|
icon={following ? 'user-times' : 'user-plus'}
|
||||||
|
title={intl.formatMessage(
|
||||||
|
following ? messages.unfollow : messages.follow,
|
||||||
|
)}
|
||||||
|
onClick={this.handleFollow}
|
||||||
|
active={following}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='directory__card'>
|
<div className='directory__card'>
|
||||||
<div className='directory__card__img'>
|
<div className='directory__card__img'>
|
||||||
<img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' />
|
<img
|
||||||
|
src={
|
||||||
|
autoPlayGif ? account.get('header') : account.get('header_static')
|
||||||
|
}
|
||||||
|
alt=''
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='directory__card__bar'>
|
<div className='directory__card__bar'>
|
||||||
<Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
|
<Permalink
|
||||||
|
className='directory__card__bar__name'
|
||||||
|
href={account.get('url')}
|
||||||
|
to={`/accounts/${account.get('id')}`}
|
||||||
|
>
|
||||||
<Avatar account={account} size={48} />
|
<Avatar account={account} size={48} />
|
||||||
<DisplayName account={account} />
|
<DisplayName account={account} />
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
@ -176,13 +240,44 @@ class AccountCard extends ImmutablePureComponent {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='directory__card__extra' ref={this.setRef}>
|
<div className='directory__card__extra' ref={this.setRef}>
|
||||||
<div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
|
<div
|
||||||
|
className='account__header__content'
|
||||||
|
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='directory__card__extra'>
|
<div className='directory__card__extra'>
|
||||||
<div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div>
|
<div className='accounts-table__count'>
|
||||||
<div className='accounts-table__count'>{shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div>
|
<ShortNumber value={account.get('statuses_count')} />
|
||||||
<div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div>
|
<small>
|
||||||
|
<FormattedMessage id='account.posts' defaultMessage='Toots' />
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className='accounts-table__count'>
|
||||||
|
<ShortNumber value={account.get('followers_count')} />{' '}
|
||||||
|
<small>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.followers'
|
||||||
|
defaultMessage='Followers'
|
||||||
|
/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className='accounts-table__count'>
|
||||||
|
{account.get('last_status_at') === null ? (
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.never_active'
|
||||||
|
defaultMessage='Never'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RelativeTimestamp timestamp={account.get('last_status_at')} />
|
||||||
|
)}{' '}
|
||||||
|
<small>
|
||||||
|
<FormattedMessage
|
||||||
|
id='account.last_status'
|
||||||
|
defaultMessage='Last active'
|
||||||
|
/>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,6 +4,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import Toggle from 'react-toggle';
|
import Toggle from 'react-toggle';
|
||||||
import AsyncSelect from 'react-select/async';
|
import AsyncSelect from 'react-select/async';
|
||||||
|
import { NonceProvider } from 'react-select';
|
||||||
import SettingToggle from '../../notifications/components/setting_toggle';
|
import SettingToggle from '../../notifications/components/setting_toggle';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -58,6 +59,7 @@ class ColumnSettings extends React.PureComponent {
|
||||||
{this.modeLabel(mode)}
|
{this.modeLabel(mode)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<NonceProvider nonce={document.querySelector('meta[name=style-nonce]').content}>
|
||||||
<AsyncSelect
|
<AsyncSelect
|
||||||
isMulti
|
isMulti
|
||||||
autoFocus
|
autoFocus
|
||||||
|
@ -70,6 +72,7 @@ class ColumnSettings extends React.PureComponent {
|
||||||
placeholder={this.props.intl.formatMessage(messages.placeholder)}
|
placeholder={this.props.intl.formatMessage(messages.placeholder)}
|
||||||
noOptionsMessage={this.noOptionsMessage}
|
noOptionsMessage={this.noOptionsMessage}
|
||||||
/>
|
/>
|
||||||
|
</NonceProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { changeUploadCompose } from '../../../actions/compose';
|
import { changeUploadCompose, uploadThumbnail } from '../../../actions/compose';
|
||||||
import { getPointerPosition } from '../../video';
|
import { getPointerPosition } from '../../video';
|
||||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||||
import IconButton from 'mastodon/components/icon_button';
|
import IconButton from 'mastodon/components/icon_button';
|
||||||
|
@ -23,11 +23,13 @@ const messages = defineMessages({
|
||||||
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||||
apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
|
apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
|
||||||
placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
|
placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
|
||||||
|
chooseImage: { id: 'upload_modal.choose_image', defaultMessage: 'Choose image' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, { id }) => ({
|
const mapStateToProps = (state, { id }) => ({
|
||||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||||
account: state.getIn(['accounts', me]),
|
account: state.getIn(['accounts', me]),
|
||||||
|
isUploadingThumbnail: state.getIn(['compose', 'isUploadingThumbnail']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch, { id }) => ({
|
const mapDispatchToProps = (dispatch, { id }) => ({
|
||||||
|
@ -36,6 +38,10 @@ const mapDispatchToProps = (dispatch, { id }) => ({
|
||||||
dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
|
dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onSelectThumbnail: files => {
|
||||||
|
dispatch(uploadThumbnail(id, files[0]));
|
||||||
|
},
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
|
const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
|
||||||
|
@ -81,6 +87,9 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
media: ImmutablePropTypes.map.isRequired,
|
||||||
account: ImmutablePropTypes.map.isRequired,
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
|
isUploadingThumbnail: PropTypes.bool,
|
||||||
|
onSave: PropTypes.func.isRequired,
|
||||||
|
onSelectThumbnail: PropTypes.func.isRequired,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
@ -235,13 +244,29 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||||
}).catch(() => this.setState({ detecting: false }));
|
}).catch(() => this.setState({ detecting: false }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleThumbnailChange = e => {
|
||||||
|
if (e.target.files.length > 0) {
|
||||||
|
this.setState({ dirty: true });
|
||||||
|
this.props.onSelectThumbnail(e.target.files);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFileInputRef = c => {
|
||||||
|
this.fileInput = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileInputClick = () => {
|
||||||
|
this.fileInput.click();
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { media, intl, account, onClose } = this.props;
|
const { media, intl, account, onClose, isUploadingThumbnail } = this.props;
|
||||||
const { x, y, dragging, description, dirty, detecting, progress } = this.state;
|
const { x, y, dragging, description, dirty, detecting, progress } = this.state;
|
||||||
|
|
||||||
const width = media.getIn(['meta', 'original', 'width']) || null;
|
const width = media.getIn(['meta', 'original', 'width']) || null;
|
||||||
const height = media.getIn(['meta', 'original', 'height']) || null;
|
const height = media.getIn(['meta', 'original', 'height']) || null;
|
||||||
const focals = ['image', 'gifv'].includes(media.get('type'));
|
const focals = ['image', 'gifv'].includes(media.get('type'));
|
||||||
|
const thumbnailable = ['audio', 'video'].includes(media.get('type'));
|
||||||
|
|
||||||
const previewRatio = 16/9;
|
const previewRatio = 16/9;
|
||||||
const previewWidth = 200;
|
const previewWidth = 200;
|
||||||
|
@ -268,6 +293,30 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||||
<div className='report-modal__comment'>
|
<div className='report-modal__comment'>
|
||||||
{focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
|
{focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
|
||||||
|
|
||||||
|
{thumbnailable && (
|
||||||
|
<React.Fragment>
|
||||||
|
<label className='setting-text-label' htmlFor='upload-modal__thumbnail'><FormattedMessage id='upload_form.thumbnail' defaultMessage='Change thumbnail' /></label>
|
||||||
|
|
||||||
|
<Button disabled={isUploadingThumbnail} text={intl.formatMessage(messages.chooseImage)} onClick={this.handleFileInputClick} />
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span style={{ display: 'none' }}>{intl.formatMessage(messages.chooseImage)}</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id='upload-modal__thumbnail'
|
||||||
|
ref={this.setFileInputRef}
|
||||||
|
type='file'
|
||||||
|
accept='image/png,image/jpeg'
|
||||||
|
onChange={this.handleThumbnailChange}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
disabled={isUploadingThumbnail}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<hr className='setting-divider' />
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
|
||||||
<label className='setting-text-label' htmlFor='upload-modal__description'>
|
<label className='setting-text-label' htmlFor='upload-modal__description'>
|
||||||
{descriptionLabel}
|
{descriptionLabel}
|
||||||
</label>
|
</label>
|
||||||
|
@ -293,7 +342,7 @@ class FocalPointModal extends ImmutablePureComponent {
|
||||||
<CharacterCounter max={1500} text={detecting ? '' : description} />
|
<CharacterCounter max={1500} text={detecting ? '' : description} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button disabled={!dirty || detecting || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
|
<Button disabled={!dirty || detecting || isUploadingThumbnail || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='focal-point-modal__content'>
|
<div className='focal-point-modal__content'>
|
||||||
|
|
|
@ -666,24 +666,16 @@
|
||||||
{
|
{
|
||||||
"descriptors": [
|
"descriptors": [
|
||||||
{
|
{
|
||||||
"defaultMessage": "No comment provided",
|
"defaultMessage": "Click to add a note",
|
||||||
"id": "account_note.placeholder"
|
"id": "account_note.placeholder"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defaultMessage": "Cancel",
|
"defaultMessage": "Saved",
|
||||||
"id": "account_note.cancel"
|
"id": "generic.saved"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defaultMessage": "Save",
|
"defaultMessage": "Note",
|
||||||
"id": "account_note.save"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"defaultMessage": "Your note for @{name}",
|
|
||||||
"id": "account.account_note_header"
|
"id": "account.account_note_header"
|
||||||
},
|
|
||||||
{
|
|
||||||
"defaultMessage": "Edit",
|
|
||||||
"id": "account_note.edit"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"path": "app/javascript/mastodon/features/account/components/account_note.json"
|
"path": "app/javascript/mastodon/features/account/components/account_note.json"
|
||||||
|
@ -818,10 +810,6 @@
|
||||||
"defaultMessage": "Open moderation interface for @{name}",
|
"defaultMessage": "Open moderation interface for @{name}",
|
||||||
"id": "status.admin_account"
|
"id": "status.admin_account"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"defaultMessage": "Add note for @{name}",
|
|
||||||
"id": "account.add_account_note"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"defaultMessage": "Follows you",
|
"defaultMessage": "Follows you",
|
||||||
"id": "account.follows_you"
|
"id": "account.follows_you"
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
{
|
{
|
||||||
"account.account_note_header": "Your note for @{name}",
|
"account.account_note_header": "Note",
|
||||||
"account.add_account_note": "Add note for @{name}",
|
|
||||||
"account.add_or_remove_from_list": "Add or Remove from lists",
|
"account.add_or_remove_from_list": "Add or Remove from lists",
|
||||||
"account.badges.bot": "Bot",
|
"account.badges.bot": "Bot",
|
||||||
"account.badges.group": "Group",
|
"account.badges.group": "Group",
|
||||||
|
@ -42,10 +41,7 @@
|
||||||
"account.unfollow": "Unfollow",
|
"account.unfollow": "Unfollow",
|
||||||
"account.unmute": "Unmute @{name}",
|
"account.unmute": "Unmute @{name}",
|
||||||
"account.unmute_notifications": "Unmute notifications from @{name}",
|
"account.unmute_notifications": "Unmute notifications from @{name}",
|
||||||
"account_note.cancel": "Cancel",
|
"account_note.placeholder": "Click to add note",
|
||||||
"account_note.edit": "Edit",
|
|
||||||
"account_note.placeholder": "No comment provided",
|
|
||||||
"account_note.save": "Save",
|
|
||||||
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
|
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
|
||||||
"alert.rate_limited.title": "Rate limited",
|
"alert.rate_limited.title": "Rate limited",
|
||||||
"alert.unexpected.message": "An unexpected error occurred.",
|
"alert.unexpected.message": "An unexpected error occurred.",
|
||||||
|
@ -178,6 +174,7 @@
|
||||||
"follow_request.authorize": "Authorize",
|
"follow_request.authorize": "Authorize",
|
||||||
"follow_request.reject": "Reject",
|
"follow_request.reject": "Reject",
|
||||||
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
|
"follow_requests.unlocked_explanation": "Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.",
|
||||||
|
"generic.saved": "Saved",
|
||||||
"getting_started.developers": "Developers",
|
"getting_started.developers": "Developers",
|
||||||
"getting_started.directory": "Profile directory",
|
"getting_started.directory": "Profile directory",
|
||||||
"getting_started.documentation": "Documentation",
|
"getting_started.documentation": "Documentation",
|
||||||
|
@ -377,7 +374,7 @@
|
||||||
"status.bookmark": "Bookmark",
|
"status.bookmark": "Bookmark",
|
||||||
"status.cancel_reblog_private": "Unboost",
|
"status.cancel_reblog_private": "Unboost",
|
||||||
"status.cannot_reblog": "This post cannot be boosted",
|
"status.cannot_reblog": "This post cannot be boosted",
|
||||||
"status.copy": "Copy link to status",
|
"status.copy": "Copy link to toot",
|
||||||
"status.delete": "Delete",
|
"status.delete": "Delete",
|
||||||
"status.detailed_status": "Detailed conversation view",
|
"status.detailed_status": "Detailed conversation view",
|
||||||
"status.direct": "Direct message @{name}",
|
"status.direct": "Direct message @{name}",
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
import { Map as ImmutableMap } from 'immutable';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ACCOUNT_NOTE_INIT_EDIT,
|
|
||||||
ACCOUNT_NOTE_CANCEL,
|
|
||||||
ACCOUNT_NOTE_CHANGE_COMMENT,
|
|
||||||
ACCOUNT_NOTE_SUBMIT_REQUEST,
|
|
||||||
ACCOUNT_NOTE_SUBMIT_FAIL,
|
|
||||||
ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
|
||||||
} from '../actions/account_notes';
|
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
|
||||||
edit: ImmutableMap({
|
|
||||||
isSubmitting: false,
|
|
||||||
account_id: null,
|
|
||||||
comment: null,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function account_notes(state = initialState, action) {
|
|
||||||
switch (action.type) {
|
|
||||||
case ACCOUNT_NOTE_INIT_EDIT:
|
|
||||||
return state.withMutations((state) => {
|
|
||||||
state.setIn(['edit', 'isSubmitting'], false);
|
|
||||||
state.setIn(['edit', 'account_id'], action.account.get('id'));
|
|
||||||
state.setIn(['edit', 'comment'], action.comment);
|
|
||||||
});
|
|
||||||
case ACCOUNT_NOTE_CHANGE_COMMENT:
|
|
||||||
return state.setIn(['edit', 'comment'], action.comment);
|
|
||||||
case ACCOUNT_NOTE_SUBMIT_REQUEST:
|
|
||||||
return state.setIn(['edit', 'isSubmitting'], true);
|
|
||||||
case ACCOUNT_NOTE_SUBMIT_FAIL:
|
|
||||||
return state.setIn(['edit', 'isSubmitting'], false);
|
|
||||||
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
|
|
||||||
case ACCOUNT_NOTE_CANCEL:
|
|
||||||
return state.withMutations((state) => {
|
|
||||||
state.setIn(['edit', 'isSubmitting'], false);
|
|
||||||
state.setIn(['edit', 'account_id'], null);
|
|
||||||
state.setIn(['edit', 'comment'], null);
|
|
||||||
});
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -14,6 +14,10 @@ import {
|
||||||
COMPOSE_UPLOAD_FAIL,
|
COMPOSE_UPLOAD_FAIL,
|
||||||
COMPOSE_UPLOAD_UNDO,
|
COMPOSE_UPLOAD_UNDO,
|
||||||
COMPOSE_UPLOAD_PROGRESS,
|
COMPOSE_UPLOAD_PROGRESS,
|
||||||
|
THUMBNAIL_UPLOAD_REQUEST,
|
||||||
|
THUMBNAIL_UPLOAD_SUCCESS,
|
||||||
|
THUMBNAIL_UPLOAD_FAIL,
|
||||||
|
THUMBNAIL_UPLOAD_PROGRESS,
|
||||||
COMPOSE_SUGGESTIONS_CLEAR,
|
COMPOSE_SUGGESTIONS_CLEAR,
|
||||||
COMPOSE_SUGGESTIONS_READY,
|
COMPOSE_SUGGESTIONS_READY,
|
||||||
COMPOSE_SUGGESTION_SELECT,
|
COMPOSE_SUGGESTION_SELECT,
|
||||||
|
@ -60,6 +64,8 @@ const initialState = ImmutableMap({
|
||||||
is_changing_upload: false,
|
is_changing_upload: false,
|
||||||
is_uploading: false,
|
is_uploading: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
|
isUploadingThumbnail: false,
|
||||||
|
thumbnailProgress: 0,
|
||||||
media_attachments: ImmutableList(),
|
media_attachments: ImmutableList(),
|
||||||
pending_media_attachments: 0,
|
pending_media_attachments: 0,
|
||||||
poll: null,
|
poll: null,
|
||||||
|
@ -332,6 +338,22 @@ export default function compose(state = initialState, action) {
|
||||||
return removeMedia(state, action.media_id);
|
return removeMedia(state, action.media_id);
|
||||||
case COMPOSE_UPLOAD_PROGRESS:
|
case COMPOSE_UPLOAD_PROGRESS:
|
||||||
return state.set('progress', Math.round((action.loaded / action.total) * 100));
|
return state.set('progress', Math.round((action.loaded / action.total) * 100));
|
||||||
|
case THUMBNAIL_UPLOAD_REQUEST:
|
||||||
|
return state.set('isUploadingThumbnail', true);
|
||||||
|
case THUMBNAIL_UPLOAD_PROGRESS:
|
||||||
|
return state.set('thumbnailProgress', Math.round((action.loaded / action.total) * 100));
|
||||||
|
case THUMBNAIL_UPLOAD_FAIL:
|
||||||
|
return state.set('isUploadingThumbnail', false);
|
||||||
|
case THUMBNAIL_UPLOAD_SUCCESS:
|
||||||
|
return state
|
||||||
|
.set('isUploadingThumbnail', false)
|
||||||
|
.update('media_attachments', list => list.map(item => {
|
||||||
|
if (item.get('id') === action.media.id) {
|
||||||
|
return fromJS(action.media);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}));
|
||||||
case COMPOSE_MENTION:
|
case COMPOSE_MENTION:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
|
map.update('text', text => [text.trim(), `@${action.account.get('acct')} `].filter((str) => str.length !== 0).join(' '));
|
||||||
|
|
|
@ -36,7 +36,6 @@ import trends from './trends';
|
||||||
import missed_updates from './missed_updates';
|
import missed_updates from './missed_updates';
|
||||||
import announcements from './announcements';
|
import announcements from './announcements';
|
||||||
import markers from './markers';
|
import markers from './markers';
|
||||||
import account_notes from './account_notes';
|
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
announcements,
|
announcements,
|
||||||
|
@ -76,7 +75,6 @@ const reducers = {
|
||||||
trends,
|
trends,
|
||||||
missed_updates,
|
missed_updates,
|
||||||
markers,
|
markers,
|
||||||
account_notes,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default combineReducers(reducers);
|
export default combineReducers(reducers);
|
||||||
|
|
|
@ -1,16 +1,71 @@
|
||||||
import React, { Fragment } from 'react';
|
// @ts-check
|
||||||
import { FormattedNumber } from 'react-intl';
|
|
||||||
|
|
||||||
export const shortNumberFormat = number => {
|
export const DECIMAL_UNITS = Object.freeze({
|
||||||
if (number < 1000) {
|
ONE: 1,
|
||||||
return <FormattedNumber value={number} />;
|
TEN: 10,
|
||||||
} else if (number < 10000) {
|
HUNDRED: Math.pow(10, 2),
|
||||||
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</Fragment>;
|
THOUSAND: Math.pow(10, 3),
|
||||||
} else if (number < 1000000) {
|
MILLION: Math.pow(10, 6),
|
||||||
return <Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={0} />K</Fragment>;
|
BILLION: Math.pow(10, 9),
|
||||||
} else if (number < 10000000) {
|
TRILLION: Math.pow(10, 12),
|
||||||
return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={1} />M</Fragment>;
|
});
|
||||||
} else {
|
|
||||||
return <Fragment><FormattedNumber value={number / 1000000} maximumFractionDigits={0} />M</Fragment>;
|
const TEN_THOUSAND = DECIMAL_UNITS.THOUSAND * 10;
|
||||||
|
const TEN_MILLIONS = DECIMAL_UNITS.MILLION * 10;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {[number, number, number]} ShortNumber
|
||||||
|
* Array of: shorten number, unit of shorten number and maximum fraction digits
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} sourceNumber Number to convert to short number
|
||||||
|
* @returns {ShortNumber} Calculated short number
|
||||||
|
* @example
|
||||||
|
* shortNumber(5936);
|
||||||
|
* // => [5.936, 1000, 1]
|
||||||
|
*/
|
||||||
|
export function toShortNumber(sourceNumber) {
|
||||||
|
if (sourceNumber < DECIMAL_UNITS.THOUSAND) {
|
||||||
|
return [sourceNumber, DECIMAL_UNITS.ONE, 0];
|
||||||
|
} else if (sourceNumber < DECIMAL_UNITS.MILLION) {
|
||||||
|
return [
|
||||||
|
sourceNumber / DECIMAL_UNITS.THOUSAND,
|
||||||
|
DECIMAL_UNITS.THOUSAND,
|
||||||
|
sourceNumber < TEN_THOUSAND ? 1 : 0,
|
||||||
|
];
|
||||||
|
} else if (sourceNumber < DECIMAL_UNITS.BILLION) {
|
||||||
|
return [
|
||||||
|
sourceNumber / DECIMAL_UNITS.MILLION,
|
||||||
|
DECIMAL_UNITS.MILLION,
|
||||||
|
sourceNumber < TEN_MILLIONS ? 1 : 0,
|
||||||
|
];
|
||||||
|
} else if (sourceNumber < DECIMAL_UNITS.TRILLION) {
|
||||||
|
return [
|
||||||
|
sourceNumber / DECIMAL_UNITS.BILLION,
|
||||||
|
DECIMAL_UNITS.BILLION,
|
||||||
|
0,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
return [sourceNumber, DECIMAL_UNITS.ONE, 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} sourceNumber Original number that is shortened
|
||||||
|
* @param {number} division The scale in which short number is displayed
|
||||||
|
* @returns {number} Number that can be used for plurals when short form used
|
||||||
|
* @example
|
||||||
|
* pluralReady(1793, DECIMAL_UNITS.THOUSAND)
|
||||||
|
* // => 1790
|
||||||
|
*/
|
||||||
|
export function pluralReady(sourceNumber, division) {
|
||||||
|
// eslint-disable-next-line eqeqeq
|
||||||
|
if (division == null || division < DECIMAL_UNITS.HUNDRED) {
|
||||||
|
return sourceNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
let closestScale = division / DECIMAL_UNITS.TEN;
|
||||||
|
|
||||||
|
return Math.trunc(sourceNumber / closestScale) * closestScale;
|
||||||
|
}
|
||||||
|
|
|
@ -11,6 +11,15 @@
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-alert {
|
||||||
|
color: $valid-value-color;
|
||||||
|
font-weight: 400;
|
||||||
|
|
||||||
|
.no-reduce-motion & {
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.link-button {
|
.link-button {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
@ -4868,6 +4877,15 @@ a.status-card.compact:hover {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setting-divider {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
margin-bottom: 29px;
|
||||||
|
}
|
||||||
|
|
||||||
.report-modal__comment {
|
.report-modal__comment {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-right: 1px solid $ui-secondary-color;
|
border-right: 1px solid $ui-secondary-color;
|
||||||
|
@ -6557,6 +6575,11 @@ noscript {
|
||||||
padding: 20px 15px;
|
padding: 20px 15px;
|
||||||
padding-bottom: 5px;
|
padding-bottom: 5px;
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
|
|
||||||
|
.columns-area--mobile & {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.account__header__fields {
|
.account__header__fields {
|
||||||
|
@ -6601,63 +6624,50 @@ noscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
&__account-note {
|
&__account-note {
|
||||||
margin: 5px;
|
padding: 15px;
|
||||||
padding: 10px;
|
padding-bottom: 10px;
|
||||||
background: $ui-highlight-color;
|
|
||||||
color: $primary-text-color;
|
color: $primary-text-color;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
border-bottom: 1px solid lighten($ui-base-color, 12%);
|
||||||
|
|
||||||
&__header {
|
.columns-area--mobile & {
|
||||||
display: flex;
|
padding-left: 20px;
|
||||||
flex-direction: row;
|
padding-right: 20px;
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
label {
|
||||||
white-space: pre-wrap;
|
display: block;
|
||||||
margin-top: 5px;
|
font-size: 12px;
|
||||||
}
|
|
||||||
|
|
||||||
&__buttons {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: flex-end;
|
|
||||||
margin-top: 5px;
|
|
||||||
|
|
||||||
.flex-spacer {
|
|
||||||
flex: 0 0 20px;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
strong {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
color: $darker-text-color;
|
||||||
|
text-transform: uppercase;
|
||||||
button:hover span {
|
margin-bottom: 5px;
|
||||||
text-decoration: underline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
display: block;
|
display: block;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100%;
|
width: calc(100% + 20px);
|
||||||
margin: 0;
|
color: $secondary-text-color;
|
||||||
margin-top: 5px;
|
background: transparent;
|
||||||
color: $inverted-text-color;
|
|
||||||
background: $simple-background-color;
|
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
margin: 0 -10px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
resize: none;
|
resize: none;
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $dark-text-color;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: $ui-base-color;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,6 +52,6 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def note
|
def note
|
||||||
(instance_options[:relationships].account_note[object.id] || {})[:comment]
|
(instance_options[:relationships].account_note[object.id] || {})[:comment] || ''
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -45,7 +45,7 @@ class FetchLinkCardService < BaseService
|
||||||
def html
|
def html
|
||||||
return @html if defined?(@html)
|
return @html if defined?(@html)
|
||||||
|
|
||||||
Request.new(:get, @url).add_headers('Accept' => 'text/html').perform do |res|
|
Request.new(:get, @url).add_headers('Accept' => 'text/html', 'User-Agent' => Mastodon::Version.user_agent + ' Bot').perform do |res|
|
||||||
if res.code == 200 && res.mime_type == 'text/html'
|
if res.code == 200 && res.mime_type == 'text/html'
|
||||||
@html = res.body_with_limit
|
@html = res.body_with_limit
|
||||||
@html_charset = res.charset
|
@html_charset = res.charset
|
||||||
|
|
|
@ -27,6 +27,7 @@
|
||||||
- elsif @theme[:supported_locales].include? 'en'
|
- elsif @theme[:supported_locales].include? 'en'
|
||||||
= javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous'
|
= javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous'
|
||||||
= csrf_meta_tags
|
= csrf_meta_tags
|
||||||
|
%meta{ name: 'style-nonce', content: request.content_security_policy_nonce }
|
||||||
|
|
||||||
= stylesheet_link_tag '/inert.css', skip_pipeline: true, media: 'all', id: 'inert-style'
|
= stylesheet_link_tag '/inert.css', skip_pipeline: true, media: 'all', id: 'inert-style'
|
||||||
|
|
||||||
|
|
|
@ -104,12 +104,12 @@ persistence:
|
||||||
accessMode: ReadWriteOnce
|
accessMode: ReadWriteOnce
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 100Gi
|
storage: 10Gi
|
||||||
system:
|
system:
|
||||||
accessMode: ReadWriteOnce
|
accessMode: ReadWriteOnce
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: 10Gi
|
storage: 100Gi
|
||||||
|
|
||||||
service:
|
service:
|
||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
|
|
|
@ -49,7 +49,25 @@ end
|
||||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
|
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only
|
||||||
# Rails.application.config.content_security_policy_report_only = true
|
# Rails.application.config.content_security_policy_report_only = true
|
||||||
|
|
||||||
|
Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) }
|
||||||
|
|
||||||
|
# Monkey-patching Rails 5
|
||||||
|
module ActionDispatch
|
||||||
|
class ContentSecurityPolicy
|
||||||
|
def nonce_directive?(directive)
|
||||||
|
directive == 'style-src'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Rails 6 would require the following instead:
|
||||||
|
# Rails.application.config.content_security_policy_nonce_directives = %w(style-src)
|
||||||
|
|
||||||
PgHero::HomeController.content_security_policy do |p|
|
PgHero::HomeController.content_security_policy do |p|
|
||||||
p.script_src :self, :unsafe_inline, assets_host
|
p.script_src :self, :unsafe_inline, assets_host
|
||||||
p.style_src :self, :unsafe_inline, assets_host
|
p.style_src :self, :unsafe_inline, assets_host
|
||||||
end
|
end
|
||||||
|
|
||||||
|
PgHero::HomeController.after_action do
|
||||||
|
request.content_security_policy_nonce_generator = nil
|
||||||
|
end
|
||||||
|
|
|
@ -38,15 +38,6 @@ class Rack::Attack
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
PROTECTED_PATHS = %w(
|
|
||||||
/auth/sign_in
|
|
||||||
/auth
|
|
||||||
/auth/password
|
|
||||||
/auth/confirmation
|
|
||||||
).freeze
|
|
||||||
|
|
||||||
PROTECTED_PATHS_REGEX = Regexp.union(PROTECTED_PATHS.map { |path| /\A#{Regexp.escape(path)}/ })
|
|
||||||
|
|
||||||
Rack::Attack.safelist('allow from localhost') do |req|
|
Rack::Attack.safelist('allow from localhost') do |req|
|
||||||
req.remote_ip == '127.0.0.1' || req.remote_ip == '::1'
|
req.remote_ip == '127.0.0.1' || req.remote_ip == '::1'
|
||||||
end
|
end
|
||||||
|
@ -86,8 +77,32 @@ class Rack::Attack
|
||||||
req.authenticated_user_id if (req.post? && req.path =~ API_DELETE_REBLOG_REGEX) || (req.delete? && req.path =~ API_DELETE_STATUS_REGEX)
|
req.authenticated_user_id if (req.post? && req.path =~ API_DELETE_REBLOG_REGEX) || (req.delete? && req.path =~ API_DELETE_STATUS_REGEX)
|
||||||
end
|
end
|
||||||
|
|
||||||
throttle('protected_paths', limit: 25, period: 5.minutes) do |req|
|
throttle('throttle_sign_up_attempts/ip', limit: 25, period: 5.minutes) do |req|
|
||||||
req.remote_ip if req.post? && req.path =~ PROTECTED_PATHS_REGEX
|
req.remote_ip if req.post? && req.path == '/auth'
|
||||||
|
end
|
||||||
|
|
||||||
|
throttle('throttle_password_resets/ip', limit: 25, period: 5.minutes) do |req|
|
||||||
|
req.remote_ip if req.post? && req.path == '/auth/password'
|
||||||
|
end
|
||||||
|
|
||||||
|
throttle('throttle_password_resets/email', limit: 5, period: 30.minutes) do |req|
|
||||||
|
req.params.dig('user', 'email').presence if req.post? && req.path == '/auth/password'
|
||||||
|
end
|
||||||
|
|
||||||
|
throttle('throttle_email_confirmations/ip', limit: 25, period: 5.minutes) do |req|
|
||||||
|
req.remote_ip if req.post? && req.path == '/auth/confirmation'
|
||||||
|
end
|
||||||
|
|
||||||
|
throttle('throttle_email_confirmations/email', limit: 5, period: 30.minutes) do |req|
|
||||||
|
req.params.dig('user', 'email').presence if req.post? && req.path == '/auth/password'
|
||||||
|
end
|
||||||
|
|
||||||
|
throttle('throttle_login_attempts/ip', limit: 25, period: 5.minutes) do |req|
|
||||||
|
req.remote_ip if req.post? && req.path == '/auth/sign_in'
|
||||||
|
end
|
||||||
|
|
||||||
|
throttle('throttle_login_attempts/email', limit: 25, period: 1.hour) do |req|
|
||||||
|
req.session[:attempt_user_id] || req.params.dig('user', 'email').presence if req.post? && req.path == '/auth/sign_in'
|
||||||
end
|
end
|
||||||
|
|
||||||
self.throttled_response = lambda do |env|
|
self.throttled_response = lambda do |env|
|
||||||
|
|
|
@ -2,5 +2,6 @@ ActiveSupport::Notifications.subscribe(/rack_attack/) do |_name, _start, _finish
|
||||||
req = payload[:request]
|
req = payload[:request]
|
||||||
|
|
||||||
next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
|
next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
|
||||||
|
|
||||||
Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
|
Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
class MediaAttachmentIdsToTimestampIds < ActiveRecord::Migration[5.1]
|
||||||
|
def up
|
||||||
|
# Set up the media_attachments.id column to use our timestamp-based IDs.
|
||||||
|
safety_assured do
|
||||||
|
execute("ALTER TABLE media_attachments ALTER COLUMN id SET DEFAULT timestamp_id('media_attachments')")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Make sure we have a sequence to use.
|
||||||
|
Mastodon::Snowflake.ensure_id_sequences_exist
|
||||||
|
end
|
||||||
|
|
||||||
|
def down
|
||||||
|
execute("LOCK media_attachments")
|
||||||
|
execute("SELECT setval('media_attachments_id_seq', (SELECT MAX(id) FROM media_attachments))")
|
||||||
|
execute("ALTER TABLE media_attachments ALTER COLUMN id SET DEFAULT nextval('media_attachments_id_seq')")
|
||||||
|
end
|
||||||
|
end
|
26
db/schema.rb
26
db/schema.rb
|
@ -77,6 +77,16 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
|
||||||
t.index ["target_account_id"], name: "index_account_moderation_notes_on_target_account_id"
|
t.index ["target_account_id"], name: "index_account_moderation_notes_on_target_account_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "account_notes", force: :cascade do |t|
|
||||||
|
t.bigint "account_id"
|
||||||
|
t.bigint "target_account_id"
|
||||||
|
t.text "comment", null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["account_id", "target_account_id"], name: "index_account_notes_on_account_id_and_target_account_id", unique: true
|
||||||
|
t.index ["target_account_id"], name: "index_account_notes_on_target_account_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "account_pins", force: :cascade do |t|
|
create_table "account_pins", force: :cascade do |t|
|
||||||
t.bigint "account_id"
|
t.bigint "account_id"
|
||||||
t.bigint "target_account_id"
|
t.bigint "target_account_id"
|
||||||
|
@ -472,7 +482,7 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
|
||||||
t.index ["user_id", "timeline"], name: "index_markers_on_user_id_and_timeline", unique: true
|
t.index ["user_id", "timeline"], name: "index_markers_on_user_id_and_timeline", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "media_attachments", force: :cascade do |t|
|
create_table "media_attachments", id: :bigint, default: -> { "timestamp_id('media_attachments'::text)" }, force: :cascade do |t|
|
||||||
t.bigint "status_id"
|
t.bigint "status_id"
|
||||||
t.string "file_file_name"
|
t.string "file_file_name"
|
||||||
t.string "file_content_type"
|
t.string "file_content_type"
|
||||||
|
@ -836,16 +846,6 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
|
||||||
t.index ["user_id"], name: "index_user_invite_requests_on_user_id"
|
t.index ["user_id"], name: "index_user_invite_requests_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "account_notes", force: :cascade do |t|
|
|
||||||
t.bigint "account_id"
|
|
||||||
t.bigint "target_account_id"
|
|
||||||
t.text "comment", null: false
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.index ["account_id", "target_account_id"], name: "index_account_notes_on_account_id_and_target_account_id", unique: true
|
|
||||||
t.index ["target_account_id"], name: "index_account_notes_on_target_account_id"
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table "users", force: :cascade do |t|
|
create_table "users", force: :cascade do |t|
|
||||||
t.string "email", default: "", null: false
|
t.string "email", default: "", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
@ -921,6 +921,8 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
|
||||||
add_foreign_key "account_migrations", "accounts", on_delete: :cascade
|
add_foreign_key "account_migrations", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "account_moderation_notes", "accounts"
|
add_foreign_key "account_moderation_notes", "accounts"
|
||||||
add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id"
|
add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id"
|
||||||
|
add_foreign_key "account_notes", "accounts", column: "target_account_id", on_delete: :cascade
|
||||||
|
add_foreign_key "account_notes", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade
|
add_foreign_key "account_pins", "accounts", column: "target_account_id", on_delete: :cascade
|
||||||
add_foreign_key "account_pins", "accounts", on_delete: :cascade
|
add_foreign_key "account_pins", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "account_stats", "accounts", on_delete: :cascade
|
add_foreign_key "account_stats", "accounts", on_delete: :cascade
|
||||||
|
@ -1002,8 +1004,6 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do
|
||||||
add_foreign_key "statuses_tags", "tags", name: "fk_3081861e21", on_delete: :cascade
|
add_foreign_key "statuses_tags", "tags", name: "fk_3081861e21", on_delete: :cascade
|
||||||
add_foreign_key "tombstones", "accounts", on_delete: :cascade
|
add_foreign_key "tombstones", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "user_invite_requests", "users", on_delete: :cascade
|
add_foreign_key "user_invite_requests", "users", on_delete: :cascade
|
||||||
add_foreign_key "account_notes", "accounts", column: "target_account_id", on_delete: :cascade
|
|
||||||
add_foreign_key "account_notes", "accounts", on_delete: :cascade
|
|
||||||
add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade
|
add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade
|
||||||
add_foreign_key "users", "invites", on_delete: :nullify
|
add_foreign_key "users", "invites", on_delete: :nullify
|
||||||
add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify
|
add_foreign_key "users", "oauth_applications", column: "created_by_application_id", on_delete: :nullify
|
||||||
|
|
16
package.json
16
package.json
|
@ -63,17 +63,17 @@
|
||||||
"@babel/core": "^7.10.3",
|
"@babel/core": "^7.10.3",
|
||||||
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
"@babel/plugin-proposal-class-properties": "^7.8.3",
|
||||||
"@babel/plugin-proposal-decorators": "^7.10.3",
|
"@babel/plugin-proposal-decorators": "^7.10.3",
|
||||||
"@babel/plugin-transform-react-inline-elements": "^7.10.1",
|
"@babel/plugin-transform-react-inline-elements": "^7.10.4",
|
||||||
"@babel/plugin-transform-runtime": "^7.10.3",
|
"@babel/plugin-transform-runtime": "^7.10.4",
|
||||||
"@babel/preset-env": "^7.10.2",
|
"@babel/preset-env": "^7.10.4",
|
||||||
"@babel/preset-react": "^7.10.1",
|
"@babel/preset-react": "^7.10.4",
|
||||||
"@babel/runtime": "^7.8.4",
|
"@babel/runtime": "^7.8.4",
|
||||||
"@clusterws/cws": "^2.0.0",
|
"@clusterws/cws": "^2.0.0",
|
||||||
"@gamestdio/websocket": "^0.3.2",
|
"@gamestdio/websocket": "^0.3.2",
|
||||||
"@rails/ujs": "^6.0.3",
|
"@rails/ujs": "^6.0.3",
|
||||||
"array-includes": "^3.1.1",
|
"array-includes": "^3.1.1",
|
||||||
"arrow-key-navigation": "^1.1.0",
|
|
||||||
"atrament": "0.2.4",
|
"atrament": "0.2.4",
|
||||||
|
"arrow-key-navigation": "^1.2.0",
|
||||||
"autoprefixer": "^9.8.0",
|
"autoprefixer": "^9.8.0",
|
||||||
"axios": "^0.19.2",
|
"axios": "^0.19.2",
|
||||||
"babel-loader": "^8.1.0",
|
"babel-loader": "^8.1.0",
|
||||||
|
@ -159,7 +159,7 @@
|
||||||
"stacktrace-js": "^2.0.2",
|
"stacktrace-js": "^2.0.2",
|
||||||
"stringz": "^2.1.0",
|
"stringz": "^2.1.0",
|
||||||
"substring-trie": "^1.0.2",
|
"substring-trie": "^1.0.2",
|
||||||
"terser-webpack-plugin": "^3.0.3",
|
"terser-webpack-plugin": "^3.0.6",
|
||||||
"tesseract.js": "^2.1.1",
|
"tesseract.js": "^2.1.1",
|
||||||
"throng": "^4.0.0",
|
"throng": "^4.0.0",
|
||||||
"tiny-queue": "^0.2.1",
|
"tiny-queue": "^0.2.1",
|
||||||
|
@ -175,7 +175,7 @@
|
||||||
"@testing-library/jest-dom": "^5.11.0",
|
"@testing-library/jest-dom": "^5.11.0",
|
||||||
"@testing-library/react": "^10.4.3",
|
"@testing-library/react": "^10.4.3",
|
||||||
"babel-eslint": "^10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"babel-jest": "^25.2.4",
|
"babel-jest": "^26.1.0",
|
||||||
"eslint": "^6.8.0",
|
"eslint": "^6.8.0",
|
||||||
"eslint-plugin-import": "~2.21.2",
|
"eslint-plugin-import": "~2.21.2",
|
||||||
"eslint-plugin-jsx-a11y": "~6.3.1",
|
"eslint-plugin-jsx-a11y": "~6.3.1",
|
||||||
|
@ -187,7 +187,7 @@
|
||||||
"react-test-renderer": "^16.13.1",
|
"react-test-renderer": "^16.13.1",
|
||||||
"sass-lint": "^1.13.1",
|
"sass-lint": "^1.13.1",
|
||||||
"webpack-dev-server": "^3.11.0",
|
"webpack-dev-server": "^3.11.0",
|
||||||
"yargs": "^15.3.1"
|
"yargs": "^15.4.0"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"kind-of": "^6.0.3"
|
"kind-of": "^6.0.3"
|
||||||
|
|
|
@ -28,9 +28,8 @@ describe MediaController do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'raises when not permitted to view' do
|
it 'raises when not permitted to view' do
|
||||||
status = Fabricate(:status)
|
status = Fabricate(:status, visibility: :direct)
|
||||||
media_attachment = Fabricate(:media_attachment, status: status)
|
media_attachment = Fabricate(:media_attachment, status: status)
|
||||||
allow_any_instance_of(MediaController).to receive(:authorize).and_raise(ActiveRecord::RecordNotFound)
|
|
||||||
get :show, params: { id: media_attachment.to_param }
|
get :show, params: { id: media_attachment.to_param }
|
||||||
|
|
||||||
expect(response).to have_http_status(404)
|
expect(response).to have_http_status(404)
|
||||||
|
|
42
spec/controllers/media_proxy_controller_spec.rb
Normal file
42
spec/controllers/media_proxy_controller_spec.rb
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe MediaProxyController do
|
||||||
|
render_views
|
||||||
|
|
||||||
|
before do
|
||||||
|
stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#show' do
|
||||||
|
it 'redirects when attached to a status' do
|
||||||
|
status = Fabricate(:status)
|
||||||
|
media_attachment = Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png')
|
||||||
|
get :show, params: { id: media_attachment.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(302)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'responds with missing when there is not an attached status' do
|
||||||
|
media_attachment = Fabricate(:media_attachment, status: nil, remote_url: 'http://example.com/attachment.png')
|
||||||
|
get :show, params: { id: media_attachment.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises when id cant be found' do
|
||||||
|
get :show, params: { id: 'missing' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises when not permitted to view' do
|
||||||
|
status = Fabricate(:status, visibility: :direct)
|
||||||
|
media_attachment = Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png')
|
||||||
|
get :show, params: { id: media_attachment.id }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(404)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -20,6 +20,7 @@ RSpec.describe SuspendAccountService, type: :service do
|
||||||
let!(:passive_relationship) { Fabricate(:follow, target_account: account) }
|
let!(:passive_relationship) { Fabricate(:follow, target_account: account) }
|
||||||
let!(:remote_alice) { Fabricate(:account, inbox_url: 'https://alice.com/inbox', protocol: :activitypub) }
|
let!(:remote_alice) { Fabricate(:account, inbox_url: 'https://alice.com/inbox', protocol: :activitypub) }
|
||||||
let!(:remote_bob) { Fabricate(:account, inbox_url: 'https://bob.com/inbox', protocol: :activitypub) }
|
let!(:remote_bob) { Fabricate(:account, inbox_url: 'https://bob.com/inbox', protocol: :activitypub) }
|
||||||
|
let!(:endorsment) { Fabricate(:account_pin, account: passive_relationship.account, target_account: account) }
|
||||||
|
|
||||||
it 'deletes associated records' do
|
it 'deletes associated records' do
|
||||||
is_expected.to change {
|
is_expected.to change {
|
||||||
|
@ -30,8 +31,9 @@ RSpec.describe SuspendAccountService, type: :service do
|
||||||
account.favourites,
|
account.favourites,
|
||||||
account.active_relationships,
|
account.active_relationships,
|
||||||
account.passive_relationships,
|
account.passive_relationships,
|
||||||
|
AccountPin.where(target_account: account),
|
||||||
].map(&:count)
|
].map(&:count)
|
||||||
}.from([1, 1, 1, 1, 1, 1]).to([0, 0, 0, 0, 0, 0])
|
}.from([1, 1, 1, 1, 1, 1, 1]).to([0, 0, 0, 0, 0, 0, 0])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sends a delete actor activity to all known inboxes' do
|
it 'sends a delete actor activity to all known inboxes' do
|
||||||
|
|
Loading…
Reference in a new issue