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.