From 7e3bd2e8c5c9c303482807752e7b64139f1bcb6b Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 24 Aug 2026 10:29:12 +0000 Subject: [PATCH] Add counters for which features result in users following each other (#40226) --- app/controllers/api/v1/accounts_controller.rb | 2 +- app/javascript/mastodon/actions/accounts.js | 1 + app/javascript/mastodon/api/accounts.ts | 3 +- .../mastodon/components/account/index.tsx | 7 ++- .../components/account_header/buttons.tsx | 1 + .../components/account_list_item/index.tsx | 10 ++++- .../mastodon/components/follow_button.tsx | 14 +++++- .../collections/detail/accounts_list.tsx | 14 +++++- .../mastodon/features/onboarding/follows.tsx | 8 +++- app/lib/feature_usage_tracker.rb | 43 +++++++++++++++++++ app/services/follow_service.rb | 2 + 11 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 app/lib/feature_usage_tracker.rb diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 8856b8e0394..40e3f248d2c 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -45,7 +45,7 @@ class Api::V1::AccountsController < Api::BaseController end def follow - follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, languages: params.key?(:languages) ? params[:languages] : nil, with_rate_limit: true) + follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, languages: params.key?(:languages) ? params[:languages] : nil, with_rate_limit: true, ref: params[:ref]) options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify?, languages: follow.languages } }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(**options) diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 5960c3dc2a4..abedbc18251 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -149,6 +149,7 @@ export function fetchAccountFail(id, error) { * @param {Object} options * @param {boolean} [options.reblogs] * @param {boolean} [options.notify] + * @param {string} [options.ref] * @returns {function(): void} */ export function followAccount(id, options = { reblogs: true }) { diff --git a/app/javascript/mastodon/api/accounts.ts b/app/javascript/mastodon/api/accounts.ts index 52c5b017d96..dfddd947030 100644 --- a/app/javascript/mastodon/api/accounts.ts +++ b/app/javascript/mastodon/api/accounts.ts @@ -32,7 +32,8 @@ export const apiSubmitAccountNote = (id: string, value: string) => export const apiFollowAccount = ( id: string, params?: { - reblogs: boolean; + reblogs?: boolean; + ref?: string; }, ) => apiRequestPost(`v1/accounts/${id}/follow`, { diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index 9ec2e132055..047249383ab 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -77,6 +77,7 @@ interface AccountProps { extraAccountInfo?: React.ReactNode; className?: string; children?: React.ReactNode; + reference?: string; } export const Account: React.FC = ({ @@ -91,6 +92,7 @@ export const Account: React.FC = ({ extraAccountInfo, className, children, + reference, }) => { const intl = useIntl(); const { signedIn } = useIdentity(); @@ -169,7 +171,7 @@ export const Account: React.FC = ({ modalProps: { accountId: id, onConfirm: () => { - apiFollowAccount(id) + apiFollowAccount(id, { ref: reference }) .then((relationship) => { dispatch( followAccountSuccess({ @@ -225,6 +227,7 @@ export const Account: React.FC = ({ defaultAction, isRemote, signedIn, + reference, ]); if (hidden) { @@ -269,7 +272,7 @@ export const Account: React.FC = ({ /> ); } else { - button = ; + button = ; } let muteTimeRemaining: React.ReactNode; diff --git a/app/javascript/mastodon/components/account_header/buttons.tsx b/app/javascript/mastodon/components/account_header/buttons.tsx index af937d284da..81e6b5041ab 100644 --- a/app/javascript/mastodon/components/account_header/buttons.tsx +++ b/app/javascript/mastodon/components/account_header/buttons.tsx @@ -96,6 +96,7 @@ const AccountButtonsOther: FC< accountId={accountId} className={classes.followButton} labelLength='long' + reference='profile' /> )} {isFollowing && ( diff --git a/app/javascript/mastodon/components/account_list_item/index.tsx b/app/javascript/mastodon/components/account_list_item/index.tsx index d6da056c8c9..31f0fd8b9b3 100644 --- a/app/javascript/mastodon/components/account_list_item/index.tsx +++ b/app/javascript/mastodon/components/account_list_item/index.tsx @@ -199,6 +199,12 @@ const defaultRenderButton = ({ accountId }: RenderButtonOptions) => ( export const AccountListItemFollowButton: React.FC<{ accountId: string | undefined; -}> = ({ accountId }) => ( - + reference?: string; +}> = ({ accountId, reference }) => ( + ); diff --git a/app/javascript/mastodon/components/follow_button.tsx b/app/javascript/mastodon/components/follow_button.tsx index 6702115d888..e5b822408bd 100644 --- a/app/javascript/mastodon/components/follow_button.tsx +++ b/app/javascript/mastodon/components/follow_button.tsx @@ -61,12 +61,14 @@ export const FollowButton: React.FC<{ labelLength?: 'auto' | 'short' | 'long'; className?: string; withUnmute?: boolean; + reference?: string; }> = ({ accountId, compact, labelLength = 'auto', className, withUnmute = true, + reference, }) => { const intl = useIntl(); const dispatch = useAppDispatch(); @@ -125,9 +127,17 @@ export const FollowButton: React.FC<{ ); } else { // @ts-expect-error this action is not typed yet - dispatch(followAccount(accountId)); + dispatch(followAccount(accountId, { ref: reference })); } - }, [signedIn, relationship, accountId, withUnmute, account, dispatch]); + }, [ + signedIn, + relationship, + accountId, + withUnmute, + account, + dispatch, + reference, + ]); const isNarrow = useBreakpoint('narrow'); const useShortLabel = diff --git a/app/javascript/mastodon/features/collections/detail/accounts_list.tsx b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx index 8baa16dec42..4a81f5eff93 100644 --- a/app/javascript/mastodon/features/collections/detail/accounts_list.tsx +++ b/app/javascript/mastodon/features/collections/detail/accounts_list.tsx @@ -143,7 +143,12 @@ export const CollectionAccountsList: React.FC<{ ({ relationship, accountId }: RenderButtonOptions) => { if (!me || !relationship) { // Show follow button when logged out (it will trigger the remote interaction modal) - return ; + return ( + + ); } // When viewing your own collection, only show the Follow button @@ -165,7 +170,12 @@ export const CollectionAccountsList: React.FC<{ ); } - return ; + return ( + + ); }, [collectionOwnerId, confirmRevoke], ); diff --git a/app/javascript/mastodon/features/onboarding/follows.tsx b/app/javascript/mastodon/features/onboarding/follows.tsx index 06a2bd0827d..582b7380ba5 100644 --- a/app/javascript/mastodon/features/onboarding/follows.tsx +++ b/app/javascript/mastodon/features/onboarding/follows.tsx @@ -169,7 +169,13 @@ export const Follows: React.FC<{ } > {displayedAccountIds.map((accountId) => ( - + ))} diff --git a/app/lib/feature_usage_tracker.rb b/app/lib/feature_usage_tracker.rb new file mode 100644 index 00000000000..aa62c353533 --- /dev/null +++ b/app/lib/feature_usage_tracker.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class FeatureUsageTracker + include Redisable + + FEATURES = %i( + follow + ).freeze + + REF_VALUES = %w( + collection + onboarding + profile + ).freeze + + EXPIRE_AFTER = 6.months.seconds + + def self.for(feature) + raise ArgumentError unless FEATURES.include?(feature) + + new(feature) + end + + def initialize(feature) + @feature = feature + end + + def increment(ref) + ref = nil unless REF_VALUES.include?(ref) + key = key_at(Time.now.utc) + + with_redis do |redis| + redis.hincrby(key, ref || 'unknown', 1) + redis.expire(key, EXPIRE_AFTER) + end + end + + private + + def key_at(at_time) + "activity:feature_usage:#{@feature}:#{at_time.beginning_of_day.to_i}" + end +end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index c2d2956a985..b606e32a226 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -15,6 +15,7 @@ class FollowService < BaseService # @option [Boolean] :bypass_locked # @option [Boolean] :bypass_limit Allow following past the total follow number # @option [Boolean] :with_rate_limit + # @option [String] :ref Client-side feature reference def call(source_account, target_account, options = {}) @source_account = source_account @target_account = target_account @@ -30,6 +31,7 @@ class FollowService < BaseService end ActivityTracker.increment('activity:interactions') + FeatureUsageTracker.for(:follow).increment(@options[:ref]) # When an account follows someone for the first time, avoid showing # an empty home feed while the follow request is being processed