Merge pull request #1715 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes
This commit is contained in:
Claire 2022-03-10 17:29:15 +01:00 committed by GitHub
commit 2c8bb17453
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
179 changed files with 2096 additions and 817 deletions

View File

@ -40,6 +40,7 @@ end
gem 'net-ldap', '~> 0.17'
gem 'omniauth-cas', '~> 2.0'
gem 'omniauth-saml', '~> 1.10'
gem 'gitlab-omniauth-openid-connect', '~>0.5.0', require: 'omniauth_openid_connect'
gem 'omniauth', '~> 1.9'
gem 'omniauth-rails_csrf_protection', '~> 0.1'
@ -115,7 +116,7 @@ end
group :test do
gem 'capybara', '~> 3.36'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.19'
gem 'faker', '~> 2.20'
gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.1'

View File

@ -68,6 +68,7 @@ GEM
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
aes_key_wrap (1.1.0)
airbrussh (1.4.0)
sshkit (>= 1.6.1, != 1.7.0)
android_key_attestation (0.3.0)
@ -77,6 +78,7 @@ GEM
ast (2.4.2)
attr_encrypted (3.1.0)
encryptor (~> 3.0.0)
attr_required (1.0.1)
awrence (1.1.1)
aws-eventstream (1.2.0)
aws-partitions (1.558.0)
@ -126,7 +128,7 @@ GEM
sshkit (>= 1.9.0)
capistrano-bundler (2.0.1)
capistrano (~> 3.1)
capistrano-rails (1.6.1)
capistrano-rails (1.6.2)
capistrano (~> 3.1)
capistrano-bundler (>= 1.1, < 3)
capistrano-rbenv (2.2.0)
@ -210,8 +212,8 @@ GEM
tzinfo
excon (0.76.0)
fabrication (2.27.0)
faker (2.19.0)
i18n (>= 1.6, < 2)
faker (2.20.0)
i18n (>= 1.8.11, < 2)
faraday (1.9.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
@ -260,6 +262,10 @@ GEM
fuubar (2.5.1)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
gitlab-omniauth-openid-connect (0.5.0)
addressable (~> 2.7)
omniauth (~> 1.9)
openid_connect (~> 1.2)
globalid (1.0.0)
activesupport (>= 5.0)
hamlit (2.13.0)
@ -288,10 +294,11 @@ GEM
domain_name (~> 0.5)
http-form_data (2.3.0)
http_accept_language (2.1.1)
httpclient (2.8.3)
httplog (1.5.0)
rack (>= 1.0)
rainbow (>= 2.0.0)
i18n (1.9.1)
i18n (1.10.0)
concurrent-ruby (~> 1.0)
i18n-tasks (0.9.37)
activesupport (>= 4.0.2)
@ -308,6 +315,10 @@ GEM
jmespath (1.6.0)
json (2.5.1)
json-canonicalization (0.3.0)
json-jwt (1.13.0)
activesupport (>= 4.2)
aes_key_wrap
bindata
json-ld (3.2.0)
htmlentities (~> 4.3)
json-canonicalization (~> 0.3)
@ -408,6 +419,16 @@ GEM
omniauth-saml (1.10.3)
omniauth (~> 1.3, >= 1.3.2)
ruby-saml (~> 1.9)
openid_connect (1.2.0)
activemodel
attr_required (>= 1.0.0)
json-jwt (>= 1.5.0)
rack-oauth2 (>= 1.6.1)
swd (>= 1.0.0)
tzinfo
validate_email
validate_url
webfinger (>= 1.0.1)
openssl (2.2.0)
openssl-signature_algorithm (0.4.0)
orm_adapter (0.5.0)
@ -451,6 +472,12 @@ GEM
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-oauth2 (1.16.0)
activesupport
attr_required
httpclient
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-proxy (0.7.0)
rack
rack-test (1.1.0)
@ -498,7 +525,7 @@ GEM
rdf (~> 3.2)
redcarpet (3.5.1)
redis (4.5.1)
redis-namespace (1.8.1)
redis-namespace (1.8.2)
redis (>= 3.0.4)
regexp_parser (2.2.0)
request_store (1.5.0)
@ -606,11 +633,15 @@ GEM
sshkit (1.21.2)
net-scp (>= 1.1.2)
net-ssh (>= 2.8.0)
stackprof (0.2.17)
stackprof (0.2.19)
statsd-ruby (1.5.0)
stoplight (2.2.1)
strong_migrations (0.7.9)
activerecord (>= 5)
swd (1.2.0)
activesupport (>= 3)
attr_required (>= 0.0.5)
httpclient (>= 2.4)
temple (0.8.2)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@ -645,6 +676,12 @@ GEM
unf_ext (0.0.8)
unicode-display_width (2.1.0)
uniform_notifier (1.14.2)
validate_email (0.1.6)
activemodel (>= 3.0)
mail (>= 2.2.5)
validate_url (1.0.13)
activemodel (>= 3.0.0)
public_suffix
warden (1.2.9)
rack (>= 2.0.9)
webauthn (3.0.0.alpha1)
@ -657,6 +694,9 @@ GEM
safety_net_attestation (~> 0.4.0)
securecompare (~> 1.0)
tpm-key_attestation (~> 0.9.0)
webfinger (1.1.0)
activesupport
httpclient (>= 2.4)
webmock (3.14.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
@ -714,12 +754,13 @@ DEPENDENCIES
dotenv-rails (~> 2.7)
ed25519 (~> 1.3)
fabrication (~> 2.27)
faker (~> 2.19)
faker (~> 2.20)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
fog-openstack (~> 0.3)
fuubar (~> 2.5)
gitlab-omniauth-openid-connect (~> 0.5.0)
hamlit-rails (~> 0.2)
hcaptcha (~> 7.1)
hiredis (~> 0.6)

View File

@ -57,7 +57,7 @@ class StatusesIndex < Chewy::Index
field :id, type: 'long'
field :account_id, type: 'long'
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
field :text, type: 'text', value: ->(status) { [status.spoiler_text, Formatter.instance.plaintext(status)].concat(status.ordered_media_attachments.map(&:description)).concat(status.preloadable_poll ? status.preloadable_poll.options : []).join("\n\n") } do
field :stemmed, type: 'text', analyzer: 'content'
end

View File

@ -56,10 +56,6 @@ module Admin
end
end
def show
authorize @domain_block, :show?
end
def destroy
authorize @domain_block, :destroy?
UnblockDomainService.new.call(@domain_block)

View File

@ -4,28 +4,26 @@ module Admin
class InstancesController < BaseController
before_action :set_instances, only: :index
before_action :set_instance, except: :index
before_action :set_exhausted_deliveries_days, only: :show
def index
authorize :instance, :index?
preload_delivery_failures!
end
def show
authorize :instance, :show?
@time_period = (6.days.ago.to_date...Time.now.utc.to_date)
end
def destroy
authorize :instance, :destroy?
Admin::DomainPurgeWorker.perform_async(@instance.domain)
log_action :destroy, @instance
redirect_to admin_instances_path, notice: I18n.t('admin.instances.destroyed_msg', domain: @instance.domain)
end
def clear_delivery_errors
authorize :delivery, :clear_delivery_errors?
@instance.delivery_failure_tracker.clear_failures!
redirect_to admin_instance_path(@instance.domain)
end
@ -33,11 +31,9 @@ module Admin
def restart_delivery
authorize :delivery, :restart_delivery?
last_unavailable_domain = unavailable_domain
if last_unavailable_domain.present?
if @instance.unavailable?
@instance.delivery_failure_tracker.track_success!
log_action :destroy, last_unavailable_domain
log_action :destroy, @instance.unavailable_domain
end
redirect_to admin_instance_path(@instance.domain)
@ -45,8 +41,7 @@ module Admin
def stop_delivery
authorize :delivery, :stop_delivery?
UnavailableDomain.create(domain: @instance.domain)
unavailable_domain = UnavailableDomain.create!(domain: @instance.domain)
log_action :create, unavailable_domain
redirect_to admin_instance_path(@instance.domain)
end
@ -57,12 +52,11 @@ module Admin
@instance = Instance.find(params[:id])
end
def set_exhausted_deliveries_days
@exhausted_deliveries_days = @instance.delivery_failure_tracker.exhausted_deliveries_days
end
def set_instances
@instances = filtered_instances.page(params[:page])
end
def preload_delivery_failures!
warning_domains_map = DeliveryFailureTracker.warning_domains_map
@instances.each do |instance|
@ -70,10 +64,6 @@ module Admin
end
end
def unavailable_domain
UnavailableDomain.find_by(domain: @instance.domain)
end
def filtered_instances
InstanceFilter.new(whitelist_mode? ? { allowed: true } : filter_params).results
end

View File

@ -10,6 +10,7 @@ class Api::V1::StatusesController < Api::BaseController
before_action :set_thread, only: [:create]
override_rate_limit_headers :create, family: :statuses
override_rate_limit_headers :update, family: :statuses
# This API was originally unlimited, pagination cannot be introduced without
# breaking backwards-compatibility. Arbitrarily high number to cover most

View File

@ -4,8 +4,6 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
skip_before_action :verify_authenticity_token
def self.provides_callback_for(provider)
provider_id = provider.to_s.chomp '_oauth2'
define_method provider do
@user = User.find_for_oauth(request.env['omniauth.auth'], current_user)
@ -20,7 +18,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
)
sign_in_and_redirect @user, event: :authentication
set_flash_message(:notice, :success, kind: provider_id.capitalize) if is_navigational_format?
set_flash_message(:notice, :success, kind: Devise.omniauth_configs[provider].strategy.display_name.capitalize) if is_navigational_format?
else
session["devise.#{provider}_data"] = request.env['omniauth.auth']
redirect_to new_user_registration_url
@ -33,7 +31,7 @@ class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
def after_sign_in_path_for(resource)
if resource.email_verified?
if resource.email_present?
root_path
else
auth_setup_path(missing_email: '1')

View File

@ -241,6 +241,15 @@ module LanguagesHelper
code
end
def valid_locale_cascade(*arr)
arr.each do |str|
locale = valid_locale_or_nil(str)
return locale if locale.present?
end
nil
end
def valid_locale?(locale)
locale.present? && SUPPORTED_LOCALES.key?(locale.to_sym)
end

View File

@ -132,7 +132,7 @@ module StatusesHelper
end
def render_video_component(status, **options)
video = status.media_attachments.first
video = status.ordered_media_attachments.first
meta = video.file.meta || {}
@ -150,12 +150,12 @@ module StatusesHelper
}.merge(**options)
react_component :video, component_params do
render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments }
end
end
def render_audio_component(status, **options)
audio = status.media_attachments.first
audio = status.ordered_media_attachments.first
meta = audio.file.meta || {}
@ -170,7 +170,7 @@ module StatusesHelper
}.merge(**options)
react_component :audio, component_params do
render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments }
end
end
@ -178,11 +178,11 @@ module StatusesHelper
component_params = {
sensitive: sensitized?(status, current_account),
autoplay: prefers_autoplay?,
media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json },
media: status.ordered_media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json },
}.merge(**options)
react_component :media_gallery, component_params do
render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
render partial: 'statuses/attachment_list', locals: { attachments: status.ordered_media_attachments }
end
end

