Source: WmmText.js

  1. import { initMediaMonetization, mediaRemoved } from '../wmm-utils/client/monetize.js'
  2. import { setUrl, bindNotifications } from './common.js'
  3. /**
  4. * How much of the last paragraph must be visible to start loading
  5. * next paragraph.
  6. * Value must be between: 0 < threshold <= 1
  7. * @type {number}
  8. */
  9. const threshold = 0.1
  10. let observerOptions = {
  11. rootMargin: '0px',
  12. threshold: threshold
  13. }
  14. /**
  15. * Allowing HTML injection gives more power in the type of content that can be show,
  16. * but can causes a security vulnerability unless the source of the text is controlled
  17. * to reject harmful script tags.
  18. */
  19. const allowHtmlInjection = true
  20. /**
  21. * Creates a monetized text component &lt;wmm-text&gt;<br/>
  22. * This component loads new paragraphs with scrolling<br/>
  23. * (i.e. when there is room to show more text).<br/>
  24. * Attributes:<br/>
  25. * * src: enpoint for retrieving the paragraphs.<br/>
  26. * * paymentUrl: Payment pointer URL, can also include receipt service url.<br/>
  27. * * media: The text can be also passed in media attribute, which don't require a backend at all, but is not secure as it is directly accessible from the browser.<br/>
  28. */
  29. class WmmText extends HTMLElement {
  30. get src() { return this.getAttribute('src') }
  31. set src(url) { setUrl(this, url) }
  32. get paymentUrl() { return this.getAttribute('paymentUrl') }
  33. set paymentUrl(url) { this.setAttribute('paymentUrl', url) }
  34. constructor () {
  35. super()
  36. this.paragraph = -1
  37. }
  38. /**
  39. * Initializes component when inserted into DOM.
  40. */
  41. connectedCallback () {
  42. this.innerHTML = `
  43. <style>
  44. wmm-text {
  45. display: block;
  46. }
  47. wmm-text p {
  48. margin: 0;
  49. padding: 1em 0 0 1em;
  50. }
  51. </style>
  52. `
  53. this.parseMedia()
  54. initMediaMonetization(this)
  55. this.initNotifications()
  56. this.startLoadingText()
  57. }
  58. /**
  59. * Stops monetization when component is removed from DOM.
  60. */
  61. disconnectedCallback () {
  62. mediaRemoved(this)
  63. }
  64. /**
  65. * Native event that fires when element attribute changes.
  66. * Handle 'media' attribute when changed.
  67. */
  68. attributeChangedCallback(name, oldValue, newValue) {
  69. if (name == 'media') this.parseMedia()
  70. }
  71. /**
  72. * Initialize frontend mode, if 'media' attribute exists.
  73. * In frontend mode the text is parsed from 'media' attribute,
  74. * and not loaded from the backend.
  75. */
  76. parseMedia() {
  77. const media = this.getAttribute('media')
  78. this.setAttribute('skipVerification', !!media)
  79. this.paragraphs = media?.split(/\n\n+/g)
  80. }
  81. /**
  82. * Creates an IntersectionObserver, that calls loadParagraph()
  83. * when the last paragragh is visible on the screen.
  84. */
  85. startLoadingText() {
  86. if (!this.parentElement)
  87. return // not added to dom yet
  88. if (this.observer) {
  89. throw Error("observer already initialised in startLoadingText()")
  90. }
  91. this.observer = new IntersectionObserver(entries => {
  92. var entry = entries[0] // only one entry (lastParagraph) expected
  93. if (entry.intersectionRatio < threshold) return
  94. this.loadParagraph()
  95. }, observerOptions);
  96. this.loadParagraph() // "load" first paragraph
  97. }
  98. /**
  99. * Load the next paragragh, add it to DOM and start observing when it becomes visible
  100. * (which will cause another paragraph to be loaded).
  101. * When paragrah is loaded from backend, 'paragraphLoading' and 'paragraphLoaded'
  102. * events will be emitted.
  103. */
  104. async loadParagraph() {
  105. this.lastParagraph && this.observer.unobserve(this.lastParagraph)
  106. // get paragraph
  107. var pText
  108. if (this.paragraphs) {
  109. // text given as attribute
  110. pText = this.paragraphs.pop()
  111. if (typeof pText != 'string')
  112. return // end of article reached
  113. } else {
  114. // backend mode
  115. let src = this.getAttribute('src')
  116. if (!src)
  117. throw Error("'src' property missing in <wmm-text>")
  118. this.paragraph++
  119. // add paragraph number to url
  120. const urlAndParams = src.split('?')
  121. urlAndParams[0] += '/' + this.paragraph
  122. src = urlAndParams.join('?')
  123. // start loading
  124. this.dispatchEvent(new CustomEvent('paragraphLoading'))
  125. const res = await fetch(src)
  126. this.dispatchEvent(new CustomEvent('paragraphLoaded'))
  127. if (res.status === 204)
  128. return // end of article reached
  129. pText = await res.text()
  130. }
  131. // add to dom
  132. this.lastParagraph = document.createElement('p')
  133. if (allowHtmlInjection)
  134. this.lastParagraph.innerHTML = pText
  135. else
  136. this.lastParagraph.textContent = pText
  137. this.appendChild(this.lastParagraph)
  138. this.observer.observe(this.lastParagraph)
  139. }
  140. /**
  141. * Binds to default notifications.
  142. * Shown on 'paragraphPending' event and hidden on 'paragraphLoaded' event.
  143. */
  144. initNotifications() {
  145. bindNotifications(this, true)
  146. const waitBeforeShowing = 600
  147. let timeout
  148. this.addEventListener('paragraphLoading', () =>
  149. (timeout = setTimeout(() => {
  150. this.dispatchEvent(new CustomEvent('paragraphPending'))
  151. }, waitBeforeShowing))
  152. )
  153. this.addEventListener('paragraphLoaded', () => clearTimeout(timeout))
  154. }
  155. }
  156. window.customElements.define('wmm-text', WmmText)