Work on private messaging implementation

This commit is contained in:
June Rhodes 2016-11-27 01:11:24 +11:00
parent 4986c727d9
commit fe68151be6
18 changed files with 284 additions and 36 deletions

2
.gitignore vendored
View File

@ -22,3 +22,5 @@ public/assets
.env.*
node_modules/
neo4j/
.secret.keybase
.secret.paperclip

View File

@ -2,18 +2,20 @@ import api from '../api'
import { updateTimeline } from './timelines';
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
export const COMPOSE_CHANGE = 'COMPOSE_CHANGE';
export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST';
export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS';
export const COMPOSE_SUBMIT_FAIL = 'COMPOSE_SUBMIT_FAIL';
export const COMPOSE_REPLY = 'COMPOSE_REPLY';
export const COMPOSE_REPLY_CANCEL = 'COMPOSE_REPLY_CANCEL';
export const COMPOSE_PRIVATE_MESSAGE = 'COMPOSE_PRIVATE_MESSAGE';
export const COMPOSE_PRIVATE_MESSAGE_CANCEL = 'COMPOSE_PRIVATE_MESSAGE_CANCEL';
export const COMPOSE_MENTION = 'COMPOSE_MENTION';
export const COMPOSE_UPLOAD_REQUEST = 'COMPOSE_UPLOAD_REQUEST';
export const COMPOSE_UPLOAD_SUCCESS = 'COMPOSE_UPLOAD_SUCCESS';
export const COMPOSE_UPLOAD_FAIL = 'COMPOSE_UPLOAD_FAIL';
export const COMPOSE_UPLOAD_PROGRESS = 'COMPOSE_UPLOAD_PROGRESS';
export const COMPOSE_UPLOAD_UNDO = 'COMPOSE_UPLOAD_UNDO';
export const COMPOSE_SUGGESTIONS_CLEAR = 'COMPOSE_SUGGESTIONS_CLEAR';
export const COMPOSE_SUGGESTIONS_READY = 'COMPOSE_SUGGESTIONS_READY';
@ -50,6 +52,20 @@ export function cancelReplyCompose() {
};
};
export function privateMessageCompose(recipient) {
console.log('privateMessageCompose');
return {
type: COMPOSE_PRIVATE_MESSAGE,
recipient: recipient
};
};
export function cancelPrivateMessageCompose() {
return {
type: COMPOSE_PRIVATE_MESSAGE_CANCEL
};
};
export function mentionCompose(account) {
return {
type: COMPOSE_MENTION,
@ -64,6 +80,7 @@ export function submitCompose() {
api(getState).post('/api/v1/statuses', {
status: getState().getIn(['compose', 'text'], ''),
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
private_message_recipient_id: getState().getIn(['compose', 'private_message_to', 'id'], null),
media_ids: getState().getIn(['compose', 'media_attachments']).map(item => item.get('id')),
sensitive: getState().getIn(['compose', 'sensitive'])
}).then(function (response) {

View File

@ -11,15 +11,6 @@ import { FormattedMessage } from 'react-intl';
import emojify from '../emoji';
import escapeTextContentForBrowser from 'react/lib/escapeTextContentForBrowser';
const outerStyle = {
padding: '8px 10px',
paddingLeft: '68px',
position: 'relative',
minHeight: '48px',
borderBottom: '1px solid #363c4b',
cursor: 'default'
};
const Status = React.createClass({
contextTypes: {
@ -90,6 +81,19 @@ const Status = React.createClass({
}
}
let outerStyle = {
padding: '8px 10px',
paddingLeft: '68px',
position: 'relative',
minHeight: '48px',
borderBottom: '1px solid #363c4b',
cursor: 'default'
};
if (status.get('is_private')) {
outerStyle.background = 'rgb(75, 64, 60)';
}
return (
<div className={this.props.muted ? 'muted' : ''} style={outerStyle}>
<div style={{ fontSize: '15px' }}>

View File

@ -57,7 +57,24 @@ const StatusContent = React.createClass({
},
render () {
const content = { __html: emojify(this.props.status.get('content')) };
let _content = null;
if (this.props.status.get('is_private')) {
_content = this.props.status.get('private_content');
if (_content == null) {
// User doesn't have access to view this status.
_content = this.props.status.get('content');
} else {
// Prepend the recipient account handle so it's visible in the UI
_content =
'<a href="' + this.props.status.getIn(['private_recipient', 'url']) +
'" className="h-card u-url p-nickname mention">@<span>' +
this.props.status.getIn(['private_recipient', 'username']) +
'</span></a> ' + _content;
}
} else {
_content = this.props.status.get('content');
}
const content = { __html: emojify(_content) };
return <div className='status__content' style={{ cursor: 'pointer' }} dangerouslySetInnerHTML={content} onClick={this.props.onClick} />;
},

View File

@ -6,12 +6,12 @@ import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'r
const messages = defineMessages({
mention: { id: 'account.mention', defaultMessage: 'Mention' },
message: { id: 'account.message', defaultMessage: 'Message' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
block: { id: 'account.block', defaultMessage: 'Block' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
block: { id: 'account.block', defaultMessage: 'Block' }
follow: { id: 'account.follow', defaultMessage: 'Follow' }
});
const outerStyle = {
@ -41,7 +41,8 @@ const ActionBar = React.createClass({
me: React.PropTypes.number.isRequired,
onFollow: React.PropTypes.func.isRequired,
onBlock: React.PropTypes.func.isRequired,
onMention: React.PropTypes.func.isRequired
onMention: React.PropTypes.func.isRequired,
onMessage: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
@ -52,6 +53,7 @@ const ActionBar = React.createClass({
let menu = [];
menu.push({ text: intl.formatMessage(messages.mention), action: this.props.onMention });
menu.push({ text: intl.formatMessage(messages.message), action: this.props.onMessage });
if (account.get('id') === me) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });

View File

@ -10,7 +10,10 @@ import {
fetchAccountTimeline,
expandAccountTimeline
} from '../../actions/accounts';
import { mentionCompose } from '../../actions/compose';
import {
mentionCompose,
privateMessageCompose
} from '../../actions/compose';
import Header from './components/header';
import {
getAccountTimeline,
@ -73,6 +76,11 @@ const Account = React.createClass({
this.props.dispatch(mentionCompose(this.props.account));
},
handleMessage () {
console.log('handleMessage');
this.props.dispatch(privateMessageCompose(this.props.account));
},
render () {
const { account, me } = this.props;
@ -88,7 +96,7 @@ const Account = React.createClass({
<Column>
<ColumnBackButton />
<Header account={account} me={me} onFollow={this.handleFollow} />
<ActionBar account={account} me={me} onBlock={this.handleBlock} onMention={this.handleMention} />
<ActionBar account={account} me={me} onBlock={this.handleBlock} onMention={this.handleMention} onMessage={this.handleMessage} />
{this.props.children}
</Column>

View File

@ -3,6 +3,7 @@ import Button from '../../../components/button';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ReplyIndicator from './reply_indicator';
import PrivateMessageIndicator from './private_message_indicator';
import UploadButton from './upload_button';
import Autosuggest from 'react-autosuggest';
import AutosuggestAccountContainer from '../../compose/containers/autosuggest_account_container';
@ -72,9 +73,11 @@ const ComposeForm = React.createClass({
is_submitting: React.PropTypes.bool,
is_uploading: React.PropTypes.bool,
in_reply_to: ImmutablePropTypes.map,
private_message_to: ImmutablePropTypes.map,
onChange: React.PropTypes.func.isRequired,
onSubmit: React.PropTypes.func.isRequired,
onCancelReply: React.PropTypes.func.isRequired,
onCancelPrivateMessage: React.PropTypes.func.isRequired,
onClearSuggestions: React.PropTypes.func.isRequired,
onFetchSuggestions: React.PropTypes.func.isRequired,
onSuggestionSelected: React.PropTypes.func.isRequired,
@ -102,7 +105,7 @@ const ComposeForm = React.createClass({
},
componentDidUpdate (prevProps) {
if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to) {
if (prevProps.text !== this.props.text || prevProps.in_reply_to !== this.props.in_reply_to || prevProps.private_message_to !== this.props.private_message_to) {
const textarea = this.autosuggest.input;
if (textarea) {
@ -149,10 +152,13 @@ const ComposeForm = React.createClass({
render () {
const { intl } = this.props;
let replyArea = '';
let messageToArea = '';
const disabled = this.props.is_submitting || this.props.is_uploading;
if (this.props.in_reply_to) {
replyArea = <ReplyIndicator status={this.props.in_reply_to} onCancel={this.props.onCancelReply} />;
} else if (this.props.private_message_to) {
messageToArea = <PrivateMessageIndicator recipient={this.props.private_message_to} onCancel={this.props.onCancelPrivateMessage} />;
}
const inputProps = {
@ -166,6 +172,7 @@ const ComposeForm = React.createClass({
return (
<div style={{ padding: '10px' }}>
{replyArea}
{messageToArea}
<Autosuggest
ref={this.setRef}

View File

@ -0,0 +1,60 @@
import PureRenderMixin from 'react-addons-pure-render-mixin';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Avatar from '../../../components/avatar';
import IconButton from '../../../components/icon_button';
import DisplayName from '../../../components/display_name';
import emojify from '../../../emoji';
import { defineMessages, injectIntl } from 'react-intl';
const messages = defineMessages({
cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }
});
const PrivateMessageIndicator = React.createClass({
contextTypes: {
router: React.PropTypes.object
},
propTypes: {
recipient: ImmutablePropTypes.map.isRequired,
onCancel: React.PropTypes.func.isRequired
},
mixins: [PureRenderMixin],
handleClick () {
this.props.onCancel();
},
handleAccountClick (e) {
if (e.button === 0) {
e.preventDefault();
this.context.router.push(`/accounts/${this.props.recipient.get('id')}`);
}
},
render () {
const { intl } = this.props;
return (
<div style={{ background: '#c8ae9b', padding: '10px' }}>
<div style={{ overflow: 'hidden', marginBottom: '5px' }}>
<div style={{ float: 'right', lineHeight: '24px' }}><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
<a href={this.props.recipient.get('url')} onClick={this.handleAccountClick} className='reply-indicator__display-name' style={{ display: 'block', maxWidth: '100%', paddingRight: '25px', color: '#282c37', textDecoration: 'none', overflow: 'hidden', lineHeight: '24px' }}>
<div style={{ float: 'left', marginRight: '5px' }}><Avatar size={24} src={this.props.recipient.get('avatar')} /></div>
<DisplayName account={this.props.recipient} />
</a>
</div>
<div className='reply-indicator__content'>
<p style={{ fontStyle: 'italic' }}>This message will be visible to you, the recipient, and instance administrators.</p>
</div>
</div>
);
}
});
export default injectIntl(PrivateMessageIndicator);

View File

@ -4,6 +4,7 @@ import {
changeCompose,
submitCompose,
cancelReplyCompose,
cancelPrivateMessageCompose,
clearComposeSuggestions,
fetchComposeSuggestions,
selectComposeSuggestion,
@ -22,7 +23,8 @@ const makeMapStateToProps = () => {
sensitive: state.getIn(['compose', 'sensitive']),
is_submitting: state.getIn(['compose', 'is_submitting']),
is_uploading: state.getIn(['compose', 'is_uploading']),
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to']))
in_reply_to: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
private_message_to: state.getIn(['compose', 'private_message_to'])
};
};
@ -43,6 +45,10 @@ const mapDispatchToProps = function (dispatch) {
dispatch(cancelReplyCompose());
},
onCancelPrivateMessage () {
dispatch(cancelPrivateMessageCompose());
},
onClearSuggestions () {
dispatch(clearComposeSuggestions());
},

View File

@ -4,6 +4,8 @@ import {
COMPOSE_CHANGE,
COMPOSE_REPLY,
COMPOSE_REPLY_CANCEL,
COMPOSE_PRIVATE_MESSAGE,
COMPOSE_PRIVATE_MESSAGE_CANCEL,
COMPOSE_MENTION,
COMPOSE_SUBMIT_REQUEST,
COMPOSE_SUBMIT_SUCCESS,
@ -27,6 +29,7 @@ const initialState = Immutable.Map({
sensitive: false,
text: '',
in_reply_to: null,
private_message_to: null,
is_submitting: false,
is_uploading: false,
progress: 0,
@ -121,6 +124,16 @@ export default function compose(state = initialState, action) {
return state.set('progress', Math.round((action.loaded / action.total) * 100));
case COMPOSE_MENTION:
return state.update('text', text => `${text}@${action.account.get('acct')} `);
case COMPOSE_PRIVATE_MESSAGE:
console.log('COMPOSE_PRIVATE_MESSAGE');
return state.withMutations(map => {
map.set('private_message_to', action.recipient);
});
case COMPOSE_PRIVATE_MESSAGE_CANCEL:
return state.withMutations(map => {
map.set('private_message_to', null);
map.set('text', '');
});
case COMPOSE_SUGGESTIONS_CLEAR:
return state.update('suggestions', Immutable.List(), list => list.clear()).set('suggestion_token', null);
case COMPOSE_SUGGESTIONS_READY:

View File

@ -52,7 +52,7 @@ class Api::V1::StatusesController < ApiController
end
def create
@status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), media_ids: params[:media_ids], sensitive: params[:sensitive])
@status = PostStatusService.new.call(current_user.account, params[:status], params[:in_reply_to_id].blank? ? nil : Status.find(params[:in_reply_to_id]), params[:private_message_recipient_id].blank? ? nil : Account.find(params[:private_message_recipient_id]), media_ids: params[:media_ids], sensitive: params[:sensitive])
render action: :show
end

View File

@ -9,10 +9,10 @@ class Formatter
include ActionView::Helpers::TextHelper
include ActionView::Helpers::SanitizeHelper
def format(status)
return reformat(status.content) unless status.local?
def format(status, is_private = false)
return reformat(is_private ? status.private_text : status.content) unless status.local?
html = status.text
html = is_private ? status.private_text : status.text
html = encode(html)
html = simple_format(html, sanitize: false)
html = link_urls(html)

View File

@ -16,6 +16,8 @@ class Status < ApplicationRecord
has_many :media_attachments, dependent: :destroy
has_and_belongs_to_many :tags
belongs_to :private_recipient, foreign_key: 'private_recipient_id', class_name: 'Account', optional: true
has_one :notification, as: :activity, dependent: :destroy
validates :account, presence: true
@ -61,6 +63,10 @@ class Status < ApplicationRecord
content
end
def is_private
!private_recipient_id.nil?
end
def reblogs_count
attributes['reblogs_count'] || reblogs.count
end

View File

@ -9,8 +9,17 @@ class PostStatusService < BaseService
# @option [Boolean] :sensitive
# @option [Enumerable] :media_ids Optional array of media IDs to attach
# @return [Status]
def call(account, text, in_reply_to = nil, options = {})
status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive])
def call(account, text, in_reply_to = nil, private_message_recipient = nil, options = {})
if private_message_recipient != nil
# Ensure that we don't accidentally leak private message through any API, by using
# a dedicated colun in the table to store the private message. For any code that
# reads the text column (and for any other instances in the federation that don't
# support private messaging), they'll just see "This is a private message" as the
# content of the post.
status = account.statuses.create!(private_text: text, text: 'This is a private message', thread: in_reply_to, private_recipient: private_message_recipient, sensitive: options[:sensitive])
else
status = account.statuses.create!(text: text, thread: in_reply_to, sensitive: options[:sensitive])
end
attach_media(status, options[:media_ids])
process_mentions_service.call(status)
process_hashtags_service.call(status)

View File

@ -1,4 +1,4 @@
attributes :id, :created_at, :in_reply_to_id, :sensitive
attributes :id, :created_at, :in_reply_to_id, :is_private, :sensitive
node(:uri) { |status| TagManager.instance.uri_for(status) }
node(:content) { |status| Formatter.instance.format(status) }
@ -6,6 +6,42 @@ node(:url) { |status| TagManager.instance.url_for(status) }
node(:reblogs_count) { |status| defined?(@reblogs_counts_map) ? (@reblogs_counts_map[status.id] || 0) : status.reblogs_count }
node(:favourites_count) { |status| defined?(@favourites_counts_map) ? (@favourites_counts_map[status.id] || 0) : status.favourites_count }
node(:current_user_id) { |status|
current_user.id
}
node(:account_id) { |status|
status.account_id
}
node(:private_recipient_id) { |status|
status.private_recipient_id
}
node(:private_content) { |status|
if status.is_private then
if current_user.id == status.account_id || current_user.id == status.private_recipient_id then
Formatter.instance.format(status, true)
else
nil
end
else
nil
end
}
node(:private_recipient) { |status|
if status.is_private then
if current_user.id == status.account_id || current_user.id == status.private_recipient_id then
partial('api/v1/accounts/show', object: status.private_recipient)
else
nil
end
else
nil
end
}
child :account do
extends 'api/v1/accounts/show'
end

View File

@ -0,0 +1,5 @@
class AddPrivateMessageRecipientId < ActiveRecord::Migration
def change
add_column :statuses, :private_recipient_id, :integer, null: true, default: nil
end
end

View File

@ -0,0 +1,5 @@
class AddPrivateText < ActiveRecord::Migration
def change
add_column :statuses, :private_text, :text, null: false, default: ''
end
end

51
dev_start.sh Normal file
View File

@ -0,0 +1,51 @@
#!/bin/bash
set -e
# Environment variables
export REDIS_HOST=localhost
export REDIS_PORT=6379
export DB_HOST=localhost
export DB_USER=postgres
export DB_NAME=postgres
export DB_PASS=postgres
export DB_PORT=5432
export NEO4J_HOST=localhost
export NEO4J_PORT=7474
# Federation
export LOCAL_DOMAIN=localhost
export LOCAL_HTTPS=false
# Application secrets
if [ ! -f .secret.paperclip ]; then
echo "$(rake secret)" > .secret.paperclip
fi
if [ ! -f .secret.keybase ]; then
echo "$(rake secret)" > .secret.keybase
fi
export PAPERCLIP_SECRET=$(<.secret.paperclip)
export SECRET_KEY_BASE=$(<.secret.keybase)
# E-mail configuration
export SMTP_SERVER=smtp.mailgun.org
export SMTP_PORT=587
export SMTP_LOGIN=
export SMTP_PASSWORD=
export SMTP_FROM_ADDRESS=notifications@example.com
# Set us in production mode so that the configuration uses
# the DB_HOST variables.
export RAILS_ENV=production
# Install dependencies
#bundle install
# Upgrade database
rails db:migrate
# Compile assets
rails assets:precompile
# Run web server
rails server