diff options
Diffstat (limited to 'app')
32 files changed, 592 insertions, 264 deletions
diff --git a/app/controllers/api/v1/featured_tags/suggestions_controller.rb b/app/controllers/api/v1/featured_tags/suggestions_controller.rb index 8c1b81a0f..75545d3c7 100644 --- a/app/controllers/api/v1/featured_tags/suggestions_controller.rb +++ b/app/controllers/api/v1/featured_tags/suggestions_controller.rb @@ -3,15 +3,15 @@ class Api::V1::FeaturedTags::SuggestionsController < Api::BaseController before_action -> { doorkeeper_authorize! :read, :'read:accounts' }, only: :index before_action :require_user! - before_action :set_most_used_tags, only: :index + before_action :set_recently_used_tags, only: :index def index - render json: @most_used_tags, each_serializer: REST::TagSerializer + render json: @recently_used_tags, each_serializer: REST::TagSerializer end private - def set_most_used_tags - @most_used_tags = Tag.most_used(current_account).where.not(id: current_account.featured_tags).limit(10) + def set_recently_used_tags + @recently_used_tags = Tag.recently_used(current_account).where.not(id: current_account.featured_tags).limit(10) end end diff --git a/app/controllers/api/v1/timelines/public_controller.rb b/app/controllers/api/v1/timelines/public_controller.rb index 274392c61..fbd99667c 100644 --- a/app/controllers/api/v1/timelines/public_controller.rb +++ b/app/controllers/api/v1/timelines/public_controller.rb @@ -20,29 +20,26 @@ class Api::V1::Timelines::PublicController < Api::BaseController end def cached_public_statuses_page - cache_collection_paginated_by_id( - public_statuses, - Status, - limit_param(DEFAULT_STATUSES_LIMIT), - params_slice(:max_id, :since_id, :min_id) - ) + cache_collection(public_statuses, Status) end def public_statuses - statuses = public_timeline_statuses - - statuses = statuses.not_local_only unless truthy_param?(:local) || truthy_param?(:allow_local_only) - - if truthy_param?(:only_media) - statuses.joins(:media_attachments).group(:id) - else - statuses - end + public_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id], + params[:min_id] + ) end - def public_timeline_statuses - local = truthy_param?(:local) ? true : :local_reblogs - Status.as_public_timeline(current_account, truthy_param?(:remote) ? nil : local) + def public_feed + PublicFeed.new( + current_account, + local: truthy_param?(:local), + remote: truthy_param?(:remote), + only_media: truthy_param?(:only_media), + allow_local_only: truthy_param?(:allow_local_only) + ) end def insert_pagination_headers diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb index 76f7d3590..64a1db58d 100644 --- a/app/controllers/api/v1/timelines/tag_controller.rb +++ b/app/controllers/api/v1/timelines/tag_controller.rb @@ -20,23 +20,29 @@ class Api::V1::Timelines::TagController < Api::BaseController end def cached_tagged_statuses - if @tag.nil? - [] - else - statuses = tag_timeline_statuses - statuses = statuses.joins(:media_attachments) if truthy_param?(:only_media) - - cache_collection_paginated_by_id( - statuses, - Status, - limit_param(DEFAULT_STATUSES_LIMIT), - params_slice(:max_id, :since_id, :min_id) - ) - end + @tag.nil? ? [] : cache_collection(tag_timeline_statuses, Status) end def tag_timeline_statuses - HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, truthy_param?(:local)) + tag_feed.get( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id], + params[:min_id] + ) + end + + def tag_feed + TagFeed.new( + @tag, + current_account, + any: params[:any], + all: params[:all], + none: params[:none], + local: truthy_param?(:local), + remote: truthy_param?(:remote), + only_media: truthy_param?(:only_media) + ) end def insert_pagination_headers diff --git a/app/controllers/settings/featured_tags_controller.rb b/app/controllers/settings/featured_tags_controller.rb index 3a3241425..e9861da56 100644 --- a/app/controllers/settings/featured_tags_controller.rb +++ b/app/controllers/settings/featured_tags_controller.rb @@ -6,7 +6,7 @@ class Settings::FeaturedTagsController < Settings::BaseController 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 + before_action :set_recently_used_tags, only: :index def index @featured_tag = FeaturedTag.new @@ -20,7 +20,7 @@ class Settings::FeaturedTagsController < Settings::BaseController redirect_to settings_featured_tags_path else set_featured_tags - set_most_used_tags + set_recently_used_tags render :index end @@ -41,8 +41,8 @@ class Settings::FeaturedTagsController < Settings::BaseController @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) + def set_recently_used_tags + @recently_used_tags = Tag.recently_used(current_account).where.not(id: @featured_tags.map(&:id)).limit(10) end def featured_tag_params diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 368419ef5..d8b6019f5 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -10,8 +10,9 @@ class TagsController < ApplicationController before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } # before_action :authenticate_user!, if: :whitelist_mode? - before_action :set_tag before_action :set_local + before_action :set_tag + before_action :set_statuses before_action :set_body_classes before_action :set_instance_presenter @@ -26,22 +27,11 @@ class TagsController < ApplicationController format.rss do expires_in 0, public: true - - limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE - @statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(limit) - @statuses = cache_collection(@statuses, Status) - render xml: RSS::TagSerializer.render(@tag, @statuses) end format.json do expires_in 3.minutes, public: public_fetch_mode? - - @statuses = HashtagQueryService.new.call(@tag, filter_params, current_account, @local) - @statuses = @statuses.without_semiprivate unless authenticated_or_following?(@account) - @statuses = @statuses.paginate_by_max_id(PAGE_SIZE, params[:max_id]) - @statuses = cache_collection(@statuses, Status) - render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json', target_domain: current_account&.domain end end @@ -57,6 +47,15 @@ class TagsController < ApplicationController @local = truthy_param?(:local) end + def set_statuses + case request.format&.to_sym + when :json + @statuses = cache_collection(TagFeed.new(@tag, current_account, local: @local).get(PAGE_SIZE, params[:max_id], params[:since_id], params[:min_id]), Status) + when :rss + @statuses = cache_collection(TagFeed.new(@tag, nil, local: @local).get(limit_param), Status) + end + end + def set_body_classes @body_classes = 'with-modals' end @@ -65,16 +64,16 @@ class TagsController < ApplicationController @instance_presenter = InstancePresenter.new end + def limit_param + params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE + end + def collection_presenter ActivityPub::CollectionPresenter.new( - id: tag_url(@tag, filter_params), + id: tag_url(@tag), type: :ordered, size: @tag.statuses.count, items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } ) end - - def filter_params - params.slice(:any, :all, :none).permit(:any, :all, :none) - end end diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js index 96e29accf..6b49ebf88 100644 --- a/app/javascript/flavours/glitch/actions/markers.js +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -105,15 +105,15 @@ export function submitMarkers() { }; export const fetchMarkers = () => (dispatch, getState) => { - const params = { timeline: ['notifications'] }; + const params = { timeline: ['notifications'] }; - dispatch(fetchMarkersRequest()); + dispatch(fetchMarkersRequest()); - api(getState).get('/api/v1/markers', { params }).then(response => { - dispatch(fetchMarkersSuccess(response.data)); - }).catch(error => { - dispatch(fetchMarkersFail(error)); - }); + api(getState).get('/api/v1/markers', { params }).then(response => { + dispatch(fetchMarkersSuccess(response.data)); + }).catch(error => { + dispatch(fetchMarkersFail(error)); + }); }; export function fetchMarkersRequest() { diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index ceb1e6df6..ccc427c29 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -18,6 +18,7 @@ import compareId from 'flavours/glitch/util/compare_id'; import { searchTextFromRawStatus } from 'flavours/glitch/actions/importer/normalizer'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; +export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; // tracking the notif cleaning request export const NOTIFICATIONS_DELETE_MARKED_REQUEST = 'NOTIFICATIONS_DELETE_MARKED_REQUEST'; @@ -45,6 +46,8 @@ export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; export const NOTIFICATIONS_SET_VISIBILITY = 'NOTIFICATIONS_SET_VISIBILITY'; +export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; + defineMessages({ mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, }); @@ -318,3 +321,9 @@ export function setFilter (filterType) { dispatch(saveSettings()); }; }; + +export function markNotificationsAsRead() { + return { + type: NOTIFICATIONS_MARK_AS_READ, + }; +}; diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js index 69f93a2f1..25d98554a 100644 --- a/app/javascript/flavours/glitch/components/status.js +++ b/app/javascript/flavours/glitch/components/status.js @@ -124,6 +124,7 @@ class Status extends ImmutablePureComponent { 'notification', 'hidden', 'expanded', + 'unread', ] updateOnStates = [ @@ -700,7 +701,7 @@ class Status extends ImmutablePureComponent { unpublished: status.get('published') === false, 'has-background': isCollapsed && background, 'status__wrapper-reply': !!status.get('in_reply_to_id'), - read: unread === false, + unread, muted, }, 'focusable'); diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js index d8a51c689..b4549fdf8 100644 --- a/app/javascript/flavours/glitch/features/getting_started/index.js +++ b/app/javascript/flavours/glitch/features/getting_started/index.js @@ -159,7 +159,7 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2); listItems = listItems.concat([ <div key='9'> <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> - {lists.map(list => + {lists.filter(list => !columns.find(item => item.get('id') === 'LIST' && item.getIn(['params', 'id']) === list.get('id'))).map(list => <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} /> )} </div>, diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js index 5f405e976..7b47f411b 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/follow.js +++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js @@ -5,6 +5,7 @@ import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { HotKeys } from 'react-hotkeys'; +import classNames from 'classnames'; // Our imports. import Permalink from 'flavours/glitch/components/permalink'; @@ -19,6 +20,7 @@ export default class NotificationFollow extends ImmutablePureComponent { id: PropTypes.string.isRequired, account: ImmutablePropTypes.map.isRequired, notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, }; handleMoveUp = () => { @@ -59,7 +61,7 @@ export default class NotificationFollow extends ImmutablePureComponent { } render () { - const { account, notification, hidden } = this.props; + const { account, notification, hidden, unread } = this.props; // Links to the display name. const displayName = account.get('display_name_html') || account.get('username'); @@ -76,7 +78,7 @@ export default class NotificationFollow extends ImmutablePureComponent { // Renders. return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-follow focusable' tabIndex='0'> + <div className={classNames('notification notification-follow focusable', { unread })} tabIndex='0'> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon fixedWidth id='user-plus' /> diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js index d73dac434..f351c1035 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js +++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js @@ -10,6 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import NotificationOverlayContainer from '../containers/overlay_container'; import { HotKeys } from 'react-hotkeys'; import Icon from 'flavours/glitch/components/icon'; +import classNames from 'classnames'; const messages = defineMessages({ authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' }, @@ -25,6 +26,7 @@ class FollowRequest extends ImmutablePureComponent { onReject: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, notification: ImmutablePropTypes.map.isRequired, + unread: PropTypes.bool, }; handleMoveUp = () => { @@ -65,7 +67,7 @@ class FollowRequest extends ImmutablePureComponent { } render () { - const { intl, hidden, account, onAuthorize, onReject, notification } = this.props; + const { intl, hidden, account, onAuthorize, onReject, notification, unread } = this.props; if (!account) { return <div />; @@ -94,7 +96,7 @@ class FollowRequest extends ImmutablePureComponent { return ( <HotKeys handlers={this.getHandlers()}> - <div className='notification notification-follow-request focusable' tabIndex='0'> + <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex='0'> <div className='notification__message'> <div className='notification__favourite-icon-wrapper'> <Icon id='user' fixedWidth /> diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js index 62fc28386..bd415856c 100644 --- a/app/javascript/flavours/glitch/features/notifications/components/notification.js +++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js @@ -22,6 +22,7 @@ export default class Notification extends ImmutablePureComponent { cacheMediaWidth: PropTypes.func, cachedMediaWidth: PropTypes.number, onUnmount: PropTypes.func, + unread: PropTypes.bool, }; render () { @@ -46,6 +47,7 @@ export default class Notification extends ImmutablePureComponent { onMoveDown={onMoveDown} onMoveUp={onMoveUp} onMention={onMention} + unread={this.props.unread} /> ); case 'follow_request': @@ -58,6 +60,7 @@ export default class Notification extends ImmutablePureComponent { onMoveDown={onMoveDown} onMoveUp={onMoveUp} onMention={onMention} + unread={this.props.unread} /> ); case 'mention': @@ -77,6 +80,7 @@ export default class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} onUnmount={this.props.onUnmount} withDismiss + unread={this.props.unread} /> ); case 'favourite': @@ -98,6 +102,7 @@ export default class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} onUnmount={this.props.onUnmount} withDismiss + unread={this.props.unread} /> ); case 'reblog': @@ -119,6 +124,7 @@ export default class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} onUnmount={this.props.onUnmount} withDismiss + unread={this.props.unread} /> ); case 'poll': @@ -140,6 +146,7 @@ export default class Notification extends ImmutablePureComponent { cacheMediaWidth={this.props.cacheMediaWidth} onUnmount={this.props.onUnmount} withDismiss + unread={this.props.unread} /> ); default: diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index 26710feff..681323860 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -12,8 +12,10 @@ import { mountNotifications, unmountNotifications, loadPending, + markNotificationsAsRead, } from 'flavours/glitch/actions/notifications'; import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns'; +import { submitMarkers } from 'flavours/glitch/actions/markers'; import NotificationContainer from './containers/notification_container'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; @@ -24,12 +26,14 @@ import { debounce } from 'lodash'; import ScrollableList from 'flavours/glitch/components/scrollable_list'; import LoadGap from 'flavours/glitch/components/load_gap'; import Icon from 'flavours/glitch/components/icon'; +import compareId from 'flavours/glitch/util/compare_id'; import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' }, + markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' }, }); const getNotifications = createSelector([ @@ -56,6 +60,8 @@ const mapStateToProps = state => ({ hasMore: state.getIn(['notifications', 'hasMore']), numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), + lastReadId: state.getIn(['notifications', 'readMarkerId']), + canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), }); /* glitch */ @@ -63,6 +69,10 @@ const mapDispatchToProps = dispatch => ({ onEnterCleaningMode(yes) { dispatch(enterNotificationClearingMode(yes)); }, + onMarkAsRead() { + dispatch(markNotificationsAsRead()); + dispatch(submitMarkers()); + }, onMount() { dispatch(mountNotifications()); }, @@ -93,6 +103,8 @@ class Notifications extends React.PureComponent { onEnterCleaningMode: PropTypes.func, onMount: PropTypes.func, onUnmount: PropTypes.func, + lastReadId: PropTypes.string, + canMarkAsRead: PropTypes.bool, }; static defaultProps = { @@ -194,8 +206,12 @@ class Notifications extends React.PureComponent { this.props.onEnterCleaningMode(!this.props.notifCleaningActive); } + handleMarkAsRead = () => { + this.props.onMarkAsRead(); + } + render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props; const { notifCleaning, notifCleaningActive } = this.props; const { animatingNCD } = this.state; const pinned = !!columnId; @@ -224,6 +240,7 @@ class Notifications extends React.PureComponent { accountId={item.get('account')} onMoveUp={this.handleMoveUp} onMoveDown={this.handleMoveDown} + unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0} /> )); } else { @@ -252,6 +269,21 @@ class Notifications extends React.PureComponent { </ScrollableList> ); + const extraButtons = []; + + if (canMarkAsRead) { + extraButtons.push( + <button + aria-label={intl.formatMessage(messages.markAsRead)} + title={intl.formatMessage(messages.markAsRead)} + onClick={this.handleMarkAsRead} + className='column-header__button' + > + <Icon id='check' /> + </button> + ); + } + const notifCleaningButtonClassName = classNames('column-header__button', { 'active': notifCleaningActive, }); @@ -263,7 +295,7 @@ class Notifications extends React.PureComponent { const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning); - const notifCleaningButton = ( + extraButtons.push( <button aria-label={msgEnterNotifCleaning} title={msgEnterNotifCleaning} @@ -300,7 +332,7 @@ class Notifications extends React.PureComponent { pinned={pinned} multiColumn={multiColumn} localSettings={this.props.localSettings} - extraButton={notifCleaningButton} + extraButton={extraButtons} appendContent={notifCleaningDrawer} > <ColumnSettingsContainer /> diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js index ee1d898bb..2366226ac 100644 --- a/app/javascript/flavours/glitch/features/ui/index.js +++ b/app/javascript/flavours/glitch/features/ui/index.js @@ -12,7 +12,7 @@ import { expandHomeTimeline } from 'flavours/glitch/actions/timelines'; import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications'; import { fetchFilters } from 'flavours/glitch/actions/filters'; import { clearHeight } from 'flavours/glitch/actions/height_cache'; -import { synchronouslySubmitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; +import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers'; import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers'; import UploadArea from './components/upload_area'; import PermaLink from 'flavours/glitch/components/permalink'; @@ -360,6 +360,9 @@ class UI extends React.Component { handleVisibilityChange = () => { const visibility = !document[this.visibilityHiddenProp]; this.props.dispatch(notificationsSetVisibility(visibility)); + if (visibility) { + this.props.dispatch(submitMarkers()); + } } componentWillMount () { diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js index 31d9611a3..474ca3012 100644 --- a/app/javascript/flavours/glitch/reducers/notifications.js +++ b/app/javascript/flavours/glitch/reducers/notifications.js @@ -16,6 +16,7 @@ import { NOTIFICATIONS_DELETE_MARKED_FAIL, NOTIFICATIONS_ENTER_CLEARING_MODE, NOTIFICATIONS_MARK_ALL_FOR_DELETE, + NOTIFICATIONS_MARK_AS_READ, } from 'flavours/glitch/actions/notifications'; import { ACCOUNT_BLOCK_SUCCESS, @@ -39,6 +40,7 @@ const initialState = ImmutableMap({ mounted: 0, unread: 0, lastReadId: '0', + readMarkerId: '0', isLoading: false, cleaningMode: false, isTabVisible: true, @@ -55,16 +57,16 @@ const notificationToMap = (state, notification) => ImmutableMap({ }); const normalizeNotification = (state, notification, usePendingItems) => { - const top = !shouldCountUnreadNotifications(state); + const top = state.get('top'); if (usePendingItems || !state.get('pendingItems').isEmpty()) { return state.update('pendingItems', list => list.unshift(notificationToMap(state, notification))).update('unread', unread => unread + 1); } - if (top) { - state = state.set('lastReadId', notification.id); - } else { + if (shouldCountUnreadNotifications(state)) { state = state.update('unread', unread => unread + 1); + } else { + state = state.set('lastReadId', notification.id); } return state.update('items', list => { @@ -77,7 +79,6 @@ const normalizeNotification = (state, notification, usePendingItems) => { }; const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => { - const top = !(shouldCountUnreadNotifications(state)); const lastReadId = state.get('lastReadId'); let items = ImmutableList(); @@ -102,18 +103,19 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece }); } - if (top) { - if (!items.isEmpty()) { - mutable.update('lastReadId', id => compareId(id, items.first().get('id')) > 0 ? id : items.first().get('id')); - } - } else { - mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0)); - } - if (!next) { mutable.set('hasMore', false); } + if (shouldCountUnreadNotifications(state)) { + mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0)); + } else { + const mostRecent = items.find(item => item !== null); + if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) { + mutable.set('lastReadId', mostRecent.get('id')); + } + } + mutable.set('isLoading', false); }); }; @@ -127,7 +129,7 @@ const clearUnread = (state) => { state = state.set('unread', state.get('pendingItems').size); const lastNotification = state.get('items').find(item => item !== null); return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0'); -} +}; const updateTop = (state, top) => { state = state.set('top', top); @@ -136,16 +138,17 @@ const updateTop = (state, top) => { state = clearUnread(state); } - return state.set('top', top); + return state; }; const deleteByStatus = (state, statusId) => { - const top = !(shouldCountUnreadNotifications(state)); - if (!top) { - const lastReadId = state.get('lastReadId'); + const lastReadId = state.get('lastReadId'); + + if (shouldCountUnreadNotifications(state)) { const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); state = state.update('unread', unread => unread - deletedUnread.size); } + const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId); const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); state = state.update('unread', unread => unread - deletedUnread.size); @@ -183,6 +186,7 @@ const deleteMarkedNotifs = (state) => { const updateMounted = (state) => { state = state.update('mounted', count => count + 1); if (!shouldCountUnreadNotifications(state)) { + state = state.set('readMarkerId', state.get('lastReadId')); state = clearUnread(state); } return state; @@ -191,13 +195,20 @@ const updateMounted = (state) => { const updateVisibility = (state, visibility) => { state = state.set('isTabVisible', visibility); if (!shouldCountUnreadNotifications(state)) { + state = state.set('readMarkerId', state.get('lastReadId')); state = clearUnread(state); } return state; }; const shouldCountUnreadNotifications = (state) => { - return !(state.get('isTabVisible') && state.get('top') && state.get('mounted') > 0); + const isTabVisible = state.get('isTabVisible'); + const isOnTop = state.get('top'); + const isMounted = state.get('mounted') > 0; + const lastReadId = state.get('lastReadId'); + const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0); + + return !(isTabVisible && isOnTop && isMounted && lastItemReached); }; const recountUnread = (state, last_read_id) => { @@ -206,11 +217,15 @@ const recountUnread = (state, last_read_id) => { mutable.set('lastReadId', last_read_id); } + if (compareId(last_read_id, mutable.get('readMarkerId')) > 0) { + mutable.set('readMarkerId', last_read_id); + } + if (state.get('unread') > 0 || shouldCountUnreadNotifications(state)) { mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), last_read_id) > 0)); } }); -} +}; export default function notifications(state = initialState, action) { let st; @@ -284,6 +299,10 @@ export default function notifications(state = initialState, action) { } return markAllForDelete(st, action.yes); + case NOTIFICATIONS_MARK_AS_READ: + const lastNotification = state.get('items').find(item => item !== null); + return lastNotification ? recountUnread(state, lastNotification.get('id')) : state; + default: return state; } diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 306e62342..04266c497 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -464,6 +464,7 @@ padding: 4px 0; border-radius: 4px; box-shadow: 2px 4px 15px rgba($base-shadow-color, 0.4); + z-index: 9999; ul { list-style: none; diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss index a65581136..ba75e3ffe 100644 --- a/app/javascript/flavours/glitch/styles/components/status.scss +++ b/app/javascript/flavours/glitch/styles/components/status.scss @@ -275,7 +275,7 @@ outline: 0; background: lighten($ui-base-color, 4%); - &.status.status-direct:not(.read) { + &.status.status-direct { background: lighten($ui-base-color, 12%); &.muted { @@ -316,7 +316,7 @@ margin-top: 8px; } - &.status-direct:not(.read) { + &.status-direct { background: lighten($ui-base-color, 8%); border-bottom-color: lighten($ui-base-color, 12%); } @@ -399,7 +399,7 @@ &:focus > .status__content:after { background: linear-gradient(rgba(lighten($ui-base-color, 4%), 0), rgba(lighten($ui-base-color, 4%), 1)); } - &.status-direct:not(.read)> .status__content:after { + &.status-direct > .status__content:after { background: linear-gradient(rgba(lighten($ui-base-color, 8%), 0), rgba(lighten($ui-base-color, 8%), 1)); } @@ -1054,3 +1054,22 @@ a.status-card.compact:hover { text-decoration: underline; } } + +.notification, +.status { + position: relative; + + &.unread { + &::before { + content: ""; + position: absolute; + top: 0; + left: 0; + pointer-events: 0; + width: 100%; + height: 100%; + border-left: 2px solid $highlight-text-color; + pointer-events: none; + } + } +} diff --git a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss index c83c82766..e5a5cc246 100644 --- a/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss +++ b/app/javascript/flavours/glitch/styles/mastodon-light/diff.scss @@ -27,7 +27,7 @@ } } -.status.status-direct:not(.read) { +.status.status-direct { background: darken($ui-base-color, 8%); border-bottom-color: darken($ui-base-color, 12%); @@ -36,7 +36,7 @@ } } -.focusable:focus.status.status-direct:not(.read) { +.focusable:focus.status.status-direct { background: darken($ui-base-color, 4%); &.collapsed> .status__content:after { diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 4e34e3b61..69009ffde 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -7,33 +7,56 @@ class FeedManager include Singleton include Redisable + # Maximum number of items stored in a single feed MAX_ITEMS = 1000 - # Must be <= MAX_ITEMS or the tracking sets will grow forever + # Number of items in the feed since last reblog of status + # before the new reblog will be inserted. Must be <= MAX_ITEMS + # or the tracking sets will grow forever REBLOG_FALLOFF = 50 + # Execute block for every active account + # @yield [Account] + # @return [void] def with_active_accounts(&block) Account.joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).find_each(&block) end + # Redis key of a feed + # @param [Symbol] type + # @param [Integer] id + # @param [Symbol] subtype + # @return [String] def key(type, id, subtype = nil) return "feed:#{type}:#{id}" unless subtype "feed:#{type}:#{id}:#{subtype}" end - def filter?(timeline_type, status, receiver_id) - if [:home, :list].include?(timeline_type) - filter_from_home?(status, receiver_id, build_crutches(receiver_id, [status]), filter_options_for(receiver_id)) - elsif timeline_type == :mentions - filter_from_mentions?(status, receiver_id) - elsif timeline_type == :direct - filter_from_direct?(status, receiver_id) + # Check if the status should not be added to a feed + # @param [Symbol] timeline_type + # @param [Status] status + # @param [Account|List] receiver + # @return [Boolean] + def filter?(timeline_type, status, receiver) + case timeline_type + when :home + filter_from_home?(status, receiver.id, build_crutches(receiver.id, [status]), filter_options_for(receiver.id)) + when :list + filter_from_list?(status, receiver) || filter_from_home?(status, receiver.account_id, build_crutches(receiver.account_id, [status]), filter_options_for(receiver.id)) + when :mentions + filter_from_mentions?(status, receiver.id) + when :direct + filter_from_direct?(status, receiver.id) else false end end + # Add a status to a home feed and send a streaming API update + # @param [Account] account + # @param [Status] status + # @return [Boolean] def push_to_home(account, status) return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?) @@ -42,6 +65,10 @@ class FeedManager true end + # Remove a status from a home feed and send a streaming API update + # @param [Account] account + # @param [Status] status + # @return [Boolean] def unpush_from_home(account, status) return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?) @@ -49,24 +76,22 @@ class FeedManager true end + # Add a status to a list feed and send a streaming API update + # @param [List] list + # @param [Status] status + # @return [Boolean] def push_to_list(list, status) - return false if status.reblog? - - if status.reply? && status.in_reply_to_account_id != status.account_id - should_filter = status.in_reply_to_account_id != list.account_id - should_filter &&= status.account_id == list.account_id - should_filter &&= !list.show_all_replies? - should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) - return false if should_filter - end - - return false unless add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + return false if filter_from_list?(status, list) || !add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) trim(:list, list.id) PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}") true end + # Remove a status from a list feed and send a streaming API update + # @param [List] list + # @param [Status] status + # @return [Boolean] def unpush_from_list(list, status) return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) @@ -74,6 +99,10 @@ class FeedManager true end + # Add a status to a linear direct message feed and send a streaming API update + # @param [Account] account + # @param [Status] status + # @return [Boolean] def push_to_direct(account, status) return false unless add_to_feed(:direct, account.id, status) @@ -82,10 +111,15 @@ class FeedManager true end + # Remove a status from a linear direct message feed and send a streaming API update + # @param [List] list + # @param [Status] status + # @return [Boolean] def unpush_from_direct(account, status) return false unless remove_from_feed(:direct, account.id, status) redis.publish("timeline:direct:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) + true end def unpush_status(account, status) @@ -107,32 +141,11 @@ class FeedManager end end - def trim(type, account_id) - timeline_key = key(type, account_id) - reblog_key = key(type, account_id, 'reblogs') - - # Remove any items past the MAX_ITEMS'th entry in our feed - redis.zremrangebyrank(timeline_key, 0, -(FeedManager::MAX_ITEMS + 1)) - - # Get the score of the REBLOG_FALLOFF'th item in our feed, and stop - # tracking anything after it for deduplication purposes. - falloff_rank = FeedManager::REBLOG_FALLOFF - 1 - falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true) - falloff_score = falloff_range&.first&.last&.to_i || 0 - - # Get any reblogs we might have to clean up after. - redis.zrangebyscore(reblog_key, 0, falloff_score).each do |reblogged_id| - # Remove it from the set of reblogs we're tracking *first* to avoid races. - redis.zrem(reblog_key, reblogged_id) - # Just drop any set we might have created to track additional reblogs. - # This means that if this reblog is deleted, we won't automatically insert - # another reblog, but also that any new reblog can be inserted into the - # feed. - redis.del(key(type, account_id, "reblogs:#{reblogged_id}")) - end - end - - def merge_into_timeline(from_account, into_account) + # Fill a home feed with an account's statuses + # @param [Account] from_account + # @param [Account] into_account + # @return [void] + def merge_into_home(from_account, into_account) timeline_key = key(:home, into_account.id) aggregate = into_account.user&.aggregates_reblogs? query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4) @@ -155,7 +168,37 @@ class FeedManager trim(:home, into_account.id) end - def unmerge_from_timeline(from_account, into_account) + # Fill a list feed with an account's statuses + # @param [Account] from_account + # @param [List] list + # @return [void] + def merge_into_list(from_account, list) + timeline_key = key(:list, list.id) + aggregate = list.account.user&.aggregates_reblogs? + query = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4) + + if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4 + oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i + query = query.where('id > ?', oldest_home_score) + end + + statuses = query.to_a + crutches = build_crutches(list.account_id, statuses) + + statuses.each do |status| + next if filter_from_home?(status, list.account_id, crutches) || filter_from_list?(status, list) + + add_to_feed(:list, list.id, status, aggregate) + end + + trim(:list, list.id) + end + + # Remove an account's statuses from a home feed + # @param [Account] from_account + # @param [Account] into_account + # @return [void] + def unmerge_from_home(from_account, into_account) timeline_key = key(:home, into_account.id) oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 @@ -164,14 +207,31 @@ class FeedManager end end - def clear_from_timeline(account, target_account) - # Clear from timeline all statuses from or mentionning target_account + # Remove an account's statuses from a list feed + # @param [Account] from_account + # @param [List] list + # @return [void] + def unmerge_from_list(from_account, list) + timeline_key = key(:list, list.id) + oldest_list_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 + + from_account.statuses.select('id, reblog_of_id').where('id > ?', oldest_list_score).reorder(nil).find_each do |status| + remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) + end + end + + # Clear all statuses from or mentioning target_account from a home feed + # @param [Account] account + # @param [Account] target_account + # @return [void] + def clear_from_home(account, target_account) timeline_key = key(:home, account.id) timeline_status_ids = redis.zrange(timeline_key, 0, -1) statuses = Status.where(id: timeline_status_ids).select(:id, :reblog_of_id, :account_id).to_a reblogged_ids = Status.where(id: statuses.map(&:reblog_of_id).compact, account: target_account).pluck(:id) with_mentions_ids = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact, account: target_account).pluck(:status_id) - target_statuses = statuses.filter do |status| + + target_statuses = statuses.select do |status| status.account_id == target_account.id || reblogged_ids.include?(status.reblog_of_id) || with_mentions_ids.include?(status.id) || with_mentions_ids.include?(status.reblog_of_id) end @@ -180,7 +240,10 @@ class FeedManager end end - def populate_feed(account) + # Populate home feed of account from scratch + # @param [Account] account + # @return [void] + def populate_home(account) limit = FeedManager::MAX_ITEMS / 2 aggregate = account.user&.aggregates_reblogs? timeline_key = key(:home, account.id) @@ -214,6 +277,9 @@ class FeedManager end end + # Populate direct feed of account from scratch + # @param [Account] account + # @return [void] def populate_direct_feed(account) added = 0 limit = FeedManager::MAX_ITEMS / 2 @@ -238,15 +304,60 @@ class FeedManager private - def push_update_required?(timeline_id) - redis.exists?("subscribed:#{timeline_id}") + # Trim a feed to maximum size by removing older items + # @param [Symbol] type + # @param [Integer] timeline_id + # @return [void] + def trim(type, timeline_id) + timeline_key = key(type, timeline_id) + reblog_key = key(type, timeline_id, 'reblogs') + + # Remove any items past the MAX_ITEMS'th entry in our feed + redis.zremrangebyrank(timeline_key, 0, -(FeedManager::MAX_ITEMS + 1)) + + # Get the score of the REBLOG_FALLOFF'th item in our feed, and stop + # tracking anything after it for deduplication purposes. + falloff_rank = FeedManager::REBLOG_FALLOFF + falloff_range = redis.zrevrange(timeline_key, falloff_rank, falloff_rank, with_scores: true) + falloff_score = falloff_range&.first&.last&.to_i + + return if falloff_score.nil? + + # Get any reblogs we might have to clean up after. + redis.zrangebyscore(reblog_key, 0, falloff_score).each do |reblogged_id| + # Remove it from the set of reblogs we're tracking *first* to avoid races. + redis.zrem(reblog_key, reblogged_id) + # Just drop any set we might have created to track additional reblogs. + # This means that if this reblog is deleted, we won't automatically insert + # another reblog, but also that any new reblog can be inserted into the + # feed. + redis.del(key(type, timeline_id, "reblogs:#{reblogged_id}")) + end + end + + # Check if there is a streaming API client connected + # for the given feed + # @param [String] timeline_key + # @return [Boolean] + def push_update_required?(timeline_key) + redis.exists?("subscribed:#{timeline_key}") end + # Check if the account is blocking or muting any of the given accounts + # @param [Integer] receiver_id + # @param [Array<Integer>] account_ids + # @param [Symbol] context def blocks_or_mutes?(receiver_id, account_ids, context) Block.where(account_id: receiver_id, target_account_id: account_ids).any? || (context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?) end + # Check if status should not be added to the home feed + # @param [Status] status + # @param [Integer] receiver_id + # @param [Hash] crutches + # @param [Hash] filter_options + # @return [Boolean] def filter_from_home?(status, receiver_id, crutches, filter_options) conversation = status.conversation reblog_conversation = status.reblog&.conversation @@ -316,6 +427,11 @@ class FeedManager crutches[:following][status.account_id] end + # Check if status should not be added to the mentions feed + # @see NotifyService + # @param [Status] status + # @param [Integer] receiver_id + # @return [Boolean] def filter_from_mentions?(status, receiver_id) return true if receiver_id == status.account_id return true if phrase_filtered?(status, receiver_id, :notifications) @@ -338,12 +454,37 @@ class FeedManager .exists? end + # Check if status should not be added to the linear direct message feed + # @param [Status] status + # @param [Integer] receiver_id + # @return [Boolean] def filter_from_direct?(status, receiver_id) return false if receiver_id == status.account_id filter_from_mentions?(status, receiver_id) end + # Check if status should not be added to the list feed + # @param [Status] status + # @param [List] list + # @return [Boolean] + def filter_from_list?(status, list) + if status.reply? && status.in_reply_to_account_id != status.account_id + should_filter = status.in_reply_to_account_id != list.account_id + should_filter &&= !list.show_all_replies? + should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) + + return !!should_filter + end + + false + end + + # Check if the status hits a phrase filter + # @param [Status] status + # @param [Integer] receiver_id + # @param [Symbol] context + # @return [Boolean] def phrase_filtered?(status, receiver_id, context) active_filters = Rails.cache.fetch("filters:#{receiver_id}") { CustomFilter.where(account_id: receiver_id).active_irreversible.to_a }.to_a @@ -379,6 +520,11 @@ class FeedManager # added, and false if it was not added to the feed. Note that this is # an internal helper: callers must call trim or push updates if # either action is appropriate. + # @param [Symbol] timeline_type + # @param [Integer] account_id + # @param [Status] status + # @param [Boolean] aggregate_reblogs + # @return [Boolean] def add_to_feed(timeline_type, account_id, status, aggregate_reblogs = true) timeline_key = key(timeline_type, account_id) reblog_key = key(timeline_type, account_id, 'reblogs') @@ -391,14 +537,12 @@ class FeedManager return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF - reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id) - - if reblog_rank.nil? + # The ordered set at `reblog_key` holds statuses which have a reblog + # in the top `REBLOG_FALLOFF` statuses of the timeline + if redis.zadd(reblog_key, status.id, status.reblog_of_id, nx: true) # This is not something we've already seen reblogged, so we - # can just add it to the feed (and note that we're - # reblogging it). + # can just add it to the feed (and note that we're reblogging it). redis.zadd(timeline_key, status.id, status.id) - redis.zadd(reblog_key, status.id, status.reblog_of_id) else # Another reblog of the same status was already in the # REBLOG_FALLOFF most recent statuses, so we note that this @@ -412,9 +556,7 @@ class FeedManager # delay of the worker deliverying the original status, the late addition # by merging timelines, and other reasons. # If such a reblog already exists, just do not re-insert it into the feed. - rank = redis.zrevrank(reblog_key, status.id) - - return false unless rank.nil? + return false unless redis.zscore(reblog_key, status.id).nil? redis.zadd(timeline_key, status.id, status.id) end @@ -426,6 +568,11 @@ class FeedManager # with reblogs, and returning true if a status was removed. As with # `add_to_feed`, this does not trigger push updates, so callers must # do so if appropriate. + # @param [Symbol] timeline_type + # @param [Integer] account_id + # @param [Status] status + # @param [Boolean] aggregate_reblogs + # @return [Boolean] def remove_from_feed(timeline_type, account_id, status, aggregate_reblogs = true) timeline_key = key(timeline_type, account_id) reblog_key = key(timeline_type, account_id, 'reblogs') @@ -471,6 +618,11 @@ class FeedManager end end + # Pre-fetch various objects and relationships for given statuses that + # are going to be checked by the filtering methods + # @param [Integer] receiver_id + # @param [Array<Status>] statuses + # @return [Hash] def build_crutches(receiver_id, statuses) crutches = {} diff --git a/app/models/public_feed.rb b/app/models/public_feed.rb new file mode 100644 index 000000000..2839da5cb --- /dev/null +++ b/app/models/public_feed.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +class PublicFeed < Feed + # @param [Account] account + # @param [Hash] options + # @option [Boolean] :with_replies + # @option [Boolean] :with_reblogs + # @option [Boolean] :local + # @option [Boolean] :remote + # @option [Boolean] :only_media + # @option [Boolean] :allow_local_only + def initialize(account, options = {}) + @account = account + @options = options + end + + # @param [Integer] limit + # @param [Integer] max_id + # @param [Integer] since_id + # @param [Integer] min_id + # @return [Array<Status>] + def get(limit, max_id = nil, since_id = nil, min_id = nil) + scope = public_scope + + scope.merge!(without_local_only_scope) unless allow_local_only? + scope.merge!(without_replies_scope) unless with_replies? + scope.merge!(without_reblogs_scope) unless with_reblogs? + scope.merge!(local_only_scope) if local_only? + scope.merge!(remote_only_scope) if remote_only? + scope.merge!(account_filters_scope) if account? + scope.merge!(media_only_scope) if media_only? + + scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id) + end + + private + + def allow_local_only? + local_account? && (local_only? || @options[:allow_local_only]) + end + + def with_reblogs? + @options[:with_reblogs] + end + + def with_replies? + @options[:with_replies] + end + + def local_only? + @options[:local] + end + + def remote_only? + @options[:remote] + end + + def account? + @account.present? + end + + def local_account? + @account&.local? + end + + def media_only? + @options[:only_media] + end + + def public_scope + Status.with_public_visibility.joins(:account).merge(Account.without_suspended.without_silenced) + end + + def local_only_scope + Status.local + end + + def remote_only_scope + Status.remote + end + + def without_replies_scope + Status.without_replies + end + + def without_reblogs_scope + Status.without_reblogs + end + + def media_only_scope + Status.joins(:media_attachments).group(:id) + end + + def without_local_only_scope + Status.not_local_only + end + + def account_filters_scope + Status.not_excluded_by_account(@account).tap do |scope| + scope.merge!(Status.not_domain_blocked_by_account(@account)) unless local_only? + scope.merge!(Status.in_chosen_languages(@account)) if @account.chosen_languages.present? + end + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 83ab58418..c79cbeaf9 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -106,13 +106,13 @@ class Status < ApplicationRecord scope :recent, -> { reorder(id: :desc) } scope :remote, -> { where(local: false).where.not(uri: nil) } scope :local, -> { where(local: true).or(where(uri: nil)) } - scope :with_accounts, ->(ids) { where(id: ids).includes(:account) } scope :without_replies, -> { where(reply: false) } scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') } scope :with_public_visibility, -> { where(visibility: :public, published: true) } scope :distributable, -> { where(visibility: [:public, :unlisted], published: true) } scope :tagged_with, ->(tag) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag }) } + scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) } scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) } scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) } scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) } @@ -451,23 +451,6 @@ class Status < ApplicationRecord end end - def as_public_timeline(account = nil, local_only = false) - query = timeline_scope(local_only) - query = query.without_replies unless Setting.show_replies_in_public_timelines - - apply_timeline_filters(query, account, [:local, true].include?(local_only)) - end - - def as_tag_timeline(tag, account = nil, local_only = false) - query = timeline_scope(local_only, include_unlisted: true).tagged_with(tag) - - apply_timeline_filters(query, account, local_only) - end - - def as_outbox_timeline(account) - where(account: account, visibility: :public) - end - def favourites_map(status_ids, account_id) Favourite.select('status_id').where(status_id: status_ids).where(account_id: account_id).each_with_object({}) { |f, h| h[f.status_id] = true } end @@ -606,50 +589,6 @@ class Status < ApplicationRecord query = query.in_chosen_languages(account) if account.chosen_languages.present? query end - - def timeline_scope(scope = false, include_unlisted: false) - starting_scope = case scope - when :local, true - Status.local - when :remote - Status.remote - when :local_reblogs - Status.locally_reblogged - else - Status - end - starting_scope = include_unlisted ? starting_scope.distributable : starting_scope.with_public_visibility - scope != :local_reblogs ? starting_scope.without_reblogs : starting_scope - end - - def apply_timeline_filters(query, account, local_only) - if account.nil? - filter_timeline_default(query) - else - filter_timeline_for_account(query, account, local_only) - end - end - - def filter_timeline_for_account(query, account, local_only) - query = query.not_excluded_by_account(account) - query = query.not_domain_blocked_by_account(account) unless local_only - query = query.in_chosen_languages(account) if account.chosen_languages.present? - query = query.not_hidden_by_account(account) - query.merge(account_silencing_filter(account)) - end - - def filter_timeline_default(query) - query.not_local_only.excluding_silenced_accounts - end - - def account_silencing_filter(account) - if account.silenced? - including_myself = left_outer_joins(:account).where(account_id: account.id).references(:accounts) - excluding_silenced_accounts.or(including_myself) - else - excluding_silenced_accounts - end - end end def marked_local_only? diff --git a/app/models/tag.rb b/app/models/tag.rb index bce76fc16..df2f86d95 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -39,7 +39,7 @@ class Tag < ApplicationRecord scope :listable, -> { where(listable: [true, nil]) } scope :trendable, -> { Setting.trendable_by_default ? where(trendable: [true, nil]) : where(trendable: true) } scope :discoverable, -> { listable.joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).order(Arel.sql('account_tag_stats.accounts_count desc')) } - scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) } + scope :recently_used, ->(account) { joins(:statuses).where(statuses: { id: account.statuses.select(:id).limit(1000) }).group(:id).order(Arel.sql('count(*) desc')) } scope :matches_name, ->(value) { where(arel_table[:name].matches("#{value}%")) } delegate :accounts_count, diff --git a/app/models/tag_feed.rb b/app/models/tag_feed.rb new file mode 100644 index 000000000..baff55020 --- /dev/null +++ b/app/models/tag_feed.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class TagFeed < PublicFeed + LIMIT_PER_MODE = 4 + + # @param [Tag] tag + # @param [Account] account + # @param [Hash] options + # @option [Enumerable<String>] :any + # @option [Enumerable<String>] :all + # @option [Enumerable<String>] :none + # @option [Boolean] :local + # @option [Boolean] :remote + # @option [Boolean] :only_media + def initialize(tag, account, options = {}) + @tag = tag + @account = account + @options = options + end + + # @param [Integer] limit + # @param [Integer] max_id + # @param [Integer] since_id + # @param [Integer] min_id + # @return [Array<Status>] + def get(limit, max_id = nil, since_id = nil, min_id = nil) + scope = public_scope + + scope.merge!(without_local_only_scope) unless local_account? + scope.merge!(tagged_with_any_scope) + scope.merge!(tagged_with_all_scope) + scope.merge!(tagged_with_none_scope) + scope.merge!(local_only_scope) if local_only? + scope.merge!(remote_only_scope) if remote_only? + scope.merge!(account_filters_scope) if account? + scope.merge!(media_only_scope) if media_only? + + scope.cache_ids.to_a_paginated_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id) + end + + private + + def tagged_with_any_scope + Status.group(:id).tagged_with(tags_for(Array(@tag.name) | Array(@options[:any]))) + end + + def tagged_with_all_scope + Status.group(:id).tagged_with_all(tags_for(@options[:all])) + end + + def tagged_with_none_scope + Status.group(:id).tagged_with_none(tags_for(@options[:none])) + end + + def tags_for(names) + Tag.matching_name(Array(names).take(LIMIT_PER_MODE)) if names.present? + end +end diff --git a/app/services/after_block_service.rb b/app/services/after_block_service.rb index 432ba65e6..70e6467c7 100644 --- a/app/services/after_block_service.rb +++ b/app/services/after_block_service.rb @@ -15,7 +15,7 @@ class AfterBlockService < BaseService private def clear_home_feed! - FeedManager.instance.clear_from_timeline(@account, @target_account) + FeedManager.instance.clear_from_home(@account, @target_account) end def clear_conversations! diff --git a/app/services/hashtag_query_service.rb b/app/services/hashtag_query_service.rb deleted file mode 100644 index 0bdf60221..000000000 --- a/app/services/hashtag_query_service.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -class HashtagQueryService < BaseService - LIMIT_PER_MODE = 4 - - def call(tag, params, account = nil, local = false) - tags = tags_for(Array(tag.name) | Array(params[:any])).pluck(:id) - all = tags_for(params[:all]) - none = tags_for(params[:none]) - - Status.group(:id) - .as_tag_timeline(tags, account, local) - .tagged_with_all(all) - .tagged_with_none(none) - end - - private - - def tags_for(names) - Tag.matching_name(Array(names).take(LIMIT_PER_MODE)) if names.present? - end -end diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 65f6052bf..755fad768 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -13,15 +13,13 @@ class NotifyService < BaseService push_to_conversation! if direct_message? send_email! if email_enabled? rescue ActiveRecord::RecordInvalid - # rubocop:disable Style/RedundantReturn - return - # rubocop:enable Style/RedundantReturn + nil end private def blocked_mention? - FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient.id) + FeedManager.instance.filter?(:mentions, @notification.mention.status, @recipient) end def blocked_favourite? diff --git a/app/services/precompute_feed_service.rb b/app/services/precompute_feed_service.rb index 40cfad572..b4fa70710 100644 --- a/app/services/precompute_feed_service.rb +++ b/app/services/precompute_feed_service.rb @@ -2,8 +2,7 @@ class PrecomputeFeedService < BaseService def call(account) - Redis.current.del("feed:home:#{account.id}") - FeedManager.instance.populate_feed(account) + FeedManager.instance.populate_home(account) FeedManager.instance.populate_direct_feed(account) ensure Redis.current.del("account:#{account.id}:regeneration") diff --git a/app/views/settings/featured_tags/index.html.haml b/app/views/settings/featured_tags/index.html.haml index 6734d027c..297379893 100644 --- a/app/views/settings/featured_tags/index.html.haml +++ b/app/views/settings/featured_tags/index.html.haml @@ -9,7 +9,7 @@ = 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) }, ', ')], ' ') + = f.input :name, wrapper: :with_block_label, hint: safe_join([t('simple_form.hints.featured_tag.name'), safe_join(@recently_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 diff --git a/app/workers/feed_insert_worker.rb b/app/workers/feed_insert_worker.rb index 546f5c0c2..fd35af562 100644 --- a/app/workers/feed_insert_worker.rb +++ b/app/workers/feed_insert_worker.rb @@ -29,13 +29,13 @@ class FeedInsertWorker end def feed_filtered? - # Note: Lists are a variation of home, so the filtering rules - # of home apply to both case @type - when :home, :list - FeedManager.instance.filter?(:home, @status, @follower.id) + when :home + FeedManager.instance.filter?(:home, @status, @follower) + when :list + FeedManager.instance.filter?(:list, @status, @list) when :direct - FeedManager.instance.filter?(:direct, @status, @account.id) + FeedManager.instance.filter?(:direct, @status, @account) end end diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index d745cb99c..74ef7d4da 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -6,6 +6,8 @@ class MergeWorker sidekiq_options queue: 'pull' def perform(from_account_id, into_account_id) - FeedManager.instance.merge_into_timeline(Account.find(from_account_id), Account.find(into_account_id)) + FeedManager.instance.merge_into_home(Account.find(from_account_id), Account.find(into_account_id)) + rescue ActiveRecord::RecordNotFound + true end end diff --git a/app/workers/mute_worker.rb b/app/workers/mute_worker.rb index 7bf0923a5..c74f657cb 100644 --- a/app/workers/mute_worker.rb +++ b/app/workers/mute_worker.rb @@ -4,9 +4,8 @@ class MuteWorker include Sidekiq::Worker def perform(account_id, target_account_id) - FeedManager.instance.clear_from_timeline( - Account.find(account_id), - Account.find(target_account_id) - ) + FeedManager.instance.clear_from_home(Account.find(account_id), Account.find(target_account_id)) + rescue ActiveRecord::RecordNotFound + true end end diff --git a/app/workers/unmerge_worker.rb b/app/workers/unmerge_worker.rb index ea6aacebf..1a23faae5 100644 --- a/app/workers/unmerge_worker.rb +++ b/app/workers/unmerge_worker.rb @@ -6,6 +6,8 @@ class UnmergeWorker sidekiq_options queue: 'pull' def perform(from_account_id, into_account_id) - FeedManager.instance.unmerge_from_timeline(Account.find(from_account_id), Account.find(into_account_id)) + FeedManager.instance.unmerge_from_home(Account.find(from_account_id), Account.find(into_account_id)) + rescue ActiveRecord::RecordNotFound + true end end |