Skip to content

Live Updates Developer Guide 2

Ed Mozley edited this page Nov 11, 2026 · 1 revision

πŸ› οΈ Live updates - Developer Guide (2 of 2): the browser side

How each screen follows other people's changes without throwing away what somebody is doing. Page 1 covers the design, the endpoint and the SQL behind every signature. User guide: Live updates.


1. LivePulse, the heartbeat

assets/js/live-pulse.js is one IIFE that defines window.LivePulse. It is loaded on every analyst page by renderNotificationBell() (page 1 Β§3), after window.FREEITSM_LIVE = {seconds, url}.

State

const cfg = window.FREEITSM_LIVE || {};
const seconds = Number.isFinite(cfg.seconds) ? cfg.seconds : 15;
const url = (cfg.url || '') + 'api/system/pulse.php';
const watchers = new Map();   // topic -> Set(callback)
const sigs = new Map();       // topic -> last hash seen (undefined = not yet baselined)
let timer = null, inFlight = false, again = false, soonTimer = null;
let wroteSince = false;       // this page saved something since the last check

The beat

async function pulse() {
    if (seconds <= 0 || !watchers.size || document.hidden) return;   // off means off
    if (inFlight) { again = true; return; }
    inFlight = true;
    const mine = wroteSince;
    wroteSince = false;
    try {
        const topics = [...watchers.keys()];
        const r = await fetch(url + '?topics=' + encodeURIComponent(topics.join(',')), { credentials: 'same-origin' });
        const d = await r.json();
        if (d && d.success && d.sig) {
            for (const topic of topics) {
                if (!(topic in d.sig)) continue;
                const now = d.sig[topic];
                const before = sigs.get(topic);
                sigs.set(topic, now);
                if (before === undefined || before === now || now === null) continue;
                for (const cb of [...(watchers.get(topic) || [])]) {
                    try { cb(topic, { mine }); } catch (e) { console.error('[LivePulse]', topic, e); }
                }
            }
        }
    } catch (e) { /* offline or signed out: the next beat tries again */ }
    inFlight = false;
    if (again) { again = false; soon(400); }
}

/** Check shortly - collapses a burst of reasons into one request. */
function soon(ms) {
    clearTimeout(soonTimer);
    soonTimer = setTimeout(pulse, ms);
}
  • First answer = baseline (before === undefined): nothing runs.
  • null (gone, or not allowed) is stored but never fires, so a ticket that was deleted doesn't loop.
  • The callbacks are copied ([...set]) before running, so a callback that stops watching doesn't disturb the loop.
  • A background tab returns at once: document.hidden.

When a beat happens

Trigger Delay Code
the timer seconds (15 / 30 / 60) setInterval(pulse, seconds * 1000), started by the first watch()
a new topic is watched 300 ms baselines it, so a change right after drawing is not missed
the tab becomes visible, or the window gets focus 150 ms visibilitychange, focus
another tab of yours saved something 700 ms BroadcastChannel poke (Β§2)
this page saved something 1200 ms the fetch wrapper (Β§2)
a beat arrived while one was running 400 ms after it again

All except the timer go through soon(), so several reasons at once make one request.

The public API

function watch(topic, cb) {
    if (!topic || typeof cb !== 'function') return () => {};
    if (!watchers.has(topic)) { watchers.set(topic, new Set()); sigs.delete(topic); soon(300); }
    watchers.get(topic).add(cb);
    start();
    return () => unwatch(topic, cb);
}

function unwatch(topic, cb) {
    const set = watchers.get(topic);
    if (!set) return;
    set.delete(cb);
    if (!set.size) { watchers.delete(topic); sigs.delete(topic); }
}

window.LivePulse = {
    watch, unwatch, rebaseline, busyChip, clearChip,
    now: () => soon(50),
    enabled: () => seconds > 0,
    seconds: () => seconds,
};

rebaseline(topic) forgets the stored hash and checks within 300 ms, so the part adopts the current hash without its callback firing. Use it after redrawing from data you know is fresh.