View File

@ -68,12 +68,12 @@ export default class Counter extends React.PureComponent {
);
} else {
const measure = data[0];
const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1);
const percentChange = measure.previous_total && percIncrease(measure.previous_total * 1, measure.total * 1);
content = (
<React.Fragment>
<span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
<span className='sparkline__value__total'>{measure.human_value || <FormattedNumber value={measure.total} />}</span>
{measure.previous_total && (<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>)}
</React.Fragment>
);
}

View File

@ -0,0 +1,119 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video, Audio } from 'flavours/glitch/util/async-components';
import Bundle from 'flavours/glitch/features/ui/components/bundle';
import noop from 'lodash/noop';
export default class MediaAttachments extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
height: PropTypes.number,
width: PropTypes.number,
revealed: PropTypes.bool,
};
static defaultProps = {
height: 110,
width: 239,
};
updateOnProps = [
'status',
];
renderLoadingMediaGallery = () => {
const { height, width } = this.props;
return (
<div className='media-gallery' style={{ height, width }} />
);
}
renderLoadingVideoPlayer = () => {
const { height, width } = this.props;
return (
<div className='video-player' style={{ height, width }} />
);
}
renderLoadingAudioPlayer = () => {
const { height, width } = this.props;
return (
<div className='audio-player' style={{ height, width }} />
);
}
render () {
const { status, width, height, revealed } = this.props;
const mediaAttachments = status.get('media_attachments');
if (mediaAttachments.size === 0) {
return null;
}
if (mediaAttachments.getIn([0, 'type']) === 'audio') {
const audio = mediaAttachments.get(0);
return (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => (
<Component
src={audio.get('url')}
alt={audio.get('description')}
width={width}
height={height}
poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={audio.getIn(['meta', 'colors', 'background'])}
foregroundColor={audio.getIn(['meta', 'colors', 'foreground'])}
accentColor={audio.getIn(['meta', 'colors', 'accent'])}
duration={audio.getIn(['meta', 'original', 'duration'], 0)}
/>
)}
</Bundle>
);
} else if (mediaAttachments.getIn([0, 'type']) === 'video') {
const video = mediaAttachments.get(0);
return (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (
<Component
preview={video.get('preview_url')}
frameRate={video.getIn(['meta', 'original', 'frame_rate'])}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={width}
height={height}
inline
sensitive={status.get('sensitive')}
revealed={revealed}
onOpenVideo={noop}
/>
)}
</Bundle>
);
} else {
return (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
{Component => (
<Component
media={mediaAttachments}
sensitive={status.get('sensitive')}
defaultWidth={width}
revealed={revealed}
height={height}
onOpenMedia={noop}
/>
)}
</Bundle>
);
}
}
}

