diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index f04aa3aa..1a0dab99 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -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
diff --git a/app/javascript/gabsocial/actions/status_revision_list.js b/app/javascript/gabsocial/actions/status_revision_list.js
new file mode 100644
index 00000000..672f7bb4
--- /dev/null
+++ b/app/javascript/gabsocial/actions/status_revision_list.js
@@ -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)));
+ };
+}
\ No newline at end of file
diff --git a/app/javascript/gabsocial/components/status.js b/app/javascript/gabsocial/components/status.js
index 4e8fde9e..87d7d13e 100644
--- a/app/javascript/gabsocial/components/status.js
+++ b/app/javascript/gabsocial/components/status.js
@@ -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 {
- {!group && status.get('group') && (
+ {((!group && status.get('group')) || status.get('revised_at') !== null) && (
- Posted in
{status.getIn(['group', 'title'])}
+ {!group && status.get('group') &&
Posted in {status.getIn(['group', 'title'])}}
+ {status.get('revised_at') !== null &&
other.onShowRevisions(status)}> Edited}
)}
diff --git a/app/javascript/gabsocial/containers/status_container.js b/app/javascript/gabsocial/containers/status_container.js
index 847ae787..44497585 100644
--- a/app/javascript/gabsocial/containers/status_container.js
+++ b/app/javascript/gabsocial/containers/status_container.js
@@ -105,6 +105,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
}
},
+ onShowRevisions (status) {
+ dispatch(openModal('STATUS_REVISION', { status }));
+ },
+
onFavourite (status) {
if (status.get('favourited')) {
dispatch(unfavourite(status));
diff --git a/app/javascript/gabsocial/features/ui/components/modal_root.js b/app/javascript/gabsocial/features/ui/components/modal_root.js
index c365f75f..d1de67e3 100644
--- a/app/javascript/gabsocial/features/ui/components/modal_root.js
+++ b/app/javascript/gabsocial/features/ui/components/modal_root.js
@@ -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 }),
};
diff --git a/app/javascript/gabsocial/features/ui/components/status_revision_list.js b/app/javascript/gabsocial/features/ui/components/status_revision_list.js
new file mode 100644
index 00000000..fe873cf2
--- /dev/null
+++ b/app/javascript/gabsocial/features/ui/components/status_revision_list.js
@@ -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 ;
+
+ if (error) return (
+
+ );
+
+ return (
+
+ {data.map((revision, i) => (
+
+
+
+
+
+
{revision.text}
+
+ ))}
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/app/javascript/gabsocial/features/ui/components/status_revision_modal.js b/app/javascript/gabsocial/features/ui/components/status_revision_modal.js
new file mode 100644
index 00000000..ef1f6ff5
--- /dev/null
+++ b/app/javascript/gabsocial/features/ui/components/status_revision_modal.js
@@ -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 (
+
+ );
+ }
+}
diff --git a/app/javascript/gabsocial/features/ui/containers/status_revision_list_container.js b/app/javascript/gabsocial/features/ui/containers/status_revision_list_container.js
new file mode 100644
index 00000000..df6eb5a9
--- /dev/null
+++ b/app/javascript/gabsocial/features/ui/containers/status_revision_list_container.js
@@ -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 ;
+ }
+}
+
+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);
\ No newline at end of file
diff --git a/app/javascript/gabsocial/features/ui/util/async-components.js b/app/javascript/gabsocial/features/ui/util/async-components.js
index 40f75d7e..05c3db3e 100644
--- a/app/javascript/gabsocial/features/ui/util/async-components.js
+++ b/app/javascript/gabsocial/features/ui/util/async-components.js
@@ -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');
}
diff --git a/app/javascript/gabsocial/reducers/index.js b/app/javascript/gabsocial/reducers/index.js
index 82921f51..deb9d5a3 100644
--- a/app/javascript/gabsocial/reducers/index.js
+++ b/app/javascript/gabsocial/reducers/index.js
@@ -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);
diff --git a/app/javascript/gabsocial/reducers/status_revision_list.js b/app/javascript/gabsocial/reducers/status_revision_list.js
new file mode 100644
index 00000000..efd379b5
--- /dev/null
+++ b/app/javascript/gabsocial/reducers/status_revision_list.js
@@ -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;
+ }
+};
diff --git a/app/javascript/styles/application.scss b/app/javascript/styles/application.scss
index 4cb2e478..77aa3246 100644
--- a/app/javascript/styles/application.scss
+++ b/app/javascript/styles/application.scss
@@ -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';
diff --git a/app/javascript/styles/gabsocial/components/status-revisions.scss b/app/javascript/styles/gabsocial/components/status-revisions.scss
new file mode 100644
index 00000000..56ac39bd
--- /dev/null
+++ b/app/javascript/styles/gabsocial/components/status-revisions.scss
@@ -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;
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/models/account_verification_request.rb b/app/models/account_verification_request.rb
index 75896dbc..0b451879 100644
--- a/app/models/account_verification_request.rb
+++ b/app/models/account_verification_request.rb
@@ -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
diff --git a/app/models/status.rb b/app/models/status.rb
index 57b1c694..85aaf7e4 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -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
diff --git a/app/models/status_revision.rb b/app/models/status_revision.rb
new file mode 100644
index 00000000..62004c54
--- /dev/null
+++ b/app/models/status_revision.rb
@@ -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
diff --git a/app/serializers/rest/status_revision_serializer.rb b/app/serializers/rest/status_revision_serializer.rb
new file mode 100644
index 00000000..aa94f786
--- /dev/null
+++ b/app/serializers/rest/status_revision_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class REST::StatusRevisionSerializer < ActiveModel::Serializer
+ attributes :created_at, :text
+end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index c663c909..af017da7 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -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
diff --git a/app/services/edit_status_service.rb b/app/services/edit_status_service.rb
index f90f0312..f464f570 100644
--- a/app/services/edit_status_service.rb
+++ b/app/services/edit_status_service.rb
@@ -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?,
diff --git a/config/routes.rb b/config/routes.rb
index a64bd20b..0d31b834 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -305,6 +305,7 @@ Rails.application.routes.draw do
member do
get :context
get :card
+ get :revisions
end
end
diff --git a/db/migrate/20190917135359_create_status_revisions.rb b/db/migrate/20190917135359_create_status_revisions.rb
new file mode 100644
index 00000000..d3ca5bd5
--- /dev/null
+++ b/db/migrate/20190917135359_create_status_revisions.rb
@@ -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
diff --git a/db/migrate/20190917141707_add_revised_at_to_statuses.rb b/db/migrate/20190917141707_add_revised_at_to_statuses.rb
new file mode 100644
index 00000000..a9e88d78
--- /dev/null
+++ b/db/migrate/20190917141707_add_revised_at_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddRevisedAtToStatuses < ActiveRecord::Migration[5.2]
+ def change
+ add_column :statuses, :revised_at, :datetime
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7213bf59..8f38211e 100644
--- a/db/schema.rb
+++ b/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"