browser.mjs

/** @module browser */

import { isEmpty, isFunction } from './helpers.mjs'
import { css } from './dom.mjs'
import { parseUrlParameters } from './parsers.mjs'

export function isUserAgentIOS(str) {
  return /iPad|iPhone|iPod/i.test(str)
}

export function isUserAgentMobile(str) {
  return /\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(str) ||
    /\b(Android|Windows Phone|iPad|iPod)\b/i.test(str)
}

export function isUserAgentSafari(str) {
  return /^((?!chrome|android|crios|fxios).)*safari/i.test(str)
}

/**
 * Check if the device is an iOS device
 * 
 * @returns boolean True if the device is an iOS device, false otherwise
 */
export function isIOS() {
  return isUserAgentIOS(navigator.userAgent) && 'maxTouchPoints' in navigator && navigator.maxTouchPoints > 1
}

/**
 * Check if the device is a mobile device
 * 
 * @returns boolean True if the device is a mobile device, false otherwise
 */
export function isMobile() {
  if ('maxTouchPoints' in navigator) return navigator.maxTouchPoints > 0

  if ('matchMedia' in window) return !!matchMedia('(pointer:coarse)').matches

  if ('orientation' in window) return true

  return isUserAgentMobile(navigator.userAgent)
}

/**
 * Check if the browser is Safari
 *
 * @returns boolean True if the browser is Safari, false otherwise
 */
export function isSafari() {
  if (navigator.hasOwnProperty('vendor')) return /apple/i.test(navigator.vendor)
  return isUserAgentSafari(navigator.userAgent)
}

/**
 * Check if the browser is Safari on iOS
 * 
 * @returns boolean True if the browser is Safari on iOS, false otherwise
 */
export function isIOSSafari() {
  return isIOS() && isSafari()
}

/**
 * A wrapper for the matchMedia function, cause with `matchMedia` you can only either add a listener or check the media query
 * this function does both.
 * 
 * @param {string} query The media query to check
 * @param {function} [callback] The callback function to call when the media query changes
 * @returns {boolean} The result of the media query
 * 
 * @example
 * mediaMatcher('(min-width: 768px)', (matches) => {
 *  if (matches) {
 *    // Do something
 *  } else {
 *    // Do something else
 *  }
 * })
 * 
 * // Or
 * 
 * const isDesktop = mediaMatcher('(min-width: 768px)')
 */
export function mediaMatcher(query, callback) {
  if (isFunction(callback)) {
    const mql = matchMedia(query)
    mql.addEventListener('change', (e) => {
      callback(e.matches)
    })
    callback(mql.matches)

    return mql.matches
  }

  return matchMedia(query).matches
}

/**
 * Checks whether the user has asked the system to minimize non-essential motion.
 *
 * Animations should be skipped or reduced when this is true - it is an accessibility
 * setting, not a preference, and for some users motion causes nausea or worse.
 * Returns false where `matchMedia` does not exist, so it is safe to call outside a
 * browser: no preference expressed, no motion suppressed.
 *
 * @param {function} [callback] The callback function to call when the preference changes
 * @returns {boolean} True if the user prefers reduced motion
 *
 * @example
 * if (prefersReducedMotion()) duration = 0
 *
 * // Or, to react to the user changing it while the page is open
 *
 * prefersReducedMotion((reduced) => {
 *  document.body.classList.toggle('is-still', reduced)
 * })
 */
export function prefersReducedMotion(callback) {
  if (typeof matchMedia !== 'function') return false
  return mediaMatcher('(prefers-reduced-motion: reduce)', callback)
}

/**
 * Get the scrollbar width
 * 
 * When preventing scroll with html overflow hidden the scroll bar will disappear and the whole page will shift (if the scroll bar is visible that is).
 * To substitute for the scrollbar width we can add a padding to the body element.
 * 
 * @returns {number} The scrollbar width
 * 
 * @example
 * const scrollbarWidth = getScrollbarWidth() // 15 (on MacOS X Safari)
 */
export function getScrollbarWidth() {
  const scrollDiv = document.createElement('div')
  
  css(scrollDiv, {
    width: '100px',
    height: '100px',
    position: 'absolute',
    left: '-9999px',
    zIndex: '0',
    overflowX: 'hidden',
    overflowY: 'scroll'
  })

  document.body.appendChild(scrollDiv)
  const scrollbarWidth = scrollDiv.offsetWidth - scrollDiv.clientWidth
  document.body.removeChild(scrollDiv)
  return scrollbarWidth
}

/**
 * Check if the vertical scrollbar is visible
 * 
 * @param {number} [scrollbarWidth] The width of the scrollbar, defaults to getScrollbarWidth()
 * @returns {boolean} True if the vertical scrollbar is visible, false otherwise
 */
export function hasVerticalScrollbarVisible(scrollbarWidth) {
  if (scrollbarWidth === undefined) scrollbarWidth = getScrollbarWidth()
  return window.innerHeight < document.body.scrollHeight && scrollbarWidth > 0
}

/**
 * Check if the horizontal scrollbar is visible
 * 
 * @param {number} [scrollbarWidth] The width of the scrollbar, defaults to getScrollbarWidth()
 * @returns {boolean} True if the horizontal scrollbar is visible, false otherwise
 */
export function hasHorizontalScrollbarVisible(scrollbarWidth) {
  if (scrollbarWidth === undefined) scrollbarWidth = getScrollbarWidth()
  return window.innerWidth < document.body.scrollWidth && scrollbarWidth > 0
}

/**
 * Disable the scroll on the page.
 * 
 * @param {number} [shift=0] If greater than 0 the body will be shifted to the left by the width of the scrollbar, getScrollbarWidth() is used to provide this value  
 */
export function disableScroll(shift) {
  const body = document.body
  if (shift && hasVerticalScrollbarVisible(shift)) body.style.paddingRight = `${shift}px`
  body.style.overflow = 'hidden'
}

/**
 * Enable the scroll on the page.
 * 
 * @param {boolean} [shift=0] If greater than 0 the body will be shifted back to the left by the width of the scrollbar, getScrollbarWidth() is used to provide this value
 */
export function enableScroll(shift) {
  const body = document.body
  body.style.overflow = ''
  if (shift) body.style.paddingRight = ''
}

/**
 * Parses a string of url query parameters into an object of key value pairs. Converts the values to the correct type.
 * 
 * @param {string} [entryQuery] - Optional query string to parse, without the starting ?, defaults to window.location.search without the starting ?
 * @returns {object} of key value pairs
 * @example
 * // url: https://example.com/?test&foo=bar&baz=qux
 * getQueryProperties() // { test: undefined, foo: 'bar', baz: 'qux' }
 */
export function getQueryProperties(entryQuery) {
  const query = entryQuery ? entryQuery : window.location.search.replace('?', '')
  if (isEmpty(query)) return {}

  return parseUrlParameters(query)
}

/**
 * Parses a string of url hash parameters into an object of key value pairs. Converts the values to the correct type.
 * 
 * @param {string} [entryHash] - Optional hash string to parse, without the starting #, defaults to window.location.hash without the starting #
 * @returns {object} of key value pairs
 * @example
 * // url: https://example.com/#test&foo=bar&baz=qux
 * getHashProperties() // { test: undefined, foo: 'bar', baz: 'qux' }
 */
export function getHashProperties(entryHash) {
  const hash = entryHash ? entryHash : window.location.hash.replace('#', '')
  if (isEmpty(hash)) return {}

  return parseUrlParameters(hash)
}

/**
 * The id a `#fragment` names - out of an `href` or out of `location.hash`, both of
 * which arrive with the `#` still on.
 *
 * Decoded, because `id="café"` is reached by `href="#caf%C3%A9"` and the two have to
 * meet somewhere. Taken as written when decoding fails: a stray `%` in a fragment is a
 * typo, and a typo in one link is not a reason for the caller to stop working.
 *
 * @param {string} hash The fragment, with or without the leading `#`
 * @returns {string} The decoded id, `''` for an empty fragment
 * @example
 * decodeFragment('#caf%C3%A9') // => 'café'
 * decodeFragment(window.location.hash) // => the id the url points at
 * decodeFragment('#100%') // => '100%', kept as written
 */
export function decodeFragment(hash) {
  const raw = hash.startsWith('#') ? hash.slice(1) : hash
  try {
    return decodeURIComponent(raw)
  } catch {
    return raw
  }
}

function onHashChange(callback) {
  const hash = window.location.hash.replace('#', '')
  if (!isEmpty(hash)) callback(hash)
}

/**
 * Add a callback function to the hash change event
 * 
 * @param {function} callback - The callback function to call when the hash changes
 * @param {string} [single] - Optional string to make sure the listener is initialized only once, defaults to window[single] which is set to true after the first call
 * @example
 * hashChange((hash) => {
 * // Do something with the hash
 * })
 */
export function hashChange(callback, single) {
  onHashChange(callback)
  
  if (single && window[single]) return
  if (single) window[single] = true
  
  window.addEventListener('hashchange', () => {
    onHashChange(callback)
  })
}

/**
 * Reports when the user has stopped interacting with the page, and when they come back.
 *
 * Idle means no interaction for `timeout` milliseconds — the callback gets `false` once that
 * runs out and `true` the moment the user comes back, so a video can pause itself, a poll can
 * stop polling, or a session can end where it should. Only the changes are reported: a reader
 * scrolling steadily gets one `true` at the end of their pause, not one per event, and the page
 * starting out active is not reported at all, being the state every caller already has.
 *
 * There is a native `IdleDetector`, but it is Chromium-only, needs the `idle-detection`
 * permission, and answers a different question — whether the machine is idle or its screen
 * locked, not whether anyone is reading this page.
 *
 * What the clock says outranks what the timer says. A timer set for the idle deadline can run
 * late: a long task blocks it, a background tab has its timers clamped to a second, and a tab
 * the browser has frozen may not run it at all. Waking late is therefore treated as proof the
 * user is idle rather than as a reason to doubt it, waking early re-arms for the remainder, and
 * a tab returning from hidden checks its deadline at once, since nothing may have run while it
 * was away. The clock is `performance.now()` — the wall clock jumps on an NTP correction or a
 * daylight-saving change, and would take the deadline with it.
 *
 * Answers for this tab alone unless `channel` is given a name, in which case every tab of this
 * origin using that name agrees: activity in any of them counts in all of them, and idle means
 * all of them are. That is what a session deadline wants — three tabs open and work happening
 * in the third should not log the first two out — and what a pausing video does not, since a
 * user reading elsewhere is exactly when this tab should stop playing. Hence a name to opt in
 * rather than a default. Where `BroadcastChannel` is missing, each tab falls back to answering
 * for itself.
 *
 * @param {function} callback Called with `false` when the user goes idle and `true` when they return
 * @param {object} [options]
 * @param {number} [options.timeout=60000] Milliseconds of no interaction that count as idle. `Infinity` never reports idle; zero or less is nothing to observe, answered with `null`
 * @param {string|Array<string>} [options.events] Events that count as interaction, replacing the defaults — `pointerdown`, `pointermove`, `keydown`, `wheel`, `scroll`, `touchstart` and `resize`. Listened for on `window`, in the capturing phase, so a widget that stops its own events from propagating cannot read as the user having left, and events only `window` ever receives still arrive. `scroll` is there for the scrollbar drag some browsers fire no pointer events for, and it cuts both ways: a page that scrolls itself — a carousel, a chat log pinned to its end — reads as a user for as long as it keeps moving. Such a page should pass the defaults minus `scroll`. Several events may share one space-separated string; an empty list is nothing to observe, answered with `null`
 * @param {string} [options.channel] A `BroadcastChannel` name shared with the other tabs of this origin, making activity in any of them count in all of them. What travels is the activity, never the verdict — each tab judges idle against its own `timeout`, so every observer sharing a name must share one, or the tab with the shortest deadline goes idle while the rest disagree. Left out, this tab answers for itself
 * @returns {object|null} `{ destroy }`, or `null` when there is no callback, no DOM, no positive timeout, or no events to listen for
 * @example
 * const activity = userActivity((active) => {
 *  if (!active) video.pause()
 * }, { timeout: 30000 })
 *
 * activity.destroy() // stop listening
 *
 * // Warning someone before the deadline is two observers, not an extra option
 * userActivity((active) => { warning.hidden = active }, { timeout: 25 * 60000 })
 * userActivity((active) => { if (!active) logout() }, { timeout: 30 * 60000 })
 *
 * // A deadline the whole site agrees on, however many tabs are open
 * userActivity((active) => { if (!active) logout() }, { timeout: 15 * 60000, channel: 'session' })
 */
export function userActivity(callback, options = {}) {
  if (!isFunction(callback) || typeof window === 'undefined' || typeof document === 'undefined') return null

  const timeout = options.timeout === undefined ? 60000 : options.timeout
  // A deadline of zero, NaN or less is not a deadline, and `||` would hand a computed-to-zero
  // session budget a silent fresh minute. Infinity passes: it means never idle.
  if (!(timeout > 0)) return null
  // Split the way the library's listener helpers split, so the documented `'click keydown'`
  // form cannot land as one bogus event type nothing ever fires.
  const events = [].concat(options.events === undefined ? ['pointerdown', 'pointermove', 'keydown', 'wheel', 'scroll', 'touchstart', 'resize'] : options.events)
    .flatMap(function(event) { return String(event).split(' ') })
    .filter(Boolean)
  if (!events.length) return null

  let last = performance.now()
  // `timer === null` doubles as the idle flag: armed while the user counts as present, cleared
  // the moment idle is reported — one fact, not two to keep in step.
  let timer = null
  let lastX = null
  let lastY = null

  let channel = options.channel && typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(options.channel) : null
  // Bounded against the deadline rather than fixed at a second: a post the throttle skips leaves
  // the other tabs' idea of the last activity that much stale, which is nothing against a minute
  // and most of the answer against a four-second timeout.
  const beat = Math.min(1000, timeout / 10)
  // Not zero: `performance.now()` starts near it, so a page's first second of activity would be
  // throttled away as though a post had just gone out.
  let lastPost = -Infinity

  // `setTimeout` folds its delay into an int32, so past ~24.9 days — or at `Infinity` — it
  // fires at once, and a deadline re-armed from here would spin at timer resolution forever.
  // Clamped, a longer deadline just re-arms when the clamp runs out, `check` finding it not due.
  const arm = function(delay) {
    timer = setTimeout(check, Math.min(delay, 2147483647))
  }

  const check = function() {
    const idleFor = performance.now() - last
    if (idleFor < timeout) {
      arm(timeout - idleFor)
      return
    }

    timer = null
    callback(false)
  }

  // A pointer that has not moved is not a user. Content scrolling or animating under a parked
  // cursor makes the browser fire move events carrying the coordinates it fired last time, and
  // taking those for interaction would keep a page nobody is watching marked as read. Judged
  // only where there are coordinates to judge: a synthetic move event carrying none is left
  // alone rather than silently dropped.
  const stationary = function(event) {
    if (event.type !== 'pointermove' && event.type !== 'mousemove') return false
    if (typeof event.clientX !== 'number') return false

    const same = event.clientX === lastX && event.clientY === lastY
    lastX = event.clientX
    lastY = event.clientY
    return same
  }

  // An event costs a clock read and an assignment, so there is nothing here worth throttling,
  // and no timer to reset: the deadline extends itself the next time it comes due.
  const register = function() {
    last = performance.now()
    if (timer !== null) return

    // Armed before the callback, never after: a callback that destroys the observer would
    // otherwise have a fresh timer set behind it, on listeners that are already gone.
    arm(timeout)
    callback(true)
  }

  // Posting is throttled where registering is not, because this one leaves the page: a message
  // per `pointermove` is sixty a second to every other tab. Nothing is sent about going idle —
  // each tab derives that from the same activity and arrives there on its own — and a message
  // received is never posted onward, which would be two tabs keeping each other awake forever.
  const broadcast = function() {
    if (!channel) return

    const posted = performance.now()
    if (posted - lastPost < beat) return

    lastPost = posted
    channel.postMessage(1)
  }

  const onActivity = function(event) {
    if (stationary(event)) return

    register()
    broadcast()
  }

  const onVisibility = function() {
    if (document.visibilityState !== 'visible' || timer === null) return
    clearTimeout(timer)
    check()
  }

  for (const event of events) window.addEventListener(event, onActivity, { capture: true, passive: true })
  document.addEventListener('visibilitychange', onVisibility)
  if (channel) channel.onmessage = register
  arm(timeout)

  return {
    destroy: function() {
      for (const event of events) window.removeEventListener(event, onActivity, { capture: true })
      document.removeEventListener('visibilitychange', onVisibility)
      // Nulled as well as closed: a callback that destroys the observer returns into
      // onActivity, whose broadcast would otherwise post on a closed channel, which throws.
      if (channel) {
        channel.close()
        channel = null
      }
      clearTimeout(timer)
      timer = null
    }
  }
}