Merge upstream tag 'v2.9.3'

This commit is contained in:
Mike Barnes 2019-11-15 17:25:54 +11:00
commit a1ae88b977
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.
## [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
### Added

View File

@ -112,6 +112,7 @@ ENV NODE_ENV="production"
# Tell rails to serve static files
ENV RAILS_SERVE_STATIC_FILES="true"
ENV BIND="0.0.0.0"
# Set the run user
USER mastodon

View File

@ -231,7 +231,7 @@ GEM
fugit (1.1.6)
et-orbi (~> 1.1, >= 1.1.6)
raabro (~> 1.1)
fuubar (2.4.0)
fuubar (2.4.1)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
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
# 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 :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'
end

View File

@ -5,6 +5,8 @@ class AboutController < ApplicationController
before_action :set_instance_presenter, only: [:show, :more, :terms]
skip_before_action :check_user_permissions, only: [:more, :terms]
def show
@hide_navbar = true
end

View File

@ -17,7 +17,7 @@ module Admin
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@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
render :new
else

View File

@ -3,7 +3,8 @@
class Api::V1::Accounts::StatusesController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_account
after_action :insert_pagination_headers
after_action :insert_pagination_headers, unless: -> { truthy_param?(:pinned) }
respond_to :json

View File

@ -91,11 +91,15 @@ class ApplicationController < ActionController::Base
end
def current_account
@current_account ||= current_user.try(:account)
return @current_account if defined?(@current_account)
@current_account = current_user&.account
end
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
def current_theme
@ -126,11 +130,7 @@ class ApplicationController < ActionController::Base
def respond_with_error(code)
respond_to do |format|
format.any { head code }
format.html do
set_locale
render "errors/#{code}", layout: 'error', status: code
end
format.html { render "errors/#{code}", layout: 'error', status: code }
end
end

View File

@ -4,16 +4,19 @@ module Localized
extend ActiveSupport::Concern
included do
before_action :set_locale
around_action :set_locale
end
private
def set_locale
I18n.locale = default_locale
I18n.locale = current_user.locale if user_signed_in?
rescue I18n::InvalidLocale
I18n.locale = default_locale
locale = current_user.locale if respond_to?(:user_signed_in?) && user_signed_in?
locale ||= session[:locale] ||= default_locale
locale = default_locale unless I18n.available_locales.include?(locale.to_sym)
I18n.with_locale(locale) do
yield
end
end
def default_locale

View File

@ -39,7 +39,7 @@ class InvitesController < ApplicationController
private
def invites
Invite.where(user: current_user).order(id: :desc)
current_user.invites.order(id: :desc)
end
def resource_params

View File

@ -14,7 +14,7 @@ module Settings
def create
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
@recovery_codes = current_user.generate_otp_backup_codes!

View File

@ -10,7 +10,7 @@ module Settings
def create
@recovery_codes = current_user.generate_otp_backup_codes!
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
end
end

View File

