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

Merge upstream changes
main
ThibG 6 years ago committed by GitHub
commit a3ba28eb17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

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

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

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

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

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

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

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

@ -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_SHOW = 'ALERT_SHOW';
export const ALERT_DISMISS = 'ALERT_DISMISS'; export const ALERT_DISMISS = 'ALERT_DISMISS';
export const ALERT_CLEAR = 'ALERT_CLEAR'; 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 { return {
type: ALERT_SHOW, type: ALERT_SHOW,
title, title,
message, 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();
}
}

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

@ -1,4 +1,5 @@
import api from 'flavours/glitch/util/api'; import api from 'flavours/glitch/util/api';
import { showAlertForError } from './alerts';
export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST'; export const LIST_FETCH_REQUEST = 'LIST_FETCH_REQUEST';
export const LIST_FETCH_SUCCESS = 'LIST_FETCH_SUCCESS'; 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 }) 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) => ({ export const fetchListSuggestionsReady = (query, accounts) => ({

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

@ -1,5 +1,6 @@
import api from 'flavours/glitch/util/api'; import api from 'flavours/glitch/util/api';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { showAlertForError } from './alerts';
export const SETTING_CHANGE = 'SETTING_CHANGE'; export const SETTING_CHANGE = 'SETTING_CHANGE';
export const SETTING_SAVE = 'SETTING_SAVE'; 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(); 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 }); }, 5000, { trailing: true });
export function saveSettings() { export function saveSettings() {

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

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

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

@ -166,7 +166,7 @@ export default class GettingStarted extends ImmutablePureComponent {
<div className='getting-started__footer'> <div className='getting-started__footer'>
<ul> <ul>
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} {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='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='/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> <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a></li>

@ -127,7 +127,7 @@ export default class PublicTimeline extends React.PureComponent {
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`public_timeline-${columnId}`} 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> </Column>
); );

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

@ -97,7 +97,7 @@ export default class ReportModal extends ImmutablePureComponent {
<div className='report-modal__container'> <div className='report-modal__container'>
<div className='report-modal__comment'> <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 <textarea
className='setting-text light' className='setting-text light'

@ -186,7 +186,7 @@ export default class UI extends React.Component {
this.setState({ draggingOver: false }); this.setState({ draggingOver: false });
this.dragTargets = []; 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)); this.props.dispatch(uploadCompose(e.dataTransfer.files));
} }
} }

@ -1,4 +1,4 @@
import { showAlert } from 'flavours/glitch/actions/alerts'; import { showAlertForError } from 'flavours/glitch/actions/alerts';
const defaultFailSuffix = 'FAIL'; const defaultFailSuffix = 'FAIL';
@ -8,21 +8,7 @@ export default function errorsMiddleware() {
const isFail = new RegExp(`${defaultFailSuffix}$`, 'g'); const isFail = new RegExp(`${defaultFailSuffix}$`, 'g');
if (action.type.match(isFail)) { if (action.type.match(isFail)) {
if (action.error.response) { dispatch(showAlertForError(action.error));
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.'));
}
} }
} }

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

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

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

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

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

@ -8,6 +8,8 @@ import resizeImage from '../utils/resize_image';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { updateTimeline } from './timelines'; import { updateTimeline } from './timelines';
import { showAlertForError } from './alerts'; import { showAlertForError } from './alerts';
import { showAlert } from './alerts';
import { defineMessages } from 'react-intl';
let cancelFetchComposeSuggestionsAccounts; 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_SUCCESS = 'COMPOSE_UPLOAD_UPDATE_SUCCESS';
export const COMPOSE_UPLOAD_CHANGE_FAIL = 'COMPOSE_UPLOAD_UPDATE_FAIL'; 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) { export function changeCompose(text) {
return { return {
type: COMPOSE_CHANGE, type: COMPOSE_CHANGE,
@ -184,21 +190,33 @@ export function submitComposeFail(error) {
export function uploadCompose(files) { export function uploadCompose(files) {
return function (dispatch, getState) { 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; return;
} }
dispatch(uploadComposeRequest()); dispatch(uploadComposeRequest());
resizeImage(files[0]).then(file => { for (const [i, f] of Array.from(files).entries()) {
if (media.size + i > 3) break;
resizeImage(f).then(file => {
const data = new FormData(); const data = new FormData();
data.append('file', file); data.append('file', file);
return api(getState).post('/api/v1/media', data, { return api(getState).post('/api/v1/media', data, {
onUploadProgress: ({ loaded, total }) => dispatch(uploadComposeProgress(loaded, total)), onUploadProgress: function({ loaded }){
progress[i] = loaded;
dispatch(uploadComposeProgress(progress.reduce((a, v) => a + v, 0), total));
},
}).then(({ data }) => dispatch(uploadComposeSuccess(data))); }).then(({ data }) => dispatch(uploadComposeSuccess(data)));
}).catch(error => dispatch(uploadComposeFail(error))); }).catch(error => dispatch(uploadComposeFail(error)));
}; };
};
}; };
export function changeUploadCompose(id, params) { export function changeUploadCompose(id, params) {

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

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

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

@ -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>} {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>} {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='/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='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='/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> <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>

@ -89,7 +89,7 @@ const FrameInteractions = ({ onNext }) => (
</div> </div>
<div className='introduction__action'> <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>
</div> </div>
); );

@ -124,7 +124,7 @@ class PublicTimeline extends React.PureComponent {
onLoadMore={this.handleLoadMore} onLoadMore={this.handleLoadMore}
trackScroll={!pinned} trackScroll={!pinned}
scrollKey={`public_timeline-${columnId}`} 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} shouldUpdateScroll={shouldUpdateScroll}
/> />
</Column> </Column>

@ -97,7 +97,7 @@ class ReportModal extends ImmutablePureComponent {
<div className='report-modal__container'> <div className='report-modal__container'>
<div className='report-modal__comment'> <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 <textarea
className='setting-text light' className='setting-text light'

@ -263,7 +263,7 @@ class UI extends React.PureComponent {
this.setState({ draggingOver: false }); this.setState({ draggingOver: false });
this.dragTargets = []; 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)); this.props.dispatch(uploadCompose(e.dataTransfer.files));
} }
} }

@ -12,6 +12,15 @@
], ],
"path": "app/javascript/mastodon/actions/alerts.json" "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": [ "descriptors": [
{ {
@ -1275,7 +1284,7 @@
"id": "getting_started.security" "id": "getting_started.security"
}, },
{ {
"defaultMessage": "About this instance", "defaultMessage": "About this server",
"id": "navigation_bar.info" "id": "navigation_bar.info"
}, },
{ {
@ -1448,7 +1457,7 @@
"id": "introduction.interactions.favourite.text" "id": "introduction.interactions.favourite.text"
}, },
{ {
"defaultMessage": "Finish tutorial!", "defaultMessage": "Finish toot-orial!",
"id": "introduction.interactions.action" "id": "introduction.interactions.action"
} }
], ],
@ -1828,7 +1837,7 @@
"id": "column.public" "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" "id": "empty_column.public"
} }
], ],
@ -2188,7 +2197,7 @@
"id": "report.target" "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" "id": "report.hint"
}, },
{ {

@ -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.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.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.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.authorize": "Authorize",
"follow_request.reject": "Reject", "follow_request.reject": "Reject",
"getting_started.developers": "Developers", "getting_started.developers": "Developers",
@ -228,7 +228,7 @@
"navigation_bar.favourites": "Favourites", "navigation_bar.favourites": "Favourites",
"navigation_bar.filters": "Muted words", "navigation_bar.filters": "Muted words",
"navigation_bar.follow_requests": "Follow requests", "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.keyboard_shortcuts": "Hotkeys",
"navigation_bar.lists": "Lists", "navigation_bar.lists": "Lists",
"navigation_bar.misc": "Misc", "navigation_bar.misc": "Misc",
@ -281,7 +281,7 @@
"reply_indicator.cancel": "Cancel", "reply_indicator.cancel": "Cancel",
"report.forward": "Forward to {target}", "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.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.placeholder": "Additional comments",
"report.submit": "Submit", "report.submit": "Submit",
"report.target": "Reporting {target}", "report.target": "Reporting {target}",
@ -347,6 +347,7 @@
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload", "upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)", "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.description": "Describe for the visually impaired",
"upload_form.focus": "Change preview", "upload_form.focus": "Change preview",
"upload_form.undo": "Delete", "upload_form.undo": "Delete",

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

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

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

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

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

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

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

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

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

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

@ -99,7 +99,7 @@ class Formatter
end end
def encode_and_link_urls(html, accounts = nil, options = {}) 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) if accounts.is_a?(Hash)
options = accounts options = accounts
@ -199,6 +199,53 @@ class Formatter
result.flatten.join result.flatten.join
end 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 = {}) def link_to_url(entity, options = {})
url = Addressable::URI.parse(entity[:url]) url = Addressable::URI.parse(entity[:url])
html_attrs = { target: '_blank', rel: 'nofollow noopener' } html_attrs = { target: '_blank', rel: 'nofollow noopener' }

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

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

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

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

@ -3,7 +3,7 @@
module AccountAvatar module AccountAvatar
extend ActiveSupport::Concern 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 LIMIT = 2.megabytes
class_methods do class_methods do

@ -3,7 +3,7 @@
module AccountHeader module AccountHeader
extend ActiveSupport::Concern 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 LIMIT = 2.megabytes
MAX_PIXELS = 750_000 # 1500x500px MAX_PIXELS = 750_000 # 1500x500px

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

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

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

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

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

@ -13,20 +13,30 @@
# data_file_size :integer # data_file_size :integer
# data_updated_at :datetime # data_updated_at :datetime
# account_id :bigint(8) not null # account_id :bigint(8) not null
# overwrite :boolean default(FALSE), not null
# #
class Import < ApplicationRecord 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 self.inheritance_column = false
belongs_to :account belongs_to :account
enum type: [:following, :blocking, :muting] enum type: [:following, :blocking, :muting, :domain_blocking]
validates :type, presence: true validates :type, presence: true
has_attached_file :data has_attached_file :data
validates_attachment_content_type :data, content_type: FILE_TYPES validates_attachment_content_type :data, content_type: FILE_TYPES
validates_attachment_presence :data validates_attachment_presence :data
def mode
overwrite? ? :overwrite : :merge
end
def mode=(str)
self.overwrite = str.to_sym == :overwrite
end
end end

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

@ -25,7 +25,7 @@
# #
class PreviewCard < ApplicationRecord 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 LIMIT = 1.megabytes
self.inheritance_column = false self.inheritance_column = false

@ -14,6 +14,7 @@ class Tag < ApplicationRecord
has_and_belongs_to_many :accounts has_and_belongs_to_many :accounts
has_and_belongs_to_many :sample_accounts, -> { searchable.discoverable.popular.limit(3) }, class_name: 'Account' 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 has_one :account_tag_stat, dependent: :destroy
HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_·][[:word:]_]*' 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 :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 :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, delegate :accounts_count,
:accounts_count=, :accounts_count=,

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

@ -52,6 +52,14 @@ class ManifestSerializer < ActiveModel::Serializer
end end
def share_target 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
end end

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

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

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class FollowService < BaseService class FollowService < BaseService
include Redisable
# Follow a remote user, notify remote user about the follow # Follow a remote user, notify remote user about the follow
# @param [Account] source_account From which to 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) # @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 follow
end end
def redis
Redis.current
end
def build_follow_request_xml(follow_request) def build_follow_request_xml(follow_request)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_request_salmon(follow_request)) OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.follow_request_salmon(follow_request))
end end

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

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

