Skip to content

Commit 2defaf4

Browse files
committed
feat: add a Who's Online widget to the Network Admin dashboard
1 parent 054c49e commit 2defaf4

3 files changed

Lines changed: 539 additions & 0 deletions

File tree

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
<?php
2+
/**
3+
* Network Dashboard Widget: Who's Online
4+
*
5+
* @package Presence_API
6+
*/
7+
8+
if ( ! defined( 'ABSPATH' ) ) {
9+
exit;
10+
}
11+
12+
/**
13+
* Handles the network dashboard's "Who's Online" widget with Heartbeat integration.
14+
*
15+
* The one new surface this plugin adds for multisite: everything else folds
16+
* into existing Network Admin screens (Sites and Users list columns), but a
17+
* dashboard widget mirrors the single-site "at a glance" pattern and has no
18+
* existing native screen to fold into.
19+
*/
20+
class WP_Presence_Network_Widget_Whos_Online {
21+
22+
/**
23+
* Maximum number of sites shown before linking out to the Sites list.
24+
*
25+
* @var int
26+
*/
27+
const VISIBLE_SITES = 5;
28+
29+
/**
30+
* Registers the network dashboard widget.
31+
*/
32+
public static function register() {
33+
if ( ! current_user_can( wp_presence_network_capability() ) ) {
34+
return;
35+
}
36+
37+
wp_add_dashboard_widget(
38+
'presence_network_whos_online',
39+
__( "Who's Online", 'presence-api' ),
40+
array( __CLASS__, 'render' )
41+
);
42+
43+
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_scripts' ) );
44+
}
45+
46+
/**
47+
* Enqueues the widget's JavaScript and CSS.
48+
*
49+
* @param string $hook_suffix The current admin page.
50+
*/
51+
public static function enqueue_scripts( $hook_suffix ) {
52+
if ( 'index.php' !== $hook_suffix ) {
53+
return;
54+
}
55+
56+
wp_enqueue_script( 'heartbeat' );
57+
wp_add_inline_script( 'heartbeat', self::get_inline_script() );
58+
59+
wp_register_style( 'presence-network-widget', false, array(), WP_PRESENCE_VERSION );
60+
wp_enqueue_style( 'presence-network-widget' );
61+
wp_add_inline_style( 'presence-network-widget', self::get_inline_css() );
62+
}
63+
64+
/**
65+
* Returns the inline CSS for the widget.
66+
*
67+
* @return string CSS code.
68+
*/
69+
private static function get_inline_css() {
70+
return '#presence-network-widget-list p { margin: 0; padding: 6px 12px; color: #646970; }
71+
#presence-network-widget-list .presence-user-list { margin: 0; }
72+
#presence-network-widget-list .presence-site-item { display: flex; align-items: center; gap: 8px; padding: 6px 12px; border-bottom: 1px solid #f0f0f1; }
73+
#presence-network-widget-list .presence-site-item:last-child { border-bottom: none; }
74+
#presence-network-widget-list .presence-site-info { flex: 1; min-width: 0; }
75+
#presence-network-widget-list .presence-site-count { color: #646970; font-size: 12px; }
76+
#presence-network-widget-list .presence-more-link { display: block; padding: 6px 12px; color: var(--wp-admin-theme-color, #2271b1); font-size: 13px; text-decoration: none; }
77+
#presence-network-widget-list .presence-more-link:hover { text-decoration: underline; }
78+
#presence-network-widget-list .presence-avatar-stack { display: inline-flex; align-items: center; }
79+
#presence-network-widget-list .presence-avatar-stack img { border-radius: 50%; width: 20px; height: 20px; margin-inline-start: -6px; box-shadow: 0 0 0 2px #fff; position: relative; }
80+
#presence-network-widget-list .presence-avatar-stack img:first-child { margin-inline-start: 0; }';
81+
}
82+
83+
/**
84+
* Renders the widget.
85+
*/
86+
public static function render() {
87+
echo '<div id="presence-network-widget-list" aria-live="polite" tabindex="-1">';
88+
self::render_summary( self::get_summary() );
89+
echo '</div>';
90+
}
91+
92+
/**
93+
* Reads the slice of the network this widget draws.
94+
*
95+
* Five sites with four avatars each, asked for as five sites with four
96+
* avatars each. The widget is on the network dashboard, so on a large
97+
* network this read is the one that has to stay cheap.
98+
*
99+
* @return array See wp_presence_get_network_summary().
100+
*/
101+
private static function get_summary() {
102+
return wp_presence_get_network_summary(
103+
array(
104+
'sites' => self::VISIBLE_SITES,
105+
'users_per_site' => WP_PRESENCE_NETWORK_AVATARS,
106+
)
107+
);
108+
}
109+
110+
/**
111+
* Returns how many sites are online beyond the ones being shown.
112+
*
113+
* @param array $summary Return value of self::get_summary().
114+
* @return int Site count, zero if the whole network fits.
115+
*/
116+
private static function overflow_count( $summary ) {
117+
return max( 0, (int) $summary['total_sites_online'] - count( $summary['sites'] ) );
118+
}
119+
120+
/**
121+
* Renders the compact site list for a network summary.
122+
*
123+
* @param array $summary Return value of self::get_summary().
124+
*/
125+
private static function render_summary( $summary ) {
126+
if ( empty( $summary['sites'] ) ) {
127+
echo '<p>' . esc_html__( 'No users are currently online anywhere on the network.', 'presence-api' ) . '</p>';
128+
return;
129+
}
130+
131+
echo '<ul class="presence-user-list" aria-label="' . esc_attr__( 'Sites with online users', 'presence-api' ) . '">';
132+
133+
foreach ( $summary['sites'] as $site ) {
134+
echo '<li class="presence-site-item">';
135+
echo wp_kses_post( wp_presence_render_avatar_stack( $site['users'], WP_PRESENCE_NETWORK_AVATARS ) );
136+
echo '<span class="presence-site-info"><a href="' . esc_url( $site['url'] ) . '">' . esc_html( $site['domain'] . $site['path'] ) . '</a></span>';
137+
echo '<span class="presence-site-count">' . (int) $site['user_count'] . '</span>';
138+
echo '</li>';
139+
}
140+
141+
echo '</ul>';
142+
143+
$overflow = self::overflow_count( $summary );
144+
145+
if ( $overflow ) {
146+
printf(
147+
'<a href="%1$s" class="presence-more-link">%2$s</a>',
148+
esc_url( network_admin_url( 'sites.php' ) ),
149+
esc_html(
150+
sprintf(
151+
/* translators: %d: Number of additional sites with online users. */
152+
_n( '+%d more site — view all', '+%d more sites — view all', $overflow, 'presence-api' ),
153+
$overflow
154+
)
155+
)
156+
);
157+
}
158+
}
159+
160+
/**
161+
* Handles the heartbeat received event for the network dashboard widget.
162+
*
163+
* Self-gates on a widget-specific ping key so every other admin screen's
164+
* tick costs one empty() check here, never the capability check or the
165+
* summary query.
166+
*
167+
* @param array $response The Heartbeat response.
168+
* @param array $data The $_POST data sent.
169+
* @param string $screen_id The screen ID.
170+
* Nonce verification is handled by WordPress in wp_ajax_heartbeat().
171+
*
172+
* @return array The Heartbeat response.
173+
*/
174+
public static function heartbeat_received( $response, $data, $screen_id ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Required by filter signature.
175+
if ( empty( $data['presence-network-widget-ping'] ) ) {
176+
return $response;
177+
}
178+
179+
if ( ! current_user_can( wp_presence_network_capability() ) ) {
180+
return $response;
181+
}
182+
183+
$summary = self::get_summary();
184+
$overflow = self::overflow_count( $summary );
185+
$hash = self::hash_summary( $summary, $overflow );
186+
$client_hash = isset( $data['presence-network-widget-hash'] ) ? sanitize_text_field( $data['presence-network-widget-hash'] ) : '';
187+
188+
if ( $client_hash && $client_hash === $hash ) {
189+
$response['presence-network-widget-unchanged'] = true;
190+
191+
return $response;
192+
}
193+
194+
$response['presence-network-widget'] = $summary['sites'];
195+
$response['presence-network-widget-overflow'] = $overflow;
196+
$response['presence-network-widget-hash'] = $hash;
197+
198+
return $response;
199+
}
200+
201+
/**
202+
* Hashes the state this widget draws.
203+
*
204+
* The payload itself, rather than a picked-out subset of it. A hash over
205+
* blog IDs and user IDs alone held a stale rename or a changed avatar on
206+
* screen for as long as the same people stayed online, and a hash over the
207+
* whole network never matched twice on a network large enough for the sixth
208+
* site to keep changing out of sight.
209+
*
210+
* The read path already returns sites busiest-first and users by name, so
211+
* there is nothing left to normalize here.
212+
*
213+
* @param array $summary Return value of self::get_summary().
214+
* @param int $overflow Sites online beyond the ones being sent.
215+
* @return string The state hash.
216+
*/
217+
private static function hash_summary( $summary, $overflow ) {
218+
return md5( (string) wp_json_encode( array( $summary['sites'], $overflow ) ) );
219+
}
220+
221+
/**
222+
* Returns the inline JavaScript for Heartbeat integration.
223+
*
224+
* @return string JavaScript code.
225+
*/
226+
private static function get_inline_script() {
227+
$i18n_json = wp_json_encode(
228+
array(
229+
'noUsersOnline' => __( 'No users are currently online anywhere on the network.', 'presence-api' ),
230+
'viewAll' => __( 'view all', 'presence-api' ),
231+
)
232+
);
233+
234+
return sprintf(
235+
<<<'JS'
236+
(function($) {
237+
if (typeof wp === 'undefined' || typeof wp.heartbeat === 'undefined') {
238+
return;
239+
}
240+
241+
var i18n = %s;
242+
var viewAllUrl = %s;
243+
var avatarMax = %d;
244+
var lastHash = '';
245+
var lastSignature = '';
246+
247+
function esc(str) {
248+
var el = document.createElement('span');
249+
el.textContent = str;
250+
return el.innerHTML;
251+
}
252+
253+
function buildAvatarStack(users) {
254+
var stackMax = Math.min(users.length, avatarMax);
255+
var html = '<span class="presence-avatar-stack">';
256+
users.slice(0, stackMax).forEach(function(user, idx) {
257+
if (user.avatar_url) {
258+
html += '<img src="' + esc(user.avatar_url) + '" width="20" height="20" style="z-index:' + (stackMax - idx) + '" alt="' + esc(user.display_name) + '" />';
259+
}
260+
});
261+
html += '</span>';
262+
return html;
263+
}
264+
265+
// Already cut to the sites and avatars this widget shows, so nothing is
266+
// sliced here; overflow is a count the server sends, not what is left over.
267+
function buildListHtml(sites, overflow) {
268+
if (!sites.length) {
269+
return '<p>' + esc(i18n.noUsersOnline) + '</p>';
270+
}
271+
272+
var html = '<ul class="presence-user-list">';
273+
sites.forEach(function(site) {
274+
html += '<li class="presence-site-item">' + buildAvatarStack(site.users);
275+
html += '<span class="presence-site-info"><a href="' + esc(site.url) + '">' + esc(site.domain + site.path) + '</a></span>';
276+
html += '<span class="presence-site-count">' + site.user_count + '</span></li>';
277+
});
278+
html += '</ul>';
279+
280+
if (overflow > 0) {
281+
html += '<a href="' + esc(viewAllUrl) + '" class="presence-more-link">+' + overflow + ' — ' + esc(i18n.viewAll) + '</a>';
282+
}
283+
284+
return html;
285+
}
286+
287+
$(document).on('heartbeat-send', function(event, data) {
288+
data['presence-network-widget-ping'] = true;
289+
if (lastHash) {
290+
data['presence-network-widget-hash'] = lastHash;
291+
}
292+
});
293+
294+
$(document).on('heartbeat-tick', function(event, data) {
295+
if (data['presence-network-widget-unchanged']) {
296+
return;
297+
}
298+
299+
if (!data['presence-network-widget']) {
300+
return;
301+
}
302+
303+
lastHash = data['presence-network-widget-hash'] || '';
304+
305+
var container = $('#presence-network-widget-list');
306+
if (!container.length) {
307+
return;
308+
}
309+
310+
var sites = data['presence-network-widget'];
311+
var overflow = data['presence-network-widget-overflow'] || 0;
312+
313+
// Signature over what gets drawn, matching the server-side hash: a
314+
// rename or a new avatar has to repaint, and the order is already the
315+
// order it renders in.
316+
var sig = JSON.stringify([sites, overflow]);
317+
318+
if (sig !== lastSignature) {
319+
container.html(buildListHtml(sites, overflow));
320+
lastSignature = sig;
321+
}
322+
});
323+
})(jQuery);
324+
JS,
325+
$i18n_json,
326+
wp_json_encode( esc_url_raw( network_admin_url( 'sites.php' ) ) ),
327+
WP_PRESENCE_NETWORK_AVATARS
328+
);
329+
}
330+
}

‎presence-api.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ function wp_presence_register_network_summary_table() {
108108
require_once WP_PRESENCE_PLUGIN_DIR . 'includes/network-functions.php';
109109
require_once WP_PRESENCE_PLUGIN_DIR . 'includes/network-sites-list.php';
110110
require_once WP_PRESENCE_PLUGIN_DIR . 'includes/network-user-list.php';
111+
require_once WP_PRESENCE_PLUGIN_DIR . 'includes/widgets/class-wp-presence-network-widget-whos-online.php';
111112
}
112113

113114
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
@@ -364,6 +365,9 @@ function wp_presence_plugin_action_links( $links ) {
364365
add_filter( 'users_list_table_query_args', 'wp_presence_filter_network_online_users' );
365366
add_filter( 'wpmu_users_columns', 'wp_presence_register_network_users_column' );
366367
add_filter( 'manage_users-network_custom_column', 'wp_presence_render_network_users_column', 10, 3 );
368+
369+
add_action( 'wp_network_dashboard_setup', array( 'WP_Presence_Network_Widget_Whos_Online', 'register' ) );
370+
add_filter( 'heartbeat_received', array( 'WP_Presence_Network_Widget_Whos_Online', 'heartbeat_received' ), 10, 3 );
367371
}
368372

369373
if ( ( defined( 'WP_DEBUG' ) && WP_DEBUG )

0 commit comments

Comments
 (0)