commit
d73c1df33b
4
Gemfile
4
Gemfile
|
@ -15,7 +15,7 @@ gem 'makara', '~> 0.4'
|
||||||
gem 'pghero', '~> 2.2'
|
gem 'pghero', '~> 2.2'
|
||||||
gem 'dotenv-rails', '~> 2.2', '< 2.3'
|
gem 'dotenv-rails', '~> 2.2', '< 2.3'
|
||||||
|
|
||||||
gem 'aws-sdk-s3', '~> 1.20', require: false
|
gem 'aws-sdk-s3', '~> 1.21', require: false
|
||||||
gem 'fog-core', '~> 2.1'
|
gem 'fog-core', '~> 2.1'
|
||||||
gem 'fog-openstack', '~> 1.0', require: false
|
gem 'fog-openstack', '~> 1.0', require: false
|
||||||
gem 'paperclip', '~> 6.0'
|
gem 'paperclip', '~> 6.0'
|
||||||
|
@ -107,7 +107,7 @@ group :production, :test do
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
gem 'capybara', '~> 3.8'
|
gem 'capybara', '~> 3.9'
|
||||||
gem 'climate_control', '~> 0.2'
|
gem 'climate_control', '~> 0.2'
|
||||||
gem 'faker', '~> 1.8'
|
gem 'faker', '~> 1.8'
|
||||||
gem 'microformats', '~> 4.0'
|
gem 'microformats', '~> 4.0'
|
||||||
|
|
23
Gemfile.lock
23
Gemfile.lock
|
@ -77,7 +77,7 @@ GEM
|
||||||
cocaine (~> 0.5.3)
|
cocaine (~> 0.5.3)
|
||||||
aws-eventstream (1.0.1)
|
aws-eventstream (1.0.1)
|
||||||
aws-partitions (1.105.0)
|
aws-partitions (1.105.0)
|
||||||
aws-sdk-core (3.29.0)
|
aws-sdk-core (3.30.0)
|
||||||
aws-eventstream (~> 1.0)
|
aws-eventstream (~> 1.0)
|
||||||
aws-partitions (~> 1.0)
|
aws-partitions (~> 1.0)
|
||||||
aws-sigv4 (~> 1.0)
|
aws-sigv4 (~> 1.0)
|
||||||
|
@ -85,7 +85,7 @@ GEM
|
||||||
aws-sdk-kms (1.9.0)
|
aws-sdk-kms (1.9.0)
|
||||||
aws-sdk-core (~> 3, >= 3.26.0)
|
aws-sdk-core (~> 3, >= 3.26.0)
|
||||||
aws-sigv4 (~> 1.0)
|
aws-sigv4 (~> 1.0)
|
||||||
aws-sdk-s3 (1.20.0)
|
aws-sdk-s3 (1.21.0)
|
||||||
aws-sdk-core (~> 3, >= 3.26.0)
|
aws-sdk-core (~> 3, >= 3.26.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.0)
|
aws-sigv4 (~> 1.0)
|
||||||
|
@ -126,7 +126,7 @@ GEM
|
||||||
sshkit (~> 1.3)
|
sshkit (~> 1.3)
|
||||||
capistrano-yarn (2.0.2)
|
capistrano-yarn (2.0.2)
|
||||||
capistrano (~> 3.0)
|
capistrano (~> 3.0)
|
||||||
capybara (3.8.2)
|
capybara (3.9.0)
|
||||||
addressable
|
addressable
|
||||||
mini_mime (>= 0.1.3)
|
mini_mime (>= 0.1.3)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
|
@ -188,9 +188,6 @@ GEM
|
||||||
dotenv-rails (2.2.2)
|
dotenv-rails (2.2.2)
|
||||||
dotenv (= 2.2.2)
|
dotenv (= 2.2.2)
|
||||||
railties (>= 3.2, < 6.0)
|
railties (>= 3.2, < 6.0)
|
||||||
easy_translate (0.5.1)
|
|
||||||
thread
|
|
||||||
thread_safe
|
|
||||||
elasticsearch (6.0.2)
|
elasticsearch (6.0.2)
|
||||||
elasticsearch-api (= 6.0.2)
|
elasticsearch-api (= 6.0.2)
|
||||||
elasticsearch-transport (= 6.0.2)
|
elasticsearch-transport (= 6.0.2)
|
||||||
|
@ -255,7 +252,7 @@ GEM
|
||||||
hashdiff (0.3.7)
|
hashdiff (0.3.7)
|
||||||
hashie (3.5.7)
|
hashie (3.5.7)
|
||||||
heapy (0.1.4)
|
heapy (0.1.4)
|
||||||
highline (1.7.10)
|
highline (2.0.0)
|
||||||
hiredis (0.6.1)
|
hiredis (0.6.1)
|
||||||
hitimes (1.3.0)
|
hitimes (1.3.0)
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
|
@ -276,12 +273,11 @@ GEM
|
||||||
rainbow (>= 2.0.0)
|
rainbow (>= 2.0.0)
|
||||||
i18n (1.1.0)
|
i18n (1.1.0)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
i18n-tasks (0.9.21)
|
i18n-tasks (0.9.25)
|
||||||
activesupport (>= 4.0.2)
|
activesupport (>= 4.0.2)
|
||||||
ast (>= 2.1.0)
|
ast (>= 2.1.0)
|
||||||
easy_translate (>= 0.5.1)
|
|
||||||
erubi
|
erubi
|
||||||
highline (>= 1.7.3)
|
highline (>= 2.0.0)
|
||||||
i18n
|
i18n
|
||||||
parser (>= 2.2.3.0)
|
parser (>= 2.2.3.0)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
|
@ -599,7 +595,6 @@ GEM
|
||||||
terrapin (0.6.0)
|
terrapin (0.6.0)
|
||||||
climate_control (>= 0.0.3, < 1.0)
|
climate_control (>= 0.0.3, < 1.0)
|
||||||
thor (0.20.0)
|
thor (0.20.0)
|
||||||
thread (0.2.2)
|
|
||||||
thread_safe (0.3.6)
|
thread_safe (0.3.6)
|
||||||
tilt (2.0.8)
|
tilt (2.0.8)
|
||||||
timers (4.1.2)
|
timers (4.1.2)
|
||||||
|
@ -608,7 +603,7 @@ GEM
|
||||||
tty-command (0.8.2)
|
tty-command (0.8.2)
|
||||||
pastel (~> 0.7.0)
|
pastel (~> 0.7.0)
|
||||||
tty-cursor (0.6.0)
|
tty-cursor (0.6.0)
|
||||||
tty-prompt (0.17.0)
|
tty-prompt (0.17.1)
|
||||||
necromancer (~> 0.4.0)
|
necromancer (~> 0.4.0)
|
||||||
pastel (~> 0.7.0)
|
pastel (~> 0.7.0)
|
||||||
timers (~> 4.0)
|
timers (~> 4.0)
|
||||||
|
@ -658,7 +653,7 @@ DEPENDENCIES
|
||||||
active_record_query_trace (~> 1.5)
|
active_record_query_trace (~> 1.5)
|
||||||
addressable (~> 2.5)
|
addressable (~> 2.5)
|
||||||
annotate (~> 2.7)
|
annotate (~> 2.7)
|
||||||
aws-sdk-s3 (~> 1.20)
|
aws-sdk-s3 (~> 1.21)
|
||||||
better_errors (~> 2.4)
|
better_errors (~> 2.4)
|
||||||
binding_of_caller (~> 0.7)
|
binding_of_caller (~> 0.7)
|
||||||
bootsnap (~> 1.3)
|
bootsnap (~> 1.3)
|
||||||
|
@ -670,7 +665,7 @@ DEPENDENCIES
|
||||||
capistrano-rails (~> 1.3)
|
capistrano-rails (~> 1.3)
|
||||||
capistrano-rbenv (~> 2.1)
|
capistrano-rbenv (~> 2.1)
|
||||||
capistrano-yarn (~> 2.0)
|
capistrano-yarn (~> 2.0)
|
||||||
capybara (~> 3.8)
|
capybara (~> 3.9)
|
||||||
charlock_holmes (~> 0.7.6)
|
charlock_holmes (~> 0.7.6)
|
||||||
chewy (~> 5.0)
|
chewy (~> 5.0)
|
||||||
cld3 (~> 3.2.0)
|
cld3 (~> 3.2.0)
|
||||||
|
|
|
@ -20,6 +20,7 @@ module Admin
|
||||||
skin
|
skin
|
||||||
thumbnail
|
thumbnail
|
||||||
hero
|
hero
|
||||||
|
mascot
|
||||||
min_invite_role
|
min_invite_role
|
||||||
activity_api_enabled
|
activity_api_enabled
|
||||||
peers_api_enabled
|
peers_api_enabled
|
||||||
|
@ -42,6 +43,7 @@ module Admin
|
||||||
UPLOAD_SETTINGS = %w(
|
UPLOAD_SETTINGS = %w(
|
||||||
thumbnail
|
thumbnail
|
||||||
hero
|
hero
|
||||||
|
mascot
|
||||||
).freeze
|
).freeze
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::ConversationsController < Api::BaseController
|
||||||
|
LIMIT = 20
|
||||||
|
|
||||||
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
|
||||||
|
before_action :require_user!
|
||||||
|
after_action :insert_pagination_headers
|
||||||
|
|
||||||
|
respond_to :json
|
||||||
|
|
||||||
|
def index
|
||||||
|
@conversations = paginated_conversations
|
||||||
|
render json: @conversations, each_serializer: REST::ConversationSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def paginated_conversations
|
||||||
|
AccountConversation.where(account: current_account)
|
||||||
|
.paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
|
||||||
|
end
|
||||||
|
|
||||||
|
def insert_pagination_headers
|
||||||
|
set_pagination_headers(next_path, prev_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
def next_path
|
||||||
|
if records_continue?
|
||||||
|
api_v1_conversations_url pagination_params(max_id: pagination_max_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def prev_path
|
||||||
|
unless @conversations.empty?
|
||||||
|
api_v1_conversations_url pagination_params(min_id: pagination_since_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_max_id
|
||||||
|
@conversations.last.last_status_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_since_id
|
||||||
|
@conversations.first.last_status_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def records_continue?
|
||||||
|
@conversations.size == limit_param(LIMIT)
|
||||||
|
end
|
||||||
|
|
||||||
|
def pagination_params(core_params)
|
||||||
|
params.slice(:limit).permit(:limit).merge(core_params)
|
||||||
|
end
|
||||||
|
end
|
|
@ -87,16 +87,6 @@ module SignatureVerification
|
||||||
end.join("\n")
|
end.join("\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
def matches_time_window?
|
|
||||||
begin
|
|
||||||
time_sent = DateTime.httpdate(request.headers['Date'])
|
|
||||||
rescue ArgumentError
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
|
|
||||||
(Time.now.utc - time_sent).abs <= 30
|
|
||||||
end
|
|
||||||
|
|
||||||
def body_digest
|
def body_digest
|
||||||
"SHA-256=#{Digest::SHA256.base64digest(request_body)}"
|
"SHA-256=#{Digest::SHA256.base64digest(request_body)}"
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
import api, { getLinks } from '../api';
|
||||||
|
import {
|
||||||
|
importFetchedAccounts,
|
||||||
|
importFetchedStatuses,
|
||||||
|
importFetchedStatus,
|
||||||
|
} from './importer';
|
||||||
|
|
||||||
|
export const CONVERSATIONS_FETCH_REQUEST = 'CONVERSATIONS_FETCH_REQUEST';
|
||||||
|
export const CONVERSATIONS_FETCH_SUCCESS = 'CONVERSATIONS_FETCH_SUCCESS';
|
||||||
|
export const CONVERSATIONS_FETCH_FAIL = 'CONVERSATIONS_FETCH_FAIL';
|
||||||
|
export const CONVERSATIONS_UPDATE = 'CONVERSATIONS_UPDATE';
|
||||||
|
|
||||||
|
export const expandConversations = ({ maxId } = {}) => (dispatch, getState) => {
|
||||||
|
dispatch(expandConversationsRequest());
|
||||||
|
|
||||||
|
const params = { max_id: maxId };
|
||||||
|
|
||||||
|
if (!maxId) {
|
||||||
|
params.since_id = getState().getIn(['conversations', 0, 'last_status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
api(getState).get('/api/v1/conversations', { params })
|
||||||
|
.then(response => {
|
||||||
|
const next = getLinks(response).refs.find(link => link.rel === 'next');
|
||||||
|
|
||||||
|
dispatch(importFetchedAccounts(response.data.reduce((aggr, item) => aggr.concat(item.accounts), [])));
|
||||||
|
dispatch(importFetchedStatuses(response.data.map(item => item.last_status).filter(x => !!x)));
|
||||||
|
dispatch(expandConversationsSuccess(response.data, next ? next.uri : null));
|
||||||
|
})
|
||||||
|
.catch(err => dispatch(expandConversationsFail(err)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const expandConversationsRequest = () => ({
|
||||||
|
type: CONVERSATIONS_FETCH_REQUEST,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expandConversationsSuccess = (conversations, next) => ({
|
||||||
|
type: CONVERSATIONS_FETCH_SUCCESS,
|
||||||
|
conversations,
|
||||||
|
next,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const expandConversationsFail = error => ({
|
||||||
|
type: CONVERSATIONS_FETCH_FAIL,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateConversations = conversation => dispatch => {
|
||||||
|
dispatch(importFetchedAccounts(conversation.accounts));
|
||||||
|
|
||||||
|
if (conversation.last_status) {
|
||||||
|
dispatch(importFetchedStatus(conversation.last_status));
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: CONVERSATIONS_UPDATE,
|
||||||
|
conversation,
|
||||||
|
});
|
||||||
|
};
|
|
@ -6,6 +6,7 @@ import {
|
||||||
disconnectTimeline,
|
disconnectTimeline,
|
||||||
} from './timelines';
|
} from './timelines';
|
||||||
import { updateNotifications, expandNotifications } from './notifications';
|
import { updateNotifications, expandNotifications } from './notifications';
|
||||||
|
import { updateConversations } from './conversations';
|
||||||
import { fetchFilters } from './filters';
|
import { fetchFilters } from './filters';
|
||||||
import { getLocale } from '../locales';
|
import { getLocale } from '../locales';
|
||||||
|
|
||||||
|
@ -31,6 +32,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
|
||||||
case 'notification':
|
case 'notification':
|
||||||
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
|
dispatch(updateNotifications(JSON.parse(data.payload), messages, locale));
|
||||||
break;
|
break;
|
||||||
|
case 'conversation':
|
||||||
|
dispatch(updateConversations(JSON.parse(data.payload)));
|
||||||
|
break;
|
||||||
case 'filters_changed':
|
case 'filters_changed':
|
||||||
dispatch(fetchFilters());
|
dispatch(fetchFilters());
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -76,7 +76,6 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
|
||||||
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
|
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
|
||||||
export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
|
export const expandPublicTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`public${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { max_id: maxId, only_media: !!onlyMedia }, done);
|
||||||
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
|
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
|
||||||
export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done);
|
|
||||||
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
|
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
|
||||||
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
|
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
|
||||||
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
|
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
|
||||||
|
|
|
@ -1,18 +1,25 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
export default class DisplayName extends React.PureComponent {
|
export default class DisplayName extends React.PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
account: ImmutablePropTypes.map.isRequired,
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
|
withAcct: PropTypes.bool,
|
||||||
|
};
|
||||||
|
|
||||||
|
static defaultProps = {
|
||||||
|
withAcct: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const displayNameHtml = { __html: this.props.account.get('display_name_html') };
|
const { account, withAcct } = this.props;
|
||||||
|
const displayNameHtml = { __html: account.get('display_name_html') };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className='display-name'>
|
<span className='display-name'>
|
||||||
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /></bdi> <span className='display-name__account'>@{this.props.account.get('acct')}</span>
|
<bdi><strong className='display-name__html' dangerouslySetInnerHTML={displayNameHtml} /></bdi> {withAcct && <span className='display-name__account'>@{account.get('acct')}</span>}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import StatusContent from '../../../components/status_content';
|
||||||
|
import RelativeTimestamp from '../../../components/relative_timestamp';
|
||||||
|
import DisplayName from '../../../components/display_name';
|
||||||
|
import Avatar from '../../../components/avatar';
|
||||||
|
import AttachmentList from '../../../components/attachment_list';
|
||||||
|
import { HotKeys } from 'react-hotkeys';
|
||||||
|
|
||||||
|
export default class Conversation extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
conversationId: PropTypes.string.isRequired,
|
||||||
|
accounts: ImmutablePropTypes.list.isRequired,
|
||||||
|
lastStatus: ImmutablePropTypes.map.isRequired,
|
||||||
|
onMoveUp: PropTypes.func,
|
||||||
|
onMoveDown: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleClick = () => {
|
||||||
|
if (!this.context.router) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lastStatus } = this.props;
|
||||||
|
this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyMoveUp = () => {
|
||||||
|
this.props.onMoveUp(this.props.conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHotkeyMoveDown = () => {
|
||||||
|
this.props.onMoveDown(this.props.conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { accounts, lastStatus, lastAccount } = this.props;
|
||||||
|
|
||||||
|
if (lastStatus === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
moveDown: this.handleHotkeyMoveDown,
|
||||||
|
moveUp: this.handleHotkeyMoveUp,
|
||||||
|
open: this.handleClick,
|
||||||
|
};
|
||||||
|
|
||||||
|
let media;
|
||||||
|
|
||||||
|
if (lastStatus.get('media_attachments').size > 0) {
|
||||||
|
media = <AttachmentList compact media={lastStatus.get('media_attachments')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HotKeys handlers={handlers}>
|
||||||
|
<div className='conversation focusable' tabIndex='0' onClick={this.handleClick} role='button'>
|
||||||
|
<div className='conversation__header'>
|
||||||
|
<div className='conversation__avatars'>
|
||||||
|
<div>{accounts.map(account => <Avatar key={account.get('id')} size={36} account={account} />)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='conversation__time'>
|
||||||
|
<RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||||
|
<br />
|
||||||
|
<DisplayName account={lastAccount} withAcct={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusContent status={lastStatus} onClick={this.handleClick} />
|
||||||
|
|
||||||
|
{media}
|
||||||
|
</div>
|
||||||
|
</HotKeys>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,68 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
import ConversationContainer from '../containers/conversation_container';
|
||||||
|
import ScrollableList from '../../../components/scrollable_list';
|
||||||
|
import { debounce } from 'lodash';
|
||||||
|
|
||||||
|
export default class ConversationsList extends ImmutablePureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
conversationIds: ImmutablePropTypes.list.isRequired,
|
||||||
|
hasMore: PropTypes.bool,
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
onLoadMore: PropTypes.func,
|
||||||
|
shouldUpdateScroll: PropTypes.func,
|
||||||
|
};
|
||||||
|
|
||||||
|
getCurrentIndex = id => this.props.conversationIds.indexOf(id)
|
||||||
|
|
||||||
|
handleMoveUp = id => {
|
||||||
|
const elementIndex = this.getCurrentIndex(id) - 1;
|
||||||
|
this._selectChild(elementIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMoveDown = id => {
|
||||||
|
const elementIndex = this.getCurrentIndex(id) + 1;
|
||||||
|
this._selectChild(elementIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectChild (index) {
|
||||||
|
const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
|
||||||
|
|
||||||
|
if (element) {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.node = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLoadOlder = debounce(() => {
|
||||||
|
const last = this.props.conversationIds.last();
|
||||||
|
|
||||||
|
if (last) {
|
||||||
|
this.props.onLoadMore(last);
|
||||||
|
}
|
||||||
|
}, 300, { leading: true })
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { conversationIds, onLoadMore, ...other } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}>
|
||||||
|
{conversationIds.map(item => (
|
||||||
|
<ConversationContainer
|
||||||
|
key={item}
|
||||||
|
conversationId={item}
|
||||||
|
onMoveUp={this.handleMoveUp}
|
||||||
|
onMoveDown={this.handleMoveDown}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollableList>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import Conversation from '../components/conversation';
|
||||||
|
|
||||||
|
const mapStateToProps = (state, { conversationId }) => {
|
||||||
|
const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
|
||||||
|
const lastStatus = state.getIn(['statuses', conversation.get('last_status')], null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
|
||||||
|
lastStatus,
|
||||||
|
lastAccount: lastStatus === null ? null : state.getIn(['accounts', lastStatus.get('account')], null),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps)(Conversation);
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import ConversationsList from '../components/conversations_list';
|
||||||
|
import { expandConversations } from '../../../actions/conversations';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
conversationIds: state.getIn(['conversations', 'items']).map(x => x.get('id')),
|
||||||
|
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,23 +1,19 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import StatusListContainer from '../ui/containers/status_list_container';
|
|
||||||
import Column from '../../components/column';
|
import Column from '../../components/column';
|
||||||
import ColumnHeader from '../../components/column_header';
|
import ColumnHeader from '../../components/column_header';
|
||||||
import { expandDirectTimeline } from '../../actions/timelines';
|
import { expandConversations } from '../../actions/conversations';
|
||||||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
|
||||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
import { connectDirectStream } from '../../actions/streaming';
|
import { connectDirectStream } from '../../actions/streaming';
|
||||||
|
import ConversationsListContainer from './containers/conversations_list_container';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
export default @connect()
|
||||||
hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export default @connect(mapStateToProps)
|
|
||||||
@injectIntl
|
@injectIntl
|
||||||
class DirectTimeline extends React.PureComponent {
|
class DirectTimeline extends React.PureComponent {
|
||||||
|
|
||||||
|
@ -52,7 +48,7 @@ class DirectTimeline extends React.PureComponent {
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
const { dispatch } = this.props;
|
const { dispatch } = this.props;
|
||||||
|
|
||||||
dispatch(expandDirectTimeline());
|
dispatch(expandConversations());
|
||||||
this.disconnect = dispatch(connectDirectStream());
|
this.disconnect = dispatch(connectDirectStream());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,11 +64,11 @@ class DirectTimeline extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLoadMore = maxId => {
|
handleLoadMore = maxId => {
|
||||||
this.props.dispatch(expandDirectTimeline({ maxId }));
|
this.props.dispatch(expandConversations({ maxId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, shouldUpdateScroll, hasUnread, columnId, multiColumn } = this.props;
|
const { intl, hasUnread, columnId, multiColumn, shouldUpdateScroll } = this.props;
|
||||||
const pinned = !!columnId;
|
const pinned = !!columnId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -88,7 +84,7 @@ class DirectTimeline extends React.PureComponent {
|
||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatusListContainer
|
<ConversationsListContainer
|
||||||
trackScroll={!pinned}
|
trackScroll={!pinned}
|
||||||
scrollKey={`direct_timeline-${columnId}`}
|
scrollKey={`direct_timeline-${columnId}`}
|
||||||
timelineId='direct'
|
timelineId='direct'
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
|
import {
|
||||||
|
CONVERSATIONS_FETCH_REQUEST,
|
||||||
|
CONVERSATIONS_FETCH_SUCCESS,
|
||||||
|
CONVERSATIONS_FETCH_FAIL,
|
||||||
|
CONVERSATIONS_UPDATE,
|
||||||
|
} from '../actions/conversations';
|
||||||
|
import compareId from '../compare_id';
|
||||||
|
|
||||||
|
const initialState = ImmutableMap({
|
||||||
|
items: ImmutableList(),
|
||||||
|
isLoading: false,
|
||||||
|
hasMore: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversationToMap = item => ImmutableMap({
|
||||||
|
id: item.id,
|
||||||
|
accounts: ImmutableList(item.accounts.map(a => a.id)),
|
||||||
|
last_status: item.last_status.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateConversation = (state, item) => state.update('items', list => {
|
||||||
|
const index = list.findIndex(x => x.get('id') === item.id);
|
||||||
|
const newItem = conversationToMap(item);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return list.unshift(newItem);
|
||||||
|
} else {
|
||||||
|
return list.set(index, newItem);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const expandNormalizedConversations = (state, conversations, next) => {
|
||||||
|
let items = ImmutableList(conversations.map(conversationToMap));
|
||||||
|
|
||||||
|
return state.withMutations(mutable => {
|
||||||
|
if (!items.isEmpty()) {
|
||||||
|
mutable.update('items', list => {
|
||||||
|
list = list.map(oldItem => {
|
||||||
|
const newItemIndex = items.findIndex(x => x.get('id') === oldItem.get('id'));
|
||||||
|
|
||||||
|
if (newItemIndex === -1) {
|
||||||
|
return oldItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newItem = items.get(newItemIndex);
|
||||||
|
items = items.delete(newItemIndex);
|
||||||
|
|
||||||
|
return newItem;
|
||||||
|
});
|
||||||
|
|
||||||
|
list = list.concat(items);
|
||||||
|
|
||||||
|
return list.sortBy(x => x.get('last_status'), (a, b) => compareId(a, b) * -1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!next) {
|
||||||
|
mutable.set('hasMore', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
mutable.set('isLoading', false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function conversations(state = initialState, action) {
|
||||||
|
switch (action.type) {
|
||||||
|
case CONVERSATIONS_FETCH_REQUEST:
|
||||||
|
return state.set('isLoading', true);
|
||||||
|
case CONVERSATIONS_FETCH_FAIL:
|
||||||
|
return state.set('isLoading', false);
|
||||||
|
case CONVERSATIONS_FETCH_SUCCESS:
|
||||||
|
return expandNormalizedConversations(state, action.conversations, action.next);
|
||||||
|
case CONVERSATIONS_UPDATE:
|
||||||
|
return updateConversation(state, action.conversation);
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
|
@ -27,6 +27,7 @@ import custom_emojis from './custom_emojis';
|
||||||
import lists from './lists';
|
import lists from './lists';
|
||||||
import listEditor from './list_editor';
|
import listEditor from './list_editor';
|
||||||
import filters from './filters';
|
import filters from './filters';
|
||||||
|
import conversations from './conversations';
|
||||||
|
|
||||||
const reducers = {
|
const reducers = {
|
||||||
dropdown_menu,
|
dropdown_menu,
|
||||||
|
@ -57,6 +58,7 @@ const reducers = {
|
||||||
lists,
|
lists,
|
||||||
listEditor,
|
listEditor,
|
||||||
filters,
|
filters,
|
||||||
|
conversations,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default combineReducers(reducers);
|
export default combineReducers(reducers);
|
||||||
|
|
|
@ -69,7 +69,7 @@ const expandNormalizedNotifications = (state, notifications, next) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!next) {
|
if (!next) {
|
||||||
mutable.set('hasMore', true);
|
mutable.set('hasMore', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
mutable.set('isLoading', false);
|
mutable.set('isLoading', false);
|
||||||
|
|
|
@ -825,6 +825,7 @@
|
||||||
|
|
||||||
&.status-direct {
|
&.status-direct {
|
||||||
background: lighten($ui-base-color, 8%);
|
background: lighten($ui-base-color, 8%);
|
||||||
|
border-bottom-color: lighten($ui-base-color, 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.light {
|
&.light {
|
||||||
|
@ -5496,3 +5497,44 @@ noscript {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.conversation {
|
||||||
|
padding: 14px 10px;
|
||||||
|
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__avatars {
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: none;
|
||||||
|
width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account__avatar {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__time {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 14px;
|
||||||
|
color: $darker-text-color;
|
||||||
|
text-align: right;
|
||||||
|
|
||||||
|
.display-name {
|
||||||
|
color: $secondary-text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list.compact {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -13,6 +13,8 @@ class InlineRenderer
|
||||||
serializer = REST::StatusSerializer
|
serializer = REST::StatusSerializer
|
||||||
when :notification
|
when :notification
|
||||||
serializer = REST::NotificationSerializer
|
serializer = REST::NotificationSerializer
|
||||||
|
when :conversation
|
||||||
|
serializer = REST::ConversationSerializer
|
||||||
else
|
else
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: account_conversations
|
||||||
|
#
|
||||||
|
# id :bigint(8) not null, primary key
|
||||||
|
# account_id :bigint(8)
|
||||||
|
# conversation_id :bigint(8)
|
||||||
|
# participant_account_ids :bigint(8) default([]), not null, is an Array
|
||||||
|
# status_ids :bigint(8) default([]), not null, is an Array
|
||||||
|
# last_status_id :bigint(8)
|
||||||
|
# lock_version :integer default(0), not null
|
||||||
|
#
|
||||||
|
|
||||||
|
class AccountConversation < ApplicationRecord
|
||||||
|
after_commit :push_to_streaming_api
|
||||||
|
|
||||||
|
belongs_to :account
|
||||||
|
belongs_to :conversation
|
||||||
|
belongs_to :last_status, class_name: 'Status'
|
||||||
|
|
||||||
|
before_validation :set_last_status
|
||||||
|
|
||||||
|
def participant_account_ids=(arr)
|
||||||
|
self[:participant_account_ids] = arr.sort
|
||||||
|
end
|
||||||
|
|
||||||
|
def participant_accounts
|
||||||
|
if participant_account_ids.empty?
|
||||||
|
[account]
|
||||||
|
else
|
||||||
|
Account.where(id: participant_account_ids)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def paginate_by_id(limit, options = {})
|
||||||
|
if options[:min_id]
|
||||||
|
paginate_by_min_id(limit, options[:min_id]).reverse
|
||||||
|
else
|
||||||
|
paginate_by_max_id(limit, options[:max_id], options[:since_id])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_by_min_id(limit, min_id = nil)
|
||||||
|
query = order(arel_table[:last_status_id].asc).limit(limit)
|
||||||
|
query = query.where(arel_table[:last_status_id].gt(min_id)) if min_id.present?
|
||||||
|
query
|
||||||
|
end
|
||||||
|
|
||||||
|
def paginate_by_max_id(limit, max_id = nil, since_id = nil)
|
||||||
|
query = order(arel_table[:last_status_id].desc).limit(limit)
|
||||||
|
query = query.where(arel_table[:last_status_id].lt(max_id)) if max_id.present?
|
||||||
|
query = query.where(arel_table[:last_status_id].gt(since_id)) if since_id.present?
|
||||||
|
query
|
||||||
|
end
|
||||||
|
|
||||||
|
def add_status(recipient, status)
|
||||||
|
conversation = find_or_initialize_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status))
|
||||||
|
conversation.status_ids << status.id
|
||||||
|
conversation.save
|
||||||
|
conversation
|
||||||
|
rescue ActiveRecord::StaleObjectError
|
||||||
|
retry
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_status(recipient, status)
|
||||||
|
conversation = find_by(account: recipient, conversation_id: status.conversation_id, participant_account_ids: participants_from_status(recipient, status))
|
||||||
|
|
||||||
|
return if conversation.nil?
|
||||||
|
|
||||||
|
conversation.status_ids.delete(status.id)
|
||||||
|
|
||||||
|
if conversation.status_ids.empty?
|
||||||
|
conversation.destroy
|
||||||
|
else
|
||||||
|
conversation.save
|
||||||
|
end
|
||||||
|
|
||||||
|
conversation
|
||||||
|
rescue ActiveRecord::StaleObjectError
|
||||||
|
retry
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def participants_from_status(recipient, status)
|
||||||
|
((status.mentions.pluck(:account_id) + [status.account_id]).uniq - [recipient.id]).sort
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_last_status
|
||||||
|
self.status_ids = status_ids.sort
|
||||||
|
self.last_status_id = status_ids.last
|
||||||
|
end
|
||||||
|
|
||||||
|
def push_to_streaming_api
|
||||||
|
return if destroyed? || !subscribed_to_timeline?
|
||||||
|
PushConversationWorker.perform_async(id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def subscribed_to_timeline?
|
||||||
|
Redis.current.exists("subscribed:#{streaming_channel}")
|
||||||
|
end
|
||||||
|
|
||||||
|
def streaming_channel
|
||||||
|
"timeline:direct:#{account_id}"
|
||||||
|
end
|
||||||
|
end
|
|
@ -26,7 +26,7 @@ module Omniauthable
|
||||||
# to prevent the identity being locked with accidentally created accounts.
|
# to prevent the identity being locked with accidentally created accounts.
|
||||||
# Note that this may leave zombie accounts (with no associated identity) which
|
# Note that this may leave zombie accounts (with no associated identity) which
|
||||||
# can be cleaned up at a later date.
|
# can be cleaned up at a later date.
|
||||||
user = signed_in_resource ? signed_in_resource : identity.user
|
user = signed_in_resource || identity.user
|
||||||
user = create_for_oauth(auth) if user.nil?
|
user = create_for_oauth(auth) if user.nil?
|
||||||
|
|
||||||
if identity.user.nil?
|
if identity.user.nil?
|
||||||
|
@ -61,7 +61,7 @@ module Omniauthable
|
||||||
display_name = auth.info.full_name || [auth.info.first_name, auth.info.last_name].join(' ')
|
display_name = auth.info.full_name || [auth.info.first_name, auth.info.last_name].join(' ')
|
||||||
|
|
||||||
{
|
{
|
||||||
email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
|
email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
|
||||||
password: Devise.friendly_token[0, 20],
|
password: Devise.friendly_token[0, 20],
|
||||||
account_attributes: {
|
account_attributes: {
|
||||||
username: ensure_unique_username(auth.uid),
|
username: ensure_unique_username(auth.uid),
|
||||||
|
|
|
@ -26,6 +26,8 @@
|
||||||
#
|
#
|
||||||
|
|
||||||
class Status < ApplicationRecord
|
class Status < ApplicationRecord
|
||||||
|
before_destroy :unlink_from_conversations
|
||||||
|
|
||||||
include Paginable
|
include Paginable
|
||||||
include Streamable
|
include Streamable
|
||||||
include Cacheable
|
include Cacheable
|
||||||
|
@ -499,4 +501,15 @@ class Status < ApplicationRecord
|
||||||
reblog&.decrement_count!(:reblogs_count) if reblog?
|
reblog&.decrement_count!(:reblogs_count) if reblog?
|
||||||
thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
|
thread&.decrement_count!(:replies_count) if in_reply_to_id.present? && (public_visibility? || unlisted_visibility?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def unlink_from_conversations
|
||||||
|
return unless direct_visibility?
|
||||||
|
|
||||||
|
mentioned_accounts = mentions.includes(:account).map(&:account)
|
||||||
|
inbox_owners = mentioned_accounts.select(&:local?) + (account.local? ? [account] : [])
|
||||||
|
|
||||||
|
inbox_owners.each do |inbox_owner|
|
||||||
|
AccountConversation.remove_status(inbox_owner, self)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -53,4 +53,8 @@ class InstancePresenter
|
||||||
def hero
|
def hero
|
||||||
@hero ||= Rails.cache.fetch('site_uploads/hero') { SiteUpload.find_by(var: 'hero') }
|
@hero ||= Rails.cache.fetch('site_uploads/hero') { SiteUpload.find_by(var: 'hero') }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def mascot
|
||||||
|
@mascot ||= Rails.cache.fetch('site_uploads/mascot') { SiteUpload.find_by(var: 'mascot') }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class REST::ConversationSerializer < ActiveModel::Serializer
|
||||||
|
attribute :id
|
||||||
|
has_many :participant_accounts, key: :accounts, serializer: REST::AccountSerializer
|
||||||
|
has_one :last_status, serializer: REST::StatusSerializer
|
||||||
|
end
|
|
@ -2,16 +2,43 @@
|
||||||
|
|
||||||
class AfterBlockService < BaseService
|
class AfterBlockService < BaseService
|
||||||
def call(account, target_account)
|
def call(account, target_account)
|
||||||
FeedManager.instance.clear_from_timeline(account, target_account)
|
clear_home_feed(account, target_account)
|
||||||
clear_notifications(account, target_account)
|
clear_notifications(account, target_account)
|
||||||
|
clear_conversations(account, target_account)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def clear_home_feed(account, target_account)
|
||||||
|
FeedManager.instance.clear_from_timeline(account, target_account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_conversations(account, target_account)
|
||||||
|
AccountConversation.where(account: account)
|
||||||
|
.where('? = ANY(participant_account_ids)', target_account.id)
|
||||||
|
.in_batches
|
||||||
|
.destroy_all
|
||||||
|
end
|
||||||
|
|
||||||
def clear_notifications(account, target_account)
|
def clear_notifications(account, target_account)
|
||||||
Notification.where(account: account).joins(:follow).where(activity_type: 'Follow', follows: { account_id: target_account.id }).delete_all
|
Notification.where(account: account)
|
||||||
Notification.where(account: account).joins(mention: :status).where(activity_type: 'Mention', statuses: { account_id: target_account.id }).delete_all
|
.joins(:follow)
|
||||||
Notification.where(account: account).joins(:favourite).where(activity_type: 'Favourite', favourites: { account_id: target_account.id }).delete_all
|
.where(activity_type: 'Follow', follows: { account_id: target_account.id })
|
||||||
Notification.where(account: account).joins(:status).where(activity_type: 'Status', statuses: { account_id: target_account.id }).delete_all
|
.delete_all
|
||||||
|
|
||||||
|
Notification.where(account: account)
|
||||||
|
.joins(mention: :status)
|
||||||
|
.where(activity_type: 'Mention', statuses: { account_id: target_account.id })
|
||||||
|
.delete_all
|
||||||
|
|
||||||
|
Notification.where(account: account)
|
||||||
|
.joins(:favourite)
|
||||||
|
.where(activity_type: 'Favourite', favourites: { account_id: target_account.id })
|
||||||
|
.delete_all
|
||||||
|
|
||||||
|
Notification.where(account: account)
|
||||||
|
.joins(:status)
|
||||||
|
.where(activity_type: 'Status', statuses: { account_id: target_account.id })
|
||||||
|
.delete_all
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,6 +13,7 @@ class FanOutOnWriteService < BaseService
|
||||||
if status.direct_visibility?
|
if status.direct_visibility?
|
||||||
deliver_to_mentioned_followers(status)
|
deliver_to_mentioned_followers(status)
|
||||||
deliver_to_direct_timelines(status)
|
deliver_to_direct_timelines(status)
|
||||||
|
deliver_to_own_conversation(status)
|
||||||
else
|
else
|
||||||
deliver_to_followers(status)
|
deliver_to_followers(status)
|
||||||
deliver_to_lists(status)
|
deliver_to_lists(status)
|
||||||
|
@ -99,6 +100,11 @@ class FanOutOnWriteService < BaseService
|
||||||
status.mentions.includes(:account).each do |mention|
|
status.mentions.includes(:account).each do |mention|
|
||||||
Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local?
|
Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local?
|
||||||
end
|
end
|
||||||
|
|
||||||
Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local?
|
Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def deliver_to_own_conversation(status)
|
||||||
|
AccountConversation.add_status(status.account, status)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,11 +5,13 @@ class MuteService < BaseService
|
||||||
return if account.id == target_account.id
|
return if account.id == target_account.id
|
||||||
|
|
||||||
mute = account.mute!(target_account, notifications: notifications)
|
mute = account.mute!(target_account, notifications: notifications)
|
||||||
|
|
||||||
if mute.hide_notifications?
|
if mute.hide_notifications?
|
||||||
BlockWorker.perform_async(account.id, target_account.id)
|
BlockWorker.perform_async(account.id, target_account.id)
|
||||||
else
|
else
|
||||||
FeedManager.instance.clear_from_timeline(account, target_account)
|
MuteWorker.perform_async(account.id, target_account.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
mute
|
mute
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,9 +8,10 @@ class NotifyService < BaseService
|
||||||
|
|
||||||
return if recipient.user.nil? || blocked?
|
return if recipient.user.nil? || blocked?
|
||||||
|
|
||||||
create_notification
|
create_notification!
|
||||||
push_notification if @notification.browserable?
|
push_notification! if @notification.browserable?
|
||||||
send_email if email_enabled?
|
push_to_conversation! if direct_message?
|
||||||
|
send_email! if email_enabled?
|
||||||
rescue ActiveRecord::RecordInvalid
|
rescue ActiveRecord::RecordInvalid
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
@ -100,18 +101,23 @@ class NotifyService < BaseService
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_notification
|
def create_notification!
|
||||||
@notification.save!
|
@notification.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
def push_notification
|
def push_notification!
|
||||||
return if @notification.activity.nil?
|
return if @notification.activity.nil?
|
||||||
|
|
||||||
Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification)))
|
Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification)))
|
||||||
send_push_notifications
|
send_push_notifications!
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_push_notifications
|
def push_to_conversation!
|
||||||
|
return if @notification.activity.nil?
|
||||||
|
AccountConversation.add_status(@recipient, @notification.target_status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_push_notifications!
|
||||||
subscriptions_ids = ::Web::PushSubscription.where(user_id: @recipient.user.id)
|
subscriptions_ids = ::Web::PushSubscription.where(user_id: @recipient.user.id)
|
||||||
.select { |subscription| subscription.pushable?(@notification) }
|
.select { |subscription| subscription.pushable?(@notification) }
|
||||||
.map(&:id)
|
.map(&:id)
|
||||||
|
@ -121,7 +127,7 @@ class NotifyService < BaseService
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def send_email
|
def send_email!
|
||||||
return if @notification.activity.nil?
|
return if @notification.activity.nil?
|
||||||
NotificationMailer.public_send(@notification.type, @recipient, @notification).deliver_later(wait: 2.minutes)
|
NotificationMailer.public_send(@notification.type, @recipient, @notification).deliver_later(wait: 2.minutes)
|
||||||
end
|
end
|
||||||
|
|
|
@ -24,7 +24,7 @@
|
||||||
%span= t 'about.status_count_after', count: @instance_presenter.status_count
|
%span= t 'about.status_count_after', count: @instance_presenter.status_count
|
||||||
.row__mascot
|
.row__mascot
|
||||||
.landing-page__mascot
|
.landing-page__mascot
|
||||||
= image_tag asset_pack_path('elephant_ui_plane.svg'), alt: ''
|
= image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: ''
|
||||||
|
|
||||||
.column-2
|
.column-2
|
||||||
.landing-page__information.contact-widget
|
.landing-page__information.contact-widget
|
||||||
|
|
|
@ -62,7 +62,7 @@
|
||||||
%span= t 'about.status_count_after', count: @instance_presenter.status_count
|
%span= t 'about.status_count_after', count: @instance_presenter.status_count
|
||||||
.row__mascot
|
.row__mascot
|
||||||
.landing-page__mascot
|
.landing-page__mascot
|
||||||
= image_tag asset_pack_path('elephant_ui_plane.svg'), alt: ''
|
= image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: ''
|
||||||
|
|
||||||
- else
|
- else
|
||||||
.column-2.non-preview
|
.column-2.non-preview
|
||||||
|
@ -94,7 +94,7 @@
|
||||||
%span= t 'about.status_count_after', count: @instance_presenter.status_count
|
%span= t 'about.status_count_after', count: @instance_presenter.status_count
|
||||||
.row__mascot
|
.row__mascot
|
||||||
.landing-page__mascot
|
.landing-page__mascot
|
||||||
= image_tag asset_pack_path('elephant_ui_plane.svg'), alt: ''
|
= image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: ''
|
||||||
|
|
||||||
- if Setting.timeline_preview
|
- if Setting.timeline_preview
|
||||||
.column-3
|
.column-3
|
||||||
|
|
|
@ -26,6 +26,8 @@
|
||||||
= f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html')
|
= f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html')
|
||||||
.fields-row__column.fields-row__column-6.fields-group
|
.fields-row__column.fields-row__column-6.fields-group
|
||||||
= f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html')
|
= f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html')
|
||||||
|
.fields-row__column.fields-row__column-6.fields-group
|
||||||
|
= f.input :mascot, as: :file, wrapper: :with_block_label, label: t('admin.settings.mascot.title'), hint: t('admin.settings.mascot.desc_html')
|
||||||
|
|
||||||
%hr.spacer/
|
%hr.spacer/
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,9 @@ class BlockWorker
|
||||||
include Sidekiq::Worker
|
include Sidekiq::Worker
|
||||||
|
|
||||||
def perform(account_id, target_account_id)
|
def perform(account_id, target_account_id)
|
||||||
AfterBlockService.new.call(Account.find(account_id), Account.find(target_account_id))
|
AfterBlockService.new.call(
|
||||||
|
Account.find(account_id),
|
||||||
|
Account.find(target_account_id)
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class MuteWorker
|
||||||
|
include Sidekiq::Worker
|
||||||
|
|
||||||
|
def perform(account_id, target_account_id)
|
||||||
|
FeedManager.instance.clear_from_timeline(
|
||||||
|
Account.find(account_id),
|
||||||
|
Account.find(target_account_id)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,15 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class PushConversationWorker
|
||||||
|
include Sidekiq::Worker
|
||||||
|
|
||||||
|
def perform(conversation_account_id)
|
||||||
|
conversation = AccountConversation.find(conversation_account_id)
|
||||||
|
message = InlineRenderer.render(conversation, conversation.account, :conversation)
|
||||||
|
timeline_id = "timeline:direct:#{conversation.account_id}"
|
||||||
|
|
||||||
|
Redis.current.publish(timeline_id, Oj.dump(event: :conversation, payload: message, queued_at: (Time.now.to_f * 1000.0).to_i))
|
||||||
|
rescue ActiveRecord::RecordNotFound
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
|
@ -87,7 +87,7 @@ Rails.application.configure do
|
||||||
config.x.otp_secret = ENV.fetch('OTP_SECRET', '1fc2b87989afa6351912abeebe31ffc5c476ead9bf8b3d74cbc4a302c7b69a45b40b1bbef3506ddad73e942e15ed5ca4b402bf9a66423626051104f4b5f05109')
|
config.x.otp_secret = ENV.fetch('OTP_SECRET', '1fc2b87989afa6351912abeebe31ffc5c476ead9bf8b3d74cbc4a302c7b69a45b40b1bbef3506ddad73e942e15ed5ca4b402bf9a66423626051104f4b5f05109')
|
||||||
end
|
end
|
||||||
|
|
||||||
ActiveRecordQueryTrace.enabled = ENV.fetch('QUERY_TRACE_ENABLED') { false }
|
ActiveRecordQueryTrace.enabled = ENV['QUERY_TRACE_ENABLED'] == 'true'
|
||||||
|
|
||||||
module PrivateAddressCheck
|
module PrivateAddressCheck
|
||||||
def self.private_address?(*)
|
def self.private_address?(*)
|
||||||
|
|
|
@ -2,7 +2,7 @@ require 'open-uri'
|
||||||
|
|
||||||
module OpenURI
|
module OpenURI
|
||||||
def self.redirectable?(uri1, uri2) # :nodoc:
|
def self.redirectable?(uri1, uri2) # :nodoc:
|
||||||
uri1.scheme.downcase == uri2.scheme.downcase ||
|
uri1.scheme.casecmp(uri2.scheme).zero? ||
|
||||||
(/\A(?:http|https|ftp)\z/i =~ uri1.scheme && /\A(?:http|https|ftp)\z/i =~ uri2.scheme)
|
(/\A(?:http|https|ftp)\z/i =~ uri1.scheme && /\A(?:http|https|ftp)\z/i =~ uri2.scheme)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -368,6 +368,9 @@ en:
|
||||||
hero:
|
hero:
|
||||||
desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to instance thumbnail
|
desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to instance thumbnail
|
||||||
title: Hero image
|
title: Hero image
|
||||||
|
mascot:
|
||||||
|
desc_html: Displayed on multiple pages. At least 293px × 205px recommended. When not set, falls back to instance thumbnail
|
||||||
|
title: Mascot image
|
||||||
peers_api_enabled:
|
peers_api_enabled:
|
||||||
desc_html: Domain names this instance has encountered in the fediverse
|
desc_html: Domain names this instance has encountered in the fediverse
|
||||||
title: Publish list of discovered instances
|
title: Publish list of discovered instances
|
||||||
|
|
|
@ -267,6 +267,7 @@ Rails.application.routes.draw do
|
||||||
resources :streaming, only: [:index]
|
resources :streaming, only: [:index]
|
||||||
resources :custom_emojis, only: [:index]
|
resources :custom_emojis, only: [:index]
|
||||||
resources :suggestions, only: [:index, :destroy]
|
resources :suggestions, only: [:index, :destroy]
|
||||||
|
resources :conversations, only: [:index]
|
||||||
|
|
||||||
get '/search', to: 'search#index', as: :search
|
get '/search', to: 'search#index', as: :search
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,14 @@
|
||||||
|
class CreateAccountConversations < ActiveRecord::Migration[5.2]
|
||||||
|
def change
|
||||||
|
create_table :account_conversations do |t|
|
||||||
|
t.belongs_to :account, foreign_key: { on_delete: :cascade }
|
||||||
|
t.belongs_to :conversation, foreign_key: { on_delete: :cascade }
|
||||||
|
t.bigint :participant_account_ids, array: true, null: false, default: []
|
||||||
|
t.bigint :status_ids, array: true, null: false, default: []
|
||||||
|
t.bigint :last_status_id, null: true, default: nil
|
||||||
|
t.integer :lock_version, null: false, default: 0
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :account_conversations, [:account_id, :conversation_id, :participant_account_ids], unique: true, name: 'index_unique_conversations'
|
||||||
|
end
|
||||||
|
end
|
17
db/schema.rb
17
db/schema.rb
|
@ -10,10 +10,23 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2018_08_20_232245) do
|
ActiveRecord::Schema.define(version: 2018_09_29_222014) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
|
||||||
|
create_table "account_conversations", force: :cascade do |t|
|
||||||
|
t.bigint "account_id"
|
||||||
|
t.bigint "conversation_id"
|
||||||
|
t.bigint "participant_account_ids", default: [], null: false, array: true
|
||||||
|
t.bigint "status_ids", default: [], null: false, array: true
|
||||||
|
t.bigint "last_status_id"
|
||||||
|
t.integer "lock_version", default: 0, null: false
|
||||||
|
t.index ["account_id", "conversation_id", "participant_account_ids"], name: "index_unique_conversations", unique: true
|
||||||
|
t.index ["account_id"], name: "index_account_conversations_on_account_id"
|
||||||
|
t.index ["conversation_id"], name: "index_account_conversations_on_conversation_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "account_domain_blocks", force: :cascade do |t|
|
create_table "account_domain_blocks", force: :cascade do |t|
|
||||||
t.string "domain"
|
t.string "domain"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
|
@ -608,6 +621,8 @@ ActiveRecord::Schema.define(version: 2018_08_20_232245) do
|
||||||
t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true
|
t.index ["user_id"], name: "index_web_settings_on_user_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
add_foreign_key "account_conversations", "accounts", on_delete: :cascade
|
||||||
|
add_foreign_key "account_conversations", "conversations", on_delete: :cascade
|
||||||
add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade
|
add_foreign_key "account_domain_blocks", "accounts", name: "fk_206c6029bd", on_delete: :cascade
|
||||||
add_foreign_key "account_moderation_notes", "accounts"
|
add_foreign_key "account_moderation_notes", "accounts"
|
||||||
add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id"
|
add_foreign_key "account_moderation_notes", "accounts", column: "target_account_id"
|
||||||
|
|
|
@ -342,8 +342,8 @@ module Mastodon
|
||||||
|
|
||||||
say "Migrating #{table_name}.#{column} (~#{total.to_i} rows)"
|
say "Migrating #{table_name}.#{column} (~#{total.to_i} rows)"
|
||||||
|
|
||||||
started_time = Time.now
|
started_time = Time.zone.now
|
||||||
last_time = Time.now
|
last_time = Time.zone.now
|
||||||
migrated = 0
|
migrated = 0
|
||||||
loop do
|
loop do
|
||||||
stop_row = nil
|
stop_row = nil
|
||||||
|
@ -375,13 +375,13 @@ module Mastodon
|
||||||
end
|
end
|
||||||
|
|
||||||
migrated += batch_size
|
migrated += batch_size
|
||||||
if Time.now - last_time > 1
|
if Time.zone.now - last_time > 1
|
||||||
status = "Migrated #{migrated} rows"
|
status = "Migrated #{migrated} rows"
|
||||||
|
|
||||||
percentage = 100.0 * migrated / total
|
percentage = 100.0 * migrated / total
|
||||||
status += " (~#{sprintf('%.2f', percentage)}%, "
|
status += " (~#{sprintf('%.2f', percentage)}%, "
|
||||||
|
|
||||||
remaining_time = (100.0 - percentage) * (Time.now - started_time) / percentage
|
remaining_time = (100.0 - percentage) * (Time.zone.now - started_time) / percentage
|
||||||
|
|
||||||
status += "#{(remaining_time / 60).to_i}:"
|
status += "#{(remaining_time / 60).to_i}:"
|
||||||
status += sprintf('%02d', remaining_time.to_i % 60)
|
status += sprintf('%02d', remaining_time.to_i % 60)
|
||||||
|
@ -397,7 +397,7 @@ module Mastodon
|
||||||
status += ')'
|
status += ')'
|
||||||
|
|
||||||
say status, true
|
say status, true
|
||||||
last_time = Time.now
|
last_time = Time.zone.now
|
||||||
end
|
end
|
||||||
|
|
||||||
# There are no more rows left to update.
|
# There are no more rows left to update.
|
||||||
|
|
|
@ -15,7 +15,7 @@ RSpec.describe Api::SalmonController, type: :controller do
|
||||||
describe 'POST #update' do
|
describe 'POST #update' do
|
||||||
context 'with valid post data' do
|
context 'with valid post data' do
|
||||||
before do
|
before do
|
||||||
post :update, params: { id: account.id }, body: File.read(File.join(Rails.root, 'spec', 'fixtures', 'salmon', 'mention.xml'))
|
post :update, params: { id: account.id }, body: File.read(Rails.root.join('spec', 'fixtures', 'salmon', 'mention.xml'))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'contains XML in the request body' do
|
it 'contains XML in the request body' do
|
||||||
|
@ -54,7 +54,7 @@ RSpec.describe Api::SalmonController, type: :controller do
|
||||||
service = double(call: false)
|
service = double(call: false)
|
||||||
allow(VerifySalmonService).to receive(:new).and_return(service)
|
allow(VerifySalmonService).to receive(:new).and_return(service)
|
||||||
|
|
||||||
post :update, params: { id: account.id }, body: File.read(File.join(Rails.root, 'spec', 'fixtures', 'salmon', 'mention.xml'))
|
post :update, params: { id: account.id }, body: File.read(Rails.root.join('spec', 'fixtures', 'salmon', 'mention.xml'))
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns http client error' do
|
it 'returns http client error' do
|
||||||
|
|
|
@ -33,7 +33,7 @@ RSpec.describe Api::SubscriptionsController, type: :controller do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST #update' do
|
describe 'POST #update' do
|
||||||
let(:feed) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom')) }
|
let(:feed) { File.read(Rails.root.join('spec', 'fixtures', 'push', 'feed.atom')) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
stub_request(:post, "https://quitter.no/main/push/hub").to_return(:status => 200, :body => "", :headers => {})
|
stub_request(:post, "https://quitter.no/main/push/hub").to_return(:status => 200, :body => "", :headers => {})
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Api::V1::ConversationsController, type: :controller do
|
||||||
|
render_views
|
||||||
|
|
||||||
|
let!(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) }
|
||||||
|
let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, scopes: scopes) }
|
||||||
|
let(:other) { Fabricate(:user, account: Fabricate(:account, username: 'bob')) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(controller).to receive(:doorkeeper_token) { token }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET #index' do
|
||||||
|
let(:scopes) { 'read:statuses' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
PostStatusService.new.call(other.account, 'Hey @alice', nil, visibility: 'direct')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns http success' do
|
||||||
|
get :index
|
||||||
|
expect(response).to have_http_status(200)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns pagination headers' do
|
||||||
|
get :index, params: { limit: 1 }
|
||||||
|
expect(response.headers['Link'].links.size).to eq(2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns conversations' do
|
||||||
|
get :index
|
||||||
|
json = body_as_json
|
||||||
|
expect(json.size).to eq 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,6 @@
|
||||||
|
Fabricator(:conversation_account) do
|
||||||
|
account nil
|
||||||
|
conversation nil
|
||||||
|
participant_account_ids ""
|
||||||
|
last_status nil
|
||||||
|
end
|
|
@ -2,5 +2,5 @@ Fabricator(:user) do
|
||||||
account
|
account
|
||||||
email { sequence(:email) { |i| "#{i}#{Faker::Internet.email}" } }
|
email { sequence(:email) { |i| "#{i}#{Faker::Internet.email}" } }
|
||||||
password "123456789"
|
password "123456789"
|
||||||
confirmed_at { Time.now }
|
confirmed_at { Time.zone.now }
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,7 +3,7 @@ require "rails_helper"
|
||||||
feature "Log in" do
|
feature "Log in" do
|
||||||
given(:email) { "test@examle.com" }
|
given(:email) { "test@examle.com" }
|
||||||
given(:password) { "password" }
|
given(:password) { "password" }
|
||||||
given(:confirmed_at) { Time.now }
|
given(:confirmed_at) { Time.zone.now }
|
||||||
|
|
||||||
background do
|
background do
|
||||||
Fabricate(:user, email: email, password: password, confirmed_at: confirmed_at)
|
Fabricate(:user, email: email, password: password, confirmed_at: confirmed_at)
|
||||||
|
|
|
@ -728,9 +728,9 @@ RSpec.describe OStatus::AtomSerializer do
|
||||||
it 'appends id element with unique tag' do
|
it 'appends id element with unique tag' do
|
||||||
block = Fabricate(:block)
|
block = Fabricate(:block)
|
||||||
|
|
||||||
time_before = Time.now
|
time_before = Time.zone.now
|
||||||
block_salmon = OStatus::AtomSerializer.new.block_salmon(block)
|
block_salmon = OStatus::AtomSerializer.new.block_salmon(block)
|
||||||
time_after = Time.now
|
time_after = Time.zone.now
|
||||||
|
|
||||||
expect(block_salmon.id.text).to(
|
expect(block_salmon.id.text).to(
|
||||||
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, block.id, 'Block'))
|
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, block.id, 'Block'))
|
||||||
|
@ -815,9 +815,9 @@ RSpec.describe OStatus::AtomSerializer do
|
||||||
it 'appends id element with unique tag' do
|
it 'appends id element with unique tag' do
|
||||||
block = Fabricate(:block)
|
block = Fabricate(:block)
|
||||||
|
|
||||||
time_before = Time.now
|
time_before = Time.zone.now
|
||||||
unblock_salmon = OStatus::AtomSerializer.new.unblock_salmon(block)
|
unblock_salmon = OStatus::AtomSerializer.new.unblock_salmon(block)
|
||||||
time_after = Time.now
|
time_after = Time.zone.now
|
||||||
|
|
||||||
expect(unblock_salmon.id.text).to(
|
expect(unblock_salmon.id.text).to(
|
||||||
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, block.id, 'Block'))
|
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, block.id, 'Block'))
|
||||||
|
@ -994,9 +994,9 @@ RSpec.describe OStatus::AtomSerializer do
|
||||||
it 'appends id element with unique tag' do
|
it 'appends id element with unique tag' do
|
||||||
favourite = Fabricate(:favourite)
|
favourite = Fabricate(:favourite)
|
||||||
|
|
||||||
time_before = Time.now
|
time_before = Time.zone.now
|
||||||
unfavourite_salmon = OStatus::AtomSerializer.new.unfavourite_salmon(favourite)
|
unfavourite_salmon = OStatus::AtomSerializer.new.unfavourite_salmon(favourite)
|
||||||
time_after = Time.now
|
time_after = Time.zone.now
|
||||||
|
|
||||||
expect(unfavourite_salmon.id.text).to(
|
expect(unfavourite_salmon.id.text).to(
|
||||||
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, favourite.id, 'Favourite'))
|
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, favourite.id, 'Favourite'))
|
||||||
|
@ -1179,9 +1179,9 @@ RSpec.describe OStatus::AtomSerializer do
|
||||||
follow = Fabricate(:follow)
|
follow = Fabricate(:follow)
|
||||||
follow.destroy!
|
follow.destroy!
|
||||||
|
|
||||||
time_before = Time.now
|
time_before = Time.zone.now
|
||||||
unfollow_salmon = OStatus::AtomSerializer.new.unfollow_salmon(follow)
|
unfollow_salmon = OStatus::AtomSerializer.new.unfollow_salmon(follow)
|
||||||
time_after = Time.now
|
time_after = Time.zone.now
|
||||||
|
|
||||||
expect(unfollow_salmon.id.text).to(
|
expect(unfollow_salmon.id.text).to(
|
||||||
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow.id, 'Follow'))
|
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow.id, 'Follow'))
|
||||||
|
@ -1327,9 +1327,9 @@ RSpec.describe OStatus::AtomSerializer do
|
||||||
it 'appends id element with unique tag' do
|
it 'appends id element with unique tag' do
|
||||||
follow_request = Fabricate(:follow_request)
|
follow_request = Fabricate(:follow_request)
|
||||||
|
|
||||||
time_before = Time.now
|
time_before = Time.zone.now
|
||||||
authorize_follow_request_salmon = OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request)
|
authorize_follow_request_salmon = OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request)
|
||||||
time_after = Time.now
|
time_after = Time.zone.now
|
||||||
|
|
||||||
expect(authorize_follow_request_salmon.id.text).to(
|
expect(authorize_follow_request_salmon.id.text).to(
|
||||||
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow_request.id, 'FollowRequest'))
|
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow_request.id, 'FollowRequest'))
|
||||||
|
@ -1396,9 +1396,9 @@ RSpec.describe OStatus::AtomSerializer do
|
||||||
it 'appends id element with unique tag' do
|
it 'appends id element with unique tag' do
|
||||||
follow_request = Fabricate(:follow_request)
|
follow_request = Fabricate(:follow_request)
|
||||||
|
|
||||||
time_before = Time.now
|
time_before = Time.zone.now
|
||||||
reject_follow_request_salmon = OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request)
|
reject_follow_request_salmon = OStatus::AtomSerializer.new.reject_follow_request_salmon(follow_request)
|
||||||
time_after = Time.now
|
time_after = Time.zone.now
|
||||||
|
|
||||||
expect(reject_follow_request_salmon.id.text).to(
|
expect(reject_follow_request_salmon.id.text).to(
|
||||||
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow_request.id, 'FollowRequest'))
|
eq(OStatus::TagManager.instance.unique_tag(time_before.utc, follow_request.id, 'FollowRequest'))
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe AccountConversation, type: :model do
|
||||||
|
let!(:alice) { Fabricate(:account, username: 'alice') }
|
||||||
|
let!(:bob) { Fabricate(:account, username: 'bob') }
|
||||||
|
let!(:mark) { Fabricate(:account, username: 'mark') }
|
||||||
|
|
||||||
|
describe '.add_status' do
|
||||||
|
it 'creates new record when no others exist' do
|
||||||
|
status = Fabricate(:status, account: alice, visibility: :direct)
|
||||||
|
status.mentions.create(account: bob)
|
||||||
|
|
||||||
|
conversation = AccountConversation.add_status(alice, status)
|
||||||
|
|
||||||
|
expect(conversation.participant_accounts).to include(bob)
|
||||||
|
expect(conversation.last_status).to eq status
|
||||||
|
expect(conversation.status_ids).to eq [status.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'appends to old record when there is a match' do
|
||||||
|
last_status = Fabricate(:status, account: alice, visibility: :direct)
|
||||||
|
conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id])
|
||||||
|
|
||||||
|
status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status)
|
||||||
|
status.mentions.create(account: alice)
|
||||||
|
|
||||||
|
new_conversation = AccountConversation.add_status(alice, status)
|
||||||
|
|
||||||
|
expect(new_conversation.id).to eq conversation.id
|
||||||
|
expect(new_conversation.participant_accounts).to include(bob)
|
||||||
|
expect(new_conversation.last_status).to eq status
|
||||||
|
expect(new_conversation.status_ids).to eq [last_status.id, status.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates new record when new participants are added' do
|
||||||
|
last_status = Fabricate(:status, account: alice, visibility: :direct)
|
||||||
|
conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id])
|
||||||
|
|
||||||
|
status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status)
|
||||||
|
status.mentions.create(account: alice)
|
||||||
|
status.mentions.create(account: mark)
|
||||||
|
|
||||||
|
new_conversation = AccountConversation.add_status(alice, status)
|
||||||
|
|
||||||
|
expect(new_conversation.id).to_not eq conversation.id
|
||||||
|
expect(new_conversation.participant_accounts).to include(bob, mark)
|
||||||
|
expect(new_conversation.last_status).to eq status
|
||||||
|
expect(new_conversation.status_ids).to eq [status.id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.remove_status' do
|
||||||
|
it 'updates last status to a previous value' do
|
||||||
|
last_status = Fabricate(:status, account: alice, visibility: :direct)
|
||||||
|
status = Fabricate(:status, account: alice, visibility: :direct)
|
||||||
|
conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [status.id, last_status.id])
|
||||||
|
last_status.mentions.create(account: bob)
|
||||||
|
last_status.destroy!
|
||||||
|
conversation.reload
|
||||||
|
expect(conversation.last_status).to eq status
|
||||||
|
expect(conversation.status_ids).to eq [status.id]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'removes the record if no other statuses are referenced' do
|
||||||
|
last_status = Fabricate(:status, account: alice, visibility: :direct)
|
||||||
|
conversation = AccountConversation.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id])
|
||||||
|
last_status.mentions.create(account: bob)
|
||||||
|
last_status.destroy!
|
||||||
|
expect(AccountConversation.where(id: conversation.id).count).to eq 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -154,7 +154,7 @@ RSpec.describe Status, type: :model do
|
||||||
|
|
||||||
describe '#target' do
|
describe '#target' do
|
||||||
it 'returns nil if the status is self-contained' do
|
it 'returns nil if the status is self-contained' do
|
||||||
expect(subject.target).to be_nil
|
expect(subject.target).to be_nil
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns nil if the status is a reply' do
|
it 'returns nil if the status is a reply' do
|
||||||
|
|
|
@ -67,7 +67,7 @@ RSpec.describe User, type: :model do
|
||||||
describe 'confirmed' do
|
describe 'confirmed' do
|
||||||
it 'returns an array of users who are confirmed' do
|
it 'returns an array of users who are confirmed' do
|
||||||
user_1 = Fabricate(:user, confirmed_at: nil)
|
user_1 = Fabricate(:user, confirmed_at: nil)
|
||||||
user_2 = Fabricate(:user, confirmed_at: Time.now)
|
user_2 = Fabricate(:user, confirmed_at: Time.zone.now)
|
||||||
expect(User.confirmed).to match_array([user_2])
|
expect(User.confirmed).to match_array([user_2])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -72,11 +72,11 @@ RSpec::Sidekiq.configure do |config|
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_fixture(name)
|
def request_fixture(name)
|
||||||
File.read(File.join(Rails.root, 'spec', 'fixtures', 'requests', name))
|
File.read(Rails.root.join('spec', 'fixtures', 'requests', name))
|
||||||
end
|
end
|
||||||
|
|
||||||
def attachment_fixture(name)
|
def attachment_fixture(name)
|
||||||
File.open(File.join(Rails.root, 'spec', 'fixtures', 'files', name))
|
File.open(Rails.root.join('spec', 'fixtures', 'files', name))
|
||||||
end
|
end
|
||||||
|
|
||||||
def stub_jsonld_contexts!
|
def stub_jsonld_contexts!
|
||||||
|
|
|
@ -19,7 +19,7 @@ RSpec.describe BatchedRemoveStatusService, type: :service do
|
||||||
stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
|
stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
|
||||||
|
|
||||||
Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now)
|
Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now)
|
||||||
jeff.user.update(current_sign_in_at: Time.now)
|
jeff.user.update(current_sign_in_at: Time.zone.now)
|
||||||
jeff.follow!(alice)
|
jeff.follow!(alice)
|
||||||
hank.follow!(alice)
|
hank.follow!(alice)
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@ RSpec.describe FetchRemoteAccountService, type: :service do
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
let(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
|
||||||
let(:xml) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'xml', 'mastodon.atom')) }
|
let(:xml) { File.read(Rails.root.join('spec', 'fixtures', 'xml', 'mastodon.atom')) }
|
||||||
|
|
||||||
shared_examples 'return Account' do
|
shared_examples 'return Account' do
|
||||||
it { is_expected.to be_an Account }
|
it { is_expected.to be_an Account }
|
||||||
|
|
|
@ -4,7 +4,7 @@ RSpec.describe ProcessFeedService, type: :service do
|
||||||
subject { ProcessFeedService.new }
|
subject { ProcessFeedService.new }
|
||||||
|
|
||||||
describe 'processing a feed' do
|
describe 'processing a feed' do
|
||||||
let(:body) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'xml', 'mastodon.atom')) }
|
let(:body) { File.read(Rails.root.join('spec', 'fixtures', 'xml', 'mastodon.atom')) }
|
||||||
let(:account) { Fabricate(:account, username: 'localhost', domain: 'kickass.zone') }
|
let(:account) { Fabricate(:account, username: 'localhost', domain: 'kickass.zone') }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe UpdateRemoteProfileService, type: :service do
|
RSpec.describe UpdateRemoteProfileService, type: :service do
|
||||||
let(:xml) { File.read(File.join(Rails.root, 'spec', 'fixtures', 'push', 'feed.atom')) }
|
let(:xml) { File.read(Rails.root.join('spec', 'fixtures', 'push', 'feed.atom')) }
|
||||||
|
|
||||||
subject { UpdateRemoteProfileService.new }
|
subject { UpdateRemoteProfileService.new }
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
|
||||||
open_registrations: false,
|
open_registrations: false,
|
||||||
thumbnail: nil,
|
thumbnail: nil,
|
||||||
hero: nil,
|
hero: nil,
|
||||||
|
mascot: nil,
|
||||||
user_count: 0,
|
user_count: 0,
|
||||||
status_count: 0,
|
status_count: 0,
|
||||||
commit_hash: commit_hash,
|
commit_hash: commit_hash,
|
||||||
|
|
|
@ -485,7 +485,8 @@ const startWorker = (workerId) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/v1/streaming/direct', (req, res) => {
|
app.get('/api/v1/streaming/direct', (req, res) => {
|
||||||
streamFrom(`timeline:direct:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
|
const channel = `timeline:direct:${req.accountId}`;
|
||||||
|
streamFrom(channel, req, streamToHttp(req, res), streamHttpEnd(req, subscriptionHeartbeat(channel)), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/v1/streaming/hashtag', (req, res) => {
|
app.get('/api/v1/streaming/hashtag', (req, res) => {
|
||||||
|
@ -525,9 +526,11 @@ const startWorker = (workerId) => {
|
||||||
ws.isAlive = true;
|
ws.isAlive = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let channel;
|
||||||
|
|
||||||
switch(location.query.stream) {
|
switch(location.query.stream) {
|
||||||
case 'user':
|
case 'user':
|
||||||
const channel = `timeline:${req.accountId}`;
|
channel = `timeline:${req.accountId}`;
|
||||||
streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)));
|
streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)));
|
||||||
break;
|
break;
|
||||||
case 'user:notification':
|
case 'user:notification':
|
||||||
|
@ -546,7 +549,8 @@ const startWorker = (workerId) => {
|
||||||
streamFrom('timeline:public:local:media', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
|
streamFrom('timeline:public:local:media', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
|
||||||
break;
|
break;
|
||||||
case 'direct':
|
case 'direct':
|
||||||
streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
|
channel = `timeline:direct:${req.accountId}`;
|
||||||
|
streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)), true);
|
||||||
break;
|
break;
|
||||||
case 'hashtag':
|
case 'hashtag':
|
||||||
streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
|
streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
|
||||||
|
@ -563,7 +567,7 @@ const startWorker = (workerId) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = `timeline:list:${listId}`;
|
channel = `timeline:list:${listId}`;
|
||||||
streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)));
|
streamFrom(channel, req, streamToWs(req, ws), streamWsEnd(req, ws, subscriptionHeartbeat(channel)));
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
Loading…
Reference in New Issue