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 ( +
+
+ An error occured +
+
+ ); + + 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"