@ -139,7 +139,7 @@ export function submitCompose(routerHistory) {
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
media_ids: media.map(item => item.get('id')),
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']),
poll: getState().getIn(['compose', 'poll'], null),
}, {

View File

@ -23,6 +23,7 @@ export function blockDomain(domain) {
api(getState).post('/api/v1/domain_blocks', { domain }).then(() => {
const at_domain = '@' + domain;
const accounts = getState().get('accounts').filter(item => item.get('acct').endsWith(at_domain)).valueSeq().map(item => item.get('id'));
dispatch(blockDomainSuccess(domain, accounts));
}).catch(err => {
dispatch(blockDomainFail(domain, err));

View File

@ -22,7 +22,7 @@ export function normalizeAccount(account) {
if (account.fields) {
account.fields = account.fields.map(pair => ({
...pair,
name_emojified: emojify(escapeTextContentForBrowser(pair.name)),
name_emojified: emojify(escapeTextContentForBrowser(pair.name), emojiMap),
value_emojified: emojify(pair.value, emojiMap),
value_plain: unescapeHTML(pair.value),
}));
@ -56,7 +56,7 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.hidden = normalOldStatus.get('hidden');
} else {
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);
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 {
type: MODAL_CLOSE,
modalType: type,
};
};

View File

@ -11,7 +11,7 @@ import { saveSettings } from './settings';
import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from '../utils/html';
import { getFilters, regexFromFilters } from '../selectors';
import { getFiltersRegex } from '../selectors';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
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 showAlert = getState().getIn(['settings', 'notifications', 'alerts', 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;
if (notification.type === 'mention') {
const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
const regex = regexFromFilters(filters);
const dropRegex = filters[0];
const regex = filters[1];
const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
if (dropRegex && dropRegex.test(searchIndex)) {

View File

@ -48,7 +48,7 @@ export function submitSearch() {
dispatch(importFetchedStatuses(response.data.statuses));
}
dispatch(fetchSearchSuccess(response.data));
dispatch(fetchSearchSuccess(response.data, value));
dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
}).catch(error => {
dispatch(fetchSearchFail(error));
@ -62,10 +62,11 @@ export function fetchSearchRequest() {
};
};
export function fetchSearchSuccess(results) {
export function fetchSearchSuccess(results, searchTerm) {
return {
type: SEARCH_FETCH_SUCCESS,
results,
searchTerm,
};
};

View File

@ -12,6 +12,7 @@ export default class Button extends React.PureComponent {
secondary: PropTypes.bool,
size: PropTypes.number,
className: PropTypes.string,
title: PropTypes.string,
style: PropTypes.object,
children: PropTypes.node,
};
@ -54,6 +55,7 @@ export default class Button extends React.PureComponent {
onClick={this.handleClick}
ref={this.setRef}
style={style}
title={this.props.title}
>
{this.props.text || this.props.children}
</button>

View File

@ -1,6 +1,7 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { autoPlayGif } from 'mastodon/initial_state';
export default class DisplayName extends React.PureComponent {
@ -10,6 +11,47 @@ export default class DisplayName extends React.PureComponent {
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 () {
const { others, localDomain } = this.props;
@ -39,7 +81,7 @@ export default class DisplayName extends React.PureComponent {
}
return (
<span className='display-name'>
<span className='display-name' ref={this.setRef}>
{displayName} {suffix}
</span>
);

View File

@ -45,7 +45,9 @@ class DropdownMenu extends React.PureComponent {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
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 });
}
@ -81,6 +83,18 @@ class DropdownMenu extends React.PureComponent {
element.focus();
}
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':
element = items[0];
if (element) {
@ -93,11 +107,14 @@ class DropdownMenu extends React.PureComponent {
element.focus();
}
break;
case 'Escape':
this.props.onClose();
break;
}
}
handleItemKeyDown = e => {
if (e.key === 'Enter') {
handleItemKeyPress = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
}
@ -122,11 +139,11 @@ class DropdownMenu extends React.PureComponent {
return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
}
const { text, href = '#' } = option;
const { text, href = '#', target = '_blank', method } = option;
return (
<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}
</a>
</li>
@ -193,25 +210,41 @@ export default class Dropdown extends React.PureComponent {
} else {
const { top } = target.getBoundingClientRect();
const placement = top * 2 < innerHeight ? 'bottom' : 'top';
this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click');
}
}
handleClose = () => {
if (this.activeElement) {
this.activeElement.focus();
this.activeElement = null;
}
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) {
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
case 'Escape':
this.handleClose();
break;
}
}
@ -249,7 +282,7 @@ export default class Dropdown extends React.PureComponent {
const open = this.state.id === openDropdownId;
return (
<div onKeyDown={this.handleKeyDown}>
<div>
<IconButton
icon={icon}
title={title}
@ -258,6 +291,9 @@ export default class Dropdown extends React.PureComponent {
size={size}
ref={this.setTargetRef}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
/>
<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,
icon: PropTypes.string.isRequired,
onClick: PropTypes.func,
onMouseDown: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyPress: PropTypes.func,
size: PropTypes.number,
active: 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 () {
const style = {
fontSize: `${this.props.size}px`,
@ -84,6 +105,9 @@ export default class IconButton extends React.PureComponent {
title={title}
className={classes}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
style={style}
tabIndex={tabIndex}
disabled={disabled}
@ -103,6 +127,9 @@ export default class IconButton extends React.PureComponent {
title={title}
className={classes}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
onKeyPress={this.handleKeyPress}
style={style}
tabIndex={tabIndex}
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 () {
window.addEventListener('keyup', this.handleKeyUp, false);
window.addEventListener('keydown', this.handleKeyDown, false);
}
componentWillReceiveProps (nextProps) {
@ -52,6 +74,7 @@ export default class ModalRoot extends React.PureComponent {
componentWillUnmount () {
window.removeEventListener('keyup', this.handleKeyUp);
window.removeEventListener('keydown', this.handleKeyDown);
}
getSiblings = () => {

View File

@ -7,6 +7,7 @@ import Permalink from './permalink';
import classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container';
import Icon from 'mastodon/components/icon';
import { autoPlayGif } from 'mastodon/initial_state';
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 () {
this._updateStatusLinks();
this._updateStatusEmojis();
}
componentDidUpdate () {
this._updateStatusLinks();
this._updateStatusEmojis();
}
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) => {
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) => {
this.node = c;
}
@ -202,45 +229,26 @@ export default class StatusContent extends React.PureComponent {
);
} else if (this.props.onClick) {
const output = [
<div
ref={this.setRef}
tabIndex='0'
key='content'
className={classNames}
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.get('language')}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
/>,
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
</div>,
];
if (this.state.collapsed) {
output.push(readMoreButton);
}
if (status.get('poll')) {
output.push(<PollContainer pollId={status.get('poll')} />);
}
return output;
} else {
const output = [
<div
tabIndex='0'
ref={this.setRef}
className='status__content'
style={directionStyle}
dangerouslySetInnerHTML={content}
lang={status.get('language')}
/>,
];
return (
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
if (status.get('poll')) {
output.push(<PollContainer pollId={status.get('poll')} />);
}
return output;
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
</div>
);
}
}

View File

@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
},
onClose(id) {
dispatch(closeModal());
dispatch(closeModal('ACTIONS'));
dispatch(closeDropdownMenu(id));
},
});

View File

@ -15,6 +15,7 @@ import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
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' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
@ -79,6 +80,47 @@ class Header extends ImmutablePureComponent {
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 () {
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
actionBtn = '';
} 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'])) {
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'])) {
@ -200,7 +242,7 @@ class Header extends ImmutablePureComponent {
const acct = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
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__info'>
{info}

View File

@ -15,6 +15,7 @@ const messages = defineMessages({
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
filters: { id: 'navigation_bar.filters', defaultMessage: 'Muted words' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
});
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.domain_blocks), to: '/domain_blocks' });
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 (
<div className='compose__action-bar'>

View File

@ -117,7 +117,10 @@ class ComposeForm extends ImmutablePureComponent {
handleFocus = () => {
if (this.composeForm && !this.props.singleColumn) {
this.composeForm.scrollIntoView();
const { left, right } = this.composeForm.getBoundingClientRect();
if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
this.composeForm.scrollIntoView();
}
}
}
@ -188,12 +191,12 @@ class ComposeForm extends ImmutablePureComponent {
}
return (
<div className='compose-form' ref={this.setRef}>
<div className='compose-form'>
<WarningContainer />
<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
placeholder={intl.formatMessage(messages.spoiler_placeholder)}
value={this.props.spoilerText}

View File

@ -73,6 +73,19 @@ class PrivacyDropdownMenu extends React.PureComponent {
this.props.onChange(element.getAttribute('data-index'));
}
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':
element = this.node.firstChild;
if (element) {
@ -180,6 +193,9 @@ class PrivacyDropdown extends React.PureComponent {
}
} else {
const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) {
this.activeElement.focus();
}
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
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 = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus();
}
this.setState({ open: false });
}
@ -229,7 +263,7 @@ class PrivacyDropdown extends React.PureComponent {
return (
<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
className='privacy-dropdown__value-icon'
icon={valueOption.icon}
@ -239,6 +273,8 @@ class PrivacyDropdown extends React.PureComponent {
active={open}
inverted
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
style={{ height: null, lineHeight: '27px' }}
/>
</div>

View File

@ -7,6 +7,7 @@ import StatusContainer from '../../../containers/status_container';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Hashtag from '../../../components/hashtag';
import Icon from 'mastodon/components/icon';
import { searchEnabled } from '../../../initial_state';
const messages = defineMessages({
dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
@ -20,6 +21,7 @@ class SearchResults extends ImmutablePureComponent {
suggestions: ImmutablePropTypes.list.isRequired,
fetchSuggestions: PropTypes.func.isRequired,
dismissSuggestion: PropTypes.func.isRequired,
searchTerm: PropTypes.string,
intl: PropTypes.object.isRequired,
};
@ -28,7 +30,7 @@ class SearchResults extends ImmutablePureComponent {
}
render () {
const { intl, results, suggestions, dismissSuggestion } = this.props;
const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
if (results.isEmpty() && !suggestions.isEmpty()) {
return (
@ -76,6 +78,16 @@ class SearchResults extends ImmutablePureComponent {
{results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
</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) {

View File

@ -5,6 +5,7 @@ import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestion
const mapStateToProps = state => ({
results: state.getIn(['search', 'results']),
suggestions: state.getIn(['suggestions', 'items']),
searchTerm: state.getIn(['search', 'searchTerm']),
});
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 (shortname in customEmojis) {
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 false;

View File

@ -11,7 +11,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({
value: state.getIn(['listEditor', 'title']),
disabled: !state.getIn(['listEditor', 'isChanged']),
disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
});
const mapDispatchToProps = dispatch => ({

View File

@ -66,7 +66,7 @@ class NewListForm extends React.PureComponent {
</label>
<IconButton
disabled={disabled}
disabled={disabled || !value}
icon='plus'
title={title}
onClick={this.handleClick}

View File

@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { createSelector } from 'reselect';
import { fetchStatus } from '../../actions/statuses';
import MissingIndicator from '../../components/missing_indicator';
import DetailedStatus from './components/detailed_status';
@ -63,39 +64,58 @@ const messages = defineMessages({
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const getAncestorsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'inReplyTos']),
], (statusId, inReplyTos) => {
let ancestorsIds = Immutable.List();
ancestorsIds = ancestorsIds.withMutations(mutable => {
let id = statusId;
while (id) {
mutable.unshift(id);
id = inReplyTos.get(id);
}
});
return ancestorsIds;
});
const getDescendantsIds = createSelector([
(_, { id }) => id,
state => state.getIn(['contexts', 'replies']),
], (statusId, contextReplies) => {
let descendantsIds = Immutable.List();
descendantsIds = descendantsIds.withMutations(mutable => {
const ids = [statusId];
while (ids.length > 0) {
let id = ids.shift();
const replies = contextReplies.get(id);
if (statusId !== id) {
mutable.push(id);
}
if (replies) {
replies.reverse().forEach(reply => {
ids.unshift(reply);
});
}
}
});
return descendantsIds;
});
const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId });
let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List();
if (status) {
ancestorsIds = ancestorsIds.withMutations(mutable => {
let id = status.get('in_reply_to_id');
while (id) {
mutable.unshift(id);
id = state.getIn(['contexts', 'inReplyTos', id]);
}
});
descendantsIds = descendantsIds.withMutations(mutable => {
const ids = [status.get('id')];
while (ids.length > 0) {
let id = ids.shift();
const replies = state.getIn(['contexts', 'replies', id]);
if (status.get('id') !== id) {
mutable.push(id);
}
if (replies) {
replies.reverse().forEach(reply => {
ids.unshift(reply);
});
}
}
});
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
}
return {

View File

@ -110,6 +110,11 @@ class ColumnsArea extends ImmutablePureComponent {
// React-router does this for us, but too late, feeling laggy.
document.querySelector(currentLinkSelector).classList.remove('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 = () => {
@ -160,7 +165,6 @@ class ColumnsArea extends ImmutablePureComponent {
const { shouldAnimate } = this.state;
const columnIndex = getIndex(this.context.router.history.location.pathname);
this.pendingIndex = null;
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>;

View File

@ -195,6 +195,12 @@ const expandMentions = status => {
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) {
switch(action.type) {
case STORE_HYDRATE:
@ -224,6 +230,7 @@ export default function compose(state = initialState, action) {
}
});
case COMPOSE_SPOILER_TEXT_CHANGE:
if (!state.get('spoiler')) return state;
return state
.set('spoiler_text', action.text)
.set('idempotencyKey', uuid());
@ -352,7 +359,7 @@ export default function compose(state = initialState, action) {
map.set('poll', ImmutableMap({
options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
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_READ,
} 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';
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) {
switch (action.type) {
case CONVERSATIONS_FETCH_REQUEST:
@ -96,6 +102,11 @@ export default function conversations(state = initialState, action) {
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:
return state;
}

View File

@ -10,7 +10,7 @@ export default function modal(state = initialState, action) {
case MODAL_OPEN:
return { modalType: action.modalType, modalProps: action.modalProps };
case MODAL_CLOSE:
return initialState;
return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
default:
return state;
}

View File

@ -11,6 +11,7 @@ import {
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
} from '../actions/accounts';
import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from '../compare_id';
@ -77,8 +78,8 @@ const expandNormalizedNotifications = (state, notifications, next) => {
});
};
const filterNotifications = (state, relationship) => {
return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id));
const filterNotifications = (state, accountIds) => {
return state.update('items', list => list.filterNot(item => item !== null && accountIds.includes(item.get('account'))));
};
const updateTop = (state, top) => {
@ -108,9 +109,11 @@ export default function notifications(state = initialState, action) {
case NOTIFICATIONS_EXPAND_SUCCESS:
return expandNormalizedNotifications(state, action.notifications, action.next);
case ACCOUNT_BLOCK_SUCCESS:
return filterNotifications(state, action.relationship);
return filterNotifications(state, [action.relationship.id]);
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:
return state.set('items', ImmutableList()).set('hasMore', false);
case TIMELINE_DELETE:

View File

@ -16,6 +16,7 @@ const initialState = ImmutableMap({
submitted: false,
hidden: false,
results: ImmutableMap(),
searchTerm: '',
});
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)),
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
hashtags: fromJS(action.results.hashtags),
})).set('submitted', true);
})).set('submitted', true).set('searchTerm', action.searchTerm);
default:
return state;
}

View File

@ -4,6 +4,8 @@ import {
SUGGESTIONS_FETCH_FAIL,
SUGGESTIONS_DISMISS,
} 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';
const initialState = ImmutableMap({
@ -24,6 +26,11 @@ export default function suggestionsReducer(state = initialState, action) {
return state.set('isLoading', false);
case SUGGESTIONS_DISMISS:
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:
return state;
}

View File

@ -1,5 +1,5 @@
import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable';
import { List as ImmutableList, is } from 'immutable';
import { me } from '../initial_state';
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 =>
string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
export const regexFromFilters = filters => {
const regexFromFilters = filters => {
if (filters.size === 0) {
return null;
}
@ -63,6 +61,27 @@ export const regexFromFilters = filters => {
}).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 = () => {
return createSelector(
[
@ -70,10 +89,10 @@ export const makeGetStatus = () => {
(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', state.getIn(['statuses', id, 'reblog']), 'account'])]),
getFilters,
getFiltersRegex,
],
(statusBase, statusReblog, accountBase, accountReblog, filters) => {
(statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
if (!statusBase) {
return null;
}
@ -84,12 +103,12 @@ export const makeGetStatus = () => {
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'))) {
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'));
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);
// 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);
});

View File

@ -44,6 +44,12 @@ function main() {
}
};
const getEmojiAnimationHandler = (swapTo) => {
return ({ target }) => {
target.src = target.getAttribute(swapTo);
};
};
ready(() => {
const locale = document.documentElement.lang;
@ -116,6 +122,9 @@ function main() {
document.head.appendChild(scrollbarWidthStyle);
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 }) => {
@ -178,7 +187,7 @@ function main() {
return ({ target }) => {
const swapSrc = target.getAttribute(swapTo);
//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;
}
};

View File

@ -3996,6 +3996,11 @@ a.status-card.compact:hover {
}
}
.search-results__info {
padding: 10px;
color: $secondary-text-color;
}
.modal-root {
position: relative;
transition: opacity 0.3s linear;

View File

@ -35,7 +35,7 @@ class FeedManager
end
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))
true
end
@ -53,7 +53,7 @@ class FeedManager
end
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))
true
end
@ -105,7 +105,7 @@ class FeedManager
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|
remove_from_feed(:home, into_account.id, status)
remove_from_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?)
end
end
@ -220,7 +220,8 @@ class FeedManager
status = status.reblog if status.reblog?
!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
# Adds a status to an account's feed, returning true if a status was
@ -274,10 +275,11 @@ class FeedManager
# with reblogs, and returning true if a status was removed. As with
# `add_to_feed`, this does not trigger push updates, so callers must
# 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)
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.
status_rank = redis.zrevrank(timeline_key, status.id)
return false if status_rank.nil?
@ -286,6 +288,7 @@ class FeedManager
reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}")
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
# remains in the set. We could pick a random element, but this
# set should generally be small, and it seems ideal to show the
@ -293,12 +296,14 @@ class FeedManager
other_reblog = redis.smembers(reblog_set_key).map(&:to_i).min
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)
# (outside conditional)
else
# If the original is getting deleted, no use for reblog references
redis.del(key(timeline_type, account_id, "reblogs:#{status.id}"))
redis.zrem(reblog_key, status.id)
end
redis.zrem(timeline_key, status.id)

View File

@ -137,11 +137,7 @@ class Formatter
def encode_custom_emojis(html, emojis, animate = false)
return html if emojis.empty?
emoji_map = if animate
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
emoji_map = emojis.each_with_object({}) { |e, h| h[e.shortcode] = [full_asset_url(e.image.url), full_asset_url(e.image.url(:static))] }
i = -1
tag_open_index = nil
@ -157,7 +153,14 @@ class Formatter
emoji = emoji_map[shortcode]
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] : ''
html = before_html + replacement + html[i + 1..-1]
i += replacement.size - (shortcode.size + 2) - 1

View File

@ -69,7 +69,7 @@ class LanguageDetector
new_text = remove_html(text)
new_text.gsub!(FetchLinkCardService::URL_PATTERN, '')
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!(/\s+/, ' ')
new_text

View File

@ -25,6 +25,8 @@ class Sanitize
case env[:node_name]
when 'li'
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.replace(node.children) unless node.text?
end

View File

@ -3,9 +3,11 @@
class SidekiqErrorHandler
def call(*)
yield
rescue Mastodon::HostValidationError => e
Rails.logger.error "#{e.class}: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
rescue Mastodon::HostValidationError
# Do not retry
ensure
socket = Thread.current[:statsd_socket]
socket&.close
Thread.current[:statsd_socket] = nil
end
end

View File

@ -15,7 +15,7 @@ class AccountDomainBlock < ApplicationRecord
include DomainNormalizable
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_relationship_cache

View File

@ -17,10 +17,13 @@ class Admin::AccountAction
:type,
:text,
:report_id,
:warning_preset_id,
:send_email_notification
:warning_preset_id
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!
ApplicationRecord.transaction do

View File

@ -60,7 +60,9 @@ module Attachmentable
end
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
''
end

View File

@ -4,7 +4,7 @@ module DomainNormalizable
extend ActiveSupport::Concern
included do
before_validation :normalize_domain
before_save :normalize_domain
end
private

View File

@ -27,13 +27,15 @@ class CustomEmoji < ApplicationRecord
:(#{SHORTCODE_RE_FRAGMENT}):
(?=[^[: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_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
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 }
scope :local, -> { where(domain: nil) }

View File

@ -35,6 +35,13 @@ class CustomFilter < ApplicationRecord
before_validation :clean_up_contexts
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
def clean_up_contexts

View File

@ -17,7 +17,7 @@ class DomainBlock < ApplicationRecord
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
delegate :count, to: :accounts, prefix: true

View File

@ -12,7 +12,7 @@
class EmailDomainBlock < ApplicationRecord
include DomainNormalizable
validates :domain, presence: true, uniqueness: true
validates :domain, presence: true, uniqueness: true, domain: true
def self.block?(email)
_, domain = email.split('@', 2)

View File

@ -17,7 +17,7 @@
class Invite < ApplicationRecord
include Expireable
belongs_to :user
belongs_to :user, inverse_of: :invites
has_many :users, inverse_of: :invite
scope :available, -> { where(expires_at: nil).or(where('expires_at >= ?', Time.now.utc)) }
@ -25,7 +25,7 @@ class Invite < ApplicationRecord
before_validation :set_code
def valid_for_use?
(max_uses.nil? || uses < max_uses) && !expired?
(max_uses.nil? || uses < max_uses) && !expired? && !(user.nil? || user.disabled?)
end
private

View File

@ -113,7 +113,7 @@ class MediaAttachment < ApplicationRecord
has_attached_file :file,
styles: ->(f) { file_styles 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_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?

View File

@ -77,7 +77,7 @@ class Status < ApplicationRecord
default_scope { recent }
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 :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_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
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 :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 :backups, inverse_of: :user
has_many :invites, inverse_of: :user
has_one :invite_request, class_name: 'UserInviteRequest', inverse_of: :user, dependent: :destroy
accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }

View File

@ -14,7 +14,7 @@ class ActivityPub::UpdatePollSerializer < ActivityPub::Serializer
end
def actor
ActivityPub::TagManager.instance.uri_for(object)
ActivityPub::TagManager.instance.uri_for(object.account)
end
def to

View File

@ -4,7 +4,7 @@ class REST::WebPushSubscriptionSerializer < ActiveModel::Serializer
attributes :id, :endpoint, :alerts, :server_key
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
def server_key

View File

@ -15,6 +15,8 @@ class ActivityPub::ProcessAccountService < BaseService
@domain = domain
@collections = {}
return if auto_suspend?
RedisLock.acquire(lock_options) do |lock|
if lock.acquired?
@account = Account.find_remote(@username, @domain)
@ -55,7 +57,7 @@ class ActivityPub::ProcessAccountService < BaseService
@account.domain = @domain
@account.private_key = nil
@account.suspended_at = domain_block.created_at if auto_suspend?
@account.silenced_at = domain_block.created_at if auto_silence?
@account.silenced_at = domain_block.created_at if auto_silence?
end
def update_account

View File

@ -10,12 +10,24 @@ class AfterBlockDomainFromAccountService < BaseService
@account = account
@domain = domain
clear_notifications!
remove_follows!
reject_existing_followers!
reject_pending_follow_requests!
end
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!
@account.passive_relationships.where(account: Account.where(domain: @domain)).includes(:account).reorder(nil).find_each do |follow|
reject_follow!(follow)

View File

@ -2,43 +2,25 @@
class AfterBlockService < BaseService
def call(account, target_account)
clear_home_feed(account, target_account)
clear_notifications(account, target_account)
clear_conversations(account, target_account)
@account = account
@target_account = target_account
clear_home_feed!
clear_notifications!
clear_conversations!
end
private
def clear_home_feed(account, target_account)
FeedManager.instance.clear_from_timeline(account, target_account)
def clear_home_feed!
FeedManager.instance.clear_from_timeline(@account, @target_account)
end
def clear_conversations(account, target_account)
AccountConversation.where(account: account)
.where('? = ANY(participant_account_ids)', target_account.id)
.in_batches
.destroy_all
def clear_conversations!
AccountConversation.where(account: @account).where('? = ANY(participant_account_ids)', @target_account.id).in_batches.destroy_all
end
def clear_notifications(account, target_account)
Notification.where(account: account)
.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
def clear_notifications!
Notification.where(account: @account).where(from_account: @target_account).in_batches.delete_all
end
end

View File

@ -142,5 +142,7 @@ class BackupService < BaseService
io.write(buffer)
end
end
rescue Errno::ENOENT
Rails.logger.warn "Could not backup file #{filename}: file not found"
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(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)

View File

@ -13,7 +13,7 @@ class FollowService < BaseService
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 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)
# We're already following this account, but we'll call follow! again to

View File

@ -48,7 +48,7 @@ class ResolveAccountService < BaseService
return
end
return if links_missing?
return if links_missing? || auto_suspend?
return Account.find_local(@username) if TagManager.instance.local_domain?(@domain)
RedisLock.acquire(lock_options) do |lock|

View File

@ -66,6 +66,7 @@ class SuspendAccountService < BaseService
@account.user.destroy
else
@account.user.disable!
@account.user.invites.where(uses: 0).destroy_all
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) }/
- 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: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/
@ -75,7 +75,7 @@
- if featured_tag.last_status_at.nil?
= t('accounts.nothing_here')
- 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
= 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
- 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/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
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
def failure_tracker

View File

@ -3,7 +3,7 @@
class Web::PushNotificationWorker
include Sidekiq::Worker
sidekiq_options backtrace: true
sidekiq_options backtrace: true, retry: 5
def perform(subscription_id, notification_id)
subscription = ::Web::PushSubscription.find(subscription_id)

View File

@ -114,6 +114,9 @@ module Mastodon
Doorkeeper::AuthorizationsController.layout 'modal'
Doorkeeper::AuthorizedApplicationsController.layout 'admin'
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

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?
host, port = ENV['STATSD_ADDR'].split(':')
statsd = ::Statsd.new(host, port)
statsd.namespace = ENV.fetch('STATSD_NAMESPACE') { ['Mastodon', Rails.env].join('.') }
$statsd = ::Statsd.new(host, port)
$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(:active_record, :db)
informant.collect(:active_support_cache, :cache)

View File

@ -588,6 +588,8 @@ en:
people:
one: "%{count} person"
other: "%{count} people"
domain_validator:
invalid_domain: is not a valid domain name
errors:
'403': You don't have permission to view this page.
'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
if ENV['SOCKET']
bind 'unix://' + ENV['SOCKET']
bind "unix://#{ENV['SOCKET']}"
else
port ENV.fetch('PORT') { 3000 }
bind "tcp://#{ENV.fetch('BIND', '127.0.0.1')}:#{ENV.fetch('PORT', 3000)}"
end
environment ENV.fetch('RAILS_ENV') { 'development' }

View File

@ -38,7 +38,7 @@ services:
image: tootsuite/mastodon
restart: always
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:
- external_network
- internal_network
@ -58,7 +58,7 @@ services:
image: tootsuite/mastodon
restart: always
env_file: .env.production
command: yarn start
command: node ./streaming
networks:
- external_network
- internal_network

View File

@ -13,23 +13,23 @@ module Mastodon
end
def patch
2
end
def pre
nil
3
end
def flags
''
end
def suffix
''
end
def to_a
[major, minor, patch, pre].compact
[major, minor, patch].compact
end
def to_s
[to_a.join('.'), flags].join
[to_a.join('.'), flags, suffix].join
end
def repository

View File

@ -7,16 +7,10 @@ describe ApplicationController, type: :controller do
include Localized
def success
head 200
render plain: I18n.locale, status: 200
end
end
around do |example|
current_locale = I18n.locale
example.run
I18n.locale = current_locale
end
before do
routes.draw { get 'success' => 'anonymous#success' }
end
@ -25,19 +19,19 @@ describe ApplicationController, type: :controller do
it 'sets available and preferred language' do
request.headers['Accept-Language'] = 'ca-ES, fa'
get 'success'
expect(I18n.locale).to eq :fa
expect(response.body).to eq 'fa'
end
it 'sets available and compatible language if none of available languages are preferred' do
request.headers['Accept-Language'] = 'fa-IR'
get 'success'
expect(I18n.locale).to eq :fa
expect(response.body).to eq 'fa'
end
it 'sets default locale if none of available languages are compatible' do
request.headers['Accept-Language'] = ''
get 'success'
expect(I18n.locale).to eq :en
expect(response.body).to eq 'en'
end
end
@ -48,7 +42,7 @@ describe ApplicationController, type: :controller do
sign_in(user)
get 'success'
expect(I18n.locale).to eq :ca
expect(response.body).to eq 'ca'
end
end

View File

@ -149,6 +149,14 @@ RSpec.describe FeedManager do
status = Fabricate(:status, text: 'shiitake', account: jeff)
expect(FeedManager.instance.filter?(:home, status, alice.id)).to be true
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
@ -239,6 +247,23 @@ RSpec.describe FeedManager do
expect(FeedManager.instance.push_to_home(account, reblogs.last)).to be false
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
account = Fabricate(:account)
reblogged = Fabricate(:status)

View File

@ -261,7 +261,7 @@ RSpec.describe Formatter do
let(:text) { ':coolcat: Beep boop' }
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
@ -330,7 +330,7 @@ RSpec.describe Formatter do
let(:text) { ':coolcat: Beep boop' }
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
@ -338,7 +338,7 @@ RSpec.describe Formatter do
let(:text) { 'Beep :coolcat: boop' }
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
@ -354,7 +354,7 @@ RSpec.describe Formatter do
let(:text) { 'Beep boop :coolcat:' }
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
@ -377,7 +377,7 @@ RSpec.describe Formatter do
let(:text) { '<p>:coolcat: Beep boop<br />' }
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
@ -385,7 +385,7 @@ RSpec.describe Formatter do
let(:text) { '<p>Beep :coolcat: boop</p>' }
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
@ -401,7 +401,7 @@ RSpec.describe Formatter do
let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
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
@ -500,7 +500,7 @@ RSpec.describe Formatter do
let(:text) { ':coolcat: Beep boop' }
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
@ -508,7 +508,7 @@ RSpec.describe Formatter do
let(:text) { 'Beep :coolcat: boop' }
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
@ -524,7 +524,7 @@ RSpec.describe Formatter do
let(:text) { 'Beep boop :coolcat:' }
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
@ -551,7 +551,7 @@ RSpec.describe Formatter do
let(:text) { '<p>:coolcat: Beep boop<br />' }
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
@ -559,7 +559,7 @@ RSpec.describe Formatter do
let(:text) { '<p>Beep :coolcat: boop</p>' }
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
@ -575,7 +575,7 @@ RSpec.describe Formatter do
let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
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

View File

@ -32,11 +32,11 @@ describe LanguageDetector do
expect(result).to eq 'Our website is and also'
end
it 'strips #hashtags from strings before detection' do
string = 'Hey look at all the #animals and #fish'
it 'converts #hashtags back to normal text before detection' do
string = 'Hey look at all the #animals and #FishAndChips'
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

View File

@ -22,5 +22,9 @@ describe Sanitize::Config 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>'
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

View File

@ -3,27 +3,33 @@ require 'rails_helper'
RSpec.describe Invite, type: :model do
describe '#valid_for_use?' 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
end
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
end
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
end
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
end
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
end
end

View File

@ -31,7 +31,47 @@ RSpec.describe Tag, type: :model do
end
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

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

@ -672,7 +672,7 @@ const attachServerWithConfig = (server, onSuccess) => {
}
});
} 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) {
onSuccess(`${server.address().address}:${server.address().port}`);
}