View File

@ -40,7 +40,17 @@ class ColumnSettings extends React.PureComponent {
}
};
onSelect = mode => value => this.props.onChange(['tags', mode], value);
onSelect = mode => value => {
const oldValue = this.tags(mode);
// Prevent changes that add more than 4 tags, but allow removing
// tags that were already added before
if ((value.length > 4) && !(value < oldValue)) {
return;
}
this.props.onChange(['tags', mode], value);
};
onToggle = () => {
if (this.state.open && this.hasTags()) {

View File

@ -1,14 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import noop from 'lodash/noop';
import StatusContent from 'flavours/glitch/components/status_content';
import { MediaGallery, Video } from 'flavours/glitch/util/async-components';
import Bundle from 'flavours/glitch/features/ui/components/bundle';
import Avatar from 'flavours/glitch/components/avatar';
import DisplayName from 'flavours/glitch/components/display_name';
import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
import Option from './option';
import MediaAttachments from 'flavours/glitch/components/media_attachments';
export default class StatusCheckBox extends React.PureComponent {
@ -27,53 +25,10 @@ export default class StatusCheckBox extends React.PureComponent {
render () {
const { status, checked } = this.props;
let media = null;
if (status.get('reblog')) {
return null;
}
if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (
<Component
preview={video.get('preview_url')}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={239}
height={110}
inline
sensitive={status.get('sensitive')}
revealed={false}
onOpenVideo={noop}
/>
)}
</Bundle>
);
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
{Component => (
<Component
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
revealed={false}
height={110}
onOpenMedia={noop}
/>
)}
</Bundle>
);
}
}
const labelComponent = (
<div className='status-check-box__status poll__option__text'>
<div className='detailed-status__display-name'>
@ -84,7 +39,7 @@ export default class StatusCheckBox extends React.PureComponent {
<div><DisplayName account={status.get('account')} /> · <RelativeTimestamp timestamp={status.get('created_at')} /></div>
</div>
<StatusContent status={status} media={media} />
<StatusContent status={status} media={<MediaAttachments status={status} revealed={false} />} />
</div>
);

View File

@ -9,6 +9,7 @@ import escapeTextContentForBrowser from 'escape-html';
import InlineAccount from 'flavours/glitch/components/inline_account';
import IconButton from 'flavours/glitch/components/icon_button';
import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
import MediaAttachments from 'flavours/glitch/components/media_attachments';
const mapStateToProps = (state, { statusId }) => ({
versions: state.getIn(['history', statusId, 'items']),
@ -70,6 +71,25 @@ class CompareHistoryModal extends React.PureComponent {
)}
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} />
{!!currentVersion.get('poll') && (
<div className='poll'>
<ul>
{currentVersion.getIn(['poll', 'options']).map(option => (
<li key={option.get('title')}>
<span className='poll__input disabled' />
<span
className='poll__option__text translate'
dangerouslySetInnerHTML={{ __html: emojify(escapeTextContentForBrowser(option.get('title')), emojiMap) }}
/>
</li>
))}
</ul>
</div>
)}
<MediaAttachments status={currentVersion} />
</div>
</div>
</div>

View File

@ -367,6 +367,21 @@ body,
}
}
.positive-hint,
.negative-hint,
.neutral-hint {
a {
color: inherit;
text-decoration: underline;
&:focus,
&:hover,
&:active {
text-decoration: none;
}
}
}
.positive-hint {
color: $valid-value-color;
font-weight: 500;
@ -1612,3 +1627,38 @@ a.sparkline {
}
}
}
.availability-indicator {
display: flex;
align-items: center;
margin-bottom: 30px;
font-size: 14px;
line-height: 21px;
&__hint {
padding: 0 15px;
}
&__graphic {
display: flex;
margin: 0 -2px;
&__item {
display: block;
flex: 0 0 auto;
width: 4px;
height: 21px;
background: lighten($ui-base-color, 8%);
margin: 0 2px;
border-radius: 2px;
&.positive {
background: $valid-value-color;
}
&.negative {
background: $error-value-color;
}
}
}
}

