diff options
Diffstat (limited to 'app')
125 files changed, 1460 insertions, 635 deletions
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index faa2df1b5..4116f99f4 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -5,6 +5,7 @@ module Admin class DashboardController < BaseController def index @users_count = User.count + @pending_users_count = User.pending.count @registrations_week = Redis.current.get("activity:accounts:local:#{current_week}") || 0 @logins_week = Redis.current.pfcount("activity:logins:#{current_week}") @interactions_week = Redis.current.get("activity:interactions:#{current_week}") || 0 @@ -19,7 +20,7 @@ module Admin @redis_version = redis_info['redis_version'] @reports_count = Report.unresolved.count @queue_backlog = Sidekiq::Stats.new.enqueued - @recent_users = User.confirmed.recent.includes(:account).limit(4) + @recent_users = User.confirmed.recent.includes(:account).limit(8) @database_size = ActiveRecord::Base.connection.execute('SELECT pg_database_size(current_database())').first['pg_database_size'] @redis_size = redis_info['used_memory'] @ldap_enabled = ENV['LDAP_ENABLED'] == 'true' @@ -27,11 +28,15 @@ 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) + @pending_tags_count = Tag.pending_review.count + @authorized_fetch = authorized_fetch_mode? + @whitelist_enabled = whitelist_mode? @profile_directory = Setting.profile_directory @timeline_preview = Setting.timeline_preview @keybase_integration = Setting.enable_keybase @spam_check_enabled = Setting.spam_check_enabled + @trends_enabled = Setting.trends end private @@ -41,7 +46,13 @@ module Admin end def redis_info - @redis_info ||= Redis.current.info + @redis_info ||= begin + if Redis.current.is_a?(Redis::Namespace) + Redis.current.redis.info + else + Redis.current.info + end + end end end end diff --git a/app/controllers/admin/domain_blocks_controller.rb b/app/controllers/admin/domain_blocks_controller.rb index 7129656da..74a36b79c 100644 --- a/app/controllers/admin/domain_blocks_controller.rb +++ b/app/controllers/admin/domain_blocks_controller.rb @@ -2,13 +2,17 @@ module Admin class DomainBlocksController < BaseController - before_action :set_domain_block, only: [:show, :destroy] + before_action :set_domain_block, only: [:show, :destroy, :edit, :update] def new authorize :domain_block, :create? @domain_block = DomainBlock.new(domain: params[:_domain]) end + def edit + authorize :domain_block, :create? + end + def create authorize :domain_block, :create? @@ -35,6 +39,22 @@ module Admin end end + def update + authorize :domain_block, :create? + + @domain_block.update(update_params) + + severity_changed = @domain_block.severity_changed? + + if @domain_block.save + DomainBlockWorker.perform_async(@domain_block.id, severity_changed) + log_action :create, @domain_block + redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') + else + render :edit + end + end + def show authorize @domain_block, :show? end @@ -52,8 +72,12 @@ module Admin @domain_block = DomainBlock.find(params[:id]) end + def update_params + params.require(:domain_block).permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment) + end + def resource_params - params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports) + params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment) end end end diff --git a/app/controllers/admin/instances_controller.rb b/app/controllers/admin/instances_controller.rb index d4f201807..b47b18f8e 100644 --- a/app/controllers/admin/instances_controller.rb +++ b/app/controllers/admin/instances_controller.rb @@ -21,6 +21,8 @@ module Admin @blocks_count = Block.where(target_account: Account.where(domain: params[:id])).count @available = DeliveryFailureTracker.available?(Account.select(:shared_inbox_url).where(domain: params[:id]).first&.shared_inbox_url) @media_storage = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size) + @private_comment = @domain_block&.private_comment + @public_comment = @domain_block&.public_comment end private diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index e9f4f2cfa..d62361eaa 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -4,41 +4,75 @@ module Admin class TagsController < BaseController before_action :set_tags, only: :index before_action :set_tag, except: :index - before_action :set_filter_params + before_action :set_usage_by_domain, except: :index + before_action :set_counters, except: :index 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), notice: I18n.t('admin.tags.updated_msg') + 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 set_usage_by_domain + @usage_by_domain = @tag.statuses + .where(visibility: :public) + .where(Status.arel_table[:id].gteq(Mastodon::Snowflake.id_at(Time.now.utc.beginning_of_day))) + .joins(:account) + .group('accounts.domain') + .reorder('statuses_count desc') + .pluck('accounts.domain, count(*) AS statuses_count') + end + + def set_counters + @accounts_today = @tag.history.first[:accounts] + @accounts_week = Redis.current.pfcount(*current_week_days.map { |day| "activity:tags:#{@tag.id}:#{day}:accounts" }) + end + + def filtered_tags + scope = Tag + scope = scope.discoverable if filter_params[:context] == 'directory' + scope = scope.unreviewed if filter_params[:review] == 'unreviewed' + scope = scope.reviewed.order(reviewed_at: :desc) if filter_params[:review] == 'reviewed' + scope = scope.pending_review.order(requested_review_at: :desc) if filter_params[:review] == 'pending_review' + scope.order(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 + + def current_week_days + now = Time.now.utc.beginning_of_day.to_date + + (Date.commercial(now.cwyear, now.cweek)..now).map do |date| + date.to_time.utc.beginning_of_day.to_i + end end end end diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb new file mode 100644 index 000000000..bcea9857e --- /dev/null +++ b/app/controllers/api/v1/trends_controller.rb @@ -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 diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb index 0702ebc04..f2d1f5661 100644 --- a/app/controllers/directories_controller.rb +++ b/app/controllers/directories_controller.rb @@ -30,7 +30,7 @@ class DirectoriesController < ApplicationController end def set_tag - @tag = Tag.discoverable.find_by!(name: params[:id].downcase) + @tag = Tag.discoverable.find_normalized!(params[:id]) end def set_tags diff --git a/app/controllers/remote_interaction_controller.rb b/app/controllers/remote_interaction_controller.rb index e76f248b1..5ae72989b 100644 --- a/app/controllers/remote_interaction_controller.rb +++ b/app/controllers/remote_interaction_controller.rb @@ -40,7 +40,6 @@ class RemoteInteractionController < ApplicationController @status = Status.find(params[:id]) authorize @status, :show? rescue Mastodon::NotPermittedError - # Reraise in order to get a 404 raise ActiveRecord::RecordNotFound end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index ab6b5c0b0..418ea5d7a 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -58,7 +58,8 @@ 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), + :setting_trends, + 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 diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index ec494bb2d..d6bb28eb5 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -48,7 +48,7 @@ class TagsController < ApplicationController private def set_tag - @tag = Tag.find_normalized!(params[:id]) + @tag = Tag.usable.find_normalized!(params[:id]) end def set_body_classes diff --git a/app/controllers/well_known/webfinger_controller.rb b/app/controllers/well_known/webfinger_controller.rb index 53f7f1e27..50bace217 100644 --- a/app/controllers/well_known/webfinger_controller.rb +++ b/app/controllers/well_known/webfinger_controller.rb @@ -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 diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 0bda25974..506429e10 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -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 diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js index 80e15c28e..3d0299db5 100644 --- a/app/javascript/flavours/glitch/actions/modal.js +++ b/app/javascript/flavours/glitch/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/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js index 05611c135..39d7ba50c 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/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); } } @@ -126,7 +143,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} onKeyPress={this.handleItemKeyPress} data-index={i}> {text} </a> </li> @@ -193,25 +210,41 @@ export default class Dropdown extends React.PureComponent { } else { const { top } = target.getBoundingClientRect(); const placement = top * 2 < innerHeight ? 'bottom' : 'top'; - this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click'); } } handleClose = () => { + if (this.activeElement) { + this.activeElement.focus(); + this.activeElement = null; + } this.props.onClose(this.state.id); } - handleKeyDown = e => { + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + + handleKeyPress = (e) => { switch(e.key) { case ' ': case 'Enter': this.handleClick(e); + e.stopPropagation(); e.preventDefault(); break; - case 'Escape': - this.handleClose(); - break; } } @@ -248,7 +281,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} @@ -257,6 +290,9 @@ export default class Dropdown extends React.PureComponent { size={size} ref={this.setTargetRef} onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} /> <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index 6a25794d3..521353238 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -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} diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js index 4e8648b49..fd0af9f6e 100644 --- a/app/javascript/flavours/glitch/components/modal_root.js +++ b/app/javascript/flavours/glitch/components/modal_root.js @@ -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 () { diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 7c08ae4e8..170efad04 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -699,6 +699,7 @@ class Status extends ImmutablePureComponent { onExpandedToggle={this.handleExpandedToggle} parseClick={parseClick} disabled={!router} + tagLinks={settings.get('tag_misleading_links')} /> {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( <StatusActionBar diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index 602a28064..95a4fe3fa 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -6,6 +6,53 @@ import { FormattedMessage } from 'react-intl'; import Permalink from './permalink'; import classnames from 'classnames'; import { autoPlayGif } from 'flavours/glitch/util/initial_state'; +import { decode as decodeIDNA } from 'flavours/glitch/util/idna'; + +const textMatchesTarget = (text, origin, host) => { + return (text === origin || text === host + || text.startsWith(origin + '/') || text.startsWith(host + '/') + || 'www.' + text === host || ('www.' + text).startsWith(host + '/')); +} + +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 (textMatchesTarget(linkText, targetURL.origin, targetURL.host) || textMatchesTarget(linkText.toLowerCase(), targetURL.origin, targetURL.host)) { + return false; + } + + // The link hasn't been recognized, maybe it features an international domain name + const hostname = decodeIDNA(targetURL.hostname).normalize('NFKC'); + const host = targetURL.host.replace(targetURL.hostname, hostname); + const origin = targetURL.origin.replace(targetURL.host, host); + const text = linkText.normalize('NFKC'); + return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)); +}; export default class StatusContent extends React.PureComponent { @@ -19,6 +66,11 @@ export default class StatusContent extends React.PureComponent { parseClick: PropTypes.func, disabled: PropTypes.bool, onUpdate: PropTypes.func, + tagLinks: PropTypes.bool, + }; + + static defaultProps = { + tagLinks: true, }; state = { @@ -27,6 +79,7 @@ export default class StatusContent extends React.PureComponent { _updateStatusLinks () { const node = this.contentsNode; + const { tagLinks } = this.props; if (!node) { return; @@ -52,6 +105,21 @@ export default class StatusContent extends React.PureComponent { link.addEventListener('click', this.onLinkClick.bind(this), false); link.setAttribute('title', link.href); link.classList.add('unhandled-link'); + + 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'); + } } link.setAttribute('target', '_blank'); @@ -104,7 +172,7 @@ export default class StatusContent extends React.PureComponent { } onHashtagClick = (hashtag, e) => { - hashtag = hashtag.replace(/^#/, '').toLowerCase(); + hashtag = hashtag.replace(/^#/, ''); if (this.props.parseClick) { this.props.parseClick(e, `/timelines/tag/${hashtag}`); @@ -173,6 +241,7 @@ export default class StatusContent extends React.PureComponent { mediaIcon, parseClick, disabled, + tagLinks, } = this.props; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; @@ -247,6 +316,7 @@ export default class StatusContent extends React.PureComponent { <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}> <div ref={this.setContentsRef} + key={`contents-${tagLinks}`} style={directionStyle} tabIndex={!hidden ? 0 : null} dangerouslySetInnerHTML={content} @@ -270,6 +340,7 @@ export default class StatusContent extends React.PureComponent { > <div ref={this.setContentsRef} + key={`contents-${tagLinks}`} dangerouslySetInnerHTML={content} lang={status.get('language')} className='status__content__text' @@ -286,7 +357,7 @@ export default class StatusContent extends React.PureComponent { tabIndex='0' ref={this.setRef} > - <div ref={this.setContentsRef} className='status__content__text' dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' /> + <div ref={this.setContentsRef} key={`contents-${tagLinks}`} className='status__content__text' dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' /> {media} </div> ); diff --git a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js index b2419a0fd..1378e75fe 100644 --- a/app/javascript/flavours/glitch/containers/dropdown_menu_container.js +++ b/app/javascript/flavours/glitch/containers/dropdown_menu_container.js @@ -25,7 +25,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/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js index 8d982208f..60035b705 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -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, -}; diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js index 19d35a8f4..f812be7a9 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js @@ -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> diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js index ed52b1997..92348b000 100644 --- a/app/javascript/flavours/glitch/features/compose/components/options.js +++ b/app/javascript/flavours/glitch/features/compose/components/options.js @@ -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'; diff --git a/app/javascript/flavours/glitch/components/text_icon_button.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js index 9c8ffab1f..7f2005060 100644 --- a/app/javascript/flavours/glitch/components/text_icon_button.js +++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js @@ -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> ); diff --git a/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js index 24aaf82ac..bf5a8de35 100644 --- a/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js +++ b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js @@ -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 => ({ diff --git a/app/javascript/flavours/glitch/features/lists/components/new_list_form.js b/app/javascript/flavours/glitch/features/lists/components/new_list_form.js index 61fcbeaf9..eb5b6188a 100644 --- a/app/javascript/flavours/glitch/features/lists/components/new_list_form.js +++ b/app/javascript/flavours/glitch/features/lists/components/new_list_form.js @@ -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} diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js index 910cb5346..bd92a81c2 100644 --- a/app/javascript/flavours/glitch/features/local_settings/page/index.js +++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js @@ -66,6 +66,15 @@ export default class LocalSettingsPage extends React.PureComponent { > <FormattedMessage id='settings.confirm_boost_missing_media_description' defaultMessage='Show confirmation dialog before boosting toots lacking media descriptions' /> </LocalSettingsPageItem> + <LocalSettingsPageItem + settings={settings} + item={['tag_misleading_links']} + id='mastodon-settings--tag_misleading_links' + onChange={onChange} + > + <FormattedMessage id='settings.tag_misleading_links' defaultMessage='Tag misleading links' /> + <span className='hint'><FormattedMessage id='settings.tag_misleading_links.hint' defaultMessage="Add a visual indication with the link target host to every link not mentioning it explicitly" /></span> + </LocalSettingsPageItem> <section> <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2> <LocalSettingsPageItem diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js index f974a87a1..108b6e3b2 100644 --- a/app/javascript/flavours/glitch/features/status/components/card.js +++ b/app/javascript/flavours/glitch/features/status/components/card.js @@ -4,15 +4,7 @@ import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import punycode from 'punycode'; import classnames from 'classnames'; - -const IDNA_PREFIX = 'xn--'; - -const decodeIDNA = domain => { - return domain - .split('.') - .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) - .join('.'); -}; +import { decode as decodeIDNA } from 'flavours/glitch/util/idna'; const getHostname = url => { const parser = document.createElement('a'); diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js index 1c2258256..fa4ed2fd5 100644 --- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js +++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js @@ -241,6 +241,7 @@ export default class DetailedStatus extends ImmutablePureComponent { onExpandedToggle={onToggleHidden} parseClick={this.parseClick} onUpdate={this.handleChildUpdate} + tagLinks={settings.get('tag_misleading_links')} disabled /> diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js index 6fd3d901b..7477c5584 100644 --- a/app/javascript/flavours/glitch/reducers/local_settings.js +++ b/app/javascript/flavours/glitch/reducers/local_settings.js @@ -22,6 +22,7 @@ const initialState = ImmutableMap({ hicolor_privacy_icons: false, show_content_type_choice: false, filtering_behavior: 'hide', + tag_misleading_links: true, content_warnings : ImmutableMap({ auto_unfold : false, filter : null, diff --git a/app/javascript/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js index 80bc11dda..7bd9d4b32 100644 --- a/app/javascript/flavours/glitch/reducers/modal.js +++ b/app/javascript/flavours/glitch/reducers/modal.js @@ -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; } diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 9f96a3154..6942170f2 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -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; } diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index 803494df6..d0183c2de 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -135,6 +135,11 @@ a.unhandled-link { color: lighten($ui-highlight-color, 8%); + + .link-origin-tag { + color: $gold-star; + font-size: 0.8em; + } } .status__content__spoiler-link { diff --git a/app/javascript/mastodon/utils/idna.js b/app/javascript/flavours/glitch/util/idna.js index efab5bacf..efab5bacf 100644 --- a/app/javascript/mastodon/utils/idna.js +++ b/app/javascript/flavours/glitch/util/idna.js diff --git a/app/javascript/flavours/glitch/util/resize_image.js b/app/javascript/flavours/glitch/util/resize_image.js index bbdbc865e..a8ec5f3fa 100644 --- a/app/javascript/flavours/glitch/util/resize_image.js +++ b/app/javascript/flavours/glitch/util/resize_image.js @@ -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); }); 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/trends.js b/app/javascript/mastodon/actions/trends.js new file mode 100644 index 000000000..853e4f60a --- /dev/null +++ b/app/javascript/mastodon/actions/trends.js @@ -0,0 +1,32 @@ +import api from '../api'; + +export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST'; +export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS'; +export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL'; + +export const fetchTrends = () => (dispatch, getState) => { + dispatch(fetchTrendsRequest()); + + api(getState) + .get('/api/v1/trends') + .then(({ data }) => dispatch(fetchTrendsSuccess(data))) + .catch(err => dispatch(fetchTrendsFail(err))); +}; + +export const fetchTrendsRequest = () => ({ + type: TRENDS_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchTrendsSuccess = trends => ({ + type: TRENDS_FETCH_SUCCESS, + trends, + skipLoading: true, +}); + +export const fetchTrendsFail = error => ({ + type: TRENDS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js index d45387463..55e3bfd5e 100644 --- a/app/javascript/mastodon/components/column.js +++ b/app/javascript/mastodon/components/column.js @@ -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 () { diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js index f41045787..cc0e5c07c 100644 --- a/app/javascript/mastodon/components/column_back_button.js +++ b/app/javascript/mastodon/components/column_back_button.js @@ -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')); + } } } diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index f33c689e7..89c5fe723 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -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')); + } } } diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index e122515c4..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); } } @@ -126,7 +143,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} onKeyPress={this.handleItemKeyPress} data-index={i}> {text} </a> </li> @@ -193,25 +210,41 @@ export default class Dropdown extends React.PureComponent { } else { const { top } = target.getBoundingClientRect(); const placement = top * 2 < innerHeight ? 'bottom' : 'top'; - this.props.onOpen(this.state.id, this.handleItemClick, placement, type !== 'click'); } } handleClose = () => { + if (this.activeElement) { + this.activeElement.focus(); + this.activeElement = null; + } this.props.onClose(this.state.id); } - handleKeyDown = e => { + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + + handleKeyPress = (e) => { switch(e.key) { case ' ': case 'Enter': this.handleClick(e); + e.stopPropagation(); e.preventDefault(); break; - case 'Escape': - this.handleClose(); - break; } } @@ -249,7 +282,7 @@ export default class Dropdown extends React.PureComponent { const open = this.state.id === openDropdownId; return ( - <div onKeyDown={this.handleKeyDown}> + <div> <IconButton icon={icon} title={title} @@ -258,6 +291,9 @@ export default class Dropdown extends React.PureComponent { size={size} ref={this.setTargetRef} onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} /> <Overlay show={open} placement={dropdownPlacement} target={this.findTarget}> 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 e717934fa..76117f1d9 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -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'); @@ -202,7 +112,7 @@ export default class StatusContent extends React.PureComponent { } onHashtagClick = (hashtag, e) => { - hashtag = hashtag.replace(/^#/, '').toLowerCase(); + hashtag = hashtag.replace(/^#/, ''); if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); 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_gallery/index.js b/app/javascript/mastodon/features/account_gallery/index.js index 5d6a53e18..f1a665d8f 100644 --- a/app/javascript/mastodon/features/account_gallery/index.js +++ b/app/javascript/mastodon/features/account_gallery/index.js @@ -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}> diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js index 9914b7e65..69bab1e86 100644 --- a/app/javascript/mastodon/features/account_timeline/index.js +++ b/app/javascript/mastodon/features/account_timeline/index.js @@ -100,7 +100,7 @@ class AccountTimeline extends ImmutablePureComponent { return ( <Column> - <ColumnBackButton /> + <ColumnBackButton multiColumn={multiColumn} /> <StatusList prepend={<HeaderContainer accountId={this.props.params.accountId} />} diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js index 8fb0f051b..051431ed2 100644 --- a/app/javascript/mastodon/features/blocks/index.js +++ b/app/javascript/mastodon/features/blocks/index.js @@ -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' diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index 2f6999f61..f95fa4970 100644 --- a/app/javascript/mastodon/features/community_timeline/index.js +++ b/app/javascript/mastodon/features/community_timeline/index.js @@ -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} diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index d02a55be0..7cbfe463a 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -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> diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.js b/app/javascript/mastodon/features/compose/components/text_icon_button.js index 9c8ffab1f..f0b133538 100644 --- a/app/javascript/mastodon/features/compose/components/text_icon_button.js +++ b/app/javascript/mastodon/features/compose/components/text_icon_button.js @@ -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> ); diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js index d202f3bfd..5ce795760 100644 --- a/app/javascript/mastodon/features/direct_timeline/index.js +++ b/app/javascript/mastodon/features/direct_timeline/index.js @@ -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} diff --git a/app/javascript/mastodon/features/domain_blocks/index.js b/app/javascript/mastodon/features/domain_blocks/index.js index 16e200b31..482245c86 100644 --- a/app/javascript/mastodon/features/domain_blocks/index.js +++ b/app/javascript/mastodon/features/domain_blocks/index.js @@ -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' diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index 8c7b23869..db8a3f815 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -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)} diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js index 464f7aeb0..62d3c2f06 100644 --- a/app/javascript/mastodon/features/favourites/index.js +++ b/app/javascript/mastodon/features/favourites/index.js @@ -51,7 +51,7 @@ class Favourites extends ImmutablePureComponent { return ( <Column> - <ColumnBackButton /> + <ColumnBackButton multiColumn={multiColumn} /> <ScrollableList scrollKey='favourites' diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js index 570cf57c8..57ef44145 100644 --- a/app/javascript/mastodon/features/follow_requests/index.js +++ b/app/javascript/mastodon/features/follow_requests/index.js @@ -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' diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js index dce05bdc6..3913bf8d0 100644 --- a/app/javascript/mastodon/features/followers/index.js +++ b/app/javascript/mastodon/features/followers/index.js @@ -78,7 +78,7 @@ class Followers extends ImmutablePureComponent { return ( <Column> - <ColumnBackButton /> + <ColumnBackButton multiColumn={multiColumn} /> <ScrollableList scrollKey='followers' diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js index d9f2ef079..8e126f4c3 100644 --- a/app/javascript/mastodon/features/following/index.js +++ b/app/javascript/mastodon/features/following/index.js @@ -78,7 +78,7 @@ class Following extends ImmutablePureComponent { return ( <Column> - <ColumnBackButton /> + <ColumnBackButton multiColumn={multiColumn} /> <ScrollableList scrollKey='following' diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js new file mode 100644 index 000000000..1dcacc8b3 --- /dev/null +++ b/app/javascript/mastodon/features/getting_started/components/trends.js @@ -0,0 +1,43 @@ +import React from 'react'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import Hashtag from 'mastodon/components/hashtag'; + +export default class Trends extends ImmutablePureComponent { + + static defaultProps = { + loading: false, + }; + + static propTypes = { + trends: ImmutablePropTypes.list, + fetchTrends: PropTypes.func.isRequired, + }; + + componentDidMount () { + this.props.fetchTrends(); + this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000); + } + + componentWillUnmount () { + if (this.refreshInterval) { + clearInterval(this.refreshInterval); + } + } + + render () { + const { trends } = this.props; + + if (!trends || trends.isEmpty()) { + return null; + } + + return ( + <div className='getting-started__trends'> + {trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/getting_started/containers/trends_container.js b/app/javascript/mastodon/features/getting_started/containers/trends_container.js new file mode 100644 index 000000000..1df3fb4fe --- /dev/null +++ b/app/javascript/mastodon/features/getting_started/containers/trends_container.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { fetchTrends } from '../../../actions/trends'; +import Trends from '../components/trends'; + +const mapStateToProps = state => ({ + trends: state.getIn(['trends', 'items']), +}); + +const mapDispatchToProps = dispatch => ({ + fetchTrends: () => dispatch(fetchTrends()), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Trends); diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index fc7840ec1..6a122a750 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -7,12 +7,13 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { me, profile_directory } from '../../initial_state'; +import { me, profile_directory, showTrends } from '../../initial_state'; import { fetchFollowRequests } from 'mastodon/actions/accounts'; import { List as ImmutableList } from 'immutable'; import NavigationBar from '../compose/components/navigation_bar'; import Icon from 'mastodon/components/icon'; import LinkFooter from 'mastodon/features/ui/components/link_footer'; +import TrendsContainer from './containers/trends_container'; const messages = defineMessages({ home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, @@ -148,7 +149,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> @@ -168,6 +169,8 @@ class GettingStarted extends ImmutablePureComponent { <LinkFooter withHotkeys={multiColumn} /> </div> + + {multiColumn && showTrends && <TrendsContainer />} </Column> ); } diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index c50f6a79a..28200e6c2 100644 --- a/app/javascript/mastodon/features/hashtag_timeline/index.js +++ b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -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} diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index bf8ff117b..1cafb88ed 100644 --- a/app/javascript/mastodon/features/home_timeline/index.js +++ b/app/javascript/mastodon/features/home_timeline/index.js @@ -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} diff --git a/app/javascript/mastodon/features/keyboard_shortcuts/index.js b/app/javascript/mastodon/features/keyboard_shortcuts/index.js index 01b45652c..90dc87cbb 100644 --- a/app/javascript/mastodon/features/keyboard_shortcuts/index.js +++ b/app/javascript/mastodon/features/keyboard_shortcuts/index.js @@ -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> diff --git a/app/javascript/mastodon/features/list_editor/components/edit_list_form.js b/app/javascript/mastodon/features/list_editor/components/edit_list_form.js index 3dc59c12e..3ccab12a8 100644 --- a/app/javascript/mastodon/features/list_editor/components/edit_list_form.js +++ b/app/javascript/mastodon/features/list_editor/components/edit_list_form.js @@ -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 => ({ diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js index 844c93db1..f3205b2bf 100644 --- a/app/javascript/mastodon/features/list_timeline/index.js +++ b/app/javascript/mastodon/features/list_timeline/index.js @@ -148,14 +148,14 @@ class ListTimeline extends React.PureComponent { } else if (list === false) { return ( <Column> - <ColumnBackButton /> + <ColumnBackButton multiColumn={multiColumn} /> <MissingIndicator /> </Column> ); } return ( - <Column ref={this.setRef} label={title}> + <Column bindToDocument={!multiColumn} ref={this.setRef} label={title}> <ColumnHeader icon='list-ul' active={hasUnread} diff --git a/app/javascript/mastodon/features/lists/components/new_list_form.js b/app/javascript/mastodon/features/lists/components/new_list_form.js index 739246640..7faf50be8 100644 --- a/app/javascript/mastodon/features/lists/components/new_list_form.js +++ b/app/javascript/mastodon/features/lists/components/new_list_form.js @@ -66,7 +66,7 @@ class NewListForm extends React.PureComponent { </label> <IconButton - disabled={disabled} + disabled={disabled || !value} icon='plus' title={title} onClick={this.handleClick} diff --git a/app/javascript/mastodon/features/lists/index.js b/app/javascript/mastodon/features/lists/index.js index a06e0b934..7f7f5009c 100644 --- a/app/javascript/mastodon/features/lists/index.js +++ b/app/javascript/mastodon/features/lists/index.js @@ -61,7 +61,7 @@ class Lists extends ImmutablePureComponent { const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />; return ( - <Column icon='list-ul' heading={intl.formatMessage(messages.heading)}> + <Column bindToDocument={!multiColumn} icon='list-ul' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> <NewListForm /> diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js index 57d8b9915..91dd268c1 100644 --- a/app/javascript/mastodon/features/mutes/index.js +++ b/app/javascript/mastodon/features/mutes/index.js @@ -57,7 +57,7 @@ class Mutes extends ImmutablePureComponent { const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />; return ( - <Column icon='volume-off' heading={intl.formatMessage(messages.heading)}> + <Column bindToDocument={!multiColumn} icon='volume-off' heading={intl.formatMessage(messages.heading)}> <ColumnBackButtonSlim /> <ScrollableList scrollKey='mutes' diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index e708c4fcf..f2b239afe 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -198,7 +198,7 @@ class Notifications extends React.PureComponent { ); return ( - <Column ref={this.setColumnRef} label={intl.formatMessage(messages.title)}> + <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}> <ColumnHeader icon='bell' active={isUnread} diff --git a/app/javascript/mastodon/features/pinned_statuses/index.js b/app/javascript/mastodon/features/pinned_statuses/index.js index 64ebfc7ae..ad5c9cafc 100644 --- a/app/javascript/mastodon/features/pinned_statuses/index.js +++ b/app/javascript/mastodon/features/pinned_statuses/index.js @@ -47,7 +47,7 @@ class PinnedStatuses extends ImmutablePureComponent { const { intl, shouldUpdateScroll, statusIds, hasMore, multiColumn } = this.props; return ( - <Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> + <Column bindToDocument={!multiColumn} icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}> <ColumnBackButtonSlim /> <StatusList statusIds={statusIds} diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index 1edb303b8..e7825e236 100644 --- a/app/javascript/mastodon/features/public_timeline/index.js +++ b/app/javascript/mastodon/features/public_timeline/index.js @@ -105,7 +105,7 @@ class PublicTimeline 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='globe' active={hasUnread} diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js index 26f93ad1b..229f626b3 100644 --- a/app/javascript/mastodon/features/reblogs/index.js +++ b/app/javascript/mastodon/features/reblogs/index.js @@ -51,7 +51,7 @@ class Reblogs extends ImmutablePureComponent { return ( <Column> - <ColumnBackButton /> + <ColumnBackButton multiColumn={multiColumn} /> <ScrollableList scrollKey='reblogs' diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 012542843..0eff54411 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -2,9 +2,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import punycode from 'punycode'; import classnames from 'classnames'; import Icon from 'mastodon/components/icon'; -import { decode as decodeIDNA } from 'mastodon/utils/idna'; + +const IDNA_PREFIX = 'xn--'; + +const decodeIDNA = domain => { + return domain + .split('.') + .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) + .join('.'); +}; const getHostname = url => { const parser = document.createElement('a'); diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index 0422111ae..ad4f75820 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -146,6 +146,7 @@ class Status extends ImmutablePureComponent { descendantsIds: ImmutablePropTypes.list, intl: PropTypes.object.isRequired, askReplyConfirmation: PropTypes.bool, + multiColumn: PropTypes.bool, domain: PropTypes.string.isRequired, }; @@ -437,13 +438,13 @@ class Status extends ImmutablePureComponent { render () { let ancestors, descendants; - const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain } = this.props; + const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props; const { fullscreen } = this.state; if (status === null) { return ( <Column> - <ColumnBackButton /> + <ColumnBackButton multiColumn={multiColumn} /> <MissingIndicator /> </Column> ); @@ -470,9 +471,10 @@ class Status extends ImmutablePureComponent { }; return ( - <Column label={intl.formatMessage(messages.detailedStatus)}> + <Column bindToDocument={!multiColumn} label={intl.formatMessage(messages.detailedStatus)}> <ColumnHeader showBackButton + multiColumn={multiColumn} extraButton={( <button className='column-header__button' title={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(status.get('hidden') ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={status.get('hidden') ? 'false' : 'true'}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button> )} diff --git a/app/javascript/mastodon/features/ui/components/column_loading.js b/app/javascript/mastodon/features/ui/components/column_loading.js index 9503a7a1a..0cdfd05d8 100644 --- a/app/javascript/mastodon/features/ui/components/column_loading.js +++ b/app/javascript/mastodon/features/ui/components/column_loading.js @@ -21,7 +21,7 @@ export default class ColumnLoading extends ImmutablePureComponent { let { title, icon } = this.props; return ( <Column> - <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} /> + <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} placeholder /> <div className='scrollable' /> </Column> ); diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js index ef3ad2e09..64a40a9da 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.js +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js @@ -2,10 +2,11 @@ import React from 'react'; import { NavLink, withRouter } from 'react-router-dom'; import { FormattedMessage } from 'react-intl'; import Icon from 'mastodon/components/icon'; -import { profile_directory } from 'mastodon/initial_state'; +import { profile_directory, showTrends } from 'mastodon/initial_state'; import NotificationsCounterIcon from './notifications_counter_icon'; import FollowRequestsNavLink from './follow_requests_nav_link'; import ListPanel from './list_panel'; +import TrendsContainer from 'mastodon/features/getting_started/containers/trends_container'; const NavigationPanel = () => ( <div className='navigation-panel'> @@ -25,6 +26,9 @@ const NavigationPanel = () => ( <a className='column-link column-link--transparent' href='/settings/preferences'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a> <a className='column-link column-link--transparent' href='/relationships'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a> {!!profile_directory && <a className='column-link column-link--transparent' href='/explore'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='navigation_bar.profile_directory' defaultMessage='Profile directory' /></a>} + + {showTrends && <div className='flex-spacer' />} + {showTrends && <TrendsContainer />} </div> ); diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js index 29583d3d7..1911da8ba 100644 --- a/app/javascript/mastodon/features/ui/components/tabs_bar.js +++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js @@ -73,9 +73,13 @@ class TabsBar extends React.PureComponent { const { intl: { formatMessage } } = this.props; return ( - <nav className='tabs-bar' ref={this.setRef}> - {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))} - </nav> + <div className='tabs-bar__wrapper'> + <nav className='tabs-bar' ref={this.setRef}> + {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))} + </nav> + + <div id='tabs-bar__portal' /> + </div> ); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 3c3c80e99..8db5f59af 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -23,5 +23,6 @@ export const isStaff = getMeta('is_staff'); export const forceSingleColumn = !getMeta('advanced_layout'); export const useBlurhash = getMeta('use_blurhash'); export const usePendingItems = getMeta('use_pending_items'); +export const showTrends = getMeta('trends'); export default initialState; diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index e683a9c1a..7b0cdd5a5 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -153,9 +153,9 @@ const sortHashtagsByUse = (state, tags) => { if (usedA === usedB) { return 0; } else if (usedA && !usedB) { - return 1; - } else { return -1; + } else { + return 1; } }); }; diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 981ad8e64..3b60878eb 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -31,6 +31,7 @@ import conversations from './conversations'; import suggestions from './suggestions'; import polls from './polls'; import identity_proofs from './identity_proofs'; +import trends from './trends'; const reducers = { dropdown_menu, @@ -65,6 +66,7 @@ const reducers = { conversations, suggestions, polls, + trends, }; export default combineReducers(reducers); diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js index 599a2443e..a30da2db1 100644 --- a/app/javascript/mastodon/reducers/modal.js +++ b/app/javascript/mastodon/reducers/modal.js @@ -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; } diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index 033bfc999..793a99f8f 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -12,6 +12,10 @@ const initialState = ImmutableMap({ skinTone: 1, + trends: ImmutableMap({ + show: true, + }), + home: ImmutableMap({ shows: ImmutableMap({ reblog: true, diff --git a/app/javascript/mastodon/reducers/trends.js b/app/javascript/mastodon/reducers/trends.js new file mode 100644 index 000000000..5cecc8fca --- /dev/null +++ b/app/javascript/mastodon/reducers/trends.js @@ -0,0 +1,23 @@ +import { TRENDS_FETCH_REQUEST, TRENDS_FETCH_SUCCESS, TRENDS_FETCH_FAIL } from '../actions/trends'; +import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; + +const initialState = ImmutableMap({ + items: ImmutableList(), + isLoading: false, +}); + +export default function trendsReducer(state = initialState, action) { + switch(action.type) { + case TRENDS_FETCH_REQUEST: + return state.set('isLoading', true); + case TRENDS_FETCH_SUCCESS: + return state.withMutations(map => { + map.set('items', fromJS(action.trends)); + map.set('isLoading', false); + }); + case TRENDS_FETCH_FAIL: + return state.set('isLoading', false); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/utils/resize_image.js b/app/javascript/mastodon/utils/resize_image.js index bbdbc865e..a8ec5f3fa 100644 --- a/app/javascript/mastodon/utils/resize_image.js +++ b/app/javascript/mastodon/utils/resize_image.js @@ -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); }); diff --git a/app/javascript/styles/mastodon/basics.scss b/app/javascript/styles/mastodon/basics.scss index 7df76bdff..7b983efab 100644 --- a/app/javascript/styles/mastodon/basics.scss +++ b/app/javascript/styles/mastodon/basics.scss @@ -39,7 +39,7 @@ body { &.layout-single-column { height: auto; - min-height: 100%; + min-height: 100vh; overflow-y: scroll; } diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 1853cd2e5..2d04aeca7 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -129,19 +129,28 @@ padding: 0; color: $action-button-color; 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; } @@ -166,10 +175,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 { @@ -197,6 +212,7 @@ .text-icon-button { color: $lighter-text-color; border: 0; + border-radius: 4px; background: transparent; cursor: pointer; font-weight: 600; @@ -204,17 +220,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; } @@ -604,7 +628,8 @@ } } - .icon-button { + .icon-button, + .text-icon-button { box-sizing: content-box; padding: 0 3px; } @@ -731,7 +756,7 @@ white-space: pre-wrap; &:last-child { - margin-bottom: 2px; + margin-bottom: 0; } } @@ -1852,6 +1877,26 @@ a.account__display-name { } } +.tabs-bar__wrapper { + background: darken($ui-base-color, 8%); + position: sticky; + top: 0; + z-index: 2; + padding-top: 0; + + @media screen and (min-width: $no-gap-breakpoint) { + padding-top: 10px; + } + + .tabs-bar { + margin-bottom: 0; + + @media screen and (min-width: $no-gap-breakpoint) { + margin-bottom: 10px; + } + } +} + .react-swipeable-view-container { &, .columns-area, @@ -1949,9 +1994,6 @@ a.account__display-name { background: lighten($ui-base-color, 8%); flex: 0 0 auto; overflow-y: auto; - position: sticky; - top: 0; - z-index: 3; } .tabs-bar__link { @@ -2014,6 +2056,14 @@ a.account__display-name { padding: 0; } + //.column { + // margin-top: 0; + + // @media screen and (min-width: $no-gap-breakpoint) { + // margin-top: 10px; + // } + //} + .autosuggest-textarea__textarea { font-size: 16px; } @@ -2039,6 +2089,7 @@ a.account__display-name { @media screen and (min-width: $no-gap-breakpoint) { padding: 10px 0; + padding-top: 0; } @media screen and (min-width: 630px) { @@ -2153,17 +2204,14 @@ a.account__display-name { @media screen and (min-width: $no-gap-breakpoint) { .tabs-bar { - margin: 10px auto; - margin-bottom: 0; width: 100%; } .react-swipeable-view-container .columns-area--mobile { - height: calc(100% - 20px) !important; + height: calc(100% - 10px) !important; } .getting-started__wrapper, - .getting-started__trends, .search { margin-bottom: 10px; } @@ -2270,13 +2318,24 @@ a.account__display-name { margin-bottom: 10px; height: calc(100% - 20px); overflow-y: auto; + display: flex; + flex-direction: column; + + & > a { + flex: 0 0 auto; + } hr { + flex: 0 0 auto; border: 0; background: transparent; border-top: 1px solid lighten($ui-base-color, 4%); margin: 10px 0; } + + .flex-spacer { + background: transparent; + } } .drawer__pager { @@ -2387,6 +2446,8 @@ a.account__display-name { } .column-back-button { + box-sizing: border-box; + width: 100%; background: lighten($ui-base-color, 4%); color: $highlight-text-color; cursor: pointer; @@ -2666,8 +2727,10 @@ a.account__display-name { } &__trends { - background: $ui-base-color; flex: 0 1 auto; + opacity: 1; + animation: fade 150ms linear; + margin-top: 10px; @media screen and (max-height: 810px) { .trends__item:nth-child(3) { @@ -2684,11 +2747,15 @@ a.account__display-name { @media screen and (max-height: 670px) { display: none; } - } - &__scrollable { - max-height: 100%; - overflow-y: auto; + .trends__item { + border-bottom: 0; + padding: 10px; + + &__current { + color: $darker-text-color; + } + } } } @@ -5917,7 +5984,8 @@ noscript { font-size: 24px; line-height: 36px; font-weight: 500; - text-align: center; + text-align: right; + padding-right: 15px; color: $secondary-text-color; } @@ -5925,7 +5993,12 @@ noscript { flex: 0 0 auto; width: 50px; - path { + path:first-child { + fill: rgba($highlight-text-color, 0.25) !important; + fill-opacity: 1 !important; + } + + path:last-child { stroke: lighten($highlight-text-color, 6%) !important; } } diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss index acaf5b024..8c30bc57c 100644 --- a/app/javascript/styles/mastodon/widgets.scss +++ b/app/javascript/styles/mastodon/widgets.scss @@ -324,7 +324,8 @@ &.active h4 { &, .fa, - small { + small, + .trends__item__current { color: $primary-text-color; } } @@ -337,6 +338,10 @@ &.active .avatar-stack .account__avatar { border-color: $ui-highlight-color; } + + .trends__item__current { + padding-right: 0; + } } } diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 59767cdfe..224d90660 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -37,7 +37,7 @@ class FeedManager end def unpush_from_home(account, status) - return false unless remove_from_feed(:home, account.id, status) + return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?) redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) true end @@ -56,7 +56,7 @@ class FeedManager end def unpush_from_list(list, status) - return false unless remove_from_feed(:list, list.id, status) + return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) true end @@ -120,7 +120,7 @@ class FeedManager oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_home_score).reorder(nil).find_each do |status| - remove_from_feed(:home, into_account.id, status) + remove_from_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?) end end @@ -316,10 +316,11 @@ class FeedManager # with reblogs, and returning true if a status was removed. As with # `add_to_feed`, this does not trigger push updates, so callers must # do so if appropriate. - def remove_from_feed(timeline_type, account_id, status) + def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true) timeline_key = key(timeline_type, account_id) + reblog_key = key(timeline_type, account_id, 'reblogs') - if status.reblog? + if status.reblog? && (aggregate_reblogs.nil? || aggregate_reblogs) # 1. If the reblogging status is not in the feed, stop. status_rank = redis.zrevrank(timeline_key, status.id) return false if status_rank.nil? @@ -328,6 +329,7 @@ class FeedManager reblog_set_key = key(timeline_type, account_id, "reblogs:#{status.reblog_of_id}") redis.srem(reblog_set_key, status.id) + redis.zrem(reblog_key, status.reblog_of_id) # 3. Re-insert another reblog or original into the feed if one # remains in the set. We could pick a random element, but this # set should generally be small, and it seems ideal to show the @@ -335,12 +337,14 @@ class FeedManager other_reblog = redis.smembers(reblog_set_key).map(&:to_i).min redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog + redis.zadd(reblog_key, other_reblog, status.reblog_of_id) if other_reblog # 4. Remove the reblogging status from the feed (as normal) # (outside conditional) else # If the original is getting deleted, no use for reblog references redis.del(key(timeline_type, account_id, "reblogs:#{status.id}")) + redis.zrem(reblog_key, status.id) end redis.zrem(timeline_key, status.id) diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index c9f78cd31..d85a333b3 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -380,7 +380,7 @@ class Formatter end def hashtag_html(tag) - "<a href=\"#{encode(tag_url(tag.downcase))}\" class=\"mention hashtag\" rel=\"tag\">#<span>#{encode(tag)}</span></a>" + "<a href=\"#{encode(tag_url(tag))}\" class=\"mention hashtag\" rel=\"tag\">#<span>#{encode(tag)}</span></a>" end def mention_html(account) diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index 51d8c0970..a52172707 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -40,6 +40,7 @@ class UserSettingsDecorator user.settings['default_content_type']= default_content_type_preference if change?('setting_default_content_type') user.settings['use_blurhash'] = use_blurhash_preference if change?('setting_use_blurhash') user.settings['use_pending_items'] = use_pending_items_preference if change?('setting_use_pending_items') + user.settings['trends'] = trends_preference if change?('setting_trends') end def merged_notification_emails @@ -142,6 +143,10 @@ class UserSettingsDecorator boolean_cast_setting 'setting_use_pending_items' end + def trends_preference + boolean_cast_setting 'setting_trends' + end + def boolean_cast_setting(key) ActiveModel::Type::Boolean.new.cast(settings[key]) end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index 9ab3e2bbd..8abce5f05 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -24,4 +24,14 @@ class AdminMailer < ApplicationMailer mail to: @me.user_email, subject: I18n.t('admin_mailer.new_pending_account.subject', instance: @instance, username: @account.username) end end + + def new_trending_tag(recipient, tag) + @tag = tag + @me = recipient + @instance = Rails.configuration.x.local_domain + + locale_for_account(@me) do + mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tag.subject', instance: @instance, name: @tag.name) + end + end end diff --git a/app/models/account.rb b/app/models/account.rb index 3370fbc5e..92e60f747 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -231,17 +231,7 @@ class Account < ApplicationRecord end def tags_as_strings=(tag_names) - tag_names.map! { |name| name.mb_chars.downcase.to_s } - tag_names.uniq! - - # Existing hashtags - hashtags_map = Tag.where(name: tag_names).each_with_object({}) { |tag, h| h[tag.name] = tag } - - # Initialize not yet existing hashtags - tag_names.each do |name| - next if hashtags_map.key?(name) - hashtags_map[name] = Tag.new(name: name) - end + hashtags_map = Tag.find_or_create_by_names(tag_names).each_with_object({}) { |tag, h| h[tag.name] = tag } # Remove hashtags that are to be deleted tags.each do |tag| diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 83134d41a..c1b873da6 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -2,5 +2,16 @@ class ApplicationRecord < ActiveRecord::Base self.abstract_class = true + include Remotable + + def boolean_with_default(key, default_value) + value = attributes[key] + + if value.nil? + default_value + else + value + end + end end diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 25d3b87ef..3f5b9f23e 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -3,13 +3,15 @@ # # Table name: domain_blocks # -# id :bigint(8) not null, primary key -# domain :string default(""), not null -# created_at :datetime not null -# updated_at :datetime not null -# severity :integer default("silence") -# reject_media :boolean default(FALSE), not null -# reject_reports :boolean default(FALSE), not null +# id :bigint(8) not null, primary key +# domain :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# severity :integer default("silence") +# reject_media :boolean default(FALSE), not null +# reject_reports :boolean default(FALSE), not null +# private_comment :text +# public_comment :text # class DomainBlock < ApplicationRecord diff --git a/app/models/featured_tag.rb b/app/models/featured_tag.rb index d06ae26a8..e02ae0705 100644 --- a/app/models/featured_tag.rb +++ b/app/models/featured_tag.rb @@ -23,7 +23,7 @@ class FeaturedTag < ApplicationRecord validate :validate_featured_tags_limit, on: :create def name=(str) - self.tag = Tag.find_or_initialize_by(name: str.strip.delete('#').mb_chars.downcase.to_s) + self.tag = Tag.find_or_create_by_names(str.strip)&.first end def increment(timestamp) diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index ecaed44f6..2c3a7f13b 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -35,6 +35,7 @@ class Form::AdminSettings show_reblogs_in_public_timelines show_replies_in_public_timelines spam_check_enabled + trends ).freeze BOOLEAN_KEYS = %i( @@ -51,6 +52,7 @@ class Form::AdminSettings show_reblogs_in_public_timelines show_replies_in_public_timelines spam_check_enabled + trends ).freeze UPLOAD_KEYS = %i( diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb index 2537de36c..93df11724 100644 --- a/app/models/remote_follow.rb +++ b/app/models/remote_follow.rb @@ -2,19 +2,21 @@ class RemoteFollow include ActiveModel::Validations + include RoutingHelper attr_accessor :acct, :addressable_template validates :acct, presence: true - def initialize(attrs = nil) - @acct = attrs[:acct].gsub(/\A@/, '').strip if !attrs.nil? && !attrs[:acct].nil? + def initialize(attrs = {}) + @acct = normalize_acct(attrs[:acct]) end def valid? return false unless super - populate_template + fetch_template! + errors.empty? end @@ -28,8 +30,30 @@ class RemoteFollow private - def populate_template - if acct.blank? || redirect_url_link.nil? || redirect_url_link.template.nil? + def normalize_acct(value) + return if value.blank? + + username, domain = value.strip.gsub(/\A@/, '').split('@') + + domain = begin + if TagManager.instance.local_domain?(domain) + nil + else + TagManager.instance.normalize_domain(domain) + end + end + + [username, domain].compact.join('@') + end + + def fetch_template! + return missing_resource if acct.blank? + + _, domain = acct.split('@') + + if domain.nil? + @addressable_template = Addressable::Template.new("#{authorize_interaction_url}?uri={uri}") + elsif redirect_url_link.nil? || redirect_url_link.template.nil? missing_resource_error else @addressable_template = Addressable::Template.new(redirect_uri_template) @@ -45,7 +69,7 @@ class RemoteFollow end def acct_resource - @_acct_resource ||= Goldfinger.finger("acct:#{acct}") + @acct_resource ||= Goldfinger.finger("acct:#{acct}") rescue Goldfinger::Error, HTTP::ConnectionError nil end diff --git a/app/models/tag.rb b/app/models/tag.rb index 46e3a3ec0..1364d1dba 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -3,10 +3,16 @@ # # Table name: tags # -# id :bigint(8) not null, primary key -# name :string default(""), not null -# created_at :datetime not null -# updated_at :datetime not null +# id :bigint(8) not null, primary key +# name :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# score :integer +# usable :boolean +# trendable :boolean +# listable :boolean +# reviewed_at :datetime +# requested_review_at :datetime # class Tag < ApplicationRecord @@ -21,16 +27,19 @@ class Tag < ApplicationRecord HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i } + validate :validate_name_change, if: -> { !new_record? && name_changed? } - scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) } - scope :hidden, -> { where(account_tag_stats: { hidden: true }) } + scope :reviewed, -> { where.not(reviewed_at: nil) } + scope :unreviewed, -> { where(reviewed_at: nil) } + scope :pending_review, -> { unreviewed.where.not(requested_review_at: nil) } + scope :usable, -> { where(usable: [true, nil]) } + scope :discoverable, -> { where(listable: [true, nil]).joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).order(Arel.sql('account_tag_stats.accounts_count desc')) } scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) } delegate :accounts_count, :accounts_count=, :increment_count!, :decrement_count!, - :hidden?, to: :account_tag_stat after_save :save_account_tag_stat @@ -47,6 +56,40 @@ class Tag < ApplicationRecord name end + def usable + boolean_with_default('usable', true) + end + + alias usable? usable + + def listable + boolean_with_default('listable', true) + end + + alias listable? listable + + def trendable + boolean_with_default('trendable', false) + end + + alias trendable? trendable + + def requires_review? + reviewed_at.nil? + end + + def reviewed? + reviewed_at.present? + end + + def requested_review? + requested_review_at.present? + end + + def trending? + TrendingTags.trending?(self) + end + def history days = [] @@ -75,10 +118,12 @@ class Tag < ApplicationRecord end def search_for(term, limit = 5, offset = 0) - pattern = sanitize_sql_like(normalize(term.strip)) + '%' + normalized_term = normalize(term.strip).mb_chars.downcase.to_s + pattern = sanitize_sql_like(normalized_term) + '%' - Tag.where(arel_table[:name].lower.matches(pattern.mb_chars.downcase.to_s)) - .order(:name) + Tag.where(arel_table[:name].lower.matches(pattern)) + .where(arel_table[:score].gt(0).or(arel_table[:name].lower.eq(normalized_term))) + .order(Arel.sql('length(name) ASC, score DESC, name ASC')) .limit(limit) .offset(offset) end @@ -114,4 +159,8 @@ class Tag < ApplicationRecord return unless account_tag_stat&.changed? account_tag_stat.save end + + def validate_name_change + errors.add(:name, I18n.t('tags.does_not_match_previous_name')) unless name_was.mb_chars.casecmp(name.mb_chars).zero? + end end diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb index 148535c21..594ae9520 100644 --- a/app/models/trending_tags.rb +++ b/app/models/trending_tags.rb @@ -5,23 +5,32 @@ class TrendingTags EXPIRE_HISTORY_AFTER = 7.days.seconds EXPIRE_TRENDS_AFTER = 1.day.seconds THRESHOLD = 5 + LIMIT = 10 class << self include Redisable def record_use!(tag, account, at_time = Time.now.utc) - return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot? + return if account.silenced? || account.bot? || !tag.usable? || !(tag.trendable? || tag.requires_review?) increment_historical_use!(tag.id, at_time) increment_unique_use!(tag.id, account.id, at_time) - increment_vote!(tag.id, at_time) + increment_vote!(tag, at_time) end - def get(limit) - key = "#{KEY}:#{Time.now.utc.beginning_of_day.to_i}" - tag_ids = redis.zrevrange(key, 0, limit - 1).map(&:to_i) - tags = Tag.where(id: tag_ids).to_a.each_with_object({}) { |tag, h| h[tag.id] = tag } - tag_ids.map { |tag_id| tags[tag_id] }.compact + def get(limit, filtered: true) + tag_ids = redis.zrevrange("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", 0, LIMIT - 1).map(&:to_i) + + tags = Tag.where(id: tag_ids) + tags = tags.where(trendable: true) if filtered + tags = tags.each_with_object({}) { |tag, h| h[tag.id] = tag } + + tag_ids.map { |tag_id| tags[tag_id] }.compact.take(limit) + end + + def trending?(tag) + rank = redis.zrevrank("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", tag.id) + rank.present? && rank <= LIMIT end private @@ -38,28 +47,31 @@ class TrendingTags redis.expire(key, EXPIRE_HISTORY_AFTER) end - def increment_vote!(tag_id, at_time) + def increment_vote!(tag, at_time) key = "#{KEY}:#{at_time.beginning_of_day.to_i}" - expected = redis.pfcount("activity:tags:#{tag_id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f + expected = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f expected = 1.0 if expected.zero? - observed = redis.pfcount("activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts").to_f + observed = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f if expected > observed || observed < THRESHOLD - redis.zrem(key, tag_id.to_s) + redis.zrem(key, tag.id) else - score = ((observed - expected)**2) / expected - redis.zadd(key, score, tag_id.to_s) + score = ((observed - expected)**2) / expected + old_rank = redis.zrevrank(key, tag.id) + + redis.zadd(key, score, tag.id) + request_review!(tag) if (old_rank.nil? || old_rank > LIMIT) && redis.zrevrank(key, tag.id) <= LIMIT && !tag.trendable? && tag.requires_review? && !tag.requested_review? end redis.expire(key, EXPIRE_TRENDS_AFTER) end - def disallowed_hashtags - return @disallowed_hashtags if defined?(@disallowed_hashtags) + def request_review!(tag) + return unless Setting.trends + + tag.touch(:requested_review_at) - @disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags - @disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String - @disallowed_hashtags = @disallowed_hashtags.map(&:downcase) + User.staff.includes(:account).find_each { |u| AdminMailer.new_trending_tag(u.account, tag).deliver_later! if u.allows_trending_tag_emails? } end end end diff --git a/app/models/user.rb b/app/models/user.rb index 2a7fffca5..45a4b8989 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -107,7 +107,9 @@ class User < ApplicationRecord delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal, :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_media, :hide_network, :hide_followers_count, :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, - :advanced_layout, :default_content_type, :use_blurhash, :use_pending_items, :use_pending_items, to: :settings, prefix: :setting, allow_nil: false + :advanced_layout, :use_blurhash, :use_pending_items, :trends, + :default_content_type, + to: :settings, prefix: :setting, allow_nil: false attr_reader :invite_code attr_writer :external @@ -207,6 +209,10 @@ class User < ApplicationRecord settings.notification_emails['pending_account'] end + def allows_trending_tag_emails? + settings.notification_emails['trending_tag'] + end + def hides_network? @hides_network ||= settings.hide_network end diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb index c63de01db..aaf70fcab 100644 --- a/app/policies/tag_policy.rb +++ b/app/policies/tag_policy.rb @@ -5,11 +5,11 @@ class TagPolicy < ApplicationPolicy staff? end - def hide? + def show? staff? end - def unhide? + def update? staff? end end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index e22059182..c8da6e725 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -34,6 +34,7 @@ class InitialStateSerializer < ActiveModel::Serializer invites_enabled: Setting.min_invite_role == 'user', mascot: instance_presenter.mascot&.file&.url, profile_directory: Setting.profile_directory, + trends: Setting.trends, } if object.current_account @@ -50,6 +51,7 @@ class InitialStateSerializer < ActiveModel::Serializer store[:use_blurhash] = object.current_account.user.setting_use_blurhash store[:use_pending_items] = object.current_account.user.setting_use_pending_items store[:is_staff] = object.current_account.user.staff? + store[:trends] = Setting.trends && object.current_account.user.setting_trends store[:default_content_type] = object.current_account.user.setting_default_content_type end diff --git a/app/serializers/webfinger_serializer.rb b/app/serializers/webfinger_serializer.rb index 008d0c182..c67363b8f 100644 --- a/app/serializers/webfinger_serializer.rb +++ b/app/serializers/webfinger_serializer.rb @@ -26,7 +26,6 @@ class WebfingerSerializer < ActiveModel::Serializer else [ { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: short_account_url(object) }, - { rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(object, format: 'atom') }, { rel: 'self', type: 'application/activity+json', href: account_url(object) }, { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_interaction_url}?uri={uri}" }, ] diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index bbee47cb7..c9a9a5a6e 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -81,8 +81,8 @@ class BatchedRemoveStatusService < BaseService end @tags[status.id].each do |hashtag| - redis.publish("timeline:hashtag:#{hashtag}", payload) - redis.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local? + redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", payload) + redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", payload) if status.local? end end end diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb index c5e5e5761..0ec6be503 100644 --- a/app/services/block_domain_service.rb +++ b/app/services/block_domain_service.rb @@ -3,13 +3,22 @@ class BlockDomainService < BaseService attr_reader :domain_block - def call(domain_block) + def call(domain_block, update = false) @domain_block = domain_block process_domain_block! + process_retroactive_updates! if update end private + def process_retroactive_updates! + # If the domain block severity has been changed, undo the appropriate limitations + scope = Account.by_domain_and_subdomains(domain_block.domain) + + scope.where(silenced_at: domain_block.created_at).in_batches.update_all(silenced_at: nil) unless domain_block.silence? + scope.where(suspended_at: domain_block.created_at).in_batches.update_all(suspended_at: nil) unless domain_block.suspend? + end + def process_domain_block! clear_media! if domain_block.reject_media? diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index cf433d8a6..72f716dc5 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -77,8 +77,8 @@ class FanOutOnWriteService < BaseService Rails.logger.debug "Delivering status #{status.id} to hashtags" status.tags.pluck(:name).each do |hashtag| - Redis.current.publish("timeline:hashtag:#{hashtag}", @payload) - Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if status.local? + Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload) + Redis.current.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if status.local? end end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 958a67e8f..c19fa2126 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -126,8 +126,8 @@ class RemoveStatusService < BaseService return unless @status.public_visibility? @tags.each do |hashtag| - redis.publish("timeline:hashtag:#{hashtag}", @payload) - redis.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local? + redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}", @payload) + redis.publish("timeline:hashtag:#{hashtag.mb_chars.downcase}:local", @payload) if @status.local? end end diff --git a/app/services/resolve_account_service.rb b/app/services/resolve_account_service.rb index 7864c4bcd..12e6544a0 100644 --- a/app/services/resolve_account_service.rb +++ b/app/services/resolve_account_service.rb @@ -60,17 +60,23 @@ class ResolveAccountService < BaseService @account = uri @username = @account.username @domain = @account.domain - @uri = [@username, @domain].compact.join('@') else - @uri = uri @username, @domain = uri.split('@') end - @domain = nil if TagManager.instance.local_domain?(@domain) + @domain = begin + if TagManager.instance.local_domain?(@domain) + nil + else + TagManager.instance.normalize_domain(@domain) + end + end + + @uri = [@username, @domain].compact.join('@') end def process_webfinger!(uri, redirected = false) - @webfinger = Goldfinger.finger("acct:#{@uri}") + @webfinger = Goldfinger.finger("acct:#{uri}") confirmed_username, confirmed_domain = @webfinger.subject.gsub(/\Aacct:/, '').split('@') if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero? diff --git a/app/services/unblock_domain_service.rb b/app/services/unblock_domain_service.rb index fc262a50a..d502d9e49 100644 --- a/app/services/unblock_domain_service.rb +++ b/app/services/unblock_domain_service.rb @@ -10,24 +10,9 @@ class UnblockDomainService < BaseService end def process_retroactive_updates - blocked_accounts.in_batches.update_all(update_options) unless domain_block.noop? - end - - def blocked_accounts scope = Account.by_domain_and_subdomains(domain_block.domain) - if domain_block.silence? - scope.where(silenced_at: @domain_block.created_at) - else - scope.where(suspended_at: @domain_block.created_at) - end - end - - def update_options - { domain_block_impact => nil } - end - - def domain_block_impact - domain_block.silence? ? :silenced_at : :suspended_at + scope.where(silenced_at: domain_block.created_at).in_batches.update_all(silenced_at: nil) unless domain_block.noop? + scope.where(suspended_at: domain_block.created_at).in_batches.update_all(suspended_at: nil) if domain_block.suspend? end end diff --git a/app/validators/disallowed_hashtags_validator.rb b/app/validators/disallowed_hashtags_validator.rb index ee06b20f6..d745b767f 100644 --- a/app/validators/disallowed_hashtags_validator.rb +++ b/app/validators/disallowed_hashtags_validator.rb @@ -4,24 +4,7 @@ class DisallowedHashtagsValidator < ActiveModel::Validator def validate(status) return unless status.local? && !status.reblog? - @status = status - tags = select_tags - - status.errors.add(:text, I18n.t('statuses.disallowed_hashtags', tags: tags.join(', '), count: tags.size)) unless tags.empty? - end - - private - - def select_tags - tags = Extractor.extract_hashtags(@status.text) - tags.keep_if { |tag| disallowed_hashtags.include? tag.downcase } - end - - def disallowed_hashtags - return @disallowed_hashtags if @disallowed_hashtags - - @disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags - @disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String - @disallowed_hashtags = @disallowed_hashtags.map(&:downcase) + disallowed_hashtags = Tag.matching_name(Extractor.extract_hashtags(status.text)).reject(&:usable?) + status.errors.add(:text, I18n.t('statuses.disallowed_hashtags', tags: disallowed_hashtags.map(&:name).join(', '), count: disallowed_hashtags.size)) unless disallowed_hashtags.empty? end end diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index 6846abeb6..034304936 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -7,7 +7,6 @@ - if @account.user&.setting_noindex %meta{ name: 'robots', content: 'noindex, noarchive' }/ - %link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/ %link{ rel: 'alternate', type: 'application/rss+xml', href: account_url(@account, format: 'rss') }/ %link{ rel: 'alternate', type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(@account) }/ @@ -74,7 +73,7 @@ - if featured_tag.last_status_at.nil? = t('accounts.nothing_here') - else - %time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at + %time.formatted{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at .trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true = render 'application/sidebar' diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index 7494c9fa2..59babd3b0 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -174,7 +174,7 @@ - unless @account.local? - if DomainBlock.where(domain: @account.domain).exists? - = link_to t('admin.domain_blocks.undo'), admin_instance_path(@account.domain), class: 'button' + = link_to t('admin.domain_blocks.view'), admin_instance_path(@account.domain), class: 'button' - else = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @account.domain), class: 'button button--destructive' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 54cf9af5d..408d515ca 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -15,14 +15,22 @@ .dashboard__counters__num= number_with_delimiter @logins_week .dashboard__counters__label= t 'admin.dashboard.week_users_active' %div - %div - .dashboard__counters__num= number_with_delimiter @interactions_week - .dashboard__counters__label= t 'admin.dashboard.week_interactions' + = link_to admin_pending_accounts_path do + .dashboard__counters__num= number_with_delimiter @pending_users_count + .dashboard__counters__label= t 'admin.dashboard.pending_users' %div = link_to admin_reports_url do .dashboard__counters__num= number_with_delimiter @reports_count .dashboard__counters__label= t 'admin.dashboard.open_reports' %div + = link_to admin_tags_path(review: 'pending_review') do + .dashboard__counters__num= number_with_delimiter @pending_tags_count + .dashboard__counters__label= t 'admin.dashboard.pending_tags' + %div + %div + .dashboard__counters__num= number_with_delimiter @interactions_week + .dashboard__counters__label= t 'admin.dashboard.week_interactions' + %div = link_to sidekiq_url do .dashboard__counters__num= number_with_delimiter @queue_backlog .dashboard__counters__label= t 'admin.dashboard.backlog' @@ -52,6 +60,8 @@ %li = feature_hint(link_to(t('admin.dashboard.keybase'), edit_admin_settings_path), @keybase_integration) %li + = feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled) + %li = feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled) %li = feature_hint(link_to(t('admin.dashboard.feature_spam_check'), edit_admin_settings_path), @spam_check_enabled) @@ -93,6 +103,10 @@ %li = feature_hint(t('admin.dashboard.single_user_mode'), @single_user_mode) %li + = feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch) + %li + = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_mode) + %li = feature_hint('LDAP', @ldap_enabled) %li = feature_hint('CAS', @cas_enabled) @@ -109,5 +123,5 @@ %ul - @trending_hashtags.each do |tag| %li - = link_to "##{tag.name}", web_url("timelines/tag/#{tag.name}") + = link_to content_tag(:span, "##{tag.name}", class: !tag.trendable? && !tag.reviewed? ? 'warning-hint' : (!tag.trendable? ? 'negative-hint' : nil)), admin_tag_path(tag.id) %span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i) diff --git a/app/views/admin/domain_blocks/edit.html.haml b/app/views/admin/domain_blocks/edit.html.haml new file mode 100644 index 000000000..8669bb6d1 --- /dev/null +++ b/app/views/admin/domain_blocks/edit.html.haml @@ -0,0 +1,27 @@ +- content_for :page_title do + = t('admin.domain_blocks.edit') + += simple_form_for @domain_block, url: admin_domain_block_path(@domain_block), method: :put do |f| + = render 'shared/error_messages', object: @domain_block + + .fields-row + .fields-row__column.fields-row__column-6.fields-group + = f.input :domain, wrapper: :with_label, label: t('admin.domain_blocks.domain'), hint: t('admin.domain_blocks.new.hint'), required: true, readonly: true, disabled: true + + .fields-row__column.fields-row__column-6.fields-group + = f.input :severity, collection: DomainBlock.severities.keys, wrapper: :with_label, include_blank: false, label_method: lambda { |type| t("admin.domain_blocks.new.severity.#{type}") }, hint: t('admin.domain_blocks.new.severity.desc_html') + + .fields-group + = f.input :reject_media, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_media'), hint: I18n.t('admin.domain_blocks.reject_media_hint') + + .fields-group + = f.input :reject_reports, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_reports'), hint: I18n.t('admin.domain_blocks.reject_reports_hint') + + .field-group + = f.input :private_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.private_comment'), hint: t('admin.domain_blocks.private_comment_hint'), rows: 6 + + .field-group + = f.input :public_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.public_comment'), hint: t('admin.domain_blocks.public_comment_hint'), rows: 6 + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/domain_blocks/new.html.haml b/app/views/admin/domain_blocks/new.html.haml index 3a4963489..a643825df 100644 --- a/app/views/admin/domain_blocks/new.html.haml +++ b/app/views/admin/domain_blocks/new.html.haml @@ -17,5 +17,11 @@ .fields-group = f.input :reject_reports, as: :boolean, wrapper: :with_label, label: I18n.t('admin.domain_blocks.reject_reports'), hint: I18n.t('admin.domain_blocks.reject_reports_hint') + .field-group + = f.input :private_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.private_comment'), hint: t('admin.domain_blocks.private_comment_hint'), rows: 6 + + .field-group + = f.input :public_comment, wrapper: :with_label, label: I18n.t('admin.domain_blocks.public_comment'), hint: t('admin.domain_blocks.public_comment_hint'), rows: 6 + .actions = f.button :button, t('.create'), type: :submit diff --git a/app/views/admin/domain_blocks/show.html.haml b/app/views/admin/domain_blocks/show.html.haml index dca4dbac7..e64aaa629 100644 --- a/app/views/admin/domain_blocks/show.html.haml +++ b/app/views/admin/domain_blocks/show.html.haml @@ -1,6 +1,18 @@ - content_for :page_title do = t('admin.domain_blocks.show.title', domain: @domain_block.domain) +- if @domain_block.private_comment.present? + .speech-bubble + .speech-bubble__bubble + = simple_format(h(@domain_block.private_comment)) + .speech-bubble__owner= t 'admin.instances.private_comment' + +- if @domain_block.public_comment.present? + .speech-bubble + .speech-bubble__bubble + = simple_format(h(@domain_block.public_comment)) + .speech-bubble__owner= t 'admin.instances.public_comment' + = simple_form_for @domain_block, url: admin_domain_block_path(@domain_block), method: :delete do |f| - unless (@domain_block.noop?) diff --git a/app/views/admin/instances/show.html.haml b/app/views/admin/instances/show.html.haml index fbb49ba02..294c9495d 100644 --- a/app/views/admin/instances/show.html.haml +++ b/app/views/admin/instances/show.html.haml @@ -31,6 +31,18 @@ = fa_icon 'times' .dashboard__counters__label= t 'admin.instances.delivery_available' +- if @private_comment.present? + .speech-bubble + .speech-bubble__bubble + = simple_format(h(@private_comment)) + .speech-bubble__owner= t 'admin.instances.private_comment' + +- if @public_comment.present? + .speech-bubble + .speech-bubble__bubble + = simple_format(h(@public_comment)) + .speech-bubble__owner= t 'admin.instances.public_comment' + %hr.spacer/ %div{ style: 'overflow: hidden' } @@ -41,6 +53,7 @@ - if @domain_allow = link_to t('admin.domain_allows.undo'), admin_domain_allow_path(@domain_allow), class: 'button button--destructive', data: { confirm: t('admin.accounts.are_you_sure'), method: :delete } - elsif @domain_block + = link_to t('admin.domain_blocks.edit'), edit_admin_domain_block_path(@domain_block), class: 'button' = link_to t('admin.domain_blocks.undo'), admin_domain_block_path(@domain_block), class: 'button' - else = link_to t('admin.domain_blocks.add_new'), new_admin_domain_block_path(_domain: @instance.domain), class: 'button' diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index efe6ea56b..b0ab394d6 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -68,6 +68,9 @@ .fields-group = f.input :profile_directory, as: :boolean, wrapper: :with_label, label: t('admin.settings.profile_directory.title'), hint: t('admin.settings.profile_directory.desc_html') + .fields-group + = f.input :trends, as: :boolean, wrapper: :with_label, label: t('admin.settings.trends.title'), hint: t('admin.settings.trends.desc_html') + .fields-group = f.input :hide_followers_count, as: :boolean, wrapper: :with_label, label: t('admin.settings.hide_followers_count.title'), hint: t('admin.settings.hide_followers_count.desc_html') diff --git a/app/views/admin/tags/_tag.html.haml b/app/views/admin/tags/_tag.html.haml index 961b83f93..91af8e492 100644 --- a/app/views/admin/tags/_tag.html.haml +++ b/app/views/admin/tags/_tag.html.haml @@ -1,12 +1,16 @@ -%tr - %td - = link_to explore_hashtag_path(tag) do +.directory__tag + = link_to admin_tag_path(tag.id) do + %h4 = fa_icon 'hashtag' = tag.name - %td - = t('directories.people', count: tag.accounts_count) - %td - - if tag.hidden? - = table_link_to 'eye', t('admin.tags.unhide'), unhide_admin_tag_path(tag.id, **@filter_params), method: :post - - else - = table_link_to 'eye-slash', t('admin.tags.hide'), hide_admin_tag_path(tag.id, **@filter_params), method: :post + + %small + = t('admin.tags.in_directory', count: tag.accounts_count) + • + = t('admin.tags.unique_uses_today', count: tag.history.first[:accounts]) + + - if tag.trending? + = fa_icon 'fire fw' + = t('admin.tags.trending_right_now') + + .trends__item__current= number_to_human tag.history.first[:uses], strip_insignificant_zeros: true diff --git a/app/views/admin/tags/index.html.haml b/app/views/admin/tags/index.html.haml index 4ba395860..d994955ef 100644 --- a/app/views/admin/tags/index.html.haml +++ b/app/views/admin/tags/index.html.haml @@ -3,17 +3,20 @@ .filters .filter-subset - %strong= t('admin.reports.status') + %strong= t('admin.tags.context') %ul - %li= filter_link_to t('admin.tags.visible'), hidden: nil - %li= filter_link_to t('admin.tags.hidden'), hidden: '1' + %li= filter_link_to t('generic.all'), context: nil + %li= filter_link_to t('admin.tags.directory'), context: 'directory' -.table-wrapper - %table.table - %thead - %tr - %th= t('admin.tags.name') - %th= t('admin.tags.accounts') - %th - %tbody - = render @tags + .filter-subset + %strong= t('admin.tags.review') + %ul + %li= filter_link_to t('generic.all'), review: nil + %li= filter_link_to t('admin.tags.unreviewed'), review: 'unreviewed' + %li= filter_link_to t('admin.tags.reviewed'), review: 'reviewed' + %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), review: 'pending_review' + +%hr.spacer/ + += render @tags += paginate @tags diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml new file mode 100644 index 000000000..6a1e03065 --- /dev/null +++ b/app/views/admin/tags/show.html.haml @@ -0,0 +1,45 @@ +- content_for :page_title do + = "##{@tag.name}" + +.dashboard__counters + %div + = link_to web_url("timelines/tag/#{@tag.name}") do + .dashboard__counters__num= number_with_delimiter @accounts_today + .dashboard__counters__label= t 'admin.tags.accounts_today' + %div + %div + .dashboard__counters__num= number_with_delimiter @accounts_week + .dashboard__counters__label= t 'admin.tags.accounts_week' + %div + = link_to explore_hashtag_path(@tag) do + .dashboard__counters__num= number_with_delimiter @tag.accounts_count + .dashboard__counters__label= t 'admin.tags.directory' + +%hr.spacer/ + += simple_form_for @tag, url: admin_tag_path(@tag.id) do |f| + = render 'shared/error_messages', object: @tag + + .fields-group + = f.input :name, wrapper: :with_block_label + + .fields-group + = f.input :usable, as: :boolean, wrapper: :with_label + = f.input :trendable, as: :boolean, wrapper: :with_label, disabled: !Setting.trends + = f.input :listable, as: :boolean, wrapper: :with_label, disabled: !Setting.profile_directory + + .actions + = f.button :button, t('generic.save_changes'), type: :submit + +%hr.spacer/ + +%h3= t 'admin.tags.breakdown' + +.table-wrapper + %table.table + %tbody + - @usage_by_domain.each do |(domain, count)| + %tr + %th= domain || site_hostname + %td= "#{number_with_delimiter((count.to_f / @tag.history[0][:uses].to_f) * 100)}%" + %td= number_with_delimiter count diff --git a/app/views/admin_mailer/new_trending_tag.text.erb b/app/views/admin_mailer/new_trending_tag.text.erb new file mode 100644 index 000000000..f3087df37 --- /dev/null +++ b/app/views/admin_mailer/new_trending_tag.text.erb @@ -0,0 +1,5 @@ +<%= raw t('application_mailer.salutation', name: display_name(@me)) %> + +<%= raw t('admin_mailer.new_trending_tag.body', name: @tag.name) %> + +<%= raw t('application_mailer.view')%> <%= admin_tags_url(review: 'pending_review') %> diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index 447958253..0bda49f44 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -22,6 +22,11 @@ = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label + %h4= t 'appearance.discovery' + + .fields-group + = f.input :setting_trends, as: :boolean, wrapper: :with_label + %h4= t 'appearance.confirmation_dialogs' .fields-group diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml index acc646fc3..f666ae4ff 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -15,6 +15,7 @@ - if current_user.staff? = ff.input :report, as: :boolean, wrapper: :with_label = ff.input :pending_account, as: :boolean, wrapper: :with_label + = ff.input :trending_tag, as: :boolean, wrapper: :with_label .fields-group = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| diff --git a/app/views/well_known/webfinger/show.xml.ruby b/app/views/well_known/webfinger/show.xml.ruby deleted file mode 100644 index f5a54052a..000000000 --- a/app/views/well_known/webfinger/show.xml.ruby +++ /dev/null @@ -1,51 +0,0 @@ -doc = Ox::Document.new(version: '1.0') - -doc << Ox::Element.new('XRD').tap do |xrd| - xrd['xmlns'] = 'http://docs.oasis-open.org/ns/xri/xrd-1.0' - - xrd << (Ox::Element.new('Subject') << @account.to_webfinger_s) - - if @account.instance_actor? - xrd << (Ox::Element.new('Alias') << instance_actor_url) - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'http://webfinger.net/rel/profile-page' - link['type'] = 'text/html' - link['href'] = about_more_url(instance_actor: true) - end - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'self' - link['type'] = 'application/activity+json' - link['href'] = instance_actor_url - end - else - xrd << (Ox::Element.new('Alias') << short_account_url(@account)) - xrd << (Ox::Element.new('Alias') << account_url(@account)) - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'http://webfinger.net/rel/profile-page' - link['type'] = 'text/html' - link['href'] = short_account_url(@account) - end - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'http://schemas.google.com/g/2010#updates-from' - link['type'] = 'application/atom+xml' - link['href'] = account_url(@account, format: 'atom') - end - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'self' - link['type'] = 'application/activity+json' - link['href'] = account_url(@account) - end - - xrd << Ox::Element.new('Link').tap do |link| - link['rel'] = 'http://ostatus.org/schema/1.0/subscribe' - link['template'] = "#{authorize_interaction_url}?acct={uri}" - end - end -end - -('<?xml version="1.0" encoding="UTF-8"?>' + Ox.dump(doc, effort: :tolerant)).force_encoding('UTF-8') diff --git a/app/workers/domain_block_worker.rb b/app/workers/domain_block_worker.rb index 884477829..35518d6b5 100644 --- a/app/workers/domain_block_worker.rb +++ b/app/workers/domain_block_worker.rb @@ -3,8 +3,8 @@ class DomainBlockWorker include Sidekiq::Worker - def perform(domain_block_id) - BlockDomainService.new.call(DomainBlock.find(domain_block_id)) + def perform(domain_block_id, update = false) + BlockDomainService.new.call(DomainBlock.find(domain_block_id), update) rescue ActiveRecord::RecordNotFound true end |