diff options
49 files changed, 408 insertions, 104 deletions
diff --git a/app/assets/javascripts/components/actions/compose.jsx b/app/assets/javascripts/components/actions/compose.jsx index c2a7909f6..23bcf8eac 100644 --- a/app/assets/javascripts/components/actions/compose.jsx +++ b/app/assets/javascripts/components/actions/compose.jsx @@ -69,8 +69,14 @@ export function submitCompose() { sensitive: getState().getIn(['compose', 'sensitive']), unlisted: getState().getIn(['compose', 'unlisted']) }).then(function (response) { - dispatch(submitComposeSuccess(response.data)); - dispatch(updateTimeline('home', response.data)); + dispatch(submitComposeSuccess({ ...response.data })); + + // To make the app more responsive, immediately get the status into the columns + dispatch(updateTimeline('home', { ...response.data })); + + if (response.data.in_reply_to_id === null) { + dispatch(updateTimeline('public', { ...response.data })); + } }).catch(function (error) { dispatch(submitComposeFail(error)); }); diff --git a/app/assets/javascripts/components/actions/timelines.jsx b/app/assets/javascripts/components/actions/timelines.jsx index 0f23ca7fc..5aab993c1 100644 --- a/app/assets/javascripts/components/actions/timelines.jsx +++ b/app/assets/javascripts/components/actions/timelines.jsx @@ -12,12 +12,13 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL'; -export function refreshTimelineSuccess(timeline, statuses, replace) { +export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; + +export function refreshTimelineSuccess(timeline, statuses) { return { type: TIMELINE_REFRESH_SUCCESS, timeline: timeline, - statuses: statuses, - replace: replace + statuses: statuses }; }; @@ -48,24 +49,25 @@ export function deleteFromTimelines(id) { }; }; -export function refreshTimelineRequest(timeline) { +export function refreshTimelineRequest(timeline, id) { return { type: TIMELINE_REFRESH_REQUEST, - timeline: timeline + timeline, + id }; }; -export function refreshTimeline(timeline, replace = false, id = null) { +export function refreshTimeline(timeline, id = null) { return function (dispatch, getState) { - dispatch(refreshTimelineRequest(timeline)); + dispatch(refreshTimelineRequest(timeline, id)); - const ids = getState().getIn(['timelines', timeline], Immutable.List()); + const ids = getState().getIn(['timelines', timeline, 'items'], Immutable.List()); const newestId = ids.size > 0 ? ids.first() : null; let params = ''; let path = timeline; - if (newestId !== null && !replace) { + if (newestId !== null) { params = `?since_id=${newestId}`; } @@ -74,7 +76,7 @@ export function refreshTimeline(timeline, replace = false, id = null) { } api(getState).get(`/api/v1/timelines/${path}${params}`).then(function (response) { - dispatch(refreshTimelineSuccess(timeline, response.data, replace)); + dispatch(refreshTimelineSuccess(timeline, response.data)); }).catch(function (error) { dispatch(refreshTimelineFail(timeline, error)); }); @@ -84,14 +86,14 @@ export function refreshTimeline(timeline, replace = false, id = null) { export function refreshTimelineFail(timeline, error) { return { type: TIMELINE_REFRESH_FAIL, - timeline: timeline, - error: error + timeline, + error }; }; export function expandTimeline(timeline, id = null) { return (dispatch, getState) => { - const lastId = getState().getIn(['timelines', timeline], Immutable.List()).last(); + const lastId = getState().getIn(['timelines', timeline, 'items'], Immutable.List()).last(); dispatch(expandTimelineRequest(timeline)); @@ -112,22 +114,30 @@ export function expandTimeline(timeline, id = null) { export function expandTimelineRequest(timeline) { return { type: TIMELINE_EXPAND_REQUEST, - timeline: timeline + timeline }; }; export function expandTimelineSuccess(timeline, statuses) { return { type: TIMELINE_EXPAND_SUCCESS, - timeline: timeline, - statuses: statuses + timeline, + statuses }; }; export function expandTimelineFail(timeline, error) { return { type: TIMELINE_EXPAND_FAIL, - timeline: timeline, - error: error + timeline, + error + }; +}; + +export function scrollTopTimeline(timeline, top) { + return { + type: TIMELINE_SCROLL_TOP, + timeline, + top }; }; diff --git a/app/assets/javascripts/components/components/status_list.jsx b/app/assets/javascripts/components/components/status_list.jsx index f989ef895..b48d94405 100644 --- a/app/assets/javascripts/components/components/status_list.jsx +++ b/app/assets/javascripts/components/components/status_list.jsx @@ -1,14 +1,16 @@ -import Status from './status'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import PureRenderMixin from 'react-addons-pure-render-mixin'; +import Status from './status'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import PureRenderMixin from 'react-addons-pure-render-mixin'; import { ScrollContainer } from 'react-router-scroll'; -import StatusContainer from '../containers/status_container'; +import StatusContainer from '../containers/status_container'; const StatusList = React.createClass({ propTypes: { statusIds: ImmutablePropTypes.list.isRequired, onScrollToBottom: React.PropTypes.func, + onScrollToTop: React.PropTypes.func, + onScroll: React.PropTypes.func, trackScroll: React.PropTypes.bool }, @@ -27,6 +29,10 @@ const StatusList = React.createClass({ if (scrollTop === scrollHeight - clientHeight) { this.props.onScrollToBottom(); + } else if (scrollTop < 100) { + this.props.onScrollToTop(); + } else { + this.props.onScroll(); } }, diff --git a/app/assets/javascripts/components/components/video_player.jsx b/app/assets/javascripts/components/components/video_player.jsx index 61c1995a7..8f64ad3cd 100644 --- a/app/assets/javascripts/components/components/video_player.jsx +++ b/app/assets/javascripts/components/components/video_player.jsx @@ -53,7 +53,8 @@ const VideoPlayer = React.createClass({ propTypes: { media: ImmutablePropTypes.map.isRequired, width: React.PropTypes.number, - height: React.PropTypes.number + height: React.PropTypes.number, + sensitive: React.PropTypes.bool }, getDefaultProps () { @@ -102,6 +103,12 @@ const VideoPlayer = React.createClass({ <span style={spoilerSubSpanStyle}><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span> </div> ); + } else if (!sensitive && !this.state.visible) { + return ( + <div style={{ cursor: 'pointer', position: 'relative', marginTop: '8px', width: `${width}px`, height: `${height}px`, background: `url(${media.get('preview_url')}) no-repeat center`, backgroundSize: 'cover' }} onClick={this.handleOpen}> + <div style={{ position: 'absolute', top: '50%', left: '50%', fontSize: '36px', transform: 'translate(-50%, -50%)', padding: '5px', borderRadius: '100px', color: 'rgba(255, 255, 255, 0.8)' }}><i className='fa fa-play' /></div> + </div> + ); } return ( diff --git a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx index bea0a2759..cf53a7729 100644 --- a/app/assets/javascripts/components/features/hashtag_timeline/index.jsx +++ b/app/assets/javascripts/components/features/hashtag_timeline/index.jsx @@ -47,13 +47,13 @@ const HashtagTimeline = React.createClass({ const { dispatch } = this.props; const { id } = this.props.params; - dispatch(refreshTimeline('tag', true, id)); + dispatch(refreshTimeline('tag', id)); this._subscribe(dispatch, id); }, componentWillReceiveProps (nextProps) { if (nextProps.params.id !== this.props.params.id) { - this.props.dispatch(refreshTimeline('tag', true, nextProps.params.id)); + this.props.dispatch(refreshTimeline('tag', nextProps.params.id)); this._unsubscribe(); this._subscribe(this.props.dispatch, nextProps.params.id); } diff --git a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx index 8004e3f04..1621cec7b 100644 --- a/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx +++ b/app/assets/javascripts/components/features/ui/containers/status_list_container.jsx @@ -1,16 +1,25 @@ import { connect } from 'react-redux'; import StatusList from '../../../components/status_list'; -import { expandTimeline } from '../../../actions/timelines'; +import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines'; import Immutable from 'immutable'; const mapStateToProps = (state, props) => ({ - statusIds: state.getIn(['timelines', props.type], Immutable.List()) + statusIds: state.getIn(['timelines', props.type, 'items'], Immutable.List()) }); const mapDispatchToProps = function (dispatch, props) { return { onScrollToBottom () { + dispatch(scrollTopTimeline(props.type, false)); dispatch(expandTimeline(props.type, props.id)); + }, + + onScrollToTop () { + dispatch(scrollTopTimeline(props.type, true)); + }, + + onScroll () { + dispatch(scrollTopTimeline(props.type, false)); } }; }; diff --git a/app/assets/javascripts/components/reducers/timelines.jsx b/app/assets/javascripts/components/reducers/timelines.jsx index 358734eaf..de157eb25 100644 --- a/app/assets/javascripts/components/reducers/timelines.jsx +++ b/app/assets/javascripts/components/reducers/timelines.jsx @@ -1,8 +1,10 @@ import { + TIMELINE_REFRESH_REQUEST, TIMELINE_REFRESH_SUCCESS, TIMELINE_UPDATE, TIMELINE_DELETE, - TIMELINE_EXPAND_SUCCESS + TIMELINE_EXPAND_SUCCESS, + TIMELINE_SCROLL_TOP } from '../actions/timelines'; import { REBLOG_SUCCESS, @@ -23,10 +25,31 @@ import { import Immutable from 'immutable'; const initialState = Immutable.Map({ - home: Immutable.List(), - mentions: Immutable.List(), - public: Immutable.List(), - tag: Immutable.List(), + home: Immutable.Map({ + loaded: false, + top: true, + items: Immutable.List() + }), + + mentions: Immutable.Map({ + loaded: false, + top: true, + items: Immutable.List() + }), + + public: Immutable.Map({ + loaded: false, + top: true, + items: Immutable.List() + }), + + tag: Immutable.Map({ + id: null, + loaded: false, + top: true, + items: Immutable.List() + }), + accounts_timelines: Immutable.Map(), ancestors: Immutable.Map(), descendants: Immutable.Map() @@ -50,14 +73,17 @@ const normalizeStatus = (state, status) => { }; const normalizeTimeline = (state, timeline, statuses, replace = false) => { - let ids = Immutable.List(); + let ids = Immutable.List(); + const loaded = state.getIn([timeline, 'loaded']); statuses.forEach((status, i) => { state = normalizeStatus(state, status); ids = ids.set(i, status.get('id')); }); - return state.update(timeline, Immutable.List(), list => (replace ? ids : list.unshift(...ids))); + state = state.setIn([timeline, 'loaded'], true); + + return state.updateIn([timeline, 'items'], Immutable.List(), list => (loaded ? list.unshift(...ids) : list.push(...ids))); }; const appendNormalizedTimeline = (state, timeline, statuses) => { @@ -68,7 +94,7 @@ const appendNormalizedTimeline = (state, timeline, statuses) => { moreIds = moreIds.set(i, status.get('id')); }); - return state.update(timeline, Immutable.List(), list => list.push(...moreIds)); + return state.updateIn([timeline, 'items'], Immutable.List(), list => list.push(...moreIds)); }; const normalizeAccountTimeline = (state, accountId, statuses, replace = false) => { @@ -94,9 +120,15 @@ const appendNormalizedAccountTimeline = (state, accountId, statuses) => { }; const updateTimeline = (state, timeline, status, references) => { + const top = state.getIn([timeline, 'top']); + state = normalizeStatus(state, status); - state = state.update(timeline, Immutable.List(), list => { + state = state.updateIn([timeline, 'items'], Immutable.List(), list => { + if (top && list.size > 40) { + list = list.take(20); + } + if (list.includes(status.get('id'))) { return list; } @@ -116,7 +148,7 @@ const updateTimeline = (state, timeline, status, references) => { const deleteStatus = (state, id, accountId, references) => { // Remove references from timelines ['home', 'mentions', 'public', 'tag'].forEach(function (timeline) { - state = state.update(timeline, list => list.filterNot(item => item === id)); + state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); }); // Remove references from account timelines @@ -166,10 +198,23 @@ const normalizeContext = (state, id, ancestors, descendants) => { }); }; +const resetTimeline = (state, timeline, id) => { + if (timeline === 'tag' && state.getIn([timeline, 'id']) !== id) { + state = state.update(timeline, map => map + .set('id', id) + .set('loaded', false) + .update('items', list => list.clear())); + } + + return state; +}; + export default function timelines(state = initialState, action) { switch(action.type) { + case TIMELINE_REFRESH_REQUEST: + return resetTimeline(state, action.timeline, action.id); case TIMELINE_REFRESH_SUCCESS: - return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses), action.replace); + return normalizeTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); case TIMELINE_EXPAND_SUCCESS: return appendNormalizedTimeline(state, action.timeline, Immutable.fromJS(action.statuses)); case TIMELINE_UPDATE: @@ -184,6 +229,8 @@ export default function timelines(state = initialState, action) { return appendNormalizedAccountTimeline(state, action.id, Immutable.fromJS(action.statuses)); case ACCOUNT_BLOCK_SUCCESS: return filterTimelines(state, action.relationship, action.statuses); + case TIMELINE_SCROLL_TOP: + return state.setIn([action.timeline, 'top'], action.top); default: return state; } diff --git a/app/assets/stylesheets/tables.scss b/app/assets/stylesheets/tables.scss index 89b35891d..b28b91191 100644 --- a/app/assets/stylesheets/tables.scss +++ b/app/assets/stylesheets/tables.scss @@ -3,6 +3,7 @@ max-width: 100%; border-spacing: 0; border-collapse: collapse; + margin-bottom: 20px; th, td { padding: 8px; @@ -18,8 +19,39 @@ border-top: 0; font-weight: 500; } + + & > tbody > tr > th { + font-weight: 500; + } + + a { + color: #2b90d9; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } } samp { font-family: 'Roboto Mono', monospace; } + +.filters { + list-style: none; + margin-bottom: 20px; + + li { + display: inline-block; + } + + a { + color: #2b90d9; + text-decoration: underline; + + &:hover { + text-decoration: none; + } + } +} diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index b0e5a8320..46231dd97 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -9,12 +9,12 @@ class AccountsController < ApplicationController def show respond_to do |format| format.html do - @statuses = @account.statuses.order('id desc').paginate_by_max_id(20, params[:max_id || nil]) + @statuses = @account.statuses.order('id desc').paginate_by_max_id(20, params[:max_id], params[:since_id]) @statuses = cache_collection(@statuses, Status) end format.atom do - @entries = @account.stream_entries.order('id desc').with_includes.paginate_by_max_id(20, params[:max_id] || nil) + @entries = @account.stream_entries.order('id desc').with_includes.paginate_by_max_id(20, params[:max_id], params[:since_id]) end end end diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb index e84799040..79fb37eb9 100644 --- a/app/controllers/admin/accounts_controller.rb +++ b/app/controllers/admin/accounts_controller.rb @@ -2,12 +2,37 @@ class Admin::AccountsController < ApplicationController before_action :require_admin! + before_action :set_account, except: :index layout 'public' def index + @accounts = Account.order('domain ASC, username ASC').paginate(page: params[:page], per_page: 40) + + @accounts = @accounts.local if params[:local].present? + @accounts = @accounts.remote if params[:remote].present? + @accounts = @accounts.where(domain: params[:by_domain]) if params[:by_domain].present? + @accounts = @accounts.where(silenced: true) if params[:silenced].present? + @accounts = @accounts.reorder('id desc') if params[:recent].present? + end + + def show; end + + def update + if @account.update(account_params) + redirect_to admin_accounts_path + else + render :show + end + end + + private + + def set_account + @account = Account.find(params[:id]) end - def show + def account_params + params.require(:account).permit(:silenced) end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index fbe4af07c..7270686de 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -61,7 +61,7 @@ class ApplicationController < ActionController::Base def cache_collection(raw, klass) return raw unless klass.respond_to?(:with_includes) - raw = raw.select(:id, :updated_at).to_a if raw.is_a?(ActiveRecord::Relation) + raw = raw.cache_ids.to_a if raw.is_a?(ActiveRecord::Relation) uncached_ids = [] cached_keys_with_value = Rails.cache.read_multi(*raw.map(&:cache_key)) @@ -69,6 +69,8 @@ class ApplicationController < ActionController::Base uncached_ids << item.id unless cached_keys_with_value.key?(item.cache_key) end + klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!) + unless uncached_ids.empty? uncached = klass.where(id: uncached_ids).with_includes.map { |item| [item.id, item] }.to_h diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 4a70b2a8f..6b6c73080 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -4,7 +4,8 @@ class TagsController < ApplicationController layout 'public' def show - @statuses = Tag.find_by!(name: params[:id].downcase).statuses.order('id desc').paginate_by_max_id(20, params[:max_id] || nil) - @statuses = cache_collection(@statuses, Status) + @tag = Tag.find_by!(name: params[:id].downcase) + @statuses = @tag.statuses.order('id desc').paginate_by_max_id(20, params[:max_id]) + @statuses = cache_collection(@statuses, Status) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 29c2c9120..be82ff2fe 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -4,8 +4,4 @@ module ApplicationHelper def active_nav_class(path) current_page?(path) ? 'active' : '' end - - def id_paginate(path, per_page, collection) - # todo - end end diff --git a/app/helpers/atom_builder_helper.rb b/app/helpers/atom_builder_helper.rb index 40bbe0491..953ccd438 100644 --- a/app/helpers/atom_builder_helper.rb +++ b/app/helpers/atom_builder_helper.rb @@ -112,13 +112,11 @@ module AtomBuilderHelper end def link_enclosure(xml, media) - xml.link(rel: 'enclosure', href: full_asset_url(media.file.url), type: media.file_content_type, length: media.file_file_size) + xml.link(rel: 'enclosure', href: full_asset_url(media.file.url(:original, false)), type: media.file_content_type, length: media.file_file_size) end def link_avatar(xml, account) - single_link_avatar(xml, account, :large, 300) - # single_link_avatar(xml, account, :medium, 96) - # single_link_avatar(xml, account, :small, 48) + single_link_avatar(xml, account, :original, 120) end def logo(xml, url) diff --git a/app/helpers/stream_entries_helper.rb b/app/helpers/stream_entries_helper.rb index 1eb2ed058..0aa7008be 100644 --- a/app/helpers/stream_entries_helper.rb +++ b/app/helpers/stream_entries_helper.rb @@ -6,7 +6,7 @@ module StreamEntriesHelper end def avatar_for_status_url(status) - status.reblog? ? status.reblog.account.avatar.url(:large) : status.account.avatar.url(:large) + status.reblog? ? status.reblog.account.avatar.url( :original) : status.account.avatar.url( :original) end def entry_classes(status, is_predecessor, is_successor, include_threads) diff --git a/app/lib/email_validator.rb b/app/lib/email_validator.rb new file mode 100644 index 000000000..856b8b1f7 --- /dev/null +++ b/app/lib/email_validator.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class EmailValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if Rails.configuration.x.email_domains_blacklist.empty? + + record.errors.add(attribute, I18n.t('users.invalid_email')) if blocked_email?(value) + end + + private + + def blocked_email?(value) + domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.') + regexp = Regexp.new("@(.+\\.)?(#{domains})", true) + + value =~ regexp + end +end diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index b812ad1f4..e08f9a0da 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -82,12 +82,13 @@ class FeedManager end def filter_from_mentions?(status, receiver) - should_filter = receiver.id == status.account_id # Filter if I'm mentioning myself - should_filter ||= receiver.blocking?(status.account) # or it's from someone I blocked + should_filter = receiver.id == status.account_id # Filter if I'm mentioning myself + should_filter ||= receiver.blocking?(status.account) # or it's from someone I blocked should_filter ||= receiver.blocking?(status.mentions.includes(:account).map(&:account)) # or if it mentions someone I blocked + should_filter ||= (status.account.silenced? && !receiver.following?(status.account)) # of if the account is silenced and I'm not following them - if status.reply? && !status.thread.account.nil? # or it's a reply - should_filter ||= receiver.blocking?(status.thread.account) # to a user I blocked + if status.reply? && !status.thread.account.nil? # or it's a reply + should_filter ||= receiver.blocking?(status.thread.account) # to a user I blocked end should_filter diff --git a/app/models/account.rb b/app/models/account.rb index 105b77e04..b1cf34e9a 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -13,12 +13,12 @@ class Account < ApplicationRecord validates :username, presence: true, uniqueness: { scope: :domain, case_sensitive: true }, unless: 'local?' # Avatar upload - has_attached_file :avatar, styles: { large: '300x300#' }, convert_options: { all: '-strip' } + has_attached_file :avatar, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' } validates_attachment_content_type :avatar, content_type: IMAGE_MIME_TYPES validates_attachment_size :avatar, less_than: 2.megabytes # Header upload - has_attached_file :header, styles: { medium: '700x335#' }, convert_options: { all: '-strip' } + has_attached_file :header, styles: { original: '700x335#' }, convert_options: { all: '-quality 80 -strip' } validates_attachment_content_type :header, content_type: IMAGE_MIME_TYPES validates_attachment_size :header, less_than: 2.megabytes diff --git a/app/models/concerns/cacheable.rb b/app/models/concerns/cacheable.rb index cd0167048..51451d260 100644 --- a/app/models/concerns/cacheable.rb +++ b/app/models/concerns/cacheable.rb @@ -11,5 +11,6 @@ module Cacheable included do scope :with_includes, -> { includes(@cache_associated) } + scope :cache_ids, -> { select(:id, :updated_at) } end end diff --git a/app/models/feed.rb b/app/models/feed.rb index 7b181d529..5e1905e15 100644 --- a/app/models/feed.rb +++ b/app/models/feed.rb @@ -14,9 +14,9 @@ class Feed # If we're after most recent items and none are there, we need to precompute the feed if unhydrated.empty? && max_id == '+inf' && since_id == '-inf' RegenerationWorker.perform_async(@account.id, @type) - @statuses = Status.send("as_#{@type}_timeline", @account).paginate_by_max_id(limit, nil, nil) + @statuses = Status.send("as_#{@type}_timeline", @account).cache_ids.paginate_by_max_id(limit, nil, nil) else - status_map = Status.where(id: unhydrated).map { |s| [s.id, s] }.to_h + status_map = Status.where(id: unhydrated).cache_ids.map { |s| [s.id, s] }.to_h @statuses = unhydrated.map { |id| status_map[id] }.compact end diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb index f1b9b8112..d37ef99a8 100644 --- a/app/models/media_attachment.rb +++ b/app/models/media_attachment.rb @@ -10,7 +10,7 @@ class MediaAttachment < ApplicationRecord has_attached_file :file, styles: -> (f) { file_styles f }, processors: -> (f) { f.video? ? [:transcoder] : [:thumbnail] }, - convert_options: { all: '-strip' } + convert_options: { all: '-quality 80 -strip' } validates_attachment_content_type :file, content_type: IMAGE_MIME_TYPES + VIDEO_MIME_TYPES validates_attachment_size :file, less_than: 4.megabytes diff --git a/app/models/notification.rb b/app/models/notification.rb index b066cd87a..9d076ad41 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -5,6 +5,7 @@ class Notification < ApplicationRecord include Cacheable belongs_to :account + belongs_to :from_account, class_name: 'Account' belongs_to :activity, polymorphic: true belongs_to :mention, foreign_type: 'Mention', foreign_key: 'activity_id' @@ -16,10 +17,12 @@ class Notification < ApplicationRecord STATUS_INCLUDES = [:account, :stream_entry, :media_attachments, :tags, mentions: :account, reblog: [:stream_entry, :account, :media_attachments, :tags, mentions: :account]].freeze - cache_associated status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account + scope :cache_ids, -> { select(:id, :updated_at, :activity_type, :activity_id) } - def activity - send(activity_type.downcase) + cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account + + def activity(eager_loaded = true) + eager_loaded ? send(activity_type.downcase) : super end def type @@ -31,15 +34,6 @@ class Notification < ApplicationRecord end end - def from_account - case type - when :mention - activity.status.account - when :follow, :favourite, :reblog - activity.account - end - end - def target_status case type when :reblog @@ -48,4 +42,29 @@ class Notification < ApplicationRecord activity.status end end + + class << self + def reload_stale_associations!(cached_items) + account_ids = cached_items.map(&:from_account_id).uniq + accounts = Account.where(id: account_ids).map { |a| [a.id, a] }.to_h + + cached_items.each do |item| + item.from_account = accounts[item.from_account_id] + end + end + end + + after_initialize :set_from_account + before_validation :set_from_account + + private + + def set_from_account + case activity_type + when 'Status', 'Follow', 'Favourite' + self.from_account_id = activity(false)&.account_id + when 'Mention' + self.from_account_id = activity(false)&.status&.account_id + end + end end diff --git a/app/models/status.rb b/app/models/status.rb index 1e70101a3..1f5cf9b46 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -94,11 +94,11 @@ class Status < ApplicationRecord class << self def as_home_timeline(account) - where(account: [account] + account.following).with_includes + where(account: [account] + account.following) end def as_mentions_timeline(account) - where(id: Mention.where(account: account).pluck(:status_id)).with_includes + where(id: Mention.where(account: account).select(:status_id)) end def as_public_timeline(account = nil) @@ -130,6 +130,22 @@ class Status < ApplicationRecord select('reblog_of_id').where(reblog_of_id: status_ids).where(account_id: account_id).map { |s| [s.reblog_of_id, true] }.to_h end + def reload_stale_associations!(cached_items) + account_ids = [] + + cached_items.each do |item| + account_ids << item.account_id + account_ids << item.reblog.account_id if item.reblog? + end + + accounts = Account.where(id: account_ids.uniq).map { |a| [a.id, a] }.to_h + + cached_items.each do |item| + item.account = accounts[item.account_id] + item.reblog.account = accounts[item.reblog.account_id] if item.reblog? + end + end + private def filter_timeline(query, account) diff --git a/app/models/user.rb b/app/models/user.rb index 423833d47..3fc028a6a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -8,6 +8,7 @@ class User < ApplicationRecord validates :account, presence: true validates :locale, inclusion: I18n.available_locales.map(&:to_s), unless: 'locale.nil?' + validates :email, email: true scope :prolific, -> { joins('inner join statuses on statuses.account_id = users.account_id').select('users.*, count(statuses.id) as statuses_count').group('users.id').order('statuses_count desc') } scope :recent, -> { order('id desc') } diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 1efd326b0..ab76e2a6b 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -36,6 +36,7 @@ class NotifyService < BaseService blocked = false blocked ||= @recipient.id == @notification.from_account.id blocked ||= @recipient.blocking?(@notification.from_account) + blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) blocked ||= (@recipient.user.settings(:interactions).must_be_follower && !@notification.from_account.following?(@recipient)) blocked ||= (@recipient.user.settings(:interactions).must_be_following && !@recipient.following?(@notification.from_account)) blocked ||= send("blocked_#{@notification.type}?") diff --git a/app/views/accounts/_grid_card.html.haml b/app/views/accounts/_grid_card.html.haml index f65b78470..dfd7a9f5e 100644 --- a/app/views/accounts/_grid_card.html.haml +++ b/app/views/accounts/_grid_card.html.haml @@ -1,6 +1,6 @@ .account-grid-card .account-grid-card__header - .avatar= image_tag account.avatar.url(:medium) + .avatar= image_tag account.avatar.url( :original) .name = link_to TagManager.instance.url_for(account) do %span.display_name= display_name(account) diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index 0063d9f16..c132a6896 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -1,4 +1,4 @@ -.card{ style: "background-image: url(#{@account.header.url(:medium)})" } +.card{ style: "background-image: url(#{@account.header.url( :original)})" } - if user_signed_in? && current_account.id != @account.id .controls - if current_account.following?(@account) @@ -6,7 +6,7 @@ - else = link_to t('accounts.follow'), follow_account_path(@account), data: { method: :post }, class: 'button' - .avatar= image_tag @account.avatar.url(:large) + .avatar= image_tag @account.avatar.url( :original) %h1.name = display_name(@account) %small= "@#{@account.username}" diff --git a/app/views/accounts/show.atom.ruby b/app/views/accounts/show.atom.ruby index 558c777f0..b2903d189 100644 --- a/app/views/accounts/show.atom.ruby +++ b/app/views/accounts/show.atom.ruby @@ -6,7 +6,7 @@ Nokogiri::XML::Builder.new do |xml| title xml, @account.display_name subtitle xml, @account.note updated_at xml, stream_updated_at - logo xml, full_asset_url(@account.avatar.url(:medium, false)) + logo xml, full_asset_url(@account.avatar.url( :original)) author(xml) do include_author xml, @account diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index c04faa32f..db8e45e6b 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -14,4 +14,6 @@ .activity-stream = render partial: 'stream_entries/status', collection: @statuses, as: :status -= id_paginate account_url(@account), 20, @statuses +.pagination + - if @statuses.size == 20 + = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), account_url(@account, max_id: @statuses.last.id), class: 'next_page', rel: 'next' diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml index e69de29bb..a074f0ad9 100644 --- a/app/views/admin/accounts/index.html.haml +++ b/app/views/admin/accounts/index.html.haml @@ -0,0 +1,36 @@ +%ul.filters + %li= link_to 'Local', admin_accounts_path(local: '1') + %li= link_to 'Remote', admin_accounts_path(remote: '1') + %li= link_to 'Silenced', admin_accounts_path(silenced: '1') + %li= link_to 'Most recent', admin_accounts_path(recent: '1') + +%table.table + %thead + %tr + %th Username + %th Domain + %th Subscribed + %th Silenced + %th + %tbody + - @accounts.each do |account| + %tr + %td= account.username + %td + - unless account.local? + = link_to account.domain, admin_accounts_path(by_domain: account.domain) + %td + - if account.local? + Local + - elsif account.subscribed? + %i.fa.fa-check + - else + %i.fa.fa-times + %td + - if account.silenced? + %i.fa.fa-check + - else + %i.fa.fa-times + %td= link_to 'Edit', admin_account_path(account.id) + += will_paginate @accounts, pagination_options diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml index e69de29bb..02f7dcfe9 100644 --- a/app/views/admin/accounts/show.html.haml +++ b/app/views/admin/accounts/show.html.haml @@ -0,0 +1,34 @@ +%table.table + %tbody + %tr + %th Username + %td= @account.username + %tr + %th Domain + %td= @account.domain + %tr + %th Display name + %td= @account.display_name + + - if @account.local? + %tr + %th E-mail + %td= @account.user.email + %tr + %th Current IP + %td= @account.user.current_sign_in_ip + - else + %tr + %th Profile URL + %td= link_to @account.url + %tr + %th Feed URL + %td= link_to @account.remote_url + += simple_form_for @account, url: admin_account_path(@account.id) do |f| + = render 'shared/error_messages', object: @account + + = f.input :silenced, as: :boolean, wrapper: :with_label + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/api/v1/accounts/show.rabl b/app/views/api/v1/accounts/show.rabl index c01349ef2..22cb87f6c 100644 --- a/app/views/api/v1/accounts/show.rabl +++ b/app/views/api/v1/accounts/show.rabl @@ -4,8 +4,8 @@ attributes :id, :username, :acct, :display_name node(:note) { |account| Formatter.instance.simplified_format(account) } node(:url) { |account| TagManager.instance.url_for(account) } -node(:avatar) { |account| full_asset_url(account.avatar.url(:large, false)) } -node(:header) { |account| full_asset_url(account.header.url(:medium, false)) } +node(:avatar) { |account| full_asset_url(account.avatar.url( :original)) } +node(:header) { |account| full_asset_url(account.header.url( :original)) } node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : (account.try(:followers_count) || account.followers.count) } node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : (account.try(:following_count) || account.following.count) } node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : (account.try(:statuses_count) || account.statuses.count) } diff --git a/app/views/api/v1/media/create.rabl b/app/views/api/v1/media/create.rabl index 803a93094..0b42e6e3d 100644 --- a/app/views/api/v1/media/create.rabl +++ b/app/views/api/v1/media/create.rabl @@ -1,5 +1,5 @@ object @media attribute :id, :type -node(:url) { |media| full_asset_url(media.file.url) } -node(:preview_url) { |media| full_asset_url(media.file.url(:small)) } +node(:url) { |media| full_asset_url(media.file.url( :original)) } +node(:preview_url) { |media| full_asset_url(media.file.url( :small)) } node(:text_url) { |media| medium_url(media) } diff --git a/app/views/api/v1/statuses/_media.rabl b/app/views/api/v1/statuses/_media.rabl index e4ceef763..af635dfec 100644 --- a/app/views/api/v1/statuses/_media.rabl +++ b/app/views/api/v1/statuses/_media.rabl @@ -1,4 +1,4 @@ attributes :id, :remote_url, :type -node(:url) { |media| full_asset_url(media.file.url) } -node(:preview_url) { |media| full_asset_url(media.file.url(:small)) } +node(:url) { |media| full_asset_url(media.file.url( :original)) } +node(:preview_url) { |media| full_asset_url(media.file.url( :small)) } diff --git a/app/views/stream_entries/_status.html.haml b/app/views/stream_entries/_status.html.haml index 2edc8bc3f..8169b8178 100644 --- a/app/views/stream_entries/_status.html.haml +++ b/app/views/stream_entries/_status.html.haml @@ -34,7 +34,7 @@ - if (status.reblog? ? status.reblog : status).media_attachments.size > 0 %ul.media-attachments - (status.reblog? ? status.reblog : status).media_attachments.each do |media| - %li.transparent-background= link_to '', media.file.url, style: "background-image: url(#{media.file.url(:small)})", target: '_blank' + %li.transparent-background= link_to '', media.file.url( :original), style: "background-image: url(#{media.file.url( :small)})", target: '_blank' - if include_threads = render partial: 'status', collection: @descendants, as: :status, locals: { is_successor: true } diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml index a0e248873..1bacaf32b 100644 --- a/app/views/stream_entries/show.html.haml +++ b/app/views/stream_entries/show.html.haml @@ -7,7 +7,7 @@ %meta{ name: 'og:title', content: "#{@account.username} on #{Rails.configuration.x.local_domain}" }/ %meta{ name: 'og:article:author', content: @account.username }/ %meta{ name: 'og:description', content: @stream_entry.activity.content }/ - %meta{ name: 'og:image', content: @stream_entry.activity.is_a?(Status) && @stream_entry.activity.media_attachments.size > 0 ? full_asset_url(@stream_entry.activity.media_attachments.first.file.url(:small)) : full_asset_url(@account.avatar.url(:large)) }/ + %meta{ name: 'og:image', content: @stream_entry.activity.is_a?(Status) && @stream_entry.activity.media_attachments.size > 0 ? full_asset_url(@stream_entry.activity.media_attachments.first.file.url( :small)) : full_asset_url(@account.avatar.url( :original)) }/ .activity-stream.activity-stream-headless = render partial: @type, locals: { @type.to_sym => @stream_entry.activity, include_threads: true } diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml index bfe5c0439..dd42fe22c 100644 --- a/app/views/tags/show.html.haml +++ b/app/views/tags/show.html.haml @@ -5,4 +5,6 @@ .activity-stream = render partial: 'stream_entries/status', collection: @statuses, as: :status, cached: true -= id_paginate tag_path, 20, @statuses +.pagination + - if @statuses.size == 20 + = link_to safe_join([t('pagination.next'), fa_icon('chevron-right')], ' '), tag_url(@tag, max_id: @statuses.last.id), class: 'next_page', rel: 'next' diff --git a/config/initializers/blacklists.rb b/config/initializers/blacklists.rb new file mode 100644 index 000000000..52646e64d --- /dev/null +++ b/config/initializers/blacklists.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.x.email_domains_blacklist = ENV.fetch('EMAIL_DOMAIN_BLACKLIST') { 'mvrht.com' } +end diff --git a/config/initializers/ostatus.rb b/config/initializers/ostatus.rb index 4ba432b6a..c5723b2e9 100644 --- a/config/initializers/ostatus.rb +++ b/config/initializers/ostatus.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + port = ENV.fetch('PORT') { 3000 } host = ENV.fetch('LOCAL_DOMAIN') { "localhost:#{port}" } https = ENV['LOCAL_HTTPS'] == 'true' - + Rails.application.configure do config.x.local_domain = host config.x.hub_url = ENV.fetch('HUB_URL') { 'https://pubsubhubbub.superfeedr.com' } diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb index 80effc05e..93822a2d1 100644 --- a/config/initializers/paperclip.rb +++ b/config/initializers/paperclip.rb @@ -1,11 +1,19 @@ +# frozen_string_literal: true + if ENV['S3_ENABLED'] == 'true' Aws.eager_autoload!(services: %w(S3)) - Paperclip::Attachment.default_options[:storage] = :s3 - Paperclip::Attachment.default_options[:s3_protocol] = 'https' - Paperclip::Attachment.default_options[:url] = ':s3_domain_url' - Paperclip::Attachment.default_options[:s3_host_name] = "s3-#{ENV.fetch('S3_REGION')}.amazonaws.com" - Paperclip::Attachment.default_options[:path] = '/:class/:attachment/:id_partition/:style/:filename' + Paperclip::Attachment.default_options[:storage] = :s3 + Paperclip::Attachment.default_options[:s3_protocol] = 'https' + Paperclip::Attachment.default_options[:url] = ':s3_domain_url' + Paperclip::Attachment.default_options[:s3_host_name] = "s3-#{ENV.fetch('S3_REGION')}.amazonaws.com" + Paperclip::Attachment.default_options[:path] = '/:class/:attachment/:id_partition/:style/:filename' + Paperclip::Attachment.default_options[:s3_headers] = { 'Cache-Control' => 'max-age=315576000', 'Expires' => 10.years.from_now.httpdate } + + unless ENV['S3_CLOUDFRONT_HOST'].blank? + Paperclip::Attachment.default_options[:url] = ':s3_alias_url' + Paperclip::Attachment.default_options[:s3_host_alias] = ENV['S3_CLOUDFRONT_HOST'] + end Paperclip::Attachment.default_options[:s3_credentials] = { bucket: ENV.fetch('S3_BUCKET'), diff --git a/config/locales/en.yml b/config/locales/en.yml index 426f3928a..50a1f0e95 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -55,5 +55,7 @@ en: stream_entries: favourited: favourited a post by is_now_following: is now following + users: + invalid_email: The e-mail address is invalid will_paginate: page_gap: "…" diff --git a/config/routes.rb b/config/routes.rb index 35e5c269a..ac53bbed6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -46,7 +46,7 @@ Rails.application.routes.draw do namespace :admin do resources :pubsubhubbub, only: [:index] - resources :accounts, only: [:index, :show] + resources :accounts, only: [:index, :show, :update] end namespace :api do diff --git a/db/migrate/20161203164520_add_from_account_id_to_notifications.rb b/db/migrate/20161203164520_add_from_account_id_to_notifications.rb new file mode 100644 index 000000000..142adbe9c --- /dev/null +++ b/db/migrate/20161203164520_add_from_account_id_to_notifications.rb @@ -0,0 +1,14 @@ +class AddFromAccountIdToNotifications < ActiveRecord::Migration[5.0] + def up + add_column :notifications, :from_account_id, :integer + + Notification.where(from_account_id: nil).where(activity_type: 'Status').update_all('from_account_id = (SELECT statuses.account_id FROM notifications AS notifications1 INNER JOIN statuses ON notifications1.activity_id = statuses.id WHERE notifications1.activity_type = \'Status\' AND notifications1.id = notifications.id)') + Notification.where(from_account_id: nil).where(activity_type: 'Mention').update_all('from_account_id = (SELECT statuses.account_id FROM notifications AS notifications1 INNER JOIN mentions ON notifications1.activity_id = mentions.id INNER JOIN statuses ON mentions.status_id = statuses.id WHERE notifications1.activity_type = \'Mention\' AND notifications1.id = notifications.id)') + Notification.where(from_account_id: nil).where(activity_type: 'Favourite').update_all('from_account_id = (SELECT favourites.account_id FROM notifications AS notifications1 INNER JOIN favourites ON notifications1.activity_id = favourites.id WHERE notifications1.activity_type = \'Favourite\' AND notifications1.id = notifications.id)') + Notification.where(from_account_id: nil).where(activity_type: 'Follow').update_all('from_account_id = (SELECT follows.account_id FROM notifications AS notifications1 INNER JOIN follows ON notifications1.activity_id = follows.id WHERE notifications1.activity_type = \'Follow\' AND notifications1.id = notifications.id)') + end + + def down + remove_column :notifications, :from_account_id + end +end diff --git a/db/schema.rb b/db/schema.rb index f9080ae2d..8e5c806d4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20161202132159) do +ActiveRecord::Schema.define(version: 20161203164520) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -100,8 +100,9 @@ ActiveRecord::Schema.define(version: 20161202132159) do t.integer "account_id" t.integer "activity_id" t.string "activity_type" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "from_account_id" t.index ["account_id", "activity_id", "activity_type"], name: "account_activity", unique: true, using: :btree end diff --git a/public/avatars/medium/missing.png b/public/avatars/medium/missing.png deleted file mode 100644 index 98fffdae3..000000000 --- a/public/avatars/medium/missing.png +++ /dev/null Binary files differdiff --git a/public/avatars/large/missing.png b/public/avatars/original/missing.png index 53cffc312..53cffc312 100644 --- a/public/avatars/large/missing.png +++ b/public/avatars/original/missing.png Binary files differdiff --git a/public/avatars/small/missing.png b/public/avatars/small/missing.png deleted file mode 100644 index 43fe8b741..000000000 --- a/public/avatars/small/missing.png +++ /dev/null Binary files differdiff --git a/spec/controllers/admin/accounts_controller_spec.rb b/spec/controllers/admin/accounts_controller_spec.rb index 485310495..47b1267e8 100644 --- a/spec/controllers/admin/accounts_controller_spec.rb +++ b/spec/controllers/admin/accounts_controller_spec.rb @@ -13,8 +13,10 @@ RSpec.describe Admin::AccountsController, type: :controller do end describe 'GET #show' do + let(:account) { Fabricate(:account, username: 'bob') } + it 'returns http success' do - get :show, params: { id: 1 } + get :show, params: { id: account.id } expect(response).to have_http_status(:success) end end diff --git a/spec/helpers/atom_builder_helper_spec.rb b/spec/helpers/atom_builder_helper_spec.rb index 8a161cab3..3d3bd56a1 100644 --- a/spec/helpers/atom_builder_helper_spec.rb +++ b/spec/helpers/atom_builder_helper_spec.rb @@ -162,7 +162,7 @@ RSpec.describe AtomBuilderHelper, type: :helper do let(:account) { Fabricate(:account, username: 'alice') } it 'creates a link' do - expect(used_with_namespaces { |xml| helper.link_avatar(xml, account) }).to match '<link rel="avatar" type="" media:width="300" media:height="300" href="http://test.host/avatars/large/missing.png"/>' + expect(used_with_namespaces { |xml| helper.link_avatar(xml, account) }).to match '<link rel="avatar" type="" media:width="120" media:height="120" href="http://test.host/avatars/original/missing.png"/>' end end |