@ -3,11 +3,21 @@
class ProcessHashtagsService < BaseService class ProcessHashtagsService < BaseService
def call(status, tags = []) 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| tags.map { |str| str.mb_chars.downcase }.uniq(&:to_s).each do |name|
tag = Tag.where(name: name).first_or_create(name: name) tag = Tag.where(name: name).first_or_create(name: name)
status.tags << tag status.tags << tag
records << tag
TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility? TrendingTags.record_use!(tag, status.account, status.created_at) if status.public_visibility?
end 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
end end

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

@ -63,4 +63,17 @@
- @endorsed_accounts.each do |account| - @endorsed_accounts.each do |account|
= account_link_to 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' = render 'application/sidebar'

@ -3,7 +3,7 @@
= simple_form_for @user, url: admin_account_change_email_path(@account.id) do |f| = simple_form_for @user, url: admin_account_change_email_path(@account.id) do |f|
.fields-group .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 .fields-group
= f.input :unconfirmed_email, wrapper: :with_label, label: t('admin.accounts.change_email.new_email') = f.input :unconfirmed_email, wrapper: :with_label, label: t('admin.accounts.change_email.new_email')

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

@ -5,8 +5,11 @@
.field-group .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') = 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 .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') = 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 .actions
= f.button :button, t('imports.upload'), type: :submit = f.button :button, t('imports.upload'), type: :submit

@ -13,11 +13,17 @@ class Import::RelationshipWorker
case relationship case relationship
when 'follow' 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' when 'block'
BlockService.new.call(from_account, target_account) BlockService.new.call(from_account, target_account)
when 'unblock'
UnblockService.new.call(from_account, target_account)
when 'mute' when 'mute'
MuteService.new.call(from_account, target_account) MuteService.new.call(from_account, target_account)
when 'unmute'
UnmuteService.new.call(from_account, target_account)
end end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
true true

@ -1,44 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
require 'csv'
class ImportWorker class ImportWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options queue: 'pull', retry: false sidekiq_options queue: 'pull', retry: false
attr_reader :import
def perform(import_id) def perform(import_id)
@import = Import.find(import_id) import = Import.find(import_id)
ImportService.new.call(import)
Import::RelationshipWorker.push_bulk(import_rows) do |row| ensure
[@import.account_id, row.first, relationship_type] import&.destroy
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
end end
end end

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

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

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

@ -20,17 +20,17 @@ en:
action: Verify email address action: Verify email address
action_with_app: Confirm and return to %{app} 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. 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}' subject: 'Mastodon: Confirmation instructions for %{instance}'
title: Verify email address title: Verify email address
email_changed: email_changed:
explanation: 'The email address for your account is being changed to:' 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' subject: 'Mastodon: Email changed'
title: New email address title: New email address
password_change: password_change:
explanation: The password for your account has been changed. 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' subject: 'Mastodon: Password changed'
title: Password changed title: Password changed
reconfirmation_instructions: reconfirmation_instructions:

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

