...
 
Commits (63)
......@@ -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
......
......@@ -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
......
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>
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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!
......
......@@ -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
......
......@@ -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),
}, {
......
......@@ -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));
......
......@@ -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;
......
......@@ -9,8 +9,9 @@ export function openModal(type, props) {
};
};
export function closeModal() {
export function closeModal(type) {
return {
type: MODAL_CLOSE,
modalType: type,
};
};
......@@ -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)) {
......
......@@ -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,
};
};
......
......@@ -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>
......
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>
);
......
......@@ -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}>
......
......@@ -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}
......
......@@ -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 = () => {
......
......@@ -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')}
/>,
];
if (status.get('poll')) {
output.push(<PollContainer pollId={status.get('poll')} />);
}
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')} />
return output;
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
</div>
);
}
}
......
......@@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
},
onClose(id) {
dispatch(closeModal());
dispatch(closeModal('ACTIONS'));
dispatch(closeDropdownMenu(id));
},
});
......
......@@ -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}
......
......@@ -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'>
......
......@@ -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}
......
......@@ -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>
......
......@@ -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) {
......
......@@ -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 => ({
......
......@@ -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;
......
......@@ -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 => ({
......
......@@ -66,7 +66,7 @@ class NewListForm extends React.PureComponent {
</label>
<IconButton
disabled={disabled}
disabled={disabled || !value}
icon='plus'
title={title}
onClick={this.handleClick}
......
......@@ -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 mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId });
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];
if (status) {
ancestorsIds = ancestorsIds.withMutations(mutable => {
let id = status.get('in_reply_to_id');
while (ids.length > 0) {
let id = ids.shift();
const replies = contextReplies.get(id);
while (id) {
mutable.unshift(id);
id = state.getIn(['contexts', 'inReplyTos', id]);
if (statusId !== id) {
mutable.push(id);
}
});
descendantsIds = descendantsIds.withMutations(mutable => {
const ids = [status.get('id')];
if (replies) {
replies.reverse().forEach(reply => {
ids.unshift(reply);
});
}
}
});
while (ids.length > 0) {
let id = ids.shift();
const replies = state.getIn(['contexts', 'replies', id]);
return descendantsIds;
});
if (status.get('id') !== id) {
mutable.push(id);
}
const mapStateToProps = (state, props) => {
const status = getStatus(state, { id: props.params.statusId });
let ancestorsIds = Immutable.List();
let descendantsIds = Immutable.List();
if (replies) {
replies.reverse().forEach(reply => {
ids.unshift(reply);
});
}
}
});
if (status) {
ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
descendantsIds = getDescendantsIds(state, { id: status.get('id') });
}
return {
......
......@@ -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');