Add editing for published statuses (#17320)
* Add editing for published statuses * Fix change of multiple-choice boolean in poll not resetting votes * Remove the ability to update existing media attachments for now
This commit is contained in:
		
							parent
							
								
									20a3564ab2
								
							
						
					
					
						commit
						63002cde03
					
				@ -20,7 +20,7 @@ class Api::V1::MediaController < Api::BaseController
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def update
 | 
					  def update
 | 
				
			||||||
    @media_attachment.update!(media_attachment_params)
 | 
					    @media_attachment.update!(updateable_media_attachment_params)
 | 
				
			||||||
    render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment
 | 
					    render json: @media_attachment, serializer: REST::MediaAttachmentSerializer, status: status_code_for_media_attachment
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -42,6 +42,10 @@ class Api::V1::MediaController < Api::BaseController
 | 
				
			|||||||
    params.permit(:file, :thumbnail, :description, :focus)
 | 
					    params.permit(:file, :thumbnail, :description, :focus)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def updateable_media_attachment_params
 | 
				
			||||||
 | 
					    params.permit(:thumbnail, :description, :focus)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def file_type_error
 | 
					  def file_type_error
 | 
				
			||||||
    { error: 'File type of uploaded media could not be verified' }
 | 
					    { error: 'File type of uploaded media could not be verified' }
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
				
			|||||||
@ -3,8 +3,8 @@
 | 
				
			|||||||
class Api::V1::StatusesController < Api::BaseController
 | 
					class Api::V1::StatusesController < Api::BaseController
 | 
				
			||||||
  include Authorization
 | 
					  include Authorization
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :destroy]
 | 
					  before_action -> { authorize_if_got_token! :read, :'read:statuses' }, except: [:create, :update, :destroy]
 | 
				
			||||||
  before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only:   [:create, :destroy]
 | 
					  before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only:   [:create, :update, :destroy]
 | 
				
			||||||
  before_action :require_user!, except:  [:show, :context]
 | 
					  before_action :require_user!, except:  [:show, :context]
 | 
				
			||||||
  before_action :set_status, only:       [:show, :context]
 | 
					  before_action :set_status, only:       [:show, :context]
 | 
				
			||||||
  before_action :set_thread, only:       [:create]
 | 
					  before_action :set_thread, only:       [:create]
 | 
				
			||||||
@ -35,24 +35,44 @@ class Api::V1::StatusesController < Api::BaseController
 | 
				
			|||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def create
 | 
					  def create
 | 
				
			||||||
    @status = PostStatusService.new.call(current_user.account,
 | 
					    @status = PostStatusService.new.call(
 | 
				
			||||||
                                         text: status_params[:status],
 | 
					      current_user.account,
 | 
				
			||||||
                                         thread: @thread,
 | 
					      text: status_params[:status],
 | 
				
			||||||
                                         media_ids: status_params[:media_ids],
 | 
					      thread: @thread,
 | 
				
			||||||
                                         sensitive: status_params[:sensitive],
 | 
					      media_ids: status_params[:media_ids],
 | 
				
			||||||
                                         spoiler_text: status_params[:spoiler_text],
 | 
					      sensitive: status_params[:sensitive],
 | 
				
			||||||
                                         visibility: status_params[:visibility],
 | 
					      spoiler_text: status_params[:spoiler_text],
 | 
				
			||||||
                                         scheduled_at: status_params[:scheduled_at],
 | 
					      visibility: status_params[:visibility],
 | 
				
			||||||
                                         application: doorkeeper_token.application,
 | 
					      language: status_params[:language],
 | 
				
			||||||
                                         poll: status_params[:poll],
 | 
					      scheduled_at: status_params[:scheduled_at],
 | 
				
			||||||
                                         idempotency: request.headers['Idempotency-Key'],
 | 
					      application: doorkeeper_token.application,
 | 
				
			||||||
                                         with_rate_limit: true)
 | 
					      poll: status_params[:poll],
 | 
				
			||||||
 | 
					      idempotency: request.headers['Idempotency-Key'],
 | 
				
			||||||
 | 
					      with_rate_limit: true
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
 | 
					    render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def update
 | 
				
			||||||
 | 
					    @status = Status.where(account: current_account).find(params[:id])
 | 
				
			||||||
 | 
					    authorize @status, :update?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    UpdateStatusService.new.call(
 | 
				
			||||||
 | 
					      @status,
 | 
				
			||||||
 | 
					      current_account.id,
 | 
				
			||||||
 | 
					      text: status_params[:status],
 | 
				
			||||||
 | 
					      media_ids: status_params[:media_ids],
 | 
				
			||||||
 | 
					      sensitive: status_params[:sensitive],
 | 
				
			||||||
 | 
					      spoiler_text: status_params[:spoiler_text],
 | 
				
			||||||
 | 
					      poll: status_params[:poll]
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    render json: @status, serializer: REST::StatusSerializer
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def destroy
 | 
					  def destroy
 | 
				
			||||||
    @status = Status.where(account_id: current_user.account).find(params[:id])
 | 
					    @status = Status.where(account: current_account).find(params[:id])
 | 
				
			||||||
    authorize @status, :destroy?
 | 
					    authorize @status, :destroy?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @status.discard
 | 
					    @status.discard
 | 
				
			||||||
@ -84,6 +104,7 @@ class Api::V1::StatusesController < Api::BaseController
 | 
				
			|||||||
      :sensitive,
 | 
					      :sensitive,
 | 
				
			||||||
      :spoiler_text,
 | 
					      :spoiler_text,
 | 
				
			||||||
      :visibility,
 | 
					      :visibility,
 | 
				
			||||||
 | 
					      :language,
 | 
				
			||||||
      :scheduled_at,
 | 
					      :scheduled_at,
 | 
				
			||||||
      media_ids: [],
 | 
					      media_ids: [],
 | 
				
			||||||
      poll: [
 | 
					      poll: [
 | 
				
			||||||
 | 
				
			|||||||
@ -70,6 +70,8 @@ export const INIT_MEDIA_EDIT_MODAL = 'INIT_MEDIA_EDIT_MODAL';
 | 
				
			|||||||
export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
 | 
					export const COMPOSE_CHANGE_MEDIA_DESCRIPTION = 'COMPOSE_CHANGE_MEDIA_DESCRIPTION';
 | 
				
			||||||
export const COMPOSE_CHANGE_MEDIA_FOCUS       = 'COMPOSE_CHANGE_MEDIA_FOCUS';
 | 
					export const COMPOSE_CHANGE_MEDIA_FOCUS       = 'COMPOSE_CHANGE_MEDIA_FOCUS';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const COMPOSE_SET_STATUS = 'COMPOSE_SET_STATUS';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messages = defineMessages({
 | 
					const messages = defineMessages({
 | 
				
			||||||
  uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
 | 
					  uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
 | 
				
			||||||
  uploadErrorPoll:  { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
 | 
					  uploadErrorPoll:  { id: 'upload_error.poll', defaultMessage: 'File upload not allowed with polls.' },
 | 
				
			||||||
@ -83,6 +85,15 @@ export const ensureComposeIsVisible = (getState, routerHistory) => {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function setComposeToStatus(status, text, spoiler_text) {
 | 
				
			||||||
 | 
					  return{
 | 
				
			||||||
 | 
					    type: COMPOSE_SET_STATUS,
 | 
				
			||||||
 | 
					    status,
 | 
				
			||||||
 | 
					    text,
 | 
				
			||||||
 | 
					    spoiler_text,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function changeCompose(text) {
 | 
					export function changeCompose(text) {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    type: COMPOSE_CHANGE,
 | 
					    type: COMPOSE_CHANGE,
 | 
				
			||||||
@ -137,8 +148,9 @@ export function directCompose(account, routerHistory) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export function submitCompose(routerHistory) {
 | 
					export function submitCompose(routerHistory) {
 | 
				
			||||||
  return function (dispatch, getState) {
 | 
					  return function (dispatch, getState) {
 | 
				
			||||||
    const status = getState().getIn(['compose', 'text'], '');
 | 
					    const status   = getState().getIn(['compose', 'text'], '');
 | 
				
			||||||
    const media  = getState().getIn(['compose', 'media_attachments']);
 | 
					    const media    = getState().getIn(['compose', 'media_attachments']);
 | 
				
			||||||
 | 
					    const statusId = getState().getIn(['compose', 'id'], null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if ((!status || !status.length) && media.size === 0) {
 | 
					    if ((!status || !status.length) && media.size === 0) {
 | 
				
			||||||
      return;
 | 
					      return;
 | 
				
			||||||
@ -146,15 +158,18 @@ export function submitCompose(routerHistory) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    dispatch(submitComposeRequest());
 | 
					    dispatch(submitComposeRequest());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    api(getState).post('/api/v1/statuses', {
 | 
					    api(getState).request({
 | 
				
			||||||
      status,
 | 
					      url: statusId === null ? '/api/v1/statuses' : `/api/v1/statuses/${statusId}`,
 | 
				
			||||||
      in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
 | 
					      method: statusId === null ? 'post' : 'put',
 | 
				
			||||||
      media_ids: media.map(item => item.get('id')),
 | 
					      data: {
 | 
				
			||||||
      sensitive: getState().getIn(['compose', 'sensitive']),
 | 
					        status,
 | 
				
			||||||
      spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
 | 
					        in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
 | 
				
			||||||
      visibility: getState().getIn(['compose', 'privacy']),
 | 
					        media_ids: media.map(item => item.get('id')),
 | 
				
			||||||
      poll: getState().getIn(['compose', 'poll'], null),
 | 
					        sensitive: getState().getIn(['compose', 'sensitive']),
 | 
				
			||||||
    }, {
 | 
					        spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
 | 
				
			||||||
 | 
					        visibility: getState().getIn(['compose', 'privacy']),
 | 
				
			||||||
 | 
					        poll: getState().getIn(['compose', 'poll'], null),
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
      headers: {
 | 
					      headers: {
 | 
				
			||||||
        'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
 | 
					        'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
@ -176,11 +191,11 @@ export function submitCompose(routerHistory) {
 | 
				
			|||||||
        }
 | 
					        }
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (response.data.visibility !== 'direct') {
 | 
					      if (statusId === null && response.data.visibility !== 'direct') {
 | 
				
			||||||
        insertIfOnline('home');
 | 
					        insertIfOnline('home');
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (response.data.in_reply_to_id === null && response.data.visibility === 'public') {
 | 
					      if (statusId === null && response.data.in_reply_to_id === null && response.data.visibility === 'public') {
 | 
				
			||||||
        insertIfOnline('community');
 | 
					        insertIfOnline('community');
 | 
				
			||||||
        insertIfOnline('public');
 | 
					        insertIfOnline('public');
 | 
				
			||||||
        insertIfOnline(`account:${response.data.account.id}`);
 | 
					        insertIfOnline(`account:${response.data.account.id}`);
 | 
				
			||||||
 | 
				
			|||||||
@ -2,7 +2,7 @@ import api from '../api';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
import { deleteFromTimelines } from './timelines';
 | 
					import { deleteFromTimelines } from './timelines';
 | 
				
			||||||
import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
 | 
					import { importFetchedStatus, importFetchedStatuses, importFetchedAccount } from './importer';
 | 
				
			||||||
import { ensureComposeIsVisible } from './compose';
 | 
					import { ensureComposeIsVisible, setComposeToStatus } from './compose';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
 | 
					export const STATUS_FETCH_REQUEST = 'STATUS_FETCH_REQUEST';
 | 
				
			||||||
export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
 | 
					export const STATUS_FETCH_SUCCESS = 'STATUS_FETCH_SUCCESS';
 | 
				
			||||||
@ -30,6 +30,10 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
export const REDRAFT = 'REDRAFT';
 | 
					export const REDRAFT = 'REDRAFT';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
 | 
				
			||||||
 | 
					export const STATUS_FETCH_SOURCE_SUCCESS = 'STATUS_FETCH_SOURCE_SUCCESS';
 | 
				
			||||||
 | 
					export const STATUS_FETCH_SOURCE_FAIL    = 'STATUS_FETCH_SOURCE_FAIL';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function fetchStatusRequest(id, skipLoading) {
 | 
					export function fetchStatusRequest(id, skipLoading) {
 | 
				
			||||||
  return {
 | 
					  return {
 | 
				
			||||||
    type: STATUS_FETCH_REQUEST,
 | 
					    type: STATUS_FETCH_REQUEST,
 | 
				
			||||||
@ -84,6 +88,37 @@ export function redraft(status, raw_text) {
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const editStatus = (id, routerHistory) => (dispatch, getState) => {
 | 
				
			||||||
 | 
					  let status = getState().getIn(['statuses', id]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  if (status.get('poll')) {
 | 
				
			||||||
 | 
					    status = status.set('poll', getState().getIn(['polls', status.get('poll')]));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  dispatch(fetchStatusSourceRequest());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  api(getState).get(`/api/v1/statuses/${id}/source`).then(response => {
 | 
				
			||||||
 | 
					    dispatch(fetchStatusSourceSuccess());
 | 
				
			||||||
 | 
					    ensureComposeIsVisible(getState, routerHistory);
 | 
				
			||||||
 | 
					    dispatch(setComposeToStatus(status, response.data.text, response.data.spoiler_text));
 | 
				
			||||||
 | 
					  }).catch(error => {
 | 
				
			||||||
 | 
					    dispatch(fetchStatusSourceFail(error));
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const fetchStatusSourceRequest = () => ({
 | 
				
			||||||
 | 
					  type: STATUS_FETCH_SOURCE_REQUEST,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const fetchStatusSourceSuccess = () => ({
 | 
				
			||||||
 | 
					  type: STATUS_FETCH_SOURCE_SUCCESS,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const fetchStatusSourceFail = error => ({
 | 
				
			||||||
 | 
					  type: STATUS_FETCH_SOURCE_FAIL,
 | 
				
			||||||
 | 
					  error,
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function deleteStatus(id, routerHistory, withRedraft = false) {
 | 
					export function deleteStatus(id, routerHistory, withRedraft = false) {
 | 
				
			||||||
  return (dispatch, getState) => {
 | 
					  return (dispatch, getState) => {
 | 
				
			||||||
    let status = getState().getIn(['statuses', id]);
 | 
					    let status = getState().getIn(['statuses', id]);
 | 
				
			||||||
 | 
				
			|||||||
@ -12,6 +12,7 @@ import classNames from 'classnames';
 | 
				
			|||||||
const messages = defineMessages({
 | 
					const messages = defineMessages({
 | 
				
			||||||
  delete: { id: 'status.delete', defaultMessage: 'Delete' },
 | 
					  delete: { id: 'status.delete', defaultMessage: 'Delete' },
 | 
				
			||||||
  redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
 | 
					  redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
 | 
				
			||||||
 | 
					  edit: { id: 'status.edit', defaultMessage: 'Edit' },
 | 
				
			||||||
  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
 | 
					  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
 | 
				
			||||||
  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
 | 
					  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
 | 
				
			||||||
  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
 | 
					  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
 | 
				
			||||||
@ -137,6 +138,10 @@ class StatusActionBar extends ImmutablePureComponent {
 | 
				
			|||||||
    this.props.onDelete(this.props.status, this.context.router.history, true);
 | 
					    this.props.onDelete(this.props.status, this.context.router.history, true);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleEditClick = () => {
 | 
				
			||||||
 | 
					    this.props.onEdit(this.props.status, this.context.router.history);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handlePinClick = () => {
 | 
					  handlePinClick = () => {
 | 
				
			||||||
    this.props.onPin(this.props.status);
 | 
					    this.props.onPin(this.props.status);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -255,6 +260,7 @@ class StatusActionBar extends ImmutablePureComponent {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (writtenByMe) {
 | 
					    if (writtenByMe) {
 | 
				
			||||||
 | 
					      menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
 | 
				
			||||||
      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
 | 
					      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
 | 
				
			||||||
      menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
 | 
					      menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
 | 
				
			|||||||
@ -24,6 +24,7 @@ import {
 | 
				
			|||||||
  hideStatus,
 | 
					  hideStatus,
 | 
				
			||||||
  revealStatus,
 | 
					  revealStatus,
 | 
				
			||||||
  toggleStatusCollapse,
 | 
					  toggleStatusCollapse,
 | 
				
			||||||
 | 
					  editStatus,
 | 
				
			||||||
} from '../actions/statuses';
 | 
					} from '../actions/statuses';
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  unmuteAccount,
 | 
					  unmuteAccount,
 | 
				
			||||||
@ -142,6 +143,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  onEdit (status, history) {
 | 
				
			||||||
 | 
					    dispatch(editStatus(status.get('id'), history));
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  onDirect (account, router) {
 | 
					  onDirect (account, router) {
 | 
				
			||||||
    dispatch(directCompose(account, router));
 | 
					    dispatch(directCompose(account, router));
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
				
			|||||||
@ -28,6 +28,7 @@ const messages = defineMessages({
 | 
				
			|||||||
  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
 | 
					  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
 | 
				
			||||||
  publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
 | 
					  publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
 | 
				
			||||||
  publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
 | 
					  publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
 | 
				
			||||||
 | 
					  saveChanges: { id: 'compose_form.save_changes', defaultMessage: 'Save changes' },
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default @injectIntl
 | 
					export default @injectIntl
 | 
				
			||||||
@ -49,6 +50,7 @@ class ComposeForm extends ImmutablePureComponent {
 | 
				
			|||||||
    preselectDate: PropTypes.instanceOf(Date),
 | 
					    preselectDate: PropTypes.instanceOf(Date),
 | 
				
			||||||
    isSubmitting: PropTypes.bool,
 | 
					    isSubmitting: PropTypes.bool,
 | 
				
			||||||
    isChangingUpload: PropTypes.bool,
 | 
					    isChangingUpload: PropTypes.bool,
 | 
				
			||||||
 | 
					    isEditing: PropTypes.bool,
 | 
				
			||||||
    isUploading: PropTypes.bool,
 | 
					    isUploading: PropTypes.bool,
 | 
				
			||||||
    onChange: PropTypes.func.isRequired,
 | 
					    onChange: PropTypes.func.isRequired,
 | 
				
			||||||
    onSubmit: PropTypes.func.isRequired,
 | 
					    onSubmit: PropTypes.func.isRequired,
 | 
				
			||||||
@ -199,7 +201,9 @@ class ComposeForm extends ImmutablePureComponent {
 | 
				
			|||||||
    const disabled = this.props.isSubmitting;
 | 
					    const disabled = this.props.isSubmitting;
 | 
				
			||||||
    let publishText = '';
 | 
					    let publishText = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
 | 
					    if (this.props.isEditing) {
 | 
				
			||||||
 | 
					      publishText = intl.formatMessage(messages.saveChanges);
 | 
				
			||||||
 | 
					    } else if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
 | 
				
			||||||
      publishText = <span className='compose-form__publish-private'><Icon id='lock' /> {intl.formatMessage(messages.publish)}</span>;
 | 
					      publishText = <span className='compose-form__publish-private'><Icon id='lock' /> {intl.formatMessage(messages.publish)}</span>;
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
 | 
					      publishText = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
 | 
				
			||||||
 | 
				
			|||||||
@ -21,6 +21,7 @@ const mapStateToProps = state => ({
 | 
				
			|||||||
  caretPosition: state.getIn(['compose', 'caretPosition']),
 | 
					  caretPosition: state.getIn(['compose', 'caretPosition']),
 | 
				
			||||||
  preselectDate: state.getIn(['compose', 'preselectDate']),
 | 
					  preselectDate: state.getIn(['compose', 'preselectDate']),
 | 
				
			||||||
  isSubmitting: state.getIn(['compose', 'is_submitting']),
 | 
					  isSubmitting: state.getIn(['compose', 'is_submitting']),
 | 
				
			||||||
 | 
					  isEditing: state.getIn(['compose', 'id']) !== null,
 | 
				
			||||||
  isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
 | 
					  isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
 | 
				
			||||||
  isUploading: state.getIn(['compose', 'is_uploading']),
 | 
					  isUploading: state.getIn(['compose', 'is_uploading']),
 | 
				
			||||||
  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
 | 
					  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
 | 
				
			||||||
 | 
				
			|||||||
@ -6,9 +6,20 @@ import ReplyIndicator from '../components/reply_indicator';
 | 
				
			|||||||
const makeMapStateToProps = () => {
 | 
					const makeMapStateToProps = () => {
 | 
				
			||||||
  const getStatus = makeGetStatus();
 | 
					  const getStatus = makeGetStatus();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const mapStateToProps = state => ({
 | 
					  const mapStateToProps = state => {
 | 
				
			||||||
    status: getStatus(state, { id: state.getIn(['compose', 'in_reply_to']) }),
 | 
					    let statusId = state.getIn(['compose', 'id'], null);
 | 
				
			||||||
  });
 | 
					    let editing  = true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (statusId === null) {
 | 
				
			||||||
 | 
					      statusId = state.getIn(['compose', 'in_reply_to']);
 | 
				
			||||||
 | 
					      editing  = false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      status: getStatus(state, { id: statusId }),
 | 
				
			||||||
 | 
					      editing,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return mapStateToProps;
 | 
					  return mapStateToProps;
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,7 @@ import classNames from 'classnames';
 | 
				
			|||||||
const messages = defineMessages({
 | 
					const messages = defineMessages({
 | 
				
			||||||
  delete: { id: 'status.delete', defaultMessage: 'Delete' },
 | 
					  delete: { id: 'status.delete', defaultMessage: 'Delete' },
 | 
				
			||||||
  redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
 | 
					  redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
 | 
				
			||||||
 | 
					  edit: { id: 'status.edit', defaultMessage: 'Edit' },
 | 
				
			||||||
  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
 | 
					  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
 | 
				
			||||||
  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
 | 
					  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
 | 
				
			||||||
  reply: { id: 'status.reply', defaultMessage: 'Reply' },
 | 
					  reply: { id: 'status.reply', defaultMessage: 'Reply' },
 | 
				
			||||||
@ -59,6 +60,7 @@ class ActionBar extends React.PureComponent {
 | 
				
			|||||||
    onFavourite: PropTypes.func.isRequired,
 | 
					    onFavourite: PropTypes.func.isRequired,
 | 
				
			||||||
    onBookmark: PropTypes.func.isRequired,
 | 
					    onBookmark: PropTypes.func.isRequired,
 | 
				
			||||||
    onDelete: PropTypes.func.isRequired,
 | 
					    onDelete: PropTypes.func.isRequired,
 | 
				
			||||||
 | 
					    onEdit: PropTypes.func.isRequired,
 | 
				
			||||||
    onDirect: PropTypes.func.isRequired,
 | 
					    onDirect: PropTypes.func.isRequired,
 | 
				
			||||||
    onMention: PropTypes.func.isRequired,
 | 
					    onMention: PropTypes.func.isRequired,
 | 
				
			||||||
    onMute: PropTypes.func,
 | 
					    onMute: PropTypes.func,
 | 
				
			||||||
@ -98,6 +100,10 @@ class ActionBar extends React.PureComponent {
 | 
				
			|||||||
    this.props.onDelete(this.props.status, this.context.router.history, true);
 | 
					    this.props.onDelete(this.props.status, this.context.router.history, true);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleEditClick = () => {
 | 
				
			||||||
 | 
					    this.props.onEdit(this.props.status, this.context.router.history);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleDirectClick = () => {
 | 
					  handleDirectClick = () => {
 | 
				
			||||||
    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
 | 
					    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -209,6 +215,7 @@ class ActionBar extends React.PureComponent {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
 | 
					      menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
 | 
				
			||||||
      menu.push(null);
 | 
					      menu.push(null);
 | 
				
			||||||
 | 
					      menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick });
 | 
				
			||||||
      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
 | 
					      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
 | 
				
			||||||
      menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
 | 
					      menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
 | 
				
			|||||||
@ -29,6 +29,7 @@ import {
 | 
				
			|||||||
  muteStatus,
 | 
					  muteStatus,
 | 
				
			||||||
  unmuteStatus,
 | 
					  unmuteStatus,
 | 
				
			||||||
  deleteStatus,
 | 
					  deleteStatus,
 | 
				
			||||||
 | 
					  editStatus,
 | 
				
			||||||
  hideStatus,
 | 
					  hideStatus,
 | 
				
			||||||
  revealStatus,
 | 
					  revealStatus,
 | 
				
			||||||
} from '../../actions/statuses';
 | 
					} from '../../actions/statuses';
 | 
				
			||||||
@ -273,6 +274,10 @@ class Status extends ImmutablePureComponent {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleEditClick = (status, history) => {
 | 
				
			||||||
 | 
					    this.props.dispatch(editStatus(status.get('id'), history));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleDirectClick = (account, router) => {
 | 
					  handleDirectClick = (account, router) => {
 | 
				
			||||||
    this.props.dispatch(directCompose(account, router));
 | 
					    this.props.dispatch(directCompose(account, router));
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -567,6 +572,7 @@ class Status extends ImmutablePureComponent {
 | 
				
			|||||||
                  onReblog={this.handleReblogClick}
 | 
					                  onReblog={this.handleReblogClick}
 | 
				
			||||||
                  onBookmark={this.handleBookmarkClick}
 | 
					                  onBookmark={this.handleBookmarkClick}
 | 
				
			||||||
                  onDelete={this.handleDeleteClick}
 | 
					                  onDelete={this.handleDeleteClick}
 | 
				
			||||||
 | 
					                  onEdit={this.handleEditClick}
 | 
				
			||||||
                  onDirect={this.handleDirectClick}
 | 
					                  onDirect={this.handleDirectClick}
 | 
				
			||||||
                  onMention={this.handleMentionClick}
 | 
					                  onMention={this.handleMentionClick}
 | 
				
			||||||
                  onMute={this.handleMuteClick}
 | 
					                  onMute={this.handleMuteClick}
 | 
				
			||||||
 | 
				
			|||||||
@ -43,6 +43,7 @@ import {
 | 
				
			|||||||
  INIT_MEDIA_EDIT_MODAL,
 | 
					  INIT_MEDIA_EDIT_MODAL,
 | 
				
			||||||
  COMPOSE_CHANGE_MEDIA_DESCRIPTION,
 | 
					  COMPOSE_CHANGE_MEDIA_DESCRIPTION,
 | 
				
			||||||
  COMPOSE_CHANGE_MEDIA_FOCUS,
 | 
					  COMPOSE_CHANGE_MEDIA_FOCUS,
 | 
				
			||||||
 | 
					  COMPOSE_SET_STATUS,
 | 
				
			||||||
} from '../actions/compose';
 | 
					} from '../actions/compose';
 | 
				
			||||||
import { TIMELINE_DELETE } from '../actions/timelines';
 | 
					import { TIMELINE_DELETE } from '../actions/timelines';
 | 
				
			||||||
import { STORE_HYDRATE } from '../actions/store';
 | 
					import { STORE_HYDRATE } from '../actions/store';
 | 
				
			||||||
@ -58,6 +59,7 @@ const initialState = ImmutableMap({
 | 
				
			|||||||
  spoiler: false,
 | 
					  spoiler: false,
 | 
				
			||||||
  spoiler_text: '',
 | 
					  spoiler_text: '',
 | 
				
			||||||
  privacy: null,
 | 
					  privacy: null,
 | 
				
			||||||
 | 
					  id: null,
 | 
				
			||||||
  text: '',
 | 
					  text: '',
 | 
				
			||||||
  focusDate: null,
 | 
					  focusDate: null,
 | 
				
			||||||
  caretPosition: null,
 | 
					  caretPosition: null,
 | 
				
			||||||
@ -107,6 +109,7 @@ function statusToTextMentions(state, status) {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
function clearAll(state) {
 | 
					function clearAll(state) {
 | 
				
			||||||
  return state.withMutations(map => {
 | 
					  return state.withMutations(map => {
 | 
				
			||||||
 | 
					    map.set('id', null);
 | 
				
			||||||
    map.set('text', '');
 | 
					    map.set('text', '');
 | 
				
			||||||
    map.set('spoiler', false);
 | 
					    map.set('spoiler', false);
 | 
				
			||||||
    map.set('spoiler_text', '');
 | 
					    map.set('spoiler_text', '');
 | 
				
			||||||
@ -313,6 +316,7 @@ export default function compose(state = initialState, action) {
 | 
				
			|||||||
    return state.set('is_composing', action.value);
 | 
					    return state.set('is_composing', action.value);
 | 
				
			||||||
  case COMPOSE_REPLY:
 | 
					  case COMPOSE_REPLY:
 | 
				
			||||||
    return state.withMutations(map => {
 | 
					    return state.withMutations(map => {
 | 
				
			||||||
 | 
					      map.set('id', null);
 | 
				
			||||||
      map.set('in_reply_to', action.status.get('id'));
 | 
					      map.set('in_reply_to', action.status.get('id'));
 | 
				
			||||||
      map.set('text', statusToTextMentions(state, action.status));
 | 
					      map.set('text', statusToTextMentions(state, action.status));
 | 
				
			||||||
      map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
 | 
					      map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
 | 
				
			||||||
@ -329,21 +333,12 @@ export default function compose(state = initialState, action) {
 | 
				
			|||||||
        map.set('spoiler_text', '');
 | 
					        map.set('spoiler_text', '');
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  case COMPOSE_REPLY_CANCEL:
 | 
					 | 
				
			||||||
  case COMPOSE_RESET:
 | 
					 | 
				
			||||||
    return state.withMutations(map => {
 | 
					 | 
				
			||||||
      map.set('in_reply_to', null);
 | 
					 | 
				
			||||||
      map.set('text', '');
 | 
					 | 
				
			||||||
      map.set('spoiler', false);
 | 
					 | 
				
			||||||
      map.set('spoiler_text', '');
 | 
					 | 
				
			||||||
      map.set('privacy', state.get('default_privacy'));
 | 
					 | 
				
			||||||
      map.set('poll', null);
 | 
					 | 
				
			||||||
      map.set('idempotencyKey', uuid());
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  case COMPOSE_SUBMIT_REQUEST:
 | 
					  case COMPOSE_SUBMIT_REQUEST:
 | 
				
			||||||
    return state.set('is_submitting', true);
 | 
					    return state.set('is_submitting', true);
 | 
				
			||||||
  case COMPOSE_UPLOAD_CHANGE_REQUEST:
 | 
					  case COMPOSE_UPLOAD_CHANGE_REQUEST:
 | 
				
			||||||
    return state.set('is_changing_upload', true);
 | 
					    return state.set('is_changing_upload', true);
 | 
				
			||||||
 | 
					  case COMPOSE_REPLY_CANCEL:
 | 
				
			||||||
 | 
					  case COMPOSE_RESET:
 | 
				
			||||||
  case COMPOSE_SUBMIT_SUCCESS:
 | 
					  case COMPOSE_SUBMIT_SUCCESS:
 | 
				
			||||||
    return clearAll(state);
 | 
					    return clearAll(state);
 | 
				
			||||||
  case COMPOSE_SUBMIT_FAIL:
 | 
					  case COMPOSE_SUBMIT_FAIL:
 | 
				
			||||||
@ -454,6 +449,34 @@ export default function compose(state = initialState, action) {
 | 
				
			|||||||
        map.set('spoiler_text', '');
 | 
					        map.set('spoiler_text', '');
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (action.status.get('poll')) {
 | 
				
			||||||
 | 
					        map.set('poll', ImmutableMap({
 | 
				
			||||||
 | 
					          options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
 | 
				
			||||||
 | 
					          multiple: action.status.getIn(['poll', 'multiple']),
 | 
				
			||||||
 | 
					          expires_in: expiresInFromExpiresAt(action.status.getIn(['poll', 'expires_at'])),
 | 
				
			||||||
 | 
					        }));
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  case COMPOSE_SET_STATUS:
 | 
				
			||||||
 | 
					    return state.withMutations(map => {
 | 
				
			||||||
 | 
					      map.set('id', action.status.get('id'));
 | 
				
			||||||
 | 
					      map.set('text', action.text);
 | 
				
			||||||
 | 
					      map.set('in_reply_to', action.status.get('in_reply_to_id'));
 | 
				
			||||||
 | 
					      map.set('privacy', action.status.get('visibility'));
 | 
				
			||||||
 | 
					      map.set('media_attachments', action.status.get('media_attachments'));
 | 
				
			||||||
 | 
					      map.set('focusDate', new Date());
 | 
				
			||||||
 | 
					      map.set('caretPosition', null);
 | 
				
			||||||
 | 
					      map.set('idempotencyKey', uuid());
 | 
				
			||||||
 | 
					      map.set('sensitive', action.status.get('sensitive'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (action.spoiler_text.length > 0) {
 | 
				
			||||||
 | 
					        map.set('spoiler', true);
 | 
				
			||||||
 | 
					        map.set('spoiler_text', action.spoiler_text);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        map.set('spoiler', false);
 | 
				
			||||||
 | 
					        map.set('spoiler_text', '');
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (action.status.get('poll')) {
 | 
					      if (action.status.get('poll')) {
 | 
				
			||||||
        map.set('poll', ImmutableMap({
 | 
					        map.set('poll', ImmutableMap({
 | 
				
			||||||
          options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
 | 
					          options: action.status.getIn(['poll', 'options']).map(x => x.get('title')),
 | 
				
			||||||
 | 
				
			|||||||
@ -208,6 +208,10 @@ class MediaAttachment < ApplicationRecord
 | 
				
			|||||||
    file.blank? && remote_url.present?
 | 
					    file.blank? && remote_url.present?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def significantly_changed?
 | 
				
			||||||
 | 
					    description_previously_changed? || thumbnail_updated_at_previously_changed? || file_meta_previously_changed?
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def larger_media_format?
 | 
					  def larger_media_format?
 | 
				
			||||||
    video? || gifv? || audio?
 | 
					    video? || gifv? || audio?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
				
			|||||||
@ -209,6 +209,16 @@ class Status < ApplicationRecord
 | 
				
			|||||||
    public_visibility? || unlisted_visibility?
 | 
					    public_visibility? || unlisted_visibility?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def snapshot!(media_attachments_changed: false, account_id: nil, at_time: nil)
 | 
				
			||||||
 | 
					    edits.create!(
 | 
				
			||||||
 | 
					      text: text,
 | 
				
			||||||
 | 
					      spoiler_text: spoiler_text,
 | 
				
			||||||
 | 
					      media_attachments_changed: media_attachments_changed,
 | 
				
			||||||
 | 
					      account_id: account_id || self.account_id,
 | 
				
			||||||
 | 
					      created_at: at_time || edited_at
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def edited?
 | 
					  def edited?
 | 
				
			||||||
    edited_at.present?
 | 
					    edited_at.present?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
				
			|||||||
@ -38,7 +38,7 @@ class StatusPolicy < ApplicationPolicy
 | 
				
			|||||||
  alias unreblog? destroy?
 | 
					  alias unreblog? destroy?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def update?
 | 
					  def update?
 | 
				
			||||||
    staff?
 | 
					    staff? || owned?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
				
			|||||||
@ -217,24 +217,18 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    return if @status.edits.any?
 | 
					    return if @status.edits.any?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @status.edits.create(
 | 
					    @status.snapshot!(
 | 
				
			||||||
      text: @status.text,
 | 
					 | 
				
			||||||
      spoiler_text: @status.spoiler_text,
 | 
					 | 
				
			||||||
      media_attachments_changed: false,
 | 
					      media_attachments_changed: false,
 | 
				
			||||||
      account_id: @account.id,
 | 
					      at_time: @status.created_at
 | 
				
			||||||
      created_at: @status.created_at
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def create_edit!
 | 
					  def create_edit!
 | 
				
			||||||
    return unless significant_changes?
 | 
					    return unless significant_changes?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @status_edit = @status.edits.create(
 | 
					    @status.snapshot!(
 | 
				
			||||||
      text: @status.text,
 | 
					 | 
				
			||||||
      spoiler_text: @status.spoiler_text,
 | 
					 | 
				
			||||||
      media_attachments_changed: @media_attachments_changed || @poll_changed,
 | 
					      media_attachments_changed: @media_attachments_changed || @poll_changed,
 | 
				
			||||||
      account_id: @account.id,
 | 
					      account_id: @account.id
 | 
				
			||||||
      created_at: @status.edited_at
 | 
					 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -1,20 +1,40 @@
 | 
				
			|||||||
# frozen_string_literal: true
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ProcessHashtagsService < BaseService
 | 
					class ProcessHashtagsService < BaseService
 | 
				
			||||||
  def call(status, tags = [])
 | 
					  def call(status, raw_tags = [])
 | 
				
			||||||
    tags    = Extractor.extract_hashtags(status.text) if status.local?
 | 
					    @status        = status
 | 
				
			||||||
    records = []
 | 
					    @account       = status.account
 | 
				
			||||||
 | 
					    @raw_tags      = status.local? ? Extractor.extract_hashtags(status.text) : raw_tags
 | 
				
			||||||
 | 
					    @previous_tags = status.tags.to_a
 | 
				
			||||||
 | 
					    @current_tags  = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    Tag.find_or_create_by_names(tags) do |tag|
 | 
					    assign_tags!
 | 
				
			||||||
      status.tags << tag
 | 
					    update_featured_tags!
 | 
				
			||||||
      records << tag
 | 
					  end
 | 
				
			||||||
      tag.update(last_status_at: status.created_at) if tag.last_status_at.nil? || (tag.last_status_at < status.created_at && tag.last_status_at < 12.hours.ago)
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def assign_tags!
 | 
				
			||||||
 | 
					    @status.tags = @current_tags = Tag.find_or_create_by_names(@raw_tags)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def update_featured_tags!
 | 
				
			||||||
 | 
					    return unless @status.distributable?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    added_tags = @current_tags - @previous_tags
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    unless added_tags.empty?
 | 
				
			||||||
 | 
					      @account.featured_tags.where(tag_id: added_tags.map(&:id)).each do |featured_tag|
 | 
				
			||||||
 | 
					        featured_tag.increment(@status.created_at)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return unless status.distributable?
 | 
					    removed_tags = @previous_tags - @current_tags
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    status.account.featured_tags.where(tag_id: records.map(&:id)).each do |featured_tag|
 | 
					    unless removed_tags.empty?
 | 
				
			||||||
      featured_tag.increment(status.created_at)
 | 
					      @account.featured_tags.where(tag_id: removed_tags.map(&:id)).each do |featured_tag|
 | 
				
			||||||
 | 
					        featured_tag.decrement(@status.id)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										156
									
								
								app/services/update_status_service.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								app/services/update_status_service.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,156 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class UpdateStatusService < BaseService
 | 
				
			||||||
 | 
					  include Redisable
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  # @param [Status] status
 | 
				
			||||||
 | 
					  # @param [Integer] account_id
 | 
				
			||||||
 | 
					  # @param [Hash] options
 | 
				
			||||||
 | 
					  # @option options [Array<Integer>] :media_ids
 | 
				
			||||||
 | 
					  # @option options [Hash] :poll
 | 
				
			||||||
 | 
					  # @option options [String] :text
 | 
				
			||||||
 | 
					  # @option options [String] :spoiler_text
 | 
				
			||||||
 | 
					  # @option options [Boolean] :sensitive
 | 
				
			||||||
 | 
					  # @option options [String] :language
 | 
				
			||||||
 | 
					  def call(status, account_id, options = {})
 | 
				
			||||||
 | 
					    @status                    = status
 | 
				
			||||||
 | 
					    @options                   = options
 | 
				
			||||||
 | 
					    @account_id                = account_id
 | 
				
			||||||
 | 
					    @media_attachments_changed = false
 | 
				
			||||||
 | 
					    @poll_changed              = false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Status.transaction do
 | 
				
			||||||
 | 
					      create_previous_edit!
 | 
				
			||||||
 | 
					      update_media_attachments!
 | 
				
			||||||
 | 
					      update_poll!
 | 
				
			||||||
 | 
					      update_immediate_attributes!
 | 
				
			||||||
 | 
					      create_edit!
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    queue_poll_notifications!
 | 
				
			||||||
 | 
					    reset_preview_card!
 | 
				
			||||||
 | 
					    update_metadata!
 | 
				
			||||||
 | 
					    broadcast_updates!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @status
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def update_media_attachments!
 | 
				
			||||||
 | 
					    previous_media_attachments = @status.media_attachments.to_a
 | 
				
			||||||
 | 
					    next_media_attachments     = validate_media!
 | 
				
			||||||
 | 
					    removed_media_attachments  = previous_media_attachments - next_media_attachments
 | 
				
			||||||
 | 
					    added_media_attachments    = next_media_attachments - previous_media_attachments
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    MediaAttachment.where(id: removed_media_attachments.map(&:id)).update_all(status_id: nil)
 | 
				
			||||||
 | 
					    MediaAttachment.where(id: added_media_attachments.map(&:id)).update_all(status_id: @status.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @status.media_attachments.reload
 | 
				
			||||||
 | 
					    @media_attachments_changed = true if removed_media_attachments.any? || added_media_attachments.any?
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def validate_media!
 | 
				
			||||||
 | 
					    return [] if @options[:media_ids].blank? || !@options[:media_ids].is_a?(Enumerable)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.too_many') if @options[:media_ids].size > 4 || @options[:poll].present?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    media_attachments = @status.account.media_attachments.where(status_id: [nil, @status.id]).where(scheduled_status_id: nil).where(id: @options[:media_ids].take(4).map(&:to_i)).to_a
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.images_and_video') if media_attachments.size > 1 && media_attachments.find(&:audio_or_video?)
 | 
				
			||||||
 | 
					    raise Mastodon::ValidationError, I18n.t('media_attachments.validations.not_ready') if media_attachments.any?(&:not_processed?)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    media_attachments
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def update_poll!
 | 
				
			||||||
 | 
					    previous_poll        = @status.preloadable_poll
 | 
				
			||||||
 | 
					    @previous_expires_at = previous_poll&.expires_at
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if @options[:poll].present?
 | 
				
			||||||
 | 
					      poll = previous_poll || @status.account.polls.new(status: @status, votes_count: 0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      # If for some reasons the options were changed, it invalidates all previous
 | 
				
			||||||
 | 
					      # votes, so we need to remove them
 | 
				
			||||||
 | 
					      if @options[:poll][:options] != poll.options || ActiveModel::Type::Boolean.new.cast(@options[:poll][:multiple]) != poll.multiple
 | 
				
			||||||
 | 
					        @poll_changed = true
 | 
				
			||||||
 | 
					        poll.votes.delete_all unless poll.new_record?
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      poll.options     = @options[:poll][:options]
 | 
				
			||||||
 | 
					      poll.hide_totals = @options[:poll][:hide_totals] || false
 | 
				
			||||||
 | 
					      poll.multiple    = @options[:poll][:multiple] || false
 | 
				
			||||||
 | 
					      poll.expires_in  = @options[:poll][:expires_in]
 | 
				
			||||||
 | 
					      poll.save!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @status.poll_id = poll.id
 | 
				
			||||||
 | 
					    elsif previous_poll.present?
 | 
				
			||||||
 | 
					      previous_poll.destroy
 | 
				
			||||||
 | 
					      @poll_changed = true
 | 
				
			||||||
 | 
					      @status.poll_id = nil
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def update_immediate_attributes!
 | 
				
			||||||
 | 
					    @status.text         = @options[:text].presence || @options.delete(:spoiler_text) || ''
 | 
				
			||||||
 | 
					    @status.spoiler_text = @options[:spoiler_text] || ''
 | 
				
			||||||
 | 
					    @status.sensitive    = @options[:sensitive] || @options[:spoiler_text].present?
 | 
				
			||||||
 | 
					    @status.language     = language_from_option || @status.language
 | 
				
			||||||
 | 
					    @status.edited_at    = Time.now.utc
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @status.save!
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def language_from_option
 | 
				
			||||||
 | 
					    ISO_639.find(@options[:language])&.alpha2
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def reset_preview_card!
 | 
				
			||||||
 | 
					    return unless @status.text_previously_changed?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @status.preview_cards.clear
 | 
				
			||||||
 | 
					    LinkCrawlWorker.perform_async(@status.id)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def update_metadata!
 | 
				
			||||||
 | 
					    ProcessHashtagsService.new.call(@status)
 | 
				
			||||||
 | 
					    ProcessMentionsService.new.call(@status)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def broadcast_updates!
 | 
				
			||||||
 | 
					    DistributionWorker.perform_async(@status.id, { 'update' => true })
 | 
				
			||||||
 | 
					    ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def queue_poll_notifications!
 | 
				
			||||||
 | 
					    poll = @status.preloadable_poll
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # If the poll had no expiration date set but now has, or now has a sooner
 | 
				
			||||||
 | 
					    # expiration date, and people have voted, schedule a notification
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return unless poll.present? && poll.expires_at.present? && poll.votes.exists?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    PollExpirationNotifyWorker.remove_from_scheduled(poll.id) if @previous_expires_at.present? && @previous_expires_at > poll.expires_at
 | 
				
			||||||
 | 
					    PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def create_previous_edit!
 | 
				
			||||||
 | 
					    # We only need to create a previous edit when no previous edits exist, e.g.
 | 
				
			||||||
 | 
					    # when the status has never been edited. For other cases, we always create
 | 
				
			||||||
 | 
					    # an edit, so the step can be skipped
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return if @status.edits.any?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @status.snapshot!(
 | 
				
			||||||
 | 
					      media_attachments_changed: false,
 | 
				
			||||||
 | 
					      at_time: @status.created_at
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def create_edit!
 | 
				
			||||||
 | 
					    @status.snapshot!(
 | 
				
			||||||
 | 
					      media_attachments_changed: @media_attachments_changed || @poll_changed,
 | 
				
			||||||
 | 
					      account_id: @account_id
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										28
									
								
								app/workers/activitypub/status_update_distribution_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/workers/activitypub/status_update_distribution_worker.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,28 @@
 | 
				
			|||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ActivityPub::StatusUpdateDistributionWorker < ActivityPub::DistributionWorker
 | 
				
			||||||
 | 
					  # Distribute an profile update to servers that might have a copy
 | 
				
			||||||
 | 
					  # of the account in question
 | 
				
			||||||
 | 
					  def perform(status_id, options = {})
 | 
				
			||||||
 | 
					    @options = options.with_indifferent_access
 | 
				
			||||||
 | 
					    @status  = Status.find(status_id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    distribute!
 | 
				
			||||||
 | 
					  rescue ActiveRecord::RecordNotFound
 | 
				
			||||||
 | 
					    true
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  protected
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def activity
 | 
				
			||||||
 | 
					    ActivityPub::ActivityPresenter.new(
 | 
				
			||||||
 | 
					      id: [ActivityPub::TagManager.instance.uri_for(@status), '#updates/', @status.edited_at.to_i].join,
 | 
				
			||||||
 | 
					      type: 'Update',
 | 
				
			||||||
 | 
					      actor: ActivityPub::TagManager.instance.uri_for(@status.account),
 | 
				
			||||||
 | 
					      published: @status.edited_at,
 | 
				
			||||||
 | 
					      to: ActivityPub::TagManager.instance.to(@status),
 | 
				
			||||||
 | 
					      cc: ActivityPub::TagManager.instance.cc(@status),
 | 
				
			||||||
 | 
					      virtual_object: @status
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
@ -332,7 +332,7 @@ Rails.application.routes.draw do
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    # JSON / REST API
 | 
					    # JSON / REST API
 | 
				
			||||||
    namespace :v1 do
 | 
					    namespace :v1 do
 | 
				
			||||||
      resources :statuses, only: [:create, :show, :destroy] do
 | 
					      resources :statuses, only: [:create, :show, :update, :destroy] do
 | 
				
			||||||
        scope module: :statuses do
 | 
					        scope module: :statuses do
 | 
				
			||||||
          resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
 | 
					          resources :reblogged_by, controller: :reblogged_by_accounts, only: :index
 | 
				
			||||||
          resources :favourited_by, controller: :favourited_by_accounts, only: :index
 | 
					          resources :favourited_by, controller: :favourited_by_accounts, only: :index
 | 
				
			||||||
 | 
				
			|||||||
@ -110,21 +110,24 @@ RSpec.describe Api::V1::MediaController, type: :controller do
 | 
				
			|||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    context 'when not attached to a status' do
 | 
					    context 'when the author \'s' do
 | 
				
			||||||
      let(:media) { Fabricate(:media_attachment, status: nil, account: user.account) }
 | 
					      let(:status) { nil }
 | 
				
			||||||
 | 
					      let(:media)  { Fabricate(:media_attachment, status: status, account: user.account) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        put :update, params: { id: media.id, description: 'Lorem ipsum!!!' }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      it 'updates the description' do
 | 
					      it 'updates the description' do
 | 
				
			||||||
        put :update, params: { id: media.id, description: 'Lorem ipsum!!!' }
 | 
					 | 
				
			||||||
        expect(media.reload.description).to eq 'Lorem ipsum!!!'
 | 
					        expect(media.reload.description).to eq 'Lorem ipsum!!!'
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    context 'when attached to a status' do
 | 
					      context 'when already attached to a status' do
 | 
				
			||||||
      let(:media) { Fabricate(:media_attachment, status: Fabricate(:status), account: user.account) }
 | 
					        let(:status) { Fabricate(:status, account: user.account) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      it 'returns http not found' do
 | 
					        it 'returns http not found' do
 | 
				
			||||||
        put :update, params: { id: media.id, description: 'Lorem ipsum!!!' }
 | 
					          expect(response).to have_http_status(:not_found)
 | 
				
			||||||
        expect(response).to have_http_status(:not_found)
 | 
					        end
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
				
			|||||||
@ -102,6 +102,23 @@ RSpec.describe Api::V1::StatusesController, type: :controller do
 | 
				
			|||||||
        expect(Status.find_by(id: status.id)).to be nil
 | 
					        expect(Status.find_by(id: status.id)).to be nil
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    describe 'PUT #update' do
 | 
				
			||||||
 | 
					      let(:scopes) { 'write:statuses' }
 | 
				
			||||||
 | 
					      let(:status) { Fabricate(:status, account: user.account) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        put :update, params: { id: status.id, status: 'I am updated' }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'returns http success' do
 | 
				
			||||||
 | 
					        expect(response).to have_http_status(200)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates the status' do
 | 
				
			||||||
 | 
					        expect(status.reload.text).to eq 'I am updated'
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  context 'without an oauth token' do
 | 
					  context 'without an oauth token' do
 | 
				
			||||||
 | 
				
			|||||||
@ -125,7 +125,7 @@ RSpec.describe StatusPolicy, type: :model do
 | 
				
			|||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  permissions :index?, :update? do
 | 
					  permissions :index? do
 | 
				
			||||||
    it 'grants access if staff' do
 | 
					    it 'grants access if staff' do
 | 
				
			||||||
      expect(subject).to permit(admin.account)
 | 
					      expect(subject).to permit(admin.account)
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
@ -134,4 +134,18 @@ RSpec.describe StatusPolicy, type: :model do
 | 
				
			|||||||
      expect(subject).to_not permit(alice)
 | 
					      expect(subject).to_not permit(alice)
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  permissions :update? do
 | 
				
			||||||
 | 
					    it 'grants access if staff' do
 | 
				
			||||||
 | 
					      expect(subject).to permit(admin.account, status)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'grants access if owner' do
 | 
				
			||||||
 | 
					      expect(subject).to permit(status.account, status)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'denies access unless staff' do
 | 
				
			||||||
 | 
					      expect(subject).to_not permit(bob, status)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										248
									
								
								spec/services/activitypub/process_status_update_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										248
									
								
								spec/services/activitypub/process_status_update_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,248 @@
 | 
				
			|||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RSpec.describe ActivityPub::ProcessStatusUpdateService, type: :service do
 | 
				
			||||||
 | 
					  let!(:status) { Fabricate(:status, text: 'Hello world', account: Fabricate(:account, domain: 'example.com')) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let(:alice) { Fabricate(:account) }
 | 
				
			||||||
 | 
					  let(:bob) { Fabricate(:account) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let(:mentions) { [] }
 | 
				
			||||||
 | 
					  let(:tags) { [] }
 | 
				
			||||||
 | 
					  let(:media_attachments) { [] }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  before do
 | 
				
			||||||
 | 
					    mentions.each { |a| Fabricate(:mention, status: status, account: a) }
 | 
				
			||||||
 | 
					    tags.each { |t| status.tags << t }
 | 
				
			||||||
 | 
					    media_attachments.each { |m| status.media_attachments << m }
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let(:payload) do
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      '@context': 'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					      id: 'foo',
 | 
				
			||||||
 | 
					      type: 'Note',
 | 
				
			||||||
 | 
					      summary: 'Show more',
 | 
				
			||||||
 | 
					      content: 'Hello universe',
 | 
				
			||||||
 | 
					      updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					      tag: [
 | 
				
			||||||
 | 
					        { type: 'Hashtag', name: 'hoge' },
 | 
				
			||||||
 | 
					        { type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) },
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  let(:json) { Oj.load(Oj.dump(payload)) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  subject { described_class.new }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe '#call' do
 | 
				
			||||||
 | 
					    it 'updates text' do
 | 
				
			||||||
 | 
					      subject.call(status, json)
 | 
				
			||||||
 | 
					      expect(status.reload.text).to eq 'Hello universe'
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'updates content warning' do
 | 
				
			||||||
 | 
					      subject.call(status, json)
 | 
				
			||||||
 | 
					      expect(status.reload.spoiler_text).to eq 'Show more'
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'originally without tags' do
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        subject.call(status, json)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates tags' do
 | 
				
			||||||
 | 
					        expect(status.tags.reload.map(&:name)).to eq %w(hoge)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'originally with tags' do
 | 
				
			||||||
 | 
					      let(:tags) { [Fabricate(:tag, name: 'test'), Fabricate(:tag, name: 'foo')] }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let(:payload) do
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          '@context': 'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          id: 'foo',
 | 
				
			||||||
 | 
					          type: 'Note',
 | 
				
			||||||
 | 
					          summary: 'Show more',
 | 
				
			||||||
 | 
					          content: 'Hello universe',
 | 
				
			||||||
 | 
					          updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					          tag: [
 | 
				
			||||||
 | 
					            { type: 'Hashtag', name: 'foo' },
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        subject.call(status, json)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates tags' do
 | 
				
			||||||
 | 
					        expect(status.tags.reload.map(&:name)).to eq %w(foo)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'originally without mentions' do
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        subject.call(status, json)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates mentions' do
 | 
				
			||||||
 | 
					        expect(status.active_mentions.reload.map(&:account_id)).to eq [alice.id]
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'originally with mentions' do
 | 
				
			||||||
 | 
					      let(:mentions) { [alice, bob] }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        subject.call(status, json)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates mentions' do
 | 
				
			||||||
 | 
					        expect(status.active_mentions.reload.map(&:account_id)).to eq [alice.id]
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'originally without media attachments' do
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        allow(RedownloadMediaWorker).to receive(:perform_async)
 | 
				
			||||||
 | 
					        subject.call(status, json)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let(:payload) do
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          '@context': 'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          id: 'foo',
 | 
				
			||||||
 | 
					          type: 'Note',
 | 
				
			||||||
 | 
					          content: 'Hello universe',
 | 
				
			||||||
 | 
					          updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					          attachment: [
 | 
				
			||||||
 | 
					            { type: 'Image', mediaType: 'image/png', url: 'https://example.com/foo.png' },
 | 
				
			||||||
 | 
					          ]
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates media attachments' do
 | 
				
			||||||
 | 
					        media_attachment = status.media_attachments.reload.first
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(media_attachment).to_not be_nil
 | 
				
			||||||
 | 
					        expect(media_attachment.remote_url).to eq 'https://example.com/foo.png'
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'queues download of media attachments' do
 | 
				
			||||||
 | 
					        expect(RedownloadMediaWorker).to have_received(:perform_async)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'records media change in edit' do
 | 
				
			||||||
 | 
					        expect(status.edits.reload.last.media_attachments_changed).to be true
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'originally with media attachments' do
 | 
				
			||||||
 | 
					      let(:media_attachments) { [Fabricate(:media_attachment, remote_url: 'https://example.com/foo.png'), Fabricate(:media_attachment, remote_url: 'https://example.com/unused.png')] }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      let(:payload) do
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          '@context': 'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          id: 'foo',
 | 
				
			||||||
 | 
					          type: 'Note',
 | 
				
			||||||
 | 
					          content: 'Hello universe',
 | 
				
			||||||
 | 
					          updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					          attachment: [
 | 
				
			||||||
 | 
					            { type: 'Image', mediaType: 'image/png', url: 'https://example.com/foo.png', name: 'A picture' },
 | 
				
			||||||
 | 
					          ]
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        allow(RedownloadMediaWorker).to receive(:perform_async)
 | 
				
			||||||
 | 
					        subject.call(status, json)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates the existing media attachment in-place' do
 | 
				
			||||||
 | 
					        media_attachment = status.media_attachments.reload.first
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(media_attachment).to_not be_nil
 | 
				
			||||||
 | 
					        expect(media_attachment.remote_url).to eq 'https://example.com/foo.png'
 | 
				
			||||||
 | 
					        expect(media_attachment.description).to eq 'A picture'
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'does not queue redownload for the existing media attachment' do
 | 
				
			||||||
 | 
					        expect(RedownloadMediaWorker).to_not have_received(:perform_async)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'updates media attachments' do
 | 
				
			||||||
 | 
					        expect(status.media_attachments.reload.map(&:remote_url)).to eq %w(https://example.com/foo.png)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'records media change in edit' do
 | 
				
			||||||
 | 
					        expect(status.edits.reload.last.media_attachments_changed).to be true
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'originally with a poll' do
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        poll = Fabricate(:poll, status: status)
 | 
				
			||||||
 | 
					        status.update(preloadable_poll: poll)
 | 
				
			||||||
 | 
					        subject.call(status, json)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'removes poll' do
 | 
				
			||||||
 | 
					        expect(status.reload.poll).to eq nil
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'records media change in edit' do
 | 
				
			||||||
 | 
					        expect(status.edits.reload.last.media_attachments_changed).to be true
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'originally without a poll' do
 | 
				
			||||||
 | 
					      let(:payload) do
 | 
				
			||||||
 | 
					        {
 | 
				
			||||||
 | 
					          '@context': 'https://www.w3.org/ns/activitystreams',
 | 
				
			||||||
 | 
					          id: 'foo',
 | 
				
			||||||
 | 
					          type: 'Question',
 | 
				
			||||||
 | 
					          content: 'Hello universe',
 | 
				
			||||||
 | 
					          updated: '2021-09-08T22:39:25Z',
 | 
				
			||||||
 | 
					          closed: true,
 | 
				
			||||||
 | 
					          oneOf: [
 | 
				
			||||||
 | 
					            { type: 'Note', name: 'Foo' },
 | 
				
			||||||
 | 
					            { type: 'Note', name: 'Bar' },
 | 
				
			||||||
 | 
					            { type: 'Note', name: 'Baz' },
 | 
				
			||||||
 | 
					          ],
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      before do
 | 
				
			||||||
 | 
					        subject.call(status, json)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'creates a poll' do
 | 
				
			||||||
 | 
					        poll = status.reload.poll
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        expect(poll).to_not be_nil
 | 
				
			||||||
 | 
					        expect(poll.options).to eq %w(Foo Bar Baz)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'records media change in edit' do
 | 
				
			||||||
 | 
					        expect(status.edits.reload.last.media_attachments_changed).to be true
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'creates edit history' do
 | 
				
			||||||
 | 
					      subject.call(status, json)
 | 
				
			||||||
 | 
					      expect(status.edits.reload.map(&:text)).to eq ['Hello world', 'Hello universe']
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'sets edited timestamp' do
 | 
				
			||||||
 | 
					      subject.call(status, json)
 | 
				
			||||||
 | 
					      expect(status.reload.edited_at.to_s).to eq '2021-09-08 22:39:25 UTC'
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'records that no media has been changed in edit' do
 | 
				
			||||||
 | 
					      subject.call(status, json)
 | 
				
			||||||
 | 
					      expect(status.edits.reload.last.media_attachments_changed).to be false
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
							
								
								
									
										130
									
								
								spec/services/update_status_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								spec/services/update_status_service_spec.rb
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,130 @@
 | 
				
			|||||||
 | 
					require 'rails_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					RSpec.describe UpdateStatusService, type: :service do
 | 
				
			||||||
 | 
					  subject { described_class.new }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when text changes' do
 | 
				
			||||||
 | 
					    let!(:status) { Fabricate(:status, text: 'Foo') }
 | 
				
			||||||
 | 
					    let(:preview_card) { Fabricate(:preview_card) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      status.preview_cards << preview_card
 | 
				
			||||||
 | 
					      subject.call(status, status.account_id, text: 'Bar')
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'updates text' do
 | 
				
			||||||
 | 
					      expect(status.reload.text).to eq 'Bar'
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'resets preview card' do
 | 
				
			||||||
 | 
					      expect(status.reload.preview_card).to be_nil
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'saves edit history' do
 | 
				
			||||||
 | 
					      expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Bar', false]]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when content warning changes' do
 | 
				
			||||||
 | 
					    let!(:status) { Fabricate(:status, text: 'Foo', spoiler_text: '') }
 | 
				
			||||||
 | 
					    let(:preview_card) { Fabricate(:preview_card) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      status.preview_cards << preview_card
 | 
				
			||||||
 | 
					      subject.call(status, status.account_id, text: 'Foo', spoiler_text: 'Bar')
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'updates content warning' do
 | 
				
			||||||
 | 
					      expect(status.reload.spoiler_text).to eq 'Bar'
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'saves edit history' do
 | 
				
			||||||
 | 
					      expect(status.edits.pluck(:text, :spoiler_text, :media_attachments_changed)).to eq [['Foo', '', false], ['Foo', 'Bar', false]]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when media attachments change' do
 | 
				
			||||||
 | 
					    let!(:status) { Fabricate(:status, text: 'Foo') }
 | 
				
			||||||
 | 
					    let!(:detached_media_attachment) { Fabricate(:media_attachment, account: status.account) }
 | 
				
			||||||
 | 
					    let!(:attached_media_attachment) { Fabricate(:media_attachment, account: status.account) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      status.media_attachments << detached_media_attachment
 | 
				
			||||||
 | 
					      subject.call(status, status.account_id, text: 'Foo', media_ids: [attached_media_attachment.id])
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'updates media attachments' do
 | 
				
			||||||
 | 
					      expect(status.media_attachments.to_a).to eq [attached_media_attachment]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'detaches detached media attachments' do
 | 
				
			||||||
 | 
					      expect(detached_media_attachment.reload.status_id).to be_nil
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'attaches attached media attachments' do
 | 
				
			||||||
 | 
					      expect(attached_media_attachment.reload.status_id).to eq status.id
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'saves edit history' do
 | 
				
			||||||
 | 
					      expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Foo', true]]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when poll changes' do
 | 
				
			||||||
 | 
					    let!(:status) { Fabricate(:status, text: 'Foo') }
 | 
				
			||||||
 | 
					    let!(:poll) { Fabricate(:poll, options: %w(Foo Bar)) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      status.update(poll: poll)
 | 
				
			||||||
 | 
					      subject.call(status, status.account_id, text: 'Foo', poll: { options: %w(Bar Baz), expires_in: 5.days.to_i })
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'updates poll' do
 | 
				
			||||||
 | 
					      expect(status.poll).to_not eq poll
 | 
				
			||||||
 | 
					      expect(status.poll.options).to eq %w(Bar Baz)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'saves edit history' do
 | 
				
			||||||
 | 
					      expect(status.edits.pluck(:text, :media_attachments_changed)).to eq [['Foo', false], ['Foo', true]]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when mentions in text change' do
 | 
				
			||||||
 | 
					    let!(:account) { Fabricate(:account) }
 | 
				
			||||||
 | 
					    let!(:alice) { Fabricate(:account, username: 'alice') }
 | 
				
			||||||
 | 
					    let!(:bob) { Fabricate(:account, username: 'bob') }
 | 
				
			||||||
 | 
					    let!(:status) { PostStatusService.new.call(account, text: 'Hello @alice') }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      subject.call(status, status.account_id, text: 'Hello @bob')
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'changes mentions' do
 | 
				
			||||||
 | 
					      expect(status.active_mentions.pluck(:account_id)).to eq [bob.id]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'keeps old mentions as silent mentions' do
 | 
				
			||||||
 | 
					      expect(status.mentions.pluck(:account_id)).to eq [alice.id, bob.id]
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  context 'when hashtags in text change' do
 | 
				
			||||||
 | 
					    let!(:account) { Fabricate(:account) }
 | 
				
			||||||
 | 
					    let!(:status) { PostStatusService.new.call(account, text: 'Hello #foo') }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    before do
 | 
				
			||||||
 | 
					      subject.call(status, status.account_id, text: 'Hello #bar')
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'changes tags' do
 | 
				
			||||||
 | 
					      expect(status.tags.pluck(:name)).to eq %w(bar)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  it 'notifies ActivityPub about the update' do
 | 
				
			||||||
 | 
					    status = Fabricate(:status, text: 'Foo')
 | 
				
			||||||
 | 
					    allow(ActivityPub::DistributionWorker).to receive(:perform_async)
 | 
				
			||||||
 | 
					    subject.call(status, status.account_id, text: 'Bar')
 | 
				
			||||||
 | 
					    expect(ActivityPub::DistributionWorker).to have_received(:perform_async)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user