# Conflicts:
#	app/controllers/settings/exports_controller.rb
#	app/models/media_attachment.rb
#	app/models/status.rb
#	app/views/about/show.html.haml
#	docker_entrypoint.sh
#	spec/views/about/show.html.haml_spec.rb
This commit is contained in:
imncls 2018-02-23 23:28:31 +09:00
commit bb6988a7ac
No known key found for this signature in database
GPG Key ID: 18FE1E1E7098294A
100 changed files with 1395 additions and 422 deletions

View File

@ -144,14 +144,22 @@ STREAMING_CLUSTER_NUM=1
# MAX_TOOT_CHARS=500 # MAX_TOOT_CHARS=500
# PAM authentication (optional) # PAM authentication (optional)
# PAM authentication uses for the email generation the "email" pam variable
# and optional as fallback PAM_DEFAULT_SUFFIX
# The pam environment variable "email" is provided by:
# https://github.com/devkral/pam_email_extractor
# PAM_ENABLED=true # PAM_ENABLED=true
# Suffix for email address generation (nil by default) # Fallback Suffix for email address generation (nil by default)
# PAM_DEFAULT_SUFFIX=pam # PAM_DEFAULT_SUFFIX=pam
# Name of the pam service (pam "auth" section is evaluated) # Name of the pam service (pam "auth" section is evaluated)
# PAM_DEFAULT_SERVICE=rpam # PAM_DEFAULT_SERVICE=rpam
# Name of the pam service used for checking if an user can register (pam "account" section is evaluated) # Name of the pam service used for checking if an user can register (pam "account" section is evaluated)
# PAM_CONTROLLED_SERVICE=rpam # PAM_CONTROLLED_SERVICE=rpam
# Global OAuth settings (optional) :
# If you have only one strategy, you may want to enable this
# OAUTH_REDIRECT_AT_SIGN_IN=true
# Optional CAS authentication (cf. omniauth-cas) : # Optional CAS authentication (cf. omniauth-cas) :
# CAS_ENABLED=true # CAS_ENABLED=true
# CAS_URL=https://sso.myserver.com/ # CAS_URL=https://sso.myserver.com/
@ -187,7 +195,10 @@ STREAMING_CLUSTER_NUM=1
# SAML_PRIVATE_KEY= # SAML_PRIVATE_KEY=
# SAML_SECURITY_WANT_ASSERTION_SIGNED=true # SAML_SECURITY_WANT_ASSERTION_SIGNED=true
# SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true # SAML_SECURITY_WANT_ASSERTION_ENCRYPTED=true
# SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED=true
# SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1" # SAML_ATTRIBUTES_STATEMENTS_UID="urn:oid:0.9.2342.19200300.100.1.1"
# SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6" # SAML_ATTRIBUTES_STATEMENTS_EMAIL="urn:oid:1.3.6.1.4.1.5923.1.1.1.6"
# SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.5.4.42" # SAML_ATTRIBUTES_STATEMENTS_FULL_NAME="urn:oid:2.5.4.42"
# SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1" # SAML_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1"
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED=
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL=

View File

@ -3,8 +3,10 @@ FROM ruby:2.5.0-alpine3.7
LABEL maintainer="https://github.com/tootsuite/mastodon" \ LABEL maintainer="https://github.com/tootsuite/mastodon" \
description="A GNU Social-compatible microblogging server" description="A GNU Social-compatible microblogging server"
ENV UID=991 GID=991 \ ARG UID=991
RAILS_SERVE_STATIC_FILES=true \ ARG GID=991
ENV RAILS_SERVE_STATIC_FILES=true \
RAILS_ENV=production NODE_ENV=production RAILS_ENV=production NODE_ENV=production
ARG YARN_VERSION=1.3.2 ARG YARN_VERSION=1.3.2
@ -71,12 +73,12 @@ RUN bundle config build.nokogiri --with-iconv-lib=/usr/local/lib --with-iconv-in
&& yarn --pure-lockfile \ && yarn --pure-lockfile \
&& yarn cache clean && yarn cache clean
COPY . /mastodon RUN addgroup -g ${GID} mastodon && adduser -h /mastodon -s /bin/sh -D -G mastodon -u ${UID} mastodon
COPY docker_entrypoint.sh /usr/local/bin/run COPY --chown=mastodon:mastodon . /mastodon
RUN chmod +x /usr/local/bin/run
VOLUME /mastodon/public/system /mastodon/public/assets /mastodon/public/packs VOLUME /mastodon/public/system /mastodon/public/assets /mastodon/public/packs
ENTRYPOINT ["/usr/local/bin/run"] USER mastodon
ENTRYPOINT ["/sbin/tini", "--"]

View File

@ -41,6 +41,7 @@ gem 'omniauth', '~> 1.2'
gem 'doorkeeper', '~> 4.2' gem 'doorkeeper', '~> 4.2'
gem 'fast_blank', '~> 1.0' gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1' gem 'goldfinger', '~> 2.1'
gem 'hiredis', '~> 0.6' gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.5' gem 'redis-namespace', '~> 1.5'
@ -117,6 +118,7 @@ group :development do
gem 'bullet', '~> 5.5' gem 'bullet', '~> 5.5'
gem 'letter_opener', '~> 1.4' gem 'letter_opener', '~> 1.4'
gem 'letter_opener_web', '~> 1.3' gem 'letter_opener_web', '~> 1.3'
gem 'memory_profiler'
gem 'rubocop', require: false gem 'rubocop', require: false
gem 'brakeman', '~> 4.0', require: false gem 'brakeman', '~> 4.0', require: false
gem 'bundler-audit', '~> 0.6', require: false gem 'bundler-audit', '~> 0.6', require: false

View File

@ -185,6 +185,7 @@ GEM
faraday (0.14.0) faraday (0.14.0)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
fast_blank (1.0.0) fast_blank (1.0.0)
fastimage (2.1.1)
ffi (1.9.18) ffi (1.9.18)
fog-core (1.45.0) fog-core (1.45.0)
builder builder
@ -302,6 +303,7 @@ GEM
mini_mime (>= 0.1.1) mini_mime (>= 0.1.1)
mario-redis-lock (1.2.0) mario-redis-lock (1.2.0)
redis (~> 3, >= 3.0.5) redis (~> 3, >= 3.0.5)
memory_profiler (0.9.10)
method_source (0.9.0) method_source (0.9.0)
microformats (4.0.7) microformats (4.0.7)
json json
@ -644,6 +646,7 @@ DEPENDENCIES
fabrication (~> 2.18) fabrication (~> 2.18)
faker (~> 1.7) faker (~> 1.7)
fast_blank (~> 1.0) fast_blank (~> 1.0)
fastimage
fog-core (~> 1.45) fog-core (~> 1.45)
fog-local (~> 0.4) fog-local (~> 0.4)
fog-openstack (~> 0.1) fog-openstack (~> 0.1)
@ -666,6 +669,7 @@ DEPENDENCIES
link_header (~> 0.0) link_header (~> 0.0)
lograge (~> 0.7) lograge (~> 0.7)
mario-redis-lock (~> 1.2) mario-redis-lock (~> 1.2)
memory_profiler
microformats (~> 4.0) microformats (~> 4.0)
mime-types (~> 3.1) mime-types (~> 3.1)
nokogiri (~> 1.8) nokogiri (~> 1.8)

View File

@ -16,6 +16,7 @@ module Admin
show_staff_badge show_staff_badge
bootstrap_timeline_accounts bootstrap_timeline_accounts
thumbnail thumbnail
hero
min_invite_role min_invite_role
activity_api_enabled activity_api_enabled
peers_api_enabled peers_api_enabled
@ -34,6 +35,7 @@ module Admin
UPLOAD_SETTINGS = %w( UPLOAD_SETTINGS = %w(
thumbnail thumbnail
hero
).freeze ).freeze
def edit def edit

View File

@ -21,6 +21,6 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
end end
def account_ids def account_ids
@_account_ids ||= Array(params[:id]).map(&:to_i) Array(params[:id]).map(&:to_i)
end end
end end

View File

@ -27,7 +27,7 @@ class Api::V1::MediaController < Api::BaseController
private private
def media_params def media_params
params.permit(:file, :description) params.permit(:file, :description, :focus)
end end
def file_type_error def file_type_error

View File

@ -36,7 +36,7 @@ class ApplicationController < ActionController::Base
end end
def store_current_location def store_current_location
store_location_for(:user, request.url) store_location_for(:user, request.url) unless request.format == :json
end end
def require_admin! def require_admin!

View File

@ -11,6 +11,15 @@ class Auth::SessionsController < Devise::SessionsController
prepend_before_action :set_pack prepend_before_action :set_pack
before_action :set_instance_presenter, only: [:new] before_action :set_instance_presenter, only: [:new]
def new
Devise.omniauth_configs.each do |provider, config|
if config.strategy.redirect_at_sign_in
return redirect_to(omniauth_authorize_path(resource_name, provider))
end
end
super
end
def create def create
super do |resource| super do |resource|
remember_me(resource) remember_me(resource)

View File

@ -3,5 +3,15 @@
class Settings::ExportsController < Settings::BaseController class Settings::ExportsController < Settings::BaseController
def show def show
@export = Export.new(current_account) @export = Export.new(current_account)
@backups = current_user.backups
end
def create
authorize :backup, :create?
backup = current_user.backups.create!
BackupWorker.perform_async(backup.id)
redirect_to settings_export_path
end end
end end

View File

@ -0,0 +1,4 @@
<svg fill="#FFFFFF" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 205 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -178,11 +178,11 @@ export function uploadCompose(files) {
}; };
}; };
export function changeUploadCompose(id, description) { export function changeUploadCompose(id, params) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(changeUploadComposeRequest()); dispatch(changeUploadComposeRequest());
api(getState).put(`/api/v1/media/${id}`, { description }).then(response => { api(getState).put(`/api/v1/media/${id}`, params).then(response => {
dispatch(changeUploadComposeSuccess(response.data)); dispatch(changeUploadComposeSuccess(response.data));
}).catch(error => { }).catch(error => {
dispatch(changeUploadComposeFail(id, error)); dispatch(changeUploadComposeFail(id, error));

View File

@ -12,6 +12,26 @@ const messages = defineMessages({
toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' }, toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
}); });
const shiftToPoint = (containerToImageRatio, containerSize, imageSize, focusSize, toMinus) => {
const containerCenter = Math.floor(containerSize / 2);
const focusFactor = (focusSize + 1) / 2;
const scaledImage = Math.floor(imageSize / containerToImageRatio);
let focus = Math.floor(focusFactor * scaledImage);
if (toMinus) focus = scaledImage - focus;
let focusOffset = focus - containerCenter;
const remainder = scaledImage - focus;
const containerRemainder = containerSize - containerCenter;
if (remainder < containerRemainder) focusOffset -= containerRemainder - remainder;
if (focusOffset < 0) focusOffset = 0;
return (focusOffset * -100 / containerSize) + '%';
};
class Item extends React.PureComponent { class Item extends React.PureComponent {
static contextTypes = { static contextTypes = {
@ -24,6 +44,8 @@ class Item extends React.PureComponent {
index: PropTypes.number.isRequired, index: PropTypes.number.isRequired,
size: PropTypes.number.isRequired, size: PropTypes.number.isRequired,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
containerWidth: PropTypes.number,
containerHeight: PropTypes.number,
}; };
static defaultProps = { static defaultProps = {
@ -62,7 +84,7 @@ class Item extends React.PureComponent {
} }
render () { render () {
const { attachment, index, size, standalone } = this.props; const { attachment, index, size, standalone, containerWidth, containerHeight } = this.props;
let width = 50; let width = 50;
let height = 100; let height = 100;
@ -121,12 +143,36 @@ class Item extends React.PureComponent {
const originalUrl = attachment.get('url'); const originalUrl = attachment.get('url');
const originalWidth = attachment.getIn(['meta', 'original', 'width']); const originalWidth = attachment.getIn(['meta', 'original', 'width']);
const originalHeight = attachment.getIn(['meta', 'original', 'height']);
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number'; const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null; const srcSet = hasSize ? `${originalUrl} ${originalWidth}w, ${previewUrl} ${previewWidth}w` : null;
const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null; const sizes = hasSize ? `(min-width: 1025px) ${320 * (width / 100)}px, ${width}vw` : null;
const focusX = attachment.getIn(['meta', 'focus', 'x']);
const focusY = attachment.getIn(['meta', 'focus', 'y']);
const imageStyle = {};
if (originalWidth && originalHeight && containerWidth && containerHeight && focusX && focusY) {
const widthRatio = originalWidth / (containerWidth * (width / 100));
const heightRatio = originalHeight / (containerHeight * (height / 100));
let hShift = 0;
let vShift = 0;
if (widthRatio > heightRatio) {
hShift = shiftToPoint(heightRatio, (containerWidth * (width / 100)), originalWidth, focusX);
} else if(widthRatio < heightRatio) {
vShift = shiftToPoint(widthRatio, (containerHeight * (height / 100)), originalHeight, focusY, true);
}
imageStyle.top = vShift;
imageStyle.left = hShift;
} else {
imageStyle.height = '100%';
}
thumbnail = ( thumbnail = (
<a <a
className='media-gallery__item-thumbnail' className='media-gallery__item-thumbnail'
@ -134,7 +180,14 @@ class Item extends React.PureComponent {
onClick={this.handleClick} onClick={this.handleClick}
target='_blank' target='_blank'
> >
<img src={previewUrl} srcSet={srcSet} sizes={sizes} alt={attachment.get('description')} title={attachment.get('description')} /> <img
src={previewUrl}
srcSet={srcSet}
sizes={sizes}
alt={attachment.get('description')}
title={attachment.get('description')}
style={imageStyle}
/>
</a> </a>
); );
} else if (attachment.get('type') === 'gifv') { } else if (attachment.get('type') === 'gifv') {
@ -205,7 +258,7 @@ export default class MediaGallery extends React.PureComponent {
} }
handleRef = (node) => { handleRef = (node) => {
if (node && this.isStandaloneEligible()) { if (node /*&& this.isStandaloneEligible()*/) {
// offsetWidth triggers a layout, so only calculate when we need to // offsetWidth triggers a layout, so only calculate when we need to
this.setState({ this.setState({
width: node.offsetWidth, width: node.offsetWidth,
@ -256,12 +309,12 @@ export default class MediaGallery extends React.PureComponent {
if (this.isStandaloneEligible()) { if (this.isStandaloneEligible()) {
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />; children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
} else { } else {
children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} />); children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} containerWidth={width} containerHeight={height} />);
} }
} }
return ( return (
<div className='media-gallery' style={style}> <div className='media-gallery' style={style} ref={this.handleRef}>
<div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}> <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
<IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} /> <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
</div> </div>

View File

@ -1,15 +1,13 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import IconButton from '../../../components/icon_button';
import Motion from '../../ui/util/optional_motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' }, description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
}); });
@ -21,6 +19,7 @@ export default class Upload extends ImmutablePureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onUndo: PropTypes.func.isRequired, onUndo: PropTypes.func.isRequired,
onDescriptionChange: PropTypes.func.isRequired, onDescriptionChange: PropTypes.func.isRequired,
onOpenFocalPoint: PropTypes.func.isRequired,
}; };
state = { state = {
@ -33,6 +32,10 @@ export default class Upload extends ImmutablePureComponent {
this.props.onUndo(this.props.media.get('id')); this.props.onUndo(this.props.media.get('id'));
} }
handleFocalPointClick = () => {
this.props.onOpenFocalPoint(this.props.media.get('id'));
}
handleInputChange = e => { handleInputChange = e => {
this.setState({ dirtyDescription: e.target.value }); this.setState({ dirtyDescription: e.target.value });
} }
@ -63,13 +66,20 @@ export default class Upload extends ImmutablePureComponent {
const { intl, media } = this.props; const { intl, media } = this.props;
const active = this.state.hovered || this.state.focused; const active = this.state.hovered || this.state.focused;
const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || ''; const description = this.state.dirtyDescription || (this.state.dirtyDescription !== '' && media.get('description')) || '';
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
const x = ((focusX / 2) + .5) * 100;
const y = ((focusY / -2) + .5) * 100;
return ( return (
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}> <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
{({ scale }) => ( {({ scale }) => (
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}> <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} /> <div className={classNames('compose-form__upload__actions', { active })}>
<button className='icon-button' onClick={this.handleUndoClick}><i className='fa fa-times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Undo' /></button>
{media.get('type') === 'image' && <button className='icon-button' onClick={this.handleFocalPointClick}><i className='fa fa-crosshairs' /> <FormattedMessage id='upload_form.focus' defaultMessage='Crop' /></button>}
</div>
<div className={classNames('compose-form__upload-description', { active })}> <div className={classNames('compose-form__upload-description', { active })}>
<label> <label>

View File

@ -1,6 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Upload from '../components/upload'; import Upload from '../components/upload';
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose'; import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
import { openModal } from '../../../actions/modal';
const mapStateToProps = (state, { id }) => ({ const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id), media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
@ -13,7 +14,11 @@ const mapDispatchToProps = dispatch => ({
}, },
onDescriptionChange: (id, description) => { onDescriptionChange: (id, description) => {
dispatch(changeUploadCompose(id, description)); dispatch(changeUploadCompose(id, { description }));
},
onOpenFocalPoint: id => {
dispatch(openModal('FOCAL_POINT', { id }));
}, },
}); });

View File

@ -0,0 +1,122 @@
import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import ImageLoader from './image_loader';
import classNames from 'classnames';
import { changeUploadCompose } from '../../../actions/compose';
import { getPointerPosition } from '../../video';
const mapStateToProps = (state, { id }) => ({
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
});
const mapDispatchToProps = (dispatch, { id }) => ({
onSave: (x, y) => {
dispatch(changeUploadCompose(id, { focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
},
});
@connect(mapStateToProps, mapDispatchToProps)
export default class FocalPointModal extends ImmutablePureComponent {
static propTypes = {
media: ImmutablePropTypes.map.isRequired,
};
state = {
x: 0,
y: 0,
focusX: 0,
focusY: 0,
dragging: false,
};
componentWillMount () {
this.updatePositionFromMedia(this.props.media);
}
componentWillReceiveProps (nextProps) {
if (this.props.media.get('id') !== nextProps.media.get('id')) {
this.updatePositionFromMedia(nextProps.media);
}
}
componentWillUnmount () {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
}
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove);
document.addEventListener('mouseup', this.handleMouseUp);
this.updatePosition(e);
this.setState({ dragging: true });
}
handleMouseMove = e => {
this.updatePosition(e);
}
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove);
document.removeEventListener('mouseup', this.handleMouseUp);
this.setState({ dragging: false });
this.props.onSave(this.state.focusX, this.state.focusY);
}
updatePosition = e => {
const { x, y } = getPointerPosition(this.node, e);
const focusX = (x - .5) * 2;
const focusY = (y - .5) * -2;
this.setState({ x, y, focusX, focusY });
}
updatePositionFromMedia = media => {
const focusX = media.getIn(['meta', 'focus', 'x']);
const focusY = media.getIn(['meta', 'focus', 'y']);
if (focusX && focusY) {
const x = (focusX / 2) + .5;
const y = (focusY / -2) + .5;
this.setState({ x, y, focusX, focusY });
} else {
this.setState({ x: 0.5, y: 0.5, focusX: 0, focusY: 0 });
}
}
setRef = c => {
this.node = c;
}
render () {
const { media } = this.props;
const { x, y, dragging } = this.state;
const width = media.getIn(['meta', 'original', 'width']) || null;
const height = media.getIn(['meta', 'original', 'height']) || null;
return (
<div className='modal-root__modal media-modal'>
<div className={classNames('media-modal__content focal-point', { dragging })} ref={this.setRef}>
<ImageLoader
previewSrc={media.get('preview_url')}
src={media.get('url')}
width={width}
height={height}
/>
<div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
<div className='focal-point__overlay' onMouseDown={this.handleMouseDown} />
</div>
</div>
);
}
}

View File

@ -8,6 +8,7 @@ import MediaModal from './media_modal';
import VideoModal from './video_modal'; import VideoModal from './video_modal';
import BoostModal from './boost_modal'; import BoostModal from './boost_modal';
import ConfirmationModal from './confirmation_modal'; import ConfirmationModal from './confirmation_modal';
import FocalPointModal from './focal_point_modal';
import { import {
OnboardingModal, OnboardingModal,
MuteModal, MuteModal,
@ -27,6 +28,7 @@ const MODAL_COMPONENTS = {
'ACTIONS': () => Promise.resolve({ default: ActionsModal }), 'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
'EMBED': EmbedModal, 'EMBED': EmbedModal,
'LIST_EDITOR': ListEditor, 'LIST_EDITOR': ListEditor,
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
}; };
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {

View File

@ -30,7 +30,7 @@ const formatTime = secondsNum => {
return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`; return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
}; };
const findElementPosition = el => { export const findElementPosition = el => {
let box; let box;
if (el.getBoundingClientRect && el.parentNode) { if (el.getBoundingClientRect && el.parentNode) {
@ -61,7 +61,7 @@ const findElementPosition = el => {
}; };
}; };
const getPointerPosition = (el, event) => { export const getPointerPosition = (el, event) => {
const position = {}; const position = {};
const box = findElementPosition(el); const box = findElementPosition(el);
const boxW = el.offsetWidth; const boxW = el.offsetWidth;
@ -77,7 +77,7 @@ const getPointerPosition = (el, event) => {
pageY = event.changedTouches[0].pageY; pageY = event.changedTouches[0].pageY;
} }
position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH)); position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW)); position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
return position; return position;

View File

@ -34,7 +34,7 @@ import uuid from '../uuid';
import { me } from '../initial_state'; import { me } from '../initial_state';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
mounted: false, mounted: 0,
sensitive: false, sensitive: false,
spoiler: false, spoiler: false,
spoiler_text: '', spoiler_text: '',
@ -159,10 +159,10 @@ export default function compose(state = initialState, action) {
case STORE_HYDRATE: case STORE_HYDRATE:
return hydrate(state, action.state.get('compose')); return hydrate(state, action.state.get('compose'));
case COMPOSE_MOUNT: case COMPOSE_MOUNT:
return state.set('mounted', true); return state.set('mounted', state.get('mounted') + 1);
case COMPOSE_UNMOUNT: case COMPOSE_UNMOUNT:
return state return state
.set('mounted', false) .set('mounted', Math.max(state.get('mounted') - 1, 0))
.set('is_composing', false); .set('is_composing', false);
case COMPOSE_SENSITIVITY_CHANGE: case COMPOSE_SENSITIVITY_CHANGE:
return state.withMutations(map => { return state.withMutations(map => {
@ -265,7 +265,7 @@ export default function compose(state = initialState, action) {
.set('is_submitting', false) .set('is_submitting', false)
.update('media_attachments', list => list.map(item => { .update('media_attachments', list => list.map(item => {
if (item.get('id') === action.media.id) { if (item.get('id') === action.media.id) {
return item.set('description', action.media.description); return fromJS(action.media);
} }
return item; return item;

View File

@ -1,3 +1,130 @@
$maximum-width: 1235px;
$fluid-breakpoint: $maximum-width + 20px;
$column-breakpoint: 700px;
$small-breakpoint: 960px;
.container {
box-sizing: border-box;
max-width: $maximum-width;
margin: 0 auto;
position: relative;
@media screen and (max-width: $fluid-breakpoint) {
width: 100%;
padding: 0 10px;
}
}
.show-xs,
.show-sm {
display: none;
}
.show-m {
display: block;
}
@media screen and (max-width: $small-breakpoint) {
.hide-sm {
display: none !important;
}
.show-sm {
display: block !important;
}
}
@media screen and (max-width: $column-breakpoint) {
.hide-xs {
display: none !important;
}
.show-xs {
display: block !important;
}
}
.row {
display: flex;
flex-wrap: wrap;
margin: 0 -5px;
@for $i from 1 through 15 {
.column-#{$i} {
box-sizing: border-box;
min-height: 1px;
flex: 0 0 percentage($i / 15);
max-width: percentage($i / 15);
padding: 0 5px;
@media screen and (max-width: $small-breakpoint) {
&-sm {
box-sizing: border-box;
min-height: 1px;
flex: 0 0 percentage($i / 15);
max-width: percentage($i / 15);
padding: 0 5px;
@media screen and (max-width: $column-breakpoint) {
max-width: 100%;
flex: 0 0 100%;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
}
}
@media screen and (max-width: $column-breakpoint) {
max-width: 100%;
flex: 0 0 100%;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
}
}
}
}
.column-flex {
display: flex;
flex-direction: column;
}
.separator-or {
position: relative;
margin: 40px 0;
text-align: center;
&::before {
content: "";
display: block;
width: 100%;
height: 0;
border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
position: absolute;
top: 50%;
left: 0;
}
span {
display: inline-block;
background: $ui-base-color;
font-size: 12px;
font-weight: 500;
color: $ui-primary-color;
text-transform: uppercase;
position: relative;
z-index: 1;
padding: 0 8px;
cursor: default;
}
}
.landing-page { .landing-page {
p, p,
li { li {
@ -116,10 +243,14 @@
} }
hr { hr {
border-color: rgba($ui-base-lighter-color, .6); width: 100%;
height: 0;
border: 0;
border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
margin: 20px 0;
} }
.container { .container-alt {
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
max-width: 800px; max-width: 800px;
@ -152,24 +283,20 @@
} }
} }
} }
.mascot-container {
max-width: 800px;
margin: 0 auto;
position: absolute;
top: 0;
left: 0;
right: 0;
height: 100%;
} }
.mascot { .brand {
position: absolute; a {
bottom: -14px; padding-left: 0;
width: auto; padding-right: 0;
height: auto; color: $white;
left: 60px; }
z-index: 3;
img {
height: 32px;
position: relative;
top: 4px;
left: -10px;
} }
} }
@ -177,7 +304,7 @@
line-height: 30px; line-height: 30px;
overflow: hidden; overflow: hidden;
.container { .container-alt {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
@ -203,21 +330,6 @@
} }
} }
.brand {
a {
padding-left: 0;
padding-right: 0;
color: $white;
}
img {
height: 32px;
position: relative;
top: 4px;
left: -10px;
}
}
ul { ul {
list-style: none; list-style: none;
margin: 0; margin: 0;
@ -243,53 +355,6 @@
align-items: center; align-items: center;
position: relative; position: relative;
.floats {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
div {
position: absolute;
transition: all 0.1s linear;
animation-name: floating;
animation-iteration-count: infinite;
animation-direction: alternate;
animation-timing-function: ease-in-out;
z-index: 2;
}
.float-1 {
width: 324px;
height: 170px;
right: -120px;
bottom: 0;
animation-duration: 3s;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 447.1875 234.375" height="170" width="324"><path fill="#{hex-color($ui-base-lighter-color)}" d="M21.69 233.366c-6.45-1.268-13.347-5.63-16.704-10.564-10.705-15.734-1.513-37.724 18.632-44.57l4.8-1.632.173-17.753c.146-14.77.515-19.063 2.2-25.55 6.736-25.944 24.46-46.032 47.766-54.137 11.913-4.143 19.558-5.366 34.178-5.47l13.828-.096V71.12c0-4.755 2.853-17.457 5.238-23.327 8.588-21.137 26.735-35.957 52.153-42.593 23.248-6.07 50.153-6.415 71.863-.923 11.14 2.82 25.686 9.957 33.857 16.615 19.335 15.756 31.82 41.05 35.183 71.275.59 5.305.672 5.435 3.11 4.926 11.833-2.474 30.4-3.132 40.065-1.42 24.388 4.32 40.568 19.076 47.214 43.058 2.16 7.8 3.953 23.894 3.59 32.237l-.24 5.498 5.156 1.317c6.392 1.633 14.55 7.098 18.003 12.062 1.435 2.062 3.305 6.597 4.156 10.078 1.428 5.84 1.43 6.8.04 12.44-1.807 7.318-5.672 13.252-10.872 16.694-8.508 5.63 3.756 5.33-211.916 5.216-108.56-.056-199.22-.464-201.47-.906z"/></svg>');
}
.float-2 {
width: 241px;
height: 100px;
right: 210px;
bottom: 0;
animation-duration: 3.5s;
animation-delay: 0.2s;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 536.25 222.1875" height="100" width="241"><path fill="#{hex-color($ui-base-lighter-color)}" d="M42.626 221.23c-14.104-1.174-26.442-5.133-32.825-10.534-4.194-3.548-7.684-10.66-8.868-18.075-1.934-12.102.633-22.265 7.528-29.81 7.61-8.328 19.998-12.76 39.855-14.257l8.47-.638-2.08-6.223c-4.826-14.422-6.357-24.813-6.37-43.255-.012-14.923.28-18.513 2.1-25.724 2.283-9.048 8.483-23.034 13.345-30.1 14.76-21.45 43.505-38.425 70.535-41.65 30.628-3.655 64.47 12.073 89.668 41.673l5.955 6.995 2.765-4.174c1.52-2.296 5.74-6.93 9.376-10.295 18.382-17.02 43.436-20.676 73.352-10.705 12.158 4.052 21.315 9.53 29.64 17.733 12.752 12.562 18.16 25.718 18.19 44.26l.02 10.98 2.312-3.01c15.64-20.365 42.29-20.485 62.438-.28 3.644 3.653 7.558 8.593 8.697 10.976 4.895 10.24 5.932 25.688 2.486 37.046-.76 2.507-1.388 4.816-1.393 5.13-.006.316 6.845.87 15.224 1.234 53.06 2.297 76.356 12.98 81.817 37.526 3.554 15.973-3.71 28.604-19.566 34.02-4.554 1.555-17.922 1.655-234.517 1.757-126.327.06-233.497-.21-238.154-.597z"/></svg>');
}
.float-3 {
width: 267px;
height: 140px;
right: 110px;
top: -30px;
animation-duration: 4s;
animation-delay: 0.5s;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 388.125 202.5" height="140" width="267"><path fill="#{hex-color($ui-base-lighter-color)}" d="M181.37 201.458c-17.184-1.81-36.762-8.944-49.523-18.05l-5.774-4.12-8.074 2.63c-11.468 3.738-21.382 4.962-35.815 4.422-14.79-.554-24.577-2.845-36.716-8.594-15.483-7.332-28.498-19.98-35.985-34.968C2.44 128.675-.94 108.435.9 91.356c3.362-31.234 18.197-53.698 43.63-66.074 12.803-6.23 22.384-8.55 37.655-9.122 14.433-.54 24.347.684 35.814 4.42l8.073 2.633 5.635-4.01c24.81-17.656 60.007-23.332 92.914-14.985 10.11 2.565 25.498 9.62 33.102 15.178l5.068 3.704 7.632-2.564c10.89-3.66 21.086-4.916 35.516-4.376 45.816 1.716 76.422 30.03 81.285 75.196 1.84 17.08-1.54 37.32-8.585 51.422-7.487 14.99-20.502 27.636-35.984 34.968-12.14 5.75-21.926 8.04-36.716 8.593-14.43.54-24.626-.716-35.516-4.376l-7.632-2.564-5.068 3.704c-12.844 9.387-32.714 16.488-51.545 18.42-10.607 1.09-13.916 1.08-24.81-.066z"/></svg>');
}
}
.heading { .heading {
position: relative; position: relative;
z-index: 4; z-index: 4;
@ -346,18 +411,18 @@
background: darken($ui-base-color, 4%); background: darken($ui-base-color, 4%);
padding: 20px 0; padding: 20px 0;
.container { .container-alt {
position: relative; position: relative;
padding-right: 280px + 15px; padding-right: 280px + 15px;
} }
.information-board-sections { &__sections {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
} }
.section { &__section {
flex: 1 0 0; flex: 1 0 0;
font-family: 'mastodon-font-sans-serif', sans-serif; font-family: 'mastodon-font-sans-serif', sans-serif;
font-size: 16px; font-size: 16px;
@ -382,6 +447,10 @@
font-size: 32px; font-size: 32px;
line-height: 48px; line-height: 48px;
} }
@media screen and (max-width: $column-breakpoint) {
text-align: center;
}
} }
.panel { .panel {
@ -460,11 +529,180 @@
} }
} }
.features { &.alternative {
padding: 50px 0; padding: 10px 0;
.container { .brand {
display: flex; text-align: center;
padding: 30px 0;
margin-bottom: 10px;
img {
position: static;
}
@media screen and (max-width: $small-breakpoint) {
padding: 15px 0;
}
@media screen and (max-width: $column-breakpoint) {
padding: 0;
margin-bottom: -10px;
}
}
}
&__information,
&__forms {
padding: 20px;
}
&__call-to-action {
margin-bottom: 10px;
background: darken($ui-base-color, 4%);
border-radius: 4px;
padding: 25px 40px;
overflow: hidden;
.row {
align-items: center;
}
.information-board__section {
padding: 0;
}
}
&__logo {
margin-right: 20px;
img {
height: 50px;
width: auto;
mix-blend-mode: lighten;
}
}
&__information {
padding: 45px 40px;
margin-bottom: 10px;
&:last-child {
margin-bottom: 0;
}
@media screen and (max-width: $column-breakpoint) {
padding: 25px 20px;
}
}
&__information,
&__forms,
#mastodon-timeline {
box-sizing: border-box;
background: $ui-base-color;
border-radius: 4px;
box-shadow: 0 0 6px rgba($black, 0.1);
}
&__mascot {
height: 104px;
position: relative;
left: -40px;
bottom: 25px;
img {
height: 190px;
width: auto;
}
}
&__short-description {
.row {
align-items: center;
margin-bottom: 40px;
}
@media screen and (max-width: $column-breakpoint) {
.row {
margin-bottom: 20px;
}
}
p a {
color: $ui-secondary-color;
}
h1 {
font-weight: 500;
color: $primary-text-color;
margin-bottom: 0;
small {
color: $ui-primary-color;
span {
color: $ui-secondary-color;
}
}
}
p:last-child {
margin-bottom: 0;
}
}
&__hero {
margin-bottom: 10px;
img {
display: block;
margin: 0;
max-width: 100%;
height: auto;
border-radius: 4px;
}
}
&__forms {
height: 100%;
@media screen and (max-width: $small-breakpoint) {
margin-bottom: 10px;
height: auto;
}
@media screen and (max-width: $column-breakpoint) {
background: transparent;
box-shadow: none;
padding: 0 20px;
margin-top: 30px;
margin-bottom: 40px;
.separator-or {
span {
background: darken($ui-base-color, 8%);
}
}
}
hr {
margin: 40px 0;
}
.button {
display: block;
}
.subtle-hint a {
text-decoration: none;
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}
} }
#mastodon-timeline { #mastodon-timeline {
@ -476,13 +714,9 @@
line-height: 18px; line-height: 18px;
font-weight: 400; font-weight: 400;
color: $primary-text-color; color: $primary-text-color;
width: 330px; width: 100%;
margin-right: 30px; flex: 1 1 auto;
flex: 0 0 auto;
background: $ui-base-color;
overflow: hidden; overflow: hidden;
border-radius: 4px;
box-shadow: 0 0 6px rgba($black, 0.1);
.column-header { .column-header {
color: inherit; color: inherit;
@ -498,6 +732,7 @@
padding: 0; padding: 0;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
width: 100%;
} }
.scrollable { .scrollable {
@ -520,13 +755,20 @@
text-decoration: none; text-decoration: none;
} }
} }
@media screen and (max-width: $column-breakpoint) {
height: 90vh;
}
} }
.about-mastodon { &__features {
max-width: 675px; .features-list {
margin: 40px 0 !important;
}
p { &__action {
margin-bottom: 20px; text-align: center;
}
} }
.features-list { .features-list {
@ -567,8 +809,6 @@
} }
} }
} }
}
}
.extended-description { .extended-description {
padding: 50px 0; padding: 50px 0;
@ -600,21 +840,31 @@
} }
} }
&__footer {
margin-top: 10px;
text-align: center;
color: $ui-base-lighter-color;
p {
font-size: 14px;
a {
color: inherit;
text-decoration: underline;
}
}
}
@media screen and (max-width: 840px) { @media screen and (max-width: 840px) {
.container { .container-alt {
padding: 0 20px; padding: 0 20px;
} }
.information-board { .information-board {
.container-alt {
.container {
padding-right: 20px; padding-right: 20px;
} }
.section {
text-align: center;
}
.panel { .panel {
position: static; position: static;
margin-top: 20px; margin-top: 20px;
@ -626,16 +876,6 @@
} }
} }
} }
.header-wrapper .mascot {
left: 20px;
}
}
@media screen and (max-width: 689px) {
.header-wrapper .mascot {
display: none;
}
} }
@media screen and (max-width: 675px) { @media screen and (max-width: 675px) {
@ -651,13 +891,12 @@
} }
} }
.header .container, .header .container-alt,
.features .container { .features .container-alt {
display: block; display: block;
} }
.header { .header {
.links { .links {
padding-top: 15px; padding-top: 15px;
background: darken($ui-base-color, 4%); background: darken($ui-base-color, 4%);
@ -682,10 +921,6 @@
margin-top: 30px; margin-top: 30px;
padding: 0; padding: 0;
.floats {
display: none;
}
.heading { .heading {
padding: 30px 20px; padding: 30px 20px;
text-align: center; text-align: center;
@ -700,16 +935,6 @@
} }
} }
} }
.features #mastodon-timeline {
height: 70vh;
width: 100%;
margin-bottom: 50px;
.column {
width: 100%;
}
}
} }
.cta { .cta {
@ -720,7 +945,7 @@
.features { .features {
padding: 30px 0; padding: 30px 0;
.container { .container-alt {
max-width: 820px; max-width: 820px;
#mastodon-timeline { #mastodon-timeline {
@ -772,7 +997,7 @@
.features { .features {
padding: 10px 0; padding: 10px 0;
.container { .container-alt {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -808,17 +1033,3 @@
} }
} }
} }
@keyframes floating {
from {
transform: translate(0, 0);
}
65% {
transform: translate(0, 4px);
}
to {
transform: translate(0, -0);
}
}

View File

@ -40,14 +40,20 @@
cursor: default; cursor: default;
} }
&.button-alternative { &.button-primary,
&.button-alternative,
&.button-secondary,
&.button-alternative-2 {
font-size: 16px; font-size: 16px;
line-height: 36px; line-height: 36px;
height: auto; height: auto;
color: $ui-base-color;
background: $ui-primary-color;
text-transform: none; text-transform: none;
padding: 4px 16px; padding: 4px 16px;
}
&.button-alternative {
color: $ui-base-color;
background: $ui-primary-color;
&:active, &:active,
&:focus, &:focus,
@ -56,15 +62,20 @@
} }
} }
&.button-alternative-2 {
background: $ui-base-lighter-color;
&:active,
&:focus,
&:hover {
background-color: lighten($ui-base-lighter-color, 4%);
}
}
&.button-secondary { &.button-secondary {
font-size: 16px;
line-height: 36px;
height: auto;
color: $ui-primary-color; color: $ui-primary-color;
text-transform: none;
background: transparent; background: transparent;
padding: 3px 15px; padding: 3px 15px;
border-radius: 4px;
border: 1px solid $ui-primary-color; border: 1px solid $ui-primary-color;
&:active, &:active,
@ -433,6 +444,34 @@
min-width: 40%; min-width: 40%;
margin: 5px; margin: 5px;
&__actions {
background: linear-gradient(180deg, rgba($base-shadow-color, 0.8) 0, rgba($base-shadow-color, 0.35) 80%, transparent);
display: flex;
align-items: flex-start;
justify-content: space-between;
opacity: 0;
transition: opacity .1s ease;
.icon-button {
flex: 0 1 auto;
color: $ui-secondary-color;
font-size: 14px;
font-weight: 500;
padding: 10px;
font-family: inherit;
&:hover,
&:focus,
&:active {
color: lighten($ui-secondary-color, 4%);
}
}
&.active {
opacity: 1;
}
}
&-description { &-description {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
@ -470,10 +509,6 @@
opacity: 1; opacity: 1;
} }
} }
.icon-button {
mix-blend-mode: difference;
}
} }
.compose-form__upload-thumbnail { .compose-form__upload-thumbnail {
@ -481,8 +516,9 @@
background-position: center; background-position: center;
background-size: cover; background-size: cover;
background-repeat: no-repeat; background-repeat: no-repeat;
height: 100px; height: 140px;
width: 100%; width: 100%;
overflow: hidden;
} }
} }
@ -4133,8 +4169,12 @@ a.status-card {
&, &,
img { img {
width: 100%; width: 100%;
height: 100%; }
img {
position: relative;
object-fit: cover; object-fit: cover;
height: auto;
} }
} }
@ -4842,3 +4882,31 @@ noscript {
margin-bottom: 0; margin-bottom: 0;
} }
} }
.focal-point {
position: relative;
cursor: pointer;
overflow: hidden;
&.dragging {
cursor: move;
}
&__reticle {
position: absolute;
width: 100px;
height: 100px;
transform: translate(-50%, -50%);
background: url('../images/reticle.png') no-repeat 0 0;
border-radius: 50%;
box-shadow: 0 0 0 9999em rgba($base-shadow-color, 0.35);
}
&__overlay {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
}

View File

@ -1,4 +1,4 @@
.container { .container-alt {
width: 700px; width: 700px;
margin: 0 auto; margin: 0 auto;
margin-top: 40px; margin-top: 40px;

View File

@ -116,7 +116,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank? next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
href = Addressable::URI.parse(attachment['url']).normalize.to_s href = Addressable::URI.parse(attachment['url']).normalize.to_s
media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence) media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
media_attachments << media_attachment media_attachments << media_attachment
next if skip_download? next if skip_download?

View File

@ -17,6 +17,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
'conversation' => 'ostatus:conversation', 'conversation' => 'ostatus:conversation',
'toot' => 'http://joinmastodon.org/ns#', 'toot' => 'http://joinmastodon.org/ns#',
'Emoji' => 'toot:Emoji', 'Emoji' => 'toot:Emoji',
'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' },
}, },
], ],
}.freeze }.freeze

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class FastGeometryParser
def self.from_file(file)
width, height = FastImage.size(file.path)
raise Paperclip::Errors::NotIdentifiedByImageMagickError if width.nil?
Paperclip::Geometry.new(width, height)
end
end

View File

@ -66,4 +66,16 @@ class UserMailer < Devise::Mailer
mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject') mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject')
end end
end end
def backup_ready(user, backup)
@resource = user
@instance = Rails.configuration.x.local_domain
@backup = backup
return if @resource.disabled?
I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject')
end
end
end end

22
app/models/backup.rb Normal file
View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: backups
#
# id :integer not null, primary key
# user_id :integer
# dump_file_name :string
# dump_content_type :string
# dump_file_size :integer
# dump_updated_at :datetime
# processed :boolean default(FALSE), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Backup < ApplicationRecord
belongs_to :user, inverse_of: :backups
has_attached_file :dump
do_not_validate_attachment_file_type :dump
end

View File

@ -7,15 +7,9 @@ module AccountAvatar
class_methods do class_methods do
def avatar_styles(file) def avatar_styles(file)
styles = {} styles = { original: { geometry: '120x120#', file_geometry_parser: FastGeometryParser } }
geometry = Paperclip::Geometry.from_file(file) styles[:static] = { geometry: '120x120#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
styles[:original] = '120x120#' if geometry.width != geometry.height || geometry.width > 120 || geometry.height > 120
styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
styles styles
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
{}
end end
private :avatar_styles private :avatar_styles
@ -23,7 +17,7 @@ module AccountAvatar
included do included do
# Avatar upload # Avatar upload
has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' } has_attached_file :avatar, styles: ->(f) { avatar_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES
validates_attachment_size :avatar, less_than: 2.megabytes validates_attachment_size :avatar, less_than: 2.megabytes
end end

View File

@ -7,15 +7,9 @@ module AccountHeader
class_methods do class_methods do
def header_styles(file) def header_styles(file)
styles = {} styles = { original: { geometry: '700x335#', file_geometry_parser: FastGeometryParser } }
geometry = Paperclip::Geometry.from_file(file) styles[:static] = { geometry: '700x335#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
styles[:original] = '700x335#' unless geometry.width == 700 && geometry.height == 335
styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
styles styles
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
{}
end end
private :header_styles private :header_styles
@ -23,7 +17,7 @@ module AccountHeader
included do included do
# Header upload # Header upload
has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' } has_attached_file :header, styles: ->(f) { header_styles(f) }, convert_options: { all: '-strip' }, processors: [:lazy_thumbnail]
validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES
validates_attachment_size :header, less_than: 2.megabytes validates_attachment_size :header, less_than: 2.megabytes
end end

View File

@ -53,8 +53,11 @@ module Omniauthable
private private
def user_params_from_auth(auth) def user_params_from_auth(auth)
email_is_verified = auth.info.email && (auth.info.verified || auth.info.verified_email) strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy
email = auth.info.email if email_is_verified && !User.exists?(email: auth.info.email) assume_verified = strategy.try(:security).try(:assume_email_is_verified)
email_is_verified = auth.info.verified || auth.info.verified_email || assume_verified
email = auth.info.verified_email || auth.info.email
email = email_is_verified && !User.exists?(email: auth.info.email) && email
{ {
email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com", email: email ? email : "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",

View File

@ -34,7 +34,18 @@ class MediaAttachment < ApplicationRecord
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze VIDEO_MIME_TYPES = ['video/webm', 'video/mp4'].freeze
AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
IMAGE_STYLES = { original: '1280x1280>', small: '400x400>' }.freeze IMAGE_STYLES = {
original: {
geometry: '1280x1280>',
file_geometry_parser: FastGeometryParser,
},
small: {
geometry: '400x400>',
file_geometry_parser: FastGeometryParser,
},
}.freeze
AUDIO_STYLES = { AUDIO_STYLES = {
original: { original: {
format: 'mp4', format: 'mp4',
@ -50,6 +61,7 @@ class MediaAttachment < ApplicationRecord
}, },
}, },
}.freeze }.freeze
VIDEO_STYLES = { VIDEO_STYLES = {
small: { small: {
convert_options: { convert_options: {
@ -97,6 +109,24 @@ class MediaAttachment < ApplicationRecord
shortcode shortcode
end end
def focus=(point)
return if point.blank?
x, y = (point.is_a?(Enumerable) ? point : point.split(',')).map(&:to_f)
meta = file.instance_read(:meta) || {}
meta['focus'] = { 'x' => x, 'y' => y }
file.instance_write(:meta, meta)
end
def focus
x = file.meta['focus']['x']
y = file.meta['focus']['y']
"#{x},#{y}"
end
before_create :prepare_description, unless: :local? before_create :prepare_description, unless: :local?
before_create :set_shortcode before_create :set_shortcode
before_post_process :set_type_and_extension before_post_process :set_type_and_extension
@ -178,7 +208,7 @@ class MediaAttachment < ApplicationRecord
end end
def populate_meta def populate_meta
meta = {} meta = file.instance_read(:meta) || {}
file.queued_for_write.each do |style, file| file.queued_for_write.each do |style, file|
meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file) meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
@ -188,16 +218,16 @@ class MediaAttachment < ApplicationRecord
end end
def image_geometry(file) def image_geometry(file)
geo = Paperclip::Geometry.from_file file width, height = FastImage.size(file.path)
return {} if width.nil?
{ {
width: geo.width.to_i, width: width,
height: geo.height.to_i, height: height,
size: "#{geo.width.to_i}x#{geo.height.to_i}", size: "#{width}x#{height}",
aspect: geo.width.to_f / geo.height.to_f, aspect: width.to_f / height.to_f,
} }
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
{}
end end
def video_metadata(file) def video_metadata(file)

View File

@ -33,7 +33,7 @@ class PreviewCard < ApplicationRecord
has_and_belongs_to_many :statuses has_and_belongs_to_many :statuses
has_attached_file :image, styles: { original: '400x400>' }, convert_options: { all: '-quality 80 -strip' } has_attached_file :image, styles: { original: { geometry: '400x400>', file_geometry_parser: FastGeometryParser } }, convert_options: { all: '-quality 80 -strip' }
include Attachmentable include Attachmentable
include Remotable include Remotable
@ -58,10 +58,11 @@ class PreviewCard < ApplicationRecord
return if file.nil? return if file.nil?
geo = Paperclip::Geometry.from_file(file) width, height = FastImage.size(file.path)
self.width = geo.width.to_i
self.height = geo.height.to_i return nil if width.nil?
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
nil self.width = width
self.height = height
end end
end end

View File

@ -34,8 +34,8 @@ class SiteUpload < ApplicationRecord
return if tempfile.nil? return if tempfile.nil?
geometry = Paperclip::Geometry.from_file(tempfile) width, height = FastImage.size(tempfile.path)
self.meta = { width: geometry.width.to_i, height: geometry.height.to_i } self.meta = { width: width, height: height }
end end
def clear_cache def clear_cache

View File

@ -79,7 +79,7 @@ class Status < ApplicationRecord
scope :not_local_only, -> { where(local_only: [false, nil]) } scope :not_local_only, -> { where(local_only: [false, nil]) }
cache_associated :account, :application, :media_attachments, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, mentions: :account], thread: :account cache_associated :account, :application, :media_attachments, :conversation, :tags, :stream_entry, mentions: :account, reblog: [:account, :application, :stream_entry, :tags, :media_attachments, :conversation, mentions: :account], thread: :account
delegate :domain, to: :account, prefix: true delegate :domain, to: :account, prefix: true

View File

@ -60,6 +60,7 @@ class User < ApplicationRecord
accepts_nested_attributes_for :account accepts_nested_attributes_for :account
has_many :applications, class_name: 'Doorkeeper::Application', as: :owner has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
has_many :backups, inverse_of: :user
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale? validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
validates_with BlacklistedEmailValidator, if: :email_changed? validates_with BlacklistedEmailValidator, if: :email_changed?

View File

@ -15,4 +15,8 @@ class ApplicationPolicy
def current_user def current_user
current_account&.user current_account&.user
end end
def user_signed_in?
!current_user.nil?
end
end end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class BackupPolicy < ApplicationPolicy
MIN_AGE = 1.week
def create?
user_signed_in? && current_user.backups.where('created_at >= ?', MIN_AGE.ago).count.zero?
end
end

View File

@ -45,7 +45,7 @@ class AccountRelationshipsPresenter
maps_for_account = Rails.cache.read("relationship:#{@current_account_id}:#{account_id}") maps_for_account = Rails.cache.read("relationship:#{@current_account_id}:#{account_id}")
if maps_for_account.is_a?(Hash) if maps_for_account.is_a?(Hash)
@cached.merge!(maps_for_account) @cached.deep_merge!(maps_for_account)
else else
@uncached_account_ids << account_id @uncached_account_ids << account_id
end end

View File

@ -48,4 +48,8 @@ class InstancePresenter
def thumbnail def thumbnail
@thumbnail ||= Rails.cache.fetch('site_uploads/thumbnail') { SiteUpload.find_by(var: 'thumbnail') } @thumbnail ||= Rails.cache.fetch('site_uploads/thumbnail') { SiteUpload.find_by(var: 'thumbnail') }
end end
def hero
@hero ||= Rails.cache.fetch('site_uploads/hero') { SiteUpload.find_by(var: 'hero') }
end
end end

View File

@ -13,8 +13,8 @@ class ActivityPub::CollectionSerializer < ActiveModel::Serializer
attribute :part_of, if: -> { object.part_of.present? } attribute :part_of, if: -> { object.part_of.present? }
has_one :first, if: -> { object.first.present? } has_one :first, if: -> { object.first.present? }
has_many :items, key: :items, if: -> { (object.items.present? || page?) && !ordered? } has_many :items, key: :items, if: -> { (!object.items.nil? || page?) && !ordered? }
has_many :items, key: :ordered_items, if: -> { (object.items.present? || page?) && ordered? } has_many :items, key: :ordered_items, if: -> { (!object.items.nil? || page?) && ordered? }
def type def type
if page? if page?

View File

@ -4,6 +4,7 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
include RoutingHelper include RoutingHelper
attributes :type, :media_type, :url attributes :type, :media_type, :url
attribute :focal_point, if: :focal_point?
def type def type
'Image' 'Image'
@ -16,4 +17,12 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
def media_type def media_type
object.content_type object.content_type
end end
def focal_point?
object.respond_to?(:meta) && object.meta.is_a?(Hash) && object.meta['focus'].is_a?(Hash)
end
def focal_point
[object.meta['focus']['x'], object.meta['focus']['y']]
end
end end

View File

@ -0,0 +1,128 @@
# frozen_string_literal: true
require 'rubygems/package'
class BackupService < BaseService
attr_reader :account, :backup, :collection
def call(backup)
@backup = backup
@account = backup.user.account
build_json!
build_archive!
end
private
def build_json!
@collection = serialize(collection_presenter, ActivityPub::CollectionSerializer)
account.statuses.with_includes.find_in_batches do |statuses|
statuses.each do |status|
item = serialize(status, ActivityPub::ActivitySerializer)
item.delete(:'@context')
unless item[:type] == 'Announce' || item[:object][:attachment].blank?
item[:object][:attachment].each do |attachment|
attachment[:url] = Addressable::URI.parse(attachment[:url]).path.gsub(/\A\/system\//, '')
end
end
@collection[:orderedItems] << item
end
GC.start
end
end
def build_archive!
tmp_file = Tempfile.new(%w(archive .tar.gz))
File.open(tmp_file, 'wb') do |file|
Zlib::GzipWriter.wrap(file) do |gz|
Gem::Package::TarWriter.new(gz) do |tar|
dump_media_attachments!(tar)
dump_outbox!(tar)
dump_actor!(tar)
end
end
end
archive_filename = ['archive', Time.now.utc.strftime('%Y%m%d%H%M%S'), SecureRandom.hex(2)].join('-') + '.tar.gz'
@backup.dump = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename)
@backup.processed = true
@backup.save!
ensure
tmp_file.close
tmp_file.unlink
end
def dump_media_attachments!(tar)
MediaAttachment.attached.where(account: account).find_in_batches do |media_attachments|
media_attachments.each do |m|
download_to_tar(tar, m.file, m.file.path)
end
GC.start
end
end
def dump_outbox!(tar)
json = Oj.dump(collection)
tar.add_file_simple('outbox.json', 0o444, json.bytesize) do |io|
io.write(json)
end
end
def dump_actor!(tar)
actor = serialize(account, ActivityPub::ActorSerializer)
actor[:icon][:url] = 'avatar' + File.extname(actor[:icon][:url]) if actor[:icon]
actor[:image][:url] = 'header' + File.extname(actor[:image][:url]) if actor[:image]
download_to_tar(tar, account.avatar, 'avatar' + File.extname(account.avatar.path)) if account.avatar.exists?
download_to_tar(tar, account.header, 'header' + File.extname(account.header.path)) if account.header.exists?
json = Oj.dump(actor)
tar.add_file_simple('actor.json', 0o444, json.bytesize) do |io|
io.write(json)
end
tar.add_file_simple('key.pem', 0o444, account.private_key.bytesize) do |io|
io.write(account.private_key)
end
end
def collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_outbox_url(account),
type: :ordered,
size: account.statuses_count,
items: []
)
end
def serialize(object, serializer)
ActiveModelSerializers::SerializableResource.new(
object,
serializer: serializer,
adapter: ActivityPub::Adapter
).as_json
end
CHUNK_SIZE = 1.megabyte
def download_to_tar(tar, attachment, filename)
adapter = Paperclip.io_adapters.for(attachment)
tar.add_file_simple(filename, 0o444, adapter.size) do |io|
while (buffer = adapter.read(CHUNK_SIZE))
io.write(buffer)
end
end
end
end

View File

@ -0,0 +1,14 @@
- if @instance_presenter.open_registrations
= render 'registration'
- else
- if @instance_presenter.closed_registrations_message.blank?
%p= t('about.closed_registrations')
- else
= @instance_presenter.closed_registrations_message.html_safe
= link_to t('auth.register'), 'https://joinmastodon.org', class: 'button button-primary'
.separator-or
%span= t('auth.or')
= link_to t('auth.login'), new_user_session_path, class: 'button button-alternative-2 webapp-btn'

View File

@ -1,4 +1,4 @@
.container.links .container-alt.links
.brand .brand
= link_to root_url do = link_to root_url do
= image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon' = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'

View File

@ -10,6 +10,6 @@
= f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' } = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
.actions .actions
= f.button :button, t('auth.register'), type: :submit, class: 'button button-alternative' = f.button :button, t('auth.register'), type: :submit, class: 'button button-primary'
%p.hint.subtle-hint=t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path) %p.hint.subtle-hint=t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path)

View File

@ -9,34 +9,34 @@
.header .header
= render 'links' = render 'links'
.container.hero .container-alt.hero
.heading .heading
%h3= t('about.description_headline', domain: site_hostname) %h3= t('about.description_headline', domain: site_hostname)
%p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
.information-board .information-board
.container .container-alt
.information-board-sections .information-board__sections
.section .information-board__section
%span= t 'about.user_count_before' %span= t 'about.user_count_before'
%strong= number_with_delimiter @instance_presenter.user_count %strong= number_with_delimiter @instance_presenter.user_count
%span= t 'about.user_count_after' %span= t 'about.user_count_after'
.section .information-board__section
%span= t 'about.status_count_before' %span= t 'about.status_count_before'
%strong= number_with_delimiter @instance_presenter.status_count %strong= number_with_delimiter @instance_presenter.status_count
%span= t 'about.status_count_after' %span= t 'about.status_count_after'
.section .information-board__section
%span= t 'about.domain_count_before' %span= t 'about.domain_count_before'
%strong= number_with_delimiter @instance_presenter.domain_count %strong= number_with_delimiter @instance_presenter.domain_count
%span= t 'about.domain_count_after' %span= t 'about.domain_count_after'
= render 'contact', contact: @instance_presenter = render 'contact', contact: @instance_presenter
.extended-description .extended-description
.container .container-alt
= @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html') = @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html')
.footer-links .footer-links
.container .container-alt
%p %p
= link_to t('about.source_code'), @instance_presenter.source_url = link_to t('about.source_code'), @instance_presenter.source_url
- if @instance_presenter.commit_hash == "" - if @instance_presenter.commit_hash == ""

View File

@ -5,62 +5,74 @@
%script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
= render partial: 'shared/og' = render partial: 'shared/og'
.landing-page .landing-page.alternative
.header-wrapper .container
.mascot-container .row
= image_tag asset_pack_path('elephant-fren.png'), alt: '', role: 'presentation', class: 'mascot' .column-4.hide-sm.show-xs.show-m
.landing-page__forms
.brand
= link_to root_url do
= image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
.header .hide-xs
= render 'links' = render 'forms'
.column-7.column-9-sm
.landing-page__hero
= image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('preview.jpg'), alt: @instance_presenter.site_title
.landing-page__information
.landing-page__short-description
.row
.landing-page__logo.hide-xs
= image_tag asset_pack_path('logo_transparent.svg'), alt: 'Mastodon'
.container.hero
.floats
%div{ role: 'presentation', class: 'float-1' }
%div{ role: 'presentation', class: 'float-2' }
%div{ role: 'presentation', class: 'float-3' }
.heading
%h1 %h1
= @instance_presenter.site_title = @instance_presenter.site_title
%small= t 'about.hosted_on', domain: site_hostname %small!= t 'about.hosted_on', domain: content_tag(:span, site_hostname)
- if @instance_presenter.open_registrations
= render 'registration'
- else
.closed-registrations-message
%div
- if @instance_presenter.closed_registrations_message.blank?
%p= t('about.closed_registrations')
- else
= @instance_presenter.closed_registrations_message.html_safe
= simple_form_for(:user, html: { style: 'margin-left: -20px' }, url: session_path(:user)) do |f|
= f.input :email, autofocus: true, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }
= f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }
.actions
= f.button :button, t('auth.login'), type: :submit
= link_to t('about.find_another_instance'), 'https://joinmastodon.org/', class: 'button button-alternative button--block'
.about-short
.container
%h3= t('about.description_headline', domain: site_hostname)
%p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
.features .show-xs
.container .landing-page__forms
- if Setting.timeline_preview = render 'forms'
#mastodon-timeline{ data: { props: Oj.dump(default_props) } } .landing-page__call-to-action.hide-xs
.row
.about-mastodon .column-5
.landing-page__mascot
= image_tag asset_pack_path('elephant_ui_plane.svg')
.column-5
.information-board__section
%span= t 'about.user_count_before'
%strong= number_with_delimiter @instance_presenter.user_count
%span= t 'about.user_count_after'
.column-5
.information-board__section
%span= t 'about.status_count_before'
%strong= number_with_delimiter @instance_presenter.status_count
%span= t 'about.status_count_after'
.landing-page__information
.landing-page__features
%h3= t 'about.what_is_mastodon' %h3= t 'about.what_is_mastodon'
%p= t 'about.about_mastodon_html' %p= t 'about.about_mastodon_html'
= link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-secondary'
= render 'features' = render 'features'
.footer-links
.container .landing-page__features__action
= link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-alternative'
.landing-page__footer
%p %p
= link_to t('about.source_code'), @instance_presenter.source_url = link_to t('about.source_code'), @instance_presenter.source_url
- if @instance_presenter.commit_hash == "" = " (#{@instance_presenter.version_number})"
%strong= " (#{@instance_presenter.version_number})"
- else .column-4.column-6-sm.column-flex
%strong= " (#{@instance_presenter.version_number}, " .show-sm.hide-xs
%strong= " #{@instance_presenter.commit_hash})" .landing-page__forms
.brand
= link_to root_url do
= image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
= render 'forms'
- if Setting.timeline_preview
#mastodon-timeline{ data: { props: Oj.dump(default_props) } }

View File

@ -7,5 +7,5 @@
= render 'links' = render 'links'
.extended-description .extended-description
.container .container-alt
= @instance_presenter.site_terms.html_safe.presence || t('terms.body_html') = @instance_presenter.site_terms.html_safe.presence || t('terms.body_html')

View File

@ -12,6 +12,7 @@
.fields-group .fields-group
= f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html') = f.input :thumbnail, as: :file, wrapper: :with_block_label, label: t('admin.settings.thumbnail.title'), hint: t('admin.settings.thumbnail.desc_html')
= f.input :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html')
%hr/ %hr/

View File

@ -1,5 +1,5 @@
- content_for :content do - content_for :content do
.container .container-alt
.logo-container .logo-container
%h1 %h1
= link_to root_path do = link_to root_path do

View File

@ -8,7 +8,7 @@
= link_to destroy_user_session_path, method: :delete, class: 'logout-link icon-button' do = link_to destroy_user_session_path, method: :delete, class: 'logout-link icon-button' do
= fa_icon 'sign-out' = fa_icon 'sign-out'
.container= yield .container-alt= yield
.modal-layout__mastodon .modal-layout__mastodon
%div %div

View File

@ -1,5 +1,5 @@
- content_for :content do - content_for :content do
.container= yield .container-alt= yield
.footer .footer
- if !user_signed_in? && single_user_mode? - if !user_signed_in? && single_user_mode?
%span.single-user-login %span.single-user-login

View File

@ -20,3 +20,26 @@
%th= t('exports.mutes') %th= t('exports.mutes')
%td= @export.total_mutes %td= @export.total_mutes
%td= table_link_to 'download', t('exports.csv'), settings_exports_mutes_path(format: :csv) %td= table_link_to 'download', t('exports.csv'), settings_exports_mutes_path(format: :csv)
%p.muted-hint= t('exports.archive_takeout.hint_html')
- if policy(:backup).create?
%p= link_to t('exports.archive_takeout.request'), settings_export_path, class: 'button', method: :post
- unless @backups.empty?
.table-wrapper
%table.table
%thead
%tr
%th= t('exports.archive_takeout.date')
%th= t('exports.archive_takeout.size')
%th
%tbody
- @backups.each do |backup|
%tr
%td= l backup.created_at
- if backup.processed?
%td= number_to_human_size backup.dump_file_size
%td= table_link_to 'download', t('exports.archive_takeout.download'), backup.dump.url
- else
%td{ colspan: 2 }= t('exports.archive_takeout.in_progress')

View File

@ -0,0 +1,59 @@
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.hero
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center.padded
%table.hero-icon{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td
= image_tag full_pack_url('icon_file_download.png'), alt: ''
%h1= t 'user_mailer.backup_ready.title'
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell.content-start
.email-row
.col-6
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.text-center
%p= t 'user_mailer.backup_ready.explanation'
%table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.email-body
.email-container
%table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.content-cell
%table.column{ cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.column-cell.button-cell
%table.button{ align: 'center', cellspacing: 0, cellpadding: 0 }
%tbody
%tr
%td.button-primary
= link_to full_asset_url(@backup.dump.url) do
%span= t 'exports.archive_takeout.download'

View File

@ -0,0 +1,7 @@
<%= t 'user_mailer.backup_ready.title' %>
===
<%= t 'user_mailer.backup_ready.explanation' %>
=> <%= full_asset_url(@backup.dump.url) %>

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class BackupWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull'
def perform(backup_id)
backup = Backup.find(backup_id)
user = backup.user
BackupService.new.call(backup)
user.backups.where.not(id: backup.id).destroy_all
UserMailer.backup_ready(user, backup).deliver_later
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
require 'sidekiq-scheduler'
class Scheduler::BackupCleanupScheduler
include Sidekiq::Worker
def perform
old_backups.find_each(&:destroy!)
end
private
def old_backups
Backup.where('created_at < ?', 7.days.ago)
end
end

View File

@ -7,6 +7,7 @@ require 'rails/all'
Bundler.require(*Rails.groups) Bundler.require(*Rails.groups)
require_relative '../app/lib/exceptions' require_relative '../app/lib/exceptions'
require_relative '../lib/paperclip/lazy_thumbnail'
require_relative '../lib/paperclip/gif_transcoder' require_relative '../lib/paperclip/gif_transcoder'
require_relative '../lib/paperclip/video_transcoder' require_relative '../lib/paperclip/video_transcoder'
require_relative '../lib/paperclip/audio_transcoder' require_relative '../lib/paperclip/audio_transcoder'

View File

@ -9,6 +9,7 @@ Chewy.settings = {
prefix: prefix, prefix: prefix,
enabled: enabled, enabled: enabled,
journal: false, journal: false,
sidekiq: { queue: 'pull' },
} }
Chewy.root_strategy = enabled ? :sidekiq : :bypass Chewy.root_strategy = enabled ? :sidekiq : :bypass

View File

@ -4,10 +4,12 @@ end
Devise.setup do |config| Devise.setup do |config|
# Devise omniauth strategies # Devise omniauth strategies
options = {}
options[:redirect_at_sign_in] = ENV['OAUTH_REDIRECT_AT_SIGN_IN'] == 'true'
# CAS strategy # CAS strategy
if ENV['CAS_ENABLED'] == 'true' if ENV['CAS_ENABLED'] == 'true'
cas_options = {} cas_options = options
cas_options[:url] = ENV['CAS_URL'] if ENV['CAS_URL'] cas_options[:url] = ENV['CAS_URL'] if ENV['CAS_URL']
cas_options[:host] = ENV['CAS_HOST'] if ENV['CAS_HOST'] cas_options[:host] = ENV['CAS_HOST'] if ENV['CAS_HOST']
cas_options[:port] = ENV['CAS_PORT'] if ENV['CAS_PORT'] cas_options[:port] = ENV['CAS_PORT'] if ENV['CAS_PORT']
@ -18,7 +20,7 @@ Devise.setup do |config|
cas_options[:login_url] = ENV['CAS_LOGIN_URL'] if ENV['CAS_LOGIN_URL'] cas_options[:login_url] = ENV['CAS_LOGIN_URL'] if ENV['CAS_LOGIN_URL']
cas_options[:uid_field] = ENV['CAS_UID_FIELD'] || 'user' if ENV['CAS_UID_FIELD'] cas_options[:uid_field] = ENV['CAS_UID_FIELD'] || 'user' if ENV['CAS_UID_FIELD']
cas_options[:ca_path] = ENV['CAS_CA_PATH'] if ENV['CAS_CA_PATH'] cas_options[:ca_path] = ENV['CAS_CA_PATH'] if ENV['CAS_CA_PATH']
cas_options[:disable_ssl_verification] = ENV['CAS_DISABLE_SSL_VERIFICATION'] == 'true' if ENV['CAS_DISABLE_SSL_VERIFICATION'] cas_options[:disable_ssl_verification] = ENV['CAS_DISABLE_SSL_VERIFICATION'] == 'true'
cas_options[:uid_key] = ENV['CAS_UID_KEY'] || 'user' cas_options[:uid_key] = ENV['CAS_UID_KEY'] || 'user'
cas_options[:name_key] = ENV['CAS_NAME_KEY'] || 'name' cas_options[:name_key] = ENV['CAS_NAME_KEY'] || 'name'
cas_options[:email_key] = ENV['CAS_EMAIL_KEY'] || 'email' cas_options[:email_key] = ENV['CAS_EMAIL_KEY'] || 'email'
@ -33,7 +35,7 @@ Devise.setup do |config|
# SAML strategy # SAML strategy
if ENV['SAML_ENABLED'] == 'true' if ENV['SAML_ENABLED'] == 'true'
saml_options = {} saml_options = options
saml_options[:assertion_consumer_service_url] = ENV['SAML_ACS_URL'] if ENV['SAML_ACS_URL'] saml_options[:assertion_consumer_service_url] = ENV['SAML_ACS_URL'] if ENV['SAML_ACS_URL']
saml_options[:issuer] = ENV['SAML_ISSUER'] if ENV['SAML_ISSUER'] saml_options[:issuer] = ENV['SAML_ISSUER'] if ENV['SAML_ISSUER']
saml_options[:idp_sso_target_url] = ENV['SAML_IDP_SSO_TARGET_URL'] if ENV['SAML_IDP_SSO_TARGET_URL'] saml_options[:idp_sso_target_url] = ENV['SAML_IDP_SSO_TARGET_URL'] if ENV['SAML_IDP_SSO_TARGET_URL']
@ -48,10 +50,13 @@ Devise.setup do |config|
saml_options[:security] = {} saml_options[:security] = {}
saml_options[:security][:want_assertions_signed] = ENV['SAML_SECURITY_WANT_ASSERTION_SIGNED'] == 'true' saml_options[:security][:want_assertions_signed] = ENV['SAML_SECURITY_WANT_ASSERTION_SIGNED'] == 'true'
saml_options[:security][:want_assertions_encrypted] = ENV['SAML_SECURITY_WANT_ASSERTION_ENCRYPTED'] == 'true' saml_options[:security][:want_assertions_encrypted] = ENV['SAML_SECURITY_WANT_ASSERTION_ENCRYPTED'] == 'true'
saml_options[:security][:assume_email_is_verified] = ENV['SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED'] == 'true'
saml_options[:attribute_statements] = {} saml_options[:attribute_statements] = {}
saml_options[:attribute_statements][:uid] = [ENV['SAML_ATTRIBUTES_STATEMENTS_UID']] if ENV['SAML_ATTRIBUTES_STATEMENTS_UID'] saml_options[:attribute_statements][:uid] = [ENV['SAML_ATTRIBUTES_STATEMENTS_UID']] if ENV['SAML_ATTRIBUTES_STATEMENTS_UID']
saml_options[:attribute_statements][:email] = [ENV['SAML_ATTRIBUTES_STATEMENTS_EMAIL']] if ENV['SAML_ATTRIBUTES_STATEMENTS_EMAIL'] saml_options[:attribute_statements][:email] = [ENV['SAML_ATTRIBUTES_STATEMENTS_EMAIL']] if ENV['SAML_ATTRIBUTES_STATEMENTS_EMAIL']
saml_options[:attribute_statements][:full_name] = [ENV['SAML_ATTRIBUTES_STATEMENTS_FULL_NAME']] if ENV['SAML_ATTRIBUTES_STATEMENTS_FULL_NAME'] saml_options[:attribute_statements][:full_name] = [ENV['SAML_ATTRIBUTES_STATEMENTS_FULL_NAME']] if ENV['SAML_ATTRIBUTES_STATEMENTS_FULL_NAME']
saml_options[:attribute_statements][:verified] = [ENV['SAML_ATTRIBUTES_STATEMENTS_VERIFIED']] if ENV['SAML_ATTRIBUTES_STATEMENTS_VERIFIED']
saml_options[:attribute_statements][:verified_email] = [ENV['SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL']] if ENV['SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL']
saml_options[:uid_attribute] = ENV['SAML_UID_ATTRIBUTE'] if ENV['SAML_UID_ATTRIBUTE'] saml_options[:uid_attribute] = ENV['SAML_UID_ATTRIBUTE'] if ENV['SAML_UID_ATTRIBUTE']
config.omniauth :saml, saml_options config.omniauth :saml, saml_options
end end

View File

@ -14,7 +14,6 @@ ar:
humane_approach_title: أسلوب يعيد الإعتبار للإنسان humane_approach_title: أسلوب يعيد الإعتبار للإنسان
not_a_product_title: إنك إنسان و لست سلعة not_a_product_title: إنك إنسان و لست سلعة
real_conversation_title: مبني لتحقيق تواصل حقيقي real_conversation_title: مبني لتحقيق تواصل حقيقي
find_another_instance: إبحث عن مثيل خادوم آخر
generic_description: "%{domain} هو سيرفر من بين سيرفرات الشبكة" generic_description: "%{domain} هو سيرفر من بين سيرفرات الشبكة"
hosted_on: ماستدون مُستضاف على %{domain} hosted_on: ماستدون مُستضاف على %{domain}
learn_more: تعلم المزيد learn_more: تعلم المزيد

View File

@ -23,7 +23,6 @@ ca:
real_conversation_title: Construït per a converses reals real_conversation_title: Construït per a converses reals
within_reach_body: Diverses aplicacions per a iOS, Android i altres plataformes gràcies a un ecosistema API amable amb el desenvolupador, et permet mantenir-te al dia amb els amics en qualsevol lloc.. within_reach_body: Diverses aplicacions per a iOS, Android i altres plataformes gràcies a un ecosistema API amable amb el desenvolupador, et permet mantenir-te al dia amb els amics en qualsevol lloc..
within_reach_title: Sempre a l'abast within_reach_title: Sempre a l'abast
find_another_instance: Troba altres instàncies
generic_description: "%{domain} és un servidor a la xarxa" generic_description: "%{domain} és un servidor a la xarxa"
hosted_on: Mastodon allotjat a %{domain} hosted_on: Mastodon allotjat a %{domain}
learn_more: Més informació learn_more: Més informació

View File

@ -23,7 +23,6 @@ de:
real_conversation_title: Für das echte Gespräch gemacht real_conversation_title: Für das echte Gespräch gemacht
within_reach_body: Verschiedene Apps für iOS, Android und andere Plattformen erlauben dir dank unserem blühenden API-Ökosystem, dich von überall auf dem Laufenden zu halten. within_reach_body: Verschiedene Apps für iOS, Android und andere Plattformen erlauben dir dank unserem blühenden API-Ökosystem, dich von überall auf dem Laufenden zu halten.
within_reach_title: Immer für dich da within_reach_title: Immer für dich da
find_another_instance: Eine andere Instanz finden
generic_description: "%{domain} ist ein Server im Netzwerk" generic_description: "%{domain} ist ein Server im Netzwerk"
hosted_on: Mastodon, beherbergt auf %{domain} hosted_on: Mastodon, beherbergt auf %{domain}
learn_more: Mehr erfahren learn_more: Mehr erfahren

View File

@ -23,7 +23,6 @@ en:
real_conversation_title: Built for real conversation real_conversation_title: Built for real conversation
within_reach_body: Multiple apps for iOS, Android, and other platforms thanks to a developer-friendly API ecosystem allow you to keep up with your friends anywhere. within_reach_body: Multiple apps for iOS, Android, and other platforms thanks to a developer-friendly API ecosystem allow you to keep up with your friends anywhere.
within_reach_title: Always within reach within_reach_title: Always within reach
find_another_instance: Find another instance
generic_description: "%{domain} is one server in the network" generic_description: "%{domain} is one server in the network"
hosted_on: Mastodon hosted on %{domain} hosted_on: Mastodon hosted on %{domain}
learn_more: Learn more learn_more: Learn more
@ -274,6 +273,9 @@ en:
contact_information: contact_information:
email: Business e-mail email: Business e-mail
username: Contact username username: Contact username
hero:
desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to instance thumbnail
title: Hero image
peers_api_enabled: peers_api_enabled:
desc_html: Domain names this instance has encountered in the fediverse desc_html: Domain names this instance has encountered in the fediverse
title: Publish list of discovered instances title: Publish list of discovered instances
@ -421,6 +423,13 @@ en:
title: This page is not correct title: This page is not correct
noscript_html: To use the Mastodon web application, please enable JavaScript. Alternatively, try one of the <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">native apps</a> for Mastodon for your platform. noscript_html: To use the Mastodon web application, please enable JavaScript. Alternatively, try one of the <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">native apps</a> for Mastodon for your platform.
exports: exports:
archive_takeout:
date: Date
download: Download your archive
hint_html: You can request an archive of your <strong>toots and uploaded media</strong>. The exported data will be in ActivityPub format, readable by any compliant software.
in_progress: Compiling your archive...
request: Request your archive
size: Size
blocks: You block blocks: You block
csv: CSV csv: CSV
follows: You follow follows: You follow
@ -742,6 +751,10 @@ en:
setup: Set up setup: Set up
wrong_code: The entered code was invalid! Are server time and device time correct? wrong_code: The entered code was invalid! Are server time and device time correct?
user_mailer: user_mailer:
backup_ready:
explanation: You requested a full backup of your Mastodon account. It's now ready for download!
subject: Your archive is ready for download
title: Archive takeout
welcome: welcome:
edit_profile_action: Setup profile edit_profile_action: Setup profile
edit_profile_step: You can customize your profile by uploading an avatar, header, changing your display name and more. If youd like to review new followers before theyre allowed to follow you, you can lock your account. edit_profile_step: You can customize your profile by uploading an avatar, header, changing your display name and more. If youd like to review new followers before theyre allowed to follow you, you can lock your account.

View File

@ -23,7 +23,6 @@ es:
real_conversation_title: Hecho para verdaderas conversaciones real_conversation_title: Hecho para verdaderas conversaciones
within_reach_body: Aplicaciones múltiples para iOS, Android, y otras plataformas gracias a un ecosistema de APIs amigable al desarrollador para permitirte estar con tus amigos donde sea. within_reach_body: Aplicaciones múltiples para iOS, Android, y otras plataformas gracias a un ecosistema de APIs amigable al desarrollador para permitirte estar con tus amigos donde sea.
within_reach_title: Siempre al alcance within_reach_title: Siempre al alcance
find_another_instance: Busca otra instancia
generic_description: "%{domain} es un servidor en la red" generic_description: "%{domain} es un servidor en la red"
hosted_on: Mastodon hosteado en %{domain} hosted_on: Mastodon hosteado en %{domain}
learn_more: Aprende más learn_more: Aprende más

View File

@ -23,7 +23,6 @@ fa:
real_conversation_title: برای گفتگوهای واقعی real_conversation_title: برای گفتگوهای واقعی
within_reach_body: اپ‌های متنوع برای iOS، اندروید، و سیستم‌های دیگر به خاطر وجود یک اکوسیستم API دوستانه برای برنامه‌نویسان. از همه جا با دوستان خود ارتباط داشته باشید. within_reach_body: اپ‌های متنوع برای iOS، اندروید، و سیستم‌های دیگر به خاطر وجود یک اکوسیستم API دوستانه برای برنامه‌نویسان. از همه جا با دوستان خود ارتباط داشته باشید.
within_reach_title: همیشه در دسترس within_reach_title: همیشه در دسترس
find_another_instance: یافتن سرورهای دیگر
generic_description: "%{domain} یک سرور روی شبکه است" generic_description: "%{domain} یک سرور روی شبکه است"
hosted_on: ماستدون، میزبانی‌شده روی %{domain} hosted_on: ماستدون، میزبانی‌شده روی %{domain}
learn_more: بیشتر بدانید learn_more: بیشتر بدانید

View File

@ -21,7 +21,6 @@ fi:
real_conversation_title: Rakennettu oikealle keskustelulle real_conversation_title: Rakennettu oikealle keskustelulle
within_reach_body: Kehittäjäystävällisen rajapintaekosysteemin ansiosta useita appeja Androidille, iOS:lle ja muille alustoille, jotka mahdollistavat yhteydenpidon ystäviesi kanssa missä vain. within_reach_body: Kehittäjäystävällisen rajapintaekosysteemin ansiosta useita appeja Androidille, iOS:lle ja muille alustoille, jotka mahdollistavat yhteydenpidon ystäviesi kanssa missä vain.
within_reach_title: Aina lähellä within_reach_title: Aina lähellä
find_another_instance: Löydä toinen instanssi
learn_more: Lisätietoja learn_more: Lisätietoja
other_instances: Muut palvelimet other_instances: Muut palvelimet
source_code: Lähdekoodi source_code: Lähdekoodi

View File

@ -23,7 +23,6 @@ fr:
real_conversation_title: Construit pour de vraies conversations real_conversation_title: Construit pour de vraies conversations
within_reach_body: Grâce à lexistence dun environnement API accueillant pour les développeur·se·s, de multiples applications pour iOS, Android et dautres plateformes vous permettent de rester en contact avec vos ami·e·s où que vous soyez. within_reach_body: Grâce à lexistence dun environnement API accueillant pour les développeur·se·s, de multiples applications pour iOS, Android et dautres plateformes vous permettent de rester en contact avec vos ami·e·s où que vous soyez.
within_reach_title: Toujours à portée de main within_reach_title: Toujours à portée de main
find_another_instance: Trouver une autre instance
generic_description: "%{domain} est seulement un serveur du réseau" generic_description: "%{domain} est seulement un serveur du réseau"
hosted_on: Instance Mastodon hébergée par %{domain} hosted_on: Instance Mastodon hébergée par %{domain}
learn_more: En savoir plus learn_more: En savoir plus

View File

@ -23,7 +23,6 @@ gl:
real_conversation_title: Construído para conversacións reais real_conversation_title: Construído para conversacións reais
within_reach_body: Existen múltiples aplicativos para iOS, Android e outras plataformas grazas a un entorno API amigable para o desenvolvedor que lle permite estar ao tanto cos seus amigos en calquer lugar. within_reach_body: Existen múltiples aplicativos para iOS, Android e outras plataformas grazas a un entorno API amigable para o desenvolvedor que lle permite estar ao tanto cos seus amigos en calquer lugar.
within_reach_title: Sempre en contacto within_reach_title: Sempre en contacto
find_another_instance: Atope outra instancia
generic_description: "%{domain} é un servidor na rede" generic_description: "%{domain} é un servidor na rede"
hosted_on: Mastodon aloxado en %{domain} hosted_on: Mastodon aloxado en %{domain}
learn_more: Coñeza máis learn_more: Coñeza máis

View File

@ -23,7 +23,6 @@ he:
real_conversation_title: בנוי לשיחות אמתיות real_conversation_title: בנוי לשיחות אמתיות
within_reach_body: שלל אפליקציות עבור iOS, אנדרואיד ופלטפורמות אחרות שיאפשרו לך לשמור על קשר עם חברים בכל מקום, תודות למערכת מנשקי תוכנה ידידותיים למפתחים. within_reach_body: שלל אפליקציות עבור iOS, אנדרואיד ופלטפורמות אחרות שיאפשרו לך לשמור על קשר עם חברים בכל מקום, תודות למערכת מנשקי תוכנה ידידותיים למפתחים.
within_reach_title: תמיד במרחק נגיעה within_reach_title: תמיד במרחק נגיעה
find_another_instance: לאיתור שרת אחר
generic_description: "%{domain} הוא שרת אחד בתוך הרשת" generic_description: "%{domain} הוא שרת אחד בתוך הרשת"
hosted_on: מסטודון שיושב בכתובת %{domain} hosted_on: מסטודון שיושב בכתובת %{domain}
learn_more: מידע נוסף learn_more: מידע נוסף

View File

@ -23,7 +23,6 @@ hu:
real_conversation_title: Valódi beszélgetésekre tervezve real_conversation_title: Valódi beszélgetésekre tervezve
within_reach_body: A fejlesztőbarát API-nak köszönhetően számos iOS, Android és egyéb platformra írt alkalmazás teszi lehetővé, hogy bármikor, bárhonnan részt vehess a társalgásban. within_reach_body: A fejlesztőbarát API-nak köszönhetően számos iOS, Android és egyéb platformra írt alkalmazás teszi lehetővé, hogy bármikor, bárhonnan részt vehess a társalgásban.
within_reach_title: Mindig elérhetőnek lenni within_reach_title: Mindig elérhetőnek lenni
find_another_instance: További instanciák keresése
generic_description: "%{domain} csak egy a számtalan szerver közül a föderációban" generic_description: "%{domain} csak egy a számtalan szerver közül a föderációban"
hosted_on: "%{domain} Mastodon instancia" hosted_on: "%{domain} Mastodon instancia"
learn_more: Tudj meg többet learn_more: Tudj meg többet

View File

@ -23,7 +23,6 @@ ja:
real_conversation_title: 本当のコミュニケーションのために real_conversation_title: 本当のコミュニケーションのために
within_reach_body: デベロッパーフレンドリーな API により実現された、iOS や Android、その他様々なプラットフォームのためのアプリでどこでも友人とやりとりできます。 within_reach_body: デベロッパーフレンドリーな API により実現された、iOS や Android、その他様々なプラットフォームのためのアプリでどこでも友人とやりとりできます。
within_reach_title: いつでも身近に within_reach_title: いつでも身近に
find_another_instance: 他のインスタンスを探す
generic_description: "%{domain} は、Mastodon インスタンスの一つです" generic_description: "%{domain} は、Mastodon インスタンスの一つです"
hosted_on: Mastodon hosted on %{domain} hosted_on: Mastodon hosted on %{domain}
learn_more: もっと詳しく learn_more: もっと詳しく

View File

@ -23,7 +23,6 @@ ko:
real_conversation_title: 진정한 커뮤니케이션을 위하여 real_conversation_title: 진정한 커뮤니케이션을 위하여
within_reach_body: 개발자 친화적인 API에 의해서 실현된 iOS나 Android, 그 외의 여러 Platform들 덕분에 어디서든 친구들과 자유롭게 메세지를 주고 받을 수 있습니다. within_reach_body: 개발자 친화적인 API에 의해서 실현된 iOS나 Android, 그 외의 여러 Platform들 덕분에 어디서든 친구들과 자유롭게 메세지를 주고 받을 수 있습니다.
within_reach_title: 언제나 유저의 곁에서 within_reach_title: 언제나 유저의 곁에서
find_another_instance: 다른 인스턴스 찾기
generic_description: "%{domain} 은 Mastodon의 인스턴스 입니다." generic_description: "%{domain} 은 Mastodon의 인스턴스 입니다."
hosted_on: "%{domain}에서 호스팅 되는 마스토돈" hosted_on: "%{domain}에서 호스팅 되는 마스토돈"
learn_more: 자세히 learn_more: 자세히

View File

@ -23,7 +23,6 @@ nl:
real_conversation_title: Voor echte gesprekken gemaakt real_conversation_title: Voor echte gesprekken gemaakt
within_reach_body: Meerdere apps voor iOS, Android en andere platformen, met dank aan het ontwikkelaarsvriendelijke API-systeem, zorgen ervoor dat je overal op de hoogte blijft. within_reach_body: Meerdere apps voor iOS, Android en andere platformen, met dank aan het ontwikkelaarsvriendelijke API-systeem, zorgen ervoor dat je overal op de hoogte blijft.
within_reach_title: Altijd binnen bereik within_reach_title: Altijd binnen bereik
find_another_instance: Vind een andere server
generic_description: "%{domain} is een server in het Mastodonnetwerk" generic_description: "%{domain} is een server in het Mastodonnetwerk"
hosted_on: Mastodon op %{domain} hosted_on: Mastodon op %{domain}
learn_more: Meer leren learn_more: Meer leren

View File

@ -23,7 +23,6 @@
real_conversation_title: Laget for ekte samtaler real_conversation_title: Laget for ekte samtaler
within_reach_body: Takket være et utviklingsvennlig API-økosystem vil flere apper for iOS, Android og andre plattformer la deg holde kontakten med dine venner hvor som helst. within_reach_body: Takket være et utviklingsvennlig API-økosystem vil flere apper for iOS, Android og andre plattformer la deg holde kontakten med dine venner hvor som helst.
within_reach_title: Alltid innen rekkevidde within_reach_title: Alltid innen rekkevidde
find_another_instance: Finn en annen instans
generic_description: "%{domain} er en tjener i nettverket" generic_description: "%{domain} er en tjener i nettverket"
hosted_on: Mastodon driftet på %{domain} hosted_on: Mastodon driftet på %{domain}
learn_more: Lær mer learn_more: Lær mer

View File

@ -23,7 +23,6 @@ oc:
real_conversation_title: Fach per de conversacions vertadièras real_conversation_title: Fach per de conversacions vertadièras
within_reach_body: Multiplas aplicacion per iOS, Android, e autras plataformas mercés a un entorn API de bon utilizar, vos permet de gardar lo contacte pertot. within_reach_body: Multiplas aplicacion per iOS, Android, e autras plataformas mercés a un entorn API de bon utilizar, vos permet de gardar lo contacte pertot.
within_reach_title: Totjorn al costat within_reach_title: Totjorn al costat
find_another_instance: Trobar mai instàncias
generic_description: "%{domain} es un dels servidors del malhum" generic_description: "%{domain} es un dels servidors del malhum"
hosted_on: Mastodon albergat sus %{domain} hosted_on: Mastodon albergat sus %{domain}
learn_more: Ne saber mai learn_more: Ne saber mai

View File

@ -23,7 +23,6 @@ pl:
real_conversation_title: Zaprojektowany do prawdziwych rozmów real_conversation_title: Zaprojektowany do prawdziwych rozmów
within_reach_body: Wiele aplikacji dla Androida, iOS i innych platform dzięki przyjaznemu programistom API sprawia, że możesz utrzymywać kontakt ze znajomymi praktycznie wszędzie. within_reach_body: Wiele aplikacji dla Androida, iOS i innych platform dzięki przyjaznemu programistom API sprawia, że możesz utrzymywać kontakt ze znajomymi praktycznie wszędzie.
within_reach_title: Zawsze w Twoim zasięgu within_reach_title: Zawsze w Twoim zasięgu
find_another_instance: Znajdź inną instancję
generic_description: "%{domain} jest jednym z serwerów sieci" generic_description: "%{domain} jest jednym z serwerów sieci"
hosted_on: Mastodon uruchomiony na %{domain} hosted_on: Mastodon uruchomiony na %{domain}
learn_more: Dowiedz się więcej learn_more: Dowiedz się więcej
@ -275,6 +274,9 @@ pl:
contact_information: contact_information:
email: Służbowy adres e-mail email: Służbowy adres e-mail
username: Nazwa użytkownika do kontaktu username: Nazwa użytkownika do kontaktu
hero:
desc_html: Wyświetlany na stronie głównej. Zalecany jest rozmiar przynajmniej 600x100 pikseli. Jeżeli nie ustawiony, zostanie użyta miniatura instancji.
title: Obraz bohatera
peers_api_enabled: peers_api_enabled:
desc_html: Nazwy domen, z którymi ta instancja wchodziła w interakcje desc_html: Nazwy domen, z którymi ta instancja wchodziła w interakcje
title: Publikuj listę znanych instancji title: Publikuj listę znanych instancji
@ -422,6 +424,13 @@ pl:
title: Ta strona jest nieprawidłowa title: Ta strona jest nieprawidłowa
noscript_html: Aby korzystać z aplikacji Mastodon, włącz JavaScript. Możesz też skorzystać z jednej z <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">natywnych aplikacji</a> obsługującej Twoje urządzenie. noscript_html: Aby korzystać z aplikacji Mastodon, włącz JavaScript. Możesz też skorzystać z jednej z <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md">natywnych aplikacji</a> obsługującej Twoje urządzenie.
exports: exports:
archive_takeout:
date: Data
download: Pobierz swoje archiwum
hint_html: Możesz uzyskać archiwum swoich <strong>wpisów i wysłanej zawartości multimedialnej</strong>. Wyeksportowane dane będą dostępne w formacie ActivityPub, obsługiwanym przez odpowiednie programy.
in_progress: Tworzenie archiwum…
request: Uzyskaj archiwum
size: Rozmiar
blocks: Zablokowani blocks: Zablokowani
csv: CSV csv: CSV
follows: Śledzeni follows: Śledzeni
@ -749,6 +758,10 @@ pl:
setup: Skonfiguruj setup: Skonfiguruj
wrong_code: Wprowadzony kod jest niepoprawny! Czy czas serwera i urządzenia jest poprawny? wrong_code: Wprowadzony kod jest niepoprawny! Czy czas serwera i urządzenia jest poprawny?
user_mailer: user_mailer:
backup_ready:
explanation: Zażądałeś pełnej kopii zapasowej konta na Mastodonie. Jest ono dostępne do pobrania
subject: Twoje archiwum jest gotowe do pobrania
title: Odbiór archiwum
welcome: welcome:
edit_profile_action: Skonfiguruj profil edit_profile_action: Skonfiguruj profil
edit_profile_step: Możesz dostować profil wysyłając awatar, obraz nagłówka, zmieniając wyświetlaną nazwę i wiele więcej. Jeżeli chcesz, możesz zablokować konto, aby kontrolować, kto może Cię śledzić. edit_profile_step: Możesz dostować profil wysyłając awatar, obraz nagłówka, zmieniając wyświetlaną nazwę i wiele więcej. Jeżeli chcesz, możesz zablokować konto, aby kontrolować, kto może Cię śledzić.

View File

@ -23,7 +23,6 @@ pt-BR:
real_conversation_title: Feito para conversas reais real_conversation_title: Feito para conversas reais
within_reach_body: Vários apps para iOS, Android e outras plataformas graças a um ecossistema de API amigável para desenvolvedores permitem que você possa se manter atualizado sobre seus amigos de qualquer lugar. within_reach_body: Vários apps para iOS, Android e outras plataformas graças a um ecossistema de API amigável para desenvolvedores permitem que você possa se manter atualizado sobre seus amigos de qualquer lugar.
within_reach_title: Sempre ao seu alcance within_reach_title: Sempre ao seu alcance
find_another_instance: Encontre outra instância
generic_description: "%{domain} é um servidor na rede" generic_description: "%{domain} é um servidor na rede"
hosted_on: Mastodon hospedado em %{domain} hosted_on: Mastodon hospedado em %{domain}
learn_more: Saiba mais learn_more: Saiba mais

View File

@ -23,7 +23,6 @@ pt:
real_conversation_title: Feito para conversas reais real_conversation_title: Feito para conversas reais
within_reach_body: Várias aplicações para iOS, Android e outras plataformas graças a um ecossistema de API amigável para desenvolvedores, permitem-te que te mantenhas em contacto com os teus amigos em qualquer lugar. within_reach_body: Várias aplicações para iOS, Android e outras plataformas graças a um ecossistema de API amigável para desenvolvedores, permitem-te que te mantenhas em contacto com os teus amigos em qualquer lugar.
within_reach_title: Sempre ao teu alcance within_reach_title: Sempre ao teu alcance
find_another_instance: Encontra outra instância
generic_description: "%{domain} é um servidor na rede" generic_description: "%{domain} é um servidor na rede"
hosted_on: Mastodon em %{domain} hosted_on: Mastodon em %{domain}
learn_more: Saber mais learn_more: Saber mais

View File

@ -23,7 +23,6 @@ ru:
real_conversation_title: Создан для настоящего общения real_conversation_title: Создан для настоящего общения
within_reach_body: Различные приложения для iOS, Android и других платформ, написанные благодаря дружественной к разработчикам экосистеме API, позволят Вам держать связь с Вашими друзьями где угодно. within_reach_body: Различные приложения для iOS, Android и других платформ, написанные благодаря дружественной к разработчикам экосистеме API, позволят Вам держать связь с Вашими друзьями где угодно.
within_reach_title: Всегда под рукой within_reach_title: Всегда под рукой
find_another_instance: Найти другой узел
generic_description: "%{domain} - один из серверов сети" generic_description: "%{domain} - один из серверов сети"
hosted_on: Mastodon размещен на %{domain} hosted_on: Mastodon размещен на %{domain}
learn_more: Узнать больше learn_more: Узнать больше

View File

@ -23,7 +23,6 @@ sk:
real_conversation_title: Vytvorený pre reálnu konverzáciu real_conversation_title: Vytvorený pre reálnu konverzáciu
within_reach_body: Viacero aplikácií pre iOS, Android a iné platformy, ktoré vďaka jednoduchému API ekosystému vám dovoľujú byť online so svojimi priateľmi kdekoľvek. within_reach_body: Viacero aplikácií pre iOS, Android a iné platformy, ktoré vďaka jednoduchému API ekosystému vám dovoľujú byť online so svojimi priateľmi kdekoľvek.
within_reach_title: Stále v dosahu within_reach_title: Stále v dosahu
find_another_instance: Nájdi inú inštanciu
generic_description: "%{domain} je jeden server v sieti" generic_description: "%{domain} je jeden server v sieti"
hosted_on: Mastodon hostovaný na %{domain} hosted_on: Mastodon hostovaný na %{domain}
learn_more: Dozvedieť sa viac learn_more: Dozvedieť sa viac

View File

@ -23,7 +23,6 @@ sr-Latn:
real_conversation_title: Pravljen za pravi razgovor real_conversation_title: Pravljen za pravi razgovor
within_reach_body: Više aplikacija za iOS, Android, kao i druge platforme zahvaljujući ekosistemu dobrih API-ja će Vam omogućiti da ostanete u kontaktu sa prijateljima svuda. within_reach_body: Više aplikacija za iOS, Android, kao i druge platforme zahvaljujući ekosistemu dobrih API-ja će Vam omogućiti da ostanete u kontaktu sa prijateljima svuda.
within_reach_title: Uvek u kontaktu within_reach_title: Uvek u kontaktu
find_another_instance: Nađite drugu instancu
generic_description: "%{domain} je server na mreži" generic_description: "%{domain} je server na mreži"
hosted_on: Mastodont hostovan na %{domain} hosted_on: Mastodont hostovan na %{domain}
learn_more: Saznajte više learn_more: Saznajte više

View File

@ -23,7 +23,6 @@ sr:
real_conversation_title: Прављен за прави разговор real_conversation_title: Прављен за прави разговор
within_reach_body: Више апликација за iOS, Андроид, као и друге платформе захваљујући екосистему добрих API-ја ће Вам омогућити да останете у контакту са пријатељима свуда. within_reach_body: Више апликација за iOS, Андроид, као и друге платформе захваљујући екосистему добрих API-ја ће Вам омогућити да останете у контакту са пријатељима свуда.
within_reach_title: Увек у контакту within_reach_title: Увек у контакту
find_another_instance: Нађите другу инстанцу
generic_description: "%{domain} је сервер на мрежи" generic_description: "%{domain} је сервер на мрежи"
hosted_on: Мастодонт хостован на %{domain} hosted_on: Мастодонт хостован на %{domain}
learn_more: Сазнајте више learn_more: Сазнајте више

View File

@ -23,7 +23,6 @@ sv:
real_conversation_title: Byggd för riktiga konversationer real_conversation_title: Byggd för riktiga konversationer
within_reach_body: Flera appar för iOS, Android och andra plattformar tack vare ett utvecklingsvänligt API-ekosystem gör att du kan hålla kontakten med dina vänner var som helst. within_reach_body: Flera appar för iOS, Android och andra plattformar tack vare ett utvecklingsvänligt API-ekosystem gör att du kan hålla kontakten med dina vänner var som helst.
within_reach_title: Alltid inom räckhåll within_reach_title: Alltid inom räckhåll
find_another_instance: Hitta en annan instans
generic_description: "%{domain} är en server i nätverket" generic_description: "%{domain} är en server i nätverket"
hosted_on: Mastodon värd på %{domain} hosted_on: Mastodon värd på %{domain}
learn_more: Lär dig mer learn_more: Lär dig mer

View File

@ -23,7 +23,6 @@ zh-CN:
real_conversation_title: 为真正的交流而生 real_conversation_title: 为真正的交流而生
within_reach_body: 通过一个面向开发者友好的 API 生态系统Mastodon 让你可以随时随地通过众多 iOS、Android 以及其他平台的应用与朋友们保持联系。 within_reach_body: 通过一个面向开发者友好的 API 生态系统Mastodon 让你可以随时随地通过众多 iOS、Android 以及其他平台的应用与朋友们保持联系。
within_reach_title: 始终触手可及 within_reach_title: 始终触手可及
find_another_instance: 寻找另一个实例
generic_description: "%{domain} 是这个庞大网络中的一台服务器" generic_description: "%{domain} 是这个庞大网络中的一台服务器"
hosted_on: 一个在 %{domain} 上运行的 Mastodon 实例 hosted_on: 一个在 %{domain} 上运行的 Mastodon 实例
learn_more: 详细了解 learn_more: 详细了解

View File

@ -83,7 +83,7 @@ Rails.application.routes.draw do
resource :notifications, only: [:show, :update] resource :notifications, only: [:show, :update]
resource :import, only: [:show, :create] resource :import, only: [:show, :create]
resource :export, only: [:show] resource :export, only: [:show, :create]
namespace :exports, constraints: { format: :csv } do namespace :exports, constraints: { format: :csv } do
resources :follows, only: :index, controller: :following_accounts resources :follows, only: :index, controller: :following_accounts
resources :blocks, only: :index, controller: :blocked_accounts resources :blocks, only: :index, controller: :blocked_accounts

View File

@ -30,3 +30,6 @@
email_scheduler: email_scheduler:
cron: '0 10 * * 2' cron: '0 10 * * 2'
class: Scheduler::EmailScheduler class: Scheduler::EmailScheduler
backup_cleanup_scheduler:
cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
class: Scheduler::BackupCleanupScheduler

View File

@ -0,0 +1,11 @@
class CreateBackups < ActiveRecord::Migration[5.1]
def change
create_table :backups do |t|
t.references :user, foreign_key: { on_delete: :nullify }
t.attachment :dump
t.boolean :processed, null: false, default: false
t.timestamps
end
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180206000000) do ActiveRecord::Schema.define(version: 20180211015820) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -92,6 +92,18 @@ ActiveRecord::Schema.define(version: 20180206000000) do
t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id" t.index ["target_type", "target_id"], name: "index_admin_action_logs_on_target_type_and_target_id"
end end
create_table "backups", force: :cascade do |t|
t.bigint "user_id"
t.string "dump_file_name"
t.string "dump_content_type"
t.integer "dump_file_size"
t.datetime "dump_updated_at"
t.boolean "processed", default: false, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_backups_on_user_id"
end
create_table "blocks", force: :cascade do |t| create_table "blocks", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false

View File

@ -1,14 +0,0 @@
#!/bin/sh
### 1. Adds local user (UID and GID are provided from environment variables).
### 2. Updates permissions, except for ./public/system (should be chown on previous installations).
### 3. Executes the command as that user.
echo "Creating mastodon user (UID : ${UID} and GID : ${GID})..."
addgroup -g ${GID} mastodon && adduser -h /mastodon -s /bin/sh -D -G mastodon -u ${UID} mastodon
echo "Updating permissions..."
find /mastodon -path /mastodon/public/system -prune -o -not -user mastodon -not -group mastodon -print0 | xargs -0 chown -f mastodon:mastodon
echo "Executing process..."
LD_PRELOAD=/lib/stack-fix.so exec su-exec mastodon:mastodon /sbin/tini -- "$@"

View File

@ -16,7 +16,7 @@ module Paperclip
final_file = Paperclip::Transcoder.make(file, options, attachment) final_file = Paperclip::Transcoder.make(file, options, attachment)
attachment.instance.file_file_name = 'media.mp4' attachment.instance.file_file_name = File.basename(attachment.instance.file_file_name, '.*') + '.mp4'
attachment.instance.file_content_type = 'video/mp4' attachment.instance.file_content_type = 'video/mp4'
attachment.instance.type = MediaAttachment.types[:gifv] attachment.instance.type = MediaAttachment.types[:gifv]

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
module Paperclip
class LazyThumbnail < Paperclip::Thumbnail
def make
return File.open(@file.path) unless needs_convert?
Paperclip::Thumbnail.make(file, options, attachment)
end
private
def needs_convert?
needs_different_geometry? || needs_different_format?
end
def needs_different_geometry?
!@target_geometry.nil? && @current_geometry.width != @target_geometry.width && @current_geometry.height != @target_geometry.height
end
def needs_different_format?
@format.present? && @current_format != @format
end
end
end

View File

@ -752,6 +752,7 @@ namespace :mastodon do
if [404, 410].include?(res.code) if [404, 410].include?(res.code)
if options[:force] if options[:force]
SuspendAccountService.new.call(account)
account.destroy account.destroy
else else
progress_bar.pause progress_bar.pause
@ -764,6 +765,7 @@ namespace :mastodon do
if confirm.casecmp('n').zero? if confirm.casecmp('n').zero?
next next
else else
SuspendAccountService.new.call(account)
account.destroy account.destroy
end end
end end

View File

@ -66,6 +66,28 @@ describe Api::V1::Accounts::RelationshipsController do
expect(json.second[:requested]).to be false expect(json.second[:requested]).to be false
expect(json.second[:domain_blocking]).to be false expect(json.second[:domain_blocking]).to be false
end end
it 'returns JSON with correct data on cached requests too' do
get :index, params: { id: [simon.id] }
json = body_as_json
expect(json).to be_a Enumerable
expect(json.first[:following]).to be true
expect(json.first[:showing_reblogs]).to be true
end
it 'returns JSON with correct data after change too' do
user.account.unfollow!(simon)
get :index, params: { id: [simon.id] }
json = body_as_json
expect(json).to be_a Enumerable
expect(json.first[:following]).to be false
expect(json.first[:showing_reblogs]).to be false
end
end end
end end
end end

View File

@ -0,0 +1,3 @@
Fabricator(:backup) do
user
end

View File

@ -34,4 +34,9 @@ class UserMailerPreview < ActionMailer::Preview
def welcome def welcome
UserMailer.welcome(User.first) UserMailer.welcome(User.first)
end end
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/backup_ready
def backup_ready
UserMailer.backup_ready(User.first, Backup.first)
end
end end

View File

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe Backup, type: :model do
end

View File

@ -18,6 +18,9 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
source_url: 'https://github.com/tootsuite/mastodon', source_url: 'https://github.com/tootsuite/mastodon',
open_registrations: false, open_registrations: false,
thumbnail: nil, thumbnail: nil,
hero: nil,
user_count: 0,
status_count: 0,
closed_registrations_message: 'yes', closed_registrations_message: 'yes',
commit_hash: commit_hash) commit_hash: commit_hash)