2. "Mine", other tabs, and the fetch wrapper

const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel('freeitsm-live') : null;
if (channel) channel.onmessage = (e) => { if (e.data === 'poke') soon(700); };
const BACKGROUND_WRITES = /\/(pulse|ticket_presence|check_mailbox_email|scheduler_fallback|recent_trail_visit|set_user_preference|mark_read|clear)\.php$/;
const realFetch = window.fetch;
window.fetch = function (input, init) {
    const method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
    const p = realFetch.apply(this, arguments);
    if (method !== 'GET' && method !== 'HEAD') {
        let target = '';
        try { target = new URL(typeof input === 'string' ? input : input.url, location.href).pathname; } catch (e) { /* ignore */ }
        if (target.includes('/api/') && !BACKGROUND_WRITES.test(target)) {
            p.then(res => {
                if (!res || !res.ok) return;
                wroteSince = true;
                if (channel) channel.postMessage('poke');
                soon(1200);   // others' screens settle on this; ours learns it was mine
            }).catch(() => {});
        }
    }
    return p;
};

When a same-origin write to /api/ succeeds, three things happen:

  1. wroteSince = true. The next beat reads it as info.mine and resets it.
  2. Your other tabs are poked. They are on the same browser and check within 700 ms. BroadcastChannel never leaves the browser, so nothing crosses analysts.
  3. This page checks within 1200 ms, so it adopts the new hashes while they are still marked mine, instead of later mistaking them for someone else's change.

The wrapper never changes the request or the response; it only watches. The ticket workspace (ticket-workspace.js) wraps fetch too, to count writes in flight. live-pulse.js loads first, in the header, and the workspace wraps after it, so the chain is workspace β†’ live-pulse β†’ native.

mine is a hint, not proof. It means "this page wrote something since the last beat", not "the change was mine". If a colleague edits the open ticket in the same window as your own save, the beat counts as yours: the conversation still catches up, but their field change waits for the next change. That was accepted so that your own saves don't come back as a redraw or a false "someone else" prompt.


3. The shared prompt

function busyChip(host, onRefresh) {
    if (!host) return;
    if (host.querySelector(':scope > .live-pulse-chip')) return;   // one per host
    const chip = document.createElement('div');
    chip.className = 'live-pulse-chip';
    chip.setAttribute('role', 'status');
    chip.style.cssText = 'display:flex;align-items:center;gap:10px;margin:6px 0 8px;padding:6px 12px;border-radius:999px;'
        + 'background:rgba(37,99,235,.10);color:var(--text,#1e293b);font-size:13px;width:fit-content;max-width:100%;';
    const msg = document.createElement('span');
    msg.textContent = T('common.live.updated', 'Updated by someone else just now.');
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'btn btn-primary';
    btn.style.cssText = 'padding:3px 12px;font-size:12px;';
    btn.textContent = T('common.live.refresh', 'Refresh');
    btn.onclick = () => { chip.remove(); onRefresh(); };
    chip.append(msg, btn);
    host.prepend(chip);
}
  • It is built from DOM nodes with textContent, so a translation can't inject markup.
  • The colours use theme variables with a tint, so it reads in light and dark.
  • A redraw of host removes it with everything else.
  • Strings: common.live.updated, common.live.refresh (en, de, es, pl, id).

4. Each screen

The rule for every screen is the same: check busy; if busy, wait (lists) or show the prompt (open records); otherwise redraw without a spinner and put the scroll position back.

The ticket list - tickets/index.php

if (window.LivePulse) {
    const listBusy = () => !!document.querySelector('#ticketContextMenu.active, .email-item.dragging, .ctx-sheet.active');
    const listChanged = () => {
        if (listBusy()) { setTimeout(listChanged, 2500); return; }
        if (typeof refreshCurrentView === 'function') refreshCurrentView();
    };
    LivePulse.watch('tickets', listChanged);
}
  • refreshCurrentView() is the ↻ button: folder counts plus loadEmails().
  • loadEmails() keeps the selection (it's state, not DOM). It is "last response wins" (loadEmailsToken), and in the table view it calls TicketTable.reload(), so the table follows too.
  • It redraws even for mine: the list is cheap and safe.
  • The old once-a-minute marker check in the same script now runs only when live updates are off:
const marker = window.LivePulse?.enabled() ? null : await newestMarker();

The open ticket - assets/js/inbox.js

displayEmail() ends with liveWatchOpenTicket(email.ticket_id), and stopPresence() (the pane emptied) calls liveWatchOpenTicket(0).

let liveTicketId = 0, liveTicketStop = null;

function liveWatchOpenTicket(ticketId) {
    ticketId = Number(ticketId) || 0;
    if (ticketId === liveTicketId) return;          // every redraw calls this; same ticket = nothing to do
    if (liveTicketStop) liveTicketStop();
    liveTicketStop = null;
    liveTicketId = ticketId;
    if (ticketId && window.LivePulse) {
        liveTicketStop = LivePulse.watch('ticket:' + ticketId, (topic, info) => liveOpenTicketChanged(ticketId, info));
    }
}

function liveTicketBusy() {
    if (typeof ticketHasUnsavedInput === 'function' && ticketHasUnsavedInput()) return true;   // reply / note open, chat text typed
    if (ticketPendingWrites > 0) return true;                                                     // a save on its way
    if (window.TicketWorkspace?.isActive() && TicketWorkspace.hasUnsavedWork?.()) return true;    // property drafts
    if (document.querySelector('.modal.active, #ticketContextMenu.active')) return true;
    const a = document.activeElement;
    const pane = document.getElementById('readingPane');
    return !!(a && pane && pane.contains(a) && (a.matches('input, textarea, select') || a.isContentEditable));
}

async function liveOpenTicketChanged(ticketId, info) {
    if (Number(currentEmail?.ticket_id) !== ticketId) return;
    const busy = liveTicketBusy();
    if (info.mine || busy) {
        // Messages and replies can arrive while you write - show them; they never
        // touch what you are typing.
        if (document.getElementById('threadContainer')) {
            loadCorrespondenceThread(ticketId, true);
            loadTicketAttachments(ticketId);
        }
        if (busy && !info.mine) LivePulse.busyChip(document.getElementById('readingPane'), () => liveReloadTicket(ticketId));
        return;
    }
    liveReloadTicket(ticketId);
}

async function liveReloadTicket(ticketId) {
    try {
        const d = await (await fetch(`${API_BASE}get_email_detail.php?ticket_id=${ticketId}`)).json();
        if (!d.success || Number(currentEmail?.ticket_id) !== ticketId || liveTicketBusy()) return;   // re-checked after the fetch
        const pane = document.getElementById('readingPane');
        const scrollers = [pane, ...pane.querySelectorAll('.tw-main, .tw-properties')];
        const tops = scrollers.map(el => el.scrollTop);
        displayEmail(d.email, d.recordings || []);
        const after = [pane, ...pane.querySelectorAll('.tw-main, .tw-properties')];
        after.forEach((el, i) => { if (tops[i]) el.scrollTop = tops[i]; });
    } catch (e) { /* the next change tries again */ }
}
Situation What happens
not busy, not mine liveReloadTicket(): no spinner (unlike loadTicketById()); scroll kept for the reading pane and both workspace columns
busy, someone else's change the conversation catches up and the prompt appears
mine only the conversation catches up: the part that saved already redrew
  • The conversation always catches up because it is append-only: a customer's new WhatsApp message must appear while you're writing your reply. That is why the old chat timer now arms only without live updates:

    if (currentTicketChannel !== 'email' && !window.LivePulse?.enabled()) {
        channelRefreshTimer = setInterval(() => { /* … */ }, 15000);
    }
  • hasUnsavedWork was exported from ticket-workspace.js for this; it already backed the workspace's own holdRender():

    requestPersonal, canLeave, holdRender, flushRender, hasUnsavedWork,   // hasUnsavedWork: live updates (GH #267)

The tasks board and the open task - assets/js/tasks.js

// DOMContentLoaded, after the first loadTasks() - not in an embedded editor (it returns earlier)
if (window.LivePulse) LivePulse.watch('tasks', liveBoardChanged);

function taskBoardBusy() {
    if (dragState || document.querySelector('.col-dragging, .subtask-item.dragging')) return true;
    const menu = document.getElementById('ctxMenu');                       // TasksCtxMenu's card menu
    return !!(menu && menu.style.display === 'block');
}

function liveBoardChanged() {
    if (window.TASK_EMBED_CONTEXT) return;
    if (taskBoardBusy()) { setTimeout(liveBoardChanged, 2500); return; }
    loadTasks();
}

loadTasks() redraws the board or list from api/tasks/list.php. The detail panel is separate DOM, so the board can redraw under an open task.

// openDetailPanel(), once drawn:        liveWatchTask(taskId);
// closeDetailPanel():                   liveWatchTask(0);

function taskPanelBusy() {
    const panel = document.getElementById('detailPanel');
    if (!panel) return false;
    const a = document.activeElement;
    if (a && panel.contains(a) && (a.matches('input, textarea, select') || a.isContentEditable)) return true;
    if (document.getElementById('newCommentInput')?.value.trim()) return true;
    if (descSaveTimer || (tinyEditor && typeof tinyEditor.hasFocus === 'function' && tinyEditor.hasFocus())) return true;
    if (panel.querySelector('.tf-frame') || document.querySelector('.modal.active, .modal-overlay.active')) return true;
    return taskBoardBusy();
}

async function liveTaskChanged(taskId, info) {
    if (selectedTaskId !== taskId || info.mine) return;   // ours: the panel already shows it
    const panel = document.getElementById('detailPanel');
    const body = panel?.querySelector('.detail-panel-body') || panel;
    if (taskPanelBusy()) { LivePulse.busyChip(body, () => liveRedrawTask(taskId)); return; }
    liveRedrawTask(taskId);
}

async function liveRedrawTask(taskId) {
    const panel = document.getElementById('detailPanel');
    const body = panel?.querySelector('.detail-panel-body') || panel;
    const top = body ? body.scrollTop : 0;
    await openDetailPanel(taskId);                      // which scrolls to the top on every open
    const after = document.getElementById('detailPanel')?.querySelector('.detail-panel-body') || document.getElementById('detailPanel');
    if (after && top) after.scrollTop = top;
}
  • The open task ignores mine where the open ticket doesn't. Task fields save themselves on change, so each of your own saves would otherwise come back as a redraw while you move to the next field.
  • descSaveTimer is the description's debounced save. Redrawing before it fires would destroy the TinyMCE instance holding the unsaved text.

The bell - assets/js/notification-bell.js

poll();
setInterval(poll, 60000);
// GH #267: a new notification shows within one heartbeat (15 seconds by default),
// not the next minute. The minute poll stays: it also starts the scheduler (#233).
if (window.LivePulse && !cfg.portal) window.LivePulse.watch('notifications', () => poll());

The portal bell never loads live-pulse.js, so window.LivePulse is undefined there. poll() is the bell's existing count request, which also paints the badge and plays the chime.

The preference - system/preferences/index.php

'live_updates'               => '15',   // in $prefDefaults: '15', '30', '60' or '0'
const liveSelect = document.getElementById('liveUpdatesSelect');
if (liveSelect) {
    let savedLive = liveSelect.value;
    liveSelect.addEventListener('change', async () => {
        liveSelect.disabled = true;
        if (await savePref('live_updates', liveSelect.value)) savedLive = liveSelect.value;
        else liveSelect.value = savedLive;
        liveSelect.disabled = false;
    });
}

includes/preference_templates.php lists it as section live-updates, so preference templates can carry it (tests/preference-templates.php fails for a page key in neither list). It applies from the next page load, because FREEITSM_LIVE is printed by the server.


5. Traps (page side)

TRAP: housekeeping writes are not "mine". The presence heartbeat (ticket_presence.php) posts every 10 seconds. While it counted as a write, every beat was mine, so the open ticket never followed anybody else's change: the list updated and the pane did not. Found by the headless check (Β§6). A new background writer goes in BACKGROUND_WRITES.

TRAP: re-check busy after the fetch. liveReloadTicket() fetches first and redraws after, and you may have started typing in between. It calls liveTicketBusy() again before displayEmail().

TRAP: openDetailPanel() scrolls to the top. A live redraw that called it bare would jump the panel back to the top every time a colleague commented. liveRedrawTask() saves and restores the scroll position.

TRAP: lists retry, records prompt. A list that is busy only because a menu is open will be free in a moment, so it retries every 2.5 s. An open record that is busy may stay busy for minutes (a long reply), so it shows the prompt instead of retrying for ever.

TRAP: embedded task editors. A task inside a ticket's Work tab is tasks/embedded.php in a frame, with no header, so it has no LivePulse. liveBoardChanged() returns early for TASK_EMBED_CONTEXT as well.


6. The browser checks

Done for 5.0.0 on a test install with headless Chrome. The harness lives outside the repo; this is its shape. A separate PHP script plays "somebody else", so the page can't count the change as its own:

// zz267change.php - in the test install, removed afterwards
$p = new PDO(/* the throwaway database */);
$p->prepare("UPDATE tickets SET subject = ?, updated_datetime = UTC_TIMESTAMP() WHERE id = ?")
  ->execute(['ZZ267 renamed by someone else', $ticketId]);
<!-- zz272harness.html: the real page in a frame; the harness's own fetch is not wrapped -->
<iframe id="f" src="tickets/?ticket_id=1451"></iframe>
<script>
f.onload = () => setTimeout(async () => {
  const d = f.contentWindow.document;
  // the busy variant: f.contentWindow.openNoteModal(); d.getElementById('noteText').textContent = 'half-written note';
  await fetch('zz267change.php');
  setTimeout(() => {
    const listHas = [...d.querySelectorAll('.email-item')].some(r => r.textContent.includes('ZZ267 renamed'));
    const paneHas = d.getElementById('readingPane').textContent.includes('ZZ267 renamed');
    document.title = 'list=' + listHas + ' pane=' + paneHas + ' chip=' + !!d.querySelector('.live-pulse-chip');
  }, 20000);
}, 4000);
</script>
chrome --headless=new --user-data-dir=<throwaway profile> --virtual-time-budget=40000 --dump-dom http://localhost/<install>/zz272login.php
Check Result
Rename the open ticket list=true pane=true (before the BACKGROUND_WRITES fix: list=true pane=false)
The same, with a half-written note pane=false chip=true, and the note and its text were kept
Rename the open task and another card the card shows the new title, and the task's title input has the new value (read input.value; textContent doesn't include it)

Delete the throwaway profile afterwards, never every Chrome process.


7. Adding live updates to another screen

  1. Server: add livePulse<Thing>() and a branch in livePulseSignatures(). See page 1 Β§5 for the pattern, and gate and scope it exactly like the list it stands for.

  2. Watch it:

    function changesChanged() {
        if (changesBusy()) { setTimeout(changesChanged, 2500); return; }   // a list: wait
        loadChanges();
    }
    const stop = LivePulse.watch('changes', changesChanged);
    // an open record: LivePulse.watch('change:' + id, (t, info) => {
    //     if (info.mine) return;
    //     if (recordBusy()) return LivePulse.busyChip(container, () => redraw(id));
    //     redraw(id);   // no spinner, scroll restored
    // });
  3. Write the busy rule for that screen: focused fields, open modals and menus, drags, writes in flight, unsaved drafts.

  4. Test both halves: the signature in tests/live-pulse.php, and a headless check where a separate script makes the change.

Not wired yet: the Changes, Problems and Assets lists and their open records.


Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally