/ static / components / mastodon-comments.js
mastodon-comments.js
  1  const styles = `
  2  :root {
  3    --font-color: #5d686f;
  4    --font-size: 1.0rem;
  5  
  6    --block-border-width: 1px;
  7    --block-border-radius: 3px;
  8    --block-border-color: #ededf0;
  9    --block-background-color: #f7f8f8;
 10  
 11    --comment-indent: 40px;
 12    --comment-padding: 20px;
 13  }
 14  
 15  #mastodon-stats {
 16    text-align: center;
 17    font-size: calc(var(--font-size) * 2)
 18  }
 19  
 20  #mastodon-comments-list {
 21    margin: 0 auto;
 22  }
 23  
 24  .mastodon-comment {
 25    background-color: var(--block-background-color);
 26    border-radius: var(--block-border-radius);
 27    border: var(--block-border-width) var(--block-border-color) solid;
 28    padding: var(--comment-padding);
 29    margin-bottom: 1.5rem;
 30    display: flex;
 31    flex-direction: column;
 32    color: var(--font-color);
 33    font-size: var(--font-size);
 34  }
 35  
 36  .mastodon-comment p {
 37    margin-bottom: 0px;
 38  }
 39  
 40  .mastodon-comment .author {
 41    padding-top:0;
 42    display:flex;
 43  }
 44  
 45  .mastodon-comment .author a {
 46    text-decoration: none;
 47  }
 48  
 49  .mastodon-comment .author .avatar img {
 50    margin-right:1rem;
 51    min-width:60px;
 52    border-radius: 5px;
 53  }
 54  
 55  .mastodon-comment .author .details {
 56    display: flex;
 57    flex-direction: column;
 58  }
 59  
 60  .mastodon-comment .author .details .name {
 61    font-weight: bold;
 62  }
 63  
 64  .mastodon-comment .author .details .user {
 65    color: #5d686f;
 66    font-size: medium;
 67  }
 68  
 69  .mastodon-comment .author .date {
 70    margin-left: auto;
 71    font-size: small;
 72  }
 73  
 74  .mastodon-comment .content {
 75    margin: 15px 20px;
 76  }
 77  
 78  .mastodon-comment .attachments {
 79    margin: 0px 10px;
 80  }
 81  
 82  .mastodon-comment .attachments > * {
 83    margin: 0px 10px;
 84  }
 85  
 86  .mastodon-comment .attachments img {
 87    max-width: 100%;
 88  }
 89  
 90  .mastodon-comment .content p:first-child {
 91    margin-top:0;
 92    margin-bottom:0;
 93  }
 94  
 95  .mastodon-comment .status > div, #mastodon-stats > div {
 96    display: inline-block;
 97    margin-right: 15px;
 98  }
 99  
100  .mastodon-comment .status a, #mastodon-stats a {
101    color: #5d686f;
102    text-decoration: none;
103  }
104  
105  .mastodon-comment .status .replies.active a, #mastodon-stats .replies.active a {
106    color: #003eaa;
107  }
108  
109  .mastodon-comment .status .reblogs.active a, #mastodon-stats .reblogs.active a {
110    color: #8c8dff;
111  }
112  
113  .mastodon-comment .status .favourites.active a, #mastodon-stats .favourites.active a {
114    color: #ca8f04;
115  }
116  `;
117  
118  class MastodonComments extends HTMLElement {
119      constructor() {
120          super();
121  
122          this.host = this.getAttribute("host");
123          this.user = this.getAttribute("user");
124          this.tootId = this.getAttribute("tootId");
125  
126          this.commentsLoaded = false;
127  
128          const styleElem = document.createElement("style");
129          styleElem.innerHTML = styles;
130          document.head.appendChild(styleElem);
131      }
132  
133      connectedCallback() {
134          this.innerHTML = `
135        <!-- div id="mastodon-stats"></div -->
136        <h2 id="comments" class="headerLink">
137        <a href="#comments" class="header-mark">
138        # 
139        </a>
140        Comments
141        </h2>
142  
143        <p>You can use your Fediverse (i.e. Mastodon, among many others) account to reply to this <a class="link"
144            href="https://${this.host}/@${this.user}/${this.tootId}">post</a>.
145        </p>
146        <p id="mastodon-comments-list"></p>
147      `;
148  
149          const comments = document.getElementById("mastodon-comments-list");
150          const rootStyle = this.getAttribute("style");
151          if (rootStyle) {
152              comments.setAttribute("style", rootStyle);
153          }
154          this.respondToVisibility(comments, this.loadComments.bind(this));
155      }
156  
157      escapeHtml(unsafe) {
158          return (unsafe || "")
159              .replace(/&/g, "&amp;")
160              .replace(/</g, "&lt;")
161              .replace(/>/g, "&gt;")
162              .replace(/"/g, "&quot;")
163              .replace(/'/g, "&#039;");
164      }
165  
166      toot_active(toot, what) {
167          var count = toot[what + "_count"];
168          return count > 0 ? "active" : "";
169      }
170  
171      toot_count(toot, what) {
172          var count = toot[what + "_count"];
173          return count > 0 ? count : "";
174      }
175  
176      toot_stats(toot) {
177          return `
178        <div class="replies ${this.toot_active(toot, "replies")}">
179          <a href="${toot.url
180              }" rel="nofollow"><i class="fa fa-reply fa-fw"></i>${this.toot_count(
181                  toot,
182                  "replies",
183              )}</a>
184        </div>
185        <div class="reblogs ${this.toot_active(toot, "reblogs")}">
186          <a href="${toot.url
187              }" rel="nofollow"><i class="fa fa-retweet fa-fw"></i>${this.toot_count(
188                  toot,
189                  "reblogs",
190              )}</a>
191        </div>
192        <div class="favourites ${this.toot_active(toot, "favourites")}">
193          <a href="${toot.url
194              }" rel="nofollow"><i class="fa fa-star fa-fw"></i>${this.toot_count(
195                  toot,
196                  "favourites",
197              )}</a>
198        </div>
199      `;
200      }
201  
202      user_account(account) {
203          var result = `@${account.acct}`;
204          if (account.acct.indexOf("@") === -1) {
205              var domain = new URL(account.url);
206              result += `@${domain.hostname}`;
207          }
208          return result;
209      }
210  
211      render_toots(toots, in_reply_to, depth) {
212          var tootsToRender = toots
213              .filter((toot) => toot.in_reply_to_id === in_reply_to)
214              .sort((a, b) => a.created_at.localeCompare(b.created_at));
215          tootsToRender.forEach((toot) => this.render_toot(toots, toot, depth));
216      }
217  
218      render_toot(toots, toot, depth) {
219          toot.account.display_name = this.escapeHtml(toot.account.display_name);
220          toot.account.emojis.forEach((emoji) => {
221              toot.account.display_name = toot.account.display_name.replace(
222                  `:${emoji.shortcode}:`,
223                  `<img src="${this.escapeHtml(emoji.static_url)}" alt="Emoji ${emoji.shortcode
224                  }" height="20" width="20" />`,
225              );
226          });
227  
228          const mastodonComment = `<div class="mastodon-comment" style="margin-left: calc(var(--comment-indent) * ${depth})">
229          <div class="author">
230            <div class="avatar">
231              <img src="${this.escapeHtml(
232              toot.account.avatar_static,
233          )}" height=60 width=60 alt="">
234            </div>
235            <div class="details">
236              <a class="name" href="${toot.account.url}" rel="nofollow">${toot.account.display_name
237              }</a>
238              <a class="user" href="${toot.account.url
239              }" rel="nofollow">${this.user_account(toot.account)}</a>
240            </div>
241            <a class="date" href="${toot.url
242              }" rel="nofollow">${toot.created_at.substr(
243                  0,
244                  10,
245              )} ${toot.created_at.substr(11, 8)}</a>
246          </div>
247          <div class="content">${toot.content}</div>
248          <div class="attachments">
249            ${toot.media_attachments
250                  .map((attachment) => {
251                      if (attachment.type === "image") {
252                          return `<a href="${attachment.url}" rel="nofollow"><img src="${attachment.preview_url
253                              }" alt="${this.escapeHtml(attachment.description)}" /></a>`;
254                      } else if (attachment.type === "video") {
255                          return `<video controls><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
256                      } else if (attachment.type === "gifv") {
257                          return `<video autoplay loop muted playsinline><source src="${attachment.url}" type="${attachment.mime_type}"></video>`;
258                      } else if (attachment.type === "audio") {
259                          return `<audio controls><source src="${attachment.url}" type="${attachment.mime_type}"></audio>`;
260                      } else {
261                          return `<a href="${attachment.url}" rel="nofollow">${attachment.type}</a>`;
262                      }
263                  })
264                  .join("")}
265          </div>
266          <div class="status">
267            ${this.toot_stats(toot)}
268          </div>
269        </div>`;
270  
271          var div = document.createElement("div");
272          div.innerHTML =
273              typeof DOMPurify !== "undefined"
274                  ? DOMPurify.sanitize(mastodonComment.trim())
275                  : mastodonComment.trim();
276          document
277              .getElementById("mastodon-comments-list")
278              .appendChild(div.firstChild);
279  
280          this.render_toots(toots, toot.id, depth + 1);
281      }
282  
283      loadComments() {
284          if (this.commentsLoaded) return;
285  
286          document.getElementById("mastodon-comments-list").innerHTML =
287              "Loading comments from the Fediverse...";
288  
289          let _this = this;
290  
291          /*
292          fetch("https://" + this.host + "/api/v1/statuses/" + this.tootId)
293              .then((response) => response.json())
294              .then((toot) => {
295                  document.getElementById("mastodon-stats").innerHTML =
296                      this.toot_stats(toot);
297              });
298          */
299          fetch(
300              "https://" + this.host + "/api/v1/statuses/" + this.tootId + "/context",
301          )
302              .then((response) => response.json())
303              .then((data) => {
304                  if (
305                      data["descendants"] &&
306                      Array.isArray(data["descendants"]) &&
307                      data["descendants"].length > 0
308                  ) {
309                      document.getElementById("mastodon-comments-list").innerHTML = "";
310                      _this.render_toots(data["descendants"], _this.tootId, 0);
311                  } else {
312                      document.getElementById("mastodon-comments-list").innerHTML =
313                          "<p>No comments found</p>";
314                  }
315  
316                  _this.commentsLoaded = true;
317              });
318      }
319  
320      respondToVisibility(element, callback) {
321          var options = {
322              root: null,
323          };
324  
325          var observer = new IntersectionObserver((entries, observer) => {
326              entries.forEach((entry) => {
327                  if (entry.intersectionRatio > 0) {
328                      callback();
329                  }
330              });
331          }, options);
332  
333          observer.observe(element);
334      }
335  }
336  
337  customElements.define("mastodon-comments", MastodonComments);