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, "&") 160 .replace(/</g, "<") 161 .replace(/>/g, ">") 162 .replace(/"/g, """) 163 .replace(/'/g, "'"); 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);