Merge branch 'feature/revision-history' of https://code.gab.com/gab/social/gab-social into develop

This commit is contained in:
Rob Colbert 2019-09-18 11:08:04 -04:00
commit 4de30bd8d0
23 changed files with 319 additions and 13 deletions

@ -6,7 +6,7 @@ class Api::V1::StatusesController < Api::BaseController
before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:create, :update, :destroy]
before_action :require_user!, except: [:show, :context, :card]
before_action :set_status, only: [:show, :context, :card, :update]
before_action :set_status, only: [:show, :context, :card, :update, :revisions]
respond_to :json
@ -33,14 +33,10 @@ class Api::V1::StatusesController < Api::BaseController
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
end
def card
@card = @status.preview_cards.first
def revisions
@revisions = @status.revisions
if @card.nil?
render_empty
else
render json: @card, serializer: REST::PreviewCardSerializer
end
render json: @revisions, each_serializer: REST::StatusRevisionSerializer
end
def create

@ -0,0 +1,16 @@
import api from '../api';
export const STATUS_REVISION_LIST_LOAD = 'STATUS_REVISION_LIST';
export const STATUS_REVISION_LIST_LOAD_SUCCESS = 'STATUS_REVISION_LIST_SUCCESS';
export const STATUS_REVISION_LIST_LOAD_FAIL = 'STATUS_REVISION_LIST_FAIL';
const loadSuccess = data => ({ type: STATUS_REVISION_LIST_LOAD_SUCCESS, payload: data });
const loadFail = e => ({ type: STATUS_REVISION_LIST_LOAD_FAIL, payload: e });
export function load(statusId) {
return (dispatch, getState) => {
api(getState).get(`/api/v1/statuses/${statusId}/revisions`)
.then(res => dispatch(loadSuccess(res.data)))
.catch(e => dispatch(loadFail(e)));
};
}

@ -67,6 +67,7 @@ class Status extends ImmutablePureComponent {
otherAccounts: ImmutablePropTypes.list,
onClick: PropTypes.func,
onReply: PropTypes.func,
onShowRevisions: PropTypes.func,
onQuote: PropTypes.func,
onFavourite: PropTypes.func,
onReblog: PropTypes.func,
@ -438,9 +439,10 @@ class Status extends ImmutablePureComponent {
</NavLink>
</div>
{!group && status.get('group') && (
{((!group && status.get('group')) || status.get('revised_at') !== null) && (
<div className='status__meta'>
Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink>
{!group && status.get('group') && <React.Fragment>Posted in <NavLink to={`/groups/${status.getIn(['group', 'id'])}`}>{status.getIn(['group', 'title'])}</NavLink></React.Fragment>}
{status.get('revised_at') !== null && <a onClick={() => other.onShowRevisions(status)}> Edited</a>}
</div>
)}

@ -105,6 +105,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
onShowRevisions (status) {
dispatch(openModal('STATUS_REVISION', { status }));
},
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));

@ -20,6 +20,7 @@ import {
EmbedModal,
ListEditor,
ListAdder,
StatusRevisionModal,
} from '../../../features/ui/util/async-components';
const MODAL_COMPONENTS = {
@ -35,6 +36,7 @@ const MODAL_COMPONENTS = {
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
'LIST_ADDER':ListAdder,
'HOTKEYS': () => Promise.resolve({ default: HotkeysModal }),
'STATUS_REVISION': StatusRevisionModal,
'COMPOSE': () => Promise.resolve({ default: ComposeModal }),
'UNAUTHORIZED': () => Promise.resolve({ default: UnauthorizedModal }),
};

@ -0,0 +1,44 @@
import React from 'react';
import { injectIntl } from 'react-intl';
import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ModalLoading from './modal_loading';
import RelativeTimestamp from '../../../components/relative_timestamp';
export default @injectIntl
class StatusRevisionsList extends ImmutablePureComponent {
static propTypes = {
loading: PropTypes.bool.isRequired,
error: PropTypes.object,
data: PropTypes.array
};
render () {
const { loading, error, data } = this.props;
if (loading || !data) return <ModalLoading />;
if (error) return (
<div className='status-revisions-list'>
<div className='status-revisions-list__error'>
An error occured
</div>
</div>
);
return (
<div className='status-revisions-list'>
{data.map((revision, i) => (
<div key={i} className='status-revisions-list__item'>
<div className='status-revisions-list__item__timestamp'>
<RelativeTimestamp timestamp={revision.created_at} />
</div>
<div className='status-revisions-list__item__text'>{revision.text}</div>
</div>
))}
</div>
);
}
}

@ -0,0 +1,40 @@
import React from 'react';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import IconButton from 'gabsocial/components/icon_button';
import StatusRevisionListContainer from '../containers/status_revision_list_container';
const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' },
});
export default @injectIntl
class StatusRevisionModal extends ImmutablePureComponent {
static propTypes = {
intl: PropTypes.object.isRequired,
onClose: PropTypes.func.isRequired,
status: ImmutablePropTypes.map.isRequired
};
render () {
const { intl, onClose, status } = this.props;
return (
<div className='modal-root__modal'>
<div className='status-revisions'>
<div className='status-revisions__header'>
<h3 className='status-revisions__header__title'><FormattedMessage id='status_revisions.heading' defaultMessage='Revision History' /></h3>
<IconButton className='status-revisions__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={20} />
</div>
<div className='status-revisions__content'>
<StatusRevisionListContainer id={status.get('id')} />
</div>
</div>
</div>
);
}
}

@ -0,0 +1,27 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import { load } from '../../../actions/status_revision_list';
import StatusRevisionList from '../components/status_revision_list';
class StatusRevisionListContainer extends ImmutablePureComponent {
componentDidMount() {
this.props.load(this.props.id);
}
render() {
return <StatusRevisionList {...this.props} />;
}
}
const mapStateToProps = state => ({
loading: state.getIn(['status_revision_list', 'loading']),
error: state.getIn(['status_revision_list', 'error']),
data: state.getIn(['status_revision_list', 'data']),
});
const mapDispatchToProps = {
load
};
export default connect(mapStateToProps, mapDispatchToProps)(StatusRevisionListContainer);

@ -122,6 +122,10 @@ export function MuteModal () {
return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal');
}
export function StatusRevisionModal () {
return import(/* webpackChunkName: "modals/mute_modal" */'../components/status_revision_modal');
}
export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
}

@ -37,6 +37,7 @@ import group_relationships from './group_relationships';
import group_lists from './group_lists';
import group_editor from './group_editor';
import sidebar from './sidebar';
import status_revision_list from './status_revision_list';
const reducers = {
dropdown_menu,
@ -77,6 +78,7 @@ const reducers = {
group_lists,
group_editor,
sidebar,
status_revision_list,
};
export default combineReducers(reducers);

@ -0,0 +1,31 @@
import { Map as ImmutableMap } from 'immutable';
import {
STATUS_REVISION_LIST_LOAD,
STATUS_REVISION_LIST_LOAD_SUCCESS,
STATUS_REVISION_LIST_LOAD_FAIL
} from '../actions/status_revision_list';
const initialState = ImmutableMap({
loading: false,
error: null,
data: null
});
export default function statusRevisionList(state = initialState, action) {
switch(action.type) {
case STATUS_REVISION_LIST_LOAD:
return initialState;
case STATUS_REVISION_LIST_LOAD_SUCCESS:
return state.withMutations(mutable => {
mutable.set('loading', false);
mutable.set('data', action.payload);
});
case STATUS_REVISION_LIST_LOAD_FAIL:
return state.withMutations(mutable => {
mutable.set('loading', false);
mutable.set('error', action.payload);
});
default:
return state;
}
};

@ -32,6 +32,7 @@
@import 'gabsocial/components/group-form';
@import 'gabsocial/components/group-sidebar-panel';
@import 'gabsocial/components/sidebar-menu';
@import 'gabsocial/components/status-revisions';
@import 'gabsocial/polls';
@import 'gabsocial/introduction';

@ -0,0 +1,72 @@
.status-revisions {
padding: 8px 0 0;
overflow: hidden;
background-color: $classic-base-color;
border-radius: 6px;
@media screen and (max-width: 960px) {
height: 90vh;
}
&__header {
display: block;
position: relative;
border-bottom: 1px solid lighten($classic-base-color, 8%);
border-radius: 6px 6px 0 0;
padding-top: 12px;
padding-bottom: 12px;
&__title {
display: block;
width: 80%;
margin: 0 auto;
font-size: 18px;
font-weight: bold;
line-height: 24px;
color: $primary-text-color;
text-align: center;
}
}
&__close {
position: absolute;
right: 10px;
top: 10px;
}
&__content {
display: flex;
flex-direction: row;
width: 500px;
flex-direction: column;
overflow: hidden;
overflow-y: scroll;
height: calc(100% - 80px);
-webkit-overflow-scrolling: touch;
widows: 90%;
}
&-list {
width: 100%;
&__error {
padding: 15px;
text-align: center;
font-weight: bold;
}
&__item {
padding: 15px;
border-bottom: 1px solid lighten($classic-base-color, 8%);
&__timestamp {
opacity: 0.5;
font-size: 13px;
}
&__text {
font-size: 15px;
}
}
}
}

@ -6,7 +6,7 @@
# account_id :bigint(8)
# image_file_name :string
# image_content_type :string
# image_file_size :bigint(8)
# image_file_size :integer
# image_updated_at :datetime
# created_at :datetime not null
# updated_at :datetime not null

@ -24,6 +24,7 @@
# poll_id :bigint(8)
# group_id :integer
# quote_of_id :bigint(8)
# revised_at :datetime
#
class Status < ApplicationRecord
@ -61,6 +62,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
has_many :media_attachments, dependent: :nullify
has_many :revisions, class_name: 'StatusRevision', dependent: :destroy
has_and_belongs_to_many :tags
has_and_belongs_to_many :preview_cards

@ -0,0 +1,13 @@
# == Schema Information
#
# Table name: status_revisions
#
# id :bigint(8) not null, primary key
# status_id :bigint(8)
# text :string
# created_at :datetime not null
# updated_at :datetime not null
#
class StatusRevision < ApplicationRecord
end

@ -0,0 +1,5 @@
# frozen_string_literal: true
class REST::StatusRevisionSerializer < ActiveModel::Serializer
attributes :created_at, :text
end

@ -1,7 +1,7 @@
# frozen_string_literal: true
class REST::StatusSerializer < ActiveModel::Serializer
attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
attributes :id, :created_at, :revised_at, :in_reply_to_id, :in_reply_to_account_id,
:sensitive, :spoiler_text, :visibility, :language,
:uri, :url, :replies_count, :reblogs_count,
:favourites_count, :quote_of_id

@ -25,9 +25,11 @@ class EditStatusService < BaseService
validate_media!
preprocess_attributes!
revision_text = prepare_revision_text
process_status!
postprocess_status!
create_revision! revision_text
redis.setex(idempotency_key, 3_600, @status.id) if idempotency_given?
@ -60,6 +62,25 @@ class EditStatusService < BaseService
LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text?
end
def prepare_revision_text
text = @status.text
current_media_ids = @status.media_attachments.pluck(:id)
new_media_ids = @options[:media_ids].take(4).map(&:to_i)
if current_media_ids.sort != new_media_ids.sort
text = "" if text == @options[:text]
text += " [Media attachments changed]"
end
text.strip()
end
def create_revision!(text)
@status.revisions.create!({
text: text
})
end
def validate_media!
return if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
@ -100,6 +121,7 @@ class EditStatusService < BaseService
def status_attributes
{
revised_at: Time.now,
text: @text,
media_attachments: @media || [],
sensitive: (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?,

@ -305,6 +305,7 @@ Rails.application.routes.draw do
member do
get :context
get :card
get :revisions
end
end

@ -0,0 +1,9 @@
class CreateStatusRevisions < ActiveRecord::Migration[5.2]
def change
create_table :status_revisions do |t|
t.bigint :status_id
t.string :text
t.timestamps
end
end
end

@ -0,0 +1,5 @@
class AddRevisedAtToStatuses < ActiveRecord::Migration[5.2]
def change
add_column :statuses, :revised_at, :datetime
end
end

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_09_03_162122) do
ActiveRecord::Schema.define(version: 2019_09_17_141707) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -672,6 +672,13 @@ ActiveRecord::Schema.define(version: 2019_09_03_162122) do
t.index ["account_id", "status_id"], name: "index_status_pins_on_account_id_and_status_id", unique: true
end
create_table "status_revisions", force: :cascade do |t|
t.bigint "status_id"
t.string "text"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "status_stats", force: :cascade do |t|
t.bigint "status_id", null: false
t.bigint "replies_count", default: 0, null: false
@ -703,6 +710,7 @@ ActiveRecord::Schema.define(version: 2019_09_03_162122) do
t.bigint "poll_id"
t.integer "group_id"
t.bigint "quote_of_id"
t.datetime "revised_at"
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc }
t.index ["group_id"], name: "index_statuses_on_group_id"
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"