PageSourceSearch

https://herb-whisperer.com/wp-content/plugins/jetonomy-pro/assets/js/pro-view.js?ver=2.0.0+1790654525

js herb-whisperer.com collected 2026-10-08 23:04:28 UTC 51,564 bytes, 1,356 lines download raw bytes

1/**
2 * Jetonomy Pro — Interactivity API Store Extension
3 *
4 * Merges Pro-specific actions (reactions, messaging, polls) into the
5 * core 'jetonomy' IA store. Loaded as a module dependency of the
6 * core view.js so the store namespace is shared.
7 */
8import { store, getContext, getElement } from '@wordpress/interactivity';
9
10/**
11 * Safely create a DOM element with attributes and text.
12 */
13function el( tag, attrs = {}, text = '' ) {
14	const node = document.createElement( tag );
15	Object.entries( attrs ).forEach( ( [ k, v ] ) => {
16		if ( k === 'style' && typeof v === 'object' ) {
17			Object.assign( node.style, v );
18		} else {
19			node.setAttribute( k, v );
20		}
21	} );
22	if ( text ) node.textContent = text;
23	return node;
24}
25
26const { state, actions } = store( 'jetonomy', {
27	state: {
28		// Messaging
29		showComposeModal: false,
30		composeSending: false,
31		// Polls
32		pollVoting: false,
33	},
34
35	actions: {
36
37		/* ═══════════════════════════════════════
38		 *  REACTIONS
39		 * ═══════════════════════════════════════ */
40
41		// [1.4.3 WS3-B] Migrated to jetonomyOptimistic.gen. Previous
42		// implementation had no revert path — a 403 / 5xx would leave the
43		// chip in the optimistic state forever (same root cause as the
44		// downvote bug in core, Basecamp 9886066727). We snapshot the
45		// summary's child node list before mutating and restore it on failure
46		// using DOM clones (no innerHTML — avoids any XSS shape concerns even
47		// though the source is our own SSR markup).
48		*toggleReaction( event ) {
49			/*
50			 * DELEGATED off the .jt-reactions wrapper, which is always server
51			 * rendered and therefore always hydrated.
52			 *
53			 * Binding per chip does not work for the chip the optimistic path
54			 * creates: the Interactivity API wires data-wp-on--* during its own
55			 * render pass, so a button spliced in afterwards with
56			 * document.createElement() carries the attribute and has no live
57			 * listener. It looked identical in the DOM and did nothing until a
58			 * reload re-rendered it in PHP. Delegating to a hydrated ancestor
59			 * covers chips that exist at render AND chips created later, which
60			 * is the only version of this that survives the next refactor.
61			 *
62			 * Resolve the actual clicked control from the event. Falls back to
63			 * getElement().ref so a direct binding still behaves if one is ever
64			 * added back.
65			 */
66			const wrapRef = getElement().ref;
67			const elRef   = event?.target?.closest?.( '[data-emoji]' ) || wrapRef;
68			const ctx     = getContext();
69			const emoji   = elRef?.dataset?.emoji;
70
71			// A click inside the widget but not on a reaction control - the
72			// picker's own backdrop, whitespace in the summary row - reaches
73			// the delegated handler too. Ignore it rather than treating the
74			// wrapper as a chip.
75			if ( ! emoji ) return;
76
77			// Close the picker after selecting.
78			ctx.reactionPickerOpen = false;
79			actions._closeReactionPicker( elRef );
80
81			const apiBase = state.apiBase;
82			const nonce   = state._nonce || state.nonce;
83
84			if ( ! emoji || ! ctx.objectType || ! ctx.objectId ) return;
85
86			// Logged-out users cannot react. The REST endpoint rejects them
87			// too, but without this early return the optimistic UI below
88			// would flash the chip in and give a false "applied" impression.
89			if ( state.isLoggedIn === false ) {
90				const msg = ( state.i18n && state.i18n.loginRequired ) || 'Please sign in to use this.';
91				if ( window.bnToast ) {
92					window.bnToast( msg );
93				}
94				if ( state.loginUrl ) {
95					window.location.assign( state.loginUrl );
96				}
97				return;
98			}
99
100			const objectTypePlural = ctx.objectType === 'reply' ? 'replies' : `${ ctx.objectType }s`;
101			const endpoint = `${ apiBase }/${ objectTypePlural }/${ ctx.objectId }/reactions`;
102			const container = elRef.closest( '.jt-reactions' );
103
104			// Helper — read the count span from a chip. MUST select the
105			// dedicated `.jt-reaction-count` span (or fall back to the chip's
106			// last child); naive `querySelector('span')` returns the icon
107			// first (NaN bug, fixed in 1.3.7).
108			const countOf = chipEl => chipEl?.querySelector( '.jt-reaction-count' )
109				|| chipEl?.lastElementChild
110				|| null;
111
112			const popChip = chipEl => {
113				if ( ! chipEl ) return;
114				chipEl.classList.remove( 'jt-reaction-chip--pop' );
115				void chipEl.offsetWidth;
116				chipEl.classList.add( 'jt-reaction-chip--pop' );
117				setTimeout( () => chipEl.classList.remove( 'jt-reaction-chip--pop' ), 360 );
118			};
119
120			// One place that says what "mine" looks like on a chip, used by the
121			// create path AND the post-response sync. They used to disagree: the
122			// sync toggled the is-mine class only, so aria-pressed kept whatever
123			// the server rendered and went stale the moment you toggled. A
124			// screen-reader user was told "pressed" on a reaction they had just
125			// removed. The label is the reaction NAME and never changes - state
126			// lives in aria-pressed, which is the standard toggle pattern and
127			// keeps this free of hardcoded English.
128			const setChipState = ( chipEl, mine, label ) => {
129				if ( ! chipEl ) return;
130				chipEl.classList.toggle( 'is-mine', !! mine );
131				chipEl.setAttribute( 'aria-pressed', mine ? 'true' : 'false' );
132				if ( label ) chipEl.setAttribute( 'aria-label', label );
133			};
134
135			yield window.jetonomyOptimistic.gen( {
136				apply: () => {
137					let summary = container?.querySelector( '.jt-reaction-summary' );
138					const summaryExistedBefore = !! summary;
139					if ( ! summary && container ) {
140						summary = document.createElement( 'span' );
141						summary.className = 'jt-reaction-summary';
142						container.insertBefore( summary, container.querySelector( '.jt-reaction-trigger-wrap' ) );
143					}
144					// Deep-clone every child so revert can restore the exact
145					// same chip nodes (incl. SVG icons) without parsing HTML.
146					const childrenSnapshot = summary
147						? Array.from( summary.childNodes ).map( ( n ) => n.cloneNode( true ) )
148						: [];
149
150					let chip = summary?.querySelector( `.jt-reaction-chip[data-emoji="${ emoji }"]` );
151					const wasReacted = !! chip?.classList.contains( 'is-mine' );
152
153					// Single-reaction: remove previous "is-mine" chip first.
154					const prevMine = summary?.querySelector( '.jt-reaction-chip.is-mine' );
155					if ( prevMine && prevMine !== chip ) {
156						const prevCountEl = countOf( prevMine );
157						const prevCount = parseInt( prevCountEl?.textContent || '1', 10 ) - 1;
158						if ( prevCount <= 0 ) {
159							prevMine.remove();
160						} else {
161							if ( prevCountEl ) prevCountEl.textContent = String( prevCount );
162							prevMine.classList.remove( 'is-mine' );
163						}
164					}
165
166					if ( wasReacted ) {
167						const countEl = countOf( chip );
168						const count = parseInt( countEl?.textContent || '1', 10 ) - 1;
169						if ( count <= 0 ) {
170							chip?.remove();
171						} else {
172							if ( countEl ) countEl.textContent = String( count );
173							chip?.classList.remove( 'is-mine' );
174						}
175					} else if ( chip ) {
176						const countEl = countOf( chip );
177						const count = parseInt( countEl?.textContent || '0', 10 ) + 1;
178						if ( countEl ) countEl.textContent = String( count );
179						chip.classList.add( 'is-mine' );
180						popChip( chip );
181					} else if ( summary ) {
182						// Build the SAME element PHP renders, not a lookalike.
183						// This was a <span> with no handler, no aria-pressed and
184						// no tabindex, so the chip you get the instant you react
185						// - exactly when you are most likely to click it again to
186						// undo - was inert until you reloaded the page. The
187						// server chip is a button; so is this one.
188						chip = document.createElement( 'button' );
189						chip.type = 'button';
190						chip.className = 'jt-reaction-chip';
191						chip.dataset.emoji = emoji;
192						const reactionLabel = container.querySelector( `.jt-reaction-picker__item[data-emoji="${ emoji }"]` )?.getAttribute( 'aria-label' ) || emoji;
193						setChipState( chip, true, reactionLabel );
194						// Decorative: the button carries the name, so the icon
195						// must not announce it again. Mirrors the server, which
196						// passes $decorative = true for the same reason.
197						const pickerIcon = container.querySelector( `.jt-reaction-picker__item[data-emoji="${ emoji }"] .jt-reaction-icon` );
198						if ( pickerIcon ) {
199							const iconClone = pickerIcon.cloneNode( true );
200							iconClone.setAttribute( 'aria-hidden', 'true' );
201							iconClone.removeAttribute( 'role' );
202							iconClone.removeAttribute( 'aria-label' );
203							chip.appendChild( iconClone );
204						} else {
205							const pickerImg = container.querySelector( `.jt-reaction-picker__item[data-emoji="${ emoji }"] img` );
206							if ( pickerImg ) {
207								const img = pickerImg.cloneNode( true );
208								img.width = 22;
209								img.height = 22;
210								img.setAttribute( 'aria-hidden', 'true' );
211								img.removeAttribute( 'role' );
212								img.removeAttribute( 'aria-label' );
213								chip.appendChild( img );
214							}
215						}
216						const countEl = document.createElement( 'span' );
217						countEl.className = 'jt-reaction-count';
218						countEl.textContent = '1';
219						chip.appendChild( countEl );
220						summary.appendChild( chip );
221						popChip( chip );
222					}
223
224					return { summary, summaryExistedBefore, childrenSnapshot };
225				},
226				fetch: () => {
227					// Optimistic's contract wants Promise<Response>, but
228					// restFetch returns { ok, status, data }. Wrap the result
229					// back into a Response so the 1.4.3 nonce-refresh path is
230					// available without rewriting the helper.
231					const path = endpoint.replace( apiBase, '' );
232					if ( window.jetonomyRest && typeof window.jetonomyRest.restFetch === 'function' ) {
233						return window.jetonomyRest.restFetch( path, {
234							method: 'POST',
235							body: { emoji },
236						} ).then( r => new Response(
237							r.data === null || r.data === undefined ? '' : JSON.stringify( r.data ),
238							{ status: r.status, headers: { 'Content-Type': 'application/json' } }
239						) );
240					}
241					return fetch( endpoint, {
242						method: 'POST',
243						headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
244						credentials: 'same-origin',
245						body: JSON.stringify( { emoji } ),
246					} );
247				},
248				onSuccess: ( data, _response, snap ) => {
249					const summary = snap?.summary;
250					if ( ! data || ! summary ) return;
251					if ( data.counts ) {
252						Object.entries( data.counts ).forEach( ( [ slug, count ] ) => {
253							const c = summary.querySelector( `.jt-reaction-chip[data-emoji="${ slug }"]` );
254							const cntEl = countOf( c );
255							if ( cntEl ) cntEl.textContent = String( count );
256						} );
257						summary.querySelectorAll( '.jt-reaction-chip' ).forEach( c => {
258							const n = parseInt( countOf( c )?.textContent || '0', 10 );
259							if ( n <= 0 ) c.remove();
260						} );
261					}
262					if ( Array.isArray( data.user_reactions ) ) {
263						summary.querySelectorAll( '.jt-reaction-chip' ).forEach( c => {
264							const slug = c.dataset.emoji;
265							// Through the shared helper, so aria-pressed follows
266							// the class instead of drifting away from it.
267							setChipState( c, data.user_reactions.includes( slug ) );
268						} );
269					}
270				},
271				revert: ( snap ) => {
272					if ( ! snap?.summary ) return;
273					if ( snap.summaryExistedBefore ) {
274						// Clear current children and re-attach cloned originals.
275						while ( snap.summary.firstChild ) {
276							snap.summary.removeChild( snap.summary.firstChild );
277						}
278						snap.childrenSnapshot.forEach( ( n ) => snap.summary.appendChild( n ) );
279					} else {
280						// We created the summary in apply; remove it cleanly.
281						snap.summary.remove();
282					}
283				},
284				toastOnError: true,
285				errorFallback: ( state.i18n && state.i18n.reactionFailed ) || 'Could not save reaction.',
286			} );
287		},
288
289		showReactionPicker() {
290			const ctx = getContext();
291			ctx.reactionPickerOpen = true;
292		},
293
294		// [1.4.3 WS3-B] Migrated to jetonomySmartDropdown with
295		// group='jt-reaction' — fixes the multi-open bug where opening the
296		// picker on reply B left reply A's picker visible (the SSR `hidden`
297		// + ctx.reactionPickerOpen flip was per-context with no cross-card
298		// awareness). The shared group closes the prior picker on open.
299		toggleReactionPicker() {
300			const ctx = getContext();
301			const trigger = getElement().ref;
302			const wrap = trigger.closest( '.jt-reaction-trigger-wrap' );
303			const panel = wrap?.querySelector( '.jt-reaction-picker' );
304			if ( ! panel ) {
305				ctx.reactionPickerOpen = ! ctx.reactionPickerOpen;
306				return;
307			}
308			panel.removeAttribute( 'hidden' );
309
310			if ( ! trigger._jtDropdown ) {
311				trigger._jtDropdown = window.jetonomySmartDropdown( trigger, panel, {
312					placement: 'top-end',
313					group: 'jt-reaction',
314					closeOnOutside: true,
315					closeOnEscape: true,
316					onOpen: () => { ctx.reactionPickerOpen = true; },
317					onClose: () => { ctx.reactionPickerOpen = false; },
318				} );
319				panel.style.display = 'none';
320			}
321			trigger._jtDropdown.toggle();
322		},
323
324		// Internal — called by toggleReaction after a pick. Walks up from the
325		// emoji button to find the owning trigger and closes its dropdown.
326		_closeReactionPicker( elRef ) {
327			const wrap = elRef.closest( '.jt-reaction-trigger-wrap' );
328			const trigger = wrap?.querySelector( '.jt-reaction-trigger' );
329			if ( trigger && trigger._jtDropdown && trigger._jtDropdown.isOpen ) {
330				trigger._jtDropdown.close();
331			}
332		},
333
334		/* ═══════════════════════════════════════
335		 *  PRIVATE MESSAGING
336		 * ═══════════════════════════════════════ */
337
338		/**
339		 * Opens the compose panel in the wide right-hand thread area.
340		 *
341		 * Before 1.3.6 this built a cramped compose form inside the narrow
342		 * left sidebar via manual DOM, and queried a class name
343		 * (.jt-messages-header) that the template no longer emits — so the
344		 * button was effectively dead. Now the compose form is a first-class
345		 * part of messages.php that renders in the right panel; toggling it
346		 * is a one-line state flip.
347		 *
348		 * The compose panel only exists in messages.php. Inside a
349		 * conversation view there is nothing to unhide, so flipping the
350		 * state made the button silently dead there (#9977608733) — instead,
351		 * navigate to the messages index with ?compose=1, which the
352		 * extension turns into msgComposeOpen=true at render.
353		 */
354		newConversation() {
355			const wrap = document.querySelector( '.jt-msg-thread-compose-wrap' );
356			if ( ! wrap ) {
357				const base = ( state.communityBase || '' ).replace( /\/+$/, '' );
358				window.location.href = base + '/messages/?compose=1';
359				return;
360			}
361			state.msgComposeOpen = true;
362			state.submitError    = '';
363			// Focus the recipient input on next tick (after the panel unhides).
364			requestAnimationFrame( () => {
365				const input = document.querySelector( '.jt-msg-compose-recipient' );
366				if ( input ) input.focus();
367			} );
368		},
369
370		closeComposePanel() {
371			state.msgComposeOpen = false;
372			state.submitError    = '';
373		},
374
375		/**
376		 * Start a direct message. Handles both entry points:
377		 *
378		 *   • /messages/?to={login}  — recipient is pre-resolved server-side
379		 *                              and flowed in via ctx.recipientId.
380		 *   • "New" button           — recipient comes from the username input,
381		 *                              resolved via /users/by-login/{name}.
382		 *
383		 * Errors surface inline via state.submitError (trust-level block,
384		 * user-not-found, network failure) so the user never sees a silent
385		 * failure.
386		 */
387		*startDirectMessage( event ) {
388			event.preventDefault();
389			const ctx  = getContext();
390			const form = getElement().ref;
391			const body = form?.querySelector( 'textarea[name="message"]' );
392			const text = body?.value?.trim() || '';
393
394			if ( ! text ) return;
395
396			state.isSubmitting = true;
397			state.submitError  = '';
398
399			try {
400				let recipientId = ctx.recipientId || 0;
401
402				// No prefilled recipient? Resolve the username from the input.
403				if ( ! recipientId ) {
404					const recipInput = form?.querySelector( 'input[name="recipient"]' );
405					const recipLogin = recipInput?.value?.trim() || '';
406					if ( ! recipLogin ) {
407						state.submitError = state.i18n?.recipientRequired || 'Please enter a recipient.';
408						return;
409					}
410					const userRes = yield fetch(
411						`${ state.apiBase }/users/by-login/${ encodeURIComponent( recipLogin ) }`,
412						{ headers: { 'X-WP-Nonce': state._nonce || state.nonce }, credentials: 'same-origin' }
413					);
414					if ( ! userRes.ok ) {
415						state.submitError = ( state.i18n?.userNotFound || 'User not found: ' ) + recipLogin;
416						return;
417					}
418					const userData = yield userRes.json();
419					recipientId    = userData.id || userData.user_id;
420					if ( ! recipientId ) {
421						state.submitError = ( state.i18n?.userNotFound || 'User not found: ' ) + recipLogin;
422						return;
423					}
424				}
425
426				const res = yield fetch( `${ state.apiBase }/conversations`, {
427					method: 'POST',
428					headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': state._nonce || state.nonce },
429					credentials: 'same-origin',
430					body: JSON.stringify( { recipient_ids: [ recipientId ], message: text } ),
431				} );
432
433				if ( ! res.ok ) {
434					const err = yield res.json().catch( () => ( {} ) );
435					const msg = err.message || state.i18n?.messageSendFailed || 'Failed to send message.';
436					state.submitError = msg;
437					if ( window.bnToast ) window.bnToast( msg );
438					return;
439				}
440
441				const data = yield res.json();
442				if ( data.conversation_id ) {
443					window.location.href = state.communityBase + '/messages/' + data.conversation_id + '/';
444				} else {
445					window.location.reload();
446				}
447			} catch {
448				const msg = state.i18n?.networkError || 'Network error. Please try again.';
449				state.submitError = msg;
450				if ( window.bnToast ) window.bnToast( msg );
451			} finally {
452				state.isSubmitting = false;
453			}
454		},
455
456		sendMessage( event ) {
457			event.preventDefault();
458			const ctx  = getContext();
459			const form = getElement().ref;
460			const body = form.querySelector( '[name="message"], [name="content"], textarea' );
461
462			if ( ! body || ! body.value.trim() ) return;
463
464			const i18n = state.i18n || {};
465			const sendingLabel = i18n.messageSending || 'Sending...';
466			const sendLabel = i18n.messageSend || 'Send';
467			const sendFailedLabel = i18n.messageSendFailed || 'Failed to send. Please try again.';
468
469			const sendBtn = form.querySelector( 'button[type="submit"], .jt-btn-fill' );
470			if ( sendBtn ) { sendBtn.disabled = true; sendBtn.textContent = sendingLabel; }
471
472			fetch( `${ state.apiBase }/conversations/${ ctx.conversationId }/messages`, {
473				method: 'POST',
474				headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': state._nonce || state.nonce },
475				credentials: 'same-origin',
476				body: JSON.stringify( { content: body.value.trim() } ),
477			} )
478			.then( r => r.json() )
479			.then( () => { body.value = ''; window.location.reload(); } )
480			.catch( () => { if ( window.bnToast ) window.bnToast( sendFailedLabel ); } )
481			.finally( () => { if ( sendBtn ) { sendBtn.disabled = false; sendBtn.textContent = sendLabel; } } );
482		},
483
484		sendMessageOnEnter( event ) {
485			if ( event.key === 'Enter' && ( event.ctrlKey || event.metaKey ) ) {
486				event.preventDefault();
487				actions.sendMessage( event );
488			}
489		},
490
491		*loadOlderMessages( event ) {
492			// Cursor pagination via GET /conversations/:id/messages?before=<oldest_id>.
493			// The button stores the current oldest message id in data-before-id;
494			// on each successful fetch we prepend the returned messages, then
495			// update data-before-id to the new oldest. has_more=false hides the
496			// button. State.loadingOlder is a soft guard against double-clicks.
497			if ( event && typeof event.preventDefault === 'function' ) {
498				event.preventDefault();
499			}
500			if ( state.loadingOlder ) {
501				return;
502			}
503
504			const ctx     = getContext();
505			const btn     = getElement().ref;
506			const list    = document.getElementById( 'jt-msg-list' );
507			if ( ! btn || ! list || ! ctx.conversationId ) {
508				return;
509			}
510
511			const beforeId = parseInt( btn.dataset.beforeId || '0', 10 );
512			if ( ! beforeId ) {
513				return;
514			}
515
516			state.loadingOlder = true;
517			const origLabel    = btn.textContent;
518			btn.disabled       = true;
519			btn.textContent    = ( state.i18n && state.i18n.loadingOlder ) || 'Loading…';
520
521			try {
522				const url = `${ state.apiBase }/conversations/${ ctx.conversationId }/messages?before=${ beforeId }&limit=50`;
523				const res = yield fetch( url, {
524					credentials: 'same-origin',
525					headers: { 'X-WP-Nonce': state._nonce || state.nonce },
526				} );
527
528				if ( ! res.ok ) {
529					throw new Error( 'failed' );
530				}
531
532				const body = yield res.json();
533				const rows = Array.isArray( body.data ) ? body.data : [];
534				const meta = body.meta || {};
535
536				if ( rows.length ) {
537					// Build the prepended fragment in chronological order (rows
538					// arrive oldest→newest). We render server-side bubbles via
539					// a thin client template, escaping text content but allowing
540					// the server-provided HTML for the bubble itself.
541					const frag = document.createDocumentFragment();
542					rows.forEach( ( m ) => {
543						const isOwn    = parseInt( m.sender_id, 10 ) === parseInt( state.currentUserId, 10 );
544						const isSystem = !! m.is_system;
545						const wrap     = document.createElement( 'div' );
546
547						if ( isSystem ) {
548							wrap.className = 'jt-msg jt-msg--system';
549							const bubble   = document.createElement( 'div' );
550							bubble.className = 'jt-msg-bubble';
551							bubble.textContent = m.content_plain || '';
552							wrap.appendChild( bubble );
553						} else {
554							wrap.className = 'jt-msg' + ( isOwn ? ' jt-msg--own' : '' );
555
556							if ( ! isOwn && m.sender_avatar ) {
557								const img = document.createElement( 'img' );
558								img.src = m.sender_avatar;
559								img.alt = '';
560								img.width = 32;
561								img.height = 32;
562								img.className = 'jt-msg-avatar';
563								wrap.appendChild( img );
564							}
565
566							const body = document.createElement( 'div' );
567
568							if ( ! isOwn && m.sender_name ) {
569								const name = document.createElement( 'div' );
570								name.className = 'jt-msg-sender';
571								name.textContent = m.sender_name;
572								body.appendChild( name );
573							} else if ( isOwn ) {
574								const name = document.createElement( 'div' );
575								name.className = 'jt-msg-sender';
576								name.textContent = ( state.i18n && state.i18n.you ) || 'You';
577								body.appendChild( name );
578							}
579
580							const bubble = document.createElement( 'div' );
581							bubble.className = 'jt-msg-bubble';
582							// content is server-formatted (wp_kses_post on output);
583							// trust it the same way the SSR template does.
584							bubble.innerHTML = m.content || '';
585							body.appendChild( bubble );
586
587							if ( m.created_at_human ) {
588								const time = document.createElement( 'div' );
589								time.className = 'jt-msg-time';
590								time.textContent = m.created_at_human;
591								body.appendChild( time );
592							}
593
594							wrap.appendChild( body );
595
596							if ( isOwn && state.currentUserAvatar ) {
597								const img = document.createElement( 'img' );
598								img.src = state.currentUserAvatar;
599								img.alt = '';
600								img.width = 32;
601								img.height = 32;
602								img.className = 'jt-msg-avatar';
603								wrap.appendChild( img );
604							}
605						}
606
607						frag.appendChild( wrap );
608					} );
609
610					// Insert the new batch right after the load-more wrapper so
611					// the older messages appear above the existing thread but
612					// below the button.
613					const loader = list.querySelector( '.jt-msg-load-more' );
614					if ( loader && loader.nextSibling ) {
615						list.insertBefore( frag, loader.nextSibling );
616					} else if ( loader ) {
617						list.appendChild( frag );
618					} else {
619						list.insertBefore( frag, list.firstChild );
620					}
621
622					// Update cursor to the new oldest id.
623					const firstId = parseInt( rows[ 0 ].id, 10 );
624					if ( firstId ) {
625						btn.dataset.beforeId = String( firstId );
626					}
627				}
628
629				if ( ! meta.has_more ) {
630					// Hide the entire wrapper so layout collapses cleanly.
631					const wrap = btn.closest( '.jt-msg-load-more' );
632					if ( wrap ) {
633						wrap.hidden = true;
634					} else {
635						btn.hidden = true;
636					}
637				}
638			} catch ( e ) {
639				if ( window.bnToast ) {
640					window.bnToast(
641						( state.i18n && state.i18n.failedLoadOlder ) ||
642						'Failed to load older messages.'
643					);
644				}
645			} finally {
646				state.loadingOlder = false;
647				btn.disabled       = false;
648				btn.textContent    = origLabel;
649			}
650		},
651
652		/* ═══════════════════════════════════════
653		 *  CONVERSATION HEADER MENU (WS3-C)
654		 * ═══════════════════════════════════════
655		 *
656		 * All four conversation actions (mute/archive/leave/block) go through
657		 * the shared restFetch + optimisticAction generator pattern:
658		 *
659		 *   • Optimistic flip of the context flag drives the menu label
660		 *     instantly (Mute ↔ Unmute, Archive ↔ Unarchive, etc.).
661		 *   • restFetch reads the wp_rest nonce from window.jetonomyData and
662		 *     auto-refreshes it on 403 — so a stale page after a long idle
663		 *     still works once.
664		 *   • optimisticAction.gen wraps the call in apply/revert/onSuccess
665		 *     so a server-side validation error (e.g. leave on a direct
666		 *     conversation) reverts the UI and shows a toast instead of
667		 *     drifting the state.
668		 *
669		 * The response from each endpoint is the full formatted conversation,
670		 * so onSuccess just syncs the relevant flag from the server payload
671		 * — that way the menu reflects the canonical state even if multiple
672		 * tabs raced.
673		 */
674
675		toggleConvMore( event ) {
676			// Lazily build the smartDropdown instance the first time the
677			// trigger is clicked. Storing it on the trigger element (rather
678			// than IA state) keeps multiple conversations on the same page
679			// independent — currently we only render one, but the panel
680			// component is reusable.
681			const trigger = event.target.closest( '.jt-conv-more__trigger' );
682			if ( ! trigger ) return;
683			const panel = trigger.parentElement?.querySelector( '.jt-conv-more__panel' );
684			if ( ! panel ) return;
685
686			const ctx = getContext();
687
688			if ( ! trigger._jtDropdown && typeof window.jetonomySmartDropdown === 'function' ) {
689				trigger._jtDropdown = window.jetonomySmartDropdown( trigger, panel, {
690					placement: 'bottom-end',
691					group:     'jt-conv-more',
692					onOpen:    () => { ctx.convMoreOpen = true; },
693					onClose:   () => { ctx.convMoreOpen = false; },
694				} );
695			}
696
697			if ( trigger._jtDropdown ) {
698				trigger._jtDropdown.toggle();
699			} else {
700				// Fallback: no smartDropdown shipped (very old free plugin) —
701				// just toggle the flag, CSS handles visibility.
702				ctx.convMoreOpen = ! ctx.convMoreOpen;
703			}
704		},
705
706		*toggleMuteConversation() {
707			const ctx = getContext();
708			if ( ! ctx.conversationId || ! window.jetonomyRest || ! window.jetonomyOptimistic ) return;
709
710			const target = ! ctx.isMuted;
711			ctx.convMoreOpen = false;
712
713			const labelFor = ( muted ) => muted ? ctx.muteLabels.on : ctx.muteLabels.off;
714
715			yield window.jetonomyOptimistic.gen( {
716				apply: () => {
717					const prev = { isMuted: ctx.isMuted, muteLabel: ctx.muteLabel };
718					ctx.isMuted = target;
719					ctx.muteLabel = labelFor( target );
720					return prev;
721				},
722				fetch: () => window.jetonomyRest
723					.restFetch( '/conversations/' + ctx.conversationId + '/mute', {
724						method: 'POST',
725						body:   { muted: target },
726					} )
727					.then( r => new Response(
728						JSON.stringify( r.data || {} ),
729						{ status: r.status || 0, headers: { 'Content-Type': 'application/json' } },
730					) ),
731				onSuccess: ( payload ) => {
732					const data = payload && payload.data ? payload.data : null;
733					if ( data && typeof data.is_muted === 'boolean' ) {
734						ctx.isMuted = data.is_muted;
735						ctx.muteLabel = labelFor( data.is_muted );
736					}
737				},
738				revert: ( snap ) => {
739					ctx.isMuted = snap.isMuted;
740					ctx.muteLabel = snap.muteLabel;
741				},
742				errorFallback: state.i18n?.muteFailed || 'Could not update mute setting.',
743			} );
744		},
745
746		*toggleArchiveConversation() {
747			const ctx = getContext();
748			if ( ! ctx.conversationId || ! window.jetonomyRest || ! window.jetonomyOptimistic ) return;
749
750			const target = ! ctx.isArchived;
751			ctx.convMoreOpen = false;
752
753			const labelFor = ( archived ) => archived ? ctx.archiveLabels.on : ctx.archiveLabels.off;
754
755			yield window.jetonomyOptimistic.gen( {
756				apply: () => {
757					const prev = { isArchived: ctx.isArchived, archiveLabel: ctx.archiveLabel };
758					ctx.isArchived = target;
759					ctx.archiveLabel = labelFor( target );
760					return prev;
761				},
762				fetch: () => window.jetonomyRest
763					.restFetch( '/conversations/' + ctx.conversationId + '/archive', {
764						method: 'POST',
765						body:   { archived: target },
766					} )
767					.then( r => new Response(
768						JSON.stringify( r.data || {} ),
769						{ status: r.status || 0, headers: { 'Content-Type': 'application/json' } },
770					) ),
771				onSuccess: ( payload ) => {
772					const data = payload && payload.data ? payload.data : null;
773					if ( data && typeof data.is_archived === 'boolean' ) {
774						ctx.isArchived = data.is_archived;
775						ctx.archiveLabel = labelFor( data.is_archived );
776					}
777					// Archived → bounce back to /messages/ so the user sees the
778					// updated inbox (the current conversation is now hidden
779					// from the default list). Unarchive stays in-place.
780					if ( target && state.communityBase ) {
781						window.location.href = state.communityBase + '/messages/';
782					}
783				},
784				revert: ( snap ) => {
785					ctx.isArchived = snap.isArchived;
786					ctx.archiveLabel = snap.archiveLabel;
787				},
788				errorFallback: state.i18n?.archiveFailed || 'Could not update archive.',
789			} );
790		},
791
792		*leaveConversation() {
793			const ctx = getContext();
794			if ( ! ctx.conversationId || ! window.jetonomyRest || ! window.jetonomyOptimistic ) return;
795			if ( ! ctx.canLeave ) return;
796
797			const confirmMsg = state.i18n?.confirmLeave
798				|| 'Leave this conversation? You will not receive new messages.';
799			const confirmFn = typeof window.jetonomyConfirm === 'function'
800				? window.jetonomyConfirm
801				: ( m ) => Promise.resolve( window.confirm( m ) ); // eslint-disable-line no-alert
802			if ( ! ( yield confirmFn( confirmMsg, { tone: 'danger' } ) ) ) return;
803
804			ctx.convMoreOpen = false;
805
806			yield window.jetonomyOptimistic.gen( {
807				apply: () => ( { leftAt: ctx.leftAt || null } ),
808				fetch: () => window.jetonomyRest
809					.restFetch( '/conversations/' + ctx.conversationId + '/leave', {
810						method: 'POST',
811					} )
812					.then( r => new Response(
813						JSON.stringify( r.data || {} ),
814						{ status: r.status || 0, headers: { 'Content-Type': 'application/json' } },
815					) ),
816				onSuccess: () => {
817					// Leaving is terminal — kick the user back to the inbox
818					// where the conversation is now filtered out.
819					if ( state.communityBase ) {
820						window.location.href = state.communityBase + '/messages/';
821					} else {
822						window.location.reload();
823					}
824				},
825				revert: () => { /* nothing to revert: the alert + redirect path is the UX */ },
826				errorFallback: state.i18n?.leaveFailed || 'Could not leave conversation.',
827			} );
828		},
829
830		*blockUser() {
831			const ctx = getContext();
832			if ( ! ctx.conversationId || ! window.jetonomyRest || ! window.jetonomyOptimistic ) return;
833			if ( ! ctx.canBlock ) return;
834
835			const target = ! ctx.isBlocked;
836
837			if ( target ) {
838				const confirmMsg = state.i18n?.confirmBlock
839					|| 'Block this user? They will no longer be able to message you here.';
840				const confirmFn = typeof window.jetonomyConfirm === 'function'
841					? window.jetonomyConfirm
842					: ( m ) => Promise.resolve( window.confirm( m ) ); // eslint-disable-line no-alert
843				if ( ! ( yield confirmFn( confirmMsg, { tone: 'danger' } ) ) ) return;
844			}
845
846			ctx.convMoreOpen = false;
847
848			const labelFor = ( blocked ) => blocked ? ctx.blockLabels.on : ctx.blockLabels.off;
849
850			yield window.jetonomyOptimistic.gen( {
851				apply: () => {
852					const prev = { isBlocked: ctx.isBlocked, blockLabel: ctx.blockLabel };
853					ctx.isBlocked = target;
854					ctx.blockLabel = labelFor( target );
855					return prev;
856				},
857				fetch: () => window.jetonomyRest
858					.restFetch( '/conversations/' + ctx.conversationId + '/block', {
859						method: 'POST',
860						body:   { blocked: target },
861					} )
862					.then( r => new Response(
863						JSON.stringify( r.data || {} ),
864						{ status: r.status || 0, headers: { 'Content-Type': 'application/json' } },
865					) ),
866				onSuccess: ( payload ) => {
867					const data = payload && payload.data ? payload.data : null;
868					if ( data && typeof data.is_blocked === 'boolean' ) {
869						ctx.isBlocked = data.is_blocked;
870						ctx.blockLabel = labelFor( data.is_blocked );
871					}
872				},
873				revert: ( snap ) => {
874					ctx.isBlocked = snap.isBlocked;
875					ctx.blockLabel = snap.blockLabel;
876				},
877				errorFallback: state.i18n?.blockFailed || 'Could not update block.',
878			} );
879		},
880
881		/* ═══════════════════════════════════════
882		 *  POLLS
883		 * ═══════════════════════════════════════ */
884
885		togglePollBuilder() {
886			const btn = getElement().ref;
887			const builder = btn.nextElementSibling;
888			builder.hidden = ! builder.hidden;
889			btn.style.display = builder.hidden ? '' : 'none';
890		},
891
892		/* ═══════════════════════════════════════
893		 *  AI — USE SUGGESTION AS REPLY
894		 * ═══════════════════════════════════════ */
895
896		/**
897		 * Post an AI-suggested answer as a reply.
898		 *
899		 * The suggester card (ai/class-suggester.php) wraps itself in
900		 * data-wp-interactive="jetonomy" and gives each suggestion a
901		 * data-wp-context of { aiPostId, aiSuggestion }. This POSTs that text to
902		 * the core /posts/{id}/replies endpoint — reusing its permission,
903		 * rate-limit, and moderation pipeline — then reloads so the new reply
904		 * (or pending-approval state) renders. Uses the messaging-localized
905		 * apiBase + wp_rest nonce already on the page.
906		 */
907		*useAiSuggestion() {
908			const ctx     = getContext();
909			const postId  = parseInt( ctx.aiPostId, 10 ) || 0;
910			const content = ctx.aiSuggestion || '';
911			const apiBase = state.apiBase;
912			const nonce   = state._nonce || state.nonce;
913
914			if ( ! postId || '' === content || ! apiBase ) return;
915
916			if ( state.isLoggedIn === false ) {
917				const msg = ( state.i18n && state.i18n.loginRequired ) || 'Please sign in to use this.';
918				if ( window.bnToast ) window.bnToast( msg );
919				return;
920			}
921
922			try {
923				const res = yield fetch( `${ apiBase }/posts/${ postId }/replies`, {
924					method: 'POST',
925					headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
926					credentials: 'same-origin',
927					body: JSON.stringify( { content } ),
928				} );
929				if ( res.ok ) {
930					window.location.reload();
931				} else if ( window.bnToast ) {
932					window.bnToast( ( state.i18n && state.i18n.replyFailed ) || 'Could not post reply.' );
933				}
934			} catch ( e ) {
935				if ( window.bnToast ) {
936					window.bnToast( ( state.i18n && state.i18n.networkError ) || 'Network error. Please try again.' );
937				}
938			}
939		},
940
941		/**
942		 * Ask the AI to draft a reply for the current post and drop the draft
943		 * into the composer editor for the member to edit and post. On-demand
944		 * and synchronous; works on any post (not just unanswered Q&A). The
945		 * button lives in the composer toolbar with data-post-id, rendered by
946		 * jetonomy-pro ai/class-extension.php::render_composer_suggest_button.
947		 * Hits POST /ai/suggest-reply (logged-in, rate-limited server-side).
948		 */
949		*suggestReply() {
950			const btn     = getElement().ref;
951			const postId  = btn ? parseInt( btn.getAttribute( 'data-post-id' ), 10 ) : 0;
952			const apiBase = state.apiBase;
953			const nonce   = state._nonce || state.nonce;
954
955			if ( ! postId || ! apiBase ) return;
956
957			if ( state.isLoggedIn === false ) {
958				if ( window.bnToast ) {
959					window.bnToast( ( state.i18n && state.i18n.loginRequired ) || 'Please sign in to use this.' );
960				}
961				return;
962			}
963
964			const editor = document.getElementById( 'jt-composer-' + postId );
965			const label  = btn ? btn.textContent : '';
966			if ( btn ) {
967				btn.disabled    = true;
968				btn.textContent = ( state.i18n && state.i18n.aiDrafting ) || 'Drafting…';
969			}
970
971			try {
972				const res  = yield fetch( `${ apiBase }/ai/suggest-reply`, {
973					method: 'POST',
974					headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': nonce },
975					credentials: 'same-origin',
976					body: JSON.stringify( { post_id: postId } ),
977				} );
978				const data = yield res.json();
979				if ( res.ok && data && data.reply ) {
980					if ( editor ) {
981						editor.textContent = data.reply;
982						editor.dispatchEvent( new Event( 'input', { bubbles: true } ) );
983						editor.focus();
984					}
985				} else if ( window.bnToast ) {
986					window.bnToast( ( data && data.message ) || ( state.i18n && state.i18n.aiSuggestFailed ) || 'Could not draft a reply. Please try again.' );
987				}
988			} catch ( e ) {
989				if ( window.bnToast ) {
990					window.bnToast( ( state.i18n && state.i18n.networkError ) || 'Network error. Please try again.' );
991				}
992			} finally {
993				if ( btn ) {
994					btn.disabled    = false;
995					btn.textContent = label;
996				}
997			}
998		},
999
1000		removePoll() {
1001			const btn = getElement().ref;
1002			const wrap = btn.closest( '.jt-poll-builder-wrap' );
1003			wrap.querySelector( '.jt-poll-builder' ).hidden = true;
1004			const toggle = wrap.querySelector( '.jt-poll-toggle' );
1005			toggle.style.display = '';
1006			wrap.querySelectorAll( 'input,select' ).forEach( i => {
1007				i.value = '';
1008			} );
1009		},
1010
1011		/**
1012		 * Submit new post with optional poll — wraps the free-side composePost
1013		 * generator (1.4.3 WS1) and injects poll_* fields via extraPayload.
1014		 *
1015		 * Server-side, includes/extensions/polls/class-extension.php hooks
1016		 * jetonomy_post_created and reads poll_question / poll_options /
1017		 * poll_type / poll_closes_at off the request, so this client only
1018		 * has to bundle them into the create-post request body — no second
1019		 * REST call. Falls back to feature parity with the free submitter
1020		 * for everything else (CAPTCHA, scheduler, private flag).
1021		 *
1022		 * Wired up server-side by the polls extension via the
1023		 * jetonomy_new_post_submit_action filter, which swaps the form's
1024		 * data-wp-on--submit binding from 'actions.submitNewPost' to
1025		 * 'actions.submitNewPostWithPoll'.
1026		 */
1027		*submitNewPostWithPoll( event ) {
1028			// Follow the space type for the title requirement, mirroring the
1029			// free submitNewPost / inline embed: feed spaces are untitled (the
1030			// server derives a title). Polls aren't offered in feed composers
1031			// today, so this stays `true` for every space that can hold a poll —
1032			// but reading the type means it can't regress if that ever changes.
1033			const requireTitle = ( 'feed' !== ( getContext().spaceType || '' ) );
1034			yield actions.composePost( event, {
1035				requireTitle,
1036				collectTags:     true,
1037				collectPrefix:   true,
1038				collectPrivate:  true,
1039				collectSchedule: true,
1040				collectCaptcha:  true,
1041				bodySource:      'editor',
1042				errorSink:       'state',
1043				// extraPayload lets us bolt the poll_* fields onto the
1044				// existing /spaces/:id/posts request rather than firing a
1045				// second round-trip. The server-side polls extension reads
1046				// them out of the create-post hook context.
1047				extraPayload( form ) {
1048					const pollQuestion = form.querySelector( '[name="poll_question"]' )?.value?.trim() || '';
1049					const pollOptions  = Array.from( form.querySelectorAll( '[name="poll_options[]"]' ) )
1050						.map( ( i ) => i.value.trim() )
1051						.filter( Boolean );
1052					const pollType   = form.querySelector( '[name="poll_type"]' )?.value || 'single';
1053					const pollCloses = form.querySelector( '[name="poll_closes_at"]' )?.value || '';
1054
1055					// Skip the poll fields entirely when the user hasn't
1056					// filled in at least a question and two options. Sending
1057					// partial data would trip the server-side poll validator
1058					// and reject the whole post for a feature the user
1059					// didn't even enable.
1060					if ( ! pollQuestion || pollOptions.length < 2 ) {
1061						return {};
1062					}
1063
1064					const extras = {
1065						poll_question: pollQuestion,
1066						poll_options:  pollOptions,
1067						poll_type:     pollType,
1068					};
1069					if ( pollCloses ) extras.poll_closes_at = pollCloses;
1070					return extras;
1071				},
1072			} );
1073		},
1074
1075		votePoll() {
1076			const ctx   = getContext();
1077			const input = getElement().ref;
1078
1079			if ( ! ctx.pollId ) return;
1080
1081			const poll    = input.closest( '.jt-poll' );
1082			const checked = poll
1083				? Array.from( poll.querySelectorAll( 'input:checked' ) ).map( i => parseInt( i.value, 10 ) )
1084				: [];
1085
1086			// A radio (single-choice) can never produce an empty set, so keep the
1087			// no-op guard for it. A checkbox (multiple-choice) legitimately hits
1088			// zero when you uncheck your last option — that must POST [] so the
1089			// server clears your votes (the server now treats option_ids as the
1090			// desired final set, not a toggle list).
1091			if ( ! checked.length && input.type === 'radio' ) return;
1092
1093			// 1.4.3 — restFetch handles the rest_cookie_invalid_nonce refresh
1094			// that previously caused the first poll vote after a long-lived
1095			// page session to silently 403 (Basecamp #9882694198).
1096			const _voteRequest = ( window.jetonomyRest && typeof window.jetonomyRest.restFetch === 'function' )
1097				? window.jetonomyRest.restFetch( `/polls/${ ctx.pollId }/vote`, {
1098					method: 'POST',
1099					body: { option_ids: checked },
1100				} ).then( r => r.data )
1101				: fetch( `${ state.apiBase }/polls/${ ctx.pollId }/vote`, {
1102					method: 'POST',
1103					headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': state._nonce || state.nonce },
1104					credentials: 'same-origin',
1105					body: JSON.stringify( { option_ids: checked } ),
1106				} ).then( r => r.json() );
1107
1108			_voteRequest
1109			.then( res => {
1110				const data    = ( res && res.data ) || res || {};
1111				const options = data.options;
1112				if ( ! options || ! poll ) return;
1113
1114				// Update each option's percentage bar and count.
1115				const optionEls = poll.querySelectorAll( '.jt-poll-option' );
1116				options.forEach( ( opt, i ) => {
1117					const el = optionEls[ i ];
1118					if ( ! el ) return;
1119					const bar = el.querySelector( '.jt-poll-option-bar' );
1120					const pct = el.querySelector( '.jt-poll-option-count' );
1121					if ( bar ) bar.style.width = opt.percentage + '%';
1122					if ( pct ) pct.textContent = opt.percentage + '%';
1123
1124					// Highlight voted option.
1125					const isVoted = ( data.user_votes || [] ).includes( opt.id );
1126					el.classList.toggle( 'jt-poll-option--voted', isVoted );
1127				} );
1128
1129				// Update meta (vote count).
1130				const meta = poll.querySelector( '.jt-poll-meta' );
1131				if ( meta && data.total_voters !== undefined ) {
1132					const i18n = state.i18n || {};
1133					const fmt  = data.total_voters === 1
1134						? ( i18n.voteSingular || '1 vote' )
1135						: ( i18n.voteFormat || '%d votes' );
1136					meta.childNodes[ 0 ].textContent = fmt.replace( '%d', data.total_voters );
1137				}
1138			} )
1139			.catch( () => {
1140				if ( window.bnToast ) {
1141					window.bnToast( state.i18n?.failedSubmitVote || 'Failed to submit vote. Please try again.' );
1142				}
1143			} );
1144		},
1145	},
1146} );
1147
1148/* ═══════════════════════════════════════════════════════════════════════════
1149 *  Recipient typeahead (messaging composer)
1150 *
1151 *  Wired as a plain DOM listener rather than an Interactivity action so the
1152 *  suggestion list can render independent of the WP Interactivity store —
1153 *  the list is transient UI, not application state.
1154 *
1155 *  Rules (matches product intent, see Basecamp 9803719326):
1156 *   • Fires only when >= 3 characters have been typed.
1157 *   • Debounced 250ms (tuned for a 40k-user community where network is cheap
1158 *     but hammering the REST endpoint on every keystroke is wasteful).
1159 *   • Results come from /jetonomy/v1/messaging/recipient-suggestions which
1160 *     is scoped server-side to shared-space membership (no global directory
1161 *     leak).
1162 *   • Empty result renders a "no matches" line so the user knows it ran.
1163 *   • Click / Enter fills the input and closes the list; Escape closes.
1164 *  ═══════════════════════════════════════════════════════════════════════ */
1165( function () {
1166
1167	function debounce( fn, delay ) {
1168		let t = 0;
1169		return function () {
1170			const args = arguments;
1171			const ctx  = this;
1172			clearTimeout( t );
1173			t = setTimeout( () => fn.apply( ctx, args ), delay );
1174		};
1175	}
1176
1177	function renderSuggestions( list, items, onPick ) {
1178		list.replaceChildren();
1179		if ( ! items.length ) {
1180			const empty = document.createElement( 'li' );
1181			empty.className = 'jt-msg-suggest-empty';
1182			empty.textContent = ( state.i18n && state.i18n.noMessageMatches ) || 'No matches. You can only message people who share at least one space with you.';
1183			list.appendChild( empty );
1184			list.hidden = false;
1185			return;
1186		}
1187		items.forEach( ( u ) => {
1188			const li = document.createElement( 'li' );
1189			li.className = 'jt-msg-suggest-item';
1190			li.dataset.login = u.user_login;
1191
1192			const img = document.createElement( 'img' );
1193			img.src = u.avatar_url;
1194			img.alt = '';
1195			img.width  = 24;
1196			img.height = 24;
1197			img.className = 'jt-msg-suggest-avatar';
1198
1199			const name = document.createElement( 'span' );
1200			name.className = 'jt-msg-suggest-name';
1201			name.textContent = u.display_name || u.user_login;
1202
1203			const handle = document.createElement( 'span' );
1204			handle.className = 'jt-msg-suggest-handle';
1205			handle.textContent = '@' + u.user_login;
1206
1207			li.append( img, name, handle );
1208			li.addEventListener( 'mousedown', ( ev ) => {
1209				ev.preventDefault(); // don't blur the input
1210				onPick( u.user_login );
1211			} );
1212			list.appendChild( li );
1213		} );
1214		list.hidden = false;
1215	}
1216
1217	function attachTypeahead( input ) {
1218		if ( input.dataset.jtSuggestAttached === '1' ) return;
1219		input.dataset.jtSuggestAttached = '1';
1220
1221		// Suggestion list — lazily inserted after the input on first keystroke.
1222		let list = null;
1223		function ensureList() {
1224			if ( list ) return list;
1225			list = document.createElement( 'ul' );
1226			list.className = 'jt-msg-suggest-list';
1227			list.hidden = true;
1228			input.insertAdjacentElement( 'afterend', list );
1229			return list;
1230		}
1231
1232		let currentController = null;
1233
1234		const fetchSuggestions = debounce( async ( q ) => {
1235			// Free's unified REST client (always enqueued on community
1236			// routes) — owns the base URL, nonce, and 403 nonce-refresh
1237			// retry. The old per-extension jetonomyMessaging bridge
1238			// re-localized the same data without the retry (audit B).
1239			if ( ! window.jetonomyRest || typeof window.jetonomyRest.restFetch !== 'function' ) {
1240				if ( list ) list.hidden = true;
1241				return;
1242			}
1243			if ( currentController ) currentController.abort();
1244			currentController = ( typeof AbortController === 'function' ) ? new AbortController() : null;
1245			try {
1246				const result = await window.jetonomyRest.restFetch(
1247					'/messaging/recipient-suggestions?q=' + encodeURIComponent( q ),
1248					{ signal: currentController ? currentController.signal : undefined }
1249				);
1250				if ( ! result.ok ) {
1251					renderSuggestions( ensureList(), [], pickLogin );
1252					return;
1253				}
1254				renderSuggestions( ensureList(), Array.isArray( result.data ) ? result.data : [], pickLogin );
1255			} catch ( err ) {
1256				if ( err?.name === 'AbortError' ) return;
1257				// Network error — hide the list silently, user can still type free-form.
1258				if ( list ) list.hidden = true;
1259			}
1260		}, 250 );
1261
1262		function pickLogin( login ) {
1263			input.value = login;
1264			input.dispatchEvent( new Event( 'input', { bubbles: true } ) );
1265			if ( list ) list.hidden = true;
1266			input.focus();
1267		}
1268
1269		input.addEventListener( 'input', () => {
1270			const q = input.value.trim();
1271			if ( q.length < 3 ) {
1272				if ( list ) list.hidden = true;
1273				return;
1274			}
1275			fetchSuggestions( q );
1276		} );
1277		input.addEventListener( 'keydown', ( ev ) => {
1278			if ( ev.key === 'Escape' && list ) {
1279				list.hidden = true;
1280			}
1281		} );
1282		// Hide on blur (with a short delay so mousedown on a suggestion still fires).
1283		input.addEventListener( 'blur', () => {
1284			setTimeout( () => { if ( list ) list.hidden = true; }, 150 );
1285		} );
1286	}
1287
1288	// Pin a long conversation thread to its newest message. Only acts when the
1289	// thread list is present (conversation view), so it's a no-op on the inbox
1290	// list view and every non-messaging route.
1291	function scrollThreadToBottom() {
1292		const list = document.getElementById( 'jt-msg-list' );
1293		if ( list ) {
1294			list.scrollTop = list.scrollHeight;
1295		}
1296	}
1297
1298	// Mobile auto-fit: size the messages split to the visible screen so the
1299	// composer sits above the bottom tab bar with no dead gap. Measures the
1300	// live top offset + any fixed bottom bar instead of hard-coding a per-theme
1301	// number. Desktop ( >900px ) keeps the CSS height. No-op when the split is
1302	// absent.
1303	function fitMobileThread() {
1304		const split = document.querySelector( '.jt-msg-split' );
1305		if ( ! split ) {
1306			return;
1307		}
1308		if ( window.innerWidth > 900 ) {
1309			split.style.height = '';
1310			split.style.minHeight = '';
1311			return;
1312		}
1313		let navH = 0;
1314		document.querySelectorAll( '.jt-mobile-tabs' ).forEach( ( n ) => {
1315			if ( getComputedStyle( n ).position === 'fixed' ) {
1316				navH = Math.max( navH, n.offsetHeight );
1317			}
1318		} );
1319		const avail = window.innerHeight - split.getBoundingClientRect().top - navH - 8;
1320		if ( avail > 320 ) {
1321			split.style.minHeight = '0px';
1322			split.style.height = Math.round( avail ) + 'px';
1323		}
1324	}
1325
1326	function init() {
1327		document.querySelectorAll( '.jt-msg-compose-recipient' ).forEach( attachTypeahead );
1328		// Order matters: fit changes the thread height, then scroll pins to the
1329		// newest message against that final height.
1330		fitMobileThread();
1331		scrollThreadToBottom();
1332	}
1333
1334	if ( document.readyState === 'loading' ) {
1335		document.addEventListener( 'DOMContentLoaded', init );
1336	} else {
1337		init();
1338	}
1339
1340	// Re-run after a free iAPI client-side navigation swaps in the messages /
1341	// conversation view (both client-nav). init() is idempotent — the typeahead
1342	// guards on jtSuggestAttached, and the fit/scroll helpers just read live DOM
1343	// — so this only acts on the freshly-swapped content. Replaces the old
1344	// server-rendered inline DOMContentLoaded snippets, which never re-ran on a
1345	// client-side navigation (thread opened at the oldest message; mobile layout
1346	// left a gap below the composer).
1347	document.addEventListener( 'jetonomy:navigated', init );
1348
1349	// Keep the mobile fit correct across orientation / viewport changes.
1350	window.addEventListener( 'resize', fitMobileThread );
1351
1352	// The compose panel is also inserted dynamically when the "New" button
1353	// toggles state.msgComposeOpen. The input is already in the DOM (just
1354	// [hidden]), so no MutationObserver is needed — the initial scan on page
1355	// load covers it.
1356}() );

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.