diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 19285f6976..47c49cb9a4 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -121,10 +121,6 @@ Style/GlobalStdStream: # Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. Style/GuardClause: Exclude: - - 'app/controllers/admin/confirmations_controller.rb' - - 'app/controllers/auth/confirmations_controller.rb' - - 'app/controllers/auth/passwords_controller.rb' - - 'app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb' - 'app/lib/activitypub/activity/block.rb' - 'app/lib/request.rb' - 'app/lib/request_pool.rb' diff --git a/Gemfile b/Gemfile index 4dc200df24..cd0ef255b1 100644 --- a/Gemfile +++ b/Gemfile @@ -123,7 +123,7 @@ group :test do gem 'database_cleaner-active_record' # Used to mock environment variables - gem 'climate_control', '~> 0.2' + gem 'climate_control' # Generating fake data for specs gem 'faker', '~> 3.2' diff --git a/Gemfile.lock b/Gemfile.lock index a31d0a929c..57b2580722 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -185,7 +185,7 @@ GEM elasticsearch (>= 7.12.0, < 7.14.0) elasticsearch-dsl chunky_png (1.4.0) - climate_control (0.2.0) + climate_control (1.2.0) cocoon (1.2.15) color_diff (0.1) concurrent-ruby (1.2.3) @@ -746,8 +746,8 @@ GEM temple (0.10.3) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) - terrapin (0.6.0) - climate_control (>= 0.0.3, < 1.0) + terrapin (1.0.1) + climate_control test-prof (1.3.1) thor (1.3.0) tilt (2.3.0) @@ -836,7 +836,7 @@ DEPENDENCIES capybara (~> 3.39) charlock_holmes (~> 0.7.7) chewy (~> 7.3) - climate_control (~> 0.2) + climate_control cocoon (~> 1.2) color_diff (~> 0.1) concurrent-ruby diff --git a/app/controllers/admin/confirmations_controller.rb b/app/controllers/admin/confirmations_controller.rb index 7ccf5c9012..702550eecc 100644 --- a/app/controllers/admin/confirmations_controller.rb +++ b/app/controllers/admin/confirmations_controller.rb @@ -3,7 +3,7 @@ module Admin class ConfirmationsController < BaseController before_action :set_user - before_action :check_confirmation, only: [:resend] + before_action :redirect_confirmed_user, only: [:resend], if: :user_confirmed? def create authorize @user, :confirm? @@ -25,11 +25,13 @@ module Admin private - def check_confirmation - if @user.confirmed? - flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') - redirect_to admin_accounts_path - end + def redirect_confirmed_user + flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed') + redirect_to admin_accounts_path + end + + def user_confirmed? + @user.confirmed? end end end diff --git a/app/controllers/admin/export_domain_blocks_controller.rb b/app/controllers/admin/export_domain_blocks_controller.rb index ffc4478172..9caafd9684 100644 --- a/app/controllers/admin/export_domain_blocks_controller.rb +++ b/app/controllers/admin/export_domain_blocks_controller.rb @@ -49,7 +49,7 @@ module Admin next end - @warning_domains = Instance.where(domain: @domain_blocks.map(&:domain)).where('EXISTS (SELECT 1 FROM follows JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id WHERE accounts.domain = instances.domain)').pluck(:domain) + @warning_domains = instances_from_imported_blocks.pluck(:domain) rescue ActionController::ParameterMissing flash.now[:alert] = I18n.t('admin.export_domain_blocks.no_file') set_dummy_import! @@ -58,6 +58,10 @@ module Admin private + def instances_from_imported_blocks + Instance.with_domain_follows(@domain_blocks.map(&:domain)) + end + def export_filename 'domain_blocks.csv' end diff --git a/app/controllers/auth/confirmations_controller.rb b/app/controllers/auth/confirmations_controller.rb index 5d476d0440..8b8a27d260 100644 --- a/app/controllers/auth/confirmations_controller.rb +++ b/app/controllers/auth/confirmations_controller.rb @@ -8,7 +8,7 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController before_action :set_body_classes before_action :set_pack before_action :set_confirmation_user!, only: [:show, :confirm_captcha] - before_action :require_unconfirmed! + before_action :redirect_confirmed_user, if: :signed_in_confirmed_user? before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha] before_action :require_captcha_if_needed!, only: [:show] @@ -70,10 +70,12 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController use_pack 'auth' end - def require_unconfirmed! - if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? - redirect_to(current_user.approved? ? root_path : edit_user_registration_path) - end + def redirect_confirmed_user + redirect_to(current_user.approved? ? root_path : edit_user_registration_path) + end + + def signed_in_confirmed_user? + user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank? end def set_body_classes diff --git a/app/controllers/auth/passwords_controller.rb b/app/controllers/auth/passwords_controller.rb index 0c572d3b19..f0d47bf774 100644 --- a/app/controllers/auth/passwords_controller.rb +++ b/app/controllers/auth/passwords_controller.rb @@ -2,7 +2,7 @@ class Auth::PasswordsController < Devise::PasswordsController skip_before_action :check_self_destruct! - before_action :check_validity_of_reset_password_token, only: :edit + before_action :redirect_invalid_reset_token, only: :edit, unless: :reset_password_token_is_valid? before_action :set_pack before_action :set_body_classes @@ -20,11 +20,9 @@ class Auth::PasswordsController < Devise::PasswordsController private - def check_validity_of_reset_password_token - unless reset_password_token_is_valid? - flash[:error] = I18n.t('auth.invalid_reset_password_token') - redirect_to new_password_path(resource_name) - end + def redirect_invalid_reset_token + flash[:error] = I18n.t('auth.invalid_reset_password_token') + redirect_to new_password_path(resource_name) end def set_body_classes diff --git a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb index 0128509dea..aed5af6e79 100644 --- a/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb +++ b/app/controllers/settings/two_factor_authentication/webauthn_credentials_controller.rb @@ -6,8 +6,8 @@ module Settings skip_before_action :check_self_destruct! skip_before_action :require_functional! - before_action :require_otp_enabled - before_action :require_webauthn_enabled, only: [:index, :destroy] + before_action :redirect_invalid_otp, unless: -> { current_user.otp_enabled? } + before_action :redirect_invalid_webauthn, only: [:index, :destroy], unless: -> { current_user.webauthn_enabled? } def index; end def new; end @@ -89,18 +89,14 @@ module Settings use_pack 'auth' end - def require_otp_enabled - unless current_user.otp_enabled? - flash[:error] = t('webauthn_credentials.otp_required') - redirect_to settings_two_factor_authentication_methods_path - end + def redirect_invalid_otp + flash[:error] = t('webauthn_credentials.otp_required') + redirect_to settings_two_factor_authentication_methods_path end - def require_webauthn_enabled - unless current_user.webauthn_enabled? - flash[:error] = t('webauthn_credentials.not_enabled') - redirect_to settings_two_factor_authentication_methods_path - end + def redirect_invalid_webauthn + flash[:error] = t('webauthn_credentials.not_enabled') + redirect_to settings_two_factor_authentication_methods_path end end end diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx index 00fbc8d464..6ccd1497a7 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.jsx @@ -1,17 +1,24 @@ import PropTypes from 'prop-types'; +import { useCallback, useState } from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import classNames from 'classnames'; -import { withRouter } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; +import { createSelector } from '@reduxjs/toolkit'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import { useDispatch, useSelector } from 'react-redux'; + import { HotKeys } from 'react-hotkeys'; import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react'; import ReplyIcon from '@/material-icons/400-24px/reply.svg?react'; +import { replyCompose } from 'flavours/glitch/actions/compose'; +import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations'; +import { openModal } from 'flavours/glitch/actions/modal'; +import { muteStatus, unmuteStatus, revealStatus, hideStatus } from 'flavours/glitch/actions/statuses'; import AttachmentList from 'flavours/glitch/components/attachment_list'; import AvatarComposite from 'flavours/glitch/components/avatar_composite'; import { IconButton } from 'flavours/glitch/components/icon_button'; @@ -20,7 +27,7 @@ import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp import StatusContent from 'flavours/glitch/components/status_content'; import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container'; import { autoPlayGif } from 'flavours/glitch/initial_state'; -import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router'; +import { makeGetStatus } from 'flavours/glitch/selectors'; const messages = defineMessages({ more: { id: 'status.more', defaultMessage: 'More' }, @@ -30,45 +37,48 @@ const messages = defineMessages({ delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' }, muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' }, unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' }, + replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, + replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, }); -class Conversation extends ImmutablePureComponent { +const getAccounts = createSelector( + (state) => state.get('accounts'), + (_, accountIds) => accountIds, + (accounts, accountIds) => + accountIds.map(id => accounts.get(id)) +); - static propTypes = { - conversationId: PropTypes.string.isRequired, - accounts: ImmutablePropTypes.list.isRequired, - lastStatus: ImmutablePropTypes.map, - unread:PropTypes.bool.isRequired, - scrollKey: PropTypes.string, - onMoveUp: PropTypes.func, - onMoveDown: PropTypes.func, - markRead: PropTypes.func.isRequired, - delete: PropTypes.func.isRequired, - intl: PropTypes.object.isRequired, - ...WithRouterPropTypes, - }; +const getStatus = makeGetStatus(); - state = { - isExpanded: undefined, - }; +export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => { + const id = conversation.get('id'); + const unread = conversation.get('unread'); + const lastStatusId = conversation.get('last_status'); + const accountIds = conversation.get('accounts'); + const intl = useIntl(); + const dispatch = useDispatch(); + const history = useHistory(); + const lastStatus = useSelector(state => getStatus(state, { id: lastStatusId })); + const accounts = useSelector(state => getAccounts(state, accountIds)); - parseClick = (e, destination) => { - const { history, lastStatus, unread, markRead } = this.props; - if (!history) return; + // glitch-soc additions + const sharedCWState = useSelector(state => state.getIn(['state', 'content_warnings', 'shared_state'])); + const [expanded, setExpanded] = useState(undefined); + const parseClick = useCallback((e, destination) => { if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { if (destination === undefined) { if (unread) { - markRead(); + dispatch(markConversationRead(id)); } destination = `/statuses/${lastStatus.get('id')}`; } history.push(destination); e.preventDefault(); } - }; + }, [dispatch, history, unread, id, lastStatus]); - handleMouseEnter = ({ currentTarget }) => { + const handleMouseEnter = useCallback(({ currentTarget }) => { if (autoPlayGif) { return; } @@ -79,9 +89,9 @@ class Conversation extends ImmutablePureComponent { let emoji = emojis[i]; emoji.src = emoji.getAttribute('data-original'); } - }; + }, []); - handleMouseLeave = ({ currentTarget }) => { + const handleMouseLeave = useCallback(({ currentTarget }) => { if (autoPlayGif) { return; } @@ -92,145 +102,160 @@ class Conversation extends ImmutablePureComponent { let emoji = emojis[i]; emoji.src = emoji.getAttribute('data-static'); } - }; - - handleClick = () => { - if (!this.props.history) { - return; - } - - const { lastStatus, unread, markRead } = this.props; + }, []); + const handleClick = useCallback(() => { if (unread) { - markRead(); + dispatch(markConversationRead(id)); } - this.props.history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`); - }; + history.push(`/@${lastStatus.getIn(['account', 'acct'])}/${lastStatus.get('id')}`); + }, [dispatch, history, unread, id, lastStatus]); - handleMarkAsRead = () => { - this.props.markRead(); - }; + const handleMarkAsRead = useCallback(() => { + dispatch(markConversationRead(id)); + }, [dispatch, id]); - handleReply = () => { - this.props.reply(this.props.lastStatus, this.props.history); - }; + const handleReply = useCallback(() => { + dispatch((_, getState) => { + let state = getState(); - handleDelete = () => { - this.props.delete(); - }; + if (state.getIn(['compose', 'text']).trim().length !== 0) { + dispatch(openModal({ + modalType: 'CONFIRM', + modalProps: { + message: intl.formatMessage(messages.replyMessage), + confirm: intl.formatMessage(messages.replyConfirm), + onConfirm: () => dispatch(replyCompose(lastStatus, history)), + }, + })); + } else { + dispatch(replyCompose(lastStatus, history)); + } + }); + }, [dispatch, lastStatus, history, intl]); - handleHotkeyMoveUp = () => { - this.props.onMoveUp(this.props.conversationId); - }; + const handleDelete = useCallback(() => { + dispatch(deleteConversation(id)); + }, [dispatch, id]); - handleHotkeyMoveDown = () => { - this.props.onMoveDown(this.props.conversationId); - }; + const handleHotkeyMoveUp = useCallback(() => { + onMoveUp(id); + }, [id, onMoveUp]); - handleConversationMute = () => { - this.props.onMute(this.props.lastStatus); - }; + const handleHotkeyMoveDown = useCallback(() => { + onMoveDown(id); + }, [id, onMoveDown]); - handleShowMore = () => { - this.props.onToggleHidden(this.props.lastStatus); - - if (this.props.lastStatus.get('spoiler_text')) { - this.setExpansion(!this.state.isExpanded); + const handleConversationMute = useCallback(() => { + if (lastStatus.get('muted')) { + dispatch(unmuteStatus(lastStatus.get('id'))); + } else { + dispatch(muteStatus(lastStatus.get('id'))); } - }; + }, [dispatch, lastStatus]); - setExpansion = value => { - this.setState({ isExpanded: value }); - }; - - render () { - const { accounts, lastStatus, unread, scrollKey, intl } = this.props; - - if (lastStatus === null) { - return null; + const handleShowMore = useCallback(() => { + if (lastStatus.get('hidden')) { + dispatch(revealStatus(lastStatus.get('id'))); + } else { + dispatch(hideStatus(lastStatus.get('id'))); } - const isExpanded = this.props.settings.getIn(['content_warnings', 'shared_state']) ? !lastStatus.get('hidden') : this.state.isExpanded; - - const menu = [ - { text: intl.formatMessage(messages.open), action: this.handleClick }, - null, - ]; - - menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute }); - - if (unread) { - menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead }); - menu.push(null); + if (lastStatus.get('spoiler_text')) { + setExpanded(!expanded); } + }, [dispatch, lastStatus, expanded]); - menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete }); + const menu = [ + { text: intl.formatMessage(messages.open), action: handleClick }, + null, + { text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: handleConversationMute }, + ]; - const names = accounts.map(a => ).reduce((prev, cur) => [prev, ', ', cur]); + if (unread) { + menu.push({ text: intl.formatMessage(messages.markAsRead), action: handleMarkAsRead }); + menu.push(null); + } - const handlers = { - reply: this.handleReply, - open: this.handleClick, - moveUp: this.handleHotkeyMoveUp, - moveDown: this.handleHotkeyMoveDown, - toggleHidden: this.handleShowMore, - }; + menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete }); - let media = null; - if (lastStatus.get('media_attachments').size > 0) { - media = ; - } + const names = accounts.map(a => ( + + + + + + )).reduce((prev, cur) => [prev, ', ', cur]); - return ( - -
-
- -
+ const handlers = { + reply: handleReply, + open: handleClick, + moveUp: handleHotkeyMoveUp, + moveDown: handleHotkeyMoveDown, + toggleHidden: handleShowMore, + }; -
-
-
- {unread && } -
+ let media = null; + if (lastStatus.get('media_attachments').size > 0) { + media = ; + } -
- {names} }} /> -
+ return ( + +
+
+ +
+ +
+
+
+ {unread && }
- +
+ {names} }} /> +
+
-
- + -
- -
+
+ + +
+
- - ); - } +
+ + ); +}; -} - -export default withRouter(injectIntl(Conversation)); +Conversation.propTypes = { + conversation: ImmutablePropTypes.map.isRequired, + scrollKey: PropTypes.string, + onMoveUp: PropTypes.func, + onMoveDown: PropTypes.func, +}; diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.jsx b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.jsx index 8c12ea9e5f..b1a8fd09b6 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.jsx +++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.jsx @@ -1,77 +1,72 @@ import PropTypes from 'prop-types'; +import { useRef, useMemo, useCallback } from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import ImmutablePureComponent from 'react-immutable-pure-component'; +import { useSelector, useDispatch } from 'react-redux'; import { debounce } from 'lodash'; -import ScrollableList from '../../../components/scrollable_list'; -import ConversationContainer from '../containers/conversation_container'; +import { expandConversations } from 'flavours/glitch/actions/conversations'; +import ScrollableList from 'flavours/glitch/components/scrollable_list'; -export default class ConversationsList extends ImmutablePureComponent { +import { Conversation } from './conversation'; - static propTypes = { - conversations: ImmutablePropTypes.list.isRequired, - scrollKey: PropTypes.string.isRequired, - hasMore: PropTypes.bool, - isLoading: PropTypes.bool, - onLoadMore: PropTypes.func, - }; +const focusChild = (node, index, alignTop) => { + const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`); - getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id); - - handleMoveUp = id => { - const elementIndex = this.getCurrentIndex(id) - 1; - this._selectChild(elementIndex, true); - }; - - handleMoveDown = id => { - const elementIndex = this.getCurrentIndex(id) + 1; - this._selectChild(elementIndex, false); - }; - - _selectChild (index, align_top) { - const container = this.node.node; - const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`); - - if (element) { - if (align_top && container.scrollTop > element.offsetTop) { - element.scrollIntoView(true); - } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) { - element.scrollIntoView(false); - } - element.focus(); + if (element) { + if (alignTop && node.scrollTop > element.offsetTop) { + element.scrollIntoView(true); + } else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) { + element.scrollIntoView(false); } + + element.focus(); } +}; - setRef = c => { - this.node = c; - }; +export const ConversationsList = ({ scrollKey, ...other }) => { + const listRef = useRef(); + const conversations = useSelector(state => state.getIn(['conversations', 'items'])); + const isLoading = useSelector(state => state.getIn(['conversations', 'isLoading'], true)); + const hasMore = useSelector(state => state.getIn(['conversations', 'hasMore'], false)); + const dispatch = useDispatch(); + const lastStatusId = conversations.last()?.get('last_status'); - handleLoadOlder = debounce(() => { - const last = this.props.conversations.last(); + const handleMoveUp = useCallback(id => { + const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1; + focusChild(listRef.current.node, elementIndex, true); + }, [listRef, conversations]); - if (last && last.get('last_status')) { - this.props.onLoadMore(last.get('last_status')); + const handleMoveDown = useCallback(id => { + const elementIndex = conversations.findIndex(x => x.get('id') === id) + 1; + focusChild(listRef.current.node, elementIndex, false); + }, [listRef, conversations]); + + const debouncedLoadMore = useMemo(() => debounce(id => { + dispatch(expandConversations({ maxId: id })); + }, 300, { leading: true }), [dispatch]); + + const handleLoadMore = useCallback(() => { + if (lastStatusId) { + debouncedLoadMore(lastStatusId); } - }, 300, { leading: true }); + }, [debouncedLoadMore, lastStatusId]); - render () { - const { conversations, isLoading, onLoadMore, ...other } = this.props; + return ( + + {conversations.map(item => ( + + ))} + + ); +}; - return ( - - {conversations.map(item => ( - - ))} - - ); - } - -} +ConversationsList.propTypes = { + scrollKey: PropTypes.string.isRequired, +}; diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js deleted file mode 100644 index 207d3ebb65..0000000000 --- a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js +++ /dev/null @@ -1,81 +0,0 @@ -import { defineMessages, injectIntl } from 'react-intl'; - -import { connect } from 'react-redux'; - -import { replyCompose } from 'flavours/glitch/actions/compose'; -import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations'; -import { openModal } from 'flavours/glitch/actions/modal'; -import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'flavours/glitch/actions/statuses'; -import { makeGetStatus } from 'flavours/glitch/selectors'; - -import Conversation from '../components/conversation'; - -const messages = defineMessages({ - replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, - replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, -}); - -const mapStateToProps = () => { - const getStatus = makeGetStatus(); - - return (state, { conversationId }) => { - const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId); - const lastStatusId = conversation.get('last_status', null); - - return { - accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)), - unread: conversation.get('unread'), - lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }), - settings: state.get('local_settings'), - }; - }; -}; - -const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({ - - markRead () { - dispatch(markConversationRead(conversationId)); - }, - - reply (status, router) { - dispatch((_, getState) => { - let state = getState(); - - if (state.getIn(['compose', 'text']).trim().length !== 0) { - dispatch(openModal({ - modalType: 'CONFIRM', - modalProps: { - message: intl.formatMessage(messages.replyMessage), - confirm: intl.formatMessage(messages.replyConfirm), - onConfirm: () => dispatch(replyCompose(status, router)), - }, - })); - } else { - dispatch(replyCompose(status, router)); - } - }); - }, - - delete () { - dispatch(deleteConversation(conversationId)); - }, - - onMute (status) { - if (status.get('muted')) { - dispatch(unmuteStatus(status.get('id'))); - } else { - dispatch(muteStatus(status.get('id'))); - } - }, - - onToggleHidden (status) { - if (status.get('hidden')) { - dispatch(revealStatus(status.get('id'))); - } else { - dispatch(hideStatus(status.get('id'))); - } - }, - -}); - -export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation)); diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js deleted file mode 100644 index 1dcd3ec1bd..0000000000 --- a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js +++ /dev/null @@ -1,16 +0,0 @@ -import { connect } from 'react-redux'; - -import { expandConversations } from '../../../actions/conversations'; -import ConversationsList from '../components/conversations_list'; - -const mapStateToProps = state => ({ - conversations: state.getIn(['conversations', 'items']), - isLoading: state.getIn(['conversations', 'isLoading'], true), - hasMore: state.getIn(['conversations', 'hasMore'], false), -}); - -const mapDispatchToProps = dispatch => ({ - onLoadMore: maxId => dispatch(expandConversations({ maxId })), -}); - -export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList); diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.jsx b/app/javascript/flavours/glitch/features/direct_timeline/index.jsx index 9de5751ffb..25f0dd9997 100644 --- a/app/javascript/flavours/glitch/features/direct_timeline/index.jsx +++ b/app/javascript/flavours/glitch/features/direct_timeline/index.jsx @@ -1,12 +1,11 @@ import PropTypes from 'prop-types'; -import { PureComponent } from 'react'; +import { useRef, useCallback, useEffect } from 'react'; -import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import { defineMessages, useIntl, FormattedMessage } from 'react-intl'; import { Helmet } from 'react-helmet'; -import { connect } from 'react-redux'; - +import { useDispatch, useSelector } from 'react-redux'; import MailIcon from '@/material-icons/400-24px/mail.svg?react'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; @@ -17,51 +16,44 @@ import Column from 'flavours/glitch/components/column'; import ColumnHeader from 'flavours/glitch/components/column_header'; import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container'; +import { ConversationsList } from './components/conversations_list'; import ColumnSettingsContainer from './containers/column_settings_container'; -import ConversationsListContainer from './containers/conversations_list_container'; const messages = defineMessages({ title: { id: 'column.direct', defaultMessage: 'Private mentions' }, }); -const mapStateToProps = state => ({ - hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, - conversationsMode: state.getIn(['settings', 'direct', 'conversations']), -}); +const DirectTimeline = ({ columnId, multiColumn }) => { + const columnRef = useRef(); + const intl = useIntl(); + const dispatch = useDispatch(); + const pinned = !!columnId; -class DirectTimeline extends PureComponent { - - static propTypes = { - dispatch: PropTypes.func.isRequired, - columnId: PropTypes.string, - intl: PropTypes.object.isRequired, - hasUnread: PropTypes.bool, - multiColumn: PropTypes.bool, - conversationsMode: PropTypes.bool, - }; - - handlePin = () => { - const { columnId, dispatch } = this.props; + // glitch-soc additions + const hasUnread = useSelector(state => state.getIn(['timelines', 'direct', 'unread']) > 0); + const conversationsMode = useSelector(state => state.getIn(['settings', 'direct', 'conversations'])); + const handlePin = useCallback(() => { if (columnId) { dispatch(removeColumn(columnId)); } else { dispatch(addColumn('DIRECT', {})); } - }; + }, [dispatch, columnId]); - handleMove = (dir) => { - const { columnId, dispatch } = this.props; + const handleMove = useCallback((dir) => { dispatch(moveColumn(columnId, dir)); - }; + }, [dispatch, columnId]); - handleHeaderClick = () => { - this.column.scrollTop(); - }; + const handleHeaderClick = useCallback(() => { + columnRef.current.scrollTop(); + }, [columnRef]); - componentDidMount () { - const { dispatch, conversationsMode } = this.props; + const handleLoadMoreTimeline = useCallback(maxId => { + dispatch(expandDirectTimeline({ maxId })); + }, [dispatch]); + useEffect(() => { dispatch(mountConversations()); if (conversationsMode) { @@ -70,99 +62,67 @@ class DirectTimeline extends PureComponent { dispatch(expandDirectTimeline()); } - this.disconnect = dispatch(connectDirectStream()); - } + const disconnect = dispatch(connectDirectStream()); - componentDidUpdate(prevProps) { - const { dispatch, conversationsMode } = this.props; + return () => { + dispatch(unmountConversations()); + disconnect(); + }; + }, [dispatch, conversationsMode]); - if (prevProps.conversationsMode && !conversationsMode) { - dispatch(expandDirectTimeline()); - } else if (!prevProps.conversationsMode && conversationsMode) { - dispatch(expandConversations()); - } - } + return ( + + + + - componentWillUnmount () { - this.props.dispatch(unmountConversations()); - - if (this.disconnect) { - this.disconnect(); - this.disconnect = null; - } - } - - setRef = c => { - this.column = c; - }; - - handleLoadMoreTimeline = maxId => { - this.props.dispatch(expandDirectTimeline({ maxId })); - }; - - handleLoadMoreConversations = maxId => { - this.props.dispatch(expandConversations({ maxId })); - }; - - render () { - const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props; - const pinned = !!columnId; - - let contents; - if (conversationsMode) { - contents = ( - } bindToDocument={!multiColumn} - onLoadMore={this.handleLoadMore} prepend={
} alwaysPrepend - emptyMessage={} /> - ); - } else { - contents = ( + ) : (
} + onLoadMore={handleLoadMoreTimeline} + prepend={ +
+ +
+ } alwaysPrepend emptyMessage={} /> - ); - } + )} - return ( - - - - + + {intl.formatMessage(messages.title)} + + + + ); +}; - {contents} +DirectTimeline.propTypes = { + columnId: PropTypes.string, + multiColumn: PropTypes.bool, +}; - - {intl.formatMessage(messages.title)} - - - - ); - } - -} - -export default connect(mapStateToProps)(injectIntl(DirectTimeline)); +export default DirectTimeline; diff --git a/app/models/instance.rb b/app/models/instance.rb index 2dec75d6fe..0fd31c8097 100644 --- a/app/models/instance.rb +++ b/app/models/instance.rb @@ -25,11 +25,25 @@ class Instance < ApplicationRecord scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) } scope :domain_starts_with, ->(value) { where(arel_table[:domain].matches("#{sanitize_sql_like(value)}%", false, true)) } scope :by_domain_and_subdomains, ->(domain) { where("reverse('.' || domain) LIKE reverse(?)", "%.#{domain}") } + scope :with_domain_follows, ->(domains) { where(domain: domains).where(domain_account_follows) } def self.refresh Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false) end + def self.domain_account_follows + Arel.sql( + <<~SQL.squish + EXISTS ( + SELECT 1 + FROM follows + JOIN accounts ON follows.account_id = accounts.id OR follows.target_account_id = accounts.id + WHERE accounts.domain = instances.domain + ) + SQL + ) + end + def readonly? true end diff --git a/lib/mastodon/cli/maintenance.rb b/lib/mastodon/cli/maintenance.rb index c644729b5e..a64206065d 100644 --- a/lib/mastodon/cli/maintenance.rb +++ b/lib/mastodon/cli/maintenance.rb @@ -244,10 +244,10 @@ module Mastodon::CLI end say 'Reindexing textual indexes on accounts…' - database_connection.execute('REINDEX INDEX search_index;') - database_connection.execute('REINDEX INDEX index_accounts_on_uri;') - database_connection.execute('REINDEX INDEX index_accounts_on_url;') - database_connection.execute('REINDEX INDEX index_accounts_on_domain_and_id;') if migrator_version >= 2023_05_24_190515 + rebuild_index(:search_index) + rebuild_index(:index_accounts_on_uri) + rebuild_index(:index_accounts_on_url) + rebuild_index(:index_accounts_on_domain_and_id) if migrator_version >= 2023_05_24_190515 end def deduplicate_users! @@ -274,7 +274,7 @@ module Mastodon::CLI database_connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true, where: 'reset_password_token IS NOT NULL', opclass: :text_pattern_ops end - database_connection.execute('REINDEX INDEX index_users_on_unconfirmed_email;') if migrator_version >= 2023_07_02_151753 + rebuild_index(:index_users_on_unconfirmed_email) if migrator_version >= 2023_07_02_151753 end def deduplicate_users_process_email @@ -735,5 +735,9 @@ module Mastodon::CLI def db_table_exists?(table) database_connection.table_exists?(table) end + + def rebuild_index(name) + database_connection.execute("REINDEX INDEX #{name}") + end end end diff --git a/spec/models/instance_spec.rb b/spec/models/instance_spec.rb new file mode 100644 index 0000000000..3e811d3325 --- /dev/null +++ b/spec/models/instance_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Instance do + describe 'Scopes' do + before { described_class.refresh } + + describe '#searchable' do + let(:expected_domain) { 'host.example' } + let(:blocked_domain) { 'other.example' } + + before do + Fabricate :account, domain: expected_domain + Fabricate :account, domain: blocked_domain + Fabricate :domain_block, domain: blocked_domain + end + + it 'returns records not domain blocked' do + results = described_class.searchable.pluck(:domain) + + expect(results) + .to include(expected_domain) + .and not_include(blocked_domain) + end + end + + describe '#matches_domain' do + let(:host_domain) { 'host.example.com' } + let(:host_under_domain) { 'host_under.example.com' } + let(:other_domain) { 'other.example' } + + before do + Fabricate :account, domain: host_domain + Fabricate :account, domain: host_under_domain + Fabricate :account, domain: other_domain + end + + it 'returns matching records' do + expect(described_class.matches_domain('host.exa').pluck(:domain)) + .to include(host_domain) + .and not_include(other_domain) + + expect(described_class.matches_domain('ple.com').pluck(:domain)) + .to include(host_domain) + .and not_include(other_domain) + + expect(described_class.matches_domain('example').pluck(:domain)) + .to include(host_domain) + .and include(other_domain) + + expect(described_class.matches_domain('host_').pluck(:domain)) # Preserve SQL wildcards + .to include(host_domain) + .and include(host_under_domain) + .and not_include(other_domain) + end + end + + describe '#by_domain_and_subdomains' do + let(:exact_match_domain) { 'example.com' } + let(:subdomain_domain) { 'foo.example.com' } + let(:partial_domain) { 'grexample.com' } + + before do + Fabricate(:account, domain: exact_match_domain) + Fabricate(:account, domain: subdomain_domain) + Fabricate(:account, domain: partial_domain) + end + + it 'returns matching instances' do + results = described_class.by_domain_and_subdomains('example.com').pluck(:domain) + + expect(results) + .to include(exact_match_domain) + .and include(subdomain_domain) + .and not_include(partial_domain) + end + end + + describe '#with_domain_follows' do + let(:example_domain) { 'example.host' } + let(:other_domain) { 'other.host' } + let(:none_domain) { 'none.host' } + + before do + example_account = Fabricate(:account, domain: example_domain) + other_account = Fabricate(:account, domain: other_domain) + Fabricate(:account, domain: none_domain) + + Fabricate :follow, account: example_account + Fabricate :follow, target_account: other_account + end + + it 'returns instances with domain accounts that have follows' do + results = described_class.with_domain_follows(['example.host', 'other.host', 'none.host']).pluck(:domain) + + expect(results) + .to include(example_domain) + .and include(other_domain) + .and not_include(none_domain) + end + end + end +end