Compare commits
63 commits
f7f34cf315
...
a1ae88b977
Author | SHA1 | Date | |
---|---|---|---|
a1ae88b977 | |||
|
06f906acac | ||
|
80d8ff97e4 | ||
|
d5963d9401 | ||
|
b95281b533 | ||
|
5432edb5a7 | ||
|
6861534d9c | ||
|
80e391afcd | ||
|
c69f190af9 | ||
|
cec93c35d8 | ||
|
ad6fcb2d9c | ||
|
d8cf2a0fb6 | ||
|
21e3671e32 | ||
|
68da55e50c | ||
|
3f7614f98a | ||
|
c1bc34da04 | ||
|
a0896ae4bf | ||
|
91fb945b0e | ||
|
ed27803822 | ||
|
4e4f73b231 | ||
|
9bb23b8d19 | ||
|
dead24a773 | ||
|
d8b8c88c22 | ||
|
ad0866804e | ||
|
6c4a196b53 | ||
|
28f3b13c63 | ||
|
8c445c80b5 | ||
|
212848b66e | ||
|
227c561064 | ||
|
2e244b7401 | ||
|
291d868773 | ||
|
b21c630043 | ||
|
f2795699dd | ||
|
d9a024840e | ||
|
c8bf30df92 | ||
|
7f9431c306 | ||
|
af410c0706 | ||
|
16f348431b | ||
|
6abd849803 | ||
|
99b27a8b4b | ||
|
39741fa2cd | ||
|
5b3d70ffa7 | ||
|
011909262a | ||
|
69680db8a2 | ||
|
6e28da2139 | ||
|
74982c71b0 | ||
|
c83c87fbe2 | ||
|
363afe5e05 | ||
|
d588173ab3 | ||
|
d1d3684fb5 | ||
|
6a3876bdaa | ||
|
5cd97c62a0 | ||
|
769bbd511f | ||
|
5d79df0273 | ||
|
0367ddb62c | ||
|
221110c5d7 | ||
|
8904487324 | ||
|
6782922584 | ||
|
8066717558 | ||
|
5a06f68f0e | ||
|
aef567cb9d | ||
|
de747948a1 | ||
|
c95ce1f3ac |
98 changed files with 852 additions and 270 deletions
59
CHANGELOG.md
59
CHANGELOG.md
|
@ -3,6 +3,65 @@ Changelog
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [2.9.3] - 2019-08-10
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Add GIF and WebP support for custom emojis ([Gargron](https://github.com/tootsuite/mastodon/pull/11519))
|
||||||
|
- Add logout link to dropdown menu in web UI ([koyuawsmbrtn](https://github.com/tootsuite/mastodon/pull/11353))
|
||||||
|
- Add indication that text search is unavailable in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11112), [ThibG](https://github.com/tootsuite/mastodon/pull/11202))
|
||||||
|
- Add `suffix` to `Mastodon::Version` to help forks ([clarfon](https://github.com/tootsuite/mastodon/pull/11407))
|
||||||
|
- Add on-hover animation to animated custom emoji in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11348), [ThibG](https://github.com/tootsuite/mastodon/pull/11404), [ThibG](https://github.com/tootsuite/mastodon/pull/11522))
|
||||||
|
- Add custom emoji support in profile metadata labels ([ThibG](https://github.com/tootsuite/mastodon/pull/11350))
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Change default interface of web and streaming from 0.0.0.0 to 127.0.0.1 ([Gargron](https://github.com/tootsuite/mastodon/pull/11302), [zunda](https://github.com/tootsuite/mastodon/pull/11378), [Gargron](https://github.com/tootsuite/mastodon/pull/11351), [zunda](https://github.com/tootsuite/mastodon/pull/11326))
|
||||||
|
- Change the retry limit of web push notifications ([highemerly](https://github.com/tootsuite/mastodon/pull/11292))
|
||||||
|
- Change ActivityPub deliveries to not retry HTTP 501 errors ([Gargron](https://github.com/tootsuite/mastodon/pull/11233))
|
||||||
|
- Change language detection to include hashtags as words ([Gargron](https://github.com/tootsuite/mastodon/pull/11341))
|
||||||
|
- Change terms and privacy policy pages to always be accessible ([Gargron](https://github.com/tootsuite/mastodon/pull/11334))
|
||||||
|
- Change robots tag to include `noarchive` when user opts out of indexing ([Kjwon15](https://github.com/tootsuite/mastodon/pull/11421))
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix account domain block not clearing out notifications ([Gargron](https://github.com/tootsuite/mastodon/pull/11393))
|
||||||
|
- Fix incorrect locale sometimes being detected for browser ([Gargron](https://github.com/tootsuite/mastodon/pull/8657))
|
||||||
|
- Fix crash when saving invalid domain name ([Gargron](https://github.com/tootsuite/mastodon/pull/11528))
|
||||||
|
- Fix pinned statuses REST API returning pagination headers ([Gargron](https://github.com/tootsuite/mastodon/pull/11526))
|
||||||
|
- Fix "cancel follow request" button having unreadable text in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/11521))
|
||||||
|
- Fix image uploads being blank when canvas read access is blocked ([ThibG](https://github.com/tootsuite/mastodon/pull/11499))
|
||||||
|
- Fix avatars not being animated on hover when not logged in ([ThibG](https://github.com/tootsuite/mastodon/pull/11349))
|
||||||
|
- Fix overzealous sanitization of HTML lists ([ThibG](https://github.com/tootsuite/mastodon/pull/11354))
|
||||||
|
- Fix block crashing when a follow request exists ([ThibG](https://github.com/tootsuite/mastodon/pull/11288))
|
||||||
|
- Fix backup service crashing when an attachment is missing ([ThibG](https://github.com/tootsuite/mastodon/pull/11241))
|
||||||
|
- Fix account moderation action always sending e-mail notification ([Gargron](https://github.com/tootsuite/mastodon/pull/11242))
|
||||||
|
- Fix swiping columns on mobile sometimes failing in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11200))
|
||||||
|
- Fix wrong actor URI being serialized into poll updates ([ThibG](https://github.com/tootsuite/mastodon/pull/11194))
|
||||||
|
- Fix statsd UDP sockets not being cleaned up in Sidekiq ([Gargron](https://github.com/tootsuite/mastodon/pull/11230))
|
||||||
|
- Fix expiration date of filters being set to "never" when editing them ([ThibG](https://github.com/tootsuite/mastodon/pull/11204))
|
||||||
|
- Fix support for MP4 files that are actually M4V files ([Gargron](https://github.com/tootsuite/mastodon/pull/11210))
|
||||||
|
- Fix `alerts` not being typecast correctly in push subscription in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/11343))
|
||||||
|
- Fix some notices staying on unrelated pages ([ThibG](https://github.com/tootsuite/mastodon/pull/11364))
|
||||||
|
- Fix unboosting sometimes preventing a boost from reappearing on feed ([ThibG](https://github.com/tootsuite/mastodon/pull/11405), [Gargron](https://github.com/tootsuite/mastodon/pull/11450))
|
||||||
|
- Fix only one middle dot being recognized in hashtags ([Gargron](https://github.com/tootsuite/mastodon/pull/11345), [ThibG](https://github.com/tootsuite/mastodon/pull/11363))
|
||||||
|
- Fix unnecessary SQL query performed on unauthenticated requests ([Gargron](https://github.com/tootsuite/mastodon/pull/11179))
|
||||||
|
- Fix incorrect timestamp displayed on featured tags ([Kjwon15](https://github.com/tootsuite/mastodon/pull/11477))
|
||||||
|
- Fix privacy dropdown active state when dropdown is placed on top of it ([ThibG](https://github.com/tootsuite/mastodon/pull/11495))
|
||||||
|
- Fix filters not being applied to poll options ([ThibG](https://github.com/tootsuite/mastodon/pull/11174))
|
||||||
|
- Fix keyboard navigation on various dropdowns ([ThibG](https://github.com/tootsuite/mastodon/pull/11511), [ThibG](https://github.com/tootsuite/mastodon/pull/11492), [ThibG](https://github.com/tootsuite/mastodon/pull/11491))
|
||||||
|
- Fix keyboard navigation in modals ([ThibG](https://github.com/tootsuite/mastodon/pull/11493))
|
||||||
|
- Fix image conversation being non-deterministic due to timestamps ([Gargron](https://github.com/tootsuite/mastodon/pull/11408))
|
||||||
|
- Fix web UI performance ([ThibG](https://github.com/tootsuite/mastodon/pull/11211), [ThibG](https://github.com/tootsuite/mastodon/pull/11234))
|
||||||
|
- Fix scrolling to compose form when not necessary in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11246), [ThibG](https://github.com/tootsuite/mastodon/pull/11182))
|
||||||
|
- Fix save button being enabled when list title is empty in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11475))
|
||||||
|
- Fix poll expiration not being pre-filled on delete & redraft in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11203))
|
||||||
|
- Fix content warning sometimes being set when not requested in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/11206))
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Fix invites not being disabled upon account suspension ([ThibG](https://github.com/tootsuite/mastodon/pull/11412))
|
||||||
|
- Fix blocked domains still being able to fill database with account records ([Gargron](https://github.com/tootsuite/mastodon/pull/11219))
|
||||||
|
|
||||||
## [2.9.2] - 2019-06-22
|
## [2.9.2] - 2019-06-22
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|
|
@ -112,6 +112,7 @@ ENV NODE_ENV="production"
|
||||||
|
|
||||||
# Tell rails to serve static files
|
# Tell rails to serve static files
|
||||||
ENV RAILS_SERVE_STATIC_FILES="true"
|
ENV RAILS_SERVE_STATIC_FILES="true"
|
||||||
|
ENV BIND="0.0.0.0"
|
||||||
|
|
||||||
# Set the run user
|
# Set the run user
|
||||||
USER mastodon
|
USER mastodon
|
||||||
|
|
|
@ -231,7 +231,7 @@ GEM
|
||||||
fugit (1.1.6)
|
fugit (1.1.6)
|
||||||
et-orbi (~> 1.1, >= 1.1.6)
|
et-orbi (~> 1.1, >= 1.1.6)
|
||||||
raabro (~> 1.1)
|
raabro (~> 1.1)
|
||||||
fuubar (2.4.0)
|
fuubar (2.4.1)
|
||||||
rspec-core (~> 3.0)
|
rspec-core (~> 3.0)
|
||||||
ruby-progressbar (~> 1.4)
|
ruby-progressbar (~> 1.4)
|
||||||
get_process_mem (0.2.3)
|
get_process_mem (0.2.3)
|
||||||
|
|
14
Procfile
14
Procfile
|
@ -1,2 +1,14 @@
|
||||||
web: bundle exec puma -C config/puma.rb
|
web: if [ "$RUN_STREAMING" != "true" ]; then BIND=0.0.0.0 bundle exec puma -C config/puma.rb; else BIND=0.0.0.0 node ./streaming; fi
|
||||||
worker: bundle exec sidekiq
|
worker: bundle exec sidekiq
|
||||||
|
|
||||||
|
# For the streaming API, you need a separate app that shares Postgres and Redis:
|
||||||
|
#
|
||||||
|
# heroku create
|
||||||
|
# heroku buildpacks:add heroku/nodejs
|
||||||
|
# heroku config:set RUN_STREAMING=true
|
||||||
|
# heroku addons:attach <main-app>::DATABASE
|
||||||
|
# heroku addons:attach <main-app>::REDIS
|
||||||
|
#
|
||||||
|
# and let the main app use the separate app:
|
||||||
|
#
|
||||||
|
# heroku config:set STREAMING_API_BASE_URL=wss://<streaming-app>.herokuapp.com -a <main-app>
|
||||||
|
|
|
@ -51,7 +51,7 @@ class StatusesIndex < Chewy::Index
|
||||||
field :id, type: 'long'
|
field :id, type: 'long'
|
||||||
field :account_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)).join("\n\n") } do
|
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 :stemmed, type: 'text', analyzer: 'content'
|
field :stemmed, type: 'text', analyzer: 'content'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,8 @@ class AboutController < ApplicationController
|
||||||
|
|
||||||
before_action :set_instance_presenter, only: [:show, :more, :terms]
|
before_action :set_instance_presenter, only: [:show, :more, :terms]
|
||||||
|
|
||||||
|
skip_before_action :check_user_permissions, only: [:more, :terms]
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@hide_navbar = true
|
@hide_navbar = true
|
||||||
end
|
end
|
||||||
|
|
|
@ -17,7 +17,7 @@ module Admin
|
||||||
|
|
||||||
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
|
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
|
||||||
@domain_block.save
|
@domain_block.save
|
||||||
flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
|
flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
|
||||||
@domain_block.errors[:domain].clear
|
@domain_block.errors[:domain].clear
|
||||||
render :new
|
render :new
|
||||||
else
|
else
|
||||||
|
|
|
@ -3,7 +3,8 @@
|
||||||
class Api::V1::Accounts::StatusesController < Api::BaseController
|
class Api::V1::Accounts::StatusesController < Api::BaseController
|
||||||
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
|
||||||
before_action :set_account
|
before_action :set_account
|
||||||
after_action :insert_pagination_headers
|
|
||||||
|
after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) }
|
||||||
|
|
||||||
respond_to :json
|
respond_to :json
|
||||||
|
|
||||||
|
|
|
@ -91,11 +91,15 @@ class ApplicationController < ActionController::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_account
|
def current_account
|
||||||
@current_account ||= current_user.try(:account)
|
return @current_account if defined?(@current_account)
|
||||||
|
|
||||||
|
@current_account = current_user&.account
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_session
|
def current_session
|
||||||
@current_session ||= SessionActivation.find_by(session_id: cookies.signed['_session_id'])
|
return @current_session if defined?(@current_session)
|
||||||
|
|
||||||
|
@current_session = SessionActivation.find_by(session_id: cookies.signed['_session_id']) if cookies.signed['_session_id'].present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_theme
|
def current_theme
|
||||||
|
@ -126,11 +130,7 @@ class ApplicationController < ActionController::Base
|
||||||
def respond_with_error(code)
|
def respond_with_error(code)
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.any { head code }
|
format.any { head code }
|
||||||
|
format.html { render "errors/#{code}", layout: 'error', status: code }
|
||||||
format.html do
|
|
||||||
set_locale
|
|
||||||
render "errors/#{code}", layout: 'error', status: code
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -4,16 +4,19 @@ module Localized
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
before_action :set_locale
|
around_action :set_locale
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_locale
|
def set_locale
|
||||||
I18n.locale = default_locale
|
locale = current_user.locale if respond_to?(:user_signed_in?) && user_signed_in?
|
||||||
I18n.locale = current_user.locale if user_signed_in?
|
locale ||= session[:locale] ||= default_locale
|
||||||
rescue I18n::InvalidLocale
|
locale = default_locale unless I18n.available_locales.include?(locale.to_sym)
|
||||||
I18n.locale = default_locale
|
|
||||||
|
I18n.with_locale(locale) do
|
||||||
|
yield
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_locale
|
def default_locale
|
||||||
|
|
|
@ -39,7 +39,7 @@ class InvitesController < ApplicationController
|
||||||
private
|
private
|
||||||
|
|
||||||
def invites
|
def invites
|
||||||
Invite.where(user: current_user).order(id: :desc)
|
current_user.invites.order(id: :desc)
|
||||||
end
|
end
|
||||||
|
|
||||||
def resource_params
|
def resource_params
|
||||||
|
|
|
@ -14,7 +14,7 @@ module Settings
|
||||||
|
|
||||||
def create
|
def create
|
||||||
if current_user.validate_and_consume_otp!(confirmation_params[:code])
|
if current_user.validate_and_consume_otp!(confirmation_params[:code])
|
||||||
flash[:notice] = I18n.t('two_factor_authentication.enabled_success')
|
flash.now[:notice] = I18n.t('two_factor_authentication.enabled_success')
|
||||||
|
|
||||||
current_user.otp_required_for_login = true
|
current_user.otp_required_for_login = true
|
||||||
@recovery_codes = current_user.generate_otp_backup_codes!
|
@recovery_codes = current_user.generate_otp_backup_codes!
|
||||||
|
|
|
@ -10,7 +10,7 @@ module Settings
|
||||||
def create
|
def create
|
||||||
@recovery_codes = current_user.generate_otp_backup_codes!
|
@recovery_codes = current_user.generate_otp_backup_codes!
|
||||||
current_user.save!
|
current_user.save!
|
||||||
flash[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
|
flash.now[:notice] = I18n.t('two_factor_authentication.recovery_codes_regenerated')
|
||||||
render :index
|
render :index
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -139,7 +139,7 @@ export function submitCompose(routerHistory) {
|
||||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||||
media_ids: media.map(item => item.get('id')),
|
media_ids: media.map(item => item.get('id')),
|
||||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||||
spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
|
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
||||||
visibility: getState().getIn(['compose', 'privacy']),
|
visibility: getState().getIn(['compose', 'privacy']),
|
||||||
poll: getState().getIn(['compose', 'poll'], null),
|
poll: getState().getIn(['compose', 'poll'], null),
|
||||||
}, {
|
}, {
|
||||||
|
|
|
@ -23,6 +23,7 @@ export function blockDomain(domain) {
|
||||||
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
|
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
|
||||||
const at_domain = '@' + domain;
|
const at_domain = '@' + domain;
|
||||||
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
|
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
|
||||||
|
|
||||||
dispatch(blockDomainSuccess(domain, accounts));
|
dispatch(blockDomainSuccess(domain, accounts));
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
dispatch(blockDomainFail(domain, err));
|
dispatch(blockDomainFail(domain, err));
|
||||||
|
|
|
@ -22,7 +22,7 @@ export function normalizeAccount(account) {
|
||||||
if (account.fields) {
|
if (account.fields) {
|
||||||
account.fields = account.fields.map(pair => ({
|
account.fields = account.fields.map(pair => ({
|
||||||
...pair,
|
...pair,
|
||||||
name_emojified: emojify(escapeTextContentForBrowser(pair.name)),
|
name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap),
|
||||||
value_emojified: emojify(pair.value, emojiMap),
|
value_emojified: emojify(pair.value, emojiMap),
|
||||||
value_plain: unescapeHTML(pair.value),
|
value_plain: unescapeHTML(pair.value),
|
||||||
}));
|
}));
|
||||||
|
@ -56,7 +56,7 @@ export function normalizeStatus(status, normalOldStatus) {
|
||||||
normalStatus.hidden = normalOldStatus.get('hidden');
|
normalStatus.hidden = normalOldStatus.get('hidden');
|
||||||
} else {
|
} else {
|
||||||
const spoilerText = normalStatus.spoiler_text || '';
|
const spoilerText = normalStatus.spoiler_text || '';
|
||||||
const searchContent = [spoilerText, status.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
|
||||||
const emojiMap = makeEmojiMap(normalStatus);
|
const emojiMap = makeEmojiMap(normalStatus);
|
||||||
|
|
||||||
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
|
||||||
|
|
|
@ -9,8 +9,9 @@ export function openModal(type, props) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function closeModal() {
|
export function closeModal(type) {
|
||||||
return {
|
return {
|
||||||
type: MODAL_CLOSE,
|
type: MODAL_CLOSE,
|
||||||
|
modalType: type,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { saveSettings } from './settings';
|
||||||
import { defineMessages } from 'react-intl';
|
import { defineMessages } from 'react-intl';
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList } from 'immutable';
|
||||||
import { unescapeHTML } from '../utils/html';
|
import { unescapeHTML } from '../utils/html';
|
||||||
import { getFilters, regexFromFilters } from '../selectors';
|
import { getFiltersRegex } from '../selectors';
|
||||||
|
|
||||||
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
|
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
|
||||||
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
|
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
|
||||||
|
@ -43,13 +43,13 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
|
||||||
const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
|
const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
|
||||||
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
const showAlert = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
|
||||||
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
const playSound = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
|
||||||
const filters = getFilters(getState(), { contextType: 'notifications' });
|
const filters = getFiltersRegex(getState(), { contextType: 'notifications' });
|
||||||
|
|
||||||
let filtered = false;
|
let filtered = false;
|
||||||
|
|
||||||
if (notification.type === 'mention') {
|
if (notification.type === 'mention') {
|
||||||
const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
|
const dropRegex = filters[0];
|
||||||
const regex = regexFromFilters(filters);
|
const regex = filters[1];
|
||||||
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
|
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
|
||||||
|
|
||||||
if (dropRegex && dropRegex.test(searchIndex)) {
|
if (dropRegex && dropRegex.test(searchIndex)) {
|
||||||
|
|
|
@ -48,7 +48,7 @@ export function submitSearch() {
|
||||||
dispatch(importFetchedStatuses(response.data.statuses));
|
dispatch(importFetchedStatuses(response.data.statuses));
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(fetchSearchSuccess(response.data));
|
dispatch(fetchSearchSuccess(response.data, value));
|
||||||
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
|
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
dispatch(fetchSearchFail(error));
|
dispatch(fetchSearchFail(error));
|
||||||
|
@ -62,10 +62,11 @@ export function fetchSearchRequest() {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function fetchSearchSuccess(results) {
|
export function fetchSearchSuccess(results, searchTerm) {
|
||||||
return {
|
return {
|
||||||
type: SEARCH_FETCH_SUCCESS,
|
type: SEARCH_FETCH_SUCCESS,
|
||||||
results,
|
results,
|
||||||
|
searchTerm,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@ export default class Button extends React.PureComponent {
|
||||||
secondary: PropTypes.bool,
|
secondary: PropTypes.bool,
|
||||||
size: PropTypes.number,
|
size: PropTypes.number,
|
||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
|
title: PropTypes.string,
|
||||||
style: PropTypes.object,
|
style: PropTypes.object,
|
||||||
children: PropTypes.node,
|
children: PropTypes.node,
|
||||||
};
|
};
|
||||||
|
@ -54,6 +55,7 @@ export default class Button extends React.PureComponent {
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
ref={this.setRef}
|
ref={this.setRef}
|
||||||
style={style}
|
style={style}
|
||||||
|
title={this.props.title}
|
||||||
>
|
>
|
||||||
{this.props.text || this.props.children}
|
{this.props.text || this.props.children}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { autoPlayGif } from 'mastodon/initial_state';
|
||||||
|
|
||||||
export default class DisplayName extends React.PureComponent {
|
export default class DisplayName extends React.PureComponent {
|
||||||
|
|
||||||
|
@ -10,6 +11,47 @@ export default class DisplayName extends React.PureComponent {
|
||||||
localDomain: PropTypes.string,
|
localDomain: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_updateEmojis () {
|
||||||
|
const node = this.node;
|
||||||
|
|
||||||
|
if (!node || autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = node.querySelectorAll('.custom-emoji');
|
||||||
|
|
||||||
|
for (var i = 0; i < emojis.length; i++) {
|
||||||
|
let emoji = emojis[i];
|
||||||
|
if (emoji.classList.contains('status-emoji')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emoji.classList.add('status-emoji');
|
||||||
|
|
||||||
|
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
|
||||||
|
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this._updateEmojis();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate () {
|
||||||
|
this._updateEmojis();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEmojiMouseEnter = ({ target }) => {
|
||||||
|
target.src = target.getAttribute('data-original');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEmojiMouseLeave = ({ target }) => {
|
||||||
|
target.src = target.getAttribute('data-static');
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = (c) => {
|
||||||
|
this.node = c;
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { others, localDomain } = this.props;
|
const { others, localDomain } = this.props;
|
||||||
|
|
||||||
|
@ -39,7 +81,7 @@ export default class DisplayName extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className='display-name'>
|
<span className='display-name' ref={this.setRef}>
|
||||||
{displayName} {suffix}
|
{displayName} {suffix}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
@ -45,7 +45,9 @@ class DropdownMenu extends React.PureComponent {
|
||||||
document.addEventListener('click', this.handleDocumentClick, false);
|
document.addEventListener('click', this.handleDocumentClick, false);
|
||||||
document.addEventListener('keydown', this.handleKeyDown, false);
|
document.addEventListener('keydown', this.handleKeyDown, false);
|
||||||
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
|
||||||
if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus();
|
if (this.focusedItem && this.props.openedViaKeyboard) {
|
||||||
|
this.focusedItem.focus();
|
||||||
|
}
|
||||||
this.setState({ mounted: true });
|
this.setState({ mounted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,6 +83,18 @@ class DropdownMenu extends React.PureComponent {
|
||||||
element.focus();
|
element.focus();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
if (e.shiftKey) {
|
||||||
|
element = items[index-1] || items[items.length-1];
|
||||||
|
} else {
|
||||||
|
element = items[index+1] || items[0];
|
||||||
|
}
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
element = items[0];
|
element = items[0];
|
||||||
if (element) {
|
if (element) {
|
||||||
|
@ -93,11 +107,14 @@ class DropdownMenu extends React.PureComponent {
|
||||||
element.focus();
|
element.focus();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
this.props.onClose();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleItemKeyDown = e => {
|
handleItemKeyPress = e => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
this.handleClick(e);
|
this.handleClick(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -122,11 +139,11 @@ class DropdownMenu extends React.PureComponent {
|
||||||
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { text, href = '#' } = option;
|
const { text, href = '#', target = '_blank', method } = option;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className='dropdown-menu__item' key={`${text}-${i}`}>
|
<li className='dropdown-menu__item' key={`${text}-${i}`}>
|
||||||
<a href={href} target='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyDown={this.handleItemKeyDown} data-index={i}>
|
<a href={href} target={target} data-method={method} rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyPress={this.handleItemKeyPress} data-index={i}>
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@ -193,25 +210,41 @@ export default class Dropdown extends React.PureComponent {
|
||||||
} else {
|
} else {
|
||||||
const { top } = target.getBoundingClientRect();
|
const { top } = target.getBoundingClientRect();
|
||||||
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
|
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
|
||||||
|
|
||||||
this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
|
this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClose = () => {
|
handleClose = () => {
|
||||||
|
if (this.activeElement) {
|
||||||
|
this.activeElement.focus();
|
||||||
|
this.activeElement = null;
|
||||||
|
}
|
||||||
this.props.onClose(this.state.id);
|
this.props.onClose(this.state.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleKeyDown = e => {
|
handleMouseDown = () => {
|
||||||
|
if (!this.state.open) {
|
||||||
|
this.activeElement = document.activeElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleButtonKeyDown = (e) => {
|
||||||
|
switch(e.key) {
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
this.handleMouseDown();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyPress = (e) => {
|
||||||
switch(e.key) {
|
switch(e.key) {
|
||||||
case ' ':
|
case ' ':
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
this.handleClick(e);
|
this.handleClick(e);
|
||||||
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
break;
|
break;
|
||||||
case 'Escape':
|
|
||||||
this.handleClose();
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -249,7 +282,7 @@ export default class Dropdown extends React.PureComponent {
|
||||||
const open = this.state.id === openDropdownId;
|
const open = this.state.id === openDropdownId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onKeyDown={this.handleKeyDown}>
|
<div>
|
||||||
<IconButton
|
<IconButton
|
||||||
icon={icon}
|
icon={icon}
|
||||||
title={title}
|
title={title}
|
||||||
|
@ -258,6 +291,9 @@ export default class Dropdown extends React.PureComponent {
|
||||||
size={size}
|
size={size}
|
||||||
ref={this.setTargetRef}
|
ref={this.setTargetRef}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
|
onMouseDown={this.handleMouseDown}
|
||||||
|
onKeyDown={this.handleButtonKeyDown}
|
||||||
|
onKeyPress={this.handleKeyPress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
|
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>
|
||||||
|
|
|
@ -12,6 +12,9 @@ export default class IconButton extends React.PureComponent {
|
||||||
title: PropTypes.string.isRequired,
|
title: PropTypes.string.isRequired,
|
||||||
icon: PropTypes.string.isRequired,
|
icon: PropTypes.string.isRequired,
|
||||||
onClick: PropTypes.func,
|
onClick: PropTypes.func,
|
||||||
|
onMouseDown: PropTypes.func,
|
||||||
|
onKeyDown: PropTypes.func,
|
||||||
|
onKeyPress: PropTypes.func,
|
||||||
size: PropTypes.number,
|
size: PropTypes.number,
|
||||||
active: PropTypes.bool,
|
active: PropTypes.bool,
|
||||||
pressed: PropTypes.bool,
|
pressed: PropTypes.bool,
|
||||||
|
@ -42,6 +45,24 @@ export default class IconButton extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleKeyPress = (e) => {
|
||||||
|
if (this.props.onKeyPress && !this.props.disabled) {
|
||||||
|
this.props.onKeyPress(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMouseDown = (e) => {
|
||||||
|
if (!this.props.disabled && this.props.onMouseDown) {
|
||||||
|
this.props.onMouseDown(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyDown = (e) => {
|
||||||
|
if (!this.props.disabled && this.props.onKeyDown) {
|
||||||
|
this.props.onKeyDown(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const style = {
|
const style = {
|
||||||
fontSize: `${this.props.size}px`,
|
fontSize: `${this.props.size}px`,
|
||||||
|
@ -84,6 +105,9 @@ export default class IconButton extends React.PureComponent {
|
||||||
title={title}
|
title={title}
|
||||||
className={classes}
|
className={classes}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
|
onMouseDown={this.handleMouseDown}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
|
onKeyPress={this.handleKeyPress}
|
||||||
style={style}
|
style={style}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
@ -103,6 +127,9 @@ export default class IconButton extends React.PureComponent {
|
||||||
title={title}
|
title={title}
|
||||||
className={classes}
|
className={classes}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
|
onMouseDown={this.handleMouseDown}
|
||||||
|
onKeyDown={this.handleKeyDown}
|
||||||
|
onKeyPress={this.handleKeyPress}
|
||||||
style={style}
|
style={style}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
|
|
@ -21,8 +21,30 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
|
||||||
|
const index = focusable.indexOf(e.target);
|
||||||
|
|
||||||
|
let element;
|
||||||
|
|
||||||
|
if (e.shiftKey) {
|
||||||
|
element = focusable[index - 1] || focusable[focusable.length - 1];
|
||||||
|
} else {
|
||||||
|
element = focusable[index + 1] || focusable[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
window.addEventListener('keyup', this.handleKeyUp, false);
|
window.addEventListener('keyup', this.handleKeyUp, false);
|
||||||
|
window.addEventListener('keydown', this.handleKeyDown, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillReceiveProps (nextProps) {
|
componentWillReceiveProps (nextProps) {
|
||||||
|
@ -52,6 +74,7 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
window.removeEventListener('keyup', this.handleKeyUp);
|
window.removeEventListener('keyup', this.handleKeyUp);
|
||||||
|
window.removeEventListener('keydown', this.handleKeyDown);
|
||||||
}
|
}
|
||||||
|
|
||||||
getSiblings = () => {
|
getSiblings = () => {
|
||||||
|
|
|
@ -7,6 +7,7 @@ import Permalink from './permalink';
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
import PollContainer from 'mastodon/containers/poll_container';
|
import PollContainer from 'mastodon/containers/poll_container';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
|
import { autoPlayGif } from 'mastodon/initial_state';
|
||||||
|
|
||||||
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)
|
||||||
|
|
||||||
|
@ -71,12 +72,35 @@ export default class StatusContent extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_updateStatusEmojis () {
|
||||||
|
const node = this.node;
|
||||||
|
|
||||||
|
if (!node || autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = node.querySelectorAll('.custom-emoji');
|
||||||
|
|
||||||
|
for (var i = 0; i < emojis.length; i++) {
|
||||||
|
let emoji = emojis[i];
|
||||||
|
if (emoji.classList.contains('status-emoji')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emoji.classList.add('status-emoji');
|
||||||
|
|
||||||
|
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
|
||||||
|
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
this._updateStatusLinks();
|
this._updateStatusLinks();
|
||||||
|
this._updateStatusEmojis();
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate () {
|
componentDidUpdate () {
|
||||||
this._updateStatusLinks();
|
this._updateStatusLinks();
|
||||||
|
this._updateStatusEmojis();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMentionClick = (mention, e) => {
|
onMentionClick = (mention, e) => {
|
||||||
|
@ -95,6 +119,14 @@ export default class StatusContent extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleEmojiMouseEnter = ({ target }) => {
|
||||||
|
target.src = target.getAttribute('data-original');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEmojiMouseLeave = ({ target }) => {
|
||||||
|
target.src = target.getAttribute('data-static');
|
||||||
|
}
|
||||||
|
|
||||||
handleMouseDown = (e) => {
|
handleMouseDown = (e) => {
|
||||||
this.startXY = [e.clientX, e.clientY];
|
this.startXY = [e.clientX, e.clientY];
|
||||||
}
|
}
|
||||||
|
@ -133,11 +165,6 @@ export default class StatusContent extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCollapsedClick = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
this.setState({ collapsed: !this.state.collapsed });
|
|
||||||
}
|
|
||||||
|
|
||||||
setRef = (c) => {
|
setRef = (c) => {
|
||||||
this.node = c;
|
this.node = c;
|
||||||
}
|
}
|
||||||
|
@ -202,45 +229,26 @@ export default class StatusContent extends React.PureComponent {
|
||||||
);
|
);
|
||||||
} else if (this.props.onClick) {
|
} else if (this.props.onClick) {
|
||||||
const output = [
|
const output = [
|
||||||
<div
|
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||||
ref={this.setRef}
|
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
|
||||||
tabIndex='0'
|
|
||||||
key='content'
|
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||||
className={classNames}
|
</div>,
|
||||||
style={directionStyle}
|
|
||||||
dangerouslySetInnerHTML={content}
|
|
||||||
lang={status.get('language')}
|
|
||||||
onMouseDown={this.handleMouseDown}
|
|
||||||
onMouseUp={this.handleMouseUp}
|
|
||||||
/>,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (this.state.collapsed) {
|
if (this.state.collapsed) {
|
||||||
output.push(readMoreButton);
|
output.push(readMoreButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('poll')) {
|
|
||||||
output.push(<PollContainer pollId={status.get('poll')} />);
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
} else {
|
} else {
|
||||||
const output = [
|
return (
|
||||||
<div
|
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
|
||||||
tabIndex='0'
|
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
|
||||||
ref={this.setRef}
|
|
||||||
className='status__content'
|
|
||||||
style={directionStyle}
|
|
||||||
dangerouslySetInnerHTML={content}
|
|
||||||
lang={status.get('language')}
|
|
||||||
/>,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (status.get('poll')) {
|
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||||
output.push(<PollContainer pollId={status.get('poll')} />);
|
</div>
|
||||||
}
|
);
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({
|
||||||
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
|
||||||
},
|
},
|
||||||
onClose(id) {
|
onClose(id) {
|
||||||
dispatch(closeModal());
|
dispatch(closeModal('ACTIONS'));
|
||||||
dispatch(closeDropdownMenu(id));
|
dispatch(closeDropdownMenu(id));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -15,6 +15,7 @@ import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
|
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' },
|
||||||
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
|
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
|
||||||
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
|
||||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
|
@ -79,6 +80,47 @@ class Header extends ImmutablePureComponent {
|
||||||
return !location.pathname.match(/\/(followers|following)\/?$/);
|
return !location.pathname.match(/\/(followers|following)\/?$/);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_updateEmojis () {
|
||||||
|
const node = this.node;
|
||||||
|
|
||||||
|
if (!node || autoPlayGif) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojis = node.querySelectorAll('.custom-emoji');
|
||||||
|
|
||||||
|
for (var i = 0; i < emojis.length; i++) {
|
||||||
|
let emoji = emojis[i];
|
||||||
|
if (emoji.classList.contains('status-emoji')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
emoji.classList.add('status-emoji');
|
||||||
|
|
||||||
|
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
|
||||||
|
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
this._updateEmojis();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate () {
|
||||||
|
this._updateEmojis();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEmojiMouseEnter = ({ target }) => {
|
||||||
|
target.src = target.getAttribute('data-original');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEmojiMouseLeave = ({ target }) => {
|
||||||
|
target.src = target.getAttribute('data-static');
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = (c) => {
|
||||||
|
this.node = c;
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { account, intl, domain, identity_proofs } = this.props;
|
const { account, intl, domain, identity_proofs } = this.props;
|
||||||
|
|
||||||
|
@ -107,7 +149,7 @@ class Header extends ImmutablePureComponent {
|
||||||
if (!account.get('relationship')) { // Wait until the relationship is loaded
|
if (!account.get('relationship')) { // Wait until the relationship is loaded
|
||||||
actionBtn = '';
|
actionBtn = '';
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
} else if (account.getIn(['relationship', 'requested'])) {
|
||||||
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
|
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
|
||||||
} else if (!account.getIn(['relationship', 'blocking'])) {
|
} else if (!account.getIn(['relationship', 'blocking'])) {
|
||||||
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />;
|
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />;
|
||||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
} else if (account.getIn(['relationship', 'blocking'])) {
|
||||||
|
@ -200,7 +242,7 @@ class Header extends ImmutablePureComponent {
|
||||||
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
|
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('account__header', { inactive: !!account.get('moved') })}>
|
<div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}>
|
||||||
<div className='account__header__image'>
|
<div className='account__header__image'>
|
||||||
<div className='account__header__info'>
|
<div className='account__header__info'>
|
||||||
{info}
|
{info}
|
||||||
|
|
|
@ -15,6 +15,7 @@ const messages = defineMessages({
|
||||||
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
|
||||||
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
|
||||||
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
|
||||||
|
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||||
});
|
});
|
||||||
|
|
||||||
export default @injectIntl
|
export default @injectIntl
|
||||||
|
@ -42,6 +43,8 @@ class ActionBar extends React.PureComponent {
|
||||||
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
|
||||||
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
|
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
|
||||||
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
|
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
|
||||||
|
menu.push(null);
|
||||||
|
menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='compose__action-bar'>
|
<div className='compose__action-bar'>
|
||||||
|
|
|
@ -117,7 +117,10 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
|
|
||||||
handleFocus = () => {
|
handleFocus = () => {
|
||||||
if (this.composeForm && !this.props.singleColumn) {
|
if (this.composeForm && !this.props.singleColumn) {
|
||||||
this.composeForm.scrollIntoView();
|
const { left, right } = this.composeForm.getBoundingClientRect();
|
||||||
|
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
|
||||||
|
this.composeForm.scrollIntoView();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,12 +191,12 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='compose-form' ref={this.setRef}>
|
<div className='compose-form'>
|
||||||
<WarningContainer />
|
<WarningContainer />
|
||||||
|
|
||||||
<ReplyIndicatorContainer />
|
<ReplyIndicatorContainer />
|
||||||
|
|
||||||
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}>
|
<div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}>
|
||||||
<AutosuggestInput
|
<AutosuggestInput
|
||||||
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
|
||||||
value={this.props.spoilerText}
|
value={this.props.spoilerText}
|
||||||
|
|
|
@ -73,6 +73,19 @@ class PrivacyDropdownMenu extends React.PureComponent {
|
||||||
this.props.onChange(element.getAttribute('data-index'));
|
this.props.onChange(element.getAttribute('data-index'));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'Tab':
|
||||||
|
if (e.shiftKey) {
|
||||||
|
element = this.node.childNodes[index - 1] || this.node.lastChild;
|
||||||
|
} else {
|
||||||
|
element = this.node.childNodes[index + 1] || this.node.firstChild;
|
||||||
|
}
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
this.props.onChange(element.getAttribute('data-index'));
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
element = this.node.firstChild;
|
element = this.node.firstChild;
|
||||||
if (element) {
|
if (element) {
|
||||||
|
@ -180,6 +193,9 @@ class PrivacyDropdown extends React.PureComponent {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const { top } = target.getBoundingClientRect();
|
const { top } = target.getBoundingClientRect();
|
||||||
|
if (this.state.open && this.activeElement) {
|
||||||
|
this.activeElement.focus();
|
||||||
|
}
|
||||||
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
|
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
|
||||||
this.setState({ open: !this.state.open });
|
this.setState({ open: !this.state.open });
|
||||||
}
|
}
|
||||||
|
@ -202,7 +218,25 @@ class PrivacyDropdown extends React.PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
handleMouseDown = () => {
|
||||||
|
if (!this.state.open) {
|
||||||
|
this.activeElement = document.activeElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleButtonKeyDown = (e) => {
|
||||||
|
switch(e.key) {
|
||||||
|
case ' ':
|
||||||
|
case 'Enter':
|
||||||
|
this.handleMouseDown();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
handleClose = () => {
|
handleClose = () => {
|
||||||
|
if (this.state.open && this.activeElement) {
|
||||||
|
this.activeElement.focus();
|
||||||
|
}
|
||||||
this.setState({ open: false });
|
this.setState({ open: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -229,7 +263,7 @@ class PrivacyDropdown extends React.PureComponent {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
|
<div className={classNames('privacy-dropdown', placement, { active: open })} onKeyDown={this.handleKeyDown}>
|
||||||
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
|
<div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === (placement === 'bottom' ? 0 : (this.options.length - 1)) })}>
|
||||||
<IconButton
|
<IconButton
|
||||||
className='privacy-dropdown__value-icon'
|
className='privacy-dropdown__value-icon'
|
||||||
icon={valueOption.icon}
|
icon={valueOption.icon}
|
||||||
|
@ -239,6 +273,8 @@ class PrivacyDropdown extends React.PureComponent {
|
||||||
active={open}
|
active={open}
|
||||||
inverted
|
inverted
|
||||||
onClick={this.handleToggle}
|
onClick={this.handleToggle}
|
||||||
|
onMouseDown={this.handleMouseDown}
|
||||||
|
onKeyDown={this.handleButtonKeyDown}
|
||||||
style={{ height: null, lineHeight: '27px' }}
|
style={{ height: null, lineHeight: '27px' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,6 +7,7 @@ import StatusContainer from '../../../containers/status_container';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import Hashtag from '../../../components/hashtag';
|
import Hashtag from '../../../components/hashtag';
|
||||||
import Icon from 'mastodon/components/icon';
|
import Icon from 'mastodon/components/icon';
|
||||||
|
import { searchEnabled } from '../../../initial_state';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
|
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
|
||||||
|
@ -20,6 +21,7 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
suggestions: ImmutablePropTypes.list.isRequired,
|
suggestions: ImmutablePropTypes.list.isRequired,
|
||||||
fetchSuggestions: PropTypes.func.isRequired,
|
fetchSuggestions: PropTypes.func.isRequired,
|
||||||
dismissSuggestion: PropTypes.func.isRequired,
|
dismissSuggestion: PropTypes.func.isRequired,
|
||||||
|
searchTerm: PropTypes.string,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -28,7 +30,7 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, results, suggestions, dismissSuggestion } = this.props;
|
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
|
||||||
|
|
||||||
if (results.isEmpty() && !suggestions.isEmpty()) {
|
if (results.isEmpty() && !suggestions.isEmpty()) {
|
||||||
return (
|
return (
|
||||||
|
@ -76,6 +78,16 @@ class SearchResults extends ImmutablePureComponent {
|
||||||
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
|
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
} else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
|
||||||
|
statuses = (
|
||||||
|
<div className='search-results__section'>
|
||||||
|
<h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
|
||||||
|
|
||||||
|
<div className='search-results__info'>
|
||||||
|
<FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (results.get('hashtags') && results.get('hashtags').size > 0) {
|
if (results.get('hashtags') && results.get('hashtags').size > 0) {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestion
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
results: state.getIn(['search', 'results']),
|
results: state.getIn(['search', 'results']),
|
||||||
suggestions: state.getIn(['suggestions', 'items']),
|
suggestions: state.getIn(['suggestions', 'items']),
|
||||||
|
searchTerm: state.getIn(['search', 'searchTerm']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
|
|
@ -29,7 +29,7 @@ const emojify = (str, customEmojis = {}) => {
|
||||||
// if you want additional emoji handler, add statements below which set replacement and return true.
|
// if you want additional emoji handler, add statements below which set replacement and return true.
|
||||||
if (shortname in customEmojis) {
|
if (shortname in customEmojis) {
|
||||||
const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
|
const filename = autoPlayGif ? customEmojis[shortname].url : customEmojis[shortname].static_url;
|
||||||
replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${filename}" />`;
|
replacement = `<img draggable="false" class="emojione custom-emoji" alt="${shortname}" title="${shortname}" src="${filename}" data-original="${customEmojis[shortname].url}" data-static="${customEmojis[shortname].static_url}" />`;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -11,7 +11,7 @@ const messages = defineMessages({
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
value: state.getIn(['listEditor', 'title']),
|
value: state.getIn(['listEditor', 'title']),
|
||||||
disabled: !state.getIn(['listEditor', 'isChanged']),
|
disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
|
|
|
@ -66,7 +66,7 @@ class NewListForm extends React.PureComponent {
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
disabled={disabled}
|
disabled={disabled || !value}
|
||||||
icon='plus'
|
icon='plus'
|
||||||
title={title}
|
title={title}
|
||||||
onClick={this.handleClick}
|
onClick={this.handleClick}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { connect } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
import { fetchStatus } from '../../actions/statuses';
|
import { fetchStatus } from '../../actions/statuses';
|
||||||
import MissingIndicator from '../../components/missing_indicator';
|
import MissingIndicator from '../../components/missing_indicator';
|
||||||
import DetailedStatus from './components/detailed_status';
|
import DetailedStatus from './components/detailed_status';
|
||||||
|
@ -63,39 +64,58 @@ const messages = defineMessages({
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
|
|
||||||
|
const getAncestorsIds = createSelector([
|
||||||
|
(_, { id }) => id,
|
||||||
|
state => state.getIn(['contexts', 'inReplyTos']),
|
||||||
|
], (statusId, inReplyTos) => {
|
||||||
|
let ancestorsIds = Immutable.List();
|
||||||
|
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
||||||
|
let id = statusId;
|
||||||
|
|
||||||
|
while (id) {
|
||||||
|
mutable.unshift(id);
|
||||||
|
id = inReplyTos.get(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ancestorsIds;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDescendantsIds = createSelector([
|
||||||
|
(_, { id }) => id,
|
||||||
|
state => state.getIn(['contexts', 'replies']),
|
||||||
|
], (statusId, contextReplies) => {
|
||||||
|
let descendantsIds = Immutable.List();
|
||||||
|
descendantsIds = descendantsIds.withMutations(mutable => {
|
||||||
|
const ids = [statusId];
|
||||||
|
|
||||||
|
while (ids.length > 0) {
|
||||||
|
let id = ids.shift();
|
||||||
|
const replies = contextReplies.get(id);
|
||||||
|
|
||||||
|
if (statusId !== id) {
|
||||||
|
mutable.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replies) {
|
||||||
|
replies.reverse().forEach(reply => {
|
||||||
|
ids.unshift(reply);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return descendantsIds;
|
||||||
|
});
|
||||||
|
|
||||||
const mapStateToProps = (state, props) => {
|
const mapStateToProps = (state, props) => {
|
||||||
const status = getStatus(state, { id: props.params.statusId });
|
const status = getStatus(state, { id: props.params.statusId });
|
||||||
let ancestorsIds = Immutable.List();
|
let ancestorsIds = Immutable.List();
|
||||||
let descendantsIds = Immutable.List();
|
let descendantsIds = Immutable.List();
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
ancestorsIds = ancestorsIds.withMutations(mutable => {
|
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
|
||||||
let id = status.get('in_reply_to_id');
|
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
|
||||||
|
|
||||||
while (id) {
|
|
||||||
mutable.unshift(id);
|
|
||||||
id = state.getIn(['contexts', 'inReplyTos', id]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
descendantsIds = descendantsIds.withMutations(mutable => {
|
|
||||||
const ids = [status.get('id')];
|
|
||||||
|
|
||||||
while (ids.length > 0) {
|
|
||||||
let id = ids.shift();
|
|
||||||
const replies = state.getIn(['contexts', 'replies', id]);
|
|
||||||
|
|
||||||
if (status.get('id') !== id) {
|
|
||||||
mutable.push(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replies) {
|
|
||||||
replies.reverse().forEach(reply => {
|
|
||||||
ids.unshift(reply);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -110,6 +110,11 @@ class ColumnsArea extends ImmutablePureComponent {
|
||||||
// React-router does this for us, but too late, feeling laggy.
|
// React-router does this for us, but too late, feeling laggy.
|
||||||
document.querySelector(currentLinkSelector).classList.remove('active');
|
document.querySelector(currentLinkSelector).classList.remove('active');
|
||||||
document.querySelector(nextLinkSelector).classList.add('active');
|
document.querySelector(nextLinkSelector).classList.add('active');
|
||||||
|
|
||||||
|
if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
|
||||||
|
this.context.router.history.push(getLink(this.pendingIndex));
|
||||||
|
this.pendingIndex = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleAnimationEnd = () => {
|
handleAnimationEnd = () => {
|
||||||
|
@ -160,7 +165,6 @@ class ColumnsArea extends ImmutablePureComponent {
|
||||||
const { shouldAnimate } = this.state;
|
const { shouldAnimate } = this.state;
|
||||||
|
|
||||||
const columnIndex = getIndex(this.context.router.history.location.pathname);
|
const columnIndex = getIndex(this.context.router.history.location.pathname);
|
||||||
this.pendingIndex = null;
|
|
||||||
|
|
||||||
if (singleColumn) {
|
if (singleColumn) {
|
||||||
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
|
const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
|
||||||
|
|
|
@ -195,6 +195,12 @@ const expandMentions = status => {
|
||||||
return fragment.innerHTML;
|
return fragment.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const expiresInFromExpiresAt = expires_at => {
|
||||||
|
if (!expires_at) return 24 * 3600;
|
||||||
|
const delta = (new Date(expires_at).getTime() - Date.now()) / 1000;
|
||||||
|
return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
|
||||||
|
};
|
||||||
|
|
||||||
export default function compose(state = initialState, action) {
|
export default function compose(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case STORE_HYDRATE:
|
case STORE_HYDRATE:
|
||||||
|
@ -224,6 +230,7 @@ export default function compose(state = initialState, action) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
case COMPOSE_SPOILER_TEXT_CHANGE:
|
case COMPOSE_SPOILER_TEXT_CHANGE:
|
||||||
|
if (!state.get('spoiler')) return state;
|
||||||
return state
|
return state
|
||||||
.set('spoiler_text', action.text)
|
.set('spoiler_text', action.text)
|
||||||
.set('idempotencyKey', uuid());
|
.set('idempotencyKey', uuid());
|
||||||
|
@ -352,7 +359,7 @@ export default function compose(state = initialState, action) {
|
||||||
map.set('poll', ImmutableMap({
|
map.set('poll', ImmutableMap({
|
||||||
options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
|
options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
|
||||||
multiple: action.status.getIn(['poll', 'multiple']),
|
multiple: action.status.getIn(['poll', 'multiple']),
|
||||||
expires_in: 24 * 3600,
|
expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -8,6 +8,8 @@ import {
|
||||||
CONVERSATIONS_UPDATE,
|
CONVERSATIONS_UPDATE,
|
||||||
CONVERSATIONS_READ,
|
CONVERSATIONS_READ,
|
||||||
} from '../actions/conversations';
|
} from '../actions/conversations';
|
||||||
|
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
|
||||||
|
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
|
||||||
import compareId from '../compare_id';
|
import compareId from '../compare_id';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
|
@ -74,6 +76,10 @@ const expandNormalizedConversations = (state, conversations, next, isLoadingRece
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filterConversations = (state, accountIds) => {
|
||||||
|
return state.update('items', list => list.filterNot(item => item.get('accounts').some(accountId => accountIds.includes(accountId))));
|
||||||
|
};
|
||||||
|
|
||||||
export default function conversations(state = initialState, action) {
|
export default function conversations(state = initialState, action) {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case CONVERSATIONS_FETCH_REQUEST:
|
case CONVERSATIONS_FETCH_REQUEST:
|
||||||
|
@ -96,6 +102,11 @@ export default function conversations(state = initialState, action) {
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}));
|
}));
|
||||||
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
|
return filterConversations(state, [action.relationship.id]);
|
||||||
|
case DOMAIN_BLOCK_SUCCESS:
|
||||||
|
return filterConversations(state, action.accounts);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,7 +10,7 @@ export default function modal(state = initialState, action) {
|
||||||
case MODAL_OPEN:
|
case MODAL_OPEN:
|
||||||
return { modalType: action.modalType, modalProps: action.modalProps };
|
return { modalType: action.modalType, modalProps: action.modalProps };
|
||||||
case MODAL_CLOSE:
|
case MODAL_CLOSE:
|
||||||
return initialState;
|
return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
ACCOUNT_BLOCK_SUCCESS,
|
ACCOUNT_BLOCK_SUCCESS,
|
||||||
ACCOUNT_MUTE_SUCCESS,
|
ACCOUNT_MUTE_SUCCESS,
|
||||||
} from '../actions/accounts';
|
} from '../actions/accounts';
|
||||||
|
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
|
||||||
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
|
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
|
||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
import compareId from '../compare_id';
|
import compareId from '../compare_id';
|
||||||
|
@ -77,8 +78,8 @@ const expandNormalizedNotifications = (state, notifications, next) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterNotifications = (state, relationship) => {
|
const filterNotifications = (state, accountIds) => {
|
||||||
return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id));
|
return state.update('items', list => list.filterNot(item => item !== null && accountIds.includes(item.get('account'))));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateTop = (state, top) => {
|
const updateTop = (state, top) => {
|
||||||
|
@ -108,9 +109,11 @@ export default function notifications(state = initialState, action) {
|
||||||
case NOTIFICATIONS_EXPAND_SUCCESS:
|
case NOTIFICATIONS_EXPAND_SUCCESS:
|
||||||
return expandNormalizedNotifications(state, action.notifications, action.next);
|
return expandNormalizedNotifications(state, action.notifications, action.next);
|
||||||
case ACCOUNT_BLOCK_SUCCESS:
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
return filterNotifications(state, action.relationship);
|
return filterNotifications(state, [action.relationship.id]);
|
||||||
case ACCOUNT_MUTE_SUCCESS:
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state;
|
return action.relationship.muting_notifications ? filterNotifications(state, [action.relationship.id]) : state;
|
||||||
|
case DOMAIN_BLOCK_SUCCESS:
|
||||||
|
return filterNotifications(state, action.accounts);
|
||||||
case NOTIFICATIONS_CLEAR:
|
case NOTIFICATIONS_CLEAR:
|
||||||
return state.set('items', ImmutableList()).set('hasMore', false);
|
return state.set('items', ImmutableList()).set('hasMore', false);
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
|
|
|
@ -16,6 +16,7 @@ const initialState = ImmutableMap({
|
||||||
submitted: false,
|
submitted: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
results: ImmutableMap(),
|
results: ImmutableMap(),
|
||||||
|
searchTerm: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function search(state = initialState, action) {
|
export default function search(state = initialState, action) {
|
||||||
|
@ -40,7 +41,7 @@ export default function search(state = initialState, action) {
|
||||||
accounts: ImmutableList(action.results.accounts.map(item => item.id)),
|
accounts: ImmutableList(action.results.accounts.map(item => item.id)),
|
||||||
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
||||||
hashtags: fromJS(action.results.hashtags),
|
hashtags: fromJS(action.results.hashtags),
|
||||||
})).set('submitted', true);
|
})).set('submitted', true).set('searchTerm', action.searchTerm);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@ import {
|
||||||
SUGGESTIONS_FETCH_FAIL,
|
SUGGESTIONS_FETCH_FAIL,
|
||||||
SUGGESTIONS_DISMISS,
|
SUGGESTIONS_DISMISS,
|
||||||
} from '../actions/suggestions';
|
} from '../actions/suggestions';
|
||||||
|
import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS } from 'mastodon/actions/accounts';
|
||||||
|
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
|
@ -24,6 +26,11 @@ export default function suggestionsReducer(state = initialState, action) {
|
||||||
return state.set('isLoading', false);
|
return state.set('isLoading', false);
|
||||||
case SUGGESTIONS_DISMISS:
|
case SUGGESTIONS_DISMISS:
|
||||||
return state.update('items', list => list.filterNot(id => id === action.id));
|
return state.update('items', list => list.filterNot(id => id === action.id));
|
||||||
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
|
return state.update('items', list => list.filterNot(id => id === action.relationship.id));
|
||||||
|
case DOMAIN_BLOCK_SUCCESS:
|
||||||
|
return state.update('items', list => list.filterNot(id => action.accounts.includes(id)));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { List as ImmutableList } from 'immutable';
|
import { List as ImmutableList, is } from 'immutable';
|
||||||
import { me } from '../initial_state';
|
import { me } from '../initial_state';
|
||||||
|
|
||||||
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
|
const getAccountBase = (state, id) => state.getIn(['accounts', id], null);
|
||||||
|
@ -36,12 +36,10 @@ const toServerSideType = columnType => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFilters = (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
|
|
||||||
|
|
||||||
const escapeRegExp = string =>
|
const escapeRegExp = string =>
|
||||||
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||||
|
|
||||||
export const regexFromFilters = filters => {
|
const regexFromFilters = filters => {
|
||||||
if (filters.size === 0) {
|
if (filters.size === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -63,6 +61,27 @@ export const regexFromFilters = filters => {
|
||||||
}).join('|'), 'i');
|
}).join('|'), 'i');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Memoize the filter regexps for each valid server contextType
|
||||||
|
const makeGetFiltersRegex = () => {
|
||||||
|
let memo = {};
|
||||||
|
|
||||||
|
return (state, { contextType }) => {
|
||||||
|
if (!contextType) return ImmutableList();
|
||||||
|
|
||||||
|
const serverSideType = toServerSideType(contextType);
|
||||||
|
const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
|
||||||
|
|
||||||
|
if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) {
|
||||||
|
const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
|
||||||
|
const regex = regexFromFilters(filters);
|
||||||
|
memo[serverSideType] = { filters: filters, results: [dropRegex, regex] };
|
||||||
|
}
|
||||||
|
return memo[serverSideType].results;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFiltersRegex = makeGetFiltersRegex();
|
||||||
|
|
||||||
export const makeGetStatus = () => {
|
export const makeGetStatus = () => {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
[
|
[
|
||||||
|
@ -70,10 +89,10 @@ export const makeGetStatus = () => {
|
||||||
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
|
(state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
|
||||||
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
|
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
|
||||||
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
|
(state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
|
||||||
getFilters,
|
getFiltersRegex,
|
||||||
],
|
],
|
||||||
|
|
||||||
(statusBase, statusReblog, accountBase, accountReblog, filters) => {
|
(statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
|
||||||
if (!statusBase) {
|
if (!statusBase) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@ -84,12 +103,12 @@ export const makeGetStatus = () => {
|
||||||
statusReblog = null;
|
statusReblog = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropRegex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters.filter(filter => filter.get('irreversible')));
|
const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
|
||||||
if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
|
if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const regex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters);
|
const regex = (accountReblog || accountBase).get('id') !== me && filtersRegex[1];
|
||||||
const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'));
|
const filtered = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'));
|
||||||
|
|
||||||
return statusBase.withMutations(map => {
|
return statusBase.withMutations(map => {
|
||||||
|
|
|
@ -67,6 +67,14 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) =
|
||||||
|
|
||||||
context.drawImage(img, 0, 0, width, height);
|
context.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// The Tor Browser and maybe other browsers may prevent reading from canvas
|
||||||
|
// and return an all-white image instead. Assume reading failed if the resized
|
||||||
|
// image is perfectly white.
|
||||||
|
const imageData = context.getImageData(0, 0, width, height);
|
||||||
|
if (imageData.every(value => value === 255)) {
|
||||||
|
throw 'Failed to read from canvas';
|
||||||
|
}
|
||||||
|
|
||||||
canvas.toBlob(resolve, type);
|
canvas.toBlob(resolve, type);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -44,6 +44,12 @@ function main() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getEmojiAnimationHandler = (swapTo) => {
|
||||||
|
return ({ target }) => {
|
||||||
|
target.src = target.getAttribute(swapTo);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
ready(() => {
|
ready(() => {
|
||||||
const locale = document.documentElement.lang;
|
const locale = document.documentElement.lang;
|
||||||
|
|
||||||
|
@ -116,6 +122,9 @@ function main() {
|
||||||
document.head.appendChild(scrollbarWidthStyle);
|
document.head.appendChild(scrollbarWidthStyle);
|
||||||
scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0);
|
scrollbarWidthStyle.sheet.insertRule(`body.with-modals--active { margin-right: ${scrollbarWidth}px; }`, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
delegate(document, '.custom-emoji', 'mouseover', getEmojiAnimationHandler('data-original'));
|
||||||
|
delegate(document, '.custom-emoji', 'mouseout', getEmojiAnimationHandler('data-static'));
|
||||||
});
|
});
|
||||||
|
|
||||||
delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
|
delegate(document, '.webapp-btn', 'click', ({ target, button }) => {
|
||||||
|
@ -178,7 +187,7 @@ function main() {
|
||||||
return ({ target }) => {
|
return ({ target }) => {
|
||||||
const swapSrc = target.getAttribute(swapTo);
|
const swapSrc = target.getAttribute(swapTo);
|
||||||
//only change the img source if autoplay is off and the image src is actually different
|
//only change the img source if autoplay is off and the image src is actually different
|
||||||
if(target.getAttribute('data-autoplay') === 'false' && target.src !== swapSrc) {
|
if(target.getAttribute('data-autoplay') !== 'true' && target.src !== swapSrc) {
|
||||||
target.src = swapSrc;
|
target.src = swapSrc;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -3996,6 +3996,11 @@ a.status-card.compact:hover {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-results__info {
|
||||||
|
padding: 10px;
|
||||||
|
color: $secondary-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-root {
|
.modal-root {
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: opacity 0.3s linear;
|
transition: opacity 0.3s linear;
|
||||||
|
|
|
@ -35,7 +35,7 @@ class FeedManager
|
||||||
end
|
end
|
||||||
|
|
||||||
def unpush_from_home(account, status)
|
def unpush_from_home(account, status)
|
||||||
return false unless remove_from_feed(:home, account.id, status)
|
return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?)
|
||||||
redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
|
redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
@ -53,7 +53,7 @@ class FeedManager
|
||||||
end
|
end
|
||||||
|
|
||||||
def unpush_from_list(list, status)
|
def unpush_from_list(list, status)
|
||||||
return false unless remove_from_feed(:list, list.id, status)
|
return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?)
|
||||||
redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
|
redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
@ -105,7 +105,7 @@ class FeedManager
|
||||||
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
|
oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0
|
||||||
|
|
||||||
from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status|
|
from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status|
|
||||||
remove_from_feed(:home, into_account.id, status)
|
remove_from_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -220,7 +220,8 @@ class FeedManager
|
||||||
status = status.reblog if status.reblog?
|
status = status.reblog if status.reblog?
|
||||||
|
|
||||||
!combined_regex.match(Formatter.instance.plaintext(status)).nil? ||
|
!combined_regex.match(Formatter.instance.plaintext(status)).nil? ||
|
||||||
(status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?)
|
(status.spoiler_text.present? && !combined_regex.match(status.spoiler_text).nil?) ||
|
||||||
|
(status.preloadable_poll && !combined_regex.match(status.preloadable_poll.options.join("\n\n")).nil?)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Adds a status to an account's feed, returning true if a status was
|
# Adds a status to an account's feed, returning true if a status was
|
||||||
|
@ -274,10 +275,11 @@ class FeedManager
|
||||||
# with reblogs, and returning true if a status was removed. As with
|
# with reblogs, and returning true if a status was removed. As with
|
||||||
# `add_to_feed`, this does not trigger push updates, so callers must
|
# `add_to_feed`, this does not trigger push updates, so callers must
|
||||||
# do so if appropriate.
|
# do so if appropriate.
|
||||||
def remove_from_feed(timeline_type, account_id, status)
|
def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true)
|
||||||
timeline_key = key(timeline_type, account_id)
|
timeline_key = key(timeline_type, account_id)
|
||||||
|
reblog_key = key(timeline_type, account_id, 'reblogs')
|
||||||
|
|
||||||
if status.reblog?
|
if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs)
|
||||||
# 1. If the reblogging status is not in the feed, stop.
|
# 1. If the reblogging status is not in the feed, stop.
|
||||||
status_rank = redis.zrevrank(timeline_key, status.id)
|
status_rank = redis.zrevrank(timeline_key, status.id)
|
||||||
return false if status_rank.nil?
|
return false if status_rank.nil?
|
||||||
|
@ -286,6 +288,7 @@ class FeedManager
|
||||||
reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
|
reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
|
||||||
|
|
||||||
redis.srem(reblog_set_key, status.id)
|
redis.srem(reblog_set_key, status.id)
|
||||||
|
redis.zrem(reblog_key, status.reblog_of_id)
|
||||||
# 3. Re-insert another reblog or original into the feed if one
|
# 3. Re-insert another reblog or original into the feed if one
|
||||||
# remains in the set. We could pick a random element, but this
|
# remains in the set. We could pick a random element, but this
|
||||||
# set should generally be small, and it seems ideal to show the
|
# set should generally be small, and it seems ideal to show the
|
||||||
|
@ -293,12 +296,14 @@ class FeedManager
|
||||||
other_reblog = redis.smembers(reblog_set_key).map(&:to_i).min
|
other_reblog = redis.smembers(reblog_set_key).map(&:to_i).min
|
||||||
|
|
||||||
redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog
|
redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog
|
||||||
|
redis.zadd(reblog_key, other_reblog, status.reblog_of_id) if other_reblog
|
||||||
|
|
||||||
# 4. Remove the reblogging status from the feed (as normal)
|
# 4. Remove the reblogging status from the feed (as normal)
|
||||||
# (outside conditional)
|
# (outside conditional)
|
||||||
else
|
else
|
||||||
# If the original is getting deleted, no use for reblog references
|
# If the original is getting deleted, no use for reblog references
|
||||||
redis.del(key(timeline_type, account_id, "reblogs:#{status.id}"))
|
redis.del(key(timeline_type, account_id, "reblogs:#{status.id}"))
|
||||||
|
redis.zrem(reblog_key, status.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
redis.zrem(timeline_key, status.id)
|
redis.zrem(timeline_key, status.id)
|
||||||
|
|
|
@ -137,11 +137,7 @@ class Formatter
|
||||||
def encode_custom_emojis(html, emojis, animate = false)
|
def encode_custom_emojis(html, emojis, animate = false)
|
||||||
return html if emojis.empty?
|
return html if emojis.empty?
|
||||||
|
|
||||||
emoji_map = if animate
|
emoji_map = emojis.each_with_object({}) { |e, h| h[e.shortcode] = [full_asset_url(e.image.url), full_asset_url(e.image.url(:static))] }
|
||||||
emojis.each_with_object({}) { |e, h| h[e.shortcode] = full_asset_url(e.image.url) }
|
|
||||||
else
|
|
||||||
emojis.each_with_object({}) { |e, h| h[e.shortcode] = full_asset_url(e.image.url(:static)) }
|
|
||||||
end
|
|
||||||
|
|
||||||
i = -1
|
i = -1
|
||||||
tag_open_index = nil
|
tag_open_index = nil
|
||||||
|
@ -157,7 +153,14 @@ class Formatter
|
||||||
emoji = emoji_map[shortcode]
|
emoji = emoji_map[shortcode]
|
||||||
|
|
||||||
if emoji
|
if emoji
|
||||||
replacement = "<img draggable=\"false\" class=\"emojione\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(emoji)}\" />"
|
original_url, static_url = emoji
|
||||||
|
replacement = begin
|
||||||
|
if animate
|
||||||
|
"<img draggable=\"false\" class=\"emojione\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(original_url)}\" />"
|
||||||
|
else
|
||||||
|
"<img draggable=\"false\" class=\"emojione custom-emoji\" alt=\":#{encode(shortcode)}:\" title=\":#{encode(shortcode)}:\" src=\"#{encode(static_url)}\" data-original=\"#{original_url}\" data-static=\"#{static_url}\" />"
|
||||||
|
end
|
||||||
|
end
|
||||||
before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : ''
|
before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : ''
|
||||||
html = before_html + replacement + html[i + 1..-1]
|
html = before_html + replacement + html[i + 1..-1]
|
||||||
i += replacement.size - (shortcode.size + 2) - 1
|
i += replacement.size - (shortcode.size + 2) - 1
|
||||||
|
|
|
@ -69,7 +69,7 @@ class LanguageDetector
|
||||||
new_text = remove_html(text)
|
new_text = remove_html(text)
|
||||||
new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
|
new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
|
||||||
new_text.gsub!(Account::MENTION_RE, '')
|
new_text.gsub!(Account::MENTION_RE, '')
|
||||||
new_text.gsub!(Tag::HASHTAG_RE, '')
|
new_text.gsub!(Tag::HASHTAG_RE) { |string| string.gsub(/[#_]/, '#' => '', '_' => ' ').gsub(/[a-z][A-Z]|[a-zA-Z][\d]/) { |s| s.insert(1, ' ') }.downcase }
|
||||||
new_text.gsub!(/:#{CustomEmoji::SHORTCODE_RE_FRAGMENT}:/, '')
|
new_text.gsub!(/:#{CustomEmoji::SHORTCODE_RE_FRAGMENT}:/, '')
|
||||||
new_text.gsub!(/\s+/, ' ')
|
new_text.gsub!(/\s+/, ' ')
|
||||||
new_text
|
new_text
|
||||||
|
|
|
@ -25,6 +25,8 @@ class Sanitize
|
||||||
case env[:node_name]
|
case env[:node_name]
|
||||||
when 'li'
|
when 'li'
|
||||||
env[:node].traverse do |node|
|
env[:node].traverse do |node|
|
||||||
|
next unless %w(p ul ol li).include?(node.name)
|
||||||
|
|
||||||
node.add_next_sibling('<br>') if node.next_sibling
|
node.add_next_sibling('<br>') if node.next_sibling
|
||||||
node.replace(node.children) unless node.text?
|
node.replace(node.children) unless node.text?
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,9 +3,11 @@
|
||||||
class SidekiqErrorHandler
|
class SidekiqErrorHandler
|
||||||
def call(*)
|
def call(*)
|
||||||
yield
|
yield
|
||||||
rescue Mastodon::HostValidationError => e
|
rescue Mastodon::HostValidationError
|
||||||
Rails.logger.error "#{e.class}: #{e.message}"
|
|
||||||
Rails.logger.error e.backtrace.join("\n")
|
|
||||||
# Do not retry
|
# Do not retry
|
||||||
|
ensure
|
||||||
|
socket = Thread.current[:statsd_socket]
|
||||||
|
socket&.close
|
||||||
|
Thread.current[:statsd_socket] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,7 +15,7 @@ class AccountDomainBlock < ApplicationRecord
|
||||||
include DomainNormalizable
|
include DomainNormalizable
|
||||||
|
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
validates :domain, presence: true, uniqueness: { scope: :account_id }
|
validates :domain, presence: true, uniqueness: { scope: :account_id }, domain: true
|
||||||
|
|
||||||
after_commit :remove_blocking_cache
|
after_commit :remove_blocking_cache
|
||||||
after_commit :remove_relationship_cache
|
after_commit :remove_relationship_cache
|
||||||
|
|
|
@ -17,10 +17,13 @@ class Admin::AccountAction
|
||||||
:type,
|
:type,
|
||||||
:text,
|
:text,
|
||||||
:report_id,
|
:report_id,
|
||||||
:warning_preset_id,
|
:warning_preset_id
|
||||||
:send_email_notification
|
|
||||||
|
|
||||||
attr_reader :warning
|
attr_reader :warning, :send_email_notification
|
||||||
|
|
||||||
|
def send_email_notification=(value)
|
||||||
|
@send_email_notification = ActiveModel::Type::Boolean.new.cast(value)
|
||||||
|
end
|
||||||
|
|
||||||
def save!
|
def save!
|
||||||
ApplicationRecord.transaction do
|
ApplicationRecord.transaction do
|
||||||
|
|
|
@ -60,7 +60,9 @@ module Attachmentable
|
||||||
end
|
end
|
||||||
|
|
||||||
def calculated_content_type(attachment)
|
def calculated_content_type(attachment)
|
||||||
Paperclip.run('file', '-b --mime :file', file: attachment.queued_for_write[:original].path).split(/[:;\s]+/).first.chomp
|
content_type = Paperclip.run('file', '-b --mime :file', file: attachment.queued_for_write[:original].path).split(/[:;\s]+/).first.chomp
|
||||||
|
content_type = 'video/mp4' if content_type == 'video/x-m4v'
|
||||||
|
content_type
|
||||||
rescue Terrapin::CommandLineError
|
rescue Terrapin::CommandLineError
|
||||||
''
|
''
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,7 +4,7 @@ module DomainNormalizable
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
included do
|
included do
|
||||||
before_validation :normalize_domain
|
before_save :normalize_domain
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -27,13 +27,15 @@ class CustomEmoji < ApplicationRecord
|
||||||
:(#{SHORTCODE_RE_FRAGMENT}):
|
:(#{SHORTCODE_RE_FRAGMENT}):
|
||||||
(?=[^[:alnum:]:]|$)/x
|
(?=[^[:alnum:]:]|$)/x
|
||||||
|
|
||||||
|
IMAGE_MIME_TYPES = %w(image/png image/gif image/webp).freeze
|
||||||
|
|
||||||
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
|
has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
|
||||||
|
|
||||||
has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
|
has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
|
||||||
|
|
||||||
before_validation :downcase_domain
|
before_validation :downcase_domain
|
||||||
|
|
||||||
validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { less_than: LIMIT }
|
validates_attachment :image, content_type: { content_type: IMAGE_MIME_TYPES }, presence: true, size: { less_than: LIMIT }
|
||||||
validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 }
|
validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 }
|
||||||
|
|
||||||
scope :local, -> { where(domain: nil) }
|
scope :local, -> { where(domain: nil) }
|
||||||
|
|
|
@ -35,6 +35,13 @@ class CustomFilter < ApplicationRecord
|
||||||
before_validation :clean_up_contexts
|
before_validation :clean_up_contexts
|
||||||
after_commit :remove_cache
|
after_commit :remove_cache
|
||||||
|
|
||||||
|
def expires_in
|
||||||
|
return @expires_in if defined?(@expires_in)
|
||||||
|
return nil if expires_at.nil?
|
||||||
|
|
||||||
|
[30.minutes, 1.hour, 6.hours, 12.hours, 1.day, 1.week].find { |expires_in| expires_in.from_now >= expires_at }
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def clean_up_contexts
|
def clean_up_contexts
|
||||||
|
|
|
@ -17,7 +17,7 @@ class DomainBlock < ApplicationRecord
|
||||||
|
|
||||||
enum severity: [:silence, :suspend, :noop]
|
enum severity: [:silence, :suspend, :noop]
|
||||||
|
|
||||||
validates :domain, presence: true, uniqueness: true
|
validates :domain, presence: true, uniqueness: true, domain: true
|
||||||
|
|
||||||
has_many :accounts, foreign_key: :domain, primary_key: :domain
|
has_many :accounts, foreign_key: :domain, primary_key: :domain
|
||||||
delegate :count, to: :accounts, prefix: true
|
delegate :count, to: :accounts, prefix: true
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
class EmailDomainBlock < ApplicationRecord
|
class EmailDomainBlock < ApplicationRecord
|
||||||
include DomainNormalizable
|
include DomainNormalizable
|
||||||
|
|
||||||
validates :domain, presence: true, uniqueness: true
|
validates :domain, presence: true, uniqueness: true, domain: true
|
||||||
|
|
||||||
def self.block?(email)
|
def self.block?(email)
|
||||||
_, domain = email.split('@', 2)
|
_, domain = email.split('@', 2)
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
class Invite < ApplicationRecord
|
class Invite < ApplicationRecord
|
||||||
include Expireable
|
include Expireable
|
||||||
|
|
||||||
belongs_to :user
|
belongs_to :user, inverse_of: :invites
|
||||||
has_many :users, inverse_of: :invite
|
has_many :users, inverse_of: :invite
|
||||||
|
|
||||||
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
|
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
|
||||||
|
@ -25,7 +25,7 @@ class Invite < ApplicationRecord
|
||||||
before_validation :set_code
|
before_validation :set_code
|
||||||
|
|
||||||
def valid_for_use?
|
def valid_for_use?
|
||||||
(max_uses.nil? || uses < max_uses) && !expired?
|
(max_uses.nil? || uses < max_uses) && !expired? && !(user.nil? || user.disabled?)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -113,7 +113,7 @@ class MediaAttachment < ApplicationRecord
|
||||||
has_attached_file :file,
|
has_attached_file :file,
|
||||||
styles: ->(f) { file_styles f },
|
styles: ->(f) { file_styles f },
|
||||||
processors: ->(f) { file_processors f },
|
processors: ->(f) { file_processors f },
|
||||||
convert_options: { all: '-quality 90 -strip' }
|
convert_options: { all: '-quality 90 -strip +set modify-date +set create-date' }
|
||||||
|
|
||||||
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
|
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
|
||||||
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?
|
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?
|
||||||
|
|
|
@ -77,7 +77,7 @@ class Status < ApplicationRecord
|
||||||
default_scope { recent }
|
default_scope { recent }
|
||||||
|
|
||||||
scope :recent, -> { reorder(id: :desc) }
|
scope :recent, -> { reorder(id: :desc) }
|
||||||
scope :remote, -> { where(local: false).or(where.not(uri: nil)) }
|
scope :remote, -> { where(local: false).where.not(uri: nil) }
|
||||||
scope :local, -> { where(local: true).or(where(uri: nil)) }
|
scope :local, -> { where(local: true).or(where(uri: nil)) }
|
||||||
|
|
||||||
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
|
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
|
||||||
|
|
|
@ -17,10 +17,10 @@ class Tag < ApplicationRecord
|
||||||
has_many :featured_tags, dependent: :destroy, inverse_of: :tag
|
has_many :featured_tags, dependent: :destroy, inverse_of: :tag
|
||||||
has_one :account_tag_stat, dependent: :destroy
|
has_one :account_tag_stat, dependent: :destroy
|
||||||
|
|
||||||
HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_·][[:word:]_]*'
|
HASHTAG_NAME_RE = '([[:word:]_][[:word:]_·]*[[:alpha:]_·][[:word:]_·]*[[:word:]_])|([[:word:]_]*[[:alpha:]][[:word:]_]*)'
|
||||||
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
|
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
|
||||||
|
|
||||||
validates :name, presence: true, uniqueness: true, format: { with: /\A#{HASHTAG_NAME_RE}\z/i }
|
validates :name, presence: true, uniqueness: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
|
||||||
|
|
||||||
scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
|
scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
|
||||||
scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
|
scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
|
||||||
|
|
|
@ -73,6 +73,7 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
|
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
|
||||||
has_many :backups, inverse_of: :user
|
has_many :backups, inverse_of: :user
|
||||||
|
has_many :invites, inverse_of: :user
|
||||||
|
|
||||||
has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
|
has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
|
||||||
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
|
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
|
||||||
|
|
|
@ -14,7 +14,7 @@ class ActivityPub::UpdatePollSerializer < ActivityPub::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def actor
|
def actor
|
||||||
ActivityPub::TagManager.instance.uri_for(object)
|
ActivityPub::TagManager.instance.uri_for(object.account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def to
|
def to
|
||||||
|
|
|
@ -4,7 +4,7 @@ class REST::WebPushSubscriptionSerializer < ActiveModel::Serializer
|
||||||
attributes :id, :endpoint, :alerts, :server_key
|
attributes :id, :endpoint, :alerts, :server_key
|
||||||
|
|
||||||
def alerts
|
def alerts
|
||||||
object.data&.dig('alerts') || {}
|
(object.data&.dig('alerts') || {}).each_with_object({}) { |(k, v), h| h[k] = ActiveModel::Type::Boolean.new.cast(v) }
|
||||||
end
|
end
|
||||||
|
|
||||||
def server_key
|
def server_key
|
||||||
|
|
|
@ -15,6 +15,8 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||||
@domain = domain
|
@domain = domain
|
||||||
@collections = {}
|
@collections = {}
|
||||||
|
|
||||||
|
return if auto_suspend?
|
||||||
|
|
||||||
RedisLock.acquire(lock_options) do |lock|
|
RedisLock.acquire(lock_options) do |lock|
|
||||||
if lock.acquired?
|
if lock.acquired?
|
||||||
@account = Account.find_remote(@username, @domain)
|
@account = Account.find_remote(@username, @domain)
|
||||||
|
@ -55,7 +57,7 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||||
@account.domain = @domain
|
@account.domain = @domain
|
||||||
@account.private_key = nil
|
@account.private_key = nil
|
||||||
@account.suspended_at = domain_block.created_at if auto_suspend?
|
@account.suspended_at = domain_block.created_at if auto_suspend?
|
||||||
@account.silenced_at = domain_block.created_at if auto_silence?
|
@account.silenced_at = domain_block.created_at if auto_silence?
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_account
|
def update_account
|
||||||
|
|
|
@ -10,12 +10,24 @@ class AfterBlockDomainFromAccountService < BaseService
|
||||||
@account = account
|
@account = account
|
||||||
@domain = domain
|
@domain = domain
|
||||||
|
|
||||||
|
clear_notifications!
|
||||||
|
remove_follows!
|
||||||
reject_existing_followers!
|
reject_existing_followers!
|
||||||
reject_pending_follow_requests!
|
reject_pending_follow_requests!
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def remove_follows!
|
||||||
|
@account.active_relationships.where(account: Account.where(domain: @domain)).includes(:target_account).reorder(nil).find_each do |follow|
|
||||||
|
UnfollowService.new.call(@account, follow.target_account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_notifications!
|
||||||
|
Notification.where(account: @account).where(from_account: Account.where(domain: @domain)).in_batches.delete_all
|
||||||
|
end
|
||||||
|
|
||||||
def reject_existing_followers!
|
def reject_existing_followers!
|
||||||
@account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow|
|
@account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow|
|
||||||
reject_follow!(follow)
|
reject_follow!(follow)
|
||||||
|
|
|
@ -2,43 +2,25 @@
|
||||||
|
|
||||||
class AfterBlockService < BaseService
|
class AfterBlockService < BaseService
|
||||||
def call(account, target_account)
|
def call(account, target_account)
|
||||||
clear_home_feed(account, target_account)
|
@account = account
|
||||||
clear_notifications(account, target_account)
|
@target_account = target_account
|
||||||
clear_conversations(account, target_account)
|
|
||||||
|
clear_home_feed!
|
||||||
|
clear_notifications!
|
||||||
|
clear_conversations!
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def clear_home_feed(account, target_account)
|
def clear_home_feed!
|
||||||
FeedManager.instance.clear_from_timeline(account, target_account)
|
FeedManager.instance.clear_from_timeline(@account, @target_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_conversations(account, target_account)
|
def clear_conversations!
|
||||||
AccountConversation.where(account: account)
|
AccountConversation.where(account: @account).where('? = ANY(participant_account_ids)', @target_account.id).in_batches.destroy_all
|
||||||
.where('? = ANY(participant_account_ids)', target_account.id)
|
|
||||||
.in_batches
|
|
||||||
.destroy_all
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_notifications(account, target_account)
|
def clear_notifications!
|
||||||
Notification.where(account: account)
|
Notification.where(account: @account).where(from_account: @target_account).in_batches.delete_all
|
||||||
.joins(:follow)
|
|
||||||
.where(activity_type: 'Follow', follows: { account_id: target_account.id })
|
|
||||||
.delete_all
|
|
||||||
|
|
||||||
Notification.where(account: account)
|
|
||||||
.joins(mention: :status)
|
|
||||||
.where(activity_type: 'Mention', statuses: { account_id: target_account.id })
|
|
||||||
.delete_all
|
|
||||||
|
|
||||||
Notification.where(account: account)
|
|
||||||
.joins(:favourite)
|
|
||||||
.where(activity_type: 'Favourite', favourites: { account_id: target_account.id })
|
|
||||||
.delete_all
|
|
||||||
|
|
||||||
Notification.where(account: account)
|
|
||||||
.joins(:status)
|
|
||||||
.where(activity_type: 'Status', statuses: { account_id: target_account.id })
|
|
||||||
.delete_all
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -142,5 +142,7 @@ class BackupService < BaseService
|
||||||
io.write(buffer)
|
io.write(buffer)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
rescue Errno::ENOENT
|
||||||
|
Rails.logger.warn "Could not backup file #{filename}: file not found"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,7 +8,7 @@ class BlockService < BaseService
|
||||||
|
|
||||||
UnfollowService.new.call(account, target_account) if account.following?(target_account)
|
UnfollowService.new.call(account, target_account) if account.following?(target_account)
|
||||||
UnfollowService.new.call(target_account, account) if target_account.following?(account)
|
UnfollowService.new.call(target_account, account) if target_account.following?(account)
|
||||||
RejectFollowService.new.call(account, target_account) if target_account.requested?(account)
|
RejectFollowService.new.call(target_account, account) if target_account.requested?(account)
|
||||||
|
|
||||||
block = account.block!(target_account)
|
block = account.block!(target_account)
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ class FollowService < BaseService
|
||||||
target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
|
target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true)
|
||||||
|
|
||||||
raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
|
raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
|
||||||
raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved?
|
raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) || target_account.moved? || source_account.domain_blocking?(target_account.domain)
|
||||||
|
|
||||||
if source_account.following?(target_account)
|
if source_account.following?(target_account)
|
||||||
# We're already following this account, but we'll call follow! again to
|
# We're already following this account, but we'll call follow! again to
|
||||||
|
|
|
@ -48,7 +48,7 @@ class ResolveAccountService < BaseService
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
return if links_missing?
|
return if links_missing? || auto_suspend?
|
||||||
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
|
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
|
||||||
|
|
||||||
RedisLock.acquire(lock_options) do |lock|
|
RedisLock.acquire(lock_options) do |lock|
|
||||||
|
|
|
@ -66,6 +66,7 @@ class SuspendAccountService < BaseService
|
||||||
@account.user.destroy
|
@account.user.destroy
|
||||||
else
|
else
|
||||||
@account.user.disable!
|
@account.user.disable!
|
||||||
|
@account.user.invites.where(uses: 0).destroy_all
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
17
app/validators/domain_validator.rb
Normal file
17
app/validators/domain_validator.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class DomainValidator < ActiveModel::EachValidator
|
||||||
|
def validate_each(record, attribute, value)
|
||||||
|
return if value.blank?
|
||||||
|
|
||||||
|
record.errors.add(attribute, I18n.t('domain_validator.invalid_domain')) unless compliant?(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def compliant?(value)
|
||||||
|
Addressable::URI.new.tap { |uri| uri.host = value }
|
||||||
|
rescue Addressable::URI::InvalidURIError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
|
@ -5,7 +5,7 @@
|
||||||
%meta{ name: 'description', content: account_description(@account) }/
|
%meta{ name: 'description', content: account_description(@account) }/
|
||||||
|
|
||||||
- if @account.user&.setting_noindex
|
- if @account.user&.setting_noindex
|
||||||
%meta{ name: 'robots', content: 'noindex' }/
|
%meta{ name: 'robots', content: 'noindex, noarchive' }/
|
||||||
|
|
||||||
%link{ rel: 'salmon', href: api_salmon_url(@account.id) }/
|
%link{ rel: 'salmon', href: api_salmon_url(@account.id) }/
|
||||||
%link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
|
%link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
|
||||||
|
@ -75,7 +75,7 @@
|
||||||
- if featured_tag.last_status_at.nil?
|
- if featured_tag.last_status_at.nil?
|
||||||
= t('accounts.nothing_here')
|
= t('accounts.nothing_here')
|
||||||
- else
|
- else
|
||||||
%time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
|
%time.formatted{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
|
||||||
.trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true
|
.trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true
|
||||||
|
|
||||||
= render 'application/sidebar'
|
= render 'application/sidebar'
|
||||||
|
|
24
app/views/statuses/show.html.haml
Normal file
24
app/views/statuses/show.html.haml
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
- content_for :page_title do
|
||||||
|
= t('statuses.title', name: display_name(@account), quote: truncate(@status.spoiler_text.presence || @status.text, length: 50, omission: '…', escape: false))
|
||||||
|
|
||||||
|
- content_for :header_tags do
|
||||||
|
- if @account.user&.setting_noindex
|
||||||
|
%meta{ name: 'robots', content: 'noindex, noarchive' }/
|
||||||
|
|
||||||
|
%link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: short_account_status_url(@account, @status), format: 'json') }/
|
||||||
|
%link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@status) }/
|
||||||
|
|
||||||
|
= opengraph 'og:site_name', site_title
|
||||||
|
= opengraph 'og:type', 'article'
|
||||||
|
= opengraph 'og:title', "#{display_name(@account)} (@#{@account.local_username_and_domain})"
|
||||||
|
= opengraph 'og:url', short_account_status_url(@account, @status)
|
||||||
|
|
||||||
|
= render 'og_description', activity: @status
|
||||||
|
= render 'og_image', activity: @status, account: @account
|
||||||
|
|
||||||
|
.grid
|
||||||
|
.column-0
|
||||||
|
.activity-stream.h-entry
|
||||||
|
= render partial: 'status', locals: { status: @status, include_threads: true }
|
||||||
|
.column-1
|
||||||
|
= render 'application/sidebar'
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
- content_for :header_tags do
|
- content_for :header_tags do
|
||||||
- if @account.user&.setting_noindex
|
- if @account.user&.setting_noindex
|
||||||
%meta{ name: 'robots', content: 'noindex' }/
|
%meta{ name: 'robots', content: 'noindex, noarchive' }/
|
||||||
|
|
||||||
%link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/
|
%link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/
|
||||||
%link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/
|
%link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/
|
||||||
|
|
|
@ -51,7 +51,7 @@ class ActivityPub::DeliveryWorker
|
||||||
end
|
end
|
||||||
|
|
||||||
def response_error_unsalvageable?(response)
|
def response_error_unsalvageable?(response)
|
||||||
(400...500).cover?(response.code) && ![401, 408, 429].include?(response.code)
|
response.code == 501 || ((400...500).cover?(response.code) && ![401, 408, 429].include?(response.code))
|
||||||
end
|
end
|
||||||
|
|
||||||
def failure_tracker
|
def failure_tracker
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
class Web::PushNotificationWorker
|
class Web::PushNotificationWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
sidekiq_options backtrace: true
|
sidekiq_options backtrace: true, retry: 5
|
||||||
|
|
||||||
def perform(subscription_id, notification_id)
|
def perform(subscription_id, notification_id)
|
||||||
subscription = ::Web::PushSubscription.find(subscription_id)
|
subscription = ::Web::PushSubscription.find(subscription_id)
|
||||||
|
|
|
@ -114,6 +114,9 @@ module Mastodon
|
||||||
Doorkeeper::AuthorizationsController.layout 'modal'
|
Doorkeeper::AuthorizationsController.layout 'modal'
|
||||||
Doorkeeper::AuthorizedApplicationsController.layout 'admin'
|
Doorkeeper::AuthorizedApplicationsController.layout 'admin'
|
||||||
Doorkeeper::Application.send :include, ApplicationExtension
|
Doorkeeper::Application.send :include, ApplicationExtension
|
||||||
|
Devise::FailureApp.send :include, AbstractController::Callbacks
|
||||||
|
Devise::FailureApp.send :include, HttpAcceptLanguage::EasyAccess
|
||||||
|
Devise::FailureApp.send :include, Localized
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,18 +0,0 @@
|
||||||
# frozen_string_literal: true
|
|
||||||
|
|
||||||
instrumentation_hostname = ENV.fetch('INSTRUMENTATION_HOSTNAME') { 'localhost' }
|
|
||||||
|
|
||||||
ActiveSupport::Notifications.subscribe(/process_action.action_controller/) do |*args|
|
|
||||||
event = ActiveSupport::Notifications::Event.new(*args)
|
|
||||||
controller = event.payload[:controller]
|
|
||||||
action = event.payload[:action]
|
|
||||||
format = event.payload[:format] || 'all'
|
|
||||||
format = 'all' if format == '*/*'
|
|
||||||
status = event.payload[:status]
|
|
||||||
key = "#{controller}.#{action}.#{format}.#{instrumentation_hostname}"
|
|
||||||
|
|
||||||
ActiveSupport::Notifications.instrument :performance, action: :measure, measurement: "#{key}.total_duration", value: event.duration
|
|
||||||
ActiveSupport::Notifications.instrument :performance, action: :measure, measurement: "#{key}.db_time", value: event.payload[:db_runtime]
|
|
||||||
ActiveSupport::Notifications.instrument :performance, action: :measure, measurement: "#{key}.view_time", value: event.payload[:view_runtime]
|
|
||||||
ActiveSupport::Notifications.instrument :performance, measurement: "#{key}.status.#{status}"
|
|
||||||
end
|
|
|
@ -3,10 +3,10 @@
|
||||||
if ENV['STATSD_ADDR'].present?
|
if ENV['STATSD_ADDR'].present?
|
||||||
host, port = ENV['STATSD_ADDR'].split(':')
|
host, port = ENV['STATSD_ADDR'].split(':')
|
||||||
|
|
||||||
statsd = ::Statsd.new(host, port)
|
$statsd = ::Statsd.new(host, port)
|
||||||
statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') }
|
$statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') }
|
||||||
|
|
||||||
::NSA.inform_statsd(statsd) do |informant|
|
::NSA.inform_statsd($statsd) do |informant|
|
||||||
informant.collect(:action_controller, :web)
|
informant.collect(:action_controller, :web)
|
||||||
informant.collect(:active_record, :db)
|
informant.collect(:active_record, :db)
|
||||||
informant.collect(:active_support_cache, :cache)
|
informant.collect(:active_support_cache, :cache)
|
||||||
|
|
|
@ -588,6 +588,8 @@ en:
|
||||||
people:
|
people:
|
||||||
one: "%{count} person"
|
one: "%{count} person"
|
||||||
other: "%{count} people"
|
other: "%{count} people"
|
||||||
|
domain_validator:
|
||||||
|
invalid_domain: is not a valid domain name
|
||||||
errors:
|
errors:
|
||||||
'403': You don't have permission to view this page.
|
'403': You don't have permission to view this page.
|
||||||
'404': The page you are looking for isn't here.
|
'404': The page you are looking for isn't here.
|
||||||
|
|
|
@ -2,9 +2,9 @@ threads_count = ENV.fetch('MAX_THREADS') { 5 }.to_i
|
||||||
threads threads_count, threads_count
|
threads threads_count, threads_count
|
||||||
|
|
||||||
if ENV['SOCKET']
|
if ENV['SOCKET']
|
||||||
bind 'unix://' + ENV['SOCKET']
|
bind "unix://#{ENV['SOCKET']}"
|
||||||
else
|
else
|
||||||
port ENV.fetch('PORT') { 3000 }
|
bind "tcp://#{ENV.fetch('BIND', '127.0.0.1')}:#{ENV.fetch('PORT', 3000)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
environment ENV.fetch('RAILS_ENV') { 'development' }
|
environment ENV.fetch('RAILS_ENV') { 'development' }
|
||||||
|
|
|
@ -38,7 +38,7 @@ services:
|
||||||
image: tootsuite/mastodon
|
image: tootsuite/mastodon
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000 -b '0.0.0.0'"
|
command: bash -c "rm -f /mastodon/tmp/pids/server.pid; bundle exec rails s -p 3000"
|
||||||
networks:
|
networks:
|
||||||
- external_network
|
- external_network
|
||||||
- internal_network
|
- internal_network
|
||||||
|
@ -58,7 +58,7 @@ services:
|
||||||
image: tootsuite/mastodon
|
image: tootsuite/mastodon
|
||||||
restart: always
|
restart: always
|
||||||
env_file: .env.production
|
env_file: .env.production
|
||||||
command: yarn start
|
command: node ./streaming
|
||||||
networks:
|
networks:
|
||||||
- external_network
|
- external_network
|
||||||
- internal_network
|
- internal_network
|
||||||
|
|
|
@ -13,23 +13,23 @@ module Mastodon
|
||||||
end
|
end
|
||||||
|
|
||||||
def patch
|
def patch
|
||||||
2
|
3
|
||||||
end
|
|
||||||
|
|
||||||
def pre
|
|
||||||
nil
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def flags
|
def flags
|
||||||
''
|
''
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def suffix
|
||||||
|
''
|
||||||
|
end
|
||||||
|
|
||||||
def to_a
|
def to_a
|
||||||
[major, minor, patch, pre].compact
|
[major, minor, patch].compact
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_s
|
def to_s
|
||||||
[to_a.join('.'), flags].join
|
[to_a.join('.'), flags, suffix].join
|
||||||
end
|
end
|
||||||
|
|
||||||
def repository
|
def repository
|
||||||
|
|
|
@ -7,16 +7,10 @@ describe ApplicationController, type: :controller do
|
||||||
include Localized
|
include Localized
|
||||||
|
|
||||||
def success
|
def success
|
||||||
head 200
|
render plain: I18n.locale, status: 200
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
around do |example|
|
|
||||||
current_locale = I18n.locale
|
|
||||||
example.run
|
|
||||||
I18n.locale = current_locale
|
|
||||||
end
|
|
||||||
|
|
||||||
before do
|
before do
|
||||||
routes.draw { get 'success' => 'anonymous#success' }
|
routes.draw { get 'success' => 'anonymous#success' }
|
||||||
end
|
end
|
||||||
|
@ -25,19 +19,19 @@ describe ApplicationController, type: :controller do
|
||||||
it 'sets available and preferred language' do
|
it 'sets available and preferred language' do
|
||||||
request.headers['Accept-Language'] = 'ca-ES, fa'
|
request.headers['Accept-Language'] = 'ca-ES, fa'
|
||||||
get 'success'
|
get 'success'
|
||||||
expect(I18n.locale).to eq :fa
|
expect(response.body).to eq 'fa'
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sets available and compatible language if none of available languages are preferred' do
|
it 'sets available and compatible language if none of available languages are preferred' do
|
||||||
request.headers['Accept-Language'] = 'fa-IR'
|
request.headers['Accept-Language'] = 'fa-IR'
|
||||||
get 'success'
|
get 'success'
|
||||||
expect(I18n.locale).to eq :fa
|
expect(response.body).to eq 'fa'
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'sets default locale if none of available languages are compatible' do
|
it 'sets default locale if none of available languages are compatible' do
|
||||||
request.headers['Accept-Language'] = ''
|
request.headers['Accept-Language'] = ''
|
||||||
get 'success'
|
get 'success'
|
||||||
expect(I18n.locale).to eq :en
|
expect(response.body).to eq 'en'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -48,7 +42,7 @@ describe ApplicationController, type: :controller do
|
||||||
sign_in(user)
|
sign_in(user)
|
||||||
get 'success'
|
get 'success'
|
||||||
|
|
||||||
expect(I18n.locale).to eq :ca
|
expect(response.body).to eq 'ca'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -149,6 +149,14 @@ RSpec.describe FeedManager do
|
||||||
status = Fabricate(:status, text: 'shiitake', account: jeff)
|
status = Fabricate(:status, text: 'shiitake', account: jeff)
|
||||||
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
|
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'returns true if phrase is contained in a poll option' do
|
||||||
|
alice.custom_filters.create!(phrase: 'farts', context: %w(home public), irreversible: true)
|
||||||
|
alice.custom_filters.create!(phrase: 'pop tarts', context: %w(home), irreversible: true)
|
||||||
|
alice.follow!(jeff)
|
||||||
|
status = Fabricate(:status, text: 'what do you prefer', poll: Fabricate(:poll, options: %w(farts POP TARts)), account: jeff)
|
||||||
|
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -239,6 +247,23 @@ RSpec.describe FeedManager do
|
||||||
expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be false
|
expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'saves a new reblog of a recently-reblogged status when previous reblog has been deleted' do
|
||||||
|
account = Fabricate(:account)
|
||||||
|
reblogged = Fabricate(:status)
|
||||||
|
old_reblog = Fabricate(:status, reblog: reblogged)
|
||||||
|
|
||||||
|
# The first reblog should be accepted
|
||||||
|
expect(FeedManager.instance.push_to_home(account, old_reblog)).to be true
|
||||||
|
|
||||||
|
# The first reblog should be successfully removed
|
||||||
|
expect(FeedManager.instance.unpush_from_home(account, old_reblog)).to be true
|
||||||
|
|
||||||
|
reblog = Fabricate(:status, reblog: reblogged)
|
||||||
|
|
||||||
|
# The second reblog should be accepted
|
||||||
|
expect(FeedManager.instance.push_to_home(account, reblog)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
it 'does not save a new reblog of a multiply-reblogged-then-unreblogged status' do
|
it 'does not save a new reblog of a multiply-reblogged-then-unreblogged status' do
|
||||||
account = Fabricate(:account)
|
account = Fabricate(:account)
|
||||||
reblogged = Fabricate(:status)
|
reblogged = Fabricate(:status)
|
||||||
|
|
|
@ -261,7 +261,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { ':coolcat: Beep boop' }
|
let(:text) { ':coolcat: Beep boop' }
|
||||||
|
|
||||||
it 'converts the shortcode to an image tag' do
|
it 'converts the shortcode to an image tag' do
|
||||||
is_expected.to match(/<img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/<img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -330,7 +330,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { ':coolcat: Beep boop' }
|
let(:text) { ':coolcat: Beep boop' }
|
||||||
|
|
||||||
it 'converts the shortcode to an image tag' do
|
it 'converts the shortcode to an image tag' do
|
||||||
is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -338,7 +338,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { 'Beep :coolcat: boop' }
|
let(:text) { 'Beep :coolcat: boop' }
|
||||||
|
|
||||||
it 'converts the shortcode to an image tag' do
|
it 'converts the shortcode to an image tag' do
|
||||||
is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -354,7 +354,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { 'Beep boop :coolcat:' }
|
let(:text) { 'Beep boop :coolcat:' }
|
||||||
|
|
||||||
it 'converts the shortcode to an image tag' do
|
it 'converts the shortcode to an image tag' do
|
||||||
is_expected.to match(/boop <img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -377,7 +377,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { '<p>:coolcat: Beep boop<br />' }
|
let(:text) { '<p>:coolcat: Beep boop<br />' }
|
||||||
|
|
||||||
it 'converts the shortcode to an image tag' do
|
it 'converts the shortcode to an image tag' do
|
||||||
is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -385,7 +385,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { '<p>Beep :coolcat: boop</p>' }
|
let(:text) { '<p>Beep :coolcat: boop</p>' }
|
||||||
|
|
||||||
it 'converts the shortcode to an image tag' do
|
it 'converts the shortcode to an image tag' do
|
||||||
is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -401,7 +401,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
|
let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
|
||||||
|
|
||||||
it 'converts the shortcode to an image tag' do
|
it 'converts the shortcode to an image tag' do
|
||||||
is_expected.to match(/<br><img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/<br><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -500,7 +500,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { ':coolcat: Beep boop' }
|
let(:text) { ':coolcat: Beep boop' }
|
||||||
|
|
||||||
it 'converts the shortcode to an image tag' do
|
it 'converts the shortcode to an image tag' do
|
||||||
is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -508,7 +508,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { 'Beep :coolcat: boop' }
|
let(:text) { 'Beep :coolcat: boop' }
|
||||||
|
|
||||||
it 'converts the shortcode to an image tag' do
|
it 'converts the shortcode to an image tag' do
|
||||||
is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -524,7 +524,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { 'Beep boop :coolcat:' }
|
let(:text) { 'Beep boop :coolcat:' }
|
||||||
|
|
||||||
it 'converts the shortcode to an image tag' do
|
it 'converts the shortcode to an image tag' do
|
||||||
is_expected.to match(/boop <img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/boop <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -551,7 +551,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { '<p>:coolcat: Beep boop<br />' }
|
let(:text) { '<p>:coolcat: Beep boop<br />' }
|
||||||
|
|
||||||
it 'converts shortcode to image tag' do
|
it 'converts shortcode to image tag' do
|
||||||
is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/<p><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -559,7 +559,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { '<p>Beep :coolcat: boop</p>' }
|
let(:text) { '<p>Beep :coolcat: boop</p>' }
|
||||||
|
|
||||||
it 'converts shortcode to image tag' do
|
it 'converts shortcode to image tag' do
|
||||||
is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/Beep <img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -575,7 +575,7 @@ RSpec.describe Formatter do
|
||||||
let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
|
let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
|
||||||
|
|
||||||
it 'converts shortcode to image tag' do
|
it 'converts shortcode to image tag' do
|
||||||
is_expected.to match(/<br><img draggable="false" class="emojione" alt=":coolcat:"/)
|
is_expected.to match(/<br><img draggable="false" class="emojione custom-emoji" alt=":coolcat:"/)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -32,11 +32,11 @@ describe LanguageDetector do
|
||||||
expect(result).to eq 'Our website is and also'
|
expect(result).to eq 'Our website is and also'
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'strips #hashtags from strings before detection' do
|
it 'converts #hashtags back to normal text before detection' do
|
||||||
string = 'Hey look at all the #animals and #fish'
|
string = 'Hey look at all the #animals and #FishAndChips'
|
||||||
|
|
||||||
result = described_class.instance.send(:prepare_text, string)
|
result = described_class.instance.send(:prepare_text, string)
|
||||||
expect(result).to eq 'Hey look at all the and'
|
expect(result).to eq 'Hey look at all the animals and fish and chips'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -22,5 +22,9 @@ describe Sanitize::Config do
|
||||||
it 'converts ul inside ul' do
|
it 'converts ul inside ul' do
|
||||||
expect(Sanitize.fragment('<ul><li>Foo</li><li><ul><li>Bar</li><li>Baz</li></ul></li></ul>', subject)).to eq '<p>Foo<br>Bar<br>Baz</p>'
|
expect(Sanitize.fragment('<ul><li>Foo</li><li><ul><li>Bar</li><li>Baz</li></ul></li></ul>', subject)).to eq '<p>Foo<br>Bar<br>Baz</p>'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'keep links in lists' do
|
||||||
|
expect(Sanitize.fragment('<p>Check out:</p><ul><li><a href="https://joinmastodon.org" rel="nofollow noopener" target="_blank">joinmastodon.org</a></li><li>Bar</li></ul>', subject)).to eq '<p>Check out:</p><p><a href="https://joinmastodon.org" rel="nofollow noopener" target="_blank">joinmastodon.org</a><br>Bar</p>'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,27 +3,33 @@ require 'rails_helper'
|
||||||
RSpec.describe Invite, type: :model do
|
RSpec.describe Invite, type: :model do
|
||||||
describe '#valid_for_use?' do
|
describe '#valid_for_use?' do
|
||||||
it 'returns true when there are no limitations' do
|
it 'returns true when there are no limitations' do
|
||||||
invite = Invite.new(max_uses: nil, expires_at: nil)
|
invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
|
||||||
expect(invite.valid_for_use?).to be true
|
expect(invite.valid_for_use?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true when not expired' do
|
it 'returns true when not expired' do
|
||||||
invite = Invite.new(max_uses: nil, expires_at: 1.hour.from_now)
|
invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.from_now)
|
||||||
expect(invite.valid_for_use?).to be true
|
expect(invite.valid_for_use?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false when expired' do
|
it 'returns false when expired' do
|
||||||
invite = Invite.new(max_uses: nil, expires_at: 1.hour.ago)
|
invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.ago)
|
||||||
expect(invite.valid_for_use?).to be false
|
expect(invite.valid_for_use?).to be false
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns true when uses still available' do
|
it 'returns true when uses still available' do
|
||||||
invite = Invite.new(max_uses: 250, uses: 249, expires_at: nil)
|
invite = Fabricate(:invite, max_uses: 250, uses: 249, expires_at: nil)
|
||||||
expect(invite.valid_for_use?).to be true
|
expect(invite.valid_for_use?).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns false when maximum uses reached' do
|
it 'returns false when maximum uses reached' do
|
||||||
invite = Invite.new(max_uses: 250, uses: 250, expires_at: nil)
|
invite = Fabricate(:invite, max_uses: 250, uses: 250, expires_at: nil)
|
||||||
|
expect(invite.valid_for_use?).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false when invite creator has been disabled' do
|
||||||
|
invite = Fabricate(:invite, max_uses: nil, expires_at: nil)
|
||||||
|
SuspendAccountService.new.call(invite.user.account)
|
||||||
expect(invite.valid_for_use?).to be false
|
expect(invite.valid_for_use?).to be false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -31,7 +31,47 @@ RSpec.describe Tag, type: :model do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'matches #aesthetic' do
|
it 'matches #aesthetic' do
|
||||||
expect(subject.match('this is #aesthetic')).to_not be_nil
|
expect(subject.match('this is #aesthetic').to_s).to eq ' #aesthetic'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'matches digits at the start' do
|
||||||
|
expect(subject.match('hello #3d').to_s).to eq ' #3d'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'matches digits in the middle' do
|
||||||
|
expect(subject.match('hello #l33ts35k').to_s).to eq ' #l33ts35k'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'matches digits at the end' do
|
||||||
|
expect(subject.match('hello #world2016').to_s).to eq ' #world2016'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'matches underscores at the beginning' do
|
||||||
|
expect(subject.match('hello #_test').to_s).to eq ' #_test'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'matches underscores at the end' do
|
||||||
|
expect(subject.match('hello #test_').to_s).to eq ' #test_'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'matches underscores in the middle' do
|
||||||
|
expect(subject.match('hello #one_two_three').to_s).to eq ' #one_two_three'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'matches middle dots' do
|
||||||
|
expect(subject.match('hello #one·two·three').to_s).to eq ' #one·two·three'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not match middle dots at the start' do
|
||||||
|
expect(subject.match('hello #·one·two·three')).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not match middle dots at the end' do
|
||||||
|
expect(subject.match('hello #one·two·three·').to_s).to eq ' #one·two·three'
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not match purely-numeric hashtags' do
|
||||||
|
expect(subject.match('hello #0123456')).to be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
27
spec/serializers/activitypub/update_poll_spec.rb
Normal file
27
spec/serializers/activitypub/update_poll_spec.rb
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe ActivityPub::UpdatePollSerializer do
|
||||||
|
let(:account) { Fabricate(:account) }
|
||||||
|
let(:poll) { Fabricate(:poll, account: account) }
|
||||||
|
let!(:status) { Fabricate(:status, account: account, poll: poll) }
|
||||||
|
|
||||||
|
before(:each) do
|
||||||
|
@serialization = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::UpdatePollSerializer, adapter: ActivityPub::Adapter)
|
||||||
|
end
|
||||||
|
|
||||||
|
subject { JSON.parse(@serialization.to_json) }
|
||||||
|
|
||||||
|
it 'has a Update type' do
|
||||||
|
expect(subject['type']).to eql('Update')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has an object with Question type' do
|
||||||
|
expect(subject['object']['type']).to eql('Question')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'has the correct actor URI set' do
|
||||||
|
expect(subject['actor']).to eql(ActivityPub::TagManager.instance.uri_for(account))
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,22 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe ActivityPub::DistributePollUpdateWorker do
|
||||||
|
subject { described_class.new }
|
||||||
|
|
||||||
|
let(:account) { Fabricate(:account) }
|
||||||
|
let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') }
|
||||||
|
let(:poll) { Fabricate(:poll, account: account) }
|
||||||
|
let!(:status) { Fabricate(:status, account: account, poll: poll) }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
before do
|
||||||
|
allow(ActivityPub::DeliveryWorker).to receive(:push_bulk)
|
||||||
|
follower.follow!(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'delivers to followers' do
|
||||||
|
subject.perform(status.id)
|
||||||
|
expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com'])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -672,7 +672,7 @@ const attachServerWithConfig = (server, onSuccess) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
server.listen(+process.env.PORT || 4000, process.env.BIND || '0.0.0.0', () => {
|
server.listen(+process.env.PORT || 4000, process.env.BIND || '127.0.0.1', () => {
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
onSuccess(`${server.address().address}:${server.address().port}`);
|
onSuccess(`${server.address().address}:${server.address().port}`);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue