diff --git a/assets/css/admin-autoshare-for-twitter.css b/assets/css/admin-autoshare-for-twitter.css index a5a4ab1f..4b4f372d 100644 --- a/assets/css/admin-autoshare-for-twitter.css +++ b/assets/css/admin-autoshare-for-twitter.css @@ -265,3 +265,87 @@ tbody .autoshare-for-twitter-status-logo--disabled::before { margin-top: 12px; display: block; } + +#autopost_for_x_rate_monitor_dashboard_widget .inside { + padding: 0; + margin-top: 0px; +} + +.autoshare-for-twitter-no-accounts { + padding: 0px 12px 12px 12px; +} + +.autoshare-for-twitter-rate-monitor__users { + border-top: 1px solid #f0f0f1; +} + +.autoshare-for-twitter-rate-monitor__user { + align-items: center; + border-bottom: 1px solid #f0f0f1; + display: flex; + padding: 12px; +} + +.autoshare-for-twitter-rate-monitor__user img { + border-radius: 50%; + margin-right: 10px; + max-width: 48px; +} + +#autopost_for_x_rate_monitor_dashboard_widget .autoshare-for-twitter-rate-monitor__user h3 { + font-weight: bold; + margin-bottom: 0; + margin-right: 10px; +} + +.autoshare-for-twitter-rate-monitor__user-info p, +.autoshare-for-twitter-rate-monitor__rate p { + margin-bottom: 0; + margin-top: 0; +} + +.autoshare-for-twitter-rate-monitor__rate-reset { + font-size: 12px; + font-style: italic; +} + +.autoshare-for-twitter-rate-monitor__app { + align-items: center; + display: flex; + padding: 12px; +} + +.autoshare-for-twitter-rate-monitor__disclaimer { + background: #f6f7f7; + color: #50575e; + padding: 12px; +} + +.autoshare-for-twitter-rate-monitor__disclaimer ul { + margin-bottom: 0; + margin-top: 0; +} + +.autoshare-for-twitter-rate-monitor__disclaimer p { + margin-bottom: 8px; + margin-top: 0; +} + +.autoshare-for-twitter-editor-panel .autoshare-for-twitter-rate-monitor__user { + border-bottom: 0; + margin-bottom: 16px; + padding: 0; +} + +.autoshare-for-twitter-editor-panel .autoshare-for-twitter-rate-monitor__app { + padding: 0; + margin-bottom: 16px; +} + +.autoshare-for-twitter-editor-panel .autoshare-for-twitter-rate-monitor__disclaimer { + margin-bottom: 16px; +} + +.autoshare-for-twitter-editor-panel .autoshare-for-twitter-rate-monitor__disclaimer p { + margin-bottom: 0; +} \ No newline at end of file diff --git a/includes/core.php b/includes/core.php index bbf95d77..9bf4f2cd 100644 --- a/includes/core.php +++ b/includes/core.php @@ -51,6 +51,8 @@ function setup() { add_filter( 'autoshare_for_twitter_attached_image', __NAMESPACE__ . '\maybe_disable_upload_image', 10, 2 ); add_action( 'admin_init', __NAMESPACE__ . '\handle_notice_dismiss' ); add_action( 'admin_notices', __NAMESPACE__ . '\migrate_to_twitter_v2_api' ); + add_action( 'autoshare_for_twitter_after_status_update', __NAMESPACE__ . '\update_account_rate_limits', 10, 5 ); + add_action( 'wp_dashboard_setup', __NAMESPACE__ . '\register_rate_monitor_dashboard_widget' ); } /** @@ -222,3 +224,305 @@ function handle_notice_dismiss() { update_option( 'autoshare_migrate_to_v2_api_notice_dismissed', true ); } } + +/** + * Update the account rate limits from the last X/Twitter API request. + * + * @param object $response The response from the X/Twitter endpoint. + * @param array $update_data Data to send to the X/Twitter endpoint. + * @param \WP_Post $post The post associated with the tweet. + * @param string $account_id The account ID associated with the tweet. + * @param array|null $last_headers The headers from the last request. + * @return void + */ +function update_account_rate_limits( $response, $update_data, $post, $account_id, $last_headers ) { + + if ( empty( $account_id ) ) { + return; + } + + $accounts = get_option( 'autoshare_for_twitter_accounts', array() ); + + if ( empty( $accounts[ $account_id ] ) ) { + return; + } + + $rate_limits = parse_last_headers( + $last_headers, + array( + 'rate_limit_limit' => 'x_rate_limit_limit', + 'rate_limit_reset' => 'x_rate_limit_reset', + 'rate_limit_remaining' => 'x_rate_limit_remaining', + ) + ); + + $app_rate_limits = parse_last_headers( + $last_headers, + array( + 'app_limit_24hour_limit' => 'x_app_limit_24hour_limit', + 'app_limit_24hour_reset' => 'x_app_limit_24hour_reset', + 'app_limit_24hour_remaining' => 'x_app_limit_24hour_remaining', + ) + ); + + $user_rate_limits = parse_last_headers( + $last_headers, + array( + 'user_limit_24hour_limit' => 'x_user_limit_24hour_limit', + 'user_limit_24hour_reset' => 'x_user_limit_24hour_reset', + 'user_limit_24hour_remaining' => 'x_user_limit_24hour_remaining', + ) + ); + + foreach ( $accounts as $key => $account ) { + $current_rate_limits = ( isset( $account['rate_limits'] ) && is_array( $account['rate_limits'] ) ) ? $account['rate_limits'] : array(); + + // Update the "global" and app rate limits on all accounts. + $account_rate_limits = array_merge( $rate_limits, $app_rate_limits ); + + // Update the user rate limits on the account that made the request. + if ( $account['id'] === $account_id ) { + $account_rate_limits = array_merge( $user_rate_limits, $account_rate_limits ); + } + + // Merge the current rate limits with the new rate limits. + $account_rate_limits = array_merge( $current_rate_limits, $account_rate_limits ); + + $accounts[ $key ]['rate_limits'] = $account_rate_limits; + } + + update_option( 'autoshare_for_twitter_accounts', $accounts ); +} + +/** + * Register the Rate Monitor dashboard widget. + * + * @return void + */ +function register_rate_monitor_dashboard_widget() { + + wp_add_dashboard_widget( + 'autopost_for_x_rate_monitor_dashboard_widget', + esc_html__( 'Autopost for X — Rate Monitor', 'autoshare-for-twitter' ), + __NAMESPACE__ . '\display_rate_monitor_dashboard_widget' + ); +} + +/** + * Display the Rate Monitor dashboard widget. + * + * @return void + */ +function display_rate_monitor_dashboard_widget() { + $accounts = get_option( 'autoshare_for_twitter_accounts', array() ); + + if ( empty( $accounts ) ) { + printf( + '

