Merge branch 'feature/revision-history' of https://code.gab.com/gab/social/gab-social into develop
This commit is contained in:
commit
4de30bd8d0
|
@ -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
db/schema.rb
10
db/schema.rb
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue