Merge branch 'master' into glitch-soc/merge-upstream
Conflicts: - `.env.production.sample`: Upstream deleted it, I decided to keep it. - `package.json`: Upstream updated a dependency textually too close to wavesurfer.js which has been deleted from upstream but is kept in glitch-soc for now.
This commit is contained in:
30
app/controllers/api/v1/accounts/notes_controller.rb
Normal file
30
app/controllers/api/v1/accounts/notes_controller.rb
Normal file
@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::Accounts::NotesController < Api::BaseController
|
||||
include Authorization
|
||||
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }
|
||||
before_action :require_user!
|
||||
before_action :set_account
|
||||
|
||||
def create
|
||||
if params[:comment].blank?
|
||||
AccountNote.find_by(account: current_account, target_account: @account)&.destroy
|
||||
else
|
||||
@note = AccountNote.find_or_initialize_by(account: current_account, target_account: @account)
|
||||
@note.comment = params[:comment]
|
||||
@note.save! if @note.changed?
|
||||
end
|
||||
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships_presenter
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_account
|
||||
@account = Account.find(params[:account_id])
|
||||
end
|
||||
|
||||
def relationships_presenter
|
||||
AccountRelationshipsPresenter.new([@account.id], current_user.account_id)
|
||||
end
|
||||
end
|
@ -39,7 +39,7 @@ class Api::V1::MediaController < Api::BaseController
|
||||
end
|
||||
|
||||
def media_attachment_params
|
||||
params.permit(:file, :description, :focus)
|
||||
params.permit(:file, :thumbnail, :description, :focus)
|
||||
end
|
||||
|
||||
def file_type_error
|
||||
|
@ -28,8 +28,8 @@ class MediaProxyController < ApplicationController
|
||||
private
|
||||
|
||||
def redownload!
|
||||
@media_attachment.file_remote_url = @media_attachment.remote_url
|
||||
@media_attachment.created_at = Time.now.utc
|
||||
@media_attachment.download_file!
|
||||
@media_attachment.created_at = Time.now.utc
|
||||
@media_attachment.save!
|
||||
end
|
||||
|
||||
|
@ -7,13 +7,8 @@ module Settings
|
||||
before_action :set_picture
|
||||
|
||||
def destroy
|
||||
if valid_picture
|
||||
account_params = {
|
||||
@picture => nil,
|
||||
(@picture + '_remote_url') => nil,
|
||||
}
|
||||
|
||||
msg = UpdateAccountService.new.call(@account, account_params) ? I18n.t('generic.changes_saved_msg') : nil
|
||||
if valid_picture?
|
||||
msg = I18n.t('generic.changes_saved_msg') if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' })
|
||||
redirect_to settings_profile_path, notice: msg, status: 303
|
||||
else
|
||||
bad_request
|
||||
@ -30,8 +25,8 @@ module Settings
|
||||
@picture = params[:id]
|
||||
end
|
||||
|
||||
def valid_picture
|
||||
@picture == 'avatar' || @picture == 'header'
|
||||
def valid_picture?
|
||||
%w(avatar header).include?(@picture)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
69
app/javascript/mastodon/actions/account_notes.js
Normal file
69
app/javascript/mastodon/actions/account_notes.js
Normal file
@ -0,0 +1,69 @@
|
||||
import api from '../api';
|
||||
|
||||
export const ACCOUNT_NOTE_SUBMIT_REQUEST = 'ACCOUNT_NOTE_SUBMIT_REQUEST';
|
||||
export const ACCOUNT_NOTE_SUBMIT_SUCCESS = 'ACCOUNT_NOTE_SUBMIT_SUCCESS';
|
||||
export const ACCOUNT_NOTE_SUBMIT_FAIL = 'ACCOUNT_NOTE_SUBMIT_FAIL';
|
||||
|
||||
export const ACCOUNT_NOTE_INIT_EDIT = 'ACCOUNT_NOTE_INIT_EDIT';
|
||||
export const ACCOUNT_NOTE_CANCEL = 'ACCOUNT_NOTE_CANCEL';
|
||||
|
||||
export const ACCOUNT_NOTE_CHANGE_COMMENT = 'ACCOUNT_NOTE_CHANGE_COMMENT';
|
||||
|
||||
export function submitAccountNote() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch(submitAccountNoteRequest());
|
||||
|
||||
const id = getState().getIn(['account_notes', 'edit', 'account_id']);
|
||||
|
||||
api(getState).post(`/api/v1/accounts/${id}/note`, {
|
||||
comment: getState().getIn(['account_notes', 'edit', 'comment']),
|
||||
}).then(response => {
|
||||
dispatch(submitAccountNoteSuccess(response.data));
|
||||
}).catch(error => dispatch(submitAccountNoteFail(error)));
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteRequest() {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteSuccess(relationship) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
relationship,
|
||||
};
|
||||
};
|
||||
|
||||
export function submitAccountNoteFail(error) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
export function initEditAccountNote(account) {
|
||||
return (dispatch, getState) => {
|
||||
const comment = getState().getIn(['relationships', account.get('id'), 'note']);
|
||||
|
||||
dispatch({
|
||||
type: ACCOUNT_NOTE_INIT_EDIT,
|
||||
account,
|
||||
comment,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
export function cancelAccountNote() {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_CANCEL,
|
||||
};
|
||||
};
|
||||
|
||||
export function changeAccountNoteComment(comment) {
|
||||
return {
|
||||
type: ACCOUNT_NOTE_CHANGE_COMMENT,
|
||||
comment,
|
||||
};
|
||||
};
|
@ -1,4 +1,4 @@
|
||||
import { shallow } from 'enzyme';
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import Button from '../button';
|
||||
@ -21,16 +21,16 @@ describe('<Button />', () => {
|
||||
|
||||
it('handles click events using the given handler', () => {
|
||||
const handler = jest.fn();
|
||||
const button = shallow(<Button onClick={handler} />);
|
||||
button.find('button').simulate('click');
|
||||
render(<Button onClick={handler}>button</Button>);
|
||||
fireEvent.click(screen.getByText('button'));
|
||||
|
||||
expect(handler.mock.calls.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('does not handle click events if props.disabled given', () => {
|
||||
const handler = jest.fn();
|
||||
const button = shallow(<Button onClick={handler} disabled />);
|
||||
button.find('button').simulate('click');
|
||||
render(<Button onClick={handler} disabled>button</Button>);
|
||||
fireEvent.click(screen.getByText('button'));
|
||||
|
||||
expect(handler.mock.calls.length).toEqual(0);
|
||||
});
|
||||
|
@ -352,7 +352,8 @@ class Status extends ImmutablePureComponent {
|
||||
<Component
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
poster={status.getIn(['account', 'avatar_static'])}
|
||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={110}
|
||||
|
@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: { id: 'account_note.placeholder', defaultMessage: 'No comment provided' },
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
class Header extends ImmutablePureComponent {
|
||||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
isEditing: PropTypes.bool,
|
||||
isSubmitting: PropTypes.bool,
|
||||
accountNote: PropTypes.string,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
onCancelAccountNote: PropTypes.func.isRequired,
|
||||
onSaveAccountNote: PropTypes.func.isRequired,
|
||||
onChangeAccountNote: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleChangeAccountNote = (e) => {
|
||||
this.props.onChangeAccountNote(e.target.value);
|
||||
};
|
||||
|
||||
componentWillUnmount () {
|
||||
if (this.props.isEditing) {
|
||||
this.props.onCancelAccountNote();
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown = e => {
|
||||
if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
|
||||
this.props.onSaveAccountNote();
|
||||
} else if (e.keyCode === 27) {
|
||||
this.props.onCancelAccountNote();
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, accountNote, isEditing, isSubmitting, intl } = this.props;
|
||||
|
||||
if (!account || (!accountNote && !isEditing)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let action_buttons = null;
|
||||
if (isEditing) {
|
||||
action_buttons = (
|
||||
<div className='account__header__account-note__buttons'>
|
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onCancelAccountNote} disabled={isSubmitting}>
|
||||
<Icon id='times' size={15} /> <FormattedMessage id='account_note.cancel' defaultMessage='Cancel' />
|
||||
</button>
|
||||
<div className='flex-spacer' />
|
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onSaveAccountNote} disabled={isSubmitting}>
|
||||
<Icon id='check' size={15} /> <FormattedMessage id='account_note.save' defaultMessage='Save' />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let note_container = null;
|
||||
if (isEditing) {
|
||||
note_container = (
|
||||
<Textarea
|
||||
className='account__header__account-note__content'
|
||||
disabled={isSubmitting}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
value={accountNote}
|
||||
onChange={this.handleChangeAccountNote}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
note_container = (<div className='account__header__account-note__content'>{accountNote}</div>);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='account__header__account-note'>
|
||||
<div className='account__header__account-note__header'>
|
||||
<strong><FormattedMessage id='account.account_note_header' defaultMessage='Your note for @{name}' values={{ name: account.get('username') }} /></strong>
|
||||
{!isEditing && (
|
||||
<div>
|
||||
<button className='text-btn' tabIndex='0' onClick={this.props.onEditAccountNote} disabled={isSubmitting}>
|
||||
<Icon id='pencil' size={15} /> <FormattedMessage id='account_note.edit' defaultMessage='Edit' />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{note_container}
|
||||
{action_buttons}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
@ -11,6 +11,7 @@ import Avatar from 'mastodon/components/avatar';
|
||||
import { shortNumberFormat } from 'mastodon/utils/numbers';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_container';
|
||||
import AccountNoteContainer from '../containers/account_note_container';
|
||||
|
||||
const messages = defineMessages({
|
||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||
@ -45,6 +46,7 @@ const messages = defineMessages({
|
||||
unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
|
||||
add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
|
||||
admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
|
||||
add_account_note: { id: 'account.add_account_note', defaultMessage: 'Add note for @{name}' },
|
||||
});
|
||||
|
||||
const dateFormatOptions = {
|
||||
@ -64,6 +66,7 @@ class Header extends ImmutablePureComponent {
|
||||
identity_props: ImmutablePropTypes.list,
|
||||
onFollow: PropTypes.func.isRequired,
|
||||
onBlock: PropTypes.func.isRequired,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
domain: PropTypes.string.isRequired,
|
||||
};
|
||||
@ -128,6 +131,8 @@ class Header extends ImmutablePureComponent {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accountNote = account.getIn(['relationship', 'note']);
|
||||
|
||||
let info = [];
|
||||
let actionBtn = '';
|
||||
let lockedIcon = '';
|
||||
@ -178,6 +183,10 @@ class Header extends ImmutablePureComponent {
|
||||
menu.push(null);
|
||||
}
|
||||
|
||||
if (accountNote === null) {
|
||||
menu.push({ text: intl.formatMessage(messages.add_account_note, { name: account.get('username') }), action: this.props.onEditAccountNote });
|
||||
}
|
||||
|
||||
if (account.get('id') === me) {
|
||||
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
|
||||
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
|
||||
@ -284,6 +293,8 @@ class Header extends ImmutablePureComponent {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<AccountNoteContainer account={account} />
|
||||
|
||||
<div className='account__header__extra'>
|
||||
<div className='account__header__bio'>
|
||||
{ (fields.size > 0 || identity_proofs.size > 0) && (
|
||||
|
@ -0,0 +1,34 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { changeAccountNoteComment, submitAccountNote, initEditAccountNote, cancelAccountNote } from 'mastodon/actions/account_notes';
|
||||
import AccountNote from '../components/account_note';
|
||||
|
||||
const mapStateToProps = (state, { account }) => {
|
||||
const isEditing = state.getIn(['account_notes', 'edit', 'account_id']) === account.get('id');
|
||||
|
||||
return {
|
||||
isSubmitting: state.getIn(['account_notes', 'edit', 'isSubmitting']),
|
||||
accountNote: isEditing ? state.getIn(['account_notes', 'edit', 'comment']) : account.getIn(['relationship', 'note']),
|
||||
isEditing,
|
||||
};
|
||||
};
|
||||
|
||||
const mapDispatchToProps = (dispatch, { account }) => ({
|
||||
|
||||
onEditAccountNote() {
|
||||
dispatch(initEditAccountNote(account));
|
||||
},
|
||||
|
||||
onSaveAccountNote() {
|
||||
dispatch(submitAccountNote());
|
||||
},
|
||||
|
||||
onCancelAccountNote() {
|
||||
dispatch(cancelAccountNote());
|
||||
},
|
||||
|
||||
onChangeAccountNote(comment) {
|
||||
dispatch(changeAccountNoteComment(comment));
|
||||
},
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(AccountNote);
|
@ -23,6 +23,7 @@ export default class Header extends ImmutablePureComponent {
|
||||
onUnblockDomain: PropTypes.func.isRequired,
|
||||
onEndorseToggle: PropTypes.func.isRequired,
|
||||
onAddToList: PropTypes.func.isRequired,
|
||||
onEditAccountNote: PropTypes.func.isRequired,
|
||||
hideTabs: PropTypes.bool,
|
||||
domain: PropTypes.string.isRequired,
|
||||
};
|
||||
@ -83,6 +84,10 @@ export default class Header extends ImmutablePureComponent {
|
||||
this.props.onAddToList(this.props.account);
|
||||
}
|
||||
|
||||
handleEditAccountNote = () => {
|
||||
this.props.onEditAccountNote(this.props.account);
|
||||
}
|
||||
|
||||
render () {
|
||||
const { account, hideTabs, identity_proofs } = this.props;
|
||||
|
||||
@ -108,6 +113,7 @@ export default class Header extends ImmutablePureComponent {
|
||||
onUnblockDomain={this.handleUnblockDomain}
|
||||
onEndorseToggle={this.handleEndorseToggle}
|
||||
onAddToList={this.handleAddToList}
|
||||
onEditAccountNote={this.handleEditAccountNote}
|
||||
domain={this.props.domain}
|
||||
/>
|
||||
|
||||
|
@ -19,6 +19,7 @@ import { initBlockModal } from '../../../actions/blocks';
|
||||
import { initReport } from '../../../actions/reports';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
import { blockDomain, unblockDomain } from '../../../actions/domain_blocks';
|
||||
import { initEditAccountNote } from 'mastodon/actions/account_notes';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { unfollowModal } from '../../../initial_state';
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
@ -102,6 +103,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
}
|
||||
},
|
||||
|
||||
onEditAccountNote (account) {
|
||||
dispatch(initEditAccountNote(account));
|
||||
},
|
||||
|
||||
onBlockDomain (domain) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable. You will not see content from that domain in any public timelines or your notifications. Your followers from that domain will be removed.' values={{ domain: <strong>{domain}</strong> }} />,
|
||||
|
@ -157,6 +157,7 @@ class Audio extends React.PureComponent {
|
||||
fullscreen: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
cacheWidth: PropTypes.func,
|
||||
blurhash: PropTypes.string,
|
||||
};
|
||||
|
||||
state = {
|
||||
@ -222,32 +223,42 @@ class Audio extends React.PureComponent {
|
||||
window.addEventListener('scroll', this.handleScroll);
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => this.handlePosterLoad(img);
|
||||
img.src = this.props.poster;
|
||||
if (!this.props.blurhash) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => this.handlePosterLoad(img);
|
||||
img.src = this.props.poster;
|
||||
} else {
|
||||
this._setColorScheme();
|
||||
this._decodeBlurhash();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate (prevProps, prevState) {
|
||||
if (prevProps.poster !== this.props.poster) {
|
||||
if (prevProps.poster !== this.props.poster && !this.props.blurhash) {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => this.handlePosterLoad(img);
|
||||
img.src = this.props.poster;
|
||||
}
|
||||
|
||||
if (prevState.blurhash !== this.state.blurhash) {
|
||||
const context = this.blurhashCanvas.getContext('2d');
|
||||
const pixels = decode(this.state.blurhash, 32, 32);
|
||||
const outputImageData = new ImageData(pixels, 32, 32);
|
||||
|
||||
context.putImageData(outputImageData, 0, 0);
|
||||
if (prevState.blurhash !== this.state.blurhash || prevProps.blurhash !== this.props.blurhash) {
|
||||
this._setColorScheme();
|
||||
this._decodeBlurhash();
|
||||
}
|
||||
|
||||
this._clear();
|
||||
this._draw();
|
||||
}
|
||||
|
||||
_decodeBlurhash () {
|
||||
const context = this.blurhashCanvas.getContext('2d');
|
||||
const pixels = decode(this.props.blurhash || this.state.blurhash, 32, 32);
|
||||
const outputImageData = new ImageData(pixels, 32, 32);
|
||||
|
||||
context.putImageData(outputImageData, 0, 0);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
@ -415,7 +426,7 @@ class Audio extends React.PureComponent {
|
||||
}
|
||||
|
||||
handlePosterLoad = image => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
canvas.width = image.width;
|
||||
@ -425,10 +436,15 @@ class Audio extends React.PureComponent {
|
||||
|
||||
const inputImageData = context.getImageData(0, 0, image.width, image.height);
|
||||
const blurhash = encode(inputImageData.data, image.width, image.height, 4, 4);
|
||||
|
||||
this.setState({ blurhash });
|
||||
}
|
||||
|
||||
_setColorScheme () {
|
||||
const blurhash = this.props.blurhash || this.state.blurhash;
|
||||
const averageColor = decodeRGB(decode83(blurhash.slice(2, 6)));
|
||||
|
||||
this.setState({
|
||||
blurhash,
|
||||
color: adjustColor(averageColor),
|
||||
darkText: luma(averageColor) >= 165,
|
||||
});
|
||||
|
@ -6,7 +6,6 @@ import { FormattedMessage } from 'react-intl';
|
||||
import punycode from 'punycode';
|
||||
import classnames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import classNames from 'classnames';
|
||||
import { useBlurhash } from 'mastodon/initial_state';
|
||||
import { decode } from 'blurhash';
|
||||
import { debounce } from 'lodash';
|
||||
@ -231,7 +230,7 @@ export default class Card extends React.PureComponent {
|
||||
const height = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
|
||||
|
||||
const description = (
|
||||
<div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
|
||||
<div className='status-card__content'>
|
||||
{title}
|
||||
{!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
|
||||
<span className='status-card__host'>{provider}</span>
|
||||
@ -239,7 +238,7 @@ export default class Card extends React.PureComponent {
|
||||
);
|
||||
|
||||
let embed = '';
|
||||
let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
|
||||
let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classnames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
|
||||
let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
|
||||
let spoilerButton = (
|
||||
<button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
|
||||
@ -247,7 +246,7 @@ export default class Card extends React.PureComponent {
|
||||
</button>
|
||||
);
|
||||
spoilerButton = (
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
|
||||
<div className={classnames('spoiler-button', { 'spoiler-button--minified': revealed })}>
|
||||
{spoilerButton}
|
||||
</div>
|
||||
);
|
||||
@ -305,7 +304,6 @@ export default class Card extends React.PureComponent {
|
||||
<a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
|
||||
{embed}
|
||||
{description}
|
||||
{!revealed && spoilerButton}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
@ -125,7 +125,8 @@ class DetailedStatus extends ImmutablePureComponent {
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
poster={status.getIn(['account', 'avatar_static'])}
|
||||
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
height={150}
|
||||
/>
|
||||
);
|
||||
|
@ -1,25 +1,24 @@
|
||||
import { render, fireEvent, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import Column from '../column';
|
||||
import ColumnHeader from '../column_header';
|
||||
|
||||
describe('<Column />', () => {
|
||||
describe('<ColumnHeader /> click handler', () => {
|
||||
it('runs the scroll animation if the column contains scrollable content', () => {
|
||||
const wrapper = mount(
|
||||
const scrollToMock = jest.fn();
|
||||
const { container } = render(
|
||||
<Column heading='notifications'>
|
||||
<div className='scrollable' />
|
||||
</Column>,
|
||||
);
|
||||
const scrollToMock = jest.fn();
|
||||
wrapper.find(Column).find('.scrollable').getDOMNode().scrollTo = scrollToMock;
|
||||
wrapper.find(ColumnHeader).find('button').simulate('click');
|
||||
container.querySelector('.scrollable').scrollTo = scrollToMock;
|
||||
fireEvent.click(screen.getByText('notifications'));
|
||||
expect(scrollToMock).toHaveBeenCalledWith({ behavior: 'smooth', top: 0 });
|
||||
});
|
||||
|
||||
it('does not try to scroll if there is no scrollable content', () => {
|
||||
const wrapper = mount(<Column heading='notifications' />);
|
||||
wrapper.find(ColumnHeader).find('button').simulate('click');
|
||||
render(<Column heading='notifications' />);
|
||||
fireEvent.click(screen.getByText('notifications'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
44
app/javascript/mastodon/reducers/account_notes.js
Normal file
44
app/javascript/mastodon/reducers/account_notes.js
Normal file
@ -0,0 +1,44 @@
|
||||
import { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import {
|
||||
ACCOUNT_NOTE_INIT_EDIT,
|
||||
ACCOUNT_NOTE_CANCEL,
|
||||
ACCOUNT_NOTE_CHANGE_COMMENT,
|
||||
ACCOUNT_NOTE_SUBMIT_REQUEST,
|
||||
ACCOUNT_NOTE_SUBMIT_FAIL,
|
||||
ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
} from '../actions/account_notes';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
edit: ImmutableMap({
|
||||
isSubmitting: false,
|
||||
account_id: null,
|
||||
comment: null,
|
||||
}),
|
||||
});
|
||||
|
||||
export default function account_notes(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case ACCOUNT_NOTE_INIT_EDIT:
|
||||
return state.withMutations((state) => {
|
||||
state.setIn(['edit', 'isSubmitting'], false);
|
||||
state.setIn(['edit', 'account_id'], action.account.get('id'));
|
||||
state.setIn(['edit', 'comment'], action.comment);
|
||||
});
|
||||
case ACCOUNT_NOTE_CHANGE_COMMENT:
|
||||
return state.setIn(['edit', 'comment'], action.comment);
|
||||
case ACCOUNT_NOTE_SUBMIT_REQUEST:
|
||||
return state.setIn(['edit', 'isSubmitting'], true);
|
||||
case ACCOUNT_NOTE_SUBMIT_FAIL:
|
||||
return state.setIn(['edit', 'isSubmitting'], false);
|
||||
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
|
||||
case ACCOUNT_NOTE_CANCEL:
|
||||
return state.withMutations((state) => {
|
||||
state.setIn(['edit', 'isSubmitting'], false);
|
||||
state.setIn(['edit', 'account_id'], null);
|
||||
state.setIn(['edit', 'comment'], null);
|
||||
});
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
@ -36,6 +36,7 @@ import trends from './trends';
|
||||
import missed_updates from './missed_updates';
|
||||
import announcements from './announcements';
|
||||
import markers from './markers';
|
||||
import account_notes from './account_notes';
|
||||
|
||||
const reducers = {
|
||||
announcements,
|
||||
@ -75,6 +76,7 @@ const reducers = {
|
||||
trends,
|
||||
missed_updates,
|
||||
markers,
|
||||
account_notes,
|
||||
};
|
||||
|
||||
export default combineReducers(reducers);
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {
|
||||
MARKERS_SUBMIT_SUCCESS,
|
||||
} from '../actions/notifications';
|
||||
} from '../actions/markers';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
home: '0',
|
||||
|
@ -17,6 +17,9 @@ import {
|
||||
DOMAIN_BLOCK_SUCCESS,
|
||||
DOMAIN_UNBLOCK_SUCCESS,
|
||||
} from '../actions/domain_blocks';
|
||||
import {
|
||||
ACCOUNT_NOTE_SUBMIT_SUCCESS,
|
||||
} from '../actions/account_notes';
|
||||
import { Map as ImmutableMap, fromJS } from 'immutable';
|
||||
|
||||
const normalizeRelationship = (state, relationship) => state.set(relationship.id, fromJS(relationship));
|
||||
@ -57,6 +60,7 @@ export default function relationships(state = initialState, action) {
|
||||
case ACCOUNT_UNMUTE_SUCCESS:
|
||||
case ACCOUNT_PIN_SUCCESS:
|
||||
case ACCOUNT_UNPIN_SUCCESS:
|
||||
case ACCOUNT_NOTE_SUBMIT_SUCCESS:
|
||||
return normalizeRelationship(state, action.relationship);
|
||||
case RELATIONSHIPS_FETCH_SUCCESS:
|
||||
return normalizeRelationships(state, action.relationships);
|
||||
|
@ -1,5 +1 @@
|
||||
import { configure } from 'enzyme';
|
||||
import Adapter from 'enzyme-adapter-react-16';
|
||||
|
||||
const adapter = new Adapter();
|
||||
configure({ adapter });
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
|
@ -3105,11 +3105,6 @@ a.status-card {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
padding: 14px 14px 14px 8px;
|
||||
|
||||
&--blurred {
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.status-card__description {
|
||||
@ -3846,7 +3841,6 @@ a.status-card.compact:hover {
|
||||
color: $primary-text-color;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
vertical-align: top;
|
||||
background-color: $base-overlay-background;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
@ -6502,7 +6496,7 @@ noscript {
|
||||
&__tabs {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 7px 5px;
|
||||
padding: 7px 10px;
|
||||
margin-top: -55px;
|
||||
|
||||
&__buttons {
|
||||
@ -6524,7 +6518,7 @@ noscript {
|
||||
}
|
||||
|
||||
&__name {
|
||||
padding: 5px;
|
||||
padding: 5px 10px;
|
||||
|
||||
.account-role {
|
||||
vertical-align: top;
|
||||
@ -6610,6 +6604,67 @@ noscript {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__account-note {
|
||||
margin: 5px;
|
||||
padding: 10px;
|
||||
background: $ui-highlight-color;
|
||||
color: $primary-text-color;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__content {
|
||||
white-space: pre-wrap;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
margin-top: 5px;
|
||||
|
||||
.flex-spacer {
|
||||
flex: 0 0 20px;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button:hover span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
textarea {
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
margin-top: 5px;
|
||||
color: $inverted-text-color;
|
||||
background: $simple-background-color;
|
||||
padding: 10px;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.trends {
|
||||
|
@ -238,12 +238,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
|
||||
begin
|
||||
href = Addressable::URI.parse(attachment['url']).normalize.to_s
|
||||
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
|
||||
media_attachment = MediaAttachment.create(account: @account, remote_url: href, thumbnail_remote_url: icon_url_from_attachment(attachment), description: attachment['summary'].presence || attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
|
||||
media_attachments << media_attachment
|
||||
|
||||
next if unsupported_media_type?(attachment['mediaType']) || skip_download?
|
||||
|
||||
media_attachment.file_remote_url = href
|
||||
media_attachment.download_file!
|
||||
media_attachment.download_thumbnail!
|
||||
media_attachment.save
|
||||
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError
|
||||
RedownloadMediaWorker.perform_in(rand(30..600).seconds, media_attachment.id)
|
||||
@ -256,6 +257,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
||||
media_attachments
|
||||
end
|
||||
|
||||
def icon_url_from_attachment(attachment)
|
||||
url = attachment['icon'].is_a?(Hash) ? attachment['icon']['url'] : attachment['icon']
|
||||
Addressable::URI.parse(url).normalize.to_s if url.present?
|
||||
rescue Addressable::URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def process_poll
|
||||
return unless @object['type'] == 'Question' && (@object['anyOf'].is_a?(Array) || @object['oneOf'].is_a?(Array))
|
||||
|
||||
|
20
app/models/account_note.rb
Normal file
20
app/models/account_note.rb
Normal file
@ -0,0 +1,20 @@
|
||||
# frozen_string_literal: true
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: account_notes
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# account_id :bigint(8)
|
||||
# target_account_id :bigint(8)
|
||||
# comment :text not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
class AccountNote < ApplicationRecord
|
||||
include RelationshipCacheable
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :target_account, class_name: 'Account'
|
||||
|
||||
validates :account_id, uniqueness: { scope: :target_account_id }
|
||||
end
|
@ -44,6 +44,14 @@ module AccountInteractions
|
||||
follow_mapping(AccountPin.where(account_id: account_id, target_account_id: target_account_ids), :target_account_id)
|
||||
end
|
||||
|
||||
def account_note_map(target_account_ids, account_id)
|
||||
AccountNote.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |note, mapping|
|
||||
mapping[note.target_account_id] = {
|
||||
comment: note.comment,
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def domain_blocking_map(target_account_ids, account_id)
|
||||
accounts_map = Account.where(id: target_account_ids).select('id, domain').each_with_object({}) { |a, h| h[a.id] = a.domain }
|
||||
blocked_domains = domain_blocking_map_by_domain(accounts_map.values.compact, account_id)
|
||||
|
@ -4,12 +4,12 @@ module Remotable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
class_methods do
|
||||
def remotable_attachment(attachment_name, limit, suppress_errors: true)
|
||||
attribute_name = "#{attachment_name}_remote_url".to_sym
|
||||
method_name = "#{attribute_name}=".to_sym
|
||||
alt_method_name = "reset_#{attachment_name}!".to_sym
|
||||
def remotable_attachment(attachment_name, limit, suppress_errors: true, download_on_assign: true, attribute_name: nil)
|
||||
attribute_name ||= "#{attachment_name}_remote_url".to_sym
|
||||
|
||||
define_method("download_#{attachment_name}!") do |url = nil|
|
||||
url ||= self[attribute_name]
|
||||
|
||||
define_method method_name do |url|
|
||||
return if url.blank?
|
||||
|
||||
begin
|
||||
@ -18,7 +18,7 @@ module Remotable
|
||||
return
|
||||
end
|
||||
|
||||
return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank? || (self[attribute_name] == url && send("#{attachment_name}_file_name").present?)
|
||||
return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank?
|
||||
|
||||
begin
|
||||
Request.new(:get, url).perform do |response|
|
||||
@ -36,10 +36,8 @@ module Remotable
|
||||
|
||||
basename = SecureRandom.hex(8)
|
||||
|
||||
send("#{attachment_name}_file_name=", basename + extname)
|
||||
send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit)))
|
||||
|
||||
self[attribute_name] = url if has_attribute?(attribute_name)
|
||||
public_send("#{attachment_name}_file_name=", basename + extname)
|
||||
public_send("#{attachment_name}=", StringIO.new(response.body_with_limit(limit)))
|
||||
end
|
||||
rescue Mastodon::UnexpectedResponseError, HTTP::TimeoutError, HTTP::ConnectionError, OpenSSL::SSL::SSLError => e
|
||||
Rails.logger.debug "Error fetching remote #{attachment_name}: #{e}"
|
||||
@ -50,14 +48,15 @@ module Remotable
|
||||
end
|
||||
end
|
||||
|
||||
define_method alt_method_name do
|
||||
url = self[attribute_name]
|
||||
define_method("#{attribute_name}=") do |url|
|
||||
return if self[attribute_name] == url && public_send("#{attachment_name}_file_name").present?
|
||||
|
||||
return if url.blank?
|
||||
self[attribute_name] = url if has_attribute?(attribute_name)
|
||||
|
||||
self[attribute_name] = ''
|
||||
send(method_name, url)
|
||||
public_send("download_#{attachment_name}!", url) if download_on_assign
|
||||
end
|
||||
|
||||
alias_method("reset_#{attachment_name}!", "download_#{attachment_name}!")
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -21,6 +21,11 @@
|
||||
# blurhash :string
|
||||
# processing :integer
|
||||
# file_storage_schema_version :integer
|
||||
# thumbnail_file_name :string
|
||||
# thumbnail_content_type :string
|
||||
# thumbnail_file_size :integer
|
||||
# thumbnail_updated_at :datetime
|
||||
# thumbnail_remote_url :string
|
||||
#
|
||||
|
||||
class MediaAttachment < ApplicationRecord
|
||||
@ -49,13 +54,13 @@ class MediaAttachment < ApplicationRecord
|
||||
original: {
|
||||
pixels: 1_638_400, # 1280x1280px
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
},
|
||||
}.freeze,
|
||||
|
||||
small: {
|
||||
pixels: 160_000, # 400x400px
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
blurhash: BLURHASH_OPTIONS,
|
||||
},
|
||||
}.freeze,
|
||||
}.freeze
|
||||
|
||||
VIDEO_FORMAT = {
|
||||
@ -74,14 +79,14 @@ class MediaAttachment < ApplicationRecord
|
||||
'frames:v' => 60 * 60 * 3,
|
||||
'crf' => 18,
|
||||
'map_metadata' => '-1',
|
||||
},
|
||||
},
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze
|
||||
|
||||
VIDEO_PASSTHROUGH_OPTIONS = {
|
||||
video_codecs: ['h264'],
|
||||
audio_codecs: ['aac', nil],
|
||||
colorspaces: ['yuv420p'],
|
||||
video_codecs: ['h264'].freeze,
|
||||
audio_codecs: ['aac', nil].freeze,
|
||||
colorspaces: ['yuv420p'].freeze,
|
||||
options: {
|
||||
format: 'mp4',
|
||||
convert_options: {
|
||||
@ -90,9 +95,9 @@ class MediaAttachment < ApplicationRecord
|
||||
'map_metadata' => '-1',
|
||||
'c:v' => 'copy',
|
||||
'c:a' => 'copy',
|
||||
},
|
||||
},
|
||||
},
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze
|
||||
|
||||
VIDEO_STYLES = {
|
||||
@ -101,15 +106,15 @@ class MediaAttachment < ApplicationRecord
|
||||
output: {
|
||||
'loglevel' => 'fatal',
|
||||
vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease',
|
||||
},
|
||||
},
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
format: 'png',
|
||||
time: 0,
|
||||
file_geometry_parser: FastGeometryParser,
|
||||
blurhash: BLURHASH_OPTIONS,
|
||||
},
|
||||
}.freeze,
|
||||
|
||||
original: VIDEO_FORMAT.merge(passthrough_options: VIDEO_PASSTHROUGH_OPTIONS),
|
||||
original: VIDEO_FORMAT.merge(passthrough_options: VIDEO_PASSTHROUGH_OPTIONS).freeze,
|
||||
}.freeze
|
||||
|
||||
AUDIO_STYLES = {
|
||||
@ -119,16 +124,23 @@ class MediaAttachment < ApplicationRecord
|
||||
convert_options: {
|
||||
output: {
|
||||
'loglevel' => 'fatal',
|
||||
'map_metadata' => '-1',
|
||||
'q:a' => 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze,
|
||||
}.freeze
|
||||
|
||||
VIDEO_CONVERTED_STYLES = {
|
||||
small: VIDEO_STYLES[:small],
|
||||
original: VIDEO_FORMAT,
|
||||
small: VIDEO_STYLES[:small].freeze,
|
||||
original: VIDEO_FORMAT.freeze,
|
||||
}.freeze
|
||||
|
||||
THUMBNAIL_STYLES = {
|
||||
original: IMAGE_STYLES[:small].freeze,
|
||||
}.freeze
|
||||
|
||||
GLOBAL_CONVERT_OPTIONS = {
|
||||
all: '-quality 90 -strip +set modify-date +set create-date',
|
||||
}.freeze
|
||||
|
||||
IMAGE_LIMIT = (ENV['MAX_IMAGE_SIZE'] || 10.megabytes).to_i
|
||||
@ -144,18 +156,28 @@ class MediaAttachment < ApplicationRecord
|
||||
has_attached_file :file,
|
||||
styles: ->(f) { file_styles f },
|
||||
processors: ->(f) { file_processors f },
|
||||
convert_options: { all: '-quality 90 -strip +set modify-date +set create-date' }
|
||||
convert_options: GLOBAL_CONVERT_OPTIONS
|
||||
|
||||
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
|
||||
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :larger_media_format?
|
||||
validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :larger_media_format?
|
||||
remotable_attachment :file, VIDEO_LIMIT, suppress_errors: false
|
||||
remotable_attachment :file, VIDEO_LIMIT, suppress_errors: false, download_on_assign: false, attribute_name: :remote_url
|
||||
|
||||
has_attached_file :thumbnail,
|
||||
styles: THUMBNAIL_STYLES,
|
||||
processors: [:lazy_thumbnail, :blurhash_transcoder],
|
||||
convert_options: GLOBAL_CONVERT_OPTIONS
|
||||
|
||||
validates_attachment_content_type :thumbnail, content_type: IMAGE_MIME_TYPES
|
||||
validates_attachment_size :thumbnail, less_than: IMAGE_LIMIT
|
||||
remotable_attachment :thumbnail, IMAGE_LIMIT, suppress_errors: true, download_on_assign: false
|
||||
|
||||
include Attachmentable
|
||||
|
||||
validates :account, presence: true
|
||||
validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, if: :local?
|
||||
validates :file, presence: true, if: :local?
|
||||
validates :thumbnail, absence: true, if: -> { local? && !audio_or_video? }
|
||||
|
||||
scope :attached, -> { where.not(status_id: nil).or(where.not(scheduled_status_id: nil)) }
|
||||
scope :unattached, -> { where(status_id: nil, scheduled_status_id: nil) }
|
||||
@ -215,16 +237,21 @@ class MediaAttachment < ApplicationRecord
|
||||
@delay_processing
|
||||
end
|
||||
|
||||
def delay_processing_for_attachment?(attachment_name)
|
||||
@delay_processing && attachment_name == :file
|
||||
end
|
||||
|
||||
after_commit :enqueue_processing, on: :create
|
||||
after_commit :reset_parent_cache, on: :update
|
||||
|
||||
before_create :prepare_description, unless: :local?
|
||||
before_create :set_shortcode
|
||||
before_create :set_processing
|
||||
before_create :set_meta
|
||||
|
||||
before_post_process :set_type_and_extension
|
||||
before_post_process :check_video_dimensions
|
||||
after_post_process :set_meta
|
||||
|
||||
before_file_post_process :set_type_and_extension
|
||||
before_file_post_process :check_video_dimensions
|
||||
|
||||
class << self
|
||||
def supported_mime_types
|
||||
@ -237,25 +264,25 @@ class MediaAttachment < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def file_styles(f)
|
||||
if f.instance.file_content_type == 'image/gif' || VIDEO_CONVERTIBLE_MIME_TYPES.include?(f.instance.file_content_type)
|
||||
def file_styles(attachment)
|
||||
if attachment.instance.file_content_type == 'image/gif' || VIDEO_CONVERTIBLE_MIME_TYPES.include?(attachment.instance.file_content_type)
|
||||
VIDEO_CONVERTED_STYLES
|
||||
elsif IMAGE_MIME_TYPES.include?(f.instance.file_content_type)
|
||||
elsif IMAGE_MIME_TYPES.include?(attachment.instance.file_content_type)
|
||||
IMAGE_STYLES
|
||||
elsif VIDEO_MIME_TYPES.include?(f.instance.file_content_type)
|
||||
elsif VIDEO_MIME_TYPES.include?(attachment.instance.file_content_type)
|
||||
VIDEO_STYLES
|
||||
else
|
||||
AUDIO_STYLES
|
||||
end
|
||||
end
|
||||
|
||||
def file_processors(f)
|
||||
if f.file_content_type == 'image/gif'
|
||||
def file_processors(instance)
|
||||
if instance.file_content_type == 'image/gif'
|
||||
[:gif_transcoder, :blurhash_transcoder]
|
||||
elsif VIDEO_MIME_TYPES.include?(f.file_content_type)
|
||||
elsif VIDEO_MIME_TYPES.include?(instance.file_content_type)
|
||||
[:video_transcoder, :blurhash_transcoder, :type_corrector]
|
||||
elsif AUDIO_MIME_TYPES.include?(f.file_content_type)
|
||||
[:transcoder, :type_corrector]
|
||||
elsif AUDIO_MIME_TYPES.include?(instance.file_content_type)
|
||||
[:image_extractor, :transcoder, :type_corrector]
|
||||
else
|
||||
[:lazy_thumbnail, :blurhash_transcoder, :type_corrector]
|
||||
end
|
||||
@ -298,7 +325,7 @@ class MediaAttachment < ApplicationRecord
|
||||
def check_video_dimensions
|
||||
return unless (video? || gifv?) && file.queued_for_write[:original].present?
|
||||
|
||||
movie = FFMPEG::Movie.new(file.queued_for_write[:original].path)
|
||||
movie = ffmpeg_data(file.queued_for_write[:original].path)
|
||||
|
||||
return unless movie.valid?
|
||||
|
||||
@ -317,6 +344,8 @@ class MediaAttachment < ApplicationRecord
|
||||
meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
|
||||
end
|
||||
|
||||
meta[:small] = image_geometry(thumbnail.queued_for_write[:original]) if thumbnail.queued_for_write.key?(:original)
|
||||
|
||||
meta
|
||||
end
|
||||
|
||||
@ -334,7 +363,7 @@ class MediaAttachment < ApplicationRecord
|
||||
end
|
||||
|
||||
def video_metadata(file)
|
||||
movie = FFMPEG::Movie.new(file.path)
|
||||
movie = ffmpeg_data(file.path)
|
||||
|
||||
return {} unless movie.valid?
|
||||
|
||||
@ -347,6 +376,13 @@ class MediaAttachment < ApplicationRecord
|
||||
}.compact
|
||||
end
|
||||
|
||||
# We call this method about 3 different times on potentially different
|
||||
# paths but ultimately the same file, so it makes sense to memoize the
|
||||
# result while disregarding the path
|
||||
def ffmpeg_data(path = nil)
|
||||
@ffmpeg_data ||= FFMPEG::Movie.new(path)
|
||||
end
|
||||
|
||||
def enqueue_processing
|
||||
PostProcessMediaWorker.perform_async(id) if delay_processing?
|
||||
end
|
||||
|
@ -3,7 +3,7 @@
|
||||
class AccountRelationshipsPresenter
|
||||
attr_reader :following, :followed_by, :blocking, :blocked_by,
|
||||
:muting, :requested, :domain_blocking,
|
||||
:endorsed
|
||||
:endorsed, :account_note
|
||||
|
||||
def initialize(account_ids, current_account_id, **options)
|
||||
@account_ids = account_ids.map { |a| a.is_a?(Account) ? a.id : a.to_i }
|
||||
@ -17,6 +17,7 @@ class AccountRelationshipsPresenter
|
||||
@requested = cached[:requested].merge(Account.requested_map(@uncached_account_ids, @current_account_id))
|
||||
@domain_blocking = cached[:domain_blocking].merge(Account.domain_blocking_map(@uncached_account_ids, @current_account_id))
|
||||
@endorsed = cached[:endorsed].merge(Account.endorsed_map(@uncached_account_ids, @current_account_id))
|
||||
@account_note = cached[:account_note].merge(Account.account_note_map(@uncached_account_ids, @current_account_id))
|
||||
|
||||
cache_uncached!
|
||||
|
||||
@ -28,6 +29,7 @@ class AccountRelationshipsPresenter
|
||||
@requested.merge!(options[:requested_map] || {})
|
||||
@domain_blocking.merge!(options[:domain_blocking_map] || {})
|
||||
@endorsed.merge!(options[:endorsed_map] || {})
|
||||
@account_note.merge!(options[:account_note_map] || {})
|
||||
end
|
||||
|
||||
private
|
||||
@ -44,6 +46,7 @@ class AccountRelationshipsPresenter
|
||||
requested: {},
|
||||
domain_blocking: {},
|
||||
endorsed: {},
|
||||
account_note: {},
|
||||
}
|
||||
|
||||
@uncached_account_ids = []
|
||||
@ -72,6 +75,7 @@ class AccountRelationshipsPresenter
|
||||
requested: { account_id => requested[account_id] },
|
||||
domain_blocking: { account_id => domain_blocking[account_id] },
|
||||
endorsed: { account_id => endorsed[account_id] },
|
||||
account_note: { account_id => account_note[account_id] },
|
||||
}
|
||||
|
||||
Rails.cache.write("relationship:#{@current_account_id}:#{account_id}", maps_for_account, expires_in: 1.day)
|
||||
|
@ -172,6 +172,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||
attributes :type, :media_type, :url, :name, :blurhash
|
||||
attribute :focal_point, if: :focal_point?
|
||||
|
||||
has_one :icon, serializer: ActivityPub::ImageSerializer, if: :thumbnail?
|
||||
|
||||
def type
|
||||
'Document'
|
||||
end
|
||||
@ -195,6 +197,14 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
|
||||
def focal_point
|
||||
[object.file.meta['focus']['x'], object.file.meta['focus']['y']]
|
||||
end
|
||||
|
||||
def icon
|
||||
object.thumbnail
|
||||
end
|
||||
|
||||
def thumbnail?
|
||||
object.thumbnail.present?
|
||||
end
|
||||
end
|
||||
|
||||
class MentionSerializer < ActivityPub::Serializer
|
||||
|
@ -28,7 +28,9 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
|
||||
def preview_url
|
||||
if object.needs_redownload?
|
||||
media_proxy_url(object.id, :small)
|
||||
else
|
||||
elsif object.thumbnail.present?
|
||||
full_asset_url(object.thumbnail.url(:original))
|
||||
elsif object.file.styles.key?(:small)
|
||||
full_asset_url(object.file.url(:small))
|
||||
end
|
||||
end
|
||||
|
@ -3,7 +3,7 @@
|
||||
class REST::RelationshipSerializer < ActiveModel::Serializer
|
||||
attributes :id, :following, :showing_reblogs, :followed_by, :blocking, :blocked_by,
|
||||
:muting, :muting_notifications, :requested, :domain_blocking,
|
||||
:endorsed
|
||||
:endorsed, :note
|
||||
|
||||
def id
|
||||
object.id.to_s
|
||||
@ -50,4 +50,8 @@ class REST::RelationshipSerializer < ActiveModel::Serializer
|
||||
def endorsed
|
||||
instance_options[:relationships].endorsed[object.id] || false
|
||||
end
|
||||
|
||||
def note
|
||||
(instance_options[:relationships].account_note[object.id] || {})[:comment]
|
||||
end
|
||||
end
|
||||
|
@ -89,8 +89,8 @@ class ActivityPub::ProcessAccountService < BaseService
|
||||
end
|
||||
|
||||
def set_fetchable_attributes!
|
||||
@account.avatar_remote_url = image_url('icon') unless skip_download?
|
||||
@account.header_remote_url = image_url('image') unless skip_download?
|
||||
@account.avatar_remote_url = image_url('icon') || '' unless skip_download?
|
||||
@account.header_remote_url = image_url('image') || '' unless skip_download?
|
||||
@account.public_key = public_key || ''
|
||||
@account.statuses_count = outbox_total_items if outbox_total_items.present?
|
||||
@account.following_count = following_total_items if following_total_items.present?
|
||||
|
@ -33,7 +33,7 @@
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: audio.file.url(:original), poster: full_asset_url(status.account.avatar_static_url), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
|
||||
= react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, blurhash: audio.blurhash, width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||
|
@ -39,7 +39,7 @@
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: audio.file.url(:original), poster: full_asset_url(status.account.avatar_static_url), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
|
||||
= react_component :audio, src: audio.file.url(:original), poster: audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url, blurhash: audio.blurhash, width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||
|
@ -12,6 +12,8 @@ class MoveWorker
|
||||
else
|
||||
queue_follow_unfollows!
|
||||
end
|
||||
|
||||
copy_account_notes!
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
@ -34,4 +36,19 @@ class MoveWorker
|
||||
UnfollowFollowWorker.push_bulk(accounts.map(&:id)) { |follower_id| [follower_id, @source_account.id, @target_account.id, bypass_locked] }
|
||||
end
|
||||
end
|
||||
|
||||
def copy_account_notes!
|
||||
AccountNote.where(target_account: @source_account).find_each do |note|
|
||||
text = I18n.with_locale(note.account.user.locale || I18n.default_locale) do
|
||||
I18n.t('move_handler.copy_account_note_text', acct: @source_account.acct)
|
||||
end
|
||||
|
||||
new_note = AccountNote.find_by(account: note.account, target_account: @target_account)
|
||||
if new_note.nil?
|
||||
AccountNote.create!(account: note.account, target_account: @target_account, comment: [text, note.comment].join('\n'))
|
||||
else
|
||||
new_note.update!(comment: [text, note.comment, '\n', new_note.comment].join('\n'))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -32,7 +32,7 @@ class PostProcessMediaWorker
|
||||
|
||||
media_attachment.file.reprocess!(:original)
|
||||
media_attachment.processing = :complete
|
||||
media_attachment.file_meta = previous_meta
|
||||
media_attachment.file_meta = previous_meta.merge(media_attachment.file_meta).with_indifferent_access.slice(:focus, :original, :small)
|
||||
media_attachment.save
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
|
@ -11,7 +11,8 @@ class RedownloadMediaWorker
|
||||
|
||||
return if media_attachment.remote_url.blank?
|
||||
|
||||
media_attachment.file_remote_url = media_attachment.remote_url
|
||||
media_attachment.download_file!
|
||||
media_attachment.download_thumbnail!
|
||||
media_attachment.save
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
|
Reference in New Issue
Block a user