@ -7,7 +7,7 @@ en:
administered_by: 'Administered by:' administered_by: 'Administered by:'
api: API api: API
apps: Mobile apps 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: Contact
contact_missing: Not set contact_missing: Not set
contact_unavailable: N/A contact_unavailable: N/A
@ -27,7 +27,7 @@ en:
generic_description: "%{domain} is one server in the network" generic_description: "%{domain} is one server in the network"
hosted_on: Mastodon hosted on %{domain} hosted_on: Mastodon hosted on %{domain}
learn_more: Learn more learn_more: Learn more
other_instances: Instance list other_instances: Server list
privacy_policy: Privacy policy privacy_policy: Privacy policy
source_code: Source code source_code: Source code
status_count_after: status_count_after:
@ -386,7 +386,7 @@ en:
desc_html: Modify the look with CSS loaded on every page desc_html: Modify the look with CSS loaded on every page
title: Custom CSS title: Custom CSS
hero: 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 title: Hero image
hide_followers_count: hide_followers_count:
desc_html: Do not show followers count on user profiles 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 desc_html: Displayed on multiple pages. At least 293×205px recommended. When not set, falls back to default mascot
title: Mascot image title: Mascot image
peers_api_enabled: peers_api_enabled:
desc_html: Domain names this instance has encountered in the fediverse desc_html: Domain names this server has encountered in the fediverse
title: Publish list of discovered instances title: Publish list of discovered servers
preview_sensitive_media: preview_sensitive_media:
desc_html: Link previews on other websites will display a thumbnail even if the media is marked as sensitive 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 title: Show sensitive media in OpenGraph previews
@ -424,20 +424,20 @@ en:
title: Show staff badge title: Show staff badge
site_description: 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>. 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: 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 title: Custom extended information
site_short_description: 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. 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 instance description title: Short server description
site_terms: site_terms:
desc_html: You can write your own privacy policy, terms of service or other legalese. You can use HTML tags 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 title: Custom terms of service
site_title: Instance name site_title: Server name
thumbnail: thumbnail:
desc_html: Used for previews via OpenGraph and API. 1200x630px recommended desc_html: Used for previews via OpenGraph and API. 1200x630px recommended
title: Instance thumbnail title: Server thumbnail
timeline_preview: timeline_preview:
desc_html: Display public timeline on landing page desc_html: Display public timeline on landing page
title: Timeline preview title: Timeline preview
@ -498,7 +498,7 @@ en:
warning: Be very careful with this data. Never share it with anyone! warning: Be very careful with this data. Never share it with anyone!
your_token: Your access token your_token: Your access token
auth: 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 change_password: Password
confirm_email: Confirm email confirm_email: Confirm email
delete_account: Delete account 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. 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 proceed: Delete account
success_msg: Your account was successfully deleted 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 warning_title: Disseminated content availability
directories: directories:
directory: Profile directory directory: Profile directory
@ -591,6 +591,10 @@ en:
lists: Lists lists: Lists
mutes: You mute mutes: You mute
storage: Media storage storage: Media storage
featured_tags:
add_new: Add new
errors:
limit: You have already featured the maximum amount of hashtags
filters: filters:
contexts: contexts:
home: Home timeline home: Home timeline
@ -609,7 +613,7 @@ en:
title: Add new filter title: Add new filter
followers: followers:
domain: Domain 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 followers_count: Number of followers
lock_link: Lock your account lock_link: Lock your account
purge: Remove from followers purge: Remove from followers
@ -632,10 +636,16 @@ en:
one: Something isn't quite right yet! Please review the error below one: Something isn't quite right yet! Please review the error below
other: Something isn't quite right yet! Please review %{count} errors below other: Something isn't quite right yet! Please review %{count} errors below
imports: 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 success: Your data was successfully uploaded and will now be processed in due time
types: types:
blocking: Blocking list blocking: Blocking list
domain_blocking: Domain blocking list
following: Following list following: Following list
muting: Muting list muting: Muting list
upload: Upload upload: Upload
@ -657,7 +667,7 @@ en:
one: 1 use one: 1 use
other: "%{count} uses" other: "%{count} uses"
max_uses_prompt: No limit 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: table:
expires_at: Expires expires_at: Expires
uses: Uses uses: Uses
@ -805,6 +815,7 @@ en:
development: Development development: Development
edit_profile: Edit profile edit_profile: Edit profile
export: Data export export: Data export
featured_tags: Featured hashtags
flavours: Flavours flavours: Flavours
followers: Authorized followers followers: Authorized followers
import: Import import: Import
@ -985,7 +996,7 @@ en:
final_action: Start posting 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.' 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: 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_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. 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 subject: Welcome to Mastodon

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

