Merge tag 'v2.9.3' into hometown-2.9.3

This commit is contained in:
Darius Kazemi 2019-08-19 14:28:19 -07:00
commit 6c2e2969ca
98 changed files with 852 additions and 270 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -140,7 +140,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),
local_only: !getState().getIn(['compose', 'federation']), local_only: !getState().getIn(['compose', 'federation']),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
} }
@ -213,45 +240,26 @@ export default class StatusContent extends React.PureComponent {
return output; return output;
} 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;
} }
} }

View File

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

View File

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

View File

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

View File

@ -119,9 +119,12 @@ class ComposeForm extends ImmutablePureComponent {
handleFocus = () => { handleFocus = () => {
if (this.composeForm && !this.props.singleColumn) { if (this.composeForm && !this.props.singleColumn) {
const { left, right } = this.composeForm.getBoundingClientRect();
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
this.composeForm.scrollIntoView(); this.composeForm.scrollIntoView();
} }
} }
}
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {
// This statement does several things: // This statement does several things:
@ -190,12 +193,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}

View File

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

View File

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

View File

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

View File

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

View File

@ -12,7 +12,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 => ({

View File

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

View File

@ -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,29 +64,36 @@ const messages = defineMessages({
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const mapStateToProps = (state, props) => { const getAncestorsIds = createSelector([
const status = getStatus(state, { id: props.params.statusId }); (_, { id }) => id,
state => state.getIn(['contexts', 'inReplyTos']),
], (statusId, inReplyTos) => {
let ancestorsIds = Immutable.List(); let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List();
if (status) {
ancestorsIds = ancestorsIds.withMutations(mutable => { ancestorsIds = ancestorsIds.withMutations(mutable => {
let id = status.get('in_reply_to_id'); let id = statusId;
while (id) { while (id) {
mutable.unshift(id); mutable.unshift(id);
id = state.getIn(['contexts', 'inReplyTos', 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 => { descendantsIds = descendantsIds.withMutations(mutable => {
const ids = [status.get('id')]; const ids = [statusId];
while (ids.length > 0) { while (ids.length > 0) {
let id = ids.shift(); let id = ids.shift();
const replies = state.getIn(['contexts', 'replies', id]); const replies = contextReplies.get(id);
if (status.get('id') !== id) { if (statusId !== id) {
mutable.push(id); mutable.push(id);
} }
@ -96,6 +104,18 @@ const makeMapStateToProps = () => {
} }
} }
}); });
return descendantsIds;
});
const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId });
let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List();
if (status) {
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
} }
return { return {

View File

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

View File

@ -199,6 +199,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:
@ -228,6 +234,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());
@ -363,7 +370,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'])),
})); }));
} }
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4071,6 +4071,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;

View File

@ -37,7 +37,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
@ -55,7 +55,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
@ -107,7 +107,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
@ -229,7 +229,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
@ -283,10 +284,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?
@ -295,6 +297,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
@ -302,12 +305,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)

View File

@ -143,11 +143,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
@ -163,7 +159,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -79,7 +79,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') }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -31,7 +31,47 @@ RSpec.describe Tag, type: :model do
end end
it 'matches #' do it 'matches #' do
expect(subject.match('this is #')).to_not be_nil expect(subject.match('this is #').to_s).to eq ' #'
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

View 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

View File

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

View File

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