%s

', + esc_html__( 'No X/Twitter accounts are connected. Please connect at least one X/Twitter account to continue using Autopost for X.', 'autoshare-for-twitter' ) + ); + return; + } + + $app_rate_limits_markup = ''; + $users_rate_limits_markup = ''; + + foreach ( $accounts as $account ) { + + $account_markup = ''; + + if ( ! empty( $account['rate_limits'] ) ) { + $account_markup = get_user_rate_limits_markup( $account['rate_limits'] ); + + if ( empty( $app_rate_limits_markup ) ) { // We only need to display the app rate limits once. + $app_rate_limits_markup = get_app_rate_limits_markup( $account['rate_limits'] ); + } + } else { + $account_markup = sprintf( + '

%s

', + esc_html__( 'No X/Twitter rate limit available yet. Make a post to X/Twitter first.', 'autoshare-for-twitter' ) + ); + } + + $users_rate_limits_markup .= sprintf( + '
+ %2$s +
+

@%3$s

+ %4$s +
+
', + esc_url( $account['profile_image_url'] ), + esc_attr( $account['name'] ), + esc_html( $account['username'] ), + $account_markup // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ); + } + + $footnotes = array( + __( 'User 24-Hour Limit: The maximum number of requests a single user can make across all API endpoints within a 24-hour period.', 'autoshare-for-twitter' ), + __( 'App 24-Hour Limit: The total number of API calls your app can make across all users within a 24-hour period.', 'autoshare-for-twitter' ), + ); + + $footnotes = array_map( + function ( $footnote ) { + return sprintf( + '
  • %1$s
  • ', + esc_html( $footnote ) + ); + }, + $footnotes + ); + + printf( + '
    +
    + %1$s +
    +
    + %2$s +
    +
    +

    %3$s %4$s

    + +
    +
    ', + $users_rate_limits_markup, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $app_rate_limits_markup, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + esc_html__( 'Note:', 'autoshare-for-twitter' ), + esc_html__( 'The displayed API rate limits are updated only when a tweet is posted. Since there is no dedicated endpoint for real-time usage data, the information provided may not fully reflect the current API usage, especially if other tweets are made through the same app.', 'autoshare-for-twitter' ), + implode( ' ', $footnotes ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ); +} + +/** + * Parse the last headers from the X/Twitter API response. + * + * @param array $last_headers The headers from the last request. + * @param array $map The map of X/Twitter headers to internal WP keys. + * @return array + */ +function parse_last_headers( $last_headers, $map ) { + + $parsed = array(); + + foreach ( $map as $key => $header ) { + + if ( ! isset( $last_headers[ $header ] ) ) { + continue; + } + + $parsed[ $key ] = sanitize_text_field( $last_headers[ $header ] ); + } + + return $parsed; +} + +/** + * Get human readable time. + * + * @param int $timestamp Timestamp. + * @param string $date_format Date format. + * @return string + */ +function human_readable_time( $timestamp, $date_format = '' ) { + + $timestamp = (int) $timestamp; + + $datetime = new \DateTime( '@' . $timestamp, new \DateTimeZone( 'UTC' ) ); + + if ( empty( $date_format ) ) { + $date_format = sprintf( + '%s %s', + esc_html( get_option( 'date_format' ) ), + esc_html( get_option( 'time_format' ) ) + ); + } + + $human_readable_time = $datetime->format( $date_format ); + $human_readable_time = sprintf( '%s (UTC)', $human_readable_time ); + + return $human_readable_time; +} + +/** + * Get user rate limits markup. + * + * @param array $rate_limits Rate limits. + * @return string + */ +function get_user_rate_limits_markup( $rate_limits ) { + + $remaining = isset( $rate_limits['user_limit_24hour_remaining'] ) ? $rate_limits['user_limit_24hour_remaining'] : ''; + $limit = isset( $rate_limits['user_limit_24hour_limit'] ) ? $rate_limits['user_limit_24hour_limit'] : ''; + $reset = isset( $rate_limits['user_limit_24hour_reset'] ) ? $rate_limits['user_limit_24hour_reset'] : ''; + + if ( empty( $remaining ) && empty( $limit ) && empty( $reset ) ) { + return sprintf( + '

    %s

    ', + esc_html__( 'No X/Twitter rate limit available yet. Make a post to X/Twitter first.', 'autoshare-for-twitter' ) + ); + } + + return get_rate_limits_markup( + __( 'User 24-Hour Limit:', 'autoshare-for-twitter' ), + $remaining, + $limit, + $reset + ); +} + +/** + * Get app rate limits markup. + * + * @param array $rate_limits Rate limits. + * @return string + */ +function get_app_rate_limits_markup( $rate_limits ) { + + $remaining = isset( $rate_limits['app_limit_24hour_remaining'] ) ? $rate_limits['app_limit_24hour_remaining'] : ''; + $limit = isset( $rate_limits['app_limit_24hour_limit'] ) ? $rate_limits['app_limit_24hour_limit'] : ''; + $reset = isset( $rate_limits['app_limit_24hour_reset'] ) ? $rate_limits['app_limit_24hour_reset'] : ''; + + return get_rate_limits_markup( + __( 'App 24-Hour Limit:', 'autoshare-for-twitter' ), + $remaining, + $limit, + $reset + ); +} + +/** + * Get rate limits markup. + * + * @param string $title Rate limit title. + * @param int $remaining Remaining rate limit. + * @param int $limit Total rate limit. + * @param int $reset Rate limit reset time. + * @return string + */ +function get_rate_limits_markup( $title, $remaining, $limit, $reset ) { + + $remaining = isset( $remaining ) ? (int) $remaining : esc_html__( 'N/A', 'autoshare-for-twitter' ); + $limit = isset( $limit ) ? (int) $limit : esc_html__( 'N/A', 'autoshare-for-twitter' ); + $reset = isset( $reset ) ? human_readable_time( $reset ) : esc_html__( 'N/A', 'autoshare-for-twitter' ); + + return sprintf( + '
    +

    %1$s %2$s

    +

    %3$s

    +
    ', + esc_html( $title ), + sprintf( + /* translators: %1$s: Remaining, %2$s: Limit */ + esc_html__( '%1$s of %2$s', 'autoshare-for-twitter' ), + esc_html( $remaining ), + esc_html( $limit ) + ), + sprintf( + /* translators: %1$s: Reset time */ + esc_html__( 'Resets on %1$s', 'autoshare-for-twitter' ), + esc_html( $reset ) + ) + ); +} diff --git a/src/js/components/TwitterAccounts.js b/src/js/components/TwitterAccounts.js index 79add787..8b77f9ac 100644 --- a/src/js/components/TwitterAccounts.js +++ b/src/js/components/TwitterAccounts.js @@ -1,8 +1,10 @@ -import { ToggleControl, ExternalLink } from '@wordpress/components'; -import { __ } from '@wordpress/i18n'; +import { ToggleControl, ExternalLink, Tooltip } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { dateI18n, getSettings } from '@wordpress/date'; import { useTweetAccounts } from '../hooks'; const { connectedAccounts, connectAccountUrl } = adminAutoshareForTwitter; +const settings = getSettings(); /** * Twitter accounts component. @@ -13,12 +15,32 @@ export function TwitterAccounts() { const accounts = connectedAccounts ? Object.values( connectedAccounts ) : []; + const [ firstAccount ] = accounts; + + const [ tweetAccounts ] = useTweetAccounts(); return (
    { accounts.map( ( account ) => ( ) ) } + { firstAccount && tweetAccounts?.length > 0 && ( + + ) } + { tweetAccounts?.length > 0 && ( +
    +

    + + { __( 'Note:', 'autoshare-for-twitter' ) } + { ' ' } + { __( + 'The displayed API rate limits are updated only when a tweet is posted. Since there is no dedicated endpoint for real-time usage data, the information provided may not fully reflect the current API usage, especially if other tweets are made through the same app.', + 'autoshare-for-twitter' + ) } +

    +
    + ) } + { __( 'Connect an account', 'autoshare-for-twitter' ) } @@ -39,32 +61,139 @@ function TwitterAccount( props ) { const [ tweetAccounts, setTweetAccounts ] = useTweetAccounts(); const { id, name, username, profile_image_url: profileUrl } = props; return ( -
    - { +
    + { + + @{ username } +
    + { name } +
    + + { + if ( checked ) { + setTweetAccounts( [ ...tweetAccounts, id ] ); + } else { + setTweetAccounts( + tweetAccounts.filter( + ( account ) => account !== id + ) + ); + } + } } + className="autoshare-for-twitter-account-toggle" + /> +
    + { tweetAccounts && tweetAccounts.includes( id ) && ( + + ) } + + ); +} + +/** + * Display user rate limits. + * + * @param {Object} props + * @param {Object} props.rate_limits - Rate limit data from the API. + * @return {JSX.Element} The account rate limits. + */ +function TwitterUserRateLimits( { rate_limits: rateLimits } ) { + if ( ! rateLimits || ! rateLimits.user_limit_24hour_limit ) { + return ( +

    + { __( + 'No X/Twitter rate limit available yet. Make a post to X/Twitter first.', + 'autoshare-for-twitter' + ) } +

    + ); + } + + return ( +
    + - - @{ username } -
    - { name } -
    - { - if ( checked ) { - setTweetAccounts( [ ...tweetAccounts, id ] ); - } else { - setTweetAccounts( - tweetAccounts.filter( - ( account ) => account !== id - ) - ); - } - } } - className="autoshare-for-twitter-account-toggle" +
    + ); +} + +/** + * Display app rate limits. + * + * @param {Object} props + * @param {Object} props.rate_limits - Rate limit data from the API. + * @return {JSX.Element} The account rate limits. + */ +function TwitterAppRateLimits( { rate_limits: rateLimits } ) { + return ( +
    +
    ); } + +/** + * Display rate limit details. + * + * @param {Object} props + * @param {string} props.title - The title of the rate limit (e.g., "Rate Limit"). + * @param {number} props.remaining - The remaining requests for this limit. + * @param {number} props.limit - The total limit for this type. + * @param {number} props.reset - The UNIX timestamp for when the limit resets. + * @param {string} props.tooltip - The tooltip for the rate limit. + * @return {JSX.Element} The rate limit details. + */ +function TwitterRateLimits( { title, remaining, limit, reset, tooltip } ) { + let formattedResetTime = __( 'N/A', 'autoshare-for-twitter' ); + if ( reset && settings?.formats?.datetime ) { + formattedResetTime = dateI18n( + settings.formats.datetime, + reset * 1000, + 'UTC' + ); + formattedResetTime = sprintf( '%1$s (UTC)', formattedResetTime ); + } + + return ( +
    +

    + + { title } + { ' ' } + { sprintf( + /* translators: %1$s: Remaining, %2$s: Limit */ + __( '%1$s of %2$s', 'autoshare-for-twitter' ), + remaining ?? __( 'N/A', 'autoshare-for-twitter' ), + limit ?? __( 'N/A', 'autoshare-for-twitter' ) + ) } +

    +

    + { formattedResetTime } +

    +
    + ); +}