diff --git a/CHANGELOG.md b/CHANGELOG.md index 539fec531..a17fbf8f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Dockerfile b/Dockerfile index 3acbc9d4c..d8c7e0f0c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/Gemfile.lock b/Gemfile.lock index 43d98bbdc..bda915dfa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/Procfile b/Procfile index b18e4b6be..d48b0373b 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,14 @@ -web: bundle exec puma -C config/puma.rb +web: if [ "$RUN_STREAMING" != "true" ]; then BIND=0.0.0.0 bundle exec puma -C config/puma.rb; else BIND=0.0.0.0 node ./streaming; fi worker: bundle exec sidekiq + +# 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 ::DATABASE +# heroku addons:attach ::REDIS +# +# and let the main app use the separate app: +# +# heroku config:set STREAMING_API_BASE_URL=wss://.herokuapp.com -a diff --git a/app/chewy/statuses_index.rb b/app/chewy/statuses_index.rb index 8ce413f8a..f5983a5a5 100644 --- a/app/chewy/statuses_index.rb +++ b/app/chewy/statuses_index.rb @@ -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 diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 52a51fd62..9f608a851 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -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 diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 377cac8ad..7129656da 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb index 8cd8f8e79..13cb4caf1 100644 --- a/app/controllers/api/v1/accounts/statuses_controller.rb +++ b/app/controllers/api/v1/accounts/statuses_controller.rb @@ -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 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 9274d85a9..6b8411402 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb index 145549bcd..b43859d9d 100644 --- a/app/controllers/concerns/localized.rb +++ b/app/controllers/concerns/localized.rb @@ -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 diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index fdb3a0962..de5280305 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -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 diff --git a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb index d87117a50..02652a36c 100644 --- a/app/controllers/settings/two_factor_authentication/confirmations_controller.rb +++ b/app/controllers/settings/two_factor_authentication/confirmations_controller.rb @@ -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! diff --git a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb index c78166c65..874bf532b 100644 --- a/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb +++ b/app/controllers/settings/two_factor_authentication/recovery_codes_controller.rb @@ -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 diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 300fb48a9..fbf97d374 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -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), }, { diff --git a/app/javascript/mastodon/actions/domain_blocks.js b/app/javascript/mastodon/actions/domain_blocks.js index 0445a5e10..34a33a654 100644 --- a/app/javascript/mastodon/actions/domain_blocks.js +++ b/app/javascript/mastodon/actions/domain_blocks.js @@ -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)); diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 5badb0c49..5e7e78e69 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -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(//g, '\n').replace(/<\/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(//g, '\n').replace(/<\/p>

/g, '\n\n'); const emojiMap = makeEmojiMap(normalStatus); normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js index 80e15c28e..3d0299db5 100644 --- a/app/javascript/mastodon/actions/modal.js +++ b/app/javascript/mastodon/actions/modal.js @@ -9,8 +9,9 @@ export function openModal(type, props) { }; }; -export function closeModal() { +export function closeModal(type) { return { type: MODAL_CLOSE, + modalType: type, }; }; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 88788eec9..56c952cb0 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -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)) { diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js index 7c06670eb..0974fdd15 100644 --- a/app/javascript/mastodon/actions/search.js +++ b/app/javascript/mastodon/actions/search.js @@ -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, }; }; diff --git a/app/javascript/mastodon/components/button.js b/app/javascript/mastodon/components/button.js index 51e2e6a7a..eb8dd7dc8 100644 --- a/app/javascript/mastodon/components/button.js +++ b/app/javascript/mastodon/components/button.js @@ -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} diff --git a/app/javascript/mastodon/components/display_name.js b/app/javascript/mastodon/components/display_name.js index 6b9dd6f81..70ef82789 100644 --- a/app/javascript/mastodon/components/display_name.js +++ b/app/javascript/mastodon/components/display_name.js @@ -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 ( - + {displayName} {suffix} ); diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 91b65a02f..d423378c1 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -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

  • ; } - const { text, href = '#' } = option; + const { text, href = '#', target = '_blank', method } = option; return (
  • - + {text}
  • @@ -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 ( -
    +
    diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 9d8a8d06b..401675052 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -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} diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js index ef1156571..5d4f4bbe1 100644 --- a/app/javascript/mastodon/components/modal_root.js +++ b/app/javascript/mastodon/components/modal_root.js @@ -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 = () => { diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 06f5b4aad..ba6d3c296 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -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 = [ -
    , +
    +
    + + {!!status.get('poll') && } +
    , ]; if (this.state.collapsed) { output.push(readMoreButton); } - if (status.get('poll')) { - output.push(); - } - return output; } else { - const output = [ -
    , - ]; + return ( +
    +
    - if (status.get('poll')) { - output.push(); - } - - return output; + {!!status.get('poll') && } +
    + ); } } diff --git a/app/javascript/mastodon/containers/dropdown_menu_container.js b/app/javascript/mastodon/containers/dropdown_menu_container.js index 73c8a1e53..f79b19202 100644 --- a/app/javascript/mastodon/containers/dropdown_menu_container.js +++ b/app/javascript/mastodon/containers/dropdown_menu_container.js @@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({ }) : openDropdownMenu(id, dropdownPlacement, keyboard)); }, onClose(id) { - dispatch(closeModal()); + dispatch(closeModal('ACTIONS')); dispatch(closeDropdownMenu(id)); }, }); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index e5b60e33e..ac97bad71 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -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 =