mirror of
https://git.bsd.gay/fef/nyastodon.git
synced 2024-12-26 06:43:42 +01:00
Merge branch 'glitch-soc' into develop
This commit is contained in:
commit
bf4d613731
16 changed files with 452 additions and 449 deletions
|
@ -121,10 +121,6 @@ Style/GlobalStdStream:
|
||||||
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
|
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
|
||||||
Style/GuardClause:
|
Style/GuardClause:
|
||||||
Exclude:
|
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/activitypub/activity/block.rb'
|
||||||
- 'app/lib/request.rb'
|
- 'app/lib/request.rb'
|
||||||
- 'app/lib/request_pool.rb'
|
- 'app/lib/request_pool.rb'
|
||||||
|
|
2
Gemfile
2
Gemfile
|
@ -123,7 +123,7 @@ group :test do
|
||||||
gem 'database_cleaner-active_record'
|
gem 'database_cleaner-active_record'
|
||||||
|
|
||||||
# Used to mock environment variables
|
# Used to mock environment variables
|
||||||
gem 'climate_control', '~> 0.2'
|
gem 'climate_control'
|
||||||
|
|
||||||
# Generating fake data for specs
|
# Generating fake data for specs
|
||||||
gem 'faker', '~> 3.2'
|
gem 'faker', '~> 3.2'
|
||||||
|
|
|
@ -185,7 +185,7 @@ GEM
|
||||||
elasticsearch (>= 7.12.0, < 7.14.0)
|
elasticsearch (>= 7.12.0, < 7.14.0)
|
||||||
elasticsearch-dsl
|
elasticsearch-dsl
|
||||||
chunky_png (1.4.0)
|
chunky_png (1.4.0)
|
||||||
climate_control (0.2.0)
|
climate_control (1.2.0)
|
||||||
cocoon (1.2.15)
|
cocoon (1.2.15)
|
||||||
color_diff (0.1)
|
color_diff (0.1)
|
||||||
concurrent-ruby (1.2.3)
|
concurrent-ruby (1.2.3)
|
||||||
|
@ -746,8 +746,8 @@ GEM
|
||||||
temple (0.10.3)
|
temple (0.10.3)
|
||||||
terminal-table (3.0.2)
|
terminal-table (3.0.2)
|
||||||
unicode-display_width (>= 1.1.1, < 3)
|
unicode-display_width (>= 1.1.1, < 3)
|
||||||
terrapin (0.6.0)
|
terrapin (1.0.1)
|
||||||
climate_control (>= 0.0.3, < 1.0)
|
climate_control
|
||||||
test-prof (1.3.1)
|
test-prof (1.3.1)
|
||||||
thor (1.3.0)
|
thor (1.3.0)
|
||||||
tilt (2.3.0)
|
tilt (2.3.0)
|
||||||
|
@ -836,7 +836,7 @@ DEPENDENCIES
|
||||||
capybara (~> 3.39)
|
capybara (~> 3.39)
|
||||||
charlock_holmes (~> 0.7.7)
|
charlock_holmes (~> 0.7.7)
|
||||||
chewy (~> 7.3)
|
chewy (~> 7.3)
|
||||||
climate_control (~> 0.2)
|
climate_control
|
||||||
cocoon (~> 1.2)
|
cocoon (~> 1.2)
|
||||||
color_diff (~> 0.1)
|
color_diff (~> 0.1)
|
||||||
concurrent-ruby
|
concurrent-ruby
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
module Admin
|
module Admin
|
||||||
class ConfirmationsController < BaseController
|
class ConfirmationsController < BaseController
|
||||||
before_action :set_user
|
before_action :set_user
|
||||||
before_action :check_confirmation, only: [:resend]
|
before_action :redirect_confirmed_user, only: [:resend], if: :user_confirmed?
|
||||||
|
|
||||||
def create
|
def create
|
||||||
authorize @user, :confirm?
|
authorize @user, :confirm?
|
||||||
|
@ -25,11 +25,13 @@ module Admin
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def check_confirmation
|
def redirect_confirmed_user
|
||||||
if @user.confirmed?
|
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
|
||||||
flash[:error] = I18n.t('admin.accounts.resend_confirmation.already_confirmed')
|
redirect_to admin_accounts_path
|
||||||
redirect_to admin_accounts_path
|
end
|
||||||
end
|
|
||||||
|
def user_confirmed?
|
||||||
|
@user.confirmed?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -49,7 +49,7 @@ module Admin
|
||||||
next
|
next
|
||||||
end
|
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
|
rescue ActionController::ParameterMissing
|
||||||
flash.now[:alert] = I18n.t('admin.export_domain_blocks.no_file')
|
flash.now[:alert] = I18n.t('admin.export_domain_blocks.no_file')
|
||||||
set_dummy_import!
|
set_dummy_import!
|
||||||
|
@ -58,6 +58,10 @@ module Admin
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def instances_from_imported_blocks
|
||||||
|
Instance.with_domain_follows(@domain_blocks.map(&:domain))
|
||||||
|
end
|
||||||
|
|
||||||
def export_filename
|
def export_filename
|
||||||
'domain_blocks.csv'
|
'domain_blocks.csv'
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,7 +8,7 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
before_action :set_pack
|
before_action :set_pack
|
||||||
before_action :set_confirmation_user!, only: [:show, :confirm_captcha]
|
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 :extend_csp_for_captcha!, only: [:show, :confirm_captcha]
|
||||||
before_action :require_captcha_if_needed!, only: [:show]
|
before_action :require_captcha_if_needed!, only: [:show]
|
||||||
|
@ -70,10 +70,12 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
|
||||||
use_pack 'auth'
|
use_pack 'auth'
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_unconfirmed!
|
def redirect_confirmed_user
|
||||||
if user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
|
redirect_to(current_user.approved? ? root_path : edit_user_registration_path)
|
||||||
redirect_to(current_user.approved? ? root_path : edit_user_registration_path)
|
end
|
||||||
end
|
|
||||||
|
def signed_in_confirmed_user?
|
||||||
|
user_signed_in? && current_user.confirmed? && current_user.unconfirmed_email.blank?
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_body_classes
|
def set_body_classes
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
class Auth::PasswordsController < Devise::PasswordsController
|
class Auth::PasswordsController < Devise::PasswordsController
|
||||||
skip_before_action :check_self_destruct!
|
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_pack
|
||||||
before_action :set_body_classes
|
before_action :set_body_classes
|
||||||
|
|
||||||
|
@ -20,11 +20,9 @@ class Auth::PasswordsController < Devise::PasswordsController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def check_validity_of_reset_password_token
|
def redirect_invalid_reset_token
|
||||||
unless reset_password_token_is_valid?
|
flash[:error] = I18n.t('auth.invalid_reset_password_token')
|
||||||
flash[:error] = I18n.t('auth.invalid_reset_password_token')
|
redirect_to new_password_path(resource_name)
|
||||||
redirect_to new_password_path(resource_name)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_body_classes
|
def set_body_classes
|
||||||
|
|
|
@ -6,8 +6,8 @@ module Settings
|
||||||
skip_before_action :check_self_destruct!
|
skip_before_action :check_self_destruct!
|
||||||
skip_before_action :require_functional!
|
skip_before_action :require_functional!
|
||||||
|
|
||||||
before_action :require_otp_enabled
|
before_action :redirect_invalid_otp, unless: -> { current_user.otp_enabled? }
|
||||||
before_action :require_webauthn_enabled, only: [:index, :destroy]
|
before_action :redirect_invalid_webauthn, only: [:index, :destroy], unless: -> { current_user.webauthn_enabled? }
|
||||||
|
|
||||||
def index; end
|
def index; end
|
||||||
def new; end
|
def new; end
|
||||||
|
@ -89,18 +89,14 @@ module Settings
|
||||||
use_pack 'auth'
|
use_pack 'auth'
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_otp_enabled
|
def redirect_invalid_otp
|
||||||
unless current_user.otp_enabled?
|
flash[:error] = t('webauthn_credentials.otp_required')
|
||||||
flash[:error] = t('webauthn_credentials.otp_required')
|
redirect_to settings_two_factor_authentication_methods_path
|
||||||
redirect_to settings_two_factor_authentication_methods_path
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def require_webauthn_enabled
|
def redirect_invalid_webauthn
|
||||||
unless current_user.webauthn_enabled?
|
flash[:error] = t('webauthn_credentials.not_enabled')
|
||||||
flash[:error] = t('webauthn_credentials.not_enabled')
|
redirect_to settings_two_factor_authentication_methods_path
|
||||||
redirect_to settings_two_factor_authentication_methods_path
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,17 +1,24 @@
|
||||||
import PropTypes from 'prop-types';
|
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 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 ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
|
||||||
|
|
||||||
import { HotKeys } from 'react-hotkeys';
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import ReplyIcon from '@/material-icons/400-24px/reply.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 AttachmentList from 'flavours/glitch/components/attachment_list';
|
||||||
import AvatarComposite from 'flavours/glitch/components/avatar_composite';
|
import AvatarComposite from 'flavours/glitch/components/avatar_composite';
|
||||||
import { IconButton } from 'flavours/glitch/components/icon_button';
|
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 StatusContent from 'flavours/glitch/components/status_content';
|
||||||
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
|
||||||
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
import { autoPlayGif } from 'flavours/glitch/initial_state';
|
||||||
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
|
import { makeGetStatus } from 'flavours/glitch/selectors';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
more: { id: 'status.more', defaultMessage: 'More' },
|
more: { id: 'status.more', defaultMessage: 'More' },
|
||||||
|
@ -30,45 +37,48 @@ const messages = defineMessages({
|
||||||
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
|
delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
|
||||||
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
|
||||||
unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute 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 = {
|
const getStatus = makeGetStatus();
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown }) => {
|
||||||
isExpanded: undefined,
|
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) => {
|
// glitch-soc additions
|
||||||
const { history, lastStatus, unread, markRead } = this.props;
|
const sharedCWState = useSelector(state => state.getIn(['state', 'content_warnings', 'shared_state']));
|
||||||
if (!history) return;
|
const [expanded, setExpanded] = useState(undefined);
|
||||||
|
|
||||||
|
const parseClick = useCallback((e, destination) => {
|
||||||
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
|
||||||
if (destination === undefined) {
|
if (destination === undefined) {
|
||||||
if (unread) {
|
if (unread) {
|
||||||
markRead();
|
dispatch(markConversationRead(id));
|
||||||
}
|
}
|
||||||
destination = `/statuses/${lastStatus.get('id')}`;
|
destination = `/statuses/${lastStatus.get('id')}`;
|
||||||
}
|
}
|
||||||
history.push(destination);
|
history.push(destination);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
};
|
}, [dispatch, history, unread, id, lastStatus]);
|
||||||
|
|
||||||
handleMouseEnter = ({ currentTarget }) => {
|
const handleMouseEnter = useCallback(({ currentTarget }) => {
|
||||||
if (autoPlayGif) {
|
if (autoPlayGif) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -79,9 +89,9 @@ class Conversation extends ImmutablePureComponent {
|
||||||
let emoji = emojis[i];
|
let emoji = emojis[i];
|
||||||
emoji.src = emoji.getAttribute('data-original');
|
emoji.src = emoji.getAttribute('data-original');
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
handleMouseLeave = ({ currentTarget }) => {
|
const handleMouseLeave = useCallback(({ currentTarget }) => {
|
||||||
if (autoPlayGif) {
|
if (autoPlayGif) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -92,145 +102,160 @@ class Conversation extends ImmutablePureComponent {
|
||||||
let emoji = emojis[i];
|
let emoji = emojis[i];
|
||||||
emoji.src = emoji.getAttribute('data-static');
|
emoji.src = emoji.getAttribute('data-static');
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
handleClick = () => {
|
|
||||||
if (!this.props.history) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lastStatus, unread, markRead } = this.props;
|
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
if (unread) {
|
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 = () => {
|
const handleMarkAsRead = useCallback(() => {
|
||||||
this.props.markRead();
|
dispatch(markConversationRead(id));
|
||||||
};
|
}, [dispatch, id]);
|
||||||
|
|
||||||
handleReply = () => {
|
const handleReply = useCallback(() => {
|
||||||
this.props.reply(this.props.lastStatus, this.props.history);
|
dispatch((_, getState) => {
|
||||||
};
|
let state = getState();
|
||||||
|
|
||||||
handleDelete = () => {
|
if (state.getIn(['compose', 'text']).trim().length !== 0) {
|
||||||
this.props.delete();
|
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 = () => {
|
const handleDelete = useCallback(() => {
|
||||||
this.props.onMoveUp(this.props.conversationId);
|
dispatch(deleteConversation(id));
|
||||||
};
|
}, [dispatch, id]);
|
||||||
|
|
||||||
handleHotkeyMoveDown = () => {
|
const handleHotkeyMoveUp = useCallback(() => {
|
||||||
this.props.onMoveDown(this.props.conversationId);
|
onMoveUp(id);
|
||||||
};
|
}, [id, onMoveUp]);
|
||||||
|
|
||||||
handleConversationMute = () => {
|
const handleHotkeyMoveDown = useCallback(() => {
|
||||||
this.props.onMute(this.props.lastStatus);
|
onMoveDown(id);
|
||||||
};
|
}, [id, onMoveDown]);
|
||||||
|
|
||||||
handleShowMore = () => {
|
const handleConversationMute = useCallback(() => {
|
||||||
this.props.onToggleHidden(this.props.lastStatus);
|
if (lastStatus.get('muted')) {
|
||||||
|
dispatch(unmuteStatus(lastStatus.get('id')));
|
||||||
if (this.props.lastStatus.get('spoiler_text')) {
|
} else {
|
||||||
this.setExpansion(!this.state.isExpanded);
|
dispatch(muteStatus(lastStatus.get('id')));
|
||||||
}
|
}
|
||||||
};
|
}, [dispatch, lastStatus]);
|
||||||
|
|
||||||
setExpansion = value => {
|
const handleShowMore = useCallback(() => {
|
||||||
this.setState({ isExpanded: value });
|
if (lastStatus.get('hidden')) {
|
||||||
};
|
dispatch(revealStatus(lastStatus.get('id')));
|
||||||
|
} else {
|
||||||
render () {
|
dispatch(hideStatus(lastStatus.get('id')));
|
||||||
const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
|
|
||||||
|
|
||||||
if (lastStatus === null) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExpanded = this.props.settings.getIn(['content_warnings', 'shared_state']) ? !lastStatus.get('hidden') : this.state.isExpanded;
|
if (lastStatus.get('spoiler_text')) {
|
||||||
|
setExpanded(!expanded);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
}, [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 => <Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
|
if (unread) {
|
||||||
|
menu.push({ text: intl.formatMessage(messages.markAsRead), action: handleMarkAsRead });
|
||||||
|
menu.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
const handlers = {
|
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
|
||||||
reply: this.handleReply,
|
|
||||||
open: this.handleClick,
|
|
||||||
moveUp: this.handleHotkeyMoveUp,
|
|
||||||
moveDown: this.handleHotkeyMoveDown,
|
|
||||||
toggleHidden: this.handleShowMore,
|
|
||||||
};
|
|
||||||
|
|
||||||
let media = null;
|
const names = accounts.map(a => (
|
||||||
if (lastStatus.get('media_attachments').size > 0) {
|
<Permalink to={`/@${a.get('acct')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}>
|
||||||
media = <AttachmentList compact media={lastStatus.get('media_attachments')} />;
|
<bdi>
|
||||||
}
|
<strong
|
||||||
|
className='display-name__html'
|
||||||
|
dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }}
|
||||||
|
/>
|
||||||
|
</bdi>
|
||||||
|
</Permalink>
|
||||||
|
)).reduce((prev, cur) => [prev, ', ', cur]);
|
||||||
|
|
||||||
return (
|
const handlers = {
|
||||||
<HotKeys handlers={handlers}>
|
reply: handleReply,
|
||||||
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
|
open: handleClick,
|
||||||
<div className='conversation__avatar' onClick={this.handleClick} role='presentation'>
|
moveUp: handleHotkeyMoveUp,
|
||||||
<AvatarComposite accounts={accounts} size={48} />
|
moveDown: handleHotkeyMoveDown,
|
||||||
</div>
|
toggleHidden: handleShowMore,
|
||||||
|
};
|
||||||
|
|
||||||
<div className='conversation__content'>
|
let media = null;
|
||||||
<div className='conversation__content__info'>
|
if (lastStatus.get('media_attachments').size > 0) {
|
||||||
<div className='conversation__content__relative-time'>
|
media = <AttachmentList compact media={lastStatus.get('media_attachments')} />;
|
||||||
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='conversation__content__names' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
return (
|
||||||
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
<HotKeys handlers={handlers}>
|
||||||
</div>
|
<div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex={0}>
|
||||||
|
<div className='conversation__avatar' onClick={handleClick} role='presentation'>
|
||||||
|
<AvatarComposite accounts={accounts} size={48} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='conversation__content'>
|
||||||
|
<div className='conversation__content__info'>
|
||||||
|
<div className='conversation__content__relative-time'>
|
||||||
|
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusContent
|
<div className='conversation__content__names' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||||
status={lastStatus}
|
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
||||||
parseClick={this.parseClick}
|
</div>
|
||||||
expanded={isExpanded}
|
</div>
|
||||||
onExpandedToggle={this.handleShowMore}
|
|
||||||
collapsible
|
|
||||||
media={media}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='status__action-bar'>
|
<StatusContent
|
||||||
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={this.handleReply} />
|
status={lastStatus}
|
||||||
|
parseClick={parseClick}
|
||||||
|
expanded={sharedCWState ? lastStatus.get('hidden') : expanded}
|
||||||
|
onExpandedToggle={handleShowMore}
|
||||||
|
collapsible
|
||||||
|
media={media}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className='status__action-bar-dropdown'>
|
<div className='status__action-bar'>
|
||||||
<DropdownMenuContainer
|
<IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' iconComponent={ReplyIcon} onClick={handleReply} />
|
||||||
scrollKey={scrollKey}
|
|
||||||
status={lastStatus}
|
<div className='status__action-bar-dropdown'>
|
||||||
items={menu}
|
<DropdownMenuContainer
|
||||||
icon='ellipsis-h'
|
scrollKey={scrollKey}
|
||||||
iconComponent={MoreHorizIcon}
|
status={lastStatus}
|
||||||
size={18}
|
items={menu}
|
||||||
direction='right'
|
icon='ellipsis-h'
|
||||||
title={intl.formatMessage(messages.more)}
|
iconComponent={MoreHorizIcon}
|
||||||
/>
|
size={18}
|
||||||
</div>
|
direction='right'
|
||||||
|
title={intl.formatMessage(messages.more)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HotKeys>
|
</div>
|
||||||
);
|
</HotKeys>
|
||||||
}
|
);
|
||||||
|
};
|
||||||
|
|
||||||
}
|
Conversation.propTypes = {
|
||||||
|
conversation: ImmutablePropTypes.map.isRequired,
|
||||||
export default withRouter(injectIntl(Conversation));
|
scrollKey: PropTypes.string,
|
||||||
|
onMoveUp: PropTypes.func,
|
||||||
|
onMoveDown: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
|
@ -1,77 +1,72 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import { useRef, useMemo, useCallback } from 'react';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
|
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
import ScrollableList from '../../../components/scrollable_list';
|
import { expandConversations } from 'flavours/glitch/actions/conversations';
|
||||||
import ConversationContainer from '../containers/conversation_container';
|
import ScrollableList from 'flavours/glitch/components/scrollable_list';
|
||||||
|
|
||||||
export default class ConversationsList extends ImmutablePureComponent {
|
import { Conversation } from './conversation';
|
||||||
|
|
||||||
static propTypes = {
|
const focusChild = (node, index, alignTop) => {
|
||||||
conversations: ImmutablePropTypes.list.isRequired,
|
const element = node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||||
scrollKey: PropTypes.string.isRequired,
|
|
||||||
hasMore: PropTypes.bool,
|
|
||||||
isLoading: PropTypes.bool,
|
|
||||||
onLoadMore: PropTypes.func,
|
|
||||||
};
|
|
||||||
|
|
||||||
getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id);
|
if (element) {
|
||||||
|
if (alignTop && node.scrollTop > element.offsetTop) {
|
||||||
handleMoveUp = id => {
|
element.scrollIntoView(true);
|
||||||
const elementIndex = this.getCurrentIndex(id) - 1;
|
} else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||||
this._selectChild(elementIndex, true);
|
element.scrollIntoView(false);
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
element.focus();
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
setRef = c => {
|
export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||||
this.node = c;
|
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 handleMoveUp = useCallback(id => {
|
||||||
const last = this.props.conversations.last();
|
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
|
||||||
|
focusChild(listRef.current.node, elementIndex, true);
|
||||||
|
}, [listRef, conversations]);
|
||||||
|
|
||||||
if (last && last.get('last_status')) {
|
const handleMoveDown = useCallback(id => {
|
||||||
this.props.onLoadMore(last.get('last_status'));
|
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 () {
|
return (
|
||||||
const { conversations, isLoading, onLoadMore, ...other } = this.props;
|
<ScrollableList {...other} scrollKey={scrollKey} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} hasMore={hasMore} onLoadMore={handleLoadMore} ref={listRef}>
|
||||||
|
{conversations.map(item => (
|
||||||
|
<Conversation
|
||||||
|
key={item.get('id')}
|
||||||
|
conversation={item}
|
||||||
|
onMoveUp={handleMoveUp}
|
||||||
|
onMoveDown={handleMoveDown}
|
||||||
|
scrollKey={scrollKey}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
ConversationsList.propTypes = {
|
||||||
<ScrollableList {...other} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
|
scrollKey: PropTypes.string.isRequired,
|
||||||
{conversations.map(item => (
|
};
|
||||||
<ConversationContainer
|
|
||||||
key={item.get('id')}
|
|
||||||
conversationId={item.get('id')}
|
|
||||||
onMoveUp={this.handleMoveUp}
|
|
||||||
onMoveDown={this.handleMoveDown}
|
|
||||||
scrollKey={this.props.scrollKey}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ScrollableList>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
@ -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));
|
|
|
@ -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);
|
|
|
@ -1,12 +1,11 @@
|
||||||
import PropTypes from 'prop-types';
|
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 { 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 MailIcon from '@/material-icons/400-24px/mail.svg?react';
|
||||||
import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
|
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 ColumnHeader from 'flavours/glitch/components/column_header';
|
||||||
import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
|
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 ColumnSettingsContainer from './containers/column_settings_container';
|
||||||
import ConversationsListContainer from './containers/conversations_list_container';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.direct', defaultMessage: 'Private mentions' },
|
title: { id: 'column.direct', defaultMessage: 'Private mentions' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const DirectTimeline = ({ columnId, multiColumn }) => {
|
||||||
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
|
const columnRef = useRef();
|
||||||
conversationsMode: state.getIn(['settings', 'direct', 'conversations']),
|
const intl = useIntl();
|
||||||
});
|
const dispatch = useDispatch();
|
||||||
|
const pinned = !!columnId;
|
||||||
|
|
||||||
class DirectTimeline extends PureComponent {
|
// glitch-soc additions
|
||||||
|
const hasUnread = useSelector(state => state.getIn(['timelines', 'direct', 'unread']) > 0);
|
||||||
static propTypes = {
|
const conversationsMode = useSelector(state => state.getIn(['settings', 'direct', 'conversations']));
|
||||||
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;
|
|
||||||
|
|
||||||
|
const handlePin = useCallback(() => {
|
||||||
if (columnId) {
|
if (columnId) {
|
||||||
dispatch(removeColumn(columnId));
|
dispatch(removeColumn(columnId));
|
||||||
} else {
|
} else {
|
||||||
dispatch(addColumn('DIRECT', {}));
|
dispatch(addColumn('DIRECT', {}));
|
||||||
}
|
}
|
||||||
};
|
}, [dispatch, columnId]);
|
||||||
|
|
||||||
handleMove = (dir) => {
|
const handleMove = useCallback((dir) => {
|
||||||
const { columnId, dispatch } = this.props;
|
|
||||||
dispatch(moveColumn(columnId, dir));
|
dispatch(moveColumn(columnId, dir));
|
||||||
};
|
}, [dispatch, columnId]);
|
||||||
|
|
||||||
handleHeaderClick = () => {
|
const handleHeaderClick = useCallback(() => {
|
||||||
this.column.scrollTop();
|
columnRef.current.scrollTop();
|
||||||
};
|
}, [columnRef]);
|
||||||
|
|
||||||
componentDidMount () {
|
const handleLoadMoreTimeline = useCallback(maxId => {
|
||||||
const { dispatch, conversationsMode } = this.props;
|
dispatch(expandDirectTimeline({ maxId }));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
dispatch(mountConversations());
|
dispatch(mountConversations());
|
||||||
|
|
||||||
if (conversationsMode) {
|
if (conversationsMode) {
|
||||||
|
@ -70,99 +62,67 @@ class DirectTimeline extends PureComponent {
|
||||||
dispatch(expandDirectTimeline());
|
dispatch(expandDirectTimeline());
|
||||||
}
|
}
|
||||||
|
|
||||||
this.disconnect = dispatch(connectDirectStream());
|
const disconnect = dispatch(connectDirectStream());
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
return () => {
|
||||||
const { dispatch, conversationsMode } = this.props;
|
dispatch(unmountConversations());
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
}, [dispatch, conversationsMode]);
|
||||||
|
|
||||||
if (prevProps.conversationsMode && !conversationsMode) {
|
return (
|
||||||
dispatch(expandDirectTimeline());
|
<Column bindToDocument={!multiColumn} ref={columnRef} label={intl.formatMessage(messages.title)}>
|
||||||
} else if (!prevProps.conversationsMode && conversationsMode) {
|
<ColumnHeader
|
||||||
dispatch(expandConversations());
|
icon='envelope'
|
||||||
}
|
iconComponent={MailIcon}
|
||||||
}
|
active={hasUnread}
|
||||||
|
title={intl.formatMessage(messages.title)}
|
||||||
|
onPin={handlePin}
|
||||||
|
onMove={handleMove}
|
||||||
|
onClick={handleHeaderClick}
|
||||||
|
pinned={pinned}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
>
|
||||||
|
<ColumnSettingsContainer />
|
||||||
|
</ColumnHeader>
|
||||||
|
|
||||||
componentWillUnmount () {
|
{conversationsMode ? (
|
||||||
this.props.dispatch(unmountConversations());
|
<ConversationsList
|
||||||
|
|
||||||
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 = (
|
|
||||||
<ConversationsListContainer
|
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`direct_timeline-${columnId}`}
|
scrollKey={`direct_timeline-${columnId}`}
|
||||||
timelineId='direct'
|
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
onLoadMore={this.handleLoadMore}
|
|
||||||
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
|
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
|
||||||
alwaysPrepend
|
alwaysPrepend
|
||||||
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
|
|
||||||
/>
|
/>
|
||||||
);
|
) : (
|
||||||
} else {
|
|
||||||
contents = (
|
|
||||||
<StatusListContainer
|
<StatusListContainer
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`direct_timeline-${columnId}`}
|
scrollKey={`direct_timeline-${columnId}`}
|
||||||
timelineId='direct'
|
timelineId='direct'
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
onLoadMore={this.handleLoadMoreTimeline}
|
onLoadMore={handleLoadMoreTimeline}
|
||||||
prepend={<div className='follow_requests-unlocked_explanation'><span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span></div>}
|
prepend={
|
||||||
|
<div className='follow_requests-unlocked_explanation'>
|
||||||
|
<span><FormattedMessage id='compose_form.encryption_warning' defaultMessage='Posts on Mastodon are not end-to-end encrypted. Do not share any dangerous information over Mastodon.' /> <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a></span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
alwaysPrepend
|
alwaysPrepend
|
||||||
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
|
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any private mentions yet. When you send or receive one, it will show up here." />}
|
||||||
/>
|
/>
|
||||||
);
|
)}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
<Helmet>
|
||||||
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
<title>{intl.formatMessage(messages.title)}</title>
|
||||||
<ColumnHeader
|
<meta name='robots' content='noindex' />
|
||||||
icon='envelope'
|
</Helmet>
|
||||||
iconComponent={MailIcon}
|
</Column>
|
||||||
active={hasUnread}
|
);
|
||||||
title={intl.formatMessage(messages.title)}
|
};
|
||||||
onPin={this.handlePin}
|
|
||||||
onMove={this.handleMove}
|
|
||||||
onClick={this.handleHeaderClick}
|
|
||||||
pinned={pinned}
|
|
||||||
multiColumn={multiColumn}
|
|
||||||
>
|
|
||||||
<ColumnSettingsContainer />
|
|
||||||
</ColumnHeader>
|
|
||||||
|
|
||||||
{contents}
|
DirectTimeline.propTypes = {
|
||||||
|
columnId: PropTypes.string,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
<Helmet>
|
export default DirectTimeline;
|
||||||
<title>{intl.formatMessage(messages.title)}</title>
|
|
||||||
<meta name='robots' content='noindex' />
|
|
||||||
</Helmet>
|
|
||||||
</Column>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(injectIntl(DirectTimeline));
|
|
||||||
|
|
|
@ -25,11 +25,25 @@ class Instance < ApplicationRecord
|
||||||
scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
|
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 :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 :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
|
def self.refresh
|
||||||
Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false)
|
Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false)
|
||||||
end
|
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?
|
def readonly?
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
|
@ -244,10 +244,10 @@ module Mastodon::CLI
|
||||||
end
|
end
|
||||||
|
|
||||||
say 'Reindexing textual indexes on accounts…'
|
say 'Reindexing textual indexes on accounts…'
|
||||||
database_connection.execute('REINDEX INDEX search_index;')
|
rebuild_index(:search_index)
|
||||||
database_connection.execute('REINDEX INDEX index_accounts_on_uri;')
|
rebuild_index(:index_accounts_on_uri)
|
||||||
database_connection.execute('REINDEX INDEX index_accounts_on_url;')
|
rebuild_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(:index_accounts_on_domain_and_id) if migrator_version >= 2023_05_24_190515
|
||||||
end
|
end
|
||||||
|
|
||||||
def deduplicate_users!
|
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
|
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
|
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
|
end
|
||||||
|
|
||||||
def deduplicate_users_process_email
|
def deduplicate_users_process_email
|
||||||
|
@ -735,5 +735,9 @@ module Mastodon::CLI
|
||||||
def db_table_exists?(table)
|
def db_table_exists?(table)
|
||||||
database_connection.table_exists?(table)
|
database_connection.table_exists?(table)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def rebuild_index(name)
|
||||||
|
database_connection.execute("REINDEX INDEX #{name}")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
104
spec/models/instance_spec.rb
Normal file
104
spec/models/instance_spec.rb
Normal file
|
@ -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
|
Loading…
Reference in a new issue