View File

@ -1563,41 +1563,6 @@ button.icon-button.active i.fa-retweet {
filter: none;
}
.compare-history-modal {
.report-modal__target {
border-bottom: 1px solid $ui-secondary-color;
}
&__container {
padding: 30px;
pointer-events: all;
}
.status__content {
color: $inverted-text-color;
font-size: 19px;
line-height: 24px;
.emojione {
width: 24px;
height: 24px;
margin: -1px 0 0;
}
a {
color: $highlight-text-color;
}
hr {
height: 0.25rem;
padding: 0;
background-color: $ui-secondary-color;
border: 0;
margin: 20px 0;
}
}
}
.loading-bar {
background-color: $ui-highlight-color;
height: 3px;

View File

@ -1041,6 +1041,47 @@
}
}
.compare-history-modal {
.report-modal__target {
border-bottom: 1px solid $ui-secondary-color;
}
&__container {
padding: 30px;
pointer-events: all;
}
.status__content {
color: $inverted-text-color;
font-size: 19px;
line-height: 24px;
.emojione {
width: 24px;
height: 24px;
margin: -1px 0 0;
}
a {
color: $highlight-text-color;
}
hr {
height: 0.25rem;
padding: 0;
background-color: $ui-secondary-color;
border: 0;
margin: 20px 0;
}
}
.media-gallery,
.audio-player,
.video-player {
margin-top: 15px;
}
}
.embed-modal {
width: auto;
max-width: 80vw;

View File

@ -538,7 +538,7 @@
.media-gallery,
.audio-player,
.video-player {
margin-top: 8px;
margin-top: 15px;
max-width: 250px;
}

View File

@ -229,15 +229,21 @@
.mute-modal,
.block-modal,
.report-modal,
.report-dialog-modal,
.embed-modal,
.error-modal,
.onboarding-modal,
.report-modal__comment .setting-text__wrapper,
.report-modal__comment .setting-text {
.report-modal__comment .setting-text,
.report-dialog-modal__textarea {
background: $white;
border: 1px solid lighten($ui-base-color, 8%);
}
.report-dialog-modal .dialog-option .poll__input {
color: $white;
}
.report-modal__comment {
border-right-color: lighten($ui-base-color, 8%);
}

View File

@ -65,6 +65,24 @@
}
}
&.horizontal-table {
border-collapse: collapse;
border-style: hidden;
& > tbody > tr > th,
& > tbody > tr > td {
padding: 11px 10px;
background: transparent;
border: 1px solid lighten($ui-base-color, 8%);
color: $secondary-text-color;
}
& > tbody > tr > th {
color: $darker-text-color;
font-weight: 600;
}
}
&.batch-table {
& > thead > tr > th {
background: $ui-base-color;

View File

@ -68,12 +68,12 @@ export default class Counter extends React.PureComponent {
);
} else {
const measure = data[0];
const percentChange = percIncrease(measure.previous_total * 1, measure.total * 1);
const percentChange = measure.previous_total && percIncrease(measure.previous_total * 1, measure.total * 1);
content = (
<React.Fragment>
<span className='sparkline__value__total'><FormattedNumber value={measure.total} /></span>
<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>
<span className='sparkline__value__total'>{measure.human_value || <FormattedNumber value={measure.total} />}</span>
{measure.previous_total && (<span className={classNames('sparkline__value__change', { positive: percentChange > 0, negative: percentChange < 0 })}>{percentChange > 0 && '+'}<FormattedNumber value={percentChange} style='percent' /></span>)}
</React.Fragment>
);
}

View File

@ -0,0 +1,116 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video, Audio } from 'mastodon/features/ui/util/async-components';
import Bundle from 'mastodon/features/ui/components/bundle';
import noop from 'lodash/noop';
export default class MediaAttachments extends ImmutablePureComponent {
static propTypes = {
status: ImmutablePropTypes.map.isRequired,
height: PropTypes.number,
width: PropTypes.number,
};
static defaultProps = {
height: 110,
width: 239,
};
updateOnProps = [
'status',
];
renderLoadingMediaGallery = () => {
const { height, width } = this.props;
return (
<div className='media-gallery' style={{ height, width }} />
);
}
renderLoadingVideoPlayer = () => {
const { height, width } = this.props;
return (
<div className='video-player' style={{ height, width }} />
);
}
renderLoadingAudioPlayer = () => {
const { height, width } = this.props;
return (
<div className='audio-player' style={{ height, width }} />
);
}
render () {
const { status, width, height } = this.props;
const mediaAttachments = status.get('media_attachments');
if (mediaAttachments.size === 0) {
return null;
}
if (mediaAttachments.getIn([0, 'type']) === 'audio') {
const audio = mediaAttachments.get(0);
return (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => (
<Component
src={audio.get('url')}
alt={audio.get('description')}
width={width}
height={height}
poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={audio.getIn(['meta', 'colors', 'background'])}
foregroundColor={audio.getIn(['meta', 'colors', 'foreground'])}
accentColor={audio.getIn(['meta', 'colors', 'accent'])}
duration={audio.getIn(['meta', 'original', 'duration'], 0)}
/>
)}
</Bundle>
);
} else if (mediaAttachments.getIn([0, 'type']) === 'video') {
const video = mediaAttachments.get(0);
return (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (
<Component
preview={video.get('preview_url')}
frameRate={video.getIn(['meta', 'original', 'frame_rate'])}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={width}
height={height}
inline
sensitive={status.get('sensitive')}
onOpenVideo={noop}
/>
)}
</Bundle>
);
} else {
return (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
{Component => (
<Component
media={mediaAttachments}
sensitive={status.get('sensitive')}
defaultWidth={width}
height={height}
onOpenMedia={noop}
/>
)}
</Bundle>
);
}
}
}

