Merge pull request #913 from ThibG/glitch-soc/merge-upstream

Merge upstream changes
pull/915/head
ThibG 2019-02-12 20:29:55 +01:00 committed by GitHub
commit a3ba28eb17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 853 additions and 278 deletions

View File

@ -1,5 +1,5 @@
FROM node:8.15-alpine as node
FROM ruby:2.6-alpine3.8
FROM ruby:2.6-alpine3.9
LABEL maintainer="https://github.com/tootsuite/mastodon" \
description="Your self-hosted, globally interconnected microblogging community"
@ -24,19 +24,18 @@ COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=node /usr/local/bin/npm /usr/local/bin/npm
COPY --from=node /opt/yarn-* /opt/yarn
RUN apk -U upgrade \
&& apk add -t build-dependencies \
RUN apk add --no-cache -t build-dependencies \
build-base \
icu-dev \
libidn-dev \
libressl \
openssl \
libtool \
libxml2-dev \
libxslt-dev \
postgresql-dev \
protobuf-dev \
python \
&& apk add \
&& apk add --no-cache \
ca-certificates \
ffmpeg \
file \
@ -64,7 +63,7 @@ RUN apk -U upgrade \
&& make install \
&& libtool --finish /usr/local/lib \
&& cd /mastodon \
&& rm -rf /tmp/* /var/cache/apk/*
&& rm -rf /tmp/*
COPY Gemfile Gemfile.lock package.json yarn.lock .yarnclean /mastodon/
COPY stack-fix.c /lib

View File

@ -108,15 +108,15 @@ group :production, :test do
end
group :test do
gem 'capybara', '~> 3.12'
gem 'capybara', '~> 3.13'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 1.9'
gem 'microformats', '~> 4.0'
gem 'microformats', '~> 4.1'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0'
gem 'simplecov', '~> 0.16', require: false
gem 'webmock', '~> 3.5'
gem 'parallel_tests', '~> 2.27'
gem 'parallel_tests', '~> 2.28'
end
group :development do

View File

@ -126,7 +126,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.12.0)
capybara (3.13.2)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@ -268,7 +268,7 @@ GEM
domain_name (~> 0.5)
http-form_data (2.1.1)
http_accept_language (2.1.1)
httplog (1.2.0)
httplog (1.2.1)
rack (>= 1.0)
rainbow (>= 2.0.0)
i18n (1.5.3)
@ -337,9 +337,9 @@ GEM
redis (>= 3.0.5)
memory_profiler (0.9.12)
method_source (0.9.2)
microformats (4.0.7)
json
nokogiri
microformats (4.1.0)
json (~> 2.1)
nokogiri (~> 1.8, >= 1.8.3)
mime-types (3.2.2)
mime-types-data (~> 3.2015)
mime-types-data (3.2018.0812)
@ -392,7 +392,7 @@ GEM
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.13.0)
parallel_tests (2.27.1)
parallel_tests (2.28.0)
parallel
parser (2.6.0.0)
ast (~> 2.4.0)
@ -671,7 +671,7 @@ DEPENDENCIES
capistrano-rails (~> 1.4)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 3.12)
capybara (~> 3.13)
charlock_holmes (~> 0.7.6)
chewy (~> 5.0)
cld3 (~> 3.2.3)
@ -712,7 +712,7 @@ DEPENDENCIES
makara (~> 0.4)
mario-redis-lock (~> 1.2)
memory_profiler
microformats (~> 4.0)
microformats (~> 4.1)
mime-types (~> 3.2)
net-ldap (~> 0.10)
nokogiri (~> 1.10)
@ -725,7 +725,7 @@ DEPENDENCIES
ox (~> 2.10)
paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6)
parallel_tests (~> 2.27)
parallel_tests (~> 2.28)
pg (~> 1.1)
pghero (~> 2.2)
pkg-config (~> 1.3)

View File

@ -53,11 +53,12 @@ class AccountsController < ApplicationController
private
def show_pinned_statuses?
[replies_requested?, media_requested?, params[:max_id].present?, params[:min_id].present?].none?
[replies_requested?, media_requested?, tag_requested?, params[:max_id].present?, params[:min_id].present?].none?
end
def filtered_statuses
default_statuses.tap do |statuses|
statuses.merge!(hashtag_scope) if tag_requested?
statuses.merge!(only_media_scope) if media_requested?
statuses.merge!(no_replies_scope) unless replies_requested?
end
@ -79,12 +80,15 @@ class AccountsController < ApplicationController
Status.without_replies
end
def hashtag_scope
Status.tagged_with(Tag.find_by(name: params[:tag].downcase)&.id)
end
def set_account
@account = Account.find_local!(params[:username])
end
def older_url
::Rails.logger.info("older: max_id #{@statuses.last.id}, url #{pagination_url(max_id: @statuses.last.id)}")
pagination_url(max_id: @statuses.last.id)
end
@ -93,7 +97,9 @@ class AccountsController < ApplicationController
end
def pagination_url(max_id: nil, min_id: nil)
if media_requested?
if tag_requested?
short_account_tag_url(@account, params[:tag], max_id: max_id, min_id: min_id)
elsif media_requested?
short_account_media_url(@account, max_id: max_id, min_id: min_id)
elsif replies_requested?
short_account_with_replies_url(@account, max_id: max_id, min_id: min_id)
@ -110,6 +116,10 @@ class AccountsController < ApplicationController
request.path.ends_with?('/with_replies')
end
def tag_requested?
request.path.ends_with?(Addressable::URI.parse("/tagged/#{params[:tag]}").normalize)
end
def filtered_status_page(params)
if params[:min_id].present?
filtered_statuses.paginate_by_min_id(PAGE_SIZE, params[:min_id]).reverse

View File

@ -33,6 +33,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
statuses.merge!(only_media_scope) if truthy_param?(:only_media)
statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
statuses.merge!(no_reblogs_scope) if truthy_param?(:exclude_reblogs)
statuses.merge!(hashtag_scope) if params[:tagged].present?
statuses
end
@ -67,6 +68,10 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
Status.without_reblogs
end
def hashtag_scope
Status.tagged_with(Tag.find_by(name: params[:tagged])&.id)
end
def pagination_params(core_params)
params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params)
end

View File

@ -6,6 +6,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
before_action :store_current_location
before_action :authenticate_resource_owner!
before_action :set_pack
before_action :set_body_classes
include Localized
@ -16,6 +17,10 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
private
def set_body_classes
@body_classes = 'admin'
end
def store_current_location
store_location_for(:user, request.url)
end

View File

@ -0,0 +1,51 @@
# frozen_string_literal: true
class Settings::FeaturedTagsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :set_featured_tags, only: :index
before_action :set_featured_tag, except: [:index, :create]
before_action :set_most_used_tags, only: :index
def index
@featured_tag = FeaturedTag.new
end
def create
@featured_tag = current_account.featured_tags.new(featured_tag_params)
@featured_tag.reset_data
if @featured_tag.save
redirect_to settings_featured_tags_path
else
set_featured_tags
set_most_used_tags
render :index
end
end
def destroy
@featured_tag.destroy!
redirect_to settings_featured_tags_path
end
private
def set_featured_tag
@featured_tag = current_account.featured_tags.find(params[:id])
end
def set_featured_tags
@featured_tags = current_account.featured_tags.order(statuses_count: :desc).reject(&:new_record?)
end
def set_most_used_tags
@most_used_tags = Tag.most_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10)
end
def featured_tag_params
params.require(:featured_tag).permit(:name)
end
end

View File

@ -29,6 +29,6 @@ class Settings::ProfilesController < Settings::BaseController
end
def set_account
@account = current_user.account
@account = current_account
end
end

View File

@ -2,6 +2,7 @@
# Intentionally does not inherit from BaseController
class Settings::SessionsController < ApplicationController
before_action :authenticate_user!
before_action :set_session, only: :destroy
def destroy

View File

@ -1,3 +1,10 @@
import { defineMessages } from 'react-intl';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
});
export const ALERT_SHOW = 'ALERT_SHOW';
export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR';
@ -15,10 +22,28 @@ export function clearAlert() {
};
};
export function showAlert(title, message) {
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
return {
type: ALERT_SHOW,
title,
message,
};
};
export function showAlertForError(error) {
if (error.response) {
const { data, status, statusText } = error.response;
let message = statusText;
let title = `${status}`;
if (data.error) {
message = data.error;
}
return showAlert(title, message);
} else {
console.error(error);
return showAlert();
}
}

View File

@ -1,5 +1,5 @@
import api from 'flavours/glitch/util/api';
import { CancelToken } from 'axios';
import { CancelToken, isCancel } from 'axios';
import { throttle } from 'lodash';
import { search as emojiSearch } from 'flavours/glitch/util/emoji/emoji_mart_search_light';
import { useEmoji } from './emojis';
@ -8,6 +8,9 @@ import { recoverHashtags } from 'flavours/glitch/util/hashtag';
import resizeImage from 'flavours/glitch/util/resize_image';
import { updateTimeline } from './timelines';
import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { defineMessages } from 'react-intl';
let cancelFetchComposeSuggestionsAccounts;
@ -52,6 +55,10 @@ export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
export const COMPOSE_DOODLE_SET = 'COMPOSE_DOODLE_SET';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
});
export function changeCompose(text) {
return {
type: COMPOSE_CHANGE,
@ -207,20 +214,32 @@ export function doodleSet(options) {
export function uploadCompose(files) {
return function (dispatch, getState) {
if (getState().getIn(['compose', 'media_attachments']).size > 3) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const total = Array.from(files).reduce((a, v) => a + v.size, 0);
const progress = new Array(files.length).fill(0);
if (files.length + media.size > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
return;
}
dispatch(uploadComposeRequest());
resizeImage(files[0]).then(file => {
const data = new FormData();
data.append('file', file);
for (const [i, f] of Array.from(files).entries()) {
if (media.size + i > 3) break;
return api(getState).post('/api/v1/media', data, {
onUploadProgress: ({ loaded, total }) => dispatch(uploadComposeProgress(loaded, total)),
}).then(({ data }) => dispatch(uploadComposeSuccess(data)));
}).catch(error => dispatch(uploadComposeFail(error)));
resizeImage(f).then(file => {
const data = new FormData();
data.append('file', file);
return api(getState).post('/api/v1/media', data, {
onUploadProgress: function({ loaded }){
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ data }) => dispatch(uploadComposeSuccess(data)));
}).catch(error => dispatch(uploadComposeFail(error)));
};
};
};
@ -320,6 +339,10 @@ const fetchComposeSuggestionsAccounts = throttle((dispatch, getState, token) =>
},
}).then(response => {
dispatch(readyComposeSuggestionsAccounts(token, response.data));
}).catch(error => {
if (!isCancel(error)) {
dispatch(showAlertForError(error));
}
});
}, 200, { leading: true, trailing: true });

View File

@ -1,4 +1,5 @@
import api from 'flavours/glitch/util/api';
import { showAlertForError } from './alerts';
export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS';
@ -239,7 +240,8 @@ export const fetchListSuggestions = q => (dispatch, getState) => {
};
api(getState).get('/api/v1/accounts/search', { params })
.then(({ data }) => dispatch(fetchListSuggestionsReady(q, data)));
.then(({ data }) => dispatch(fetchListSuggestionsReady(q, data)))
.catch(error => dispatch(showAlertForError(error)));
};
export const fetchListSuggestionsReady = (query, accounts) => ({

View File

@ -109,14 +109,11 @@ export function register () {
pushNotificationsSetting.remove(me);
}
try {
getRegistration()
.then(getPushSubscription)
.then(unsubscribe);
} catch (e) {
}
});
return getRegistration()
.then(getPushSubscription)
.then(unsubscribe);
})
.catch(console.warn);
} else {
console.warn('Your browser does not support Web Push Notifications.');
}
@ -137,6 +134,6 @@ export function saveSettings() {
if (me) {
pushNotificationsSetting.set(me, data);
}
});
}).catch(console.warn);
};
}

View File

@ -1,5 +1,6 @@
import api from 'flavours/glitch/util/api';
import { debounce } from 'lodash';
import { showAlertForError } from './alerts';
export const SETTING_CHANGE = 'SETTING_CHANGE';
export const SETTING_SAVE = 'SETTING_SAVE';
@ -23,7 +24,9 @@ const debouncedSave = debounce((dispatch, getState) => {
const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS();
api(getState).put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
api(getState).put('/api/web/settings', { data })
.then(() => dispatch({ type: SETTING_SAVE }))
.catch(error => dispatch(showAlertForError(error)));
}, 5000, { trailing: true });
export function saveSettings() {

View File

@ -7,6 +7,7 @@ import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from 'mastodon/locales';
import Compose from 'flavours/glitch/features/standalone/compose';
import initialState from 'flavours/glitch/util/initial_state';
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
@ -17,6 +18,8 @@ if (initialState) {
store.dispatch(hydrateStore(initialState));
}
store.dispatch(fetchCustomEmojis());
export default class TimelineContainer extends React.PureComponent {
static propTypes = {

View File

@ -25,6 +25,7 @@ import { openModal } from 'flavours/glitch/actions/modal';
import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
import { showAlertForError } from '../actions/alerts';
const messages = defineMessages({
deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@ -134,7 +135,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
},
onEmbed (status) {
dispatch(openModal('EMBED', { url: status.get('url') }));
dispatch(openModal('EMBED', {
url: status.get('url'),
onError: error => dispatch(showAlertForError(error)),
}));
},
onDelete (status, history, withRedraft = false) {

View File

@ -214,6 +214,7 @@ export default class ComposerOptions extends React.PureComponent {
onChange={handleChangeFiles}
ref={handleRefFileElement}
type='file'
multiple
{...hiddenComponent}
/>
<Dropdown

View File

@ -166,7 +166,7 @@ export default class GettingStarted extends ImmutablePureComponent {
<div className='getting-started__footer'>
<ul>
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this instance' /></a> · </li>
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a></li>

View File

@ -127,7 +127,7 @@ export default class PublicTimeline extends React.PureComponent {
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}
scrollKey={`public_timeline-${columnId}`}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
/>
</Column>
);

View File

@ -10,6 +10,7 @@ export default class EmbedModal extends ImmutablePureComponent {
static propTypes = {
url: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
onError: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
}
@ -35,6 +36,8 @@ export default class EmbedModal extends ImmutablePureComponent {
iframeDocument.body.style.margin = 0;
this.iframe.width = iframeDocument.body.scrollWidth;
this.iframe.height = iframeDocument.body.scrollHeight;
}).catch(error => {
this.props.onError(error);
});
}

View File

@ -97,7 +97,7 @@ export default class ReportModal extends ImmutablePureComponent {
<div className='report-modal__container'>
<div className='report-modal__comment'>
<p><FormattedMessage id='report.hint' defaultMessage='The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:' /></p>
<p><FormattedMessage id='report.hint' defaultMessage='The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:' /></p>
<textarea
className='setting-text light'

View File

@ -186,7 +186,7 @@ export default class UI extends React.Component {
this.setState({ draggingOver: false });
this.dragTargets = [];
if (e.dataTransfer && e.dataTransfer.files.length === 1) {
if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
this.props.dispatch(uploadCompose(e.dataTransfer.files));
}
}

View File

@ -1,4 +1,4 @@
import { showAlert } from 'flavours/glitch/actions/alerts';
import { showAlertForError } from 'flavours/glitch/actions/alerts';
const defaultFailSuffix = 'FAIL';
@ -8,21 +8,7 @@ export default function errorsMiddleware() {
const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
if (action.type.match(isFail)) {
if (action.error.response) {
const { data, status, statusText } = action.error.response;
let message = statusText;
let title = `${status}`;
if (data.error) {
message = data.error;
}
dispatch(showAlert(title, message));
} else {
console.error(action.error);
dispatch(showAlert('Oops!', 'An unexpected error occurred.'));
}
dispatch(showAlertForError(action.error));
}
}

View File

@ -290,3 +290,7 @@
border-bottom: 0;
}
}
.directory__tag .trends__item__current {
width: auto;
}

View File

@ -153,10 +153,15 @@ $content-width: 840px;
font-weight: 500;
}
.directory__tag a {
.directory__tag > a,
.directory__tag > div {
box-shadow: none;
}
.directory__tag .table-action-link .fa {
color: inherit;
}
.directory__tag h4 {
font-size: 18px;
font-weight: 700;

View File

@ -269,7 +269,8 @@
box-sizing: border-box;
margin-bottom: 10px;
a {
& > a,
& > div {
display: flex;
align-items: center;
justify-content: space-between;
@ -279,7 +280,9 @@
text-decoration: none;
color: inherit;
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
}
& > a {
&:hover,
&:active,
&:focus {
@ -287,7 +290,7 @@
}
}
&.active a {
&.active > a {
background: $ui-highlight-color;
cursor: default;
}

View File

@ -31,7 +31,7 @@ const loadImage = inputFile => new Promise((resolve, reject) => {
});
const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
if (type !== 'image/jpeg') {
if (!['image/jpeg', 'image/webp'].includes(type)) {
resolve(1);
return;
}

View File

@ -22,7 +22,7 @@ export function clearAlert() {
};
};
export function showAlert(title, message) {
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
return {
type: ALERT_SHOW,
title,
@ -44,6 +44,6 @@ export function showAlertForError(error) {
return showAlert(title, message);
} else {
console.error(error);
return showAlert(messages.unexpectedTitle, messages.unexpectedMessage);
return showAlert();
}
}

View File

@ -8,6 +8,8 @@ import resizeImage from '../utils/resize_image';
import { importFetchedAccounts } from './importer';
import { updateTimeline } from './timelines';
import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { defineMessages } from 'react-intl';
let cancelFetchComposeSuggestionsAccounts;
@ -49,6 +51,10 @@ export const COMPOSE_UPLOAD_CHANGE_REQUEST = 'COMPOSE_UPLOAD_UPDATE_REQUEST'
export const COMPOSE_UPLOAD_CHANGE_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL';
const messages = defineMessages({
uploadErrorLimit: { id: 'upload_error.limit', defaultMessage: 'File upload limit exceeded.' },
});
export function changeCompose(text) {
return {
type: COMPOSE_CHANGE,
@ -184,20 +190,32 @@ export function submitComposeFail(error) {
export function uploadCompose(files) {
return function (dispatch, getState) {
if (getState().getIn(['compose', 'media_attachments']).size > 3) {
const uploadLimit = 4;
const media = getState().getIn(['compose', 'media_attachments']);
const total = Array.from(files).reduce((a, v) => a + v.size, 0);
const progress = new Array(files.length).fill(0);
if (files.length + media.size > uploadLimit) {
dispatch(showAlert(undefined, messages.uploadErrorLimit));
return;
}
dispatch(uploadComposeRequest());
resizeImage(files[0]).then(file => {
const data = new FormData();
data.append('file', file);
for (const [i, f] of Array.from(files).entries()) {
if (media.size + i > 3) break;
return api(getState).post('/api/v1/media', data, {
onUploadProgress: ({ loaded, total }) => dispatch(uploadComposeProgress(loaded, total)),
}).then(({ data }) => dispatch(uploadComposeSuccess(data)));
}).catch(error => dispatch(uploadComposeFail(error)));
resizeImage(f).then(file => {
const data = new FormData();
data.append('file', file);
return api(getState).post('/api/v1/media', data, {
onUploadProgress: function({ loaded }){
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ data }) => dispatch(uploadComposeSuccess(data)));
}).catch(error => dispatch(uploadComposeFail(error)));
};
};
};

View File

@ -65,7 +65,7 @@ export default class IntersectionObserverArticle extends React.Component {
}
updateStateAfterIntersection = (prevState) => {
if (prevState.isIntersecting && !this.entry.isIntersecting) {
if (prevState.isIntersecting !== false && !this.entry.isIntersecting) {
scheduleIdleTask(this.hideIfNotIntersecting);
}
return {

View File

@ -7,6 +7,7 @@ import { IntlProvider, addLocaleData } from 'react-intl';
import { getLocale } from '../locales';
import Compose from '../features/standalone/compose';
import initialState from '../initial_state';
import { fetchCustomEmojis } from '../actions/custom_emojis';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
@ -17,6 +18,8 @@ if (initialState) {
store.dispatch(hydrateStore(initialState));
}
store.dispatch(fetchCustomEmojis());
export default class TimelineContainer extends React.PureComponent {
static propTypes = {

View File

@ -63,7 +63,7 @@ class UploadButton extends ImmutablePureComponent {
key={resetFileKey}
ref={this.setRef}
type='file'
multiple={false}
multiple
accept={acceptContentTypes.toArray().join(',')}
onChange={this.handleChange}
disabled={disabled}

View File

@ -160,7 +160,7 @@ class GettingStarted extends ImmutablePureComponent {
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
{multiColumn && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this instance' /></a> · </li>
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
<li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>

View File

@ -89,7 +89,7 @@ const FrameInteractions = ({ onNext }) => (
</div>
<div className='introduction__action'>
<button className='button' onClick={onNext}><FormattedMessage id='introduction.interactions.action' defaultMessage='Finish tutorial!' /></button>
<button className='button' onClick={onNext}><FormattedMessage id='introduction.interactions.action' defaultMessage='Finish toot-orial!' /></button>
</div>
</div>
);

View File

@ -124,7 +124,7 @@ class PublicTimeline extends React.PureComponent {
onLoadMore={this.handleLoadMore}
trackScroll={!pinned}
scrollKey={`public_timeline-${columnId}`}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />}
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other servers to fill it up' />}
shouldUpdateScroll={shouldUpdateScroll}
/>
</Column>

View File

@ -97,7 +97,7 @@ class ReportModal extends ImmutablePureComponent {
<div className='report-modal__container'>
<div className='report-modal__comment'>
<p><FormattedMessage id='report.hint' defaultMessage='The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:' /></p>
<p><FormattedMessage id='report.hint' defaultMessage='The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:' /></p>
<textarea
className='setting-text light'

View File

@ -263,7 +263,7 @@ class UI extends React.PureComponent {
this.setState({ draggingOver: false });
this.dragTargets = [];
if (e.dataTransfer && e.dataTransfer.files.length === 1) {
if (e.dataTransfer && e.dataTransfer.files.length >= 1) {
this.props.dispatch(uploadCompose(e.dataTransfer.files));
}
}

View File

@ -12,6 +12,15 @@
],
"path": "app/javascript/mastodon/actions/alerts.json"
},
{
"descriptors": [
{
"defaultMessage": "File upload limit exceeded.",
"id": "upload_error.limit"
}
],
"path": "app/javascript/mastodon/actions/compose.json"
},
{
"descriptors": [
{
@ -1275,7 +1284,7 @@
"id": "getting_started.security"
},
{
"defaultMessage": "About this instance",
"defaultMessage": "About this server",
"id": "navigation_bar.info"
},
{
@ -1448,7 +1457,7 @@
"id": "introduction.interactions.favourite.text"
},
{
"defaultMessage": "Finish tutorial!",
"defaultMessage": "Finish toot-orial!",
"id": "introduction.interactions.action"
}
],
@ -1828,7 +1837,7 @@
"id": "column.public"
},
{
"defaultMessage": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
"defaultMessage": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"id": "empty_column.public"
}
],
@ -2188,7 +2197,7 @@
"id": "report.target"
},
{
"defaultMessage": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
"defaultMessage": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:",
"id": "report.hint"
},
{
@ -2298,4 +2307,4 @@
],
"path": "app/javascript/mastodon/features/video/index.json"
}
]
]

View File

@ -132,7 +132,7 @@
"empty_column.lists": "You don't have any lists yet. When you create one, it will show up here.",
"empty_column.mutes": "You haven't muted any users yet.",
"empty_column.notifications": "You don't have any notifications yet. Interact with others to start the conversation.",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other instances to fill it up",
"empty_column.public": "There is nothing here! Write something publicly, or manually follow users from other servers to fill it up",
"follow_request.authorize": "Authorize",
"follow_request.reject": "Reject",
"getting_started.developers": "Developers",
@ -228,7 +228,7 @@
"navigation_bar.favourites": "Favourites",
"navigation_bar.filters": "Muted words",
"navigation_bar.follow_requests": "Follow requests",
"navigation_bar.info": "About this instance",
"navigation_bar.info": "About this server",
"navigation_bar.keyboard_shortcuts": "Hotkeys",
"navigation_bar.lists": "Lists",
"navigation_bar.misc": "Misc",
@ -281,7 +281,7 @@
"reply_indicator.cancel": "Cancel",
"report.forward": "Forward to {target}",
"report.forward_hint": "The account is from another server. Send an anonymized copy of the report there as well?",
"report.hint": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
"report.hint": "The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:",
"report.placeholder": "Additional comments",
"report.submit": "Submit",
"report.target": "Reporting {target}",
@ -347,6 +347,7 @@
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "File upload limit exceeded.",
"upload_form.description": "Describe for the visually impaired",
"upload_form.focus": "Change preview",
"upload_form.undo": "Delete",

View File

@ -132,7 +132,7 @@
"empty_column.lists": "まだリストがありません。リストを作るとここに表示されます。",
"empty_column.mutes": "まだ誰もミュートしていません。",
"empty_column.notifications": "まだ通知がありません。他の人とふれ合って会話を始めましょう。",
"empty_column.public": "ここにはまだ何もありません! 公開で何かを投稿したり、他のインスタンスのユーザーをフォローしたりしていっぱいにしましょう",
"empty_column.public": "ここにはまだ何もありません! 公開で何かを投稿したり、他のサーバーのユーザーをフォローしたりしていっぱいにしましょう",
"follow_request.authorize": "許可",
"follow_request.reject": "拒否",
"getting_started.developers": "開発",
@ -228,7 +228,7 @@
"navigation_bar.favourites": "お気に入り",
"navigation_bar.filters": "フィルター設定",
"navigation_bar.follow_requests": "フォローリクエスト",
"navigation_bar.info": "このインスタンスについて",
"navigation_bar.info": "このサーバーについて",
"navigation_bar.keyboard_shortcuts": "ホットキー",
"navigation_bar.lists": "リスト",
"navigation_bar.logout": "ログアウト",
@ -280,8 +280,8 @@
"relative_time.seconds": "{number}秒前",
"reply_indicator.cancel": "キャンセル",
"report.forward": "{target} に転送する",
"report.forward_hint": "このアカウントは別のインスタンスに所属しています。通報内容を匿名で転送しますか?",
"report.hint": "通報内容はあなたのインスタンスのモデレーターへ送信されます。通報理由を入力してください。:",
"report.forward_hint": "このアカウントは別のサーバーに所属しています。通報内容を匿名で転送しますか?",
"report.hint": "通報内容はあなたのサーバーのモデレーターへ送信されます。通報理由を入力してください。:",
"report.placeholder": "追加コメント",
"report.submit": "通報する",
"report.target": "{target}さんを通報する",
@ -347,6 +347,7 @@
"ui.beforeunload": "Mastodonから離れると送信前の投稿は失われます。",
"upload_area.title": "ドラッグ&ドロップでアップロード",
"upload_button.label": "メディアを追加 (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "アップロードできる上限を超えています。",
"upload_form.description": "視覚障害者のための説明",
"upload_form.focus": "焦点",
"upload_form.undo": "削除",

View File

@ -347,6 +347,7 @@
"ui.beforeunload": "Utracisz tworzony wpis, jeżeli opuścisz Mastodona.",
"upload_area.title": "Przeciągnij i upuść aby wysłać",
"upload_button.label": "Dodaj zawartość multimedialną (JPEG, PNG, GIF, WebM, MP4, MOV)",
"upload_error.limit": "Przekroczono limit plików do wysłania.",
"upload_form.description": "Wprowadź opis dla niewidomych i niedowidzących",
"upload_form.focus": "Dopasuj podgląd",
"upload_form.undo": "Usuń",

View File

@ -31,7 +31,7 @@ const loadImage = inputFile => new Promise((resolve, reject) => {
});
const getOrientation = (img, type = 'image/png') => new Promise(resolve => {
if (type !== 'image/jpeg') {
if (!['image/jpeg', 'image/webp'].includes(type)) {
resolve(1);
return;
}

View File

@ -288,3 +288,7 @@
border-bottom: 0;
}
}
.directory__tag .trends__item__current {
width: auto;
}

View File

@ -153,10 +153,15 @@ $content-width: 840px;
font-weight: 500;
}
.directory__tag a {
.directory__tag > a,
.directory__tag > div {
box-shadow: none;
}
.directory__tag .table-action-link .fa {
color: inherit;
}
.directory__tag h4 {
font-size: 18px;
font-weight: 700;

View File

@ -638,7 +638,6 @@
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre-wrap;
padding-top: 2px;
color: $primary-text-color;
@ -662,6 +661,7 @@
p {
margin-bottom: 20px;
white-space: pre-wrap;
&:last-child {
margin-bottom: 0;

View File

@ -269,7 +269,8 @@
box-sizing: border-box;
margin-bottom: 10px;
a {
& > a,
& > div {
display: flex;
align-items: center;
justify-content: space-between;
@ -279,7 +280,9 @@
text-decoration: none;
color: inherit;
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
}
& > a {
&:hover,
&:active,
&:focus {
@ -287,7 +290,7 @@
}
}
&.active a {
&.active > a {
background: $ui-highlight-color;
cursor: default;
}

View File

@ -4,6 +4,8 @@ class ActivityTracker
EXPIRE_AFTER = 90.days.seconds
class << self
include Redisable
def increment(prefix)
key = [prefix, current_week].join(':')
@ -20,10 +22,6 @@ class ActivityTracker
private
def redis
Redis.current
end
def current_week
Time.zone.today.cweek
end

View File

@ -2,6 +2,7 @@
class ActivityPub::Activity
include JsonLdHelper
include Redisable
def initialize(json, account, **options)
@json = json
@ -70,10 +71,6 @@ class ActivityPub::Activity
@object_uri ||= value_or_id(@object)
end
def redis
Redis.current
end
def distribute(status)
crawl_links(status)

View File

@ -4,6 +4,7 @@ require 'singleton'
class FeedManager
include Singleton
include Redisable
MAX_ITEMS = 400
@ -35,7 +36,7 @@ class FeedManager
def unpush_from_home(account, status)
return false unless remove_from_feed(:home, account.id, status)
Redis.current.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s))
true
end
@ -54,7 +55,7 @@ class FeedManager
def unpush_from_list(list, status)
return false unless remove_from_feed(:list, list.id, status)
Redis.current.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s))
true
end
@ -143,10 +144,6 @@ class FeedManager
private
def redis
Redis.current
end
def push_update_required?(timeline_id)
redis.exists("subscribed:#{timeline_id}")
end

View File

@ -99,7 +99,7 @@ class Formatter
end
def encode_and_link_urls(html, accounts = nil, options = {})
entities = Extractor.extract_entities_with_indices(html, extract_url_without_protocol: false)
entities = utf8_friendly_extractor(html, extract_url_without_protocol: false)
if accounts.is_a?(Hash)
options = accounts
@ -199,6 +199,53 @@ class Formatter
result.flatten.join
end
UNICODE_ESCAPE_BLACKLIST_RE = /\p{Z}|\p{P}/
def utf8_friendly_extractor(text, options = {})
old_to_new_index = [0]
escaped = text.chars.map do |c|
output = begin
if c.ord.to_s(16).length > 2 && UNICODE_ESCAPE_BLACKLIST_RE.match(c).nil?
CGI.escape(c)
else
c
end
end
old_to_new_index << old_to_new_index.last + output.length
output
end.join
# Note: I couldn't obtain list_slug with @user/list-name format
# for mention so this requires additional check
special = Extractor.extract_urls_with_indices(escaped, options).map do |extract|
# exactly one of :url, :hashtag, :screen_name, :cashtag keys is present
key = (extract.keys & [:url, :hashtag, :screen_name, :cashtag]).first
new_indices = [
old_to_new_index.find_index(extract[:indices].first),
old_to_new_index.find_index(extract[:indices].last),
]
has_prefix_char = [:hashtag, :screen_name, :cashtag].include?(key)
value_indices = [
new_indices.first + (has_prefix_char ? 1 : 0), # account for #, @ or $
new_indices.last - 1,
]
next extract.merge(
:indices => new_indices,
key => text[value_indices.first..value_indices.last]
)
end
standard = Extractor.extract_entities_with_indices(text, options)
Extractor.remove_overlapping_entities(special + standard)
end
def link_to_url(entity, options = {})
url = Addressable::URI.parse(entity[:url])
html_attrs = { target: '_blank', rel: 'nofollow noopener' }

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class OStatus::Activity::Base
include Redisable
def initialize(xml, account = nil, **options)
@xml = xml
@account = account
@ -66,8 +68,4 @@ class OStatus::Activity::Base
Status.find_by(uri: uri)
end
end
def redis
Redis.current
end
end

View File

@ -11,6 +11,8 @@ class PotentialFriendshipTracker
}.freeze
class << self
include Redisable
def record(account_id, target_account_id, action)
return if account_id == target_account_id
@ -31,11 +33,5 @@ class PotentialFriendshipTracker
return [] if account_ids.empty?
Account.searchable.where(id: account_ids)
end
private
def redis
Redis.current
end
end
end

View File

@ -12,6 +12,7 @@
class AccountDomainBlock < ApplicationRecord
include Paginable
include DomainNormalizable
belongs_to :account
validates :domain, presence: true, uniqueness: { scope: :account_id }

View File

@ -56,5 +56,6 @@ module AccountAssociations
# Hashtags
has_and_belongs_to_many :tags
has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account
end
end

View File

@ -3,7 +3,7 @@
module AccountAvatar
extend ActiveSupport::Concern
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
LIMIT = 2.megabytes
class_methods do

View File

@ -3,7 +3,7 @@
module AccountHeader
extend ActiveSupport::Concern
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
LIMIT = 2.megabytes
MAX_PIXELS = 750_000 # 1500x500px

View File

@ -10,6 +10,6 @@ module DomainNormalizable
private
def normalize_domain
self.domain = TagManager.instance.normalize_domain(domain)
self.domain = TagManager.instance.normalize_domain(domain&.strip)
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Redisable
extend ActiveSupport::Concern
private
def redis
Redis.current
end
end

View File

@ -1,4 +1,5 @@
# frozen_string_literal: true
require 'csv'
class Export

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: featured_tags
#
# id :bigint(8) not null, primary key
# account_id :bigint(8)
# tag_id :bigint(8)
# statuses_count :bigint(8) default(0), not null
# last_status_at :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
class FeaturedTag < ApplicationRecord
belongs_to :account, inverse_of: :featured_tags, required: true
belongs_to :tag, inverse_of: :featured_tags, required: true
delegate :name, to: :tag, allow_nil: true
validates :name, presence: true
validate :validate_featured_tags_limit, on: :create
def name=(str)
self.tag = Tag.find_or_initialize_by(name: str.delete('#').mb_chars.downcase.to_s)
end
def increment(timestamp)
update(statuses_count: statuses_count + 1, last_status_at: timestamp)
end
def decrement(deleted_status_id)
update(statuses_count: [0, statuses_count - 1].max, last_status_at: account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).where.not(id: deleted_status_id).select(:created_at).first&.created_at)
end
def reset_data
self.statuses_count = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).count
self.last_status_at = account.statuses.where(visibility: %i(public unlisted)).tagged_with(tag).select(:created_at).first&.created_at
end
private
def validate_featured_tags_limit
errors.add(:base, I18n.t('featured_tags.errors.limit')) if account.featured_tags.count >= 10
end
end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class Feed
include Redisable
def initialize(type, id)
@type = type
@id = id
@ -27,8 +29,4 @@ class Feed
def key
FeedManager.instance.key(@type, @id)
end
def redis
Redis.current
end
end

View File

@ -13,20 +13,30 @@
# data_file_size :integer
# data_updated_at :datetime
# account_id :bigint(8) not null
# overwrite :boolean default(FALSE), not null
#
class Import < ApplicationRecord
FILE_TYPES = ['text/plain', 'text/csv'].freeze
FILE_TYPES = %w(text/plain text/csv).freeze
MODES = %i(merge overwrite).freeze
self.inheritance_column = false
belongs_to :account
enum type: [:following, :blocking, :muting]
enum type: [:following, :blocking, :muting, :domain_blocking]
validates :type, presence: true
has_attached_file :data
validates_attachment_content_type :data, content_type: FILE_TYPES
validates_attachment_presence :data
def mode
overwrite? ? :overwrite : :merge
end
def mode=(str)
self.overwrite = str.to_sym == :overwrite
end
end

View File

@ -25,11 +25,11 @@ class MediaAttachment < ApplicationRecord
enum type: [:image, :gifv, :video, :audio, :unknown]
IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'].freeze
IMAGE_FILE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].freeze
VIDEO_FILE_EXTENSIONS = ['.webm', '.mp4', '.m4v', '.mov'].freeze
AUDIO_FILE_EXTENSIONS = ['.mp3', '.m4a', '.wav', '.ogg'].freeze
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
VIDEO_MIME_TYPES = ['video/webm', 'video/mp4', 'video/quicktime'].freeze
VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
AUDIO_MIME_TYPES = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
@ -105,8 +105,8 @@ class MediaAttachment < ApplicationRecord
convert_options: { all: '-quality 90 -strip' }
validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES + AUDIO_MIME_TYPES
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :video?
validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :video?
validates_attachment_size :file, less_than: IMAGE_LIMIT, unless: :video_or_gifv?
validates_attachment_size :file, less_than: VIDEO_LIMIT, if: :video_or_gifv?
remotable_attachment :file, VIDEO_LIMIT
include Attachmentable
@ -129,6 +129,10 @@ class MediaAttachment < ApplicationRecord
file.blank? && remote_url.present?
end
def video_or_gifv?
video? || gifv?
end
def to_param
shortcode
end

View File

@ -25,7 +25,7 @@
#
class PreviewCard < ApplicationRecord
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif'].freeze
IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'].freeze
LIMIT = 1.megabytes
self.inheritance_column = false

View File

@ -14,6 +14,7 @@ class Tag < ApplicationRecord
has_and_belongs_to_many :accounts
has_and_belongs_to_many :sample_accounts, -> { searchable.discoverable.popular.limit(3) }, class_name: 'Account'
has_many :featured_tags, dependent: :destroy, inverse_of: :tag
has_one :account_tag_stat, dependent: :destroy
HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_·][[:word:]_]*'
@ -23,6 +24,7 @@ class Tag < ApplicationRecord
scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) }
delegate :accounts_count,
:accounts_count=,

View File

@ -7,6 +7,8 @@ class TrendingTags
THRESHOLD = 5
class << self
include Redisable
def record_use!(tag, account, at_time = Time.now.utc)
return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot?
@ -59,9 +61,5 @@ class TrendingTags
@disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
@disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
end
def redis
Redis.current
end
end
end

View File

@ -52,6 +52,14 @@ class ManifestSerializer < ActiveModel::Serializer
end
def share_target
{ url_template: 'share?title={title}&text={text}&url={url}' }
{
url_template: 'share?title={title}&text={text}&url={url}',
action: 'share',
params: {
title: 'title',
text: 'text',
url: 'url',
},
}
end
end

View File

@ -212,7 +212,7 @@ class ActivityPub::ProcessAccountService < BaseService
end
def clear_tombstones!
Tombstone.delete_all(account_id: @account.id)
Tombstone.where(account_id: @account.id).delete_all
end
def protocol_changed?

View File

@ -2,6 +2,7 @@
class BatchedRemoveStatusService < BaseService
include StreamEntryRenderer
include Redisable
# Delete given statuses and reblogs of them
# Dispatch PuSH updates of the deleted statuses, but only local ones
@ -120,10 +121,6 @@ class BatchedRemoveStatusService < BaseService
end
end
def redis
Redis.current
end
def build_xml(stream_entry)
return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id)

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class FollowService < BaseService
include Redisable
# Follow a remote user, notify remote user about the follow
# @param [Account] source_account From which to follow
# @param [String, Account] uri User URI to follow in the form of username@domain (or account record)
@ -67,10 +69,6 @@ class FollowService < BaseService
follow
end
def redis
Redis.current
end
def build_follow_request_xml(follow_request)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_request_salmon(follow_request))
end

View File

@ -0,0 +1,90 @@
# frozen_string_literal: true
require 'csv'
class ImportService < BaseService
ROWS_PROCESSING_LIMIT = 20_000
def call(import)
@import = import
@account = @import.account
@data = CSV.new(import_data).reject(&:blank?)
case @import.type
when 'following'
import_follows!
when 'blocking'
import_blocks!
when 'muting'
import_mutes!
when 'domain_blocking'
import_domain_blocks!
end
end
private
def import_follows!
import_relationships!('follow', 'unfollow', @account.following, follow_limit)
end
def import_blocks!
import_relationships!('block', 'unblock', @account.blocking, ROWS_PROCESSING_LIMIT)
end
def import_mutes!
import_relationships!('mute', 'unmute', @account.muting, ROWS_PROCESSING_LIMIT)
end
def import_domain_blocks!
items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row.first.strip }
if @import.overwrite?
presence_hash = items.each_with_object({}) { |id, mapping| mapping[id] = true }
@account.domain_blocks.find_each do |domain_block|
if presence_hash[domain_block.domain]
items.delete(domain_block.domain)
else
@account.unblock_domain!(domain_block.domain)
end
end
end
items.each do |domain|
@account.block_domain!(domain)
end
AfterAccountDomainBlockWorker.push_bulk(items) do |domain|
[@account.id, domain]
end
end
def import_relationships!(action, undo_action, overwrite_scope, limit)
items = @data.take(limit).map { |row| row.first.strip }
if @import.overwrite?
presence_hash = items.each_with_object({}) { |id, mapping| mapping[id] = true }
overwrite_scope.find_each do |target_account|
if presence_hash[target_account.acct]
items.delete(target_account.acct)
else
Import::RelationshipWorker.perform_async(@account.id, target_account.acct, undo_action)
end
end
end
Import::RelationshipWorker.push_bulk(items) do |acct|
[@account.id, acct, action]
end
end
def import_data
Paperclip.io_adapters.for(@import.data).read
end
def follow_limit
FollowLimitValidator.limit_for_account(@account)
end
end

View File

@ -1,6 +1,8 @@
# frozen_string_literal: true
class PostStatusService < BaseService
include Redisable
MIN_SCHEDULE_OFFSET = 5.minutes.freeze
# Post a text status update, fetch and notify remote users mentioned
@ -115,10 +117,6 @@ class PostStatusService < BaseService
ProcessHashtagsService.new
end
def redis
Redis.current
end
def scheduled?
@scheduled_at.present?
end

View File

@ -2,12 +2,22 @@
class ProcessHashtagsService < BaseService
def call(status, tags = [])
tags = Extractor.extract_hashtags(status.text) if status.local?
tags = Extractor.extract_hashtags(status.text) if status.local?
records = []
tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
tag = Tag.where(name: name).first_or_create(name: name)
status.tags << tag
records << tag
TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility?
end
return unless status.public_visibility? || status.unlisted_visibility?
status.account.featured_tags.where(tag_id: records.map(&:id)).each do |featured_tag|
featured_tag.increment(status.created_at)
end
end
end

View File

@ -2,6 +2,7 @@
class RemoveStatusService < BaseService
include StreamEntryRenderer
include Redisable
def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
@ -56,7 +57,7 @@ class RemoveStatusService < BaseService
def remove_from_affected
@mentions.map(&:account).select(&:local?).each do |account|
Redis.current.publish("timeline:#{account.id}", @payload)
redis.publish("timeline:#{account.id}", @payload)
end
end
@ -131,26 +132,30 @@ class RemoveStatusService < BaseService
end
def remove_from_hashtags
@account.featured_tags.where(tag_id: @status.tags.pluck(:id)).each do |featured_tag|
featured_tag.decrement(@status.id)
end
return unless @status.public_visibility?
@tags.each do |hashtag|
Redis.current.publish("timeline:hashtag:#{hashtag}", @payload)
Redis.current.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local?
redis.publish("timeline:hashtag:#{hashtag}", @payload)
redis.publish("timeline:hashtag:#{hashtag}:local", @payload) if @status.local?
end
end
def remove_from_public
return unless @status.public_visibility?
Redis.current.publish('timeline:public', @payload)
Redis.current.publish('timeline:public:local', @payload) if @status.local?
redis.publish('timeline:public', @payload)
redis.publish('timeline:public:local', @payload) if @status.local?
end
def remove_from_media
return unless @status.public_visibility?
Redis.current.publish('timeline:public:media', @payload)
Redis.current.publish('timeline:public:local:media', @payload) if @status.local?
redis.publish('timeline:public:media', @payload)
redis.publish('timeline:public:local:media', @payload) if @status.local?
end
def remove_from_direct
@ -159,8 +164,4 @@ class RemoveStatusService < BaseService
end
Redis.current.publish("timeline:direct:#{@account.id}", @payload) if @account.local?
end
def redis
Redis.current
end
end

View File

@ -63,4 +63,17 @@
- @endorsed_accounts.each do |account|
= account_link_to account
- @account.featured_tags.order(statuses_count: :desc).each do |featured_tag|
.directory__tag{ class: params[:tag] == featured_tag.name ? 'active' : nil }
= link_to short_account_tag_path(@account, featured_tag.tag) do
%h4
= fa_icon 'hashtag'
= featured_tag.name
%small
- if featured_tag.last_status_at.nil?
= t('accounts.nothing_here')
- else
%time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
.trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true
= render 'application/sidebar'

View File

@ -3,7 +3,7 @@
= simple_form_for @user, url: admin_account_change_email_path(@account.id) do |f|
.fields-group
= f.input :email, wrapper: :with_label, disabled: true, label: t('admin.accounts.change_email.current_email')
= f.input :email, wrapper: :with_label, hint: false, disabled: true, label: t('admin.accounts.change_email.current_email')
.fields-group
= f.input :unconfirmed_email, wrapper: :with_label, label: t('admin.accounts.change_email.new_email')

View File

@ -0,0 +1,27 @@
- content_for :page_title do
= t('settings.featured_tags')
= simple_form_for @featured_tag, url: settings_featured_tags_path do |f|
= render 'shared/error_messages', object: @featured_tag
.fields-group
= f.input :name, wrapper: :with_block_label, hint: safe_join([t('simple_form.hints.featured_tag.name'), safe_join(@most_used_tags.map { |tag| link_to("##{tag.name}", settings_featured_tags_path(featured_tag: { name: tag.name }), method: :post) }, ', ')], ' ')
.actions
= f.button :button, t('featured_tags.add_new'), type: :submit
%hr.spacer/
- @featured_tags.each do |featured_tag|
.directory__tag{ class: params[:tag] == featured_tag.name ? 'active' : nil }
%div
%h4
= fa_icon 'hashtag'
= featured_tag.name
%small
- if featured_tag.last_status_at.nil?
= t('accounts.nothing_here')
- else
%time{ datetime: featured_tag.last_status_at.iso8601, title: l(featured_tag.last_status_at) }= l featured_tag.last_status_at
= table_link_to 'trash', t('filters.index.delete'), settings_featured_tag_path(featured_tag), method: :delete, data: { confirm: t('admin.accounts.are_you_sure') }
.trends__item__current= number_to_human featured_tag.statuses_count, strip_insignificant_zeros: true

View File

@ -5,8 +5,11 @@
.field-group
= f.input :type, collection: Import.types.keys, wrapper: :with_block_label, include_blank: false, label_method: lambda { |type| I18n.t("imports.types.#{type}") }, hint: t('imports.preface')
.field-group
= f.input :data, wrapper: :with_block_label, hint: t('simple_form.hints.imports.data')
.fields-row
.fields-group.fields-row__column.fields-row__column-6
= f.input :data, wrapper: :with_block_label, hint: t('simple_form.hints.imports.data')
.fields-group.fields-row__column.fields-row__column-6
= f.input :mode, as: :radio_buttons, collection: Import::MODES, label_method: lambda { |mode| safe_join([I18n.t("imports.modes.#{mode}"), content_tag(:span, I18n.t("imports.modes.#{mode}_long"), class: 'hint')]) }, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li'
.actions
= f.button :button, t('imports.upload'), type: :submit

View File

@ -13,11 +13,17 @@ class Import::RelationshipWorker
case relationship
when 'follow'
FollowService.new.call(from_account, target_account.acct)
FollowService.new.call(from_account, target_account)
when 'unfollow'
UnfollowService.new.call(from_account, target_account)
when 'block'
BlockService.new.call(from_account, target_account)
when 'unblock'
UnblockService.new.call(from_account, target_account)
when 'mute'
MuteService.new.call(from_account, target_account)
when 'unmute'
UnmuteService.new.call(from_account, target_account)
end
rescue ActiveRecord::RecordNotFound
true

View File

@ -1,44 +1,14 @@
# frozen_string_literal: true
require 'csv'
class ImportWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull', retry: false
attr_reader :import
def perform(import_id)
@import = Import.find(import_id)
Import::RelationshipWorker.push_bulk(import_rows) do |row|
[@import.account_id, row.first, relationship_type]
end
@import.destroy
end
private
def import_contents
Paperclip.io_adapters.for(@import.data).read
end
def relationship_type
case @import.type
when 'following'
'follow'
when 'blocking'
'block'
when 'muting'
'mute'
end
end
def import_rows
rows = CSV.new(import_contents).reject(&:blank?)
rows = rows.take(FollowLimitValidator.limit_for_account(@import.account)) if @import.type == 'following'
rows
import = Import.find(import_id)
ImportService.new.call(import)
ensure
import&.destroy
end
end

View File

@ -2,6 +2,7 @@
class Scheduler::FeedCleanupScheduler
include Sidekiq::Worker
include Redisable
sidekiq_options unique: :until_executed, retry: 0
@ -57,8 +58,4 @@ class Scheduler::FeedCleanupScheduler
def feed_manager
FeedManager.instance
end
def redis
Redis.current
end
end

View File

@ -35,11 +35,8 @@ ignore_missing:
- 'activemodel.errors.*'
- 'activerecord.attributes.*'
- 'activerecord.errors.*'
- '{devise,pagination,doorkeeper}.*'
- '{pagination,doorkeeper}.*'
- '{date,datetime,time,number}.*'
- 'simple_form.{yes,no}'
- 'simple_form.{placeholders,hints,labels}.*'
- 'simple_form.{error_notification,required}.:'
- 'errors.messages.*'
- 'activerecord.errors.models.doorkeeper/*'
- 'sessions.{browsers,platforms}.*'

View File

@ -1,7 +1,7 @@
module Twitter
class Regex
REGEXEN[:valid_general_url_path_chars] = /[^\p{White_Space}\(\)\?]/iou
REGEXEN[:valid_url_path_ending_chars] = /[^\p{White_Space}\(\)\?!\*';:=\,\.\$%\[\]~&\|@]|(?:#{REGEXEN[:valid_url_balanced_parens]})/iou
REGEXEN[:valid_general_url_path_chars] = /[^\p{White_Space}<>\(\)\?]/iou
REGEXEN[:valid_url_path_ending_chars] = /[^\p{White_Space}\(\)\?!\*"'「」<>;:=\,\.\$%\[\]~&\|@]|(?:#{REGEXEN[:valid_url_balanced_parens]})/iou
REGEXEN[:valid_url_balanced_parens] = /
\(
(?:

View File

@ -20,17 +20,17 @@ en:
action: Verify email address
action_with_app: Confirm and return to %{app}
explanation: You have created an account on %{host} with this email address. You are one click away from activating it. If this wasn't you, please ignore this email.
extra_html: Please also check out <a href="%{terms_path}">the rules of the instance</a> and <a href="%{policy_path}">our terms of service</a>.
extra_html: Please also check out <a href="%{terms_path}">the rules of the server</a> and <a href="%{policy_path}">our terms of service</a>.
subject: 'Mastodon: Confirmation instructions for %{instance}'
title: Verify email address
email_changed:
explanation: 'The email address for your account is being changed to:'
extra: If you did not change your email, it is likely that someone has gained access to your account. Please change your password immediately or contact the instance admin if you're locked out of your account.
extra: If you did not change your email, it is likely that someone has gained access to your account. Please change your password immediately or contact the server admin if you're locked out of your account.
subject: 'Mastodon: Email changed'
title: New email address
password_change:
explanation: The password for your account has been changed.
extra: If you did not change your password, it is likely that someone has gained access to your account. Please change your password immediately or contact the instance admin if you're locked out of your account.
extra: If you did not change your password, it is likely that someone has gained access to your account. Please change your password immediately or contact the server admin if you're locked out of your account.
subject: 'Mastodon: Password changed'
title: Password changed
reconfirmation_instructions:

View File

@ -20,17 +20,17 @@ ja:
action: メールアドレスの確認
action_with_app: 確認し %{app} に戻る
explanation: このメールアドレスで%{host}にアカウントを作成しました。有効にするまであと一歩です。もし心当たりがない場合、申し訳ありませんがこのメールを無視してください。
extra_html: また <a href="%{terms_path}">インスタンスのルール</a> と <a href="%{policy_path}">利用規約</a> もお読みください。
extra_html: また <a href="%{terms_path}">サーバーのルール</a> と <a href="%{policy_path}">利用規約</a> もお読みください。
subject: 'Mastodon: メールアドレスの確認 %{instance}'
title: メールアドレスの確認
email_changed:
explanation: 'アカウントのメールアドレスは以下のように変更されます:'
extra: メールアドレスの変更を行っていない場合、他の誰かがあなたのアカウントにアクセスした可能性があります。すぐにパスワードを変更するか、アカウントがロックされている場合はインスタンス管理者に連絡してください。
extra: メールアドレスの変更を行っていない場合、他の誰かがあなたのアカウントにアクセスした可能性があります。すぐにパスワードを変更するか、アカウントがロックされている場合はサーバー管理者に連絡してください。
subject: 'Mastodon: メールアドレスの変更'
title: 新しいメールアドレス
password_change:
explanation: パスワードが変更されました。
extra: パスワードの変更を行っていない場合、他の誰かがあなたのアカウントにアクセスした可能性があります。すぐにパスワードを変更するか、アカウントがロックされている場合はインスタンス管理者に連絡してください。
extra: パスワードの変更を行っていない場合、他の誰かがあなたのアカウントにアクセスした可能性があります。すぐにパスワードを変更するか、アカウントがロックされている場合はサーバー管理者に連絡してください。
subject: 'Mastodon: パスワードが変更されました'
title: パスワードの変更
reconfirmation_instructions:

View File

@ -7,7 +7,7 @@ en:
administered_by: 'Administered by:'
api: API
apps: Mobile apps
closed_registrations: Registrations are currently closed on this instance. However! You can find a different instance to make an account on and get access to the very same network from there.
closed_registrations: Registrations are currently closed on this server. However! You can find a different server to make an account on and get access to the very same network from there.
contact: Contact
contact_missing: Not set
contact_unavailable: N/A
@ -27,7 +27,7 @@ en:
generic_description: "%{domain} is one server in the network"
hosted_on: Mastodon hosted on %{domain}
learn_more: Learn more
other_instances: Instance list
other_instances: Server list
privacy_policy: Privacy policy
source_code: Source code
status_count_after:
@ -386,7 +386,7 @@ en:
desc_html: Modify the look with CSS loaded on every page
title: Custom CSS
hero:
desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to instance thumbnail
desc_html: Displayed on the frontpage. At least 600x100px recommended. When not set, falls back to server thumbnail
title: Hero image
hide_followers_count:
desc_html: Do not show followers count on user profiles
@ -395,8 +395,8 @@ en:
desc_html: Displayed on multiple pages. At least 293×205px recommended. When not set, falls back to default mascot
title: Mascot image
peers_api_enabled:
desc_html: Domain names this instance has encountered in the fediverse
title: Publish list of discovered instances
desc_html: Domain names this server has encountered in the fediverse
title: Publish list of discovered servers
preview_sensitive_media:
desc_html: Link previews on other websites will display a thumbnail even if the media is marked as sensitive
title: Show sensitive media in OpenGraph previews
@ -424,20 +424,20 @@ en:
title: Show staff badge
site_description:
desc_html: Introductory paragraph on the frontpage. Describe what makes this Mastodon server special and anything else important. You can use HTML tags, in particular <code>&lt;a&gt;</code> and <code>&lt;em&gt;</code>.
title: Instance description
title: Server description
site_description_extended:
desc_html: A good place for your code of conduct, rules, guidelines and other things that set your instance apart. You can use HTML tags
desc_html: A good place for your code of conduct, rules, guidelines and other things that set your server apart. You can use HTML tags
title: Custom extended information
site_short_description:
desc_html: Displayed in sidebar and meta tags. Describe what Mastodon is and what makes this server special in a single paragraph. If empty, defaults to instance description.
title: Short instance description
desc_html: Displayed in sidebar and meta tags. Describe what Mastodon is and what makes this server special in a single paragraph. If empty, defaults to server description.
title: Short server description
site_terms:
desc_html: You can write your own privacy policy, terms of service or other legalese. You can use HTML tags
title: Custom terms of service
site_title: Instance name
site_title: Server name
thumbnail:
desc_html: Used for previews via OpenGraph and API. 1200x630px recommended
title: Instance thumbnail
title: Server thumbnail
timeline_preview:
desc_html: Display public timeline on landing page
title: Timeline preview
@ -498,7 +498,7 @@ en:
warning: Be very careful with this data. Never share it with anyone!
your_token: Your access token
auth:
agreement_html: By clicking "Sign up" below you agree to follow <a href="%{rules_path}">the rules of the instance</a> and <a href="%{terms_path}">our terms of service</a>.
agreement_html: By clicking "Sign up" below you agree to follow <a href="%{rules_path}">the rules of the server</a> and <a href="%{terms_path}">our terms of service</a>.
change_password: Password
confirm_email: Confirm email
delete_account: Delete account
@ -552,7 +552,7 @@ en:
description_html: This will <strong>permanently, irreversibly</strong> remove content from your account and deactivate it. Your username will remain reserved to prevent future impersonations.
proceed: Delete account
success_msg: Your account was successfully deleted
warning_html: Only deletion of content from this particular instance is guaranteed. Content that has been widely shared is likely to leave traces. Offline servers and servers that have unsubscribed from your updates will not update their databases.
warning_html: Only deletion of content from this particular server is guaranteed. Content that has been widely shared is likely to leave traces. Offline servers and servers that have unsubscribed from your updates will not update their databases.
warning_title: Disseminated content availability
directories:
directory: Profile directory
@ -591,6 +591,10 @@ en:
lists: Lists
mutes: You mute
storage: Media storage
featured_tags:
add_new: Add new
errors:
limit: You have already featured the maximum amount of hashtags
filters:
contexts:
home: Home timeline
@ -609,7 +613,7 @@ en:
title: Add new filter
followers:
domain: Domain
explanation_html: If you want to ensure the privacy of your statuses, you must be aware of who is following you. <strong>Your private statuses are delivered to all instances where you have followers</strong>. You may wish to review them, and remove followers if you do not trust your privacy to be respected by the staff or software of those instances.
explanation_html: If you want to ensure the privacy of your statuses, you must be aware of who is following you. <strong>Your private statuses are delivered to all servers where you have followers</strong>. You may wish to review them, and remove followers if you do not trust your privacy to be respected by the staff or software of those servers.
followers_count: Number of followers
lock_link: Lock your account
purge: Remove from followers
@ -632,10 +636,16 @@ en:
one: Something isn't quite right yet! Please review the error below
other: Something isn't quite right yet! Please review %{count} errors below
imports:
preface: You can import data that you have exported from another instance, such as a list of the people you are following or blocking.
modes:
merge: Merge
merge_long: Keep existing records and add new ones
overwrite: Overwrite
overwrite_long: Replace current records with the new ones
preface: You can import data that you have exported from another server, such as a list of the people you are following or blocking.
success: Your data was successfully uploaded and will now be processed in due time
types:
blocking: Blocking list
domain_blocking: Domain blocking list
following: Following list
muting: Muting list
upload: Upload
@ -657,7 +667,7 @@ en:
one: 1 use
other: "%{count} uses"
max_uses_prompt: No limit
prompt: Generate and share links with others to grant access to this instance
prompt: Generate and share links with others to grant access to this server
table:
expires_at: Expires
uses: Uses
@ -805,6 +815,7 @@ en:
development: Development
edit_profile: Edit profile
export: Data export
featured_tags: Featured hashtags
flavours: Flavours
followers: Authorized followers
import: Import
@ -985,7 +996,7 @@ en:
final_action: Start posting
final_step: 'Start posting! Even without followers your public messages may be seen by others, for example on the local timeline and in hashtags. You may want to introduce yourself on the #introductions hashtag.'
full_handle: Your full handle
full_handle_hint: This is what you would tell your friends so they can message or follow you from another instance.
full_handle_hint: This is what you would tell your friends so they can message or follow you from another server.
review_preferences_action: Change preferences
review_preferences_step: Make sure to set your preferences, such as which emails you'd like to receive, or what privacy level youd like your posts to default to. If you dont have motion sickness, you could choose to enable GIF autoplay.
subject: Welcome to Mastodon

View File

@ -7,7 +7,7 @@ ja:
administered_by: '管理者:'
api: API
apps: アプリ
closed_registrations: 現在このインスタンスでの新規登録は受け付けていません。しかし、他のインスタンスにアカウントを作成しても全く同じネットワークに参加することができます。
closed_registrations: 現在このサーバーでの新規登録は受け付けていません。しかし、他のサーバーにアカウントを作成しても全く同じネットワークに参加することができます。
contact: 連絡先
contact_missing: 未設定
contact_unavailable: N/A
@ -24,10 +24,10 @@ ja:
real_conversation_title: 本当のコミュニケーションのために
within_reach_body: デベロッパーフレンドリーな API により実現された、iOS や Android、その他様々なプラットフォームのためのアプリでどこでも友人とやりとりできます。
within_reach_title: いつでも身近に
generic_description: "%{domain} は、Mastodon インスタンスの一つです"
generic_description: "%{domain} は、Mastodon サーバーの一つです"
hosted_on: Mastodon hosted on %{domain}
learn_more: もっと詳しく
other_instances: 他のインスタンス
other_instances: 他のサーバー
privacy_policy: プライバシーポリシー
source_code: ソースコード
status_count_after:
@ -310,7 +310,7 @@ ja:
all: すべて
limited: 制限あり
title: モデレーション
title: 既知のインスタンス
title: 既知のサーバー
total_blocked_by_us: ブロック合計
total_followed_by_them: 被フォロー合計
total_followed_by_us: フォロー合計
@ -392,8 +392,8 @@ ja:
desc_html: 複数のページに表示されます。サイズは293x205px以上推奨です。未設定の場合、標準のマスコットが使用されます
title: マスコットイメージ
peers_api_enabled:
desc_html: 連合内でこのインスタンスが遭遇したドメインの名前
title: 接続しているインスタンスのリストを公開する
desc_html: 連合内でこのサーバーが遭遇したドメインの名前
title: 接続しているサーバーのリストを公開する
preview_sensitive_media:
desc_html: 他のウェブサイトにリンクを貼った際、メディアが閲覧注意としてマークされていてもサムネイルが表示されます
title: OpenGraphによるプレビューで閲覧注意のメディアも表示する
@ -420,21 +420,21 @@ ja:
desc_html: ユーザーページにスタッフのバッジを表示します
title: スタッフバッジを表示する
site_description:
desc_html: フロントページへの表示に使用される紹介文です。このMastodonインスタンスを特徴付けることやその他重要なことを記述してください。HTMLタグ、特に<code>&lt;a&gt;</code> と <code>&lt;em&gt;</code>が使えます。
title: インスタンスの説明
desc_html: フロントページへの表示に使用される紹介文です。このMastodonサーバーを特徴付けることやその他重要なことを記述してください。HTMLタグ、特に<code>&lt;a&gt;</code> と <code>&lt;em&gt;</code>が使えます。
title: サーバーの説明
site_description_extended:
desc_html: あなたのインスタンスにおける行動規範やルール、ガイドライン、そのほかの記述をする際に最適な場所です。HTMLタグが使えます
desc_html: あなたのサーバーにおける行動規範やルール、ガイドライン、そのほかの記述をする際に最適な場所です。HTMLタグが使えます
title: カスタム詳細説明
site_short_description:
desc_html: サイドバーと meta タグに表示されます。Mastodon とは何か、そしてこのサーバーの特別な何かを1段落で記述してください。空欄の場合、インスタンスの説明が使用されます。
title: 短いインスタンスの説明
desc_html: サイドバーと meta タグに表示されます。Mastodon とは何か、そしてこのサーバーの特別な何かを1段落で記述してください。空欄の場合、サーバーの説明が使用されます。
title: 短いサーバーの説明
site_terms:
desc_html: あなたは独自のプライバシーポリシーや利用規約、そのほかの法的根拠を書くことができます。HTMLタグが使えます
title: カスタム利用規約
site_title: インスタンスの名前
site_title: サーバーの名前
thumbnail:
desc_html: OpenGraphとAPIによるプレビューに使用されます。サイズは1200×630px推奨です
title: インスタンスのサムネイル
title: サーバーのサムネイル
timeline_preview:
desc_html: ランディングページに公開タイムラインを表示します
title: タイムラインプレビュー
@ -495,7 +495,7 @@ ja:
warning: このデータは気をつけて取り扱ってください。他の人と共有しないでください!
your_token: アクセストークン
auth:
agreement_html: 登録するをクリックすると <a href="%{rules_path}">インスタンスのルール</a> と <a href="%{terms_path}">プライバシーポリシー</a> に従うことに同意したことになります。
agreement_html: 登録するをクリックすると <a href="%{rules_path}">サーバーのルール</a> と <a href="%{terms_path}">プライバシーポリシー</a> に従うことに同意したことになります。
change_password: パスワード
confirm_email: メールアドレスの確認
delete_account: アカウントの削除
@ -513,7 +513,7 @@ ja:
cas: CAS
saml: SAML
register: 登録する
register_elsewhere: 他のインスタンスで新規登録
register_elsewhere: 他のサーバーで新規登録
resend_confirmation: 確認メールを再送する
reset_password: パスワードを再発行
security: セキュリティ
@ -549,7 +549,7 @@ ja:
description_html: あなたのアカウントに含まれるコンテンツは全て削除され、アカウントは無効化されます。これは恒久的なもので、<strong>取り消すことはできません</strong>。なりすましを防ぐために、同じユーザー名で再度登録することはできなくなります。
proceed: アカウントを削除する
success_msg: アカウントは正常に削除されました
warning_html: 削除が保証されるのはこのインスタンス上のコンテンツのみです。他のインスタンス等、外部に広く共有されたコンテンツについては痕跡が残ることがあります。また、現在接続できないサーバーや、あなたの更新を受け取らなくなったサーバーに対しては、削除は反映されません。
warning_html: 削除が保証されるのはこのサーバー上のコンテンツのみです。他のサーバー等、外部に広く共有されたコンテンツについては痕跡が残ることがあります。また、現在接続できないサーバーや、あなたの更新を受け取らなくなったサーバーに対しては、削除は反映されません。
warning_title: 共有されたコンテンツについて
directories:
directory: ディレクトリ
@ -588,6 +588,10 @@ ja:
lists: リスト
mutes: ミュート
storage: メディア
featured_tags:
add_new: 追加
errors:
limit: 注目のハッシュタグの上限に達しました
filters:
contexts:
home: ホームタイムライン
@ -606,7 +610,7 @@ ja:
title: 新規フィルターを追加
followers:
domain: ドメイン
explanation_html: あなたの投稿のプライバシーを確保したい場合、誰があなたをフォローしているのかを把握している必要があります。 <strong>プライベート投稿は、あなたのフォロワーがいる全てのインスタンスに配信されます</strong>。 フォロワーのインスタンスの管理者やソフトウェアがあなたのプライバシーを尊重してくれるかどうか怪しい場合は、そのフォロワーを削除した方がよいかもしれません。
explanation_html: あなたの投稿のプライバシーを確保したい場合、誰があなたをフォローしているのかを把握している必要があります。 <strong>プライベート投稿は、あなたのフォロワーがいる全てのサーバーに配信されます</strong>。 フォロワーのサーバーの管理者やソフトウェアがあなたのプライバシーを尊重してくれるかどうか怪しい場合は、そのフォロワーを削除した方がよいかもしれません。
followers_count: フォロワー数
lock_link: 承認制アカウントにする
purge: フォロワーから削除する
@ -629,10 +633,16 @@ ja:
one: エラーが発生しました! 以下のエラーを確認してください
other: エラーが発生しました! 以下の%{count}個のエラーを確認してください
imports:
preface: 他のインスタンスでエクスポートされたファイルから、フォロー/ブロックした情報をこのインスタンス上のアカウントにインポートできます。
modes:
merge: 統合
merge_long: 現在のレコードを保持したまま新しいものを追加します
overwrite: 上書き
overwrite_long: 現在のレコードを新しいもので置き換えます
preface: 他のサーバーでエクスポートされたファイルから、フォロー/ブロックした情報をこのサーバー上のアカウントにインポートできます。
success: ファイルは正常にアップロードされ、現在処理中です。しばらくしてから確認してください
types:
blocking: ブロックしたアカウントリスト
domain_blocking: 非表示にしたドメインリスト
following: フォロー中のアカウントリスト
muting: ミュートしたアカウントリスト
upload: アップロード
@ -654,7 +664,7 @@ ja:
one: '1'
other: "%{count}"
max_uses_prompt: 無制限
prompt: リンクを生成・共有してこのインスタンスへの新規登録を受け付けることができます
prompt: リンクを生成・共有してこのサーバーへの新規登録を受け付けることができます
table:
expires_at: 有効期限
uses: 使用
@ -801,8 +811,9 @@ ja:
development: 開発
edit_profile: プロフィールを編集
export: データのエクスポート
featured_tags: 注目のハッシュタグ
flavours: フレーバー
followers: 信頼済みのインスタンス
followers: 信頼済みのサーバー
import: データのインポート
migrate: アカウントの引っ越し
notifications: 通知
@ -980,13 +991,13 @@ ja:
final_action: 始めましょう
final_step: 'さあ始めましょう! たとえフォロワーがいなくても、あなたの公開した投稿はローカルタイムラインやハッシュタグなどで誰かの目に止まるかもしれません。自己紹介をしたい時は #introductions ハッシュタグを使うといいかもしれません。'
full_handle: あなたの正式なユーザー名
full_handle_hint: これは別のインスタンスからフォローしてもらったりメッセージのやり取りをする際に、友達に伝えるといいでしょう。
full_handle_hint: これは別のサーバーからフォローしてもらったりメッセージのやり取りをする際に、友達に伝えるといいでしょう。
review_preferences_action: 設定の変更
review_preferences_step: 受け取りたいメールや投稿の公開範囲などの設定を必ず行ってください。不快でないならアニメーション GIF の自動再生を有効にすることもできます。
subject: Mastodon へようこそ
tip_federated_timeline: 連合タイムラインは Mastodon ネットワークの流れを見られるものです。ただしあなたと同じインスタンスの人がフォローしている人だけが含まれるので、それが全てではありません。
tip_following: 標準では自動でインスタンスの管理者をフォローしています。もっと興味のある人たちを見つけるには、ローカルタイムラインと連合タイムラインを確認してください。
tip_local_timeline: ローカルタイムラインは %{instance} にいる人々の流れを見られるものです。彼らはあなたと同じインスタンスにいる隣人のようなものです!
tip_federated_timeline: 連合タイムラインは Mastodon ネットワークの流れを見られるものです。ただしあなたと同じサーバーの人がフォローしている人だけが含まれるので、それが全てではありません。
tip_following: 標準では自動でサーバーの管理者をフォローしています。もっと興味のある人たちを見つけるには、ローカルタイムラインと連合タイムラインを確認してください。
tip_local_timeline: ローカルタイムラインは %{instance} にいる人々の流れを見られるものです。彼らはあなたと同じサーバーにいる隣人のようなものです!
tip_mobile_webapp: もしモバイル端末のブラウザで Mastodon をホーム画面に追加できる場合、プッシュ通知を受け取ることができます。それはまるでネイティブアプリのように動作します!
tips: 豆知識
title: ようこそ、%{name}

View File

@ -602,6 +602,10 @@ pl:
lists: Listy
mutes: Wyciszeni
storage: Urządzenie przechowujące dane
featured_tags:
add_new: Dodaj nowy
errors:
limit: Już przekroczyłeś(-aś) maksymalną liczbę wyróżnionych hashtagów
filters:
contexts:
home: Strona główna
@ -647,10 +651,16 @@ pl:
one: Coś jest wciąż nie tak! Przyjrzyj się poniższemu błędowi
other: Coś jest wciąż nie tak! Przejrzyj poniższe błędy (%{count})
imports:
modes:
merge: Połącz
merge_long: Zachowaj obecne wpisy i dodaj nowe
overwrite: Nadpisz
overwrite_long: Zastąp obecne wpisy nowymi
preface: Możesz zaimportować pewne dane (np. lista kont, które śledzisz lub blokujesz) do swojego konta na tym serwerze, korzystając z danych wyeksportowanych z innego serwera.
success: Twoje dane zostały załadowane i zostaną niebawem przetworzone
types:
blocking: Lista blokowanych
domain_blocking: Lista zablokowanych domen
following: Lista śledzonych
muting: Lista wyciszonych
upload: Załaduj
@ -826,6 +836,7 @@ pl:
development: Tworzenie aplikacji
edit_profile: Edytuj profil
export: Eksportowanie danych
featured_tags: Wyróżnione hashtagi
flavours: Odmiany
followers: Autoryzowani śledzący
import: Importowanie danych

View File

@ -37,8 +37,10 @@ en:
setting_skin: Reskins the selected Mastodon flavour
username: Your username will be unique on %{domain}
whole_word: When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word
featured_tag:
name: 'You might want to use one of these:'
imports:
data: CSV file exported from another Mastodon instance
data: CSV file exported from another Mastodon server
sessions:
otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:'
user:
@ -112,6 +114,8 @@ en:
username: Username
username_or_email: Username or Email
whole_word: Whole word
featured_tag:
name: Hashtag
interactions:
must_be_follower: Block notifications from non-followers
must_be_following: Block notifications from people you don't follow

View File

@ -33,11 +33,14 @@ ja:
setting_display_media_show_all: 閲覧注意としてマークされたメディアも常に表示する
setting_hide_network: フォローとフォロワーの情報がプロフィールページで見られないようにします
setting_noindex: 公開プロフィールおよび各投稿ページに影響します
setting_show_application: トゥートするのに使用したアプリがトゥートの詳細ビューに表示されるようになります
setting_theme: ログインしている全てのデバイスで適用されるデザインです。
username: あなたのユーザー名は %{domain} の中で重複していない必要があります
whole_word: キーワードまたはフレーズが英数字のみの場合、単語全体と一致する場合のみ適用されるようになります
featured_tag:
name: 'これらを使うといいかもしれません:'
imports:
data: 他の Mastodon インスタンスからエクスポートしたCSVファイルを選択して下さい
data: 他の Mastodon サーバーからエクスポートしたCSVファイルを選択して下さい
sessions:
otp: '携帯電話のアプリで生成された二段階認証コードを入力するか、リカバリーコードを使用してください:'
user:
@ -101,6 +104,7 @@ ja:
setting_hide_network: 繋がりを隠す
setting_noindex: 検索エンジンによるインデックスを拒否する
setting_reduce_motion: アニメーションの動きを減らす
setting_show_application: トゥートの送信に使用したアプリを開示する
setting_system_font_ui: システムのデフォルトフォントを使う
setting_theme: サイトテーマ
setting_unfollow_modal: フォローを解除する前に確認ダイアログを表示する
@ -109,6 +113,8 @@ ja:
username: ユーザー名
username_or_email: ユーザー名またはメールアドレス
whole_word: 単語全体にマッチ
featured_tag:
name: ハッシュタグ
interactions:
must_be_follower: フォロワー以外からの通知をブロック
must_be_following: フォローしていないユーザーからの通知をブロック

View File

@ -33,9 +33,12 @@ pl:
setting_display_media_show_all: Zawsze pokazuj zawartość multimedialną jako wrażliwą
setting_hide_network: Informacje o tym, kto Cię śledzi i kogo śledzisz nie będą widoczne
setting_noindex: Wpływa na widoczność strony profilu i Twoich wpisów
setting_show_application: W informacjach o wpisie będzie widoczna informacja o aplikacji, z której został wysłany
setting_skin: Zmienia wygląd używanej odmiany Mastodona
username: Twoja nazwa użytkownika będzie niepowtarzalna na %{domain}
whole_word: Jeśli słowo lub fraza składa się jedynie z liter lub cyfr, filtr będzie zastosowany tylko do pełnych wystąpień
featured_tag:
name: 'Sugerujemy użycie jednego z następujących:'
imports:
data: Plik CSV wyeksportowany z innej instancji Mastodona
sessions:
@ -102,6 +105,7 @@ pl:
setting_hide_network: Ukryj swoją sieć
setting_noindex: Nie indeksuj mojego profilu w wyszukiwarkach internetowych
setting_reduce_motion: Ogranicz ruch w animacjach
setting_show_application: Informuj o aplikacji z której wysłano wpisy
setting_skin: Motyw
setting_system_font_ui: Używaj domyślnej czcionki systemu
setting_unfollow_modal: Pytaj o potwierdzenie przed cofnięciem śledzenia
@ -110,6 +114,8 @@ pl:
username: Nazwa użytkownika
username_or_email: Nazwa użytkownika lub adres e-mail
whole_word: Całe słowo
featured_tag:
name: Hashtag
interactions:
must_be_follower: Nie wyświetlaj powiadomień od osób, które Cię nie śledzą
must_be_following: Nie wyświetlaj powiadomień od osób, których nie śledzisz

View File

@ -6,6 +6,7 @@ SimpleNavigation::Configuration.run do |navigation|
primary.item :settings, safe_join([fa_icon('cog fw'), t('settings.settings')]), settings_profile_url do |settings|
settings.item :profile, safe_join([fa_icon('user fw'), t('settings.edit_profile')]), settings_profile_url, highlights_on: %r{/settings/profile|/settings/migration}
settings.item :featured_tags, safe_join([fa_icon('hashtag fw'), t('settings.featured_tags')]), settings_featured_tags_url
settings.item :preferences, safe_join([fa_icon('sliders fw'), t('settings.preferences')]), settings_preferences_url
settings.item :notifications, safe_join([fa_icon('bell fw'), t('settings.notifications')]), settings_notifications_url
settings.item :password, safe_join([fa_icon('lock fw'), t('auth.security')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete}

View File

@ -74,6 +74,7 @@ Rails.application.routes.draw do
get '/@:username', to: 'accounts#show', as: :short_account
get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies
get '/@:username/media', to: 'accounts#show', as: :short_account_media
get '/@:username/tagged/:tag', to: 'accounts#show', as: :short_account_tag
get '/@:account_username/:id', to: 'statuses#show', as: :short_account_status
get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_short_account_status
@ -119,6 +120,7 @@ Rails.application.routes.draw do
resource :migration, only: [:show, :update]
resources :sessions, only: [:destroy]
resources :featured_tags, only: [:index, :create, :destroy]
end
resources :media, only: [:show] do

View File

@ -7,6 +7,7 @@ class CreateAccountModerationNotes < ActiveRecord::Migration[5.1]
t.timestamps
end
add_foreign_key :account_moderation_notes, :accounts, column: :target_account_id
end
end

View File

@ -0,0 +1,17 @@
require Rails.root.join('lib', 'mastodon', 'migration_helpers')
class AddOverwriteToImports < ActiveRecord::Migration[5.2]
include Mastodon::MigrationHelpers
disable_ddl_transaction!
def up
safety_assured do
add_column_with_default :imports, :overwrite, :boolean, default: false, allow_null: false
end
end
def down
remove_column :imports, :overwrite, :boolean
end
end

View File

@ -0,0 +1,12 @@
class CreateFeaturedTags < ActiveRecord::Migration[5.2]
def change
create_table :featured_tags do |t|
t.references :account, foreign_key: { on_delete: :cascade }
t.references :tag, foreign_key: { on_delete: :cascade }
t.bigint :statuses_count, default: 0, null: false
t.datetime :last_status_at
t.timestamps
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_01_17_114553) do
ActiveRecord::Schema.define(version: 2019_02_03_180359) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -260,6 +260,17 @@ ActiveRecord::Schema.define(version: 2019_01_17_114553) do
t.index ["status_id"], name: "index_favourites_on_status_id"
end
create_table "featured_tags", force: :cascade do |t|
t.bigint "account_id"
t.bigint "tag_id"
t.bigint "statuses_count", default: 0, null: false
t.datetime "last_status_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_featured_tags_on_account_id"
t.index ["tag_id"], name: "index_featured_tags_on_tag_id"
end
create_table "follow_requests", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
@ -300,6 +311,7 @@ ActiveRecord::Schema.define(version: 2019_01_17_114553) do
t.integer "data_file_size"
t.datetime "data_updated_at"
t.bigint "account_id", null: false
t.boolean "overwrite", default: false, null: false
end
create_table "invites", force: :cascade do |t|
@ -721,6 +733,8 @@ ActiveRecord::Schema.define(version: 2019_01_17_114553) do
add_foreign_key "custom_filters", "accounts", on_delete: :cascade
add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", on_delete: :cascade
add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
add_foreign_key "featured_tags", "accounts", on_delete: :cascade
add_foreign_key "featured_tags", "tags", on_delete: :cascade
add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade
add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade

View File

@ -0,0 +1,6 @@
Fabricator(:featured_tag) do
account
tag
statuses_count 1_337
last_status_at Time.now.utc
end

View File

@ -74,10 +74,36 @@ RSpec.describe Formatter do
end
context 'given a URL with a query string' do
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' }
context 'with escaped unicode character' do
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' }
it 'matches the full URL' do
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;q=autolink"'
it 'matches the full URL' do
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;q=autolink"'
end
end
context 'with unicode character' do
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓&q=autolink' }
it 'matches the full URL' do
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓&amp;q=autolink"'
end
end
context 'with unicode character at the end' do
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=✓' }
it 'matches the full URL' do
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=✓"'
end
end
context 'with escaped and not escaped unicode characters' do
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&utf81=✓&q=autolink' }
it 'preserves escaped unicode characters' do
is_expected.to include 'href="https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&amp;utf81=✓&amp;q=autolink"'
end
end
end
@ -89,6 +115,22 @@ RSpec.describe Formatter do
end
end
context 'given a URL in quotation marks' do
let(:text) { '"https://example.com/"' }
it 'does not match the quotation marks' do
is_expected.to include 'href="https://example.com/"'
end
end
context 'given a URL in angle brackets' do
let(:text) { '<https://example.com/>' }
it 'does not match the angle brackets' do
is_expected.to include 'href="https://example.com/"'
end
end
context 'given a URL with Japanese path string' do
let(:text) { 'https://ja.wikipedia.org/wiki/日本' }
@ -105,6 +147,22 @@ RSpec.describe Formatter do
end
end
context 'given a URL with a full-width space' do
let(:text) { 'https://example.com/ abc123' }
it 'does not match the full-width space' do
is_expected.to include 'href="https://example.com/"'
end
end
context 'given a URL in Japanese quotation marks' do
let(:text) { '「[https://example.org/」' }
it 'does not match the quotation marks' do
is_expected.to include 'href="https://example.org/"'
end
end
context 'given a URL with Simplified Chinese path string' do
let(:text) { 'https://baike.baidu.com/item/中华人民共和国' }
@ -124,7 +182,11 @@ RSpec.describe Formatter do
context 'given a URL containing unsafe code (XSS attack, visible part)' do
let(:text) { %q{http://example.com/b<del>b</del>} }
it 'escapes the HTML in the URL' do
it 'does not include the HTML in the URL' do
is_expected.to include '"http://example.com/b"'
end
it 'escapes the HTML' do
is_expected.to include '&lt;del&gt;b&lt;/del&gt;'
end
end
@ -132,7 +194,11 @@ RSpec.describe Formatter do
context 'given a URL containing unsafe code (XSS attack, invisible part)' do
let(:text) { %q{http://example.com/blahblahblahblah/a<script>alert("Hello")</script>} }
it 'escapes the HTML in the URL' do
it 'does not include the HTML in the URL' do
is_expected.to include '"http://example.com/blahblahblahblah/a"'
end
it 'escapes the HTML' do
is_expected.to include '&lt;script&gt;alert(&quot;Hello&quot;)&lt;/script&gt;'
end
end
@ -168,6 +234,14 @@ RSpec.describe Formatter do
is_expected.to include '/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a>'
end
end
context 'given text containing a hashtag with Unicode chars' do
let(:text) { '#hashtagタグ' }
it 'creates a hashtag link' do
is_expected.to include '/tags/hashtag%E3%82%BF%E3%82%B0" class="mention hashtag" rel="tag">#<span>hashtagタグ</span></a>'
end
end
end
describe '#format_spoiler' do

View File

@ -244,9 +244,9 @@ describe AccountInteractions do
end
describe '#block_domain!' do
let(:domain_block) { Fabricate(:domain_block) }
let(:domain) { 'example.com' }
subject { account.block_domain!(domain_block) }
subject { account.block_domain!(domain) }
it 'creates and returns AccountDomainBlock' do
expect do

Some files were not shown because too many files have changed in this diff Show More