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:
Thibaut Girka
2020-06-30 23:00:20 +02:00
83 changed files with 2766 additions and 987 deletions

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View 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,
};
};

View File

@ -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);
});

View File

@ -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}

View File

@ -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>
);
}
}

View File

@ -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) && (

View File

@ -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);

View File

@ -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}
/>

View File

@ -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> }} />,

View File

@ -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,
});

View File

@ -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>
);
}

View File

@ -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}
/>
);

View File

@ -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'));
});
});
});

View 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;
}
}

View File

@ -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);

View File

@ -1,6 +1,6 @@
import {
MARKERS_SUBMIT_SUCCESS,
} from '../actions/notifications';
} from '../actions/markers';
const initialState = ImmutableMap({
home: '0',

View File

@ -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);

View File

@ -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';

View File

@ -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 {

View File

@ -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))

View 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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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?

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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