Merge branch 'master' into glitch-soc/merge-upstream

Conflicts:
- app/controllers/oauth/authorized_applications_controller.rb
  Two changes too close to each other
- app/controllers/settings/sessions_controller.rb
- app/lib/user_settings_decorator.rb
  Two changes too close to each other
- app/models/media_attachment.rb
  New changes too close to glitch-soc only changes.
- app/models/user.rb
  Two changes too close to each other.
- app/services/remove_status_service.rb
  Kept direct timeline code which had been removed upstream.
- app/views/settings/preferences/show.html.haml
  Two changes too close to each other.
- config/locales/en.yml
  Introduction of a new string too close to glitch-soc-only's “flavour”
- config/locales/ja.yml
  Introduction of a new string too close to glitch-soc-only's “flavour”
- config/locales/pl.yml
  Introduction of a new string too close to glitch-soc-only's “flavour”
- config/locales/simple_form.en.yml
  Introduction of a new string too close to glitch-soc-only's “skin”
- config/locales/simple_form.pl.yml
  Introduction of a new string too close to glitch-soc-only's “skin”
- config/settings.yml
  Reverted upstream's decision of enabling posting application by default.
This commit is contained in:
Thibaut Girka
2019-02-10 20:57:51 +01:00
83 changed files with 747 additions and 231 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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