diff --git a/app/controllers/api/v1/accounts/follower_accounts_controller.rb b/app/controllers/api/v1/accounts/follower_accounts_controller.rb index c4f600c54..4578cf6ca 100644 --- a/app/controllers/api/v1/accounts/follower_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/follower_accounts_controller.rb @@ -19,6 +19,8 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController end def load_accounts + return [] if @account.user_hides_network? && current_account.id != @account.id + default_accounts.merge(paginated_follows).to_a end diff --git a/app/controllers/api/v1/accounts/following_accounts_controller.rb b/app/controllers/api/v1/accounts/following_accounts_controller.rb index 90b1f7fc5..ce2bbda85 100644 --- a/app/controllers/api/v1/accounts/following_accounts_controller.rb +++ b/app/controllers/api/v1/accounts/following_accounts_controller.rb @@ -19,6 +19,8 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController end def load_accounts + return [] if @account.user_hides_network? && current_account.id != @account.id + default_accounts.merge(paginated_follows).to_a end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 30bf733ba..f5670c6bf 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -8,11 +8,15 @@ class FollowerAccountsController < ApplicationController format.html do use_pack 'public' + next if @account.user_hides_network? + follows @relationships = AccountRelationshipsPresenter.new(follows.map(&:account_id), current_user.account_id) if user_signed_in? end format.json do + raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index e7cd58739..098b2a20c 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -8,11 +8,15 @@ class FollowingAccountsController < ApplicationController format.html do use_pack 'public' + next if @account.user_hides_network? + follows @relationships = AccountRelationshipsPresenter.new(follows.map(&:target_account_id), current_user.account_id) if user_signed_in? end format.json do + raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network? + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index c853b5ab7..425664d49 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -40,6 +40,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_reduce_motion, :setting_system_font_ui, :setting_noindex, + :setting_hide_network, notification_emails: %i(follow follow_request reblog favourite mention digest), interactions: %i(must_be_follower must_be_following) ) diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index 393268811..641ad0e14 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -76,9 +76,14 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); -export function expandNotifications({ maxId } = {}) { +const noOp = () => {}; + +export function expandNotifications({ maxId } = {}, done = noOp) { return (dispatch, getState) => { - if (getState().getIn(['notifications', 'isLoading'])) { + const notifications = getState().get('notifications'); + + if (notifications.get('isLoading')) { + done(); return; } @@ -87,6 +92,10 @@ export function expandNotifications({ maxId } = {}) { exclude_types: excludeTypesFromSettings(getState()), }; + if (!maxId && notifications.get('items').size > 0) { + params.since_id = notifications.getIn(['items', 0]); + } + dispatch(expandNotificationsRequest()); api(getState).get('/api/v1/notifications', { params }).then(response => { @@ -97,8 +106,10 @@ export function expandNotifications({ maxId } = {}) { dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); fetchRelatedRelationships(dispatch, response.data); + done(); }).catch(error => { dispatch(expandNotificationsFail(error)); + done(); }); }; }; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 14215ab6d..10e68bf3a 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -36,10 +36,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) }); } -function refreshHomeTimelineAndNotification (dispatch) { - dispatch(expandHomeTimeline()); - dispatch(expandNotifications()); -} +const refreshHomeTimelineAndNotification = (dispatch, done) => { + dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done)))); +}; export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); export const connectCommunityStream = () => connectTimelineStream('community', 'public:local'); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index eca847ee7..8bcfe4db9 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -1,6 +1,6 @@ import { importFetchedStatus, importFetchedStatuses } from './importer'; import api, { getLinks } from '../api'; -import { Map as ImmutableMap } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; @@ -64,35 +64,44 @@ export function deleteFromTimelines(id) { }; }; -export function expandTimeline(timelineId, path, params = {}) { +const noOp = () => {}; + +export function expandTimeline(timelineId, path, params = {}, done = noOp) { return (dispatch, getState) => { const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); if (timeline.get('isLoading')) { + done(); return; } + if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) { + params.since_id = timeline.getIn(['items', 0]); + } + dispatch(expandTimelineRequest(timelineId)); api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(importFetchedStatuses(response.data)); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206)); + done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error)); + done(); }); }; }; -export const expandHomeTimeline = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }); -export const expandPublicTimeline = ({ maxId } = {}) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId }); -export const expandCommunityTimeline = ({ maxId } = {}) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId }); -export const expandDirectTimeline = ({ maxId } = {}) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }); +export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); +export const expandPublicTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId }, done); +export const expandCommunityTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId }, done); +export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); -export const expandHashtagTimeline = (hashtag, { maxId } = {}) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }); -export const expandListTimeline = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }); +export const expandHashtagTimeline = (hashtag, { maxId } = {}, done = noOp) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }, done); +export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); export function expandTimelineRequest(timeline) { return { diff --git a/app/javascript/mastodon/stream.js b/app/javascript/mastodon/stream.js index 6c67ba275..9928d0dd7 100644 --- a/app/javascript/mastodon/stream.js +++ b/app/javascript/mastodon/stream.js @@ -1,21 +1,24 @@ import WebSocketClient from 'websocket.js'; +const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); + export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) { return (dispatch, getState) => { const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); const accessToken = getState().getIn(['meta', 'access_token']); const { onDisconnect, onReceive } = callbacks(dispatch, getState); + let polling = null; const setupPolling = () => { - polling = setInterval(() => { - pollingRefresh(dispatch); - }, 20000); + pollingRefresh(dispatch, () => { + polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000)); + }); }; const clearPolling = () => { if (polling) { - clearInterval(polling); + clearTimeout(polling); polling = null; } }; @@ -29,8 +32,9 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ disconnected () { if (pollingRefresh) { - setupPolling(); + polling = setTimeout(() => setupPolling(), randomIntUpTo(40000)); } + onDisconnect(); }, @@ -51,6 +55,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ if (subscription) { subscription.close(); } + clearPolling(); }; diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index b063ca52d..93aa134cf 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -322,6 +322,15 @@ z-index: 2; position: relative; + &.empty img { + position: absolute; + opacity: 0.2; + height: 200px; + left: 0; + bottom: 0; + pointer-events: none; + } + @media screen and (max-width: 740px) { border-radius: 0; box-shadow: none; @@ -438,8 +447,8 @@ font-size: 14px; font-weight: 500; text-align: center; - padding: 60px 0; - padding-top: 55px; + padding: 130px 0; + padding-top: 125px; margin: 0 auto; cursor: default; } diff --git a/app/javascript/styles/mastodon/footer.scss b/app/javascript/styles/mastodon/footer.scss index ba2a06954..dd3c1b688 100644 --- a/app/javascript/styles/mastodon/footer.scss +++ b/app/javascript/styles/mastodon/footer.scss @@ -4,7 +4,7 @@ font-size: 12px; color: $darker-text-color; - .domain { + .footer__domain { font-weight: 500; a { diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb index 84d4b1752..03476920b 100644 --- a/app/lib/activitypub/activity.rb +++ b/app/lib/activitypub/activity.rb @@ -118,4 +118,13 @@ class ActivityPub::Activity def delete_later!(uri) redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri) end + + def fetch_remote_original_status + if object_uri.start_with?('http') + return if ActivityPub::TagManager.instance.local_uri?(object_uri) + ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first) + elsif @object['url'].present? + ::FetchRemoteStatusService.new.call(@object['url']) + end + end end diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb index ea94d2f98..688ab00b3 100644 --- a/app/lib/activitypub/activity/add.rb +++ b/app/lib/activitypub/activity/add.rb @@ -4,9 +4,10 @@ class ActivityPub::Activity::Add < ActivityPub::Activity def perform return unless @json['target'].present? && value_or_id(@json['target']) == @account.featured_collection_url - status = status_from_uri(object_uri) + status = status_from_uri(object_uri) + status ||= fetch_remote_original_status - return unless status.account_id == @account.id && !@account.pinned?(status) + return unless !status.nil? && status.account_id == @account.id && !@account.pinned?(status) StatusPin.create!(account: @account, status: status) end diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index f810c88a2..1147a4481 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -26,16 +26,6 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity private - def fetch_remote_original_status - if object_uri.start_with?('http') - return if ActivityPub::TagManager.instance.local_uri?(object_uri) - - ActivityPub::FetchRemoteStatusService.new.call(object_uri, id: true, on_behalf_of: @account.followers.local.first) - elsif @object['url'].present? - ::FetchRemoteStatusService.new.call(@object['url']) - end - end - def announceable?(status) status.account_id == @account.id || status.public_visibility? || status.unlisted_visibility? end diff --git a/app/lib/activitypub/activity/remove.rb b/app/lib/activitypub/activity/remove.rb index 62a1e3196..f523ead9f 100644 --- a/app/lib/activitypub/activity/remove.rb +++ b/app/lib/activitypub/activity/remove.rb @@ -6,7 +6,7 @@ class ActivityPub::Activity::Remove < ActivityPub::Activity status = status_from_uri(object_uri) - return unless status.account_id == @account.id + return unless !status.nil? && status.account_id == @account.id pin = StatusPin.find_by(account: @account, status: status) pin&.destroy! diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index 78b3aa77c..f8bacb036 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -30,6 +30,7 @@ class UserSettingsDecorator user.settings['noindex'] = noindex_preference if change?('setting_noindex') user.settings['flavour'] = flavour_preference if change?('setting_flavour') user.settings['skin'] = skin_preference if change?('setting_skin') + user.settings['hide_network'] = hide_network_preference if change?('setting_hide_network') end def merged_notification_emails @@ -92,6 +93,10 @@ class UserSettingsDecorator settings['setting_skin'] end + def hide_network_preference + boolean_cast_setting 'setting_hide_network' + end + def boolean_cast_setting(key) ActiveModel::Type::Boolean.new.cast(settings[key]) end diff --git a/app/models/account.rb b/app/models/account.rb index 06c190446..48f284785 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -139,6 +139,7 @@ class Account < ApplicationRecord :moderator?, :staff?, :locale, + :hides_network?, to: :user, prefix: true, allow_nil: true diff --git a/app/models/user.rb b/app/models/user.rb index d5ca9be36..9bdf8807f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -86,7 +86,7 @@ class User < ApplicationRecord has_many :session_activations, dependent: :destroy delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :favourite_modal, :delete_modal, - :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_sensitive_media, + :reduce_motion, :system_font_ui, :noindex, :flavour, :skin, :display_sensitive_media, :hide_network, to: :settings, prefix: :setting, allow_nil: false attr_accessor :invite_code @@ -219,6 +219,10 @@ class User < ApplicationRecord settings.notification_emails['digest'] end + def hides_network? + @hides_network ||= settings.hide_network + end + def token_for_app(a) return nil if a.nil? || a.owner != self Doorkeeper::AccessToken diff --git a/app/views/accounts/_follow_grid.html.haml b/app/views/accounts/_follow_grid.html.haml index a6d0ee817..fdcef84be 100644 --- a/app/views/accounts/_follow_grid.html.haml +++ b/app/views/accounts/_follow_grid.html.haml @@ -1,5 +1,6 @@ -.accounts-grid +.accounts-grid{ class: accounts.empty? ? 'empty' : '' } - if accounts.empty? + = image_tag asset_pack_path('elephant_ui_greeting.svg'), alt: '', role: 'presentational' = render partial: 'accounts/nothing_here' - else = render partial: 'accounts/grid_card', collection: accounts, as: :account, cached: !user_signed_in? diff --git a/app/views/accounts/_follow_grid_hidden.html.haml b/app/views/accounts/_follow_grid_hidden.html.haml new file mode 100644 index 000000000..e970350e6 --- /dev/null +++ b/app/views/accounts/_follow_grid_hidden.html.haml @@ -0,0 +1,3 @@ +.accounts-grid.empty + = image_tag asset_pack_path('elephant_ui_greeting.svg'), alt: '', role: 'presentational' + %p.nothing-here= t('accounts.network_hidden') diff --git a/app/views/follower_accounts/index.html.haml b/app/views/follower_accounts/index.html.haml index a24e4ea20..65af81a5b 100644 --- a/app/views/follower_accounts/index.html.haml +++ b/app/views/follower_accounts/index.html.haml @@ -7,4 +7,7 @@ = render 'accounts/header', account: @account -= render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:account) +- if @account.user_hides_network? + = render 'accounts/follow_grid_hidden' +- else + = render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:account) diff --git a/app/views/following_accounts/index.html.haml b/app/views/following_accounts/index.html.haml index 67f6cfede..8fd95a0b4 100644 --- a/app/views/following_accounts/index.html.haml +++ b/app/views/following_accounts/index.html.haml @@ -7,4 +7,7 @@ = render 'accounts/header', account: @account -= render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:target_account) +- if @account.user_hides_network? + = render 'accounts/follow_grid_hidden' +- else + = render 'accounts/follow_grid', follows: @follows, accounts: @follows.map(&:target_account) diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml index 07441a77d..858d354fa 100644 --- a/app/views/layouts/public.html.haml +++ b/app/views/layouts/public.html.haml @@ -5,9 +5,9 @@ %span.single-user-login = link_to t('auth.login'), new_user_session_path — - %span.domain= link_to site_hostname, about_path + %span.footer__domain= link_to site_hostname, about_path - else - %span.domain= link_to site_hostname, root_path + %span.footer__domain= link_to site_hostname, root_path %span.powered-by != t('generic.powered_by', link: link_to('Mastodon', 'https://joinmastodon.org')) diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index 102e4d200..4632034d7 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -26,6 +26,9 @@ .fields-group = f.input :setting_noindex, as: :boolean, wrapper: :with_label + .fields-group + = f.input :setting_hide_network, as: :boolean, wrapper: :with_label + %h4= t 'preferences.web' .fields-group diff --git a/config/locales/en.yml b/config/locales/en.yml index 0117a9680..2282823d0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -40,6 +40,7 @@ en: following: Following media: Media moved_html: "%{name} has moved to %{new_profile_link}:" + network_hidden: This information is not available nothing_here: There is nothing here! people_followed_by: People whom %{name} follows people_who_follow: People who follow %{name} diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index c0c3b4b85..851b678e1 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -15,6 +15,7 @@ en: note: one: 1 character left other: %{count} characters left + setting_hide_network: Who you follow and who follows you will not be shown on your profile setting_noindex: Affects your public profile and status pages setting_skin: Reskins the selected Mastodon flavour imports: @@ -55,6 +56,7 @@ en: setting_delete_modal: Show confirmation dialog before deleting a toot setting_display_sensitive_media: Always show media marked as sensitive setting_favourite_modal: Show confirmation dialog before favouriting + setting_hide_network: Hide your network setting_noindex: Opt-out of search engine indexing setting_reduce_motion: Reduce motion in animations setting_skin: Skin diff --git a/config/settings.yml b/config/settings.yml index a92a0bfd0..4a3720c2d 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -20,6 +20,7 @@ defaults: &defaults min_invite_role: 'admin' show_staff_badge: true default_sensitive: false + hide_network: false unfollow_modal: false boost_modal: false favourite_modal: false diff --git a/spec/lib/activitypub/activity/add_spec.rb b/spec/lib/activitypub/activity/add_spec.rb index 3ebab4e37..16db71c88 100644 --- a/spec/lib/activitypub/activity/add_spec.rb +++ b/spec/lib/activitypub/activity/add_spec.rb @@ -18,12 +18,31 @@ RSpec.describe ActivityPub::Activity::Add do describe '#perform' do subject { described_class.new(json, sender) } - before do + it 'creates a pin' do subject.perform + expect(sender.pinned?(status)).to be true end - it 'creates a pin' do - expect(sender.pinned?(status)).to be true + context 'when status was not known before' do + let(:json) do + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: 'foo', + type: 'Add', + actor: ActivityPub::TagManager.instance.uri_for(sender), + object: 'https://example.com/unknown', + target: sender.featured_collection_url, + }.with_indifferent_access + end + + before do + stub_request(:get, 'https://example.com/unknown').to_return(status: 410) + end + + it 'fetches the status' do + subject.perform + expect(a_request(:get, 'https://example.com/unknown')).to have_been_made.at_least_once + end end end end