@ -602,6 +602,10 @@ pl:
lists: Listy lists: Listy
mutes: Wyciszeni mutes: Wyciszeni
storage: Urządzenie przechowujące dane 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: filters:
contexts: contexts:
home: Strona główna home: Strona główna
@ -647,10 +651,16 @@ pl:
one: Coś jest wciąż nie tak! Przyjrzyj się poniższemu błędowi one: Coś jest wciąż nie tak! Przyjrzyj się poniższemu błędowi
other: Coś jest wciąż nie tak! Przejrzyj poniższe błędy (%{count}) other: Coś jest wciąż nie tak! Przejrzyj poniższe błędy (%{count})
imports: 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. 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 success: Twoje dane zostały załadowane i zostaną niebawem przetworzone
types: types:
blocking: Lista blokowanych blocking: Lista blokowanych
domain_blocking: Lista zablokowanych domen
following: Lista śledzonych following: Lista śledzonych
muting: Lista wyciszonych muting: Lista wyciszonych
upload: Załaduj upload: Załaduj
@ -826,6 +836,7 @@ pl:
development: Tworzenie aplikacji development: Tworzenie aplikacji
edit_profile: Edytuj profil edit_profile: Edytuj profil
export: Eksportowanie danych export: Eksportowanie danych
featured_tags: Wyróżnione hashtagi
flavours: Odmiany flavours: Odmiany
followers: Autoryzowani śledzący followers: Autoryzowani śledzący
import: Importowanie danych import: Importowanie danych

@ -37,8 +37,10 @@ en:
setting_skin: Reskins the selected Mastodon flavour setting_skin: Reskins the selected Mastodon flavour
username: Your username will be unique on %{domain} 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 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: imports:
data: CSV file exported from another Mastodon instance data: CSV file exported from another Mastodon server
sessions: sessions:
otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:' otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:'
user: user:
@ -112,6 +114,8 @@ en:
username: Username username: Username
username_or_email: Username or Email username_or_email: Username or Email
whole_word: Whole word whole_word: Whole word
featured_tag:
name: Hashtag
interactions: interactions:
must_be_follower: Block notifications from non-followers must_be_follower: Block notifications from non-followers
must_be_following: Block notifications from people you don't follow must_be_following: Block notifications from people you don't follow

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

@ -33,9 +33,12 @@ pl:
setting_display_media_show_all: Zawsze pokazuj zawartość multimedialną jako wrażliwą 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_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_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 setting_skin: Zmienia wygląd używanej odmiany Mastodona
username: Twoja nazwa użytkownika będzie niepowtarzalna na %{domain} 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ń 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: imports:
data: Plik CSV wyeksportowany z innej instancji Mastodona data: Plik CSV wyeksportowany z innej instancji Mastodona
sessions: sessions:
@ -102,6 +105,7 @@ pl:
setting_hide_network: Ukryj swoją sieć setting_hide_network: Ukryj swoją sieć
setting_noindex: Nie indeksuj mojego profilu w wyszukiwarkach internetowych setting_noindex: Nie indeksuj mojego profilu w wyszukiwarkach internetowych
setting_reduce_motion: Ogranicz ruch w animacjach setting_reduce_motion: Ogranicz ruch w animacjach
setting_show_application: Informuj o aplikacji z której wysłano wpisy
setting_skin: Motyw setting_skin: Motyw
setting_system_font_ui: Używaj domyślnej czcionki systemu setting_system_font_ui: Używaj domyślnej czcionki systemu
setting_unfollow_modal: Pytaj o potwierdzenie przed cofnięciem śledzenia setting_unfollow_modal: Pytaj o potwierdzenie przed cofnięciem śledzenia
@ -110,6 +114,8 @@ pl:
username: Nazwa użytkownika username: Nazwa użytkownika
username_or_email: Nazwa użytkownika lub adres e-mail username_or_email: Nazwa użytkownika lub adres e-mail
whole_word: Całe słowo whole_word: Całe słowo
featured_tag:
name: Hashtag
interactions: interactions:
must_be_follower: Nie wyświetlaj powiadomień od osób, które Cię nie śledzą 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 must_be_following: Nie wyświetlaj powiadomień od osób, których nie śledzisz

@ -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| 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 :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 :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 :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} settings.item :password, safe_join([fa_icon('lock fw'), t('auth.security')]), edit_user_registration_url, highlights_on: %r{/auth/edit|/settings/delete}

@ -74,6 +74,7 @@ Rails.application.routes.draw do
get '/@:username', to: 'accounts#show', as: :short_account get '/@:username', to: 'accounts#show', as: :short_account
get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies get '/@:username/with_replies', to: 'accounts#show', as: :short_account_with_replies
get '/@:username/media', to: 'accounts#show', as: :short_account_media 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', to: 'statuses#show', as: :short_account_status
get '/@:account_username/:id/embed', to: 'statuses#embed', as: :embed_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] resource :migration, only: [:show, :update]
resources :sessions, only: [:destroy] resources :sessions, only: [:destroy]
resources :featured_tags, only: [:index, :create, :destroy]
end end
resources :media, only: [:show] do resources :media, only: [:show] do

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

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

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

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" 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" t.index ["status_id"], name: "index_favourites_on_status_id"
end 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| create_table "follow_requests", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
@ -300,6 +311,7 @@ ActiveRecord::Schema.define(version: 2019_01_17_114553) do
t.integer "data_file_size" t.integer "data_file_size"
t.datetime "data_updated_at" t.datetime "data_updated_at"
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.boolean "overwrite", default: false, null: false
end end
create_table "invites", force: :cascade do |t| 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 "custom_filters", "accounts", on_delete: :cascade
add_foreign_key "favourites", "accounts", name: "fk_5eb6c2b873", 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 "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", 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 "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade

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

@ -74,6 +74,7 @@ RSpec.describe Formatter do
end end
context 'given a URL with a query string' do context 'given a URL with a query string' do
context 'with escaped unicode character' do
let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' } let(:text) { 'https://www.ruby-toolbox.com/search?utf8=%E2%9C%93&q=autolink' }
it 'matches the full URL' do it 'matches the full URL' do
@ -81,6 +82,31 @@ RSpec.describe Formatter do
end end
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
context 'given a URL with parentheses in it' do context 'given a URL with parentheses in it' do
let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' } let(:text) { 'https://en.wikipedia.org/wiki/Diaspora_(software)' }
@ -89,6 +115,22 @@ RSpec.describe Formatter do
end end
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 context 'given a URL with Japanese path string' do
let(:text) { 'https://ja.wikipedia.org/wiki/日本' } let(:text) { 'https://ja.wikipedia.org/wiki/日本' }
@ -105,6 +147,22 @@ RSpec.describe Formatter do
end end
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 context 'given a URL with Simplified Chinese path string' do
let(:text) { 'https://baike.baidu.com/item/中华人民共和国' } 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 context 'given a URL containing unsafe code (XSS attack, visible part)' do
let(:text) { %q{http://example.com/b<del>b</del>} } 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;' is_expected.to include '&lt;del&gt;b&lt;/del&gt;'
end end
end end
@ -132,7 +194,11 @@ RSpec.describe Formatter do
context 'given a URL containing unsafe code (XSS attack, invisible part)' 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>} } 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;' is_expected.to include '&lt;script&gt;alert(&quot;Hello&quot;)&lt;/script&gt;'
end end
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>' is_expected.to include '/tags/hashtag" class="mention hashtag" rel="tag">#<span>hashtag</span></a>'
end end
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 end
describe '#format_spoiler' do describe '#format_spoiler' do

@ -244,9 +244,9 @@ describe AccountInteractions do
end end
describe '#block_domain!' do 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 it 'creates and returns AccountDomainBlock' do
expect do expect do

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

Loading…
Cancel
Save