Merge branch 'master' of https://github.com/tootsuite/mastodon
# 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.rbpull/369/head
commit
bb6988a7ac
|
@ -144,14 +144,22 @@ STREAMING_CLUSTER_NUM=1
|
|||
# MAX_TOOT_CHARS=500
|
||||
|
||||
# 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
|
||||
# Suffix for email address generation (nil by default)
|
||||
# Fallback Suffix for email address generation (nil by default)
|
||||
# PAM_DEFAULT_SUFFIX=pam
|
||||
# Name of the pam service (pam "auth" section is evaluated)
|
||||
# PAM_DEFAULT_SERVICE=rpam
|
||||
# Name of the pam service used for checking if an user can register (pam "account" section is evaluated)
|
||||
# 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) :
|
||||
# CAS_ENABLED=true
|
||||
# CAS_URL=https://sso.myserver.com/
|
||||
|
@ -187,7 +195,10 @@ STREAMING_CLUSTER_NUM=1
|
|||
# SAML_PRIVATE_KEY=
|
||||
# SAML_SECURITY_WANT_ASSERTION_SIGNED=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_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_UID_ATTRIBUTE="urn:oid:0.9.2342.19200300.100.1.1"
|
||||
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED=
|
||||
# SAML_ATTRIBUTES_STATEMENTS_VERIFIED_EMAIL=
|
||||
|
|
16
Dockerfile
16
Dockerfile
|
@ -3,8 +3,10 @@ FROM ruby:2.5.0-alpine3.7
|
|||
LABEL maintainer="https://github.com/tootsuite/mastodon" \
|
||||
description="A GNU Social-compatible microblogging server"
|
||||
|
||||
ENV UID=991 GID=991 \
|
||||
RAILS_SERVE_STATIC_FILES=true \
|
||||
ARG UID=991
|
||||
ARG GID=991
|
||||
|
||||
ENV RAILS_SERVE_STATIC_FILES=true \
|
||||
RAILS_ENV=production NODE_ENV=production
|
||||
|
||||
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 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
|
||||
|
||||
RUN chmod +x /usr/local/bin/run
|
||||
COPY --chown=mastodon:mastodon . /mastodon
|
||||
|
||||
VOLUME /mastodon/public/system /mastodon/public/assets /mastodon/public/packs
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/run"]
|
||||
USER mastodon
|
||||
|
||||
ENTRYPOINT ["/sbin/tini", "--"]
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -41,6 +41,7 @@ gem 'omniauth', '~> 1.2'
|
|||
|
||||
gem 'doorkeeper', '~> 4.2'
|
||||
gem 'fast_blank', '~> 1.0'
|
||||
gem 'fastimage'
|
||||
gem 'goldfinger', '~> 2.1'
|
||||
gem 'hiredis', '~> 0.6'
|
||||
gem 'redis-namespace', '~> 1.5'
|
||||
|
@ -117,6 +118,7 @@ group :development do
|
|||
gem 'bullet', '~> 5.5'
|
||||
gem 'letter_opener', '~> 1.4'
|
||||
gem 'letter_opener_web', '~> 1.3'
|
||||
gem 'memory_profiler'
|
||||
gem 'rubocop', require: false
|
||||
gem 'brakeman', '~> 4.0', require: false
|
||||
gem 'bundler-audit', '~> 0.6', require: false
|
||||
|
|
|
@ -185,6 +185,7 @@ GEM
|
|||
faraday (0.14.0)
|
||||
multipart-post (>= 1.2, < 3)
|
||||
fast_blank (1.0.0)
|
||||
fastimage (2.1.1)
|
||||
ffi (1.9.18)
|
||||
fog-core (1.45.0)
|
||||
builder
|
||||
|
@ -302,6 +303,7 @@ GEM
|
|||
mini_mime (>= 0.1.1)
|
||||
mario-redis-lock (1.2.0)
|
||||
redis (~> 3, >= 3.0.5)
|
||||
memory_profiler (0.9.10)
|
||||
method_source (0.9.0)
|
||||
microformats (4.0.7)
|
||||
json
|
||||
|
@ -644,6 +646,7 @@ DEPENDENCIES
|
|||
fabrication (~> 2.18)
|
||||
faker (~> 1.7)
|
||||
fast_blank (~> 1.0)
|
||||
fastimage
|
||||
fog-core (~> 1.45)
|
||||
fog-local (~> 0.4)
|
||||
fog-openstack (~> 0.1)
|
||||
|
@ -666,6 +669,7 @@ DEPENDENCIES
|
|||
link_header (~> 0.0)
|
||||
lograge (~> 0.7)
|
||||
mario-redis-lock (~> 1.2)
|
||||
memory_profiler
|
||||
microformats (~> 4.0)
|
||||
mime-types (~> 3.1)
|
||||
nokogiri (~> 1.8)
|
||||
|
|
|
@ -16,6 +16,7 @@ module Admin
|
|||
show_staff_badge
|
||||
bootstrap_timeline_accounts
|
||||
thumbnail
|
||||
hero
|
||||
min_invite_role
|
||||
activity_api_enabled
|
||||
peers_api_enabled
|
||||
|
@ -34,6 +35,7 @@ module Admin
|
|||
|
||||
UPLOAD_SETTINGS = %w(
|
||||
thumbnail
|
||||
hero
|
||||
).freeze
|
||||
|
||||
def edit
|
||||
|
|
|
@ -21,6 +21,6 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController
|
|||
end
|
||||
|
||||
def account_ids
|
||||
@_account_ids ||= Array(params[:id]).map(&:to_i)
|
||||
Array(params[:id]).map(&:to_i)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -27,7 +27,7 @@ class Api::V1::MediaController < Api::BaseController
|
|||
private
|
||||
|
||||
def media_params
|
||||
params.permit(:file, :description)
|
||||
params.permit(:file, :description, :focus)
|
||||
end
|
||||
|
||||
def file_type_error
|
||||
|
|
|
@ -36,7 +36,7 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
def store_current_location
|
||||
store_location_for(:user, request.url)
|
||||
store_location_for(:user, request.url) unless request.format == :json
|
||||
end
|
||||
|
||||
def require_admin!
|
||||
|
|
|
@ -11,6 +11,15 @@ class Auth::SessionsController < Devise::SessionsController
|
|||
prepend_before_action :set_pack
|
||||
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
|
||||
super do |resource|
|
||||
remember_me(resource)
|
||||
|
|
|
@ -2,6 +2,16 @@
|
|||
|
||||
class Settings::ExportsController < Settings::BaseController
|
||||
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
|
||||
|
|
|
@ -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 |
|
@ -178,11 +178,11 @@ export function uploadCompose(files) {
|
|||
};
|
||||
};
|
||||
|
||||
export function changeUploadCompose(id, description) {
|
||||
export function changeUploadCompose(id, params) {
|
||||
return (dispatch, getState) => {
|
||||
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));
|
||||
}).catch(error => {
|
||||
dispatch(changeUploadComposeFail(id, error));
|
||||
|
|
|
@ -12,6 +12,26 @@ const messages = defineMessages({
|
|||
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 {
|
||||
|
||||
static contextTypes = {
|
||||
|
@ -24,6 +44,8 @@ class Item extends React.PureComponent {
|
|||
index: PropTypes.number.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
onClick: PropTypes.func.isRequired,
|
||||
containerWidth: PropTypes.number,
|
||||
containerHeight: PropTypes.number,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -62,7 +84,7 @@ class Item extends React.PureComponent {
|
|||
}
|
||||
|
||||
render () {
|
||||
const { attachment, index, size, standalone } = this.props;
|
||||
const { attachment, index, size, standalone, containerWidth, containerHeight } = this.props;
|
||||
|
||||
let width = 50;
|
||||
let height = 100;
|
||||
|
@ -116,16 +138,40 @@ class Item extends React.PureComponent {
|
|||
let thumbnail = '';
|
||||
|
||||
if (attachment.get('type') === 'image') {
|
||||
const previewUrl = attachment.get('preview_url');
|
||||
const previewUrl = attachment.get('preview_url');
|
||||
const previewWidth = attachment.getIn(['meta', 'small', 'width']);
|
||||
|
||||
const originalUrl = attachment.get('url');
|
||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||
const originalUrl = attachment.get('url');
|
||||
const originalWidth = attachment.getIn(['meta', 'original', 'width']);
|
||||
const originalHeight = attachment.getIn(['meta', 'original', 'height']);
|
||||
|
||||
const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
|
||||
|
||||
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 = (
|
||||
<a
|
||||
|
@ -134,7 +180,14 @@ class Item extends React.PureComponent {
|
|||
onClick={this.handleClick}
|
||||
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>
|
||||
);
|
||||
} else if (attachment.get('type') === 'gifv') {
|
||||
|
@ -205,7 +258,7 @@ export default class MediaGallery extends React.PureComponent {
|
|||
}
|
||||
|
||||
handleRef = (node) => {
|
||||
if (node && this.isStandaloneEligible()) {
|
||||
if (node /*&& this.isStandaloneEligible()*/) {
|
||||
// offsetWidth triggers a layout, so only calculate when we need to
|
||||
this.setState({
|
||||
width: node.offsetWidth,
|
||||
|
@ -256,12 +309,12 @@ export default class MediaGallery extends React.PureComponent {
|
|||
if (this.isStandaloneEligible()) {
|
||||
children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} />;
|
||||
} 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 (
|
||||
<div className='media-gallery' style={style}>
|
||||
<div className='media-gallery' style={style} ref={this.handleRef}>
|
||||
<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} />
|
||||
</div>
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
import React from 'react';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import PropTypes from 'prop-types';
|
||||
import IconButton from '../../../components/icon_button';
|
||||
import Motion from '../../ui/util/optional_motion';
|
||||
import spring from 'react-motion/lib/spring';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const messages = defineMessages({
|
||||
undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
|
||||
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,
|
||||
onUndo: PropTypes.func.isRequired,
|
||||
onDescriptionChange: PropTypes.func.isRequired,
|
||||
onOpenFocalPoint: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -33,6 +32,10 @@ export default class Upload extends ImmutablePureComponent {
|
|||
this.props.onUndo(this.props.media.get('id'));
|
||||
}
|
||||
|
||||
handleFocalPointClick = () => {
|
||||
this.props.onOpenFocalPoint(this.props.media.get('id'));
|
||||
}
|
||||
|
||||
handleInputChange = e => {
|
||||
this.setState({ dirtyDescription: e.target.value });
|
||||
}
|
||||
|
@ -63,13 +66,20 @@ export default class Upload extends ImmutablePureComponent {
|
|||
const { intl, media } = this.props;
|
||||
const active = this.state.hovered || this.state.focused;
|
||||
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 (
|
||||
<div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
|
||||
{({ scale }) => (
|
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
|
||||
<IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
|
||||
<div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
|
||||
<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 })}>
|
||||
<label>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { connect } from 'react-redux';
|
||||
import Upload from '../components/upload';
|
||||
import { undoUploadCompose, changeUploadCompose } from '../../../actions/compose';
|
||||
import { openModal } from '../../../actions/modal';
|
||||
|
||||
const mapStateToProps = (state, { id }) => ({
|
||||
media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
|
||||
|
@ -13,7 +14,11 @@ const mapDispatchToProps = dispatch => ({
|
|||
},
|
||||
|
||||
onDescriptionChange: (id, description) => {
|
||||
dispatch(changeUploadCompose(id, description));
|
||||
dispatch(changeUploadCompose(id, { description }));
|
||||
},
|
||||
|
||||
onOpenFocalPoint: id => {
|
||||
dispatch(openModal('FOCAL_POINT', { id }));
|
||||
},
|
||||
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -8,6 +8,7 @@ import MediaModal from './media_modal';
|
|||
import VideoModal from './video_modal';
|
||||
import BoostModal from './boost_modal';
|
||||
import ConfirmationModal from './confirmation_modal';
|
||||
import FocalPointModal from './focal_point_modal';
|
||||
import {
|
||||
OnboardingModal,
|
||||
MuteModal,
|
||||
|
@ -27,6 +28,7 @@ const MODAL_COMPONENTS = {
|
|||
'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
|
||||
'EMBED': EmbedModal,
|
||||
'LIST_EDITOR': ListEditor,
|
||||
'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
|
||||
};
|
||||
|
||||
export default class ModalRoot extends React.PureComponent {
|
||||
|
|
|
@ -30,7 +30,7 @@ const formatTime = secondsNum => {
|
|||
return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
const findElementPosition = el => {
|
||||
export const findElementPosition = el => {
|
||||
let box;
|
||||
|
||||
if (el.getBoundingClientRect && el.parentNode) {
|
||||
|
@ -61,7 +61,7 @@ const findElementPosition = el => {
|
|||
};
|
||||
};
|
||||
|
||||
const getPointerPosition = (el, event) => {
|
||||
export const getPointerPosition = (el, event) => {
|
||||
const position = {};
|
||||
const box = findElementPosition(el);
|
||||
const boxW = el.offsetWidth;
|
||||
|
@ -77,7 +77,7 @@ const getPointerPosition = (el, event) => {
|
|||
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));
|
||||
|
||||
return position;
|
||||
|
|
|
@ -34,7 +34,7 @@ import uuid from '../uuid';
|
|||
import { me } from '../initial_state';
|
||||
|
||||
const initialState = ImmutableMap({
|
||||
mounted: false,
|
||||
mounted: 0,
|
||||
sensitive: false,
|
||||
spoiler: false,
|
||||
spoiler_text: '',
|
||||
|
@ -159,10 +159,10 @@ export default function compose(state = initialState, action) {
|
|||
case STORE_HYDRATE:
|
||||
return hydrate(state, action.state.get('compose'));
|
||||
case COMPOSE_MOUNT:
|
||||
return state.set('mounted', true);
|
||||
return state.set('mounted', state.get('mounted') + 1);
|
||||
case COMPOSE_UNMOUNT:
|
||||
return state
|
||||
.set('mounted', false)
|
||||
.set('mounted', Math.max(state.get('mounted') - 1, 0))
|
||||
.set('is_composing', false);
|
||||
case COMPOSE_SENSITIVITY_CHANGE:
|
||||
return state.withMutations(map => {
|
||||
|
@ -265,7 +265,7 @@ export default function compose(state = initialState, action) {
|
|||
.set('is_submitting', false)
|
||||
.update('media_attachments', list => list.map(item => {
|
||||
if (item.get('id') === action.media.id) {
|
||||
return item.set('description', action.media.description);
|
||||
return fromJS(action.media);
|
||||
}
|
||||
|
||||
return item;
|
||||
|
|
|
@ -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 {
|
||||
p,
|
||||
li {
|
||||
|
@ -116,10 +243,14 @@
|
|||
}
|
||||
|
||||
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%;
|
||||
box-sizing: border-box;
|
||||
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%;
|
||||
.brand {
|
||||
a {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.mascot {
|
||||
position: absolute;
|
||||
bottom: -14px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
left: 60px;
|
||||
z-index: 3;
|
||||
img {
|
||||
height: 32px;
|
||||
position: relative;
|
||||
top: 4px;
|
||||
left: -10px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -177,7 +304,7 @@
|
|||
line-height: 30px;
|
||||
overflow: hidden;
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
display: flex;
|
||||
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 {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
@ -243,53 +355,6 @@
|
|||
align-items: center;
|
||||
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 {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
|
@ -346,18 +411,18 @@
|
|||
background: darken($ui-base-color, 4%);
|
||||
padding: 20px 0;
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
position: relative;
|
||||
padding-right: 280px + 15px;
|
||||
}
|
||||
|
||||
.information-board-sections {
|
||||
&__sections {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section {
|
||||
&__section {
|
||||
flex: 1 0 0;
|
||||
font-family: 'mastodon-font-sans-serif', sans-serif;
|
||||
font-size: 16px;
|
||||
|
@ -382,6 +447,10 @@
|
|||
font-size: 32px;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.panel {
|
||||
|
@ -460,111 +529,282 @@
|
|||
}
|
||||
}
|
||||
|
||||
.features {
|
||||
padding: 50px 0;
|
||||
&.alternative {
|
||||
padding: 10px 0;
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
.brand {
|
||||
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;
|
||||
}
|
||||
|
||||
#mastodon-timeline {
|
||||
display: flex;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||
font-family: 'mastodon-font-sans-serif', sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 400;
|
||||
.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;
|
||||
width: 330px;
|
||||
margin-right: 30px;
|
||||
flex: 0 0 auto;
|
||||
background: $ui-base-color;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 6px rgba($black, 0.1);
|
||||
margin-bottom: 0;
|
||||
|
||||
.column-header {
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
small {
|
||||
color: $ui-primary-color;
|
||||
|
||||
.column {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
color: $primary-text-color;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
span {
|
||||
color: $ui-secondary-color;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.about-mastodon {
|
||||
max-width: 675px;
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 20px;
|
||||
&__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 {
|
||||
display: flex;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||
font-family: 'mastodon-font-sans-serif', sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
font-weight: 400;
|
||||
color: $primary-text-color;
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
|
||||
.column-header {
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.column {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scrollable {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
color: $primary-text-color;
|
||||
margin-bottom: 20px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.features-list {
|
||||
margin-top: 20px;
|
||||
a {
|
||||
color: $ui-secondary-color;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.features-list__row {
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
justify-content: space-between;
|
||||
@media screen and (max-width: $column-breakpoint) {
|
||||
height: 90vh;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
&__features {
|
||||
.features-list {
|
||||
margin: 40px 0 !important;
|
||||
}
|
||||
|
||||
.visual {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 15px;
|
||||
&__action {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.fa {
|
||||
display: block;
|
||||
color: $ui-primary-color;
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
.features-list {
|
||||
margin-top: 20px;
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
color: $ui-primary-color;
|
||||
.features-list__row {
|
||||
display: flex;
|
||||
padding: 10px 0;
|
||||
justify-content: space-between;
|
||||
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
&:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.visual {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 15px;
|
||||
|
||||
.fa {
|
||||
display: block;
|
||||
color: $ui-primary-color;
|
||||
font-size: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 16px;
|
||||
line-height: 30px;
|
||||
color: $ui-primary-color;
|
||||
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
margin-bottom: 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) {
|
||||
.container {
|
||||
.container-alt {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.information-board {
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: static;
|
||||
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) {
|
||||
|
@ -651,13 +891,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.header .container,
|
||||
.features .container {
|
||||
.header .container-alt,
|
||||
.features .container-alt {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
.links {
|
||||
padding-top: 15px;
|
||||
background: darken($ui-base-color, 4%);
|
||||
|
@ -682,10 +921,6 @@
|
|||
margin-top: 30px;
|
||||
padding: 0;
|
||||
|
||||
.floats {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heading {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
|
@ -700,16 +935,6 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.features #mastodon-timeline {
|
||||
height: 70vh;
|
||||
width: 100%;
|
||||
margin-bottom: 50px;
|
||||
|
||||
.column {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cta {
|
||||
|
@ -720,7 +945,7 @@
|
|||
.features {
|
||||
padding: 30px 0;
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
max-width: 820px;
|
||||
|
||||
#mastodon-timeline {
|
||||
|
@ -772,7 +997,7 @@
|
|||
.features {
|
||||
padding: 10px 0;
|
||||
|
||||
.container {
|
||||
.container-alt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
@ -808,17 +1033,3 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floating {
|
||||
from {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
|
||||
65% {
|
||||
transform: translate(0, 4px);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(0, -0);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -40,14 +40,20 @@
|
|||
cursor: default;
|
||||
}
|
||||
|
||||
&.button-alternative {
|
||||
&.button-primary,
|
||||
&.button-alternative,
|
||||
&.button-secondary,
|
||||
&.button-alternative-2 {
|
||||
font-size: 16px;
|
||||
line-height: 36px;
|
||||
height: auto;
|
||||
color: $ui-base-color;
|
||||
background: $ui-primary-color;
|
||||
text-transform: none;
|
||||
padding: 4px 16px;
|
||||
}
|
||||
|
||||
&.button-alternative {
|
||||
color: $ui-base-color;
|
||||
background: $ui-primary-color;
|
||||
|
||||
&:active,
|
||||
&: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 {
|
||||
font-size: 16px;
|
||||
line-height: 36px;
|
||||
height: auto;
|
||||
color: $ui-primary-color;
|
||||
text-transform: none;
|
||||
background: transparent;
|
||||
padding: 3px 15px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid $ui-primary-color;
|
||||
|
||||
&:active,
|
||||
|
@ -433,6 +444,34 @@
|
|||
min-width: 40%;
|
||||
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 {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
|
@ -470,10 +509,6 @@
|
|||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
mix-blend-mode: difference;
|
||||
}
|
||||
}
|
||||
|
||||
.compose-form__upload-thumbnail {
|
||||
|
@ -481,8 +516,9 @@
|
|||
background-position: center;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
height: 100px;
|
||||
height: 140px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4133,8 +4169,12 @@ a.status-card {
|
|||
&,
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
position: relative;
|
||||
object-fit: cover;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -4842,3 +4882,31 @@ noscript {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
.container {
|
||||
.container-alt {
|
||||
width: 700px;
|
||||
margin: 0 auto;
|
||||
margin-top: 40px;
|
||||
|
|
|
@ -116,7 +116,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
|
|||
next if unsupported_media_type?(attachment['mediaType']) || attachment['url'].blank?
|
||||
|
||||
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
|
||||
|
||||
next if skip_download?
|
||||
|
|
|
@ -17,6 +17,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
|
|||
'conversation' => 'ostatus:conversation',
|
||||
'toot' => 'http://joinmastodon.org/ns#',
|
||||
'Emoji' => 'toot:Emoji',
|
||||
'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' },
|
||||
},
|
||||
],
|
||||
}.freeze
|
||||
|
|
|
@ -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
|
|
@ -66,4 +66,16 @@ class UserMailer < Devise::Mailer
|
|||
mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject')
|
||||
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
|
||||
|
|
|
@ -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
|
|
@ -7,15 +7,9 @@ module AccountAvatar
|
|||
|
||||
class_methods do
|
||||
def avatar_styles(file)
|
||||
styles = {}
|
||||
geometry = Paperclip::Geometry.from_file(file)
|
||||
|
||||
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 = { original: { geometry: '120x120#', file_geometry_parser: FastGeometryParser } }
|
||||
styles[:static] = { geometry: '120x120#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
|
||||
styles
|
||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||
{}
|
||||
end
|
||||
|
||||
private :avatar_styles
|
||||
|
@ -23,7 +17,7 @@ module AccountAvatar
|
|||
|
||||
included do
|
||||
# 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_size :avatar, less_than: 2.megabytes
|
||||
end
|
||||
|
|
|
@ -7,15 +7,9 @@ module AccountHeader
|
|||
|
||||
class_methods do
|
||||
def header_styles(file)
|
||||
styles = {}
|
||||
geometry = Paperclip::Geometry.from_file(file)
|
||||
|
||||
styles[:original] = '700x335#' unless geometry.width == 700 && geometry.height == 335
|
||||
styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
|
||||
|
||||
styles = { original: { geometry: '700x335#', file_geometry_parser: FastGeometryParser } }
|
||||
styles[:static] = { geometry: '700x335#', format: 'png', convert_options: '-coalesce', file_geometry_parser: FastGeometryParser } if file.content_type == 'image/gif'
|
||||
styles
|
||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||
{}
|
||||
end
|
||||
|
||||
private :header_styles
|
||||
|
@ -23,7 +17,7 @@ module AccountHeader
|
|||
|
||||
included do
|
||||
# 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_size :header, less_than: 2.megabytes
|
||||
end
|
||||
|
|
|
@ -53,8 +53,11 @@ module Omniauthable
|
|||
private
|
||||
|
||||
def user_params_from_auth(auth)
|
||||
email_is_verified = auth.info.email && (auth.info.verified || auth.info.verified_email)
|
||||
email = auth.info.email if email_is_verified && !User.exists?(email: auth.info.email)
|
||||
strategy = Devise.omniauth_configs[auth.provider.to_sym].strategy
|
||||
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",
|
||||
|
|
|
@ -34,7 +34,18 @@ class MediaAttachment < ApplicationRecord
|
|||
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
|
||||
|
||||
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 = {
|
||||
original: {
|
||||
format: 'mp4',
|
||||
|
@ -50,6 +61,7 @@ class MediaAttachment < ApplicationRecord
|
|||
},
|
||||
},
|
||||
}.freeze
|
||||
|
||||
VIDEO_STYLES = {
|
||||
small: {
|
||||
convert_options: {
|
||||
|
@ -97,6 +109,24 @@ class MediaAttachment < ApplicationRecord
|
|||
shortcode
|
||||
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 :set_shortcode
|
||||
before_post_process :set_type_and_extension
|
||||
|
@ -178,7 +208,7 @@ class MediaAttachment < ApplicationRecord
|
|||
end
|
||||
|
||||
def populate_meta
|
||||
meta = {}
|
||||
meta = file.instance_read(:meta) || {}
|
||||
|
||||
file.queued_for_write.each do |style, file|
|
||||
meta[style] = style == :small || image? ? image_geometry(file) : video_metadata(file)
|
||||
|
@ -188,16 +218,16 @@ class MediaAttachment < ApplicationRecord
|
|||
end
|
||||
|
||||
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,
|
||||
height: geo.height.to_i,
|
||||
size: "#{geo.width.to_i}x#{geo.height.to_i}",
|
||||
aspect: geo.width.to_f / geo.height.to_f,
|
||||
width: width,
|
||||
height: height,
|
||||
size: "#{width}x#{height}",
|
||||
aspect: width.to_f / height.to_f,
|
||||
}
|
||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||
{}
|
||||
end
|
||||
|
||||
def video_metadata(file)
|
||||
|
|
|
@ -33,7 +33,7 @@ class PreviewCard < ApplicationRecord
|
|||
|
||||
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 Remotable
|
||||
|
@ -58,10 +58,11 @@ class PreviewCard < ApplicationRecord
|
|||
|
||||
return if file.nil?
|
||||
|
||||
geo = Paperclip::Geometry.from_file(file)
|
||||
self.width = geo.width.to_i
|
||||
self.height = geo.height.to_i
|
||||
rescue Paperclip::Errors::NotIdentifiedByImageMagickError
|
||||
nil
|
||||
width, height = FastImage.size(file.path)
|
||||
|
||||
return nil if width.nil?
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
end
|
||||
end
|
||||
|
|
|
@ -34,8 +34,8 @@ class SiteUpload < ApplicationRecord
|
|||
|
||||
return if tempfile.nil?
|
||||
|
||||
geometry = Paperclip::Geometry.from_file(tempfile)
|
||||
self.meta = { width: geometry.width.to_i, height: geometry.height.to_i }
|
||||
width, height = FastImage.size(tempfile.path)
|
||||
self.meta = { width: width, height: height }
|
||||
end
|
||||
|
||||
def clear_cache
|
||||
|
|
|
@ -79,7 +79,7 @@ class Status < ApplicationRecord
|
|||
|
||||
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
|
||||
|
||||
|
|
|
@ -60,6 +60,7 @@ class User < ApplicationRecord
|
|||
accepts_nested_attributes_for :account
|
||||
|
||||
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_with BlacklistedEmailValidator, if: :email_changed?
|
||||
|
|
|
@ -15,4 +15,8 @@ class ApplicationPolicy
|
|||
def current_user
|
||||
current_account&.user
|
||||
end
|
||||
|
||||
def user_signed_in?
|
||||
!current_user.nil?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -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
|
|
@ -45,7 +45,7 @@ class AccountRelationshipsPresenter
|
|||
maps_for_account = Rails.cache.read("relationship:#{@current_account_id}:#{account_id}")
|
||||
|
||||
if maps_for_account.is_a?(Hash)
|
||||
@cached.merge!(maps_for_account)
|
||||
@cached.deep_merge!(maps_for_account)
|
||||
else
|
||||
@uncached_account_ids << account_id
|
||||
end
|
||||
|
|
|
@ -48,4 +48,8 @@ class InstancePresenter
|
|||
def thumbnail
|
||||
@thumbnail ||= Rails.cache.fetch('site_uploads/thumbnail') { SiteUpload.find_by(var: 'thumbnail') }
|
||||
end
|
||||
|
||||
def hero
|
||||
@hero ||= Rails.cache.fetch('site_uploads/hero') { SiteUpload.find_by(var: 'hero') }
|
||||
end
|
||||
end
|
||||
|
|
|
@ -13,8 +13,8 @@ class ActivityPub::CollectionSerializer < ActiveModel::Serializer
|
|||
attribute :part_of, if: -> { object.part_of.present? }
|
||||
|
||||
has_one :first, if: -> { object.first.present? }
|
||||
has_many :items, key: :items, if: -> { (object.items.present? || page?) && !ordered? }
|
||||
has_many :items, key: :ordered_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.nil? || page?) && ordered? }
|
||||
|
||||
def type
|
||||
if page?
|
||||
|
|
|
@ -4,6 +4,7 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
|
|||
include RoutingHelper
|
||||
|
||||
attributes :type, :media_type, :url
|
||||
attribute :focal_point, if: :focal_point?
|
||||
|
||||
def type
|
||||
'Image'
|
||||
|
@ -16,4 +17,12 @@ class ActivityPub::ImageSerializer < ActiveModel::Serializer
|
|||
def media_type
|
||||
object.content_type
|
||||
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
|
||||
|
|
|
@ -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
|
|
@ -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'
|
|
@ -1,4 +1,4 @@
|
|||
.container.links
|
||||
.container-alt.links
|
||||
.brand
|
||||
= link_to root_url do
|
||||
= image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
|
||||
|
|
|
@ -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' }
|
||||
|
||||
.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)
|
||||
|
|
|
@ -9,34 +9,34 @@
|
|||
.header
|
||||
= render 'links'
|
||||
|
||||
.container.hero
|
||||
.container-alt.hero
|
||||
.heading
|
||||
%h3= t('about.description_headline', domain: site_hostname)
|
||||
%p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
|
||||
|
||||
.information-board
|
||||
.container
|
||||
.information-board-sections
|
||||
.section
|
||||
.container-alt
|
||||
.information-board__sections
|
||||
.information-board__section
|
||||
%span= t 'about.user_count_before'
|
||||
%strong= number_with_delimiter @instance_presenter.user_count
|
||||
%span= t 'about.user_count_after'
|
||||
.section
|
||||
.information-board__section
|
||||
%span= t 'about.status_count_before'
|
||||
%strong= number_with_delimiter @instance_presenter.status_count
|
||||
%span= t 'about.status_count_after'
|
||||
.section
|
||||
.information-board__section
|
||||
%span= t 'about.domain_count_before'
|
||||
%strong= number_with_delimiter @instance_presenter.domain_count
|
||||
%span= t 'about.domain_count_after'
|
||||
= render 'contact', contact: @instance_presenter
|
||||
|
||||
.extended-description
|
||||
.container
|
||||
.container-alt
|
||||
= @instance_presenter.site_extended_description.html_safe.presence || t('about.extended_description_html')
|
||||
|
||||
.footer-links
|
||||
.container
|
||||
.container-alt
|
||||
%p
|
||||
= link_to t('about.source_code'), @instance_presenter.source_url
|
||||
- if @instance_presenter.commit_hash == ""
|
||||
|
|
|
@ -5,62 +5,74 @@
|
|||
%script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
|
||||
= render partial: 'shared/og'
|
||||
|
||||
.landing-page
|
||||
.header-wrapper
|
||||
.mascot-container
|
||||
= image_tag asset_pack_path('elephant-fren.png'), alt: '', role: 'presentation', class: 'mascot'
|
||||
.landing-page.alternative
|
||||
.container
|
||||
.row
|
||||
.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
|
||||
= render 'links'
|
||||
.hide-xs
|
||||
= render 'forms'
|
||||
|
||||
.container.hero
|
||||
.floats
|
||||
%div{ role: 'presentation', class: 'float-1' }
|
||||
%div{ role: 'presentation', class: 'float-2' }
|
||||
%div{ role: 'presentation', class: 'float-3' }
|
||||
.heading
|
||||
%h1
|
||||
= @instance_presenter.site_title
|
||||
%small= t 'about.hosted_on', domain: 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
|
||||
.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
|
||||
|
||||
= 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' }
|
||||
.landing-page__information
|
||||
.landing-page__short-description
|
||||
.row
|
||||
.landing-page__logo.hide-xs
|
||||
= image_tag asset_pack_path('logo_transparent.svg'), alt: 'Mastodon'
|
||||
|
||||
.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'
|
||||
%h1
|
||||
= @instance_presenter.site_title
|
||||
%small!= t 'about.hosted_on', domain: content_tag(:span, site_hostname)
|
||||
|
||||
.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
|
||||
.container
|
||||
- if Setting.timeline_preview
|
||||
#mastodon-timeline{ data: { props: Oj.dump(default_props) } }
|
||||
.show-xs
|
||||
.landing-page__forms
|
||||
= render 'forms'
|
||||
.landing-page__call-to-action.hide-xs
|
||||
.row
|
||||
.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'
|
||||
%p= t 'about.about_mastodon_html'
|
||||
|
||||
.about-mastodon
|
||||
%h3= t 'about.what_is_mastodon'
|
||||
%p= t 'about.about_mastodon_html'
|
||||
= link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-secondary'
|
||||
= render 'features'
|
||||
.footer-links
|
||||
.container
|
||||
%p
|
||||
= link_to t('about.source_code'), @instance_presenter.source_url
|
||||
- if @instance_presenter.commit_hash == ""
|
||||
%strong= " (#{@instance_presenter.version_number})"
|
||||
- else
|
||||
%strong= " (#{@instance_presenter.version_number}, "
|
||||
%strong= " #{@instance_presenter.commit_hash})"
|
||||
= render 'features'
|
||||
|
||||
.landing-page__features__action
|
||||
= link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-alternative'
|
||||
|
||||
.landing-page__footer
|
||||
%p
|
||||
= link_to t('about.source_code'), @instance_presenter.source_url
|
||||
= " (#{@instance_presenter.version_number})"
|
||||
|
||||
.column-4.column-6-sm.column-flex
|
||||
.show-sm.hide-xs
|
||||
.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) } }
|
||||
|
|
|
@ -7,5 +7,5 @@
|
|||
= render 'links'
|
||||
|
||||
.extended-description
|
||||
.container
|
||||
.container-alt
|
||||
= @instance_presenter.site_terms.html_safe.presence || t('terms.body_html')
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
|
||||
.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 :hero, as: :file, wrapper: :with_block_label, label: t('admin.settings.hero.title'), hint: t('admin.settings.hero.desc_html')
|
||||
|
||||
%hr/
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
- content_for :content do
|
||||
.container
|
||||
.container-alt
|
||||
.logo-container
|
||||
%h1
|
||||
= link_to root_path do
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
= link_to destroy_user_session_path, method: :delete, class: 'logout-link icon-button' do
|
||||
= fa_icon 'sign-out'
|
||||
|
||||
.container= yield
|
||||
.container-alt= yield
|
||||
.modal-layout__mastodon
|
||||
%div
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
- content_for :content do
|
||||
.container= yield
|
||||
.container-alt= yield
|
||||
.footer
|
||||
- if !user_signed_in? && single_user_mode?
|
||||
%span.single-user-login
|
||||
|
|
|
@ -20,3 +20,26 @@
|
|||
%th= t('exports.mutes')
|
||||
%td= @export.total_mutes
|
||||
%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')
|
||||
|
|
|
@ -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'
|
|
@ -0,0 +1,7 @@
|
|||
<%= t 'user_mailer.backup_ready.title' %>
|
||||
|
||||
===
|
||||
|
||||
<%= t 'user_mailer.backup_ready.explanation' %>
|
||||
|
||||
=> <%= full_asset_url(@backup.dump.url) %>
|
|
@ -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
|
|
@ -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
|
|
@ -7,6 +7,7 @@ require 'rails/all'
|
|||
Bundler.require(*Rails.groups)
|
||||
|
||||
require_relative '../app/lib/exceptions'
|
||||
require_relative '../lib/paperclip/lazy_thumbnail'
|
||||
require_relative '../lib/paperclip/gif_transcoder'
|
||||
require_relative '../lib/paperclip/video_transcoder'
|
||||
require_relative '../lib/paperclip/audio_transcoder'
|
||||
|
|
|
@ -9,6 +9,7 @@ Chewy.settings = {
|
|||
prefix: prefix,
|
||||
enabled: enabled,
|
||||
journal: false,
|
||||
sidekiq: { queue: 'pull' },
|
||||
}
|
||||
|
||||
Chewy.root_strategy = enabled ? :sidekiq : :bypass
|
||||
|
|
|
@ -4,10 +4,12 @@ end
|
|||
|
||||
Devise.setup do |config|
|
||||
# Devise omniauth strategies
|
||||
options = {}
|
||||
options[:redirect_at_sign_in] = ENV['OAUTH_REDIRECT_AT_SIGN_IN'] == 'true'
|
||||
|
||||
# CAS strategy
|
||||
if ENV['CAS_ENABLED'] == 'true'
|
||||
cas_options = {}
|
||||
cas_options = options
|
||||
cas_options[:url] = ENV['CAS_URL'] if ENV['CAS_URL']
|
||||
cas_options[:host] = ENV['CAS_HOST'] if ENV['CAS_HOST']
|
||||
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[: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[: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[:name_key] = ENV['CAS_NAME_KEY'] || 'name'
|
||||
cas_options[:email_key] = ENV['CAS_EMAIL_KEY'] || 'email'
|
||||
|
@ -33,7 +35,7 @@ Devise.setup do |config|
|
|||
|
||||
# SAML strategy
|
||||
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[: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']
|
||||
|
@ -48,10 +50,13 @@ Devise.setup do |config|
|
|||
saml_options[:security] = {}
|
||||
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][:assume_email_is_verified] = ENV['SAML_SECURITY_ASSUME_EMAIL_IS_VERIFIED'] == 'true'
|
||||
saml_options[:attribute_statements] = {}
|
||||
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][: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']
|
||||
config.omniauth :saml, saml_options
|
||||
end
|
||||
|
|
|
@ -14,7 +14,6 @@ ar:
|
|||
humane_approach_title: أسلوب يعيد الإعتبار للإنسان
|
||||
not_a_product_title: إنك إنسان و لست سلعة
|
||||
real_conversation_title: مبني لتحقيق تواصل حقيقي
|
||||
find_another_instance: إبحث عن مثيل خادوم آخر
|
||||
generic_description: "%{domain} هو سيرفر من بين سيرفرات الشبكة"
|
||||
hosted_on: ماستدون مُستضاف على %{domain}
|
||||
learn_more: تعلم المزيد
|
||||
|
|
|
@ -23,7 +23,6 @@ ca:
|
|||
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_title: Sempre a l'abast
|
||||
find_another_instance: Troba altres instàncies
|
||||
generic_description: "%{domain} és un servidor a la xarxa"
|
||||
hosted_on: Mastodon allotjat a %{domain}
|
||||
learn_more: Més informació
|
||||
|
|
|
@ -23,7 +23,6 @@ de:
|
|||
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_title: Immer für dich da
|
||||
find_another_instance: Eine andere Instanz finden
|
||||
generic_description: "%{domain} ist ein Server im Netzwerk"
|
||||
hosted_on: Mastodon, beherbergt auf %{domain}
|
||||
learn_more: Mehr erfahren
|
||||
|
|
|
@ -23,7 +23,6 @@ en:
|
|||
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_title: Always within reach
|
||||
find_another_instance: Find another instance
|
||||
generic_description: "%{domain} is one server in the network"
|
||||
hosted_on: Mastodon hosted on %{domain}
|
||||
learn_more: Learn more
|
||||
|
@ -274,6 +273,9 @@ en:
|
|||
contact_information:
|
||||
email: Business e-mail
|
||||
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:
|
||||
desc_html: Domain names this instance has encountered in the fediverse
|
||||
title: Publish list of discovered instances
|
||||
|
@ -421,6 +423,13 @@ en:
|
|||
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.
|
||||
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
|
||||
csv: CSV
|
||||
follows: You follow
|
||||
|
@ -742,6 +751,10 @@ en:
|
|||
setup: Set up
|
||||
wrong_code: The entered code was invalid! Are server time and device time correct?
|
||||
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:
|
||||
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 you’d like to review new followers before they’re allowed to follow you, you can lock your account.
|
||||
|
|
|
@ -23,7 +23,6 @@ es:
|
|||
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_title: Siempre al alcance
|
||||
find_another_instance: Busca otra instancia
|
||||
generic_description: "%{domain} es un servidor en la red"
|
||||
hosted_on: Mastodon hosteado en %{domain}
|
||||
learn_more: Aprende más
|
||||
|
|
|
@ -23,7 +23,6 @@ fa:
|
|||
real_conversation_title: برای گفتگوهای واقعی
|
||||
within_reach_body: اپهای متنوع برای iOS، اندروید، و سیستمهای دیگر به خاطر وجود یک اکوسیستم API دوستانه برای برنامهنویسان. از همه جا با دوستان خود ارتباط داشته باشید.
|
||||
within_reach_title: همیشه در دسترس
|
||||
find_another_instance: یافتن سرورهای دیگر
|
||||
generic_description: "%{domain} یک سرور روی شبکه است"
|
||||
hosted_on: ماستدون، میزبانیشده روی %{domain}
|
||||
learn_more: بیشتر بدانید
|
||||
|
|
|
@ -21,7 +21,6 @@ fi:
|
|||
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_title: Aina lähellä
|
||||
find_another_instance: Löydä toinen instanssi
|
||||
learn_more: Lisätietoja
|
||||
other_instances: Muut palvelimet
|
||||
source_code: Lähdekoodi
|
||||
|
|
|
@ -23,7 +23,6 @@ fr:
|
|||
real_conversation_title: Construit pour de vraies conversations
|
||||
within_reach_body: Grâce à l’existence d’un environnement API accueillant pour les développeur·se·s, de multiples applications pour iOS, Android et d’autres plateformes vous permettent de rester en contact avec vos ami·e·s où que vous soyez.
|
||||
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"
|
||||
hosted_on: Instance Mastodon hébergée par %{domain}
|
||||
learn_more: En savoir plus
|
||||
|
|
|
@ -23,7 +23,6 @@ gl:
|
|||
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_title: Sempre en contacto
|
||||
find_another_instance: Atope outra instancia
|
||||
generic_description: "%{domain} é un servidor na rede"
|
||||
hosted_on: Mastodon aloxado en %{domain}
|
||||
learn_more: Coñeza máis
|
||||
|
|
|
@ -23,7 +23,6 @@ he:
|
|||
real_conversation_title: בנוי לשיחות אמתיות
|
||||
within_reach_body: שלל אפליקציות עבור iOS, אנדרואיד ופלטפורמות אחרות שיאפשרו לך לשמור על קשר עם חברים בכל מקום, תודות למערכת מנשקי תוכנה ידידותיים למפתחים.
|
||||
within_reach_title: תמיד במרחק נגיעה
|
||||
find_another_instance: לאיתור שרת אחר
|
||||
generic_description: "%{domain} הוא שרת אחד בתוך הרשת"
|
||||
hosted_on: מסטודון שיושב בכתובת %{domain}
|
||||
learn_more: מידע נוסף
|
||||
|
|
|
@ -23,7 +23,6 @@ hu:
|
|||
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_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"
|
||||
hosted_on: "%{domain} Mastodon instancia"
|
||||
learn_more: Tudj meg többet
|
||||
|
|
|
@ -23,7 +23,6 @@ ja:
|
|||
real_conversation_title: 本当のコミュニケーションのために
|
||||
within_reach_body: デベロッパーフレンドリーな API により実現された、iOS や Android、その他様々なプラットフォームのためのアプリでどこでも友人とやりとりできます。
|
||||
within_reach_title: いつでも身近に
|
||||
find_another_instance: 他のインスタンスを探す
|
||||
generic_description: "%{domain} は、Mastodon インスタンスの一つです"
|
||||
hosted_on: Mastodon hosted on %{domain}
|
||||
learn_more: もっと詳しく
|
||||
|
|
|
@ -23,7 +23,6 @@ ko:
|
|||
real_conversation_title: 진정한 커뮤니케이션을 위하여
|
||||
within_reach_body: 개발자 친화적인 API에 의해서 실현된 iOS나 Android, 그 외의 여러 Platform들 덕분에 어디서든 친구들과 자유롭게 메세지를 주고 받을 수 있습니다.
|
||||
within_reach_title: 언제나 유저의 곁에서
|
||||
find_another_instance: 다른 인스턴스 찾기
|
||||
generic_description: "%{domain} 은 Mastodon의 인스턴스 입니다."
|
||||
hosted_on: "%{domain}에서 호스팅 되는 마스토돈"
|
||||
learn_more: 자세히
|
||||
|
|
|
@ -23,7 +23,6 @@ nl:
|
|||
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_title: Altijd binnen bereik
|
||||
find_another_instance: Vind een andere server
|
||||
generic_description: "%{domain} is een server in het Mastodonnetwerk"
|
||||
hosted_on: Mastodon op %{domain}
|
||||
learn_more: Meer leren
|
||||
|
|
|
@ -23,7 +23,6 @@
|
|||
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_title: Alltid innen rekkevidde
|
||||
find_another_instance: Finn en annen instans
|
||||
generic_description: "%{domain} er en tjener i nettverket"
|
||||
hosted_on: Mastodon driftet på %{domain}
|
||||
learn_more: Lær mer
|
||||
|
|
|
@ -23,7 +23,6 @@ oc:
|
|||
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_title: Totjorn al costat
|
||||
find_another_instance: Trobar mai instàncias
|
||||
generic_description: "%{domain} es un dels servidors del malhum"
|
||||
hosted_on: Mastodon albergat sus %{domain}
|
||||
learn_more: Ne saber mai
|
||||
|
|
|
@ -23,7 +23,6 @@ pl:
|
|||
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_title: Zawsze w Twoim zasięgu
|
||||
find_another_instance: Znajdź inną instancję
|
||||
generic_description: "%{domain} jest jednym z serwerów sieci"
|
||||
hosted_on: Mastodon uruchomiony na %{domain}
|
||||
learn_more: Dowiedz się więcej
|
||||
|
@ -275,6 +274,9 @@ pl:
|
|||
contact_information:
|
||||
email: Służbowy adres e-mail
|
||||
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:
|
||||
desc_html: Nazwy domen, z którymi ta instancja wchodziła w interakcje
|
||||
title: Publikuj listę znanych instancji
|
||||
|
@ -422,6 +424,13 @@ pl:
|
|||
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.
|
||||
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
|
||||
csv: CSV
|
||||
follows: Śledzeni
|
||||
|
@ -749,6 +758,10 @@ pl:
|
|||
setup: Skonfiguruj
|
||||
wrong_code: Wprowadzony kod jest niepoprawny! Czy czas serwera i urządzenia jest poprawny?
|
||||
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:
|
||||
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ć.
|
||||
|
|
|
@ -23,7 +23,6 @@ pt-BR:
|
|||
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_title: Sempre ao seu alcance
|
||||
find_another_instance: Encontre outra instância
|
||||
generic_description: "%{domain} é um servidor na rede"
|
||||
hosted_on: Mastodon hospedado em %{domain}
|
||||
learn_more: Saiba mais
|
||||
|
|
|
@ -23,7 +23,6 @@ pt:
|
|||
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_title: Sempre ao teu alcance
|
||||
find_another_instance: Encontra outra instância
|
||||
generic_description: "%{domain} é um servidor na rede"
|
||||
hosted_on: Mastodon em %{domain}
|
||||
learn_more: Saber mais
|
||||
|
|
|
@ -23,7 +23,6 @@ ru:
|
|||
real_conversation_title: Создан для настоящего общения
|
||||
within_reach_body: Различные приложения для iOS, Android и других платформ, написанные благодаря дружественной к разработчикам экосистеме API, позволят Вам держать связь с Вашими друзьями где угодно.
|
||||
within_reach_title: Всегда под рукой
|
||||
find_another_instance: Найти другой узел
|
||||
generic_description: "%{domain} - один из серверов сети"
|
||||
hosted_on: Mastodon размещен на %{domain}
|
||||
learn_more: Узнать больше
|
||||
|
|
|
@ -23,7 +23,6 @@ sk:
|
|||
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_title: Stále v dosahu
|
||||
find_another_instance: Nájdi inú inštanciu
|
||||
generic_description: "%{domain} je jeden server v sieti"
|
||||
hosted_on: Mastodon hostovaný na %{domain}
|
||||
learn_more: Dozvedieť sa viac
|
||||
|
|
|
@ -23,7 +23,6 @@ sr-Latn:
|
|||
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_title: Uvek u kontaktu
|
||||
find_another_instance: Nađite drugu instancu
|
||||
generic_description: "%{domain} je server na mreži"
|
||||
hosted_on: Mastodont hostovan na %{domain}
|
||||
learn_more: Saznajte više
|
||||
|
|
|
@ -23,7 +23,6 @@ sr:
|
|||
real_conversation_title: Прављен за прави разговор
|
||||
within_reach_body: Више апликација за iOS, Андроид, као и друге платформе захваљујући екосистему добрих API-ја ће Вам омогућити да останете у контакту са пријатељима свуда.
|
||||
within_reach_title: Увек у контакту
|
||||
find_another_instance: Нађите другу инстанцу
|
||||
generic_description: "%{domain} је сервер на мрежи"
|
||||
hosted_on: Мастодонт хостован на %{domain}
|
||||
learn_more: Сазнајте више
|
||||
|
|
|
@ -23,7 +23,6 @@ sv:
|
|||
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_title: Alltid inom räckhåll
|
||||
find_another_instance: Hitta en annan instans
|
||||
generic_description: "%{domain} är en server i nätverket"
|
||||
hosted_on: Mastodon värd på %{domain}
|
||||
learn_more: Lär dig mer
|
||||
|
|
|
@ -23,7 +23,6 @@ zh-CN:
|
|||
real_conversation_title: 为真正的交流而生
|
||||
within_reach_body: 通过一个面向开发者友好的 API 生态系统,Mastodon 让你可以随时随地通过众多 iOS、Android 以及其他平台的应用与朋友们保持联系。
|
||||
within_reach_title: 始终触手可及
|
||||
find_another_instance: 寻找另一个实例
|
||||
generic_description: "%{domain} 是这个庞大网络中的一台服务器"
|
||||
hosted_on: 一个在 %{domain} 上运行的 Mastodon 实例
|
||||
learn_more: 详细了解
|
||||
|
|
|
@ -83,7 +83,7 @@ Rails.application.routes.draw do
|
|||
resource :notifications, only: [:show, :update]
|
||||
resource :import, only: [:show, :create]
|
||||
|
||||
resource :export, only: [:show]
|
||||
resource :export, only: [:show, :create]
|
||||
namespace :exports, constraints: { format: :csv } do
|
||||
resources :follows, only: :index, controller: :following_accounts
|
||||
resources :blocks, only: :index, controller: :blocked_accounts
|
||||
|
|
|
@ -30,3 +30,6 @@
|
|||
email_scheduler:
|
||||
cron: '0 10 * * 2'
|
||||
class: Scheduler::EmailScheduler
|
||||
backup_cleanup_scheduler:
|
||||
cron: '<%= Random.rand(0..59) %> <%= Random.rand(3..5) %> * * *'
|
||||
class: Scheduler::BackupCleanupScheduler
|
||||
|
|
|
@ -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
|
14
db/schema.rb
14
db/schema.rb
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 20180206000000) do
|
||||
ActiveRecord::Schema.define(version: 20180211015820) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
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"
|
||||
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|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
|
|
|
@ -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 -- "$@"
|
|
@ -16,7 +16,7 @@ module Paperclip
|
|||
|
||||
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.type = MediaAttachment.types[:gifv]
|
||||
|
||||
|
|
|
@ -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
|
|
@ -752,6 +752,7 @@ namespace :mastodon do
|
|||
|
||||
if [404, 410].include?(res.code)
|
||||
if options[:force]
|
||||
SuspendAccountService.new.call(account)
|
||||
account.destroy
|
||||
else
|
||||
progress_bar.pause
|
||||
|
@ -764,6 +765,7 @@ namespace :mastodon do
|
|||
if confirm.casecmp('n').zero?
|
||||
next
|
||||
else
|
||||
SuspendAccountService.new.call(account)
|
||||
account.destroy
|
||||
end
|
||||
end
|
||||
|
|
|
@ -66,6 +66,28 @@ describe Api::V1::Accounts::RelationshipsController do
|
|||
expect(json.second[:requested]).to be false
|
||||
expect(json.second[:domain_blocking]).to be false
|
||||
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
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
Fabricator(:backup) do
|
||||
user
|
||||
end
|
|
@ -34,4 +34,9 @@ class UserMailerPreview < ActionMailer::Preview
|
|||
def welcome
|
||||
UserMailer.welcome(User.first)
|
||||
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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Backup, type: :model do
|
||||
|
||||
end
|
|
@ -18,6 +18,9 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
|
|||
source_url: 'https://github.com/tootsuite/mastodon',
|
||||
open_registrations: false,
|
||||
thumbnail: nil,
|
||||
hero: nil,
|
||||
user_count: 0,
|
||||
status_count: 0,
|
||||
closed_registrations_message: 'yes',
|
||||
commit_hash: commit_hash)
|
||||
|
||||
|
|
Loading…
Reference in New Issue