diff --git a/Gemfile b/Gemfile index 70295bc8d1..43d8d2fd08 100644 --- a/Gemfile +++ b/Gemfile @@ -115,7 +115,7 @@ end group :test do gem 'capybara', '~> 3.28' gem 'climate_control', '~> 0.2' - gem 'faker', '~> 1.9' + gem 'faker', '~> 2.1' gem 'microformats', '~> 4.1' gem 'rails-controller-testing', '~> 1.0' gem 'rspec-sidekiq', '~> 3.0' diff --git a/Gemfile.lock b/Gemfile.lock index 5a096ba061..6979c7a0f8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -229,7 +229,7 @@ GEM tzinfo excon (0.62.0) fabrication (2.20.2) - faker (1.9.6) + faker (2.1.0) i18n (>= 0.7) faraday (0.15.0) multipart-post (>= 1.2, < 3) @@ -698,7 +698,7 @@ DEPENDENCIES doorkeeper (~> 5.1) dotenv-rails (~> 2.7) fabrication (~> 2.20) - faker (~> 1.9) + faker (~> 2.1) fast_blank (~> 1.0) fastimage fog-core (<= 2.1.0) diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 4f4341918b..92bf7fbb90 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -28,10 +28,13 @@ module Admin @pam_enabled = ENV['PAM_ENABLED'] == 'true' @hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true' @trending_hashtags = TrendingTags.get(10, filtered: false) + @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 +44,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/tags_controller.rb b/app/controllers/admin/tags_controller.rb index 0e9dda3022..ed271aedcb 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -17,7 +17,7 @@ module Admin authorize @tag, :update? if @tag.update(tag_params.merge(reviewed_at: Time.now.utc)) - redirect_to admin_tag_path(@tag.id) + redirect_to admin_tag_path(@tag.id), notice: I18n.t('admin.tags.updated_msg') else render :show end diff --git a/app/controllers/directories_controller.rb b/app/controllers/directories_controller.rb index 0702ebc04a..f2d1f56619 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/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index ea4491d1e2..418ea5d7ac 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -58,6 +58,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_default_content_type, :setting_use_blurhash, :setting_use_pending_items, + :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) ) diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index ec494bb2dc..d6bb28eb51 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/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js index f29b824d5e..39d7ba50cf 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -45,7 +45,6 @@ class DropdownMenu extends React.PureComponent { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - this.activeElement = document.activeElement; if (this.focusedItem && this.props.openedViaKeyboard) { this.focusedItem.focus(); } @@ -56,9 +55,6 @@ class DropdownMenu extends React.PureComponent { document.removeEventListener('click', this.handleDocumentClick, false); document.removeEventListener('keydown', this.handleKeyDown, false); document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.activeElement) { - this.activeElement.focus(); - } } setRef = c => { @@ -117,7 +113,7 @@ class DropdownMenu extends React.PureComponent { } } - handleItemKeyUp = e => { + handleItemKeyPress = e => { if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } @@ -147,7 +143,7 @@ class DropdownMenu extends React.PureComponent { return (
  • - + {text}
  • @@ -214,15 +210,44 @@ 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); } + 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; + } + } + handleItemClick = (i, e) => { const { action, to } = this.props.items[i]; @@ -265,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} /> diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js index f8b101dc42..95a4fe3fa0 100644 --- a/app/javascript/flavours/glitch/components/status_content.js +++ b/app/javascript/flavours/glitch/components/status_content.js @@ -172,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}`); diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js new file mode 100644 index 0000000000..853e4f60ae --- /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/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index 9937d0f886..d423378c11 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -45,7 +45,6 @@ class DropdownMenu extends React.PureComponent { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - this.activeElement = document.activeElement; if (this.focusedItem && this.props.openedViaKeyboard) { this.focusedItem.focus(); } @@ -56,9 +55,6 @@ class DropdownMenu extends React.PureComponent { document.removeEventListener('click', this.handleDocumentClick, false); document.removeEventListener('keydown', this.handleKeyDown, false); document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.activeElement) { - this.activeElement.focus(); - } } setRef = c => { @@ -117,7 +113,7 @@ class DropdownMenu extends React.PureComponent { } } - handleItemKeyUp = e => { + handleItemKeyPress = e => { if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } @@ -147,7 +143,7 @@ class DropdownMenu extends React.PureComponent { return (
  • - + {text}
  • @@ -214,15 +210,44 @@ 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); } + 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; + } + } + handleItemClick = e => { const i = Number(e.currentTarget.getAttribute('data-index')); const { action, to } = this.props.items[i]; @@ -266,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} /> diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index a727359e94..4016750523 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -14,6 +14,7 @@ export default class IconButton extends React.PureComponent { onClick: PropTypes.func, onMouseDown: PropTypes.func, onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, size: PropTypes.number, active: PropTypes.bool, pressed: PropTypes.bool, @@ -44,6 +45,12 @@ 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); @@ -100,6 +107,7 @@ export default class IconButton extends React.PureComponent { onClick={this.handleClick} onMouseDown={this.handleMouseDown} onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} style={style} tabIndex={tabIndex} disabled={disabled} @@ -121,6 +129,7 @@ export default class IconButton extends React.PureComponent { 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/status_content.js b/app/javascript/mastodon/components/status_content.js index 549de95fc1..76117f1d92 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -112,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/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js new file mode 100644 index 0000000000..1dcacc8b39 --- /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 ( +
    + {trends.take(3).map(hashtag => )} +
    + ); + } + +} 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 0000000000..1df3fb4fe2 --- /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 791f22d471..6a122a750b 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' }, @@ -168,6 +169,8 @@ class GettingStarted extends ImmutablePureComponent { + + {multiColumn && showTrends && } ); } diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js index ef3ad2e092..64a40a9da8 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 = () => (
    @@ -25,6 +26,9 @@ const NavigationPanel = () => ( {!!profile_directory && } + + {showTrends &&
    } + {showTrends && }
    ); diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 3c3c80e995..8db5f59aff 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/index.js b/app/javascript/mastodon/reducers/index.js index 981ad8e64c..3b60878eb7 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/settings.js b/app/javascript/mastodon/reducers/settings.js index 033bfc999a..793a99f8f5 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 0000000000..5cecc8fcab --- /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/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 97ef06efeb..2d04aeca78 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -2212,7 +2212,6 @@ a.account__display-name { } .getting-started__wrapper, - .getting-started__trends, .search { margin-bottom: 10px; } @@ -2319,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 { @@ -2717,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) { @@ -2735,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; + } + } } } @@ -5968,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; } @@ -5976,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 acaf5b0240..8c30bc57c9 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/formatter.rb b/app/lib/formatter.rb index c9f78cd31a..d85a333b39 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -380,7 +380,7 @@ class Formatter end def hashtag_html(tag) - "##{encode(tag)}" + "##{encode(tag)}" end def mention_html(account) diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index 51d8c0970f..a52172707b 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/models/account.rb b/app/models/account.rb index 3370fbc5e6..92e60f7470 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/featured_tag.rb b/app/models/featured_tag.rb index d06ae26a89..e02ae0705a 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 ecaed44f60..2c3a7f13b9 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/tag.rb b/app/models/tag.rb index 6a02581fad..e2fe91da1b 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -31,7 +31,8 @@ class Tag < ApplicationRecord scope :reviewed, -> { where.not(reviewed_at: nil) } scope :pending_review, -> { where(reviewed_at: nil).where.not(requested_review_at: nil) } - scope :discoverable, -> { where.not(listable: false).joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).order(Arel.sql('account_tag_stats.accounts_count desc')) } + 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, diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb index e9b9b25e31..0a7e2feac4 100644 --- a/app/models/trending_tags.rb +++ b/app/models/trending_tags.rb @@ -66,6 +66,10 @@ class TrendingTags end def request_review!(tag) + return unless Setting.trends + + tag.touch(:requested_review_at) + User.staff.includes(:account).find_each { |u| AdminMailer.new_trending_tag(u.account, tag).deliver_later! if u.allows_trending_tag_emails? } end end diff --git a/app/models/user.rb b/app/models/user.rb index 67cf92307e..45a4b89890 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 diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index e220591823..c8da6e725c 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/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index bbee47cb70..c9a9a5a6e0 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/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index cf433d8a69..72f716dc51 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 958a67e8f1..c19fa2126f 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/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index d3ac3ff425..3c98da35f3 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -51,6 +51,8 @@ = feature_hint(link_to(t('admin.dashboard.feature_timeline_preview'), edit_admin_settings_path), @timeline_preview) %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 @@ -92,6 +94,10 @@ = feature_hint(t('admin.dashboard.search'), @search_enabled) %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 diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index efe6ea56bd..b0ab394d6b 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/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index 4479582531..0bda49f44a 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/config/locales/en.yml b/config/locales/en.yml index d4e4a0c9a3..6b6b996836 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -247,6 +247,7 @@ en: updated_msg: Emoji successfully updated! upload: Upload dashboard: + authorized_fetch_mode: Authorized fetch mode backlog: backlogged jobs config: Configuration feature_deletions: Account deletions @@ -271,6 +272,7 @@ en: week_interactions: interactions this week week_users_active: active this week week_users_new: users this week + whitelist_mode: Whitelist mode domain_allows: add_new: Whitelist domain created_msg: Domain has been successfully whitelisted @@ -473,8 +475,8 @@ en: title: Custom terms of service site_title: Server name spam_check_enabled: - desc_html: Mastodon can auto-silence and auto-report accounts based on measures such as detecting accounts who send repeated unsolicited messages. There may be false positives. - title: Anti-spam + desc_html: Mastodon can auto-silence and auto-report accounts that send repeated unsolicited messages. There may be false positives. + title: Anti-spam automation thumbnail: desc_html: Used for previews via OpenGraph and API. 1200x630px recommended title: Server thumbnail @@ -482,6 +484,9 @@ en: desc_html: Display public timeline on landing page title: Timeline preview title: Site settings + trends: + desc_html: Publicly display previously reviewed hashtags that are currently trending + title: Trending hashtags statuses: back_to_account: Back to account page batch: @@ -504,6 +509,7 @@ en: title: Hashtags trending_right_now: Trending right now unique_uses_today: "%{count} posting today" + updated_msg: Hashtag settings updated successfully title: Administration warning_presets: add_new: Add new @@ -527,6 +533,7 @@ en: advanced_web_interface_hint: 'If you want to make use of your entire screen width, the advanced web interface allows you to configure many different columns to see as much information at the same time as you want: Home, notifications, federated timeline, any number of lists and hashtags.' animations_and_accessibility: Animations and accessibility confirmation_dialogs: Confirmation dialogs + discovery: Discovery sensitive_content: Sensitive content application_mailer: notification_preferences: Change e-mail preferences @@ -574,6 +581,7 @@ en: status: account_status: Account status confirming: Waiting for e-mail confirmation to be completed. + functional: Your account is fully operational. pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved. trouble_logging_in: Trouble logging in? authorize_follow: diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 82e1295815..5da0cc45df 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -125,6 +125,8 @@ en: setting_show_application: Disclose application used to send toots setting_skin: Skin setting_system_font_ui: Use system's default font + setting_theme: Site theme + setting_trends: Show today's trends setting_unfollow_modal: Show confirmation dialog before unfollowing someone setting_use_blurhash: Show colorful gradients for hidden media setting_use_pending_items: Slow mode diff --git a/config/settings.yml b/config/settings.yml index 2abb87c43d..10836db3f5 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -38,6 +38,7 @@ defaults: &defaults advanced_layout: false use_blurhash: true use_pending_items: false + trends: true notification_emails: follow: false reblog: false diff --git a/spec/controllers/settings/identity_proofs_controller_spec.rb b/spec/controllers/settings/identity_proofs_controller_spec.rb index 2a0f91088a..261e980d4b 100644 --- a/spec/controllers/settings/identity_proofs_controller_spec.rb +++ b/spec/controllers/settings/identity_proofs_controller_spec.rb @@ -8,8 +8,8 @@ describe Settings::IdentityProofsController do let(:valid_token) { '1'*66 } let(:kbname) { 'kbuser' } let(:provider) { 'keybase' } - let(:findable_id) { Faker::Number.number(5) } - let(:unfindable_id) { Faker::Number.number(5) } + let(:findable_id) { Faker::Number.number(digits: 5) } + let(:unfindable_id) { Faker::Number.number(digits: 5) } let(:new_proof_params) do { provider: provider, provider_username: kbname, token: valid_token, username: user.account.username } end diff --git a/spec/fabricators/account_fabricator.rb b/spec/fabricators/account_fabricator.rb index f12464ef3e..ab900c5fa0 100644 --- a/spec/fabricators/account_fabricator.rb +++ b/spec/fabricators/account_fabricator.rb @@ -4,7 +4,7 @@ private_key = keypair.to_pem Fabricator(:account) do transient :suspended, :silenced - username { sequence(:username) { |i| "#{Faker::Internet.user_name(nil, %w(_))}#{i}" } } + username { sequence(:username) { |i| "#{Faker::Internet.user_name(separators: %w(_))}#{i}" } } last_webfingered_at { Time.now.utc } public_key { public_key } private_key { private_key } diff --git a/spec/fabricators/account_identity_proof_fabricator.rb b/spec/fabricators/account_identity_proof_fabricator.rb index 94f40dfd6b..7b932fa968 100644 --- a/spec/fabricators/account_identity_proof_fabricator.rb +++ b/spec/fabricators/account_identity_proof_fabricator.rb @@ -1,7 +1,7 @@ Fabricator(:account_identity_proof) do account provider 'keybase' - provider_username { sequence(:provider_username) { |i| "#{Faker::Lorem.characters(15)}" } } + provider_username { sequence(:provider_username) { |i| "#{Faker::Lorem.characters(number: 15)}" } } token { sequence(:token) { |i| "#{i}#{Faker::Crypto.sha1()*2}"[0..65] } } verified false live false diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 3a17d540ac..3eec464bd3 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -607,19 +607,19 @@ RSpec.describe Account, type: :model do end it 'is invalid if the username is longer then 30 characters' do - account = Fabricate.build(:account, username: Faker::Lorem.characters(31)) + account = Fabricate.build(:account, username: Faker::Lorem.characters(number: 31)) account.valid? expect(account).to model_have_error_on_field(:username) end it 'is invalid if the display name is longer than 30 characters' do - account = Fabricate.build(:account, display_name: Faker::Lorem.characters(31)) + account = Fabricate.build(:account, display_name: Faker::Lorem.characters(number: 31)) account.valid? expect(account).to model_have_error_on_field(:display_name) end it 'is invalid if the note is longer than 500 characters' do - account = Fabricate.build(:account, note: Faker::Lorem.characters(501)) + account = Fabricate.build(:account, note: Faker::Lorem.characters(number: 501)) account.valid? expect(account).to model_have_error_on_field(:note) end @@ -653,19 +653,19 @@ RSpec.describe Account, type: :model do end it 'is valid even if the username is longer then 30 characters' do - account = Fabricate.build(:account, domain: 'domain', username: Faker::Lorem.characters(31)) + account = Fabricate.build(:account, domain: 'domain', username: Faker::Lorem.characters(number: 31)) account.valid? expect(account).not_to model_have_error_on_field(:username) end it 'is valid even if the display name is longer than 30 characters' do - account = Fabricate.build(:account, domain: 'domain', display_name: Faker::Lorem.characters(31)) + account = Fabricate.build(:account, domain: 'domain', display_name: Faker::Lorem.characters(number: 31)) account.valid? expect(account).not_to model_have_error_on_field(:display_name) end it 'is valid even if the note is longer than 500 characters' do - account = Fabricate.build(:account, domain: 'domain', note: Faker::Lorem.characters(501)) + account = Fabricate.build(:account, domain: 'domain', note: Faker::Lorem.characters(number: 501)) account.valid? expect(account).not_to model_have_error_on_field(:note) end @@ -804,7 +804,7 @@ RSpec.describe Account, type: :model do context 'when is local' do # Test disabled because test environment omits autogenerating keys for performance xit 'generates keys' do - account = Account.create!(domain: nil, username: Faker::Internet.user_name(nil, ['_'])) + account = Account.create!(domain: nil, username: Faker::Internet.user_name(separators: ['_'])) expect(account.keypair.private?).to eq true end end @@ -812,12 +812,12 @@ RSpec.describe Account, type: :model do context 'when is remote' do it 'does not generate keys' do key = OpenSSL::PKey::RSA.new(1024).public_key - account = Account.create!(domain: 'remote', username: Faker::Internet.user_name(nil, ['_']), public_key: key.to_pem) + account = Account.create!(domain: 'remote', username: Faker::Internet.user_name(separators: ['_']), public_key: key.to_pem) expect(account.keypair.params).to eq key.params end it 'normalizes domain' do - account = Account.create!(domain: 'にゃん', username: Faker::Internet.user_name(nil, ['_'])) + account = Account.create!(domain: 'にゃん', username: Faker::Internet.user_name(separators: ['_'])) expect(account.domain).to eq 'xn--r9j5b5b' end end diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb index a0cd0800da..312954c9dc 100644 --- a/spec/models/report_spec.rb +++ b/spec/models/report_spec.rb @@ -125,7 +125,7 @@ describe Report do end it 'is invalid if comment is longer than 1000 characters' do - report = Fabricate.build(:report, comment: Faker::Lorem.characters(1001)) + report = Fabricate.build(:report, comment: Faker::Lorem.characters(number: 1001)) report.valid? expect(report).to model_have_error_on_field(:comment) end