diff --git a/.gitignore b/.gitignore
index 4545270b30b38..c49d4ca75dcf9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,3 +67,7 @@ yarn-debug.log
# Ignore Docker option files
docker-compose.override.yml
+
+# Add by highemerly
+public/announcements.json
+public/server-info.html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 18790e860b67d..6f94ebea24fda 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,35 @@ Changelog
All notable changes to this project will be documented in this file.
+## [3.2.1] - 2020-10-19
+### Added
+
+- Add support for latest HTTP Signatures spec draft ([ThibG](https://github.com/tootsuite/mastodon/pull/14556))
+- Add support for inlined objects in ActivityPub `to`/`cc` ([ThibG](https://github.com/tootsuite/mastodon/pull/14514))
+
+### Changed
+
+- Change actors to not be served at all without authentication in limited federation mode ([ThibG](https://github.com/tootsuite/mastodon/pull/14800))
+ - Previously, a bare version of an actor was served when not authenticated, i.e. username and public key
+ - Because all actor fetch requests are signed using a separate system actor, that is no longer required
+
+### Fixed
+
+- Fix `tootctl media` commands not recognizing very large IDs ([ThibG](https://github.com/tootsuite/mastodon/pull/14536))
+- Fix crash when failing to load emoji picker in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14525))
+- Fix contrast requirements in thumbnail color extraction ([ThibG](https://github.com/tootsuite/mastodon/pull/14464))
+- Fix audio/video player not using `CDN_HOST` on public pages ([ThibG](https://github.com/tootsuite/mastodon/pull/14486))
+- Fix private boost icon not being used on public pages ([OmmyZhang](https://github.com/tootsuite/mastodon/pull/14471))
+- Fix audio player on Safari in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14485), [ThibG](https://github.com/tootsuite/mastodon/pull/14465))
+- Fix dereferencing remote statuses not using the correct account for signature when receiving a targeted inbox delivery ([ThibG](https://github.com/tootsuite/mastodon/pull/14656))
+- Fix nil error in `tootctl media remove` ([noellabo](https://github.com/tootsuite/mastodon/pull/14657))
+- Fix videos with near-60 fps being rejected ([Gargron](https://github.com/tootsuite/mastodon/pull/14684))
+- Fix reported statuses not being included in warning e-mail ([Gargron](https://github.com/tootsuite/mastodon/pull/14778))
+- Fix `Reject` activities of `Follow` objects not correctly destroying a follow relationship ([ThibG](https://github.com/tootsuite/mastodon/pull/14479))
+- Fix inefficiencies in fan-out-on-write service ([Gargron](https://github.com/tootsuite/mastodon/pull/14682), [noellabo](https://github.com/tootsuite/mastodon/pull/14709))
+- Fix timeout errors when trying to webfinger some IPv6 configurations ([Gargron](https://github.com/tootsuite/mastodon/pull/14919))
+- Fix files served as `application/octet-stream` being rejected without attempting mime type detection ([ThibG](https://github.com/tootsuite/mastodon/pull/14452))
+
## [3.2.0] - 2020-07-27
### Added
diff --git a/Gemfile b/Gemfile
index 414bd2c305674..22da1e6127485 100644
--- a/Gemfile
+++ b/Gemfile
@@ -54,7 +54,6 @@ gem 'doorkeeper', '~> 5.4'
gem 'ed25519', '~> 1.2'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
-gem 'goldfinger', '~> 2.1'
gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.7'
gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b'
@@ -121,7 +120,7 @@ end
group :test do
gem 'capybara', '~> 3.33'
gem 'climate_control', '~> 0.2'
- gem 'faker', '~> 2.13'
+ gem 'faker', '~> 2.17'
gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.1'
diff --git a/Gemfile.lock b/Gemfile.lock
index 3d4fce643343e..e221a86ddfa57 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -219,7 +219,7 @@ GEM
tzinfo
excon (0.75.0)
fabrication (2.21.1)
- faker (2.13.0)
+ faker (2.17.0)
i18n (>= 1.6, < 2)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
@@ -250,11 +250,6 @@ GEM
ruby-progressbar (~> 1.4)
globalid (0.4.2)
activesupport (>= 4.2.0)
- goldfinger (2.1.1)
- addressable (~> 2.5)
- http (~> 4.0)
- nokogiri (~> 1.8)
- oj (~> 3.0)
hamlit (2.11.0)
temple (>= 0.8.2)
thor
@@ -286,7 +281,7 @@ GEM
httplog (1.4.3)
rack (>= 1.0)
rainbow (>= 2.0.0)
- i18n (1.8.3)
+ i18n (1.8.9)
concurrent-ruby (~> 1.0)
i18n-tasks (0.9.31)
activesupport (>= 4.0.2)
@@ -702,13 +697,12 @@ DEPENDENCIES
e2mmap (~> 0.1.0)
ed25519 (~> 1.2)
fabrication (~> 2.21)
- faker (~> 2.13)
+ faker (~> 2.17)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
fog-openstack (~> 0.3)
fuubar (~> 2.5)
- goldfinger (~> 2.1)
hamlit-rails (~> 0.2)
health_check!
hiredis (~> 0.6)
diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb
index 47cb856ea944a..89d6607c62c3f 100644
--- a/app/chewy/statuses_index.rb
+++ b/app/chewy/statuses_index.rb
@@ -2,30 +2,21 @@
class StatusesIndex < Chewy::Index
settings index: { refresh_interval: '15m' }, analysis: {
- filter: {
- english_stop: {
- type: 'stop',
- stopwords: '_english_',
- },
- english_stemmer: {
- type: 'stemmer',
- language: 'english',
- },
- english_possessive_stemmer: {
- type: 'stemmer',
- language: 'possessive_english',
+ tokenizer: {
+ kuromoji_user_dict: {
+ type: 'kuromoji_tokenizer',
+ user_dictionary: 'userdic.txt',
},
},
analyzer: {
content: {
- tokenizer: 'uax_url_email',
+ type: 'custom',
+ tokenizer: 'kuromoji_user_dict',
filter: %w(
- english_possessive_stemmer
- lowercase
- asciifolding
+ kuromoji_baseform
+ kuromoji_stemmer
cjk_width
- english_stop
- english_stemmer
+ lowercase
),
},
},
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index db77b628c9f5b..f5e82692ebb70 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -7,6 +7,7 @@ class AccountsController < ApplicationController
include AccountControllerConcern
include SignatureAuthentication
+ before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers
before_action :set_body_classes
@@ -49,7 +50,7 @@ def show
format.json do
expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?)
- render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to
+ render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
end
end
end
@@ -149,12 +150,4 @@ def filtered_status_page
def params_slice(*keys)
params.slice(*keys).permit(*keys)
end
-
- def restrict_fields_to
- if signed_request_account.present? || public_fetch_mode?
- # Return all fields
- else
- %i(id type preferred_username inbox public_key endpoints)
- end
- end
end
diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb
index 10efbf2e0b8be..18f549de9439b 100644
--- a/app/controllers/concerns/signature_verification.rb
+++ b/app/controllers/concerns/signature_verification.rb
@@ -7,6 +7,44 @@ module SignatureVerification
include DomainControlHelper
+ EXPIRATION_WINDOW_LIMIT = 12.hours
+ CLOCK_SKEW_MARGIN = 1.hour
+
+ class SignatureVerificationError < StandardError; end
+
+ class SignatureParamsParser < Parslet::Parser
+ rule(:token) { match("[0-9a-zA-Z!#$%&'*+.^_`|~-]").repeat(1).as(:token) }
+ rule(:quoted_string) { str('"') >> (qdtext | quoted_pair).repeat.as(:quoted_string) >> str('"') }
+ # qdtext and quoted_pair are not exactly according to spec but meh
+ rule(:qdtext) { match('[^\\\\"]') }
+ rule(:quoted_pair) { str('\\') >> any }
+ rule(:bws) { match('\s').repeat }
+ rule(:param) { (token.as(:key) >> bws >> str('=') >> bws >> (token | quoted_string).as(:value)).as(:param) }
+ rule(:comma) { bws >> str(',') >> bws }
+ # Old versions of node-http-signature add an incorrect "Signature " prefix to the header
+ rule(:buggy_prefix) { str('Signature ') }
+ rule(:params) { buggy_prefix.maybe >> (param >> (comma >> param).repeat).as(:params) }
+ root(:params)
+ end
+
+ class SignatureParamsTransformer < Parslet::Transform
+ rule(params: subtree(:p)) do
+ (p.is_a?(Array) ? p : [p]).each_with_object({}) { |(key, val), h| h[key] = val }
+ end
+
+ rule(param: { key: simple(:key), value: simple(:val) }) do
+ [key, val]
+ end
+
+ rule(quoted_string: simple(:string)) do
+ string.to_s
+ end
+
+ rule(token: simple(:string)) do
+ string.to_s
+ end
+ end
+
def require_signature!
render plain: signature_verification_failure_reason, status: signature_verification_failure_code unless signed_request_account
end
@@ -24,72 +62,40 @@ def signature_verification_failure_code
end
def signature_key_id
- raw_signature = request.headers['Signature']
- signature_params = {}
-
- raw_signature.split(',').each do |part|
- parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
- next if parsed_parts.nil? || parsed_parts.size != 3
- signature_params[parsed_parts[1]] = parsed_parts[2]
- end
-
signature_params['keyId']
+ rescue SignatureVerificationError
+ nil
end
def signed_request_account
return @signed_request_account if defined?(@signed_request_account)
- unless signed_request?
- @signature_verification_failure_reason = 'Request not signed'
- @signed_request_account = nil
- return
- end
-
- if request.headers['Date'].present? && !matches_time_window?
- @signature_verification_failure_reason = 'Signed request date outside acceptable time window'
- @signed_request_account = nil
- return
- end
+ raise SignatureVerificationError, 'Request not signed' unless signed_request?
+ raise SignatureVerificationError, 'Incompatible request signature. keyId and signature are required' if missing_required_signature_parameters?
+ raise SignatureVerificationError, 'Unsupported signature algorithm (only rsa-sha256 and hs2019 are supported)' unless %w(rsa-sha256 hs2019).include?(signature_algorithm)
+ raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
- raw_signature = request.headers['Signature']
- signature_params = {}
-
- raw_signature.split(',').each do |part|
- parsed_parts = part.match(/([a-z]+)="([^"]+)"/i)
- next if parsed_parts.nil? || parsed_parts.size != 3
- signature_params[parsed_parts[1]] = parsed_parts[2]
- end
-
- if incompatible_signature?(signature_params)
- @signature_verification_failure_reason = 'Incompatible request signature'
- @signed_request_account = nil
- return
- end
+ verify_signature_strength!
account = account_from_key_id(signature_params['keyId'])
- if account.nil?
- @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
- @signed_request_account = nil
- return
- end
+ raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
signature = Base64.decode64(signature_params['signature'])
- compare_signed_string = build_signed_string(signature_params['headers'])
+ compare_signed_string = build_signed_string
return account unless verify_signature(account, signature, compare_signed_string).nil?
account = stoplight_wrap_request { account.possibly_stale? ? account.refresh! : account_refresh_key(account) }
- if account.nil?
- @signature_verification_failure_reason = "Public key not found for key #{signature_params['keyId']}"
- @signed_request_account = nil
- return
- end
+ raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if account.nil?
return account unless verify_signature(account, signature, compare_signed_string).nil?
- @signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri}"
+ @signature_verification_failure_reason = "Verification failed for #{account.username}@#{account.domain} #{account.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)"
+ @signed_request_account = nil
+ rescue SignatureVerificationError => e
+ @signature_verification_failure_reason = e.message
@signed_request_account = nil
end
@@ -99,6 +105,31 @@ def request_body
private
+ def signature_params
+ @signature_params ||= begin
+ raw_signature = request.headers['Signature']
+ tree = SignatureParamsParser.new.parse(raw_signature)
+ SignatureParamsTransformer.new.apply(tree)
+ end
+ rescue Parslet::ParseFailed
+ raise SignatureVerificationError, 'Error parsing signature parameters'
+ end
+
+ def signature_algorithm
+ signature_params.fetch('algorithm', 'hs2019')
+ end
+
+ def signed_headers
+ signature_params.fetch('headers', signature_algorithm == 'hs2019' ? '(created)' : 'date').downcase.split(' ')
+ end
+
+ def verify_signature_strength!
+ raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
+ raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest')
+ raise SignatureVerificationError, 'Mastodon requires the Host header to be signed' unless signed_headers.include?('host')
+ raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
+ end
+
def verify_signature(account, signature, compare_signed_string)
if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string)
@signed_request_account = account
@@ -108,12 +139,20 @@ def verify_signature(account, signature, compare_signed_string)
nil
end
- def build_signed_string(signed_headers)
- signed_headers = 'date' if signed_headers.blank?
-
- signed_headers.downcase.split(' ').map do |signed_header|
+ def build_signed_string
+ signed_headers.map do |signed_header|
if signed_header == Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
+ elsif signed_header == '(created)'
+ raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
+ raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?
+
+ "(created): #{signature_params['created']}"
+ elsif signed_header == '(expires)'
+ raise SignatureVerificationError, 'Invalid pseudo-header (expires) for rsa-sha256' unless signature_algorithm == 'hs2019'
+ raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
+
+ "(expires): #{signature_params['expires']}"
elsif signed_header == 'digest'
"digest: #{body_digest}"
else
@@ -123,13 +162,28 @@ def build_signed_string(signed_headers)
end
def matches_time_window?
+ created_time = nil
+ expires_time = nil
+
begin
- time_sent = Time.httpdate(request.headers['Date'])
+ if signature_algorithm == 'hs2019' && signature_params['created'].present?
+ created_time = Time.at(signature_params['created'].to_i).utc
+ elsif request.headers['Date'].present?
+ created_time = Time.httpdate(request.headers['Date']).utc
+ end
+
+ expires_time = Time.at(signature_params['expires'].to_i).utc if signature_params['expires'].present?
rescue ArgumentError
return false
end
- (Time.now.utc - time_sent).abs <= 12.hours
+ expires_time ||= created_time + 5.minutes unless created_time.nil?
+ expires_time = [expires_time, created_time + EXPIRATION_WINDOW_LIMIT].min unless created_time.nil?
+
+ return false if created_time.present? && created_time > Time.now.utc + CLOCK_SKEW_MARGIN
+ return false if expires_time.present? && Time.now.utc > expires_time + CLOCK_SKEW_MARGIN
+
+ true
end
def body_digest
@@ -140,9 +194,8 @@ def to_header_name(name)
name.split(/-/).map(&:capitalize).join('-')
end
- def incompatible_signature?(signature_params)
- signature_params['keyId'].blank? ||
- signature_params['signature'].blank?
+ def missing_required_signature_parameters?
+ signature_params['keyId'].blank? || signature_params['signature'].blank?
end
def account_from_key_id(key_id)
diff --git a/app/helpers/webfinger_helper.rb b/app/helpers/webfinger_helper.rb
index ab7ca469811f1..482f4e19eabef 100644
--- a/app/helpers/webfinger_helper.rb
+++ b/app/helpers/webfinger_helper.rb
@@ -1,38 +1,7 @@
# frozen_string_literal: true
-# Monkey-patch on monkey-patch.
-# Because it conflicts with the request.rb patch.
-class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation
- def connect(socket_class, host, port, nodelay = false)
- ::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do
- @socket = socket_class.open(host, port)
- @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
- end
- end
-end
-
module WebfingerHelper
def webfinger!(uri)
- hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
-
- raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri
-
- opts = {
- ssl: !hidden_service_uri,
-
- headers: {
- 'User-Agent': Mastodon::Version.user_agent,
- },
-
- timeout_class: HTTP::Timeout::PerOperationOriginal,
-
- timeout_options: {
- write_timeout: 10,
- connect_timeout: 5,
- read_timeout: 10,
- },
- }
-
- Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger
+ Webfinger.new(uri).perform
end
end
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 030922520264f..fb25964e47e89 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -453,7 +453,11 @@ export function fetchComposeSuggestions(token) {
return (dispatch, getState) => {
switch (token[0]) {
case ':':
- fetchComposeSuggestionsEmojis(dispatch, getState, token);
+ if (token[1] == '@') {
+ fetchComposeSuggestionsAccounts(dispatch, getState, token.substr(1, token.length));
+ } else {
+ fetchComposeSuggestionsEmojis(dispatch, getState, token);
+ }
break;
case '#':
fetchComposeSuggestionsTags(dispatch, getState, token);
@@ -502,6 +506,9 @@ export function selectComposeSuggestion(position, token, suggestion, path) {
} else if (suggestion.type === 'account') {
completion = getState().getIn(['accounts', suggestion.id, 'acct']);
startPosition = position;
+ if (token[0] == ':') {
+ completion = `@${completion}:`;
+ }
}
dispatch({
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 174e401b7250c..deb2f1554a865 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -442,7 +442,7 @@ class Status extends ImmutablePureComponent {
{prepend}
-
+
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 231c517e9880f..d937230980aa8 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -7,6 +7,7 @@ import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, isStaff } from '../initial_state';
+import classNames from 'classnames';
const messages = defineMessages({
delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -245,8 +246,6 @@ class StatusActionBar extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
}
-
- menu.push({ text: intl.formatMessage(status.get('bookmarked') ? messages.removeBookmark : messages.bookmark), action: this.handleBookmarkClick });
menu.push(null);
if (status.getIn(['account', 'id']) === me || withDismiss) {
@@ -329,8 +328,9 @@ class StatusActionBar extends ImmutablePureComponent {
return (
{obfuscatedCount(status.get('replies_count'))}
-
+
+
{shareButton}
diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js
index 1ab1c3117d7cd..5b81726945898 100644
--- a/app/javascript/mastodon/features/audio/index.js
+++ b/app/javascript/mastodon/features/audio/index.js
@@ -115,6 +115,10 @@ class Audio extends React.PureComponent {
}
togglePlay = () => {
+ if (!this.audioContext) {
+ this._initAudioContext();
+ }
+
if (this.state.paused) {
this.setState({ paused: false }, () => this.audio.play());
} else {
@@ -133,10 +137,6 @@ class Audio extends React.PureComponent {
handlePlay = () => {
this.setState({ paused: false });
- if (this.canvas && !this.audioContext) {
- this._initAudioContext();
- }
-
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
@@ -269,8 +269,9 @@ class Audio extends React.PureComponent {
}
_initAudioContext () {
- const context = new AudioContext();
- const source = context.createMediaElementSource(this.audio);
+ const AudioContext = window.AudioContext || window.webkitAudioContext;
+ const context = new AudioContext();
+ const source = context.createMediaElementSource(this.audio);
this.visualizer.setAudioContext(context, source);
source.connect(context.destination);
diff --git a/app/javascript/mastodon/features/compose/components/announcements.js b/app/javascript/mastodon/features/compose/components/announcements.js
new file mode 100644
index 0000000000000..08d45ff181fd0
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/announcements.js
@@ -0,0 +1,120 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Immutable from 'immutable';
+import { Link } from 'react-router-dom';
+import axios from 'axios';
+import classnames from 'classnames';
+
+class Announcement extends React.PureComponent {
+
+ static propTypes = {
+ item: ImmutablePropTypes.map,
+ }
+
+ render() {
+ const { item } = this.props;
+
+ const contents = [];
+ contents.push(
{item.get('body')}
);
+ if (item.get('icon')) {
+ contents.push(
+
+
+
+ );
+ }
+
+ const href = item.get('href');
+
+ const classname = classnames({
+ 'announcements2__item': true,
+ 'announcements2__item--clickable': !!href,
+ });
+
+ if (!href) {
+ return (
{contents}
);
+ } else if (href.startsWith('/web/')) {
+ return (
{contents});
+ } else {
+ return (
{contents});
+ }
+ }
+
+}
+
+export default class Announcements extends React.PureComponent {
+
+ state = {
+ items: Announcements.cache || Immutable.Map(),
+ }
+
+ static isCacheControlled = false
+ static lastDate = null
+ static cache = null
+
+ constructor () {
+ super();
+ this.refresh();
+ }
+
+ componentWillUnmount() {
+ this.cancelPolling();
+ }
+
+ setPolling = () => {
+ this.timer = setTimeout(this.refresh, 2 * 60 * 1000);
+ }
+
+ cancelPolling = () => {
+ if (this.timer !== null) {
+ clearTimeout(this.timer);
+ this.timer = null;
+ }
+ }
+
+ deleteServiceWorkerCache = () => {
+ // files in /system/ will be cached by SW
+ if (self.caches) {
+ return caches.open('mastodon-system')
+ .then(cache => cache.delete(window.origin + '/system/announcements.json'))
+ .catch(() => {});
+ } else {
+ return Promise.resolve();
+ }
+ }
+
+ refresh = () => {
+ this.timer = null;
+
+ axios.get('/announcements.json', {
+ headers: {
+ 'If-Modified-Since': !Announcements.isCacheControlled && Announcements.lastDate || '',
+ },
+ })
+ .then(resp => {
+ Announcements.isCacheControlled = !!resp.headers['cache-control'];
+ Announcements.lastDate = resp.headers['last-modified'];
+ return resp;
+ })
+ .then(resp => this.setState({ items: Announcements.cache = Immutable.fromJS(resp.data) || {} }))
+ .catch(err => err.response.status !== 304 && console.warn(err))
+ .then(this.deleteServiceWorkerCache)
+ .then(this.setPolling)
+ .catch(err => err && console.warn(err));
+ }
+
+ render() {
+ const { items } = this.state;
+
+ return (
+
+ {items.entrySeq().map(([key, item]) =>
+ (-
+
+
)
+ )}
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index 360a7af6ab9fc..e8a36a92314f0 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -315,7 +315,7 @@ class EmojiPickerDropdown extends React.PureComponent {
this.setState({ loading: false });
}).catch(() => {
- this.setState({ loading: false });
+ this.setState({ loading: false, active: false });
});
}
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js
index db49f90eb4cb9..f322298a827ac 100644
--- a/app/javascript/mastodon/features/compose/components/poll_form.js
+++ b/app/javascript/mastodon/features/compose/components/poll_form.js
@@ -157,13 +157,16 @@ class PollForm extends ImmutablePureComponent {
-
+
{/* eslint-disable-next-line jsx-a11y/no-onchange */}