View File

@ -40,7 +40,17 @@ class ColumnSettings extends React.PureComponent {
}
};
onSelect = mode => value => this.props.onChange(['tags', mode], value);
onSelect = mode => value => {
const oldValue = this.tags(mode);
// Prevent changes that add more than 4 tags, but allow removing
// tags that were already added before
if ((value.length > 4) && !(value < oldValue)) {
return;
}
this.props.onChange(['tags', mode], value);
};
onToggle = () => {
if (this.state.open && this.hasTags()) {

View File

@ -1,14 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import noop from 'lodash/noop';
import StatusContent from 'mastodon/components/status_content';
import { MediaGallery, Video } from 'mastodon/features/ui/util/async-components';
import Bundle from 'mastodon/features/ui/components/bundle';
import Avatar from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
import Option from './option';
import MediaAttachments from 'mastodon/components/media_attachments';
export default class StatusCheckBox extends React.PureComponent {
@ -27,51 +25,10 @@ export default class StatusCheckBox extends React.PureComponent {
render () {
const { status, checked } = this.props;
let media = null;
if (status.get('reblog')) {
return null;
}
if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const video = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
{Component => (
<Component
preview={video.get('preview_url')}
blurhash={video.get('blurhash')}
src={video.get('url')}
alt={video.get('description')}
width={239}
height={110}
inline
sensitive={status.get('sensitive')}
onOpenVideo={noop}
/>
)}
</Bundle>
);
} else {
media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
{Component => (
<Component
media={status.get('media_attachments')}
sensitive={status.get('sensitive')}
height={110}
onOpenMedia={noop}
/>
)}
</Bundle>
);
}
}
const labelComponent = (
<div className='status-check-box__status poll__option__text'>
<div className='detailed-status__display-name'>
@ -79,12 +36,13 @@ export default class StatusCheckBox extends React.PureComponent {
<Avatar account={status.get('account')} size={46} />
</div>
<div><DisplayName account={status.get('account')} /> · <RelativeTimestamp timestamp={status.get('created_at')} /></div>
<div>
<DisplayName account={status.get('account')} /> · <RelativeTimestamp timestamp={status.get('created_at')} />
</div>
</div>
<StatusContent status={status} />
{media}
<MediaAttachments status={status} />
</div>
);

View File

@ -9,6 +9,7 @@ import escapeTextContentForBrowser from 'escape-html';
import InlineAccount from 'mastodon/components/inline_account';
import IconButton from 'mastodon/components/icon_button';
import RelativeTimestamp from 'mastodon/components/relative_timestamp';
import MediaAttachments from 'mastodon/components/media_attachments';
const mapStateToProps = (state, { statusId }) => ({
versions: state.getIn(['history', statusId, 'items']),
@ -70,6 +71,25 @@ class CompareHistoryModal extends React.PureComponent {
)}
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} />
{!!currentVersion.get('poll') && (
<div className='poll'>
<ul>
{currentVersion.getIn(['poll', 'options']).map(option => (
<li key={option.get('title')}>
<span className='poll__input disabled' />
<span
className='poll__option__text translate'
dangerouslySetInnerHTML={{ __html: emojify(escapeTextContentForBrowser(option.get('title')), emojiMap) }}
/>
</li>
))}
</ul>
</div>
)}
<MediaAttachments status={currentVersion} />
</div>
</div>
</div>

View File

@ -18,12 +18,12 @@
"account.followers": "Followers",
"account.followers.empty": "No one follows this user yet.",
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
"account.following": "Following",
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
"account.follows.empty": "This user doesn't follow anyone yet.",
"account.follows_you": "Follows you",
"account.hide_reblogs": "Hide boosts from @{name}",
"account.joined": "Joined {date}",
"account.last_status": "Last active",
"account.link_verified_on": "Ownership of this link was checked on {date}",
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
"account.media": "Media",
@ -32,7 +32,6 @@
"account.mute": "Mute @{name}",
"account.mute_notifications": "Mute notifications from @{name}",
"account.muted": "Muted",
"account.never_active": "Never",
"account.posts": "Toots",
"account.posts_with_replies": "Toots and replies",
"account.report": "Report @{name}",
@ -42,10 +41,12 @@
"account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
"account.unblock": "Unblock @{name}",
"account.unblock_domain": "Unblock domain {domain}",
"account.unblock_short": "Unblock",
"account.unendorse": "Don't feature on profile",
"account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}",
"account.unmute_short": "Unmute",
"account_note.placeholder": "Click to add a note",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",

View File

@ -18,12 +18,12 @@
"account.followers": "المُتابِعون",
"account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم حتى الآن.",
"account.followers_counter": "{count, plural, zero{لا مُتابع} one {مُتابعٌ واحِد} two{مُتابعانِ اِثنان} few{{counter} مُتابِعين} many{{counter} مُتابِعًا} other {{counter} مُتابع}}",
"account.following": "Following",
"account.following_counter": "{count, plural, zero{لا يُتابِع} one {يُتابِعُ واحد} two{يُتابِعُ اِثنان} few{يُتابِعُ {counter}} many{يُتابِعُ {counter}} other {يُتابِعُ {counter}}}",
"account.follows.empty": "لا يُتابع هذا المُستخدمُ أيَّ أحدٍ حتى الآن.",
"account.follows_you": "يُتابِعُك",
"account.hide_reblogs": "إخفاء مشاركات @{name}",
"account.joined": "انضم في {date}",
"account.last_status": "آخر نشاط",
"account.link_verified_on": "تمَّ التَّحقق مِن مِلْكيّة هذا الرابط بتاريخ {date}",
"account.locked_info": "تمَّ تعيين حالة خصوصية هذا الحساب إلى مُقفَل. يُراجع المالك يدويًا من يمكنه متابعته.",
"account.media": "وسائط",
@ -32,7 +32,6 @@
"account.mute": "كَتم @{name}",
"account.mute_notifications": "كَتم الإشعارات من @{name}",
"account.muted": "مَكتوم",
"account.never_active": "أبدًا",
"account.posts": "منشورات",
"account.posts_with_replies": "المنشورات والرُدود",
"account.report": "الإبلاغ عن @{name}",
@ -42,10 +41,12 @@
"account.statuses_counter": "{count, plural, zero {لَا تَبويقات} one {تَبويقةٌ واحدة} two {تَبويقَتانِ اِثنتان} few {{counter} تَبويقات} many {{counter} تَبويقتًا} other {{counter} تَبويقة}}",
"account.unblock": "إلغاء الحَظر عن @{name}",
"account.unblock_domain": "إلغاء الحَظر عن النِّطاق {domain}",
"account.unblock_short": "Unblock",
"account.unendorse": "لا تُرَوِّج لهُ في الملف الشخصي",
"account.unfollow": "إلغاء المُتابعة",
"account.unmute": "إلغاء الكَتم عن @{name}",
"account.unmute_notifications": "إلغاء كَتم الإشعارات عن @{name}",
"account.unmute_short": "Unmute",
"account_note.placeholder": "اضغط لإضافة مُلاحظة",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",

View File

@ -18,12 +18,12 @@
"account.followers": "Siguidores",
"account.followers.empty": "Naide sigue a esti usuariu entá.",
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
"account.following": "Following",
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
"account.follows.empty": "Esti usuariu entá nun sigue a naide.",
"account.follows_you": "Síguete",
"account.hide_reblogs": "Anubrir les comparticiones de @{name}",
"account.joined": "Joined {date}",
"account.last_status": "Cabera actividá",
"account.link_verified_on": "La propiedá d'esti enllaz foi comprobada'l {date}",
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
"account.media": "Media",
@ -32,7 +32,6 @@
"account.mute": "Silenciar a @{name}",
"account.mute_notifications": "Mute notifications from @{name}",