Browse Source

Merge branch 'master' into live

master
Zac 1 week ago
parent
commit
e12585184a
100 changed files with 984 additions and 679 deletions
  1. 2
    2
      Gemfile
  2. 11
    11
      Gemfile.lock
  3. 1
    1
      app/controllers/admin/dashboard_controller.rb
  4. 22
    14
      app/controllers/admin/tags_controller.rb
  5. 17
    0
      app/controllers/api/v1/trends_controller.rb
  6. 1
    1
      app/controllers/settings/preferences_controller.rb
  7. 1
    10
      app/controllers/well_known/webfinger_controller.rb
  8. 3
    2
      app/helpers/admin/filter_helper.rb
  9. 2
    1
      app/javascript/flavours/glitch/actions/modal.js
  10. 26
    18
      app/javascript/flavours/glitch/components/dropdown_menu.js
  11. 27
    0
      app/javascript/flavours/glitch/components/icon_button.js
  12. 23
    0
      app/javascript/flavours/glitch/components/modal_root.js
  13. 13
    8
      app/javascript/flavours/glitch/components/status_content.js
  14. 1
    1
      app/javascript/flavours/glitch/containers/dropdown_menu_container.js
  15. 99
    93
      app/javascript/flavours/glitch/features/compose/components/dropdown.js
  16. 146
    108
      app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
  17. 1
    1
      app/javascript/flavours/glitch/features/compose/components/options.js
  18. 15
    1
      app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
  19. 1
    1
      app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js
  20. 1
    1
      app/javascript/flavours/glitch/features/lists/components/new_list_form.js
  21. 1
    1
      app/javascript/flavours/glitch/reducers/modal.js
  22. 30
    6
      app/javascript/flavours/glitch/styles/components/index.scss
  23. 8
    0
      app/javascript/flavours/glitch/util/resize_image.js
  24. 2
    1
      app/javascript/mastodon/actions/modal.js
  25. 12
    3
      app/javascript/mastodon/components/column.js
  26. 14
    1
      app/javascript/mastodon/components/column_back_button.js
  27. 10
    2
      app/javascript/mastodon/components/column_header.js
  28. 26
    18
      app/javascript/mastodon/components/dropdown_menu.js
  29. 18
    0
      app/javascript/mastodon/components/icon_button.js
  30. 23
    0
      app/javascript/mastodon/components/modal_root.js
  31. 0
    90
      app/javascript/mastodon/components/status_content.js
  32. 1
    1
      app/javascript/mastodon/containers/dropdown_menu_container.js
  33. 3
    2
      app/javascript/mastodon/features/account_gallery/index.js
  34. 1
    1
      app/javascript/mastodon/features/account_timeline/index.js
  35. 1
    1
      app/javascript/mastodon/features/blocks/index.js
  36. 1
    1
      app/javascript/mastodon/features/community_timeline/index.js
  37. 37
    1
      app/javascript/mastodon/features/compose/components/privacy_dropdown.js
  38. 14
    1
      app/javascript/mastodon/features/compose/components/text_icon_button.js
  39. 1
    1
      app/javascript/mastodon/features/direct_timeline/index.js
  40. 1
    1
      app/javascript/mastodon/features/domain_blocks/index.js
  41. 1
    1
      app/javascript/mastodon/features/favourited_statuses/index.js
  42. 1
    1
      app/javascript/mastodon/features/favourites/index.js
  43. 1
    1
      app/javascript/mastodon/features/follow_requests/index.js
  44. 1
    1
      app/javascript/mastodon/features/followers/index.js
  45. 1
    1
      app/javascript/mastodon/features/following/index.js
  46. 1
    1
      app/javascript/mastodon/features/getting_started/index.js
  47. 1
    1
      app/javascript/mastodon/features/hashtag_timeline/index.js
  48. 1
    1
      app/javascript/mastodon/features/home_timeline/index.js
  49. 2
    2
      app/javascript/mastodon/features/keyboard_shortcuts/index.js
  50. 1
    1
      app/javascript/mastodon/features/list_editor/components/edit_list_form.js
  51. 2
    2
      app/javascript/mastodon/features/list_timeline/index.js
  52. 1
    1
      app/javascript/mastodon/features/lists/components/new_list_form.js
  53. 1
    1
      app/javascript/mastodon/features/lists/index.js
  54. 1
    1
      app/javascript/mastodon/features/mutes/index.js
  55. 1
    1
      app/javascript/mastodon/features/notifications/index.js
  56. 1
    1
      app/javascript/mastodon/features/pinned_statuses/index.js
  57. 1
    1
      app/javascript/mastodon/features/public_timeline/index.js
  58. 1
    1
      app/javascript/mastodon/features/reblogs/index.js
  59. 10
    1
      app/javascript/mastodon/features/status/components/card.js
  60. 5
    3
      app/javascript/mastodon/features/status/index.js
  61. 1
    1
      app/javascript/mastodon/features/ui/components/column_loading.js
  62. 7
    3
      app/javascript/mastodon/features/ui/components/tabs_bar.js
  63. 2
    2
      app/javascript/mastodon/reducers/compose.js
  64. 1
    1
      app/javascript/mastodon/reducers/modal.js
  65. 0
    10
      app/javascript/mastodon/utils/idna.js
  66. 8
    0
      app/javascript/mastodon/utils/resize_image.js
  67. 1
    1
      app/javascript/styles/mastodon/basics.scss
  68. 63
    12
      app/javascript/styles/mastodon/components.scss
  69. 9
    5
      app/lib/feed_manager.rb
  70. 10
    0
      app/mailers/admin_mailer.rb
  71. 11
    0
      app/models/application_record.rb
  72. 57
    10
      app/models/tag.rb
  73. 25
    18
      app/models/trending_tags.rb
  74. 4
    0
      app/models/user.rb
  75. 2
    2
      app/policies/tag_policy.rb
  76. 0
    1
      app/serializers/webfinger_serializer.rb
  77. 2
    19
      app/validators/disallowed_hashtags_validator.rb
  78. 1
    2
      app/views/accounts/show.html.haml
  79. 1
    1
      app/views/admin/dashboard/index.html.haml
  80. 14
    10
      app/views/admin/tags/_tag.html.haml
  81. 14
    12
      app/views/admin/tags/index.html.haml
  82. 16
    0
      app/views/admin/tags/show.html.haml
  83. 5
    0
      app/views/admin_mailer/new_trending_tag.text.erb
  84. 1
    0
      app/views/settings/preferences/notifications/show.html.haml
  85. 0
    51
      app/views/well_known/webfinger/show.xml.ruby
  86. 12
    6
      config/locales/en.yml
  87. 7
    0
      config/locales/simple_form.en.yml
  88. 1
    1
      config/navigation.rb
  89. 2
    7
      config/routes.rb
  90. 1
    0
      config/settings.yml
  91. 5
    0
      db/migrate/20190729185330_add_score_to_tags.rb
  92. 9
    0
      db/migrate/20190805123746_add_capabilities_to_tags.rb
  93. 13
    5
      lib/mastodon/domains_cli.rb
  94. 3
    3
      package.json
  95. 4
    52
      spec/controllers/admin/tags_controller_spec.rb
  96. 0
    11
      spec/controllers/well_known/webfinger_controller_spec.rb
  97. 17
    0
      spec/lib/feed_manager_spec.rb
  98. 2
    2
      spec/models/tag_spec.rb
  99. 1
    1
      spec/policies/tag_policy_spec.rb
  100. 0
    0
      spec/requests/webfinger_request_spec.rb

+ 2
- 2
Gemfile View File

@@ -113,7 +113,7 @@ group :production, :test do
end

group :test do
gem 'capybara', '~> 3.27'
gem 'capybara', '~> 3.28'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.9'
gem 'microformats', '~> 4.1'
@@ -133,7 +133,7 @@ group :development do
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
gem 'rubocop', '~> 0.73', require: false
gem 'rubocop', '~> 0.74', require: false
gem 'rubocop-rails', '~> 2.2', require: false
gem 'brakeman', '~> 4.6', require: false
gem 'bundler-audit', '~> 0.6', require: false

+ 11
- 11
Gemfile.lock View File

@@ -150,7 +150,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.27.0)
capybara (3.28.0)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@@ -209,9 +209,9 @@ GEM
unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.1.0)
railties (>= 5)
dotenv (2.7.4)
dotenv-rails (2.7.4)
dotenv (= 2.7.4)
dotenv (2.7.5)
dotenv-rails (2.7.5)
dotenv (= 2.7.5)
railties (>= 3.2, < 6.1)
elasticsearch (6.0.2)
elasticsearch-api (= 6.0.2)
@@ -274,7 +274,7 @@ GEM
railties (>= 4.0.1)
hamster (3.0.0)
concurrent-ruby (~> 1.0)
hashdiff (0.4.0)
hashdiff (1.0.0)
hashie (3.6.0)
heapy (0.1.4)
highline (2.0.1)
@@ -478,7 +478,7 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.0.4)
rails-html-sanitizer (1.1.0)
loofah (~> 2.2, >= 2.2.2)
rails-i18n (5.1.3)
i18n (>= 0.7, < 2)
@@ -492,7 +492,7 @@ GEM
rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0)
rainbow (3.0.0)
rake (12.3.2)
rake (12.3.3)
rdf (3.0.12)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
@@ -548,7 +548,7 @@ GEM
rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0)
rspec-support (3.8.0)
rubocop (0.73.0)
rubocop (0.74.0)
jaro_winkler (~> 1.5.1)
parallel (~> 1.10)
parser (>= 2.6)
@@ -645,7 +645,7 @@ GEM
uniform_notifier (1.12.1)
warden (1.2.8)
rack (>= 2.0.6)
webmock (3.6.0)
webmock (3.6.2)
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -684,7 +684,7 @@ DEPENDENCIES
capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 3.27)
capybara (~> 3.28)
charlock_holmes (~> 0.7.6)
chewy (~> 5.0)
cld3 (~> 3.2.4)
@@ -766,7 +766,7 @@ DEPENDENCIES
rqrcode (~> 0.10)
rspec-rails (~> 3.8)
rspec-sidekiq (~> 3.0)
rubocop (~> 0.73)
rubocop (~> 0.74)
rubocop-rails (~> 2.2)
sanitize (~> 5.0)
sidekiq (~> 5.2)

+ 1
- 1
app/controllers/admin/dashboard_controller.rb View File

@@ -27,7 +27,7 @@ module Admin
@saml_enabled = ENV['SAML_ENABLED'] == 'true'
@pam_enabled = ENV['PAM_ENABLED'] == 'true'
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
@trending_hashtags = TrendingTags.get(7)
@trending_hashtags = TrendingTags.get(10, filtered: false)
@profile_directory = Setting.profile_directory
@timeline_preview = Setting.timeline_preview
@keybase_integration = Setting.enable_keybase

+ 22
- 14
app/controllers/admin/tags_controller.rb View File

@@ -4,41 +4,49 @@ module Admin
class TagsController < BaseController
before_action :set_tags, only: :index
before_action :set_tag, except: :index
before_action :set_filter_params

def index
authorize :tag, :index?
end

def hide
authorize @tag, :hide?
@tag.account_tag_stat.update!(hidden: true)
redirect_to admin_tags_path(@filter_params)
def show
authorize @tag, :show?
end

def unhide
authorize @tag, :unhide?
@tag.account_tag_stat.update!(hidden: false)
redirect_to admin_tags_path(@filter_params)
def update
authorize @tag, :update?

if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
redirect_to admin_tag_path(@tag.id)
else
render :show
end
end

private

def set_tags
@tags = Tag.discoverable
@tags.merge!(Tag.hidden) if filter_params[:hidden]
@tags = filtered_tags.page(params[:page])
end

def set_tag
@tag = Tag.find(params[:id])
end

def set_filter_params
@filter_params = filter_params.to_hash.symbolize_keys
def filtered_tags
scope = Tag
scope = scope.discoverable if filter_params[:context] == 'directory'
scope = scope.reviewed if filter_params[:review] == 'reviewed'
scope = scope.pending_review if filter_params[:review] == 'pending_review'
scope.reorder(score: :desc)
end

def filter_params
params.permit(:hidden)
params.slice(:context, :review).permit(:context, :review)
end

def tag_params
params.require(:tag).permit(:name, :trendable, :usable, :listable)
end
end
end

+ 17
- 0
app/controllers/api/v1/trends_controller.rb View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true

class Api::V1::TrendsController < Api::BaseController
before_action :set_tags

respond_to :json

def index
render json: @tags, each_serializer: REST::TagSerializer
end

private

def set_tags
@tags = TrendingTags.get(limit_param(10))
end
end

+ 1
- 1
app/controllers/settings/preferences_controller.rb View File

@@ -58,7 +58,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_default_content_type,
:setting_use_blurhash,
:setting_use_pending_items,
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag),
interactions: %i(must_be_follower must_be_following must_be_following_dm)
)
end

+ 1
- 10
app/controllers/well_known/webfinger_controller.rb View File

@@ -9,17 +9,8 @@ module WellKnown
def show
@account = Account.find_local!(username_from_resource)

respond_to do |format|
format.any(:json, :html) do
render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json'
end

format.xml do
render content_type: 'application/xrd+xml'
end
end

expires_in 3.days, public: true
render json: @account, serializer: WebfingerSerializer, content_type: 'application/jrd+json'
rescue ActiveRecord::RecordNotFound
head 404
end

+ 3
- 2
app/helpers/admin/filter_helper.rb View File

@@ -5,15 +5,16 @@ module Admin::FilterHelper
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
INVITE_FILTER = %i(available expired).freeze
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
TAGS_FILTERS = %i(hidden).freeze
TAGS_FILTERS = %i(context review).freeze
INSTANCES_FILTERS = %i(limited by_domain).freeze
FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).freeze

FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS + INSTANCES_FILTERS + FOLLOWERS_FILTERS

def filter_link_to(text, link_to_params, link_class_params = link_to_params)
new_url = filtered_url_for(link_to_params)
new_url = filtered_url_for(link_to_params)
new_class = filtered_url_for(link_class_params)

link_to text, new_url, class: filter_link_class(new_class)
end


+ 2
- 1
app/javascript/flavours/glitch/actions/modal.js View File

@@ -9,8 +9,9 @@ export function openModal(type, props) {
};
};

export function closeModal() {
export function closeModal(type) {
return {
type: MODAL_CLOSE,
modalType: type,
};
};

+ 26
- 18
app/javascript/flavours/glitch/components/dropdown_menu.js View File

@@ -45,7 +45,10 @@ 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();
this.activeElement = document.activeElement;
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus();
}
this.setState({ mounted: true });
}

@@ -53,6 +56,9 @@ class DropdownMenu extends React.PureComponent {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('keydown', this.handleKeyDown, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.activeElement) {
this.activeElement.focus();
}
}

setRef = c => {
@@ -81,6 +87,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 +111,14 @@ class DropdownMenu extends React.PureComponent {
element.focus();
}
break;
case 'Escape':
this.props.onClose();
break;
}
}

handleItemKeyDown = e => {
if (e.key === 'Enter') {
handleItemKeyUp = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
}
@@ -126,7 +147,7 @@ class DropdownMenu extends React.PureComponent {

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='_blank' rel='noopener' role='button' tabIndex='0' ref={i === 0 ? this.setFocusRef : null} onClick={this.handleClick} onKeyUp={this.handleItemKeyUp} data-index={i}>
{text}
</a>
</li>
@@ -202,19 +223,6 @@ export default class Dropdown extends React.PureComponent {
this.props.onClose(this.state.id);
}

handleKeyDown = e => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.preventDefault();
break;
case 'Escape':
this.handleClose();
break;
}
}

handleItemClick = (i, e) => {
const { action, to } = this.props.items[i];

@@ -248,7 +256,7 @@ export default class Dropdown extends React.PureComponent {
const open = this.state.id === openDropdownId;

return (
<div onKeyDown={this.handleKeyDown}>
<div>
<IconButton
icon={icon}
title={ariaLabel}

+ 27
- 0
app/javascript/flavours/glitch/components/icon_button.js View File

@@ -11,6 +11,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,
@@ -43,6 +46,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 () {
let style = {
fontSize: `${this.props.size}px`,
@@ -105,6 +126,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}
@@ -124,6 +148,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}

+ 23
- 0
app/javascript/flavours/glitch/components/modal_root.js View File

@@ -26,8 +26,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);
this.history = this.context.router ? this.context.router.history : createHistory();
}

@@ -60,6 +82,7 @@ export default class ModalRoot extends React.PureComponent {

componentWillUnmount () {
window.removeEventListener('keyup', this.handleKeyUp);
window.removeEventListener('keydown', this.handleKeyDown);
}

handleModalClose () {

+ 13
- 8
app/javascript/flavours/glitch/components/status_content.js View File

@@ -106,14 +106,19 @@ export default class StatusContent extends React.PureComponent {
link.setAttribute('title', link.href);
link.classList.add('unhandled-link');

if (tagLinks && isLinkMisleading(link)) {
// Add a tag besides the link to display its origin

const tag = document.createElement('span');
tag.classList.add('link-origin-tag');
tag.textContent = `[${new URL(link.href).host}]`;
link.insertAdjacentText('beforeend', ' ');
link.insertAdjacentElement('beforeend', tag);
try {
if (tagLinks && isLinkMisleading(link)) {
// Add a tag besides the link to display its origin

const tag = document.createElement('span');
tag.classList.add('link-origin-tag');
tag.textContent = `[${new URL(link.href).host}]`;
link.insertAdjacentText('beforeend', ' ');
link.insertAdjacentElement('beforeend', tag);
}
} catch (e) {
// The URL is invalid, remove the href just to be safe
if (tagLinks && e instanceof TypeError) link.removeAttribute('href');
}
}


+ 1
- 1
app/javascript/flavours/glitch/containers/dropdown_menu_container.js View File

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

+ 99
- 93
app/javascript/flavours/glitch/features/compose/components/dropdown.js View File

@@ -12,33 +12,101 @@ import DropdownMenu from './dropdown_menu';
import { isUserTouching } from 'flavours/glitch/util/is_mobile';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';

// Handlers.
const handlers = {
// The component.
export default class ComposerOptionsDropdown extends React.PureComponent {

// Closes the dropdown.
handleClose () {
this.setState({ open: false });
},
static propTypes = {
active: PropTypes.bool,
disabled: PropTypes.bool,
icon: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.string,
meta: PropTypes.node,
name: PropTypes.string.isRequired,
on: PropTypes.bool,
text: PropTypes.node,
})).isRequired,
onModalOpen: PropTypes.func,
onModalClose: PropTypes.func,
title: PropTypes.string,
value: PropTypes.string,
onChange: PropTypes.func,
};

state = {
needsModalUpdate: false,
open: false,
openedViaKeyboard: undefined,
placement: 'bottom',
};

// The enter key toggles the dropdown's open state, and the escape
// key closes it.
handleKeyDown ({ key }) {
const {
handleClose,
handleToggle,
} = this.handlers;
switch (key) {
// Toggles opening and closing the dropdown.
handleToggle = ({ target, type }) => {
const { onModalOpen } = this.props;
const { open } = this.state;

if (isUserTouching()) {
if (this.state.open) {
this.props.onModalClose();
} else {
const modal = this.handleMakeModal();
if (modal && onModalOpen) {
onModalOpen(modal);
}
}
} 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, openedViaKeyboard: type !== 'click' });
}
}

handleKeyDown = (e) => {
switch (e.key) {
case 'Escape':
this.handleClose();
break;
}
}

handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
}

handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
handleToggle(key);
this.handleMouseDown();
break;
case 'Escape':
handleClose();
}
}

handleKeyPress = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleToggle(e);
e.stopPropagation();
e.preventDefault();
break;
}
},
}

handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus();
}
this.setState({ open: false });
}

// Creates an action modal object.
handleMakeModal () {
handleMakeModal = () => {
const component = this;
const {
items,
@@ -76,74 +144,31 @@ const handlers = {
})
),
};
},

// Toggles opening and closing the dropdown.
handleToggle ({ target }) {
const { handleMakeModal } = this.handlers;
const { onModalOpen } = this.props;
const { open } = this.state;

// If this is a touch device, we open a modal instead of the
// dropdown.
if (isUserTouching()) {

// This gets the modal to open.
const modal = handleMakeModal();

// If we can, we then open the modal.
if (modal && onModalOpen) {
onModalOpen(modal);
return;
}
}

const { top } = target.getBoundingClientRect();
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
// Otherwise, we just set our state to open.
this.setState({ open: !open });
},
}

// If our modal is open and our props update, we need to also update
// the modal.
handleUpdate () {
const { handleMakeModal } = this.handlers;
handleUpdate = () => {
const { onModalOpen } = this.props;
const { needsModalUpdate } = this.state;

// Gets our modal object.
const modal = handleMakeModal();
const modal = this.handleMakeModal();

// Reopens the modal with the new object.
if (needsModalUpdate && modal && onModalOpen) {
onModalOpen(modal);
}
},
};

// The component.
export default class ComposerOptionsDropdown extends React.PureComponent {

// Constructor.
constructor (props) {
super(props);
assignHandlers(this, handlers);
this.state = {
needsModalUpdate: false,
open: false,
placement: 'bottom',
};
}

// Updates our modal as necessary.
componentDidUpdate (prevProps) {
const { handleUpdate } = this.handlers;
const { items } = this.props;
const { needsModalUpdate } = this.state;
if (needsModalUpdate && items.find(
(item, i) => item.on !== prevProps.items[i].on
)) {
handleUpdate();
this.handleUpdate();
this.setState({ needsModalUpdate: false });
}
}
@@ -151,11 +176,6 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
// Rendering.
render () {
const {
handleClose,
handleKeyDown,
handleToggle,
} = this.handlers;
const {
active,
disabled,
title,
@@ -175,14 +195,18 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
return (
<div
className={computedClass}
onKeyDown={handleKeyDown}
onKeyDown={this.handleKeyDown}
>
<IconButton
active={open || active}
className='value'
disabled={disabled}
icon={icon}
onClick={handleToggle}
inverted
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
size={18}
style={{
height: null,
@@ -199,8 +223,9 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
<DropdownMenu
items={items}
onChange={onChange}
onClose={handleClose}
onClose={this.handleClose}
value={value}
openedViaKeyboard={this.state.openedViaKeyboard}
/>
</Overlay>
</div>
@@ -208,22 +233,3 @@ export default class ComposerOptionsDropdown extends React.PureComponent {
}

}

// Props.
ComposerOptionsDropdown.propTypes = {
active: PropTypes.bool,
disabled: PropTypes.bool,
icon: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
icon: PropTypes.string,
meta: PropTypes.node,
name: PropTypes.string.isRequired,
on: PropTypes.bool,
text: PropTypes.node,
})).isRequired,
onChange: PropTypes.func,
onModalClose: PropTypes.func,
onModalOpen: PropTypes.func,
title: PropTypes.string,
value: PropTypes.string,
};

+ 146
- 108
app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js View File

@@ -14,91 +14,6 @@ import { withPassive } from 'flavours/glitch/util/dom_helpers';
import Motion from 'flavours/glitch/util/optional_motion';
import { assignHandlers } from 'flavours/glitch/util/react_helpers';

class ComposerOptionsDropdownContentItem extends ImmutablePureComponent {

static propTypes = {
active: PropTypes.bool,
name: PropTypes.string,
onChange: PropTypes.func,
onClose: PropTypes.func,
options: PropTypes.shape({
icon: PropTypes.string,
meta: PropTypes.node,
on: PropTypes.bool,
text: PropTypes.node,
}),
};

handleActivate = (e) => {
const {
name,
onChange,
onClose,
options: { on },
} = this.props;

// If the escape key was pressed, we close the dropdown.
if (e.key === 'Escape' && onClose) {
onClose();

// Otherwise, we both close the dropdown and change the value.
} else if (onChange && (!e.key || e.key === 'Enter')) {
e.preventDefault(); // Prevents change in focus on click
if ((on === null || typeof on === 'undefined') && onClose) {
onClose();
}
onChange(name);
}
}

// Rendering.
render () {
const {
active,
options: {
icon,
meta,
on,
text,
},
} = this.props;
const computedClass = classNames('composer--options--dropdown--content--item', {
active,
lengthy: meta,
'toggled-off': !on && on !== null && typeof on !== 'undefined',
'toggled-on': on,
'with-icon': icon,
});

let prefix = null;

if (on !== null && typeof on !== 'undefined') {
prefix = <Toggle checked={on} onChange={this.handleActivate} />;
} else if (icon) {
prefix = <Icon className='icon' fullwidth icon={icon} />
}

// The result.
return (
<div
className={computedClass}
onClick={this.handleActivate}
onKeyDown={this.handleActivate}
role='button'
tabIndex='0'
>
{prefix}

<div className='content'>
<strong>{text}</strong>
{meta}
</div>
</div>
);
}

};

// The spring to use with our motion.
const springMotion = spring(1, {
damping: 35,
@@ -116,10 +31,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
on: PropTypes.bool,
text: PropTypes.node,
})),
onChange: PropTypes.func,
onClose: PropTypes.func,
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
style: PropTypes.object,
value: PropTypes.string,
openedViaKeyboard: PropTypes.bool,
};

static defaultProps = {
@@ -128,14 +44,13 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent

state = {
mounted: false,
value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
};

// When the document is clicked elsewhere, we close the dropdown.
handleDocumentClick = ({ target }) => {
const { node } = this;
const { onClose } = this.props;
if (onClose && node && !node.contains(target)) {
onClose();
handleDocumentClick = (e) => {
if (this.node && !this.node.contains(e.target)) {
this.props.onClose();
}
}

@@ -148,6 +63,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
componentDidMount () {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, withPassive);
if (this.focusedItem) {
this.focusedItem.focus();
} else {
this.node.firstChild.focus();
}
this.setState({ mounted: true });
}

@@ -157,6 +77,138 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
}

handleClick = (e) => {
const name = e.currentTarget.getAttribute('data-index');

const {
onChange,
onClose,
items,
} = this.props;

const { on } = this.props.items.find(item => item.name === name);
e.preventDefault(); // Prevents change in focus on click
if ((on === null || typeof on === 'undefined')) {
onClose();
}
onChange(name);
}

// Handle changes differently whether the dropdown is a list of options or actions
handleChange = (name) => {
if (this.props.value) {
this.props.onChange(name);
} else {
this.setState({ value: name });
}
}

handleKeyDown = e => {
const { items } = this.props;
const name = e.currentTarget.getAttribute('data-index');
const index = items.findIndex(item => {
return (item.name === name);
});
let element;

switch(e.key) {
case 'Escape':
this.props.onClose();
break;
case 'Enter':
case ' ':
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1];
if (element) {
element.focus();
this.handleChange(element.getAttribute('data-index'));
}
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1];
if (element) {
element.focus();
this.handleChange(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.handleChange(element.getAttribute('data-index'));
e.preventDefault();
e.stopPropagation();
}
break;
case 'Home':
element = this.node.firstChild;
if (element) {
element.focus();
this.handleChange(element.getAttribute('data-index'));
}
break;
case 'End':
element = this.node.lastChild;
if (element) {
element.focus();
this.handleChange(element.getAttribute('data-index'));
}
break;
}
}

setFocusRef = c => {
this.focusedItem = c;
}

renderItem = (item) => {
const { name, icon, meta, on, text } = item;

const active = (name === (this.props.value || this.state.value));

const computedClass = classNames('composer--options--dropdown--content--item', {
active,
lengthy: meta,
'toggled-off': !on && on !== null && typeof on !== 'undefined',
'toggled-on': on,
'with-icon': icon,
});

let prefix = null;

if (on !== null && typeof on !== 'undefined') {
prefix = <Toggle checked={on} onChange={this.handleClick} />;
} else if (icon) {
prefix = <Icon className='icon' fullwidth icon={icon} />
}

return (
<div
className={computedClass}
onClick={this.handleClick}
onKeyDown={this.handleKeyDown}
role='option'
tabIndex='0'
key={name}
data-index={name}
ref={active ? this.setFocusRef : null}
>
{prefix}

<div className='content'>
<strong>{text}</strong>
{meta}
</div>
</div>
);
}

// Rendering.
render () {
const { mounted } = this.state;
@@ -165,7 +217,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
onChange,
onClose,
style,
value,
} = this.props;

// The result.
@@ -189,27 +240,14 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
<div
className='composer--options--dropdown--content'
ref={this.handleRef}
role='listbox'
style={{
...style,
opacity: opacity,
transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
}}
>
{items ? items.map(
({
name,
...rest
}) => (
<ComposerOptionsDropdownContentItem
active={name === value}
key={name}
name={name}
onChange={onChange}
onClose={onClose}
options={rest}
/>
)
) : null}
{!!items && items.map(item => this.renderItem(item))}
</div>
)}
</Motion>

+ 1
- 1
app/javascript/flavours/glitch/features/compose/components/options.js View File

@@ -7,7 +7,7 @@ import spring from 'react-motion/lib/spring';

// Components.
import IconButton from 'flavours/glitch/components/icon_button';
import TextIconButton from 'flavours/glitch/components/text_icon_button';
import TextIconButton from './text_icon_button';
import Dropdown from './dropdown';
import ImmutablePureComponent from 'react-immutable-pure-component';


app/javascript/flavours/glitch/components/text_icon_button.js → app/javascript/flavours/glitch/features/compose/components/text_icon_button.js View File

@@ -1,6 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';

const iconStyle = {
height: null,
lineHeight: '27px',
width: `${18 * 1.28571429}px`,
};

export default class TextIconButton extends React.PureComponent {

static propTypes = {
@@ -20,7 +26,15 @@ export default class TextIconButton extends React.PureComponent {
const { label, title, active, ariaControls } = this.props;

return (
<button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}>
<button
title={title}
aria-label={title}
className={`text-icon-button ${active ? 'active' : ''}`}
aria-expanded={active}
onClick={this.handleClick}
aria-controls={ariaControls}
style={iconStyle}
>
{label}
</button>
);

+ 1
- 1
app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js 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 => ({

+ 1
- 1
app/javascript/flavours/glitch/features/lists/components/new_list_form.js View File

@@ -66,7 +66,7 @@ export default class NewListForm extends React.PureComponent {
</label>

<IconButton
disabled={disabled}
disabled={disabled || !value}
icon='plus'
title={title}
onClick={this.handleClick}

+ 1
- 1
app/javascript/flavours/glitch/reducers/modal.js 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;
}

+ 30
- 6
app/javascript/flavours/glitch/styles/components/index.scss View File

@@ -118,20 +118,29 @@
display: inline-block;
padding: 0;
color: $action-button-color;
border: none;
border: 0;
border-radius: 4px;
background: transparent;
cursor: pointer;
transition: color 100ms ease-in;
transition: all 100ms ease-in;
transition-property: background-color, color;

&:hover,
&:active,
&:focus {
color: lighten($action-button-color, 7%);
transition: color 200ms ease-out;
background-color: rgba($action-button-color, 0.15);
transition: all 200ms ease-out;
transition-property: background-color, color;
}

&:focus {
background-color: rgba($action-button-color, 0.3);
}

&.disabled {
color: darken($action-button-color, 13%);
background-color: transparent;
cursor: default;
}

@@ -156,10 +165,16 @@
&:active,
&:focus {
color: darken($lighter-text-color, 7%);
background-color: rgba($lighter-text-color, 0.15);
}

&:focus {
background-color: rgba($lighter-text-color, 0.3);
}

&.disabled {
color: lighten($lighter-text-color, 7%);
background-color: transparent;
}

&.active {
@@ -186,7 +201,8 @@

.text-icon-button {
color: $lighter-text-color;
border: none;
border: 0;
border-radius: 4px;
background: transparent;
cursor: pointer;
font-weight: 600;
@@ -194,17 +210,25 @@
padding: 0 3px;
line-height: 27px;
outline: 0;
transition: color 100ms ease-in;
transition: all 100ms ease-in;
transition-property: background-color, color;

&:hover,
&:active,
&:focus {
color: darken($lighter-text-color, 7%);
transition: color 200ms ease-out;
background-color: rgba($lighter-text-color, 0.15);
transition: all 200ms ease-out;
transition-property: background-color, color;
}

&:focus {
background-color: rgba($lighter-text-color, 0.3);
}

&.disabled {
color: lighten($lighter-text-color, 20%);
background-color: transparent;
cursor: default;
}


+ 8
- 0
app/javascript/flavours/glitch/util/resize_image.js 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);
});


+ 2
- 1
app/javascript/mastodon/actions/modal.js View File

@@ -9,8 +9,9 @@ export function openModal(type, props) {
};
};

export function closeModal() {
export function closeModal(type) {
return {
type: MODAL_CLOSE,
modalType: type,
};
};

+ 12
- 3
app/javascript/mastodon/components/column.js View File

@@ -8,10 +8,11 @@ export default class Column extends React.PureComponent {
static propTypes = {
children: PropTypes.node,
label: PropTypes.string,
bindToDocument: PropTypes.bool,
};

scrollTop () {
const scrollable = this.node.querySelector('.scrollable');
const scrollable = this.props.bindToDocument ? document.scrollingElement : this.node.querySelector('.scrollable');

if (!scrollable) {
return;
@@ -33,11 +34,19 @@ export default class Column extends React.PureComponent {
}

componentDidMount () {
this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
if (this.props.bindToDocument) {
document.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
} else {
this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
}
}

componentWillUnmount () {
this.node.removeEventListener('wheel', this.handleWheel);
if (this.props.bindToDocument) {
document.removeEventListener('wheel', this.handleWheel);
} else {
this.node.removeEventListener('wheel', this.handleWheel);
}
}

render () {

+ 14
- 1
app/javascript/mastodon/components/column_back_button.js View File

@@ -2,6 +2,7 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon';
import { createPortal } from 'react-dom';

export default class ColumnBackButton extends React.PureComponent {

@@ -9,6 +10,10 @@ export default class ColumnBackButton extends React.PureComponent {
router: PropTypes.object,
};

static propTypes = {
multiColumn: PropTypes.bool,
};

handleClick = () => {
if (window.history && window.history.length === 1) {
this.context.router.history.push('/');
@@ -18,12 +23,20 @@ export default class ColumnBackButton extends React.PureComponent {
}

render () {
return (
const { multiColumn } = this.props;

const component = (
<button onClick={this.handleClick} className='column-back-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
</button>
);

if (multiColumn) {
return component;
} else {
return createPortal(component, document.getElementById('tabs-bar__portal'));
}
}

}

+ 10
- 2
app/javascript/mastodon/components/column_header.js View File

@@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { createPortal } from 'react-dom';
import classNames from 'classnames';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import Icon from 'mastodon/components/icon';
@@ -28,6 +29,7 @@ class ColumnHeader extends React.PureComponent {
showBackButton: PropTypes.bool,
children: PropTypes.node,
pinned: PropTypes.bool,
placeholder: PropTypes.bool,
onPin: PropTypes.func,
onMove: PropTypes.func,
onClick: PropTypes.func,
@@ -79,7 +81,7 @@ class ColumnHeader extends React.PureComponent {
}

render () {
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage } } = this.props;
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder } = this.props;
const { collapsed, animating } = this.state;

const wrapperClassName = classNames('column-header__wrapper', {
@@ -146,7 +148,7 @@ class ColumnHeader extends React.PureComponent {

const hasTitle = icon && title;

return (
const component = (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
{hasTitle && (
@@ -172,6 +174,12 @@ class ColumnHeader extends React.PureComponent {
</div>
</div>
);

if (multiColumn || placeholder) {
return component;
} else {
return createPortal(component, document.getElementById('tabs-bar__portal'));
}
}

}

+ 26
- 18
app/javascript/mastodon/components/dropdown_menu.js View File

@@ -45,7 +45,10 @@ 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();
this.activeElement = document.activeElement;
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus();
}
this.setState({ mounted: true });
}

@@ -53,6 +56,9 @@ class DropdownMenu extends React.PureComponent {
document.removeEventListener('click', this.handleDocumentClick, false);
document.removeEventListener('keydown', this.handleKeyDown, false);
document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.activeElement) {
this.activeElement.focus();
}
}

setRef = c => {
@@ -81,6 +87,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 +111,14 @@ class DropdownMenu extends React.PureComponent {
element.focus();
}
break;
case 'Escape':
this.props.onClose();
break;
}
}

handleItemKeyDown = e => {
if (e.key === 'Enter') {
handleItemKeyUp = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
}
@@ -126,7 +147,7 @@ class DropdownMenu extends React.PureComponent {

return (
<li className='dropdown-menu__item' key={`${text}-${i}`}>
<a href={href} target={target} data-method={method} 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} onKeyUp={this.handleItemKeyUp} data-index={i}>
{text}
</a>
</li>
@@ -202,19 +223,6 @@ export default class Dropdown extends React.PureComponent {
this.props.onClose(this.state.id);
}

handleKeyDown = e => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.preventDefault();
break;
case 'Escape':
this.handleClose();
break;
}
}

handleItemClick = e => {
const i = Number(e.currentTarget.getAttribute('data-index'));
const { action, to } = this.props.items[i];
@@ -249,7 +257,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}

+ 18
- 0
app/javascript/mastodon/components/icon_button.js View File

@@ -12,6 +12,8 @@ export default class IconButton extends React.PureComponent {
title: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
onClick: PropTypes.func,
onMouseDown: PropTypes.func,
onKeyDown: PropTypes.func,
size: PropTypes.number,
active: PropTypes.bool,
pressed: PropTypes.bool,
@@ -42,6 +44,18 @@ export default class IconButton extends React.PureComponent {
}
}

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 +98,8 @@ export default class IconButton extends React.PureComponent {
title={title}
className={classes}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
style={style}
tabIndex={tabIndex}
disabled={disabled}
@@ -103,6 +119,8 @@ export default class IconButton extends React.PureComponent {
title={title}
className={classes}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
style={style}
tabIndex={tabIndex}
disabled={disabled}

+ 23
- 0
app/javascript/mastodon/components/modal_root.js 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 = () => {

+ 0
- 90
app/javascript/mastodon/components/status_content.js View File

@@ -8,71 +8,9 @@ import classnames from 'classnames';
import PollContainer from 'mastodon/containers/poll_container';
import Icon from 'mastodon/components/icon';
import { autoPlayGif } from 'mastodon/initial_state';
import { decode as decodeIDNA } from 'mastodon/utils/idna';

const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top)

// Regex matching what "looks like a link", that is, something that starts with
// an optional "http://" or "https://" scheme and then what could look like a
// domain main, that is, at least two sequences of characters not including spaces
// and separated by "." or an homoglyph. The idea is not to match valid URLs or
// domain names, but what could be confused for a valid URL or domain name,
// especially to the untrained eye.

const h_confusables = 'h\u13c2\u1d58d\u1d4f1\u1d691\u0068\uff48\u1d525\u210e\u1d489\u1d629\u0570\u1d4bd\u1d65d\u1d421\u1d5c1\u1d5f5\u04bb\u1d559';
const t_confusables = 't\u1d42d\u1d5cd\u1d531\u1d565\u1d4c9\u1d669\u1d4fd\u1d69d\u0074\u1d461\u1d601\u1d495\u1d635\u1d599';
const p_confusables = 'p\u0440\u03c1\u1d52d\u1d631\u1d665\u1d429\uff50\u1d6e0\u1d45d\u1d561\u1d595\u1d71a\u1d699\u1d78e\u2ca3\u1d754\u1d6d2\u1d491\u1d7c8\u1d746\u1d4c5\u1d70c\u1d5c9\u0070\u1d780\u03f1\u1d5fd\u2374\u1d7ba\u1d4f9';
const s_confusables = 's\u1d530\u118c1\u1d494\u1d634\u1d4c8\u1d668\uabaa\u1d42c\u1d5cc\u1d460\u1d600\ua731\u0073\uff53\u1d564\u0455\u1d598\u1d4fc\u1d69c\u10448\u01bd';
const column_confusables = ':\u0903\u0a83\u0703\u1803\u05c3\u0704\u0589\u1809\ua789\u16ec\ufe30\u02d0\u2236\u02f8\u003a\uff1a\u205a\ua4fd';
const slash_confusables = '/\u2041\u2f03\u2044\u2cc6\u27cb\u30ce\u002f\u2571\u31d3\u3033\u1735\u2215\u29f8\u1d23a\u4e3f';
const dot_confusables = '.\u002e\u0660\u06f0\u0701\u0702\u2024\ua4f8\ua60e\u10a50\u1d16d';

const linkRegex = new RegExp(`^\\s*(([${h_confusables}][${t_confusables}][${t_confusables}][${p_confusables}][${s_confusables}]?[${column_confusables}][${slash_confusables}][${slash_confusables}]))?[^:/\\n ]+([${dot_confusables}][^:/\\n ]+)+`);

const isLinkMisleading = (link) => {
let linkTextParts = [];

// Reconstruct visible text, as we do not have much control over how links
// from remote software look, and we can't rely on `innerText` because the
// `invisible` class does not set `display` to `none`.

const walk = (node) => {
switch (node.nodeType) {
case Node.TEXT_NODE:
linkTextParts.push(node.textContent);
break;
case Node.ELEMENT_NODE:
if (node.classList.contains('invisible')) return;
const children = node.childNodes;
for (let i = 0; i < children.length; i++) {
walk(children[i]);
}
break;
}
};

walk(link);

const linkText = linkTextParts.join('');
const targetURL = new URL(link.href);

// The following may not work with international domain names
if (linkText === targetURL.origin || linkText === targetURL.host || 'www.' + linkText === targetURL.host || linkText.startsWith(targetURL.origin + '/') || linkText.startsWith(targetURL.host + '/')) {
return false;
}

// The link hasn't been recognized, maybe it features an international domain name
const hostname = decodeIDNA(targetURL.hostname);
const host = targetURL.host.replace(targetURL.hostname, hostname);
const origin = targetURL.origin.replace(targetURL.host, host);
if (linkText === origin || linkText === host || linkText.startsWith(origin + '/') || linkText.startsWith(host + '/')) {
return false;
}

// If the link text looks like an URL or auto-generated link, it is misleading
return linkRegex.test(linkText);
};

export default class StatusContent extends React.PureComponent {

static contextTypes = {
@@ -118,34 +56,6 @@ export default class StatusContent extends React.PureComponent {
} else {
link.setAttribute('title', link.href);
link.classList.add('unhandled-link');

if (isLinkMisleading(link)) {
while (link.firstChild) {
link.removeChild(link.firstChild);
}

const prefix = (link.href.match(/https?:\/\/(www\.)?/) || [''])[0];
const text = link.href.substr(prefix.length, 30);
const suffix = link.href.substr(prefix.length + 30);
const cutoff = !!suffix;

const prefixTag = document.createElement('span');
prefixTag.classList.add('invisible');
prefixTag.textContent = prefix;
link.appendChild(prefixTag);

const textTag = document.createElement('span');
if (cutoff) {
textTag.classList.add('ellipsis');
}
textTag.textContent = text;
link.appendChild(textTag);

const suffixTag = document.createElement('span');
suffixTag.classList.add('invisible');
suffixTag.textContent = suffix;
link.appendChild(suffixTag);
}
}

link.setAttribute('target', '_blank');

+ 1
- 1
app/javascript/mastodon/containers/dropdown_menu_container.js 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));
},
});

+ 3
- 2
app/javascript/mastodon/features/account_gallery/index.js View File

@@ -56,6 +56,7 @@ class AccountGallery extends ImmutablePureComponent {
isLoading: PropTypes.bool,
hasMore: PropTypes.bool,
isAccount: PropTypes.bool,
multiColumn: PropTypes.bool,
};

state = {
@@ -116,7 +117,7 @@ class AccountGallery extends ImmutablePureComponent {
}

render () {
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn } = this.props;
const { width } = this.state;

if (!isAccount) {
@@ -143,7 +144,7 @@ class AccountGallery extends ImmutablePureComponent {

return (
<Column>
<ColumnBackButton />
<ColumnBackButton multiColumn={multiColumn} />

<ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={shouldUpdateScroll}>
<div className='scrollable scrollable--flex' onScroll={this.handleScroll}>

+ 1
- 1
app/javascript/mastodon/features/account_timeline/index.js View File

@@ -100,7 +100,7 @@ class AccountTimeline extends ImmutablePureComponent {

return (
<Column>
<ColumnBackButton />
<ColumnBackButton multiColumn={multiColumn} />

<StatusList
prepend={<HeaderContainer accountId={this.props.params.accountId} />}

+ 1
- 1
app/javascript/mastodon/features/blocks/index.js View File

@@ -57,7 +57,7 @@ class Blocks extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />;

return (
<Column icon='ban' heading={intl.formatMessage(messages.heading)}>
<Column bindToDocument={!multiColumn} icon='ban' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollableList
scrollKey='blocks'

+ 1
- 1
app/javascript/mastodon/features/community_timeline/index.js View File

@@ -105,7 +105,7 @@ class CommunityTimeline extends React.PureComponent {
const pinned = !!columnId;

return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='users'
active={hasUnread}

+ 37
- 1
app/javascript/mastodon/features/compose/components/privacy_dropdown.js 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>

+ 14
- 1
app/javascript/mastodon/features/compose/components/text_icon_button.js View File

@@ -1,6 +1,12 @@
import React from 'react';
import PropTypes from 'prop-types';

const iconStyle = {
height: null,
lineHeight: '27px',
width: `${18 * 1.28571429}px`,
};

export default class TextIconButton extends React.PureComponent {

static propTypes = {
@@ -20,7 +26,14 @@ export default class TextIconButton extends React.PureComponent {
const { label, title, active, ariaControls } = this.props;

return (
<button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}>
<button
title={title}
aria-label={title}
className={`text-icon-button ${active ? 'active' : ''}`}
aria-expanded={active}
onClick={this.handleClick}
aria-controls={ariaControls} style={iconStyle}
>
{label}
</button>
);

+ 1
- 1
app/javascript/mastodon/features/direct_timeline/index.js View File

@@ -75,7 +75,7 @@ class DirectTimeline extends React.PureComponent {
const pinned = !!columnId;

return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='envelope'
active={hasUnread}

+ 1
- 1
app/javascript/mastodon/features/domain_blocks/index.js View File

@@ -58,7 +58,7 @@ class Blocks extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no hidden domains yet.' />;

return (
<Column icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
<Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollableList
scrollKey='domain_blocks'

+ 1
- 1
app/javascript/mastodon/features/favourited_statuses/index.js View File

@@ -74,7 +74,7 @@ class Favourites extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />;

return (
<Column ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.heading)}>
<ColumnHeader
icon='star'
title={intl.formatMessage(messages.heading)}

+ 1
- 1
app/javascript/mastodon/features/favourites/index.js View File

@@ -51,7 +51,7 @@ class Favourites extends ImmutablePureComponent {

return (
<Column>
<ColumnBackButton />
<ColumnBackButton multiColumn={multiColumn} />

<ScrollableList
scrollKey='favourites'

+ 1
- 1
app/javascript/mastodon/features/follow_requests/index.js View File

@@ -57,7 +57,7 @@ class FollowRequests extends ImmutablePureComponent {
const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />;

return (
<Column icon='user-plus' heading={intl.formatMessage(messages.heading)}>
<Column bindToDocument={!multiColumn} icon='user-plus' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<ScrollableList
scrollKey='follow_requests'

+ 1
- 1
app/javascript/mastodon/features/followers/index.js View File

@@ -78,7 +78,7 @@ class Followers extends ImmutablePureComponent {

return (
<Column>
<ColumnBackButton />
<ColumnBackButton multiColumn={multiColumn} />

<ScrollableList
scrollKey='followers'

+ 1
- 1
app/javascript/mastodon/features/following/index.js View File

@@ -78,7 +78,7 @@ class Following extends ImmutablePureComponent {

return (
<Column>
<ColumnBackButton />
<ColumnBackButton multiColumn={multiColumn} />

<ScrollableList
scrollKey='following'

+ 1
- 1
app/javascript/mastodon/features/getting_started/index.js View File

@@ -148,7 +148,7 @@ class GettingStarted extends ImmutablePureComponent {
}

return (
<Column label={intl.formatMessage(messages.menu)}>
<Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.menu)}>
{multiColumn && <div className='column-header__wrapper'>
<h1 className='column-header'>
<button>

+ 1
- 1
app/javascript/mastodon/features/hashtag_timeline/index.js View File

@@ -135,7 +135,7 @@ class HashtagTimeline extends React.PureComponent {
const pinned = !!columnId;

return (
<Column ref={this.setRef} label={`#${id}`}>
<Column bindToDocument={!multiColumn} ref={this.setRef} label={`#${id}`}>
<ColumnHeader
icon='hashtag'
active={hasUnread}

+ 1
- 1
app/javascript/mastodon/features/home_timeline/index.js View File

@@ -98,7 +98,7 @@ class HomeTimeline extends React.PureComponent {
const pinned = !!columnId;

return (
<Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='home'
active={hasUnread}

+ 2
- 2
app/javascript/mastodon/features/keyboard_shortcuts/index.js View File

@@ -18,10 +18,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
};

render () {
const { intl } = this.props;
const { intl, multiColumn } = this.props;

return (
<Column icon='question' heading={intl.formatMessage(messages.heading)}>
<Column bindToDocument={!multiColumn} icon='question' heading={intl.formatMessage(messages.heading)}>
<ColumnBackButtonSlim />
<div className='keyboard-shortcuts scrollable optionally-scrollable'>
<table>

+ 1
- 1
app/javascript/mastodon/features/list_editor/components/edit_list_form.js View File

@@ -11,7 +11,7 @@ const messages = defineMessages({

const mapStateToProps = state => ({
value: state.getIn([&