Merge pull request #2585 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to 3205a654ca
th-downstream
commit
420ca90071
@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Redirect::AccountsController < Redirect::BaseController
|
||||
private
|
||||
|
||||
def set_resource
|
||||
@resource = Account.find(params[:id])
|
||||
not_found if @resource.local?
|
||||
end
|
||||
end
|
@ -0,0 +1,29 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Redirect::BaseController < ApplicationController
|
||||
vary_by 'Accept-Language'
|
||||
|
||||
before_action :set_pack
|
||||
before_action :set_resource
|
||||
before_action :set_app_body_class
|
||||
|
||||
def show
|
||||
@redirect_path = ActivityPub::TagManager.instance.url_for(@resource)
|
||||
|
||||
render 'redirects/show', layout: 'application'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_app_body_class
|
||||
@body_classes = 'app-body'
|
||||
end
|
||||
|
||||
def set_resource
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
def set_pack
|
||||
use_pack 'public'
|
||||
end
|
||||
end
|
@ -0,0 +1,10 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Redirect::StatusesController < Redirect::BaseController
|
||||
private
|
||||
|
||||
def set_resource
|
||||
@resource = Status.find(params[:id])
|
||||
not_found if @resource.local? || !@resource.distributable?
|
||||
end
|
||||
end
|
@ -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 'mastodon/actions/conversations';
|
||||
import ScrollableList from 'mastodon/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();
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c;
|
||||
};
|
||||
|
||||
handleLoadOlder = debounce(() => {
|
||||
const last = this.props.conversations.last();
|
||||
|
||||
if (last && last.get('last_status')) {
|
||||
this.props.onLoadMore(last.get('last_status'));
|
||||
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);
|
||||
}
|
||||
}, 300, { leading: true });
|
||||
|
||||
render () {
|
||||
const { conversations, isLoading, onLoadMore, ...other } = this.props;
|
||||
|
||||
return (
|
||||
<ScrollableList {...other} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
|
||||
{conversations.map(item => (
|
||||
<ConversationContainer
|
||||
key={item.get('id')}
|
||||
conversationId={item.get('id')}
|
||||
onMoveUp={this.handleMoveUp}
|
||||
onMoveDown={this.handleMoveDown}
|
||||
scrollKey={this.props.scrollKey}
|
||||
/>
|
||||
))}
|
||||
</ScrollableList>
|
||||
);
|
||||
element.focus();
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
const handleMoveUp = useCallback(id => {
|
||||
const elementIndex = conversations.findIndex(x => x.get('id') === id) - 1;
|
||||
focusChild(listRef.current.node, elementIndex, true);
|
||||
}, [listRef, conversations]);
|
||||
|
||||
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);
|
||||
}
|
||||
}, [debouncedLoadMore, lastStatusId]);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
ConversationsList.propTypes = {
|
||||
scrollKey: PropTypes.string.isRequired,
|
||||
};
|
||||
|
@ -1,80 +0,0 @@
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { replyCompose } from 'mastodon/actions/compose';
|
||||
import { markConversationRead, deleteConversation } from 'mastodon/actions/conversations';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'mastodon/actions/statuses';
|
||||
import { makeGetStatus } from 'mastodon/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 }),
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
After Width: | Height: | Size: 275 B |
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 415 B |
Before Width: | Height: | Size: 879 B After Width: | Height: | Size: 879 B |
After Width: | Height: | Size: 961 B |
@ -0,0 +1,8 @@
|
||||
.redirect
|
||||
.redirect__logo
|
||||
= link_to render_logo, root_path
|
||||
|
||||
.redirect__message
|
||||
%h1= t('redirects.title', instance: site_hostname)
|
||||
%p= t('redirects.prompt')
|
||||
%p= link_to @redirect_path, @redirect_path, rel: 'noreferrer noopener'
|
@ -0,0 +1,32 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
describe 'redirection confirmations' do
|
||||
let(:account) { Fabricate(:account, domain: 'example.com', uri: 'https://example.com/users/foo', url: 'https://example.com/@foo') }
|
||||
let(:status) { Fabricate(:status, account: account, uri: 'https://example.com/users/foo/statuses/1', url: 'https://example.com/@foo/1') }
|
||||
|
||||
context 'when a logged out user visits a local page for a remote account' do
|
||||
it 'shows a confirmation page' do
|
||||
visit "/@#{account.pretty_acct}"
|
||||
|
||||
# It explains about the redirect
|
||||
expect(page).to have_content(I18n.t('redirects.title', instance: 'cb6e6126.ngrok.io'))
|
||||
|
||||
# It features an appropriate link
|
||||
expect(page).to have_link(account.url, href: account.url)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a logged out user visits a local page for a remote status' do
|
||||
it 'shows a confirmation page' do
|
||||
visit "/@#{account.pretty_acct}/#{status.id}"
|
||||
|
||||
# It explains about the redirect
|
||||
expect(page).to have_content(I18n.t('redirects.title', instance: 'cb6e6126.ngrok.io'))
|
||||
|
||||
# It features an appropriate link
|
||||
expect(page).to have_link(status.url, href: status.url)
|
||||
end
|
||||
end
|
||||
end
|
@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CustomFilterKeyword do
|
||||
describe '#to_regex' do
|
||||
context 'when whole_word is true' do
|
||||
it 'builds a regex with boundaries and the keyword' do
|
||||
keyword = described_class.new(whole_word: true, keyword: 'test')
|
||||
|
||||
expect(keyword.to_regex).to eq(/(?mix:\b#{Regexp.escape(keyword.keyword)}\b)/)
|
||||
end
|
||||
|
||||
it 'builds a regex with starting boundary and the keyword when end with non-word' do
|
||||
keyword = described_class.new(whole_word: true, keyword: 'test#')
|
||||
|
||||
expect(keyword.to_regex).to eq(/(?mix:\btest\#)/)
|
||||
end
|
||||
|
||||
it 'builds a regex with end boundary and the keyword when start with non-word' do
|
||||
keyword = described_class.new(whole_word: true, keyword: '#test')
|
||||
|
||||
expect(keyword.to_regex).to eq(/(?mix:\#test\b)/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when whole_word is false' do
|
||||
it 'builds a regex with the keyword' do
|
||||
keyword = described_class.new(whole_word: false, keyword: 'test')
|
||||
|
||||
expect(keyword.to_regex).to eq(/test/i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in new issue