commit
e9dbe31776
@ -1 +1 @@
|
|||||||
3.2.2
|
3.2.3
|
||||||
|
@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::AnnualReportsController < Api::BaseController
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index
|
||||||
|
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, except: :index
|
||||||
|
before_action :require_user!
|
||||||
|
before_action :set_annual_report, except: :index
|
||||||
|
|
||||||
|
def index
|
||||||
|
with_read_replica do
|
||||||
|
@presenter = AnnualReportsPresenter.new(GeneratedAnnualReport.where(account_id: current_account.id).pending)
|
||||||
|
@relationships = StatusRelationshipsPresenter.new(@presenter.statuses, current_account.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: @presenter,
|
||||||
|
serializer: REST::AnnualReportsSerializer,
|
||||||
|
relationships: @relationships
|
||||||
|
end
|
||||||
|
|
||||||
|
def read
|
||||||
|
@annual_report.view!
|
||||||
|
render_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_annual_report
|
||||||
|
@annual_report = GeneratedAnnualReport.find_by!(account_id: current_account.id, year: params[:id])
|
||||||
|
end
|
||||||
|
end
|
@ -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 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);
|
|
||||||
|
|
||||||
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 (element) {
|
||||||
if (align_top && container.scrollTop > element.offsetTop) {
|
if (alignTop && node.scrollTop > element.offsetTop) {
|
||||||
element.scrollIntoView(true);
|
element.scrollIntoView(true);
|
||||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
} else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||||
element.scrollIntoView(false);
|
element.scrollIntoView(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
element.focus();
|
element.focus();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.node = c;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleLoadOlder = debounce(() => {
|
export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||||
const last = this.props.conversations.last();
|
const listRef = useRef();
|
||||||
|
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
|
||||||
if (last && last.get('last_status')) {
|
const isLoading = useSelector(state => state.getIn(['conversations', 'isLoading'], true));
|
||||||
this.props.onLoadMore(last.get('last_status'));
|
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);
|
||||||
}
|
}
|
||||||
}, 300, { leading: true });
|
}, [debouncedLoadMore, lastStatusId]);
|
||||||
|
|
||||||
render () {
|
|
||||||
const { conversations, isLoading, onLoadMore, ...other } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollableList {...other} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
|
<ScrollableList {...other} scrollKey={scrollKey} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} hasMore={hasMore} onLoadMore={handleLoadMore} ref={listRef}>
|
||||||
{conversations.map(item => (
|
{conversations.map(item => (
|
||||||
<ConversationContainer
|
<Conversation
|
||||||
key={item.get('id')}
|
key={item.get('id')}
|
||||||
conversationId={item.get('id')}
|
conversation={item}
|
||||||
onMoveUp={this.handleMoveUp}
|
onMoveUp={handleMoveUp}
|
||||||
onMoveDown={this.handleMoveDown}
|
onMoveDown={handleMoveDown}
|
||||||
scrollKey={this.props.scrollKey}
|
scrollKey={scrollKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollableList>
|
</ScrollableList>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
}
|
ConversationsList.propTypes = {
|
||||||
|
scrollKey: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
@ -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,4 +1 @@
|
|||||||
{
|
{}
|
||||||
"settings.content_warnings": "Content warnings",
|
|
||||||
"settings.preferences": "Preferences"
|
|
||||||
}
|
|
||||||
|
@ -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 'mastodon/actions/conversations';
|
||||||
import ConversationContainer from '../containers/conversation_container';
|
import ScrollableList from 'mastodon/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);
|
|
||||||
|
|
||||||
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 (element) {
|
||||||
if (align_top && container.scrollTop > element.offsetTop) {
|
if (alignTop && node.scrollTop > element.offsetTop) {
|
||||||
element.scrollIntoView(true);
|
element.scrollIntoView(true);
|
||||||
} else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
|
} else if (!alignTop && node.scrollTop + node.clientHeight < element.offsetTop + element.offsetHeight) {
|
||||||
element.scrollIntoView(false);
|
element.scrollIntoView(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
element.focus();
|
element.focus();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setRef = c => {
|
|
||||||
this.node = c;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
handleLoadOlder = debounce(() => {
|
export const ConversationsList = ({ scrollKey, ...other }) => {
|
||||||
const last = this.props.conversations.last();
|
const listRef = useRef();
|
||||||
|
const conversations = useSelector(state => state.getIn(['conversations', 'items']));
|
||||||
if (last && last.get('last_status')) {
|
const isLoading = useSelector(state => state.getIn(['conversations', 'isLoading'], true));
|
||||||
this.props.onLoadMore(last.get('last_status'));
|
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);
|
||||||
}
|
}
|
||||||
}, 300, { leading: true });
|
}, [debouncedLoadMore, lastStatusId]);
|
||||||
|
|
||||||
render () {
|
|
||||||
const { conversations, isLoading, onLoadMore, ...other } = this.props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollableList {...other} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} onLoadMore={onLoadMore && this.handleLoadOlder} ref={this.setRef}>
|
<ScrollableList {...other} scrollKey={scrollKey} isLoading={isLoading} showLoading={isLoading && conversations.isEmpty()} hasMore={hasMore} onLoadMore={handleLoadMore} ref={listRef}>
|
||||||
{conversations.map(item => (
|
{conversations.map(item => (
|
||||||
<ConversationContainer
|
<Conversation
|
||||||
key={item.get('id')}
|
key={item.get('id')}
|
||||||
conversationId={item.get('id')}
|
conversation={item}
|
||||||
onMoveUp={this.handleMoveUp}
|
onMoveUp={handleMoveUp}
|
||||||
onMoveDown={this.handleMoveDown}
|
onMoveDown={handleMoveDown}
|
||||||
scrollKey={this.props.scrollKey}
|
scrollKey={scrollKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ScrollableList>
|
</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,43 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport
|
||||||
|
include DatabaseHelper
|
||||||
|
|
||||||
|
SOURCES = [
|
||||||
|
AnnualReport::Archetype,
|
||||||
|
AnnualReport::TypeDistribution,
|
||||||
|
AnnualReport::TopStatuses,
|
||||||
|
AnnualReport::MostUsedApps,
|
||||||
|
AnnualReport::CommonlyInteractedWithAccounts,
|
||||||
|
AnnualReport::TimeSeries,
|
||||||
|
AnnualReport::TopHashtags,
|
||||||
|
AnnualReport::MostRebloggedAccounts,
|
||||||
|
AnnualReport::Percentiles,
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
SCHEMA = 1
|
||||||
|
|
||||||
|
def initialize(account, year)
|
||||||
|
@account = account
|
||||||
|
@year = year
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate
|
||||||
|
return if GeneratedAnnualReport.exists?(account: @account, year: @year)
|
||||||
|
|
||||||
|
GeneratedAnnualReport.create(
|
||||||
|
account: @account,
|
||||||
|
year: @year,
|
||||||
|
schema_version: SCHEMA,
|
||||||
|
data: data
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def data
|
||||||
|
with_read_replica do
|
||||||
|
SOURCES.each_with_object({}) { |klass, hsh| hsh.merge!(klass.new(@account, @year).generate) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,49 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport::Archetype < AnnualReport::Source
|
||||||
|
# Average number of posts (including replies and reblogs) made by
|
||||||
|
# each active user in a single year (2023)
|
||||||
|
AVERAGE_PER_YEAR = 113
|
||||||
|
|
||||||
|
def generate
|
||||||
|
{
|
||||||
|
archetype: archetype,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def archetype
|
||||||
|
if (standalone_count + replies_count + reblogs_count) < AVERAGE_PER_YEAR
|
||||||
|
:lurker
|
||||||
|
elsif reblogs_count > (standalone_count * 2)
|
||||||
|
:booster
|
||||||
|
elsif polls_count > (standalone_count * 0.1) # standalone_count includes posts with polls
|
||||||
|
:pollster
|
||||||
|
elsif replies_count > (standalone_count * 2)
|
||||||
|
:replier
|
||||||
|
else
|
||||||
|
:oracle
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def polls_count
|
||||||
|
@polls_count ||= base_scope.where.not(poll_id: nil).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def reblogs_count
|
||||||
|
@reblogs_count ||= base_scope.where.not(reblog_of_id: nil).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def replies_count
|
||||||
|
@replies_count ||= base_scope.where.not(in_reply_to_id: nil).where.not(in_reply_to_account_id: @account.id).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def standalone_count
|
||||||
|
@standalone_count ||= base_scope.without_replies.without_reblogs.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def base_scope
|
||||||
|
@account.statuses.where(id: year_as_snowflake_range)
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport::CommonlyInteractedWithAccounts < AnnualReport::Source
|
||||||
|
SET_SIZE = 40
|
||||||
|
|
||||||
|
def generate
|
||||||
|
{
|
||||||
|
commonly_interacted_with_accounts: commonly_interacted_with_accounts.map do |(account_id, count)|
|
||||||
|
{
|
||||||
|
account_id: account_id,
|
||||||
|
count: count,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def commonly_interacted_with_accounts
|
||||||
|
@account.statuses.reorder(nil).where(id: year_as_snowflake_range).where.not(in_reply_to_account_id: @account.id).group(:in_reply_to_account_id).having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('in_reply_to_account_id, count(*) AS total'))
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport::MostRebloggedAccounts < AnnualReport::Source
|
||||||
|
SET_SIZE = 10
|
||||||
|
|
||||||
|
def generate
|
||||||
|
{
|
||||||
|
most_reblogged_accounts: most_reblogged_accounts.map do |(account_id, count)|
|
||||||
|
{
|
||||||
|
account_id: account_id,
|
||||||
|
count: count,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def most_reblogged_accounts
|
||||||
|
@account.statuses.reorder(nil).where(id: year_as_snowflake_range).where.not(reblog_of_id: nil).joins(reblog: :account).group('accounts.id').having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('accounts.id, count(*) as total'))
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport::MostUsedApps < AnnualReport::Source
|
||||||
|
SET_SIZE = 10
|
||||||
|
|
||||||
|
def generate
|
||||||
|
{
|
||||||
|
most_used_apps: most_used_apps.map do |(name, count)|
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
count: count,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def most_used_apps
|
||||||
|
@account.statuses.reorder(nil).where(id: year_as_snowflake_range).joins(:application).group('oauth_applications.name').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('oauth_applications.name, count(*) as total'))
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,62 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport::Percentiles < AnnualReport::Source
|
||||||
|
def generate
|
||||||
|
{
|
||||||
|
percentiles: {
|
||||||
|
followers: (total_with_fewer_followers / (total_with_any_followers + 1.0)) * 100,
|
||||||
|
statuses: (total_with_fewer_statuses / (total_with_any_statuses + 1.0)) * 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def followers_gained
|
||||||
|
@followers_gained ||= @account.passive_relationships.where("date_part('year', follows.created_at) = ?", @year).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def statuses_created
|
||||||
|
@statuses_created ||= @account.statuses.where(id: year_as_snowflake_range).count
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_with_fewer_followers
|
||||||
|
@total_with_fewer_followers ||= Follow.find_by_sql([<<~SQL.squish, { year: @year, comparison: followers_gained }]).first.total
|
||||||
|
WITH tmp0 AS (
|
||||||
|
SELECT follows.target_account_id
|
||||||
|
FROM follows
|
||||||
|
INNER JOIN accounts ON accounts.id = follows.target_account_id
|
||||||
|
WHERE date_part('year', follows.created_at) = :year
|
||||||
|
AND accounts.domain IS NULL
|
||||||
|
GROUP BY follows.target_account_id
|
||||||
|
HAVING COUNT(*) < :comparison
|
||||||
|
)
|
||||||
|
SELECT count(*) AS total
|
||||||
|
FROM tmp0
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_with_fewer_statuses
|
||||||
|
@total_with_fewer_statuses ||= Status.find_by_sql([<<~SQL.squish, { comparison: statuses_created, min_id: year_as_snowflake_range.first, max_id: year_as_snowflake_range.last }]).first.total
|
||||||
|
WITH tmp0 AS (
|
||||||
|
SELECT statuses.account_id
|
||||||
|
FROM statuses
|
||||||
|
INNER JOIN accounts ON accounts.id = statuses.account_id
|
||||||
|
WHERE statuses.id BETWEEN :min_id AND :max_id
|
||||||
|
AND accounts.domain IS NULL
|
||||||
|
GROUP BY statuses.account_id
|
||||||
|
HAVING count(*) < :comparison
|
||||||
|
)
|
||||||
|
SELECT count(*) AS total
|
||||||
|
FROM tmp0
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_with_any_followers
|
||||||
|
@total_with_any_followers ||= Follow.where("date_part('year', follows.created_at) = ?", @year).joins(:target_account).merge(Account.local).count('distinct follows.target_account_id')
|
||||||
|
end
|
||||||
|
|
||||||
|
def total_with_any_statuses
|
||||||
|
@total_with_any_statuses ||= Status.where(id: year_as_snowflake_range).joins(:account).merge(Account.local).count('distinct statuses.account_id')
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,16 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport::Source
|
||||||
|
attr_reader :account, :year
|
||||||
|
|
||||||
|
def initialize(account, year)
|
||||||
|
@account = account
|
||||||
|
@year = year
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def year_as_snowflake_range
|
||||||
|
(Mastodon::Snowflake.id_at(DateTime.new(year, 1, 1))..Mastodon::Snowflake.id_at(DateTime.new(year, 12, 31)))
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport::TimeSeries < AnnualReport::Source
|
||||||
|
def generate
|
||||||
|
{
|
||||||
|
time_series: (1..12).map do |month|
|
||||||
|
{
|
||||||
|
month: month,
|
||||||
|
statuses: statuses_per_month[month] || 0,
|
||||||
|
following: following_per_month[month] || 0,
|
||||||
|
followers: followers_per_month[month] || 0,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def statuses_per_month
|
||||||
|
@statuses_per_month ||= @account.statuses.reorder(nil).where(id: year_as_snowflake_range).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
|
||||||
|
end
|
||||||
|
|
||||||
|
def following_per_month
|
||||||
|
@following_per_month ||= @account.active_relationships.where("date_part('year', created_at) = ?", @year).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
|
||||||
|
end
|
||||||
|
|
||||||
|
def followers_per_month
|
||||||
|
@followers_per_month ||= @account.passive_relationships.where("date_part('year', created_at) = ?", @year).group(:period).pluck(Arel.sql("date_part('month', created_at)::int AS period, count(*)")).to_h
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport::TopHashtags < AnnualReport::Source
|
||||||
|
SET_SIZE = 40
|
||||||
|
|
||||||
|
def generate
|
||||||
|
{
|
||||||
|
top_hashtags: top_hashtags.map do |(name, count)|
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
count: count,
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def top_hashtags
|
||||||
|
Tag.joins(:statuses).where(statuses: { id: @account.statuses.where(id: year_as_snowflake_range).reorder(nil).select(:id) }).group(:id).having('count(*) > 1').order(total: :desc).limit(SET_SIZE).pluck(Arel.sql('COALESCE(tags.display_name, tags.name), count(*) AS total'))
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,21 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AnnualReport::TopStatuses < AnnualReport::Source
|
||||||
|
def generate
|
||||||
|
top_reblogs = base_scope.order(reblogs_count: :desc).first&.id
|
||||||
|
top_favourites = base_scope.where.not(id: top_reblogs).order(favourites_count: :desc).first&.id
|
||||||
|
top_replies = base_scope.where.not(id: [top_reblogs, top_favourites]).order(replies_count: :desc).first&.id
|
||||||
|
|
||||||
|
{
|
||||||
|
top_statuses: {
|
||||||
|
by_reblogs: top_reblogs,
|
||||||
|
by_favourites: top_favourites,
|
||||||
|
by_replies: top_replies,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def base_scope
|
||||||
|
@account.statuses.with_public_visibility.joins(:status_stat).where(id: year_as_snowflake_range).reorder(nil)
|
||||||
|
end
|
||||||
|
end
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue