unoffical wafrn mirror wafrn.net
atproto social-network activitypub
at angular21 1109 lines 36 kB view raw
1import { Injectable, signal, inject } from "@angular/core"; 2import { ProcessedPost } from "../interfaces/processed-post"; 3import { RawPost } from "../interfaces/raw-post"; 4import { MediaService } from "./media.service"; 5import { HttpClient } from "@angular/common/http"; 6import sanitizeHtml from "sanitize-html"; 7import { BehaviorSubject, firstValueFrom, lastValueFrom } from "rxjs"; 8import { JwtService } from "./jwt.service"; 9import { 10 basicPost, 11 PostEmojiReaction, 12 unlinkedPosts, 13} from "../interfaces/unlinked-posts"; 14import { SimplifiedUser } from "../interfaces/simplified-user"; 15import { UserOptions } from "../interfaces/userOptions"; 16import { Emoji } from "../interfaces/emoji"; 17import { EmojiCollection } from "../interfaces/emoji-collection"; 18import { MessageService } from "./message.service"; 19import { emojis } from "../lists/emoji-compact"; 20import { EnvironmentService } from "./environment.service"; 21@Injectable({ 22 providedIn: "root", 23}) 24export class PostsService { 25 private mediaService = inject(MediaService); 26 private http = inject(HttpClient); 27 private jwtService = inject(JwtService); 28 private messageService = inject(MessageService); 29 30 processedQuotes: ProcessedPost[] = []; 31 parser = new DOMParser(); 32 wafrnMediaRegex = 33 /\[wafrnmediaid="[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}"\]/gm; 34 youtubeRegex = 35 /((?:https?:\/\/)?(www.|m.)?(youtube(\-nocookie)?\.com|youtu\.be)\/(v\/|watch\?v=|embed\/)?([\S]{11}))([^\S]|\?[\S]*|\&[\S]*|\b)/g; 36 public updateFollowers: BehaviorSubject<boolean> = 37 new BehaviorSubject<boolean>(false); 38 public postLiked: BehaviorSubject<{ id: string; like: boolean }> = 39 new BehaviorSubject<{ id: string; like: boolean }>({ 40 id: "undefined", 41 like: false, 42 }); 43 44 public emojiReacted = new BehaviorSubject<{ 45 postId: string; 46 emoji: Emoji; 47 type: "react" | "undo_react"; 48 }>({ 49 postId: "", 50 emoji: { 51 id: "", 52 url: "", 53 name: "", 54 external: false, 55 uuid: "", 56 }, 57 type: "react", 58 }); 59 60 public rewootedPosts = signal(new Set<string>(), { equal: () => false }); 61 62 keyboardEmojis: Emoji[] = emojis.map((emoji) => { 63 return { 64 id: emoji.char, 65 name: emoji.category + emoji.name, // todo add a display name? 66 url: "", 67 external: false, 68 uuid: emoji.name, 69 }; 70 }); 71 72 public silencedPostsIds: string[] = []; 73 public mutedUsers: string[] = []; 74 public followedUserIds: Array<string> = []; 75 public emojiCollections: EmojiCollection[] = []; 76 public notYetAcceptedFollowedUsersIds: Array<string> = []; 77 public blockedUserIds: Array<string> = []; 78 public followedHashtags: string[] = []; 79 public myFollowers: string[] = []; 80 public enableBluesky: boolean = false; 81 public usersQuotesDisabled: string[] = []; 82 public usersRewootsDisabled: string[] = []; 83 84 async loadFollowers() { 85 if (!this.jwtService.tokenValid()) return; 86 87 const followsAndBlocks = await firstValueFrom( 88 this.http.get<{ 89 followedUsers: string[]; 90 myFollowers: string[]; 91 blockedUsers: string[]; 92 notAcceptedFollows: string[]; 93 options: UserOptions[]; 94 silencedPosts: string[]; 95 emojis: EmojiCollection[]; 96 mutedUsers: string[]; 97 followedHashtags: string[]; 98 mutedRewoots: string[]; 99 mutedQuotes: string[]; 100 enableBluesky: boolean; 101 }>(`${EnvironmentService.environment.baseUrl}/my-ui-options`) 102 ); 103 104 this.followedHashtags = followsAndBlocks.followedHashtags; 105 this.emojiCollections = followsAndBlocks.emojis 106 ? followsAndBlocks.emojis 107 : []; 108 this.emojiCollections = this.emojiCollections.concat({ 109 name: "Keyboard Emojis", 110 comment: "Your phone emojis", 111 emojis: this.keyboardEmojis, 112 }); 113 this.followedUserIds = followsAndBlocks.followedUsers; 114 this.blockedUserIds = followsAndBlocks.blockedUsers; 115 this.notYetAcceptedFollowedUsersIds = followsAndBlocks.notAcceptedFollows; 116 this.mutedUsers = followsAndBlocks.mutedUsers; 117 this.enableBluesky = followsAndBlocks.enableBluesky; 118 this.myFollowers = followsAndBlocks.myFollowers; 119 this.usersQuotesDisabled = followsAndBlocks.mutedQuotes; 120 this.usersRewootsDisabled = followsAndBlocks.mutedRewoots; 121 // Here we check user options 122 if (followsAndBlocks.options?.length > 0) { 123 // frontend options start with wafrn. 124 const options = followsAndBlocks.options.filter((option) => 125 option.optionName.startsWith("wafrn.") 126 ); 127 options.forEach((option) => { 128 localStorage.setItem( 129 option.optionName.split("wafrn.")[1], 130 option.optionValue 131 ); 132 }); 133 } 134 if (followsAndBlocks.silencedPosts) { 135 this.silencedPostsIds = followsAndBlocks.silencedPosts; 136 } else { 137 this.silencedPostsIds = []; 138 } 139 this.updateFollowers.next(true); 140 } 141 142 async followUser(id: string): Promise<boolean> { 143 let res = false; 144 const payload = { 145 userId: id, 146 }; 147 try { 148 const response = await firstValueFrom( 149 this.http.post<{ success: boolean }>( 150 `${EnvironmentService.environment.baseUrl}/follow`, 151 payload 152 ) 153 ); 154 await this.loadFollowers(); 155 res = response?.success === true; 156 } catch (exception) { 157 console.error(exception); 158 } 159 160 return res; 161 } 162 163 async unfollowUser(id: string): Promise<boolean> { 164 let res = false; 165 const payload = { 166 userId: id, 167 }; 168 try { 169 const response = await this.http 170 .post<{ success: boolean }>( 171 `${EnvironmentService.environment.baseUrl}/unfollow`, 172 payload 173 ) 174 .toPromise(); 175 await this.loadFollowers(); 176 res = response?.success === true; 177 } catch (exception) { 178 console.error(exception); 179 } 180 181 return res; 182 } 183 184 async likePost(id: string): Promise<boolean> { 185 let res = false; 186 const payload = { 187 postId: id, 188 }; 189 try { 190 const response = await this.http 191 .post<{ success: boolean }>( 192 `${EnvironmentService.environment.baseUrl}/like`, 193 payload 194 ) 195 .toPromise(); 196 await this.loadFollowers(); 197 res = response?.success === true; 198 } catch (exception) { 199 console.error(exception); 200 } 201 this.postLiked.next({ 202 id: id, 203 like: true, 204 }); 205 return res; 206 } 207 208 async unlikePost(id: string): Promise<boolean> { 209 let res = false; 210 const payload = { 211 postId: id, 212 }; 213 try { 214 const response = await this.http 215 .post<{ success: boolean }>( 216 `${EnvironmentService.environment.baseUrl}/unlike`, 217 payload 218 ) 219 .toPromise(); 220 await this.loadFollowers(); 221 res = response?.success === true; 222 } catch (exception) { 223 console.error(exception); 224 } 225 this.postLiked.next({ 226 id: id, 227 like: false, 228 }); 229 return res; 230 } 231 232 async bookmarkPost(id: string): Promise<boolean> { 233 let res = false; 234 const payload = { 235 postId: id, 236 }; 237 try { 238 const response = await this.http 239 .post<{ success: boolean }>( 240 `${EnvironmentService.environment.baseUrl}/user/bookmarkPost`, 241 payload 242 ) 243 .toPromise(); 244 await this.loadFollowers(); 245 res = response?.success === true; 246 } catch (exception) { 247 console.error(exception); 248 } 249 return res; 250 } 251 252 async unbookmarkPost(id: string): Promise<boolean> { 253 let res = false; 254 const payload = { 255 postId: id, 256 }; 257 try { 258 const response = await this.http 259 .post<{ success: boolean }>( 260 `${EnvironmentService.environment.baseUrl}/user/unbookmarkPost`, 261 payload 262 ) 263 .toPromise(); 264 await this.loadFollowers(); 265 res = response?.success === true; 266 } catch (exception) { 267 console.error(exception); 268 } 269 return res; 270 } 271 272 async emojiReactPost( 273 postId: string, 274 emojiName: string, 275 undo = false 276 ): Promise<boolean> { 277 let res = false; 278 const payload = { 279 postId: postId, 280 emojiName: emojiName, 281 undo: undo, 282 }; 283 try { 284 const response = await firstValueFrom( 285 this.http.post<{ success: boolean }>( 286 `${EnvironmentService.environment.baseUrl}/emojiReact`, 287 payload 288 ) 289 ); 290 await this.loadFollowers(); 291 res = response?.success === true; 292 } catch (exception) { 293 console.error(exception); 294 } 295 let allEmojis: Emoji[] = []; 296 this.emojiCollections.forEach( 297 (col) => (allEmojis = allEmojis.concat(col.emojis)) 298 ); 299 const emoji = allEmojis.find( 300 (elem) => elem.name === emojiName || elem.id === emojiName 301 ) as Emoji; 302 const emojiIsUnicode = emoji.url.length === 0; 303 this.emojiReacted.next({ 304 type: undo ? "undo_react" : "react", 305 postId: postId, 306 emoji: emojiIsUnicode ? this.convertUnicodeEmoji(emoji) : emoji, 307 }); 308 309 return res; 310 } 311 312 convertUnicodeEmoji(unicodeEmoji: Emoji): Emoji { 313 return { 314 id: "", 315 name: unicodeEmoji.id, 316 url: "", 317 external: unicodeEmoji.external, 318 uuid: unicodeEmoji.id, 319 }; 320 } 321 322 processPostNew(unlinked: unlinkedPosts): ProcessedPost[][] { 323 const fake: ProcessedPost[] = []; 324 this.processedQuotes = unlinked.quotedPosts.map((quote) => 325 this.processSinglePost({ ...unlinked, posts: [quote] }, fake) 326 ); 327 const res = unlinked.posts 328 .filter((post) => !!post) 329 .map((elem) => { 330 const processed: ProcessedPost[] = []; 331 if (elem.ancestors) { 332 // We need to keep the ref to processed alive! 333 elem.ancestors 334 .filter((anc) => !!anc) 335 .map((anc) => 336 this.processSinglePost({ ...unlinked, posts: [anc] }, processed) 337 ) 338 .forEach((e) => { 339 processed.push(e); 340 }); 341 } 342 343 processed.push( 344 this.processSinglePost( 345 { 346 ...unlinked, 347 posts: [elem], 348 }, 349 processed 350 ) 351 ); 352 return processed.sort((a, b) => { 353 return a.createdAt.getTime() - b.createdAt.getTime(); 354 }); 355 }); 356 return res.sort((a, b) => { 357 return ( 358 b[b.length - 1].createdAt.getTime() - 359 a[a.length - 1].createdAt.getTime() 360 ); 361 }); 362 } 363 364 processSinglePost( 365 unlinked: unlinkedPosts, 366 collection: ProcessedPost[] 367 ): ProcessedPost { 368 const superMutedWordsRaw = localStorage.getItem("superMutedWords"); 369 let superMutedWords: string[] = []; 370 try { 371 if (superMutedWordsRaw && superMutedWordsRaw.trim().length > 0) { 372 superMutedWords = JSON.parse(superMutedWordsRaw) 373 .split(",") 374 .map((word: string) => word.trim().toLowerCase()) 375 .filter((word: string) => word.length > 0); 376 } 377 } catch (error) { 378 this.messageService.add({ 379 severity: "error", 380 summary: "Something wrong with your supermuted words!", 381 }); 382 } 383 const mutedWordsRaw = localStorage.getItem("mutedWords"); 384 let mutedWords: string[] = []; 385 try { 386 if (mutedWordsRaw && mutedWordsRaw.trim().length > 0) { 387 mutedWords = JSON.parse(mutedWordsRaw) 388 .split(",") 389 .map((word: string) => word.trim()) 390 .filter((word: string) => word.length > 0); 391 } 392 } catch (error) { 393 this.messageService.add({ 394 severity: "error", 395 summary: "Something wrong with your muted words!", 396 }); 397 } 398 const elem: basicPost | undefined = unlinked.posts[0]; 399 const nonExistentUser = { 400 avatar: "", 401 url: "ERROR", 402 name: "ERROR", 403 nameMarkdown: "ERROR", 404 id: "42", 405 isBot: false, 406 }; 407 unlinked.rewootIds?.forEach((id) => { 408 this.rewootedPosts().add(id); 409 }); 410 const user = elem 411 ? { ...unlinked.users.find((usr) => usr.id === elem.userId) } 412 : nonExistentUser; 413 const userEmojis = elem 414 ? unlinked.emojiRelations.userEmojiRelation.filter( 415 (elem) => elem.userId === user?.id 416 ) 417 : []; 418 const polls = elem 419 ? unlinked.polls.filter((poll) => poll.postId === elem.id) 420 : []; 421 const medias = elem 422 ? unlinked.medias.filter((media) => { 423 return media.postId === elem.id; 424 }) 425 : []; 426 if (user.name) { 427 user.name = user.name.replaceAll("‏", ""); 428 user.nameMarkdown = user.name; 429 } 430 if (userEmojis && userEmojis.length && user && user.name) { 431 userEmojis.forEach((usrEmoji) => { 432 const emoji = unlinked.emojiRelations.emojis.find( 433 (emojis) => emojis.id === usrEmoji.emojiId 434 ); 435 if (emoji && user.name) { 436 user.name = user.name.replaceAll(emoji.name, this.emojiToHtml(emoji)); 437 } 438 }); 439 } 440 const mentionedUsers = elem 441 ? unlinked.mentions 442 .filter((mention) => mention.post === elem.id) 443 .map((mention) => 444 unlinked.users.find((usr) => usr.id === mention.userMentioned) 445 ) 446 .filter((mention) => mention !== undefined) 447 : []; 448 let emojiReactions: PostEmojiReaction[] = elem 449 ? unlinked.emojiRelations.postEmojiReactions.filter( 450 (emoji) => emoji.postId === elem.id 451 ) 452 : []; 453 const likesAsEmojiReactions: PostEmojiReaction[] = elem 454 ? unlinked.likes 455 .filter((like) => like.postId === elem.id) 456 .map((likeUserId) => { 457 return { 458 emojiId: "Like", 459 postId: elem.id, 460 userId: likeUserId.userId, 461 content: "♥️", 462 //emoji?: Emoji; 463 user: unlinked.users.find((usr) => usr.id === likeUserId.userId), 464 }; 465 }) 466 : []; 467 emojiReactions = emojiReactions.map((react) => { 468 return { 469 ...react, 470 emoji: unlinked.emojiRelations.emojis.find( 471 (emj) => emj.id === react.emojiId 472 ), 473 user: unlinked.users.find((usr) => usr.id === react.userId), 474 }; 475 }); 476 emojiReactions = emojiReactions.concat(likesAsEmojiReactions); 477 const content = elem ? elem.content : ""; 478 const parsedAsHTML = this.parser.parseFromString(content, "text/html"); 479 const links = parsedAsHTML.getElementsByTagName("a"); 480 const quotes = elem 481 ? unlinked.quotes 482 .filter((quote) => quote.quoterPostId === elem.id) 483 .map( 484 (quote) => 485 this.processedQuotes.find( 486 (pst) => pst.id === quote.quotedPostId 487 ) as ProcessedPost 488 ) 489 : []; 490 Array.from(links).forEach((link, index) => { 491 const youtubeMatch = Array.from(link.href.matchAll(this.youtubeRegex)); 492 const quoteLinks = quotes 493 .filter((elem) => elem != undefined && elem.remotePostId != undefined) 494 .map((elem) => elem.remotePostId); 495 if ( 496 link.innerText === link.href && 497 youtubeMatch.length == 0 && 498 !quoteLinks.includes(link.href) && 499 !medias.map((elem) => elem.url).includes(link.href) 500 ) { 501 medias.push({ 502 mediaOrder: 9999 + index, 503 id: "", 504 NSFW: false, 505 description: "", 506 url: link.href, 507 external: true, 508 postId: elem ? elem.id : "", 509 mediaType: "text/html", 510 }); 511 } 512 }); 513 let postBookmarks: string[] = []; 514 unlinked.bookmarks.forEach((bookmarker) => { 515 if (bookmarker.postId == elem.id) { 516 postBookmarks.push(bookmarker.userId); 517 } 518 }); 519 const newPost: ProcessedPost = { 520 ...elem, 521 content: content, 522 bookmarkers: postBookmarks, 523 emojiReactions: emojiReactions, 524 user: user ? (user as SimplifiedUser) : nonExistentUser, 525 tags: elem ? unlinked.tags.filter((tag) => tag.postId === elem.id) : [], 526 descendents: [], 527 userLikesPostRelations: elem 528 ? unlinked.likes 529 .filter((like) => like.postId === elem.id) 530 .map((like) => like.userId) 531 : [], 532 emojis: unlinked.emojiRelations.postEmojiRelation.map((elem) => 533 unlinked.emojiRelations.emojis.find((emj) => emj.id === elem.emojiId) 534 ) as Emoji[], 535 createdAt: elem ? new Date(elem.createdAt) : new Date(), 536 updatedAt: elem ? new Date(elem.updatedAt) : new Date(), 537 notes: elem?.notes ? elem.notes : 0, 538 remotePostId: elem?.remotePostId 539 ? elem.remotePostId 540 : `${EnvironmentService.environment.frontUrl}/post/${elem?.id}`, 541 medias: medias.sort((a, b) => a.mediaOrder - b.mediaOrder), 542 questionPoll: 543 polls.length > 0 544 ? { ...polls[0], endDate: new Date(polls[0].endDate) } 545 : undefined, 546 mentionPost: mentionedUsers as SimplifiedUser[], 547 quotes: quotes, 548 parentCollection: collection, 549 }; 550 if (unlinked.asks) { 551 const ask = unlinked.asks.find((ask) => ask.postId === newPost.id); 552 if (ask) { 553 const user = unlinked.users.find((usr) => usr.id === ask.userAsker); 554 ask.user = user; 555 } 556 newPost.ask = ask; 557 } 558 const cwedWords = [ 559 ...new Set( 560 mutedWords 561 .filter( 562 (word) => 563 newPost.content.toLowerCase().includes(word.toLowerCase()) || 564 newPost.medias?.some((media) => 565 media.description?.toLowerCase().includes(word.toLowerCase()) 566 ) || 567 newPost.tags.some((tag) => 568 tag.tagName.toLowerCase().includes(word.toLowerCase()) 569 ) 570 ) 571 .concat( 572 superMutedWords.filter( 573 (word) => 574 newPost.content.toLowerCase().includes(word.toLowerCase()) || 575 newPost.medias?.some((media) => 576 media.description?.toLowerCase().includes(word.toLowerCase()) 577 ) || 578 newPost.tags.some((tag) => 579 tag.tagName.toLowerCase().includes(word.toLowerCase()) 580 ) 581 ) 582 ) 583 ), 584 ]; 585 if (cwedWords.length > 0) { 586 newPost.muted_words_cw = cwedWords.join(", "); 587 } 588 const hideQuotesLevel = localStorage.getItem("hideQuotes") 589 ? parseInt(localStorage.getItem("hideQuotes") as string) 590 : 1; 591 if (newPost.quotes && newPost.quotes.length) { 592 if ( 593 this.usersQuotesDisabled.includes(newPost.userId) || 594 (hideQuotesLevel == 2 && !this.followedUserIds.includes(newPost.userId)) 595 ) { 596 newPost.muted_words_cw = newPost.muted_words_cw 597 ? `${newPost.muted_words_cw}<br> Post includes quote by not allowed user` 598 : `Post includes quote by not allowed user`; 599 } 600 } 601 602 return newPost; 603 } 604 605 getPostHtml( 606 post: ProcessedPost, 607 tags: string[] = [ 608 "b", 609 "i", 610 "u", 611 "a", 612 "s", 613 "del", 614 "span", 615 "br", 616 "p", 617 "h1", 618 "h2", 619 "h3", 620 "h4", 621 "h5", 622 "h6", 623 "pre", 624 "strong", 625 "em", 626 "ul", 627 "li", 628 "marquee", 629 "font", 630 "blockquote", 631 "code", 632 "hr", 633 "ol", 634 "q", 635 "small", 636 "sub", 637 "sup", 638 "table", 639 "tr", 640 "td", 641 "th", 642 "cite", 643 "colgroup", 644 "col", 645 "dl", 646 "dt", 647 "dd", 648 "caption", 649 "details", 650 "summary", 651 "mark", 652 "tbody", 653 "tfoot", 654 "thead", 655 "ruby", 656 "rt", 657 "rp", 658 "img", // I KNOW WHAT IM DOING. We are replacing imgs with remote urls 659 ] 660 ): string { 661 const content = post.content; 662 let sanitized = sanitizeHtml(content, { 663 allowedTags: tags, 664 allowedAttributes: { 665 img: ["src"], 666 a: ["href", "title", "target"], 667 col: ["span", "visibility"], 668 colgroup: ["width", "visibility", "background", "border"], 669 hr: ["style"], 670 span: ["title", "style", "lang"], 671 th: ["colspan", "rowspan"], 672 marquee: [ 673 "behavior", 674 "bgcolor", 675 "direction", 676 "loop", 677 "height", 678 "width", 679 "scrolldelay", 680 ], 681 "*": ["title", "lang", "style"], 682 }, 683 allowedStyles: { 684 "*": { 685 "aspect-ratio": [new RegExp(".*")], 686 background: [new RegExp(".*")], 687 "background-color": [new RegExp(".*")], 688 border: [new RegExp(".*")], 689 "border-bottom": [new RegExp(".*")], 690 "border-bottom-color": [new RegExp(".*")], 691 "border-bottom-left-radius": [new RegExp(".*")], 692 "border-bottom-right-radius": [new RegExp(".*")], 693 "border-bottom-style": [new RegExp(".*")], 694 "border-bottom-width": [new RegExp(".*")], 695 "border-collapse": [new RegExp(".*")], 696 "border-color": [new RegExp(".*")], 697 "border-end-end-radius": [new RegExp(".*")], 698 "border-end-start-radius": [new RegExp(".*")], 699 "border-inline": [new RegExp(".*")], 700 "border-inline-color": [new RegExp(".*")], 701 "border-inline-end": [new RegExp(".*")], 702 "border-inline-end-color": [new RegExp(".*")], 703 "border-inline-end-style": [new RegExp(".*")], 704 "border-inline-end-width": [new RegExp(".*")], 705 "border-inline-start": [new RegExp(".*")], 706 "border-inline-start-color": [new RegExp(".*")], 707 "border-inline-start-style": [new RegExp(".*")], 708 "border-inline-start-width": [new RegExp(".*")], 709 "border-inline-style": [new RegExp(".*")], 710 "border-inline-width": [new RegExp(".*")], 711 "border-left": [new RegExp(".*")], 712 "border-left-color": [new RegExp(".*")], 713 "border-left-style": [new RegExp(".*")], 714 "border-left-width": [new RegExp(".*")], 715 "border-radius": [new RegExp(".*")], 716 "border-right": [new RegExp(".*")], 717 "border-right-color": [new RegExp(".*")], 718 "border-right-style": [new RegExp(".*")], 719 "border-right-width": [new RegExp(".*")], 720 "border-spacing": [new RegExp(".*")], 721 "border-start-end-radius": [new RegExp(".*")], 722 "border-start-start-radius": [new RegExp(".*")], 723 "border-style": [new RegExp(".*")], 724 "border-top": [new RegExp(".*")], 725 "border-top-color": [new RegExp(".*")], 726 "border-top-left-radius": [new RegExp(".*")], 727 "border-top-right-radius": [new RegExp(".*")], 728 "border-top-style": [new RegExp(".*")], 729 "border-top-width": [new RegExp(".*")], 730 "border-width": [new RegExp(".*")], 731 bottom: [new RegExp(".*")], 732 color: [new RegExp(".*")], 733 direction: [new RegExp(".*")], 734 "empty-cells": [new RegExp(".*")], 735 font: [new RegExp(".*")], 736 "font-family": [new RegExp(".*")], 737 "font-size": [new RegExp(".*")], 738 "font-size-adjust": [new RegExp(".*")], 739 "font-style": [new RegExp(".*")], 740 "font-variant": [new RegExp(".*")], 741 "font-variant-caps": [new RegExp(".*")], 742 "font-weight": [new RegExp(".*")], 743 height: [new RegExp(".*")], 744 "initial-letter": [new RegExp(".*")], 745 "inline-size": [new RegExp(".*")], 746 left: [new RegExp(".*")], 747 "left-spacing": [new RegExp(".*")], 748 "list-style": [new RegExp(".*")], 749 "list-style-position": [new RegExp(".*")], 750 "list-style-type": [new RegExp(".*")], 751 margin: [new RegExp(".*")], 752 "margin-bottom": [new RegExp(".*")], 753 "margin-inline": [new RegExp(".*")], 754 "margin-inline-end": [new RegExp(".*")], 755 "margin-inline-start": [new RegExp(".*")], 756 "margin-left": [new RegExp(".*")], 757 "margin-right": [new RegExp(".*")], 758 "margin-top": [new RegExp(".*")], 759 opacity: [new RegExp(".*")], 760 padding: [new RegExp(".*")], 761 "padding-bottom": [new RegExp(".*")], 762 "padding-inline": [new RegExp(".*")], 763 "padding-inline-end": [new RegExp(".*")], 764 "padding-inline-right": [new RegExp(".*")], 765 "padding-left": [new RegExp(".*")], 766 "padding-right": [new RegExp(".*")], 767 "padding-top": [new RegExp(".*")], 768 quotes: [new RegExp(".*")], 769 rotate: [new RegExp(".*")], 770 "tab-size": [new RegExp(".*")], 771 "table-layout": [new RegExp(".*")], 772 "text-align": [new RegExp(".*")], 773 "text-align-last": [new RegExp(".*")], 774 "text-decoration": [new RegExp(".*")], 775 "text-decoration-color": [new RegExp(".*")], 776 "text-decoration-line": [new RegExp(".*")], 777 "text-decoration-style": [new RegExp(".*")], 778 "text-decoration-thickness": [new RegExp(".*")], 779 "text-emphasis": [new RegExp(".*")], 780 "text-emphasis-color": [new RegExp(".*")], 781 "text-emphasis-position": [new RegExp(".*")], 782 "text-emphasis-style": [new RegExp(".*")], 783 "text-indent": [new RegExp(".*")], 784 "text-justify": [new RegExp(".*")], 785 "text-orientation": [new RegExp(".*")], 786 "text-shadow": [new RegExp(".*")], 787 "text-transform": [new RegExp(".*")], 788 "text-underline-offset": [new RegExp(".*")], 789 "text-underline-position": [new RegExp(".*")], 790 top: [new RegExp(".*")], 791 transform: [new RegExp(".*")], 792 visibility: [new RegExp(".*")], 793 width: [new RegExp(".*")], 794 "word-break": [new RegExp(".*")], 795 "word-spacing": [new RegExp(".*")], 796 "word-wrap": [new RegExp(".*")], 797 "writing-mode": [new RegExp(".*")], 798 }, 799 }, 800 }); 801 // we remove stuff like script tags. we only allow certain stuff. 802 const parsedAsHTML = this.parser.parseFromString(sanitized, "text/html"); 803 const links = parsedAsHTML.getElementsByTagName("a"); 804 const mentionedRemoteIds = post.mentionPost 805 ? post.mentionPost?.map((elem) => 806 elem.remoteId 807 ? elem.remoteId 808 : `https://bsky.app/profile/${elem.bskyDid}` 809 ) 810 : []; 811 const mentionRemoteUrls = post.mentionPost 812 ? post.mentionPost?.map((elem) => elem.url) 813 : []; 814 const mentionedHosts = post.mentionPost 815 ? post.mentionPost?.map( 816 (elem) => 817 this.getURL( 818 elem.remoteId 819 ? elem.remoteId 820 : "https://adomainthatdoesnotexist.google.com" 821 ).hostname 822 ) 823 : []; 824 const hostUrl = this.getURL( 825 EnvironmentService.environment.frontUrl 826 ).hostname; 827 // We are gonna allow images in posts now but they have to go through the cacher/proxy 828 const imgs = parsedAsHTML.getElementsByTagName("img"); 829 Array.from(imgs).forEach((img, index) => { 830 img.src = ""; 831 }); 832 Array.from(links).forEach((link) => { 833 const youtubeMatch = link.href.matchAll(this.youtubeRegex); 834 if (link.innerText === link.href && youtubeMatch) { 835 // NOTE: Since this should not be part of the image Viewer, we have to add then no-viewer class to be checked for later 836 Array.from(youtubeMatch).forEach((youtubeString) => { 837 link.innerHTML = `<div class="watermark"><!-- Watermark container --><div class="watermark__inner"><!-- The watermark --><div class="watermark__body"><img alt="youtube logo" class="yt-watermark no-viewer" loading="lazy" src="/assets/img/youtube_logo.png"></div></div><img class="yt-thumbnail" src="${EnvironmentService.environment.externalCacheurl + 838 encodeURIComponent( 839 `https://img.youtube.com/vi/${youtubeString[6]}/hqdefault.jpg` 840 ) 841 }" loading="lazy" alt="Thumbnail for video"></div>`; 842 }); 843 } 844 // replace mentioned users with wafrn version of profile. 845 // TODO not all software links to mentionedProfile 846 if (mentionedRemoteIds.includes(link.href)) { 847 if (post.mentionPost) { 848 const mentionedUser = post.mentionPost.find( 849 (elem) => 850 elem.remoteId === link.href || 851 `https://bsky.app/profile/${elem.bskyDid}` === link.href 852 ); 853 if (mentionedUser) { 854 link.href = `${EnvironmentService.environment.frontUrl}/blog/${mentionedUser.url}`; 855 link.classList.add("mention"); 856 link.classList.add("remote-mention"); 857 } 858 } 859 } 860 const linkAsUrl: URL = this.getURL(link.href); 861 if ( 862 mentionedHosts.includes(linkAsUrl.hostname) || 863 linkAsUrl.hostname === hostUrl 864 ) { 865 const sanitizedContent = sanitizeHtml(link.innerHTML, { 866 allowedTags: [], 867 }); 868 const isUserTag = sanitizedContent.startsWith("@"); 869 const isRemoteUser = mentionRemoteUrls.includes( 870 `${sanitizedContent}@${linkAsUrl.hostname}` 871 ); 872 const isLocalUser = mentionRemoteUrls.includes(`${sanitizedContent}`); 873 const isLocalUserLink = 874 linkAsUrl.hostname === hostUrl && 875 (linkAsUrl.pathname.startsWith("/blog") || 876 linkAsUrl.pathname.startsWith("/fediverse/blog")); 877 if (isUserTag) { 878 link.classList.add("mention"); 879 if (isRemoteUser) { 880 // Remote blog, mirror to local blog 881 link.href = `/blog/${sanitizedContent}@${linkAsUrl.hostname}`; 882 link.classList.add("remote-mention"); 883 } 884 885 if (isLocalUser) { 886 //link.href = `/blog/${sanitizedContent}` 887 link.classList.add("mention"); 888 link.classList.add("local-mention"); 889 } 890 } 891 // Also tag local user links for user styles 892 if (isLocalUserLink) { 893 link.classList.add("local-user-link"); 894 } 895 } 896 link.target = "_blank"; 897 sanitized = parsedAsHTML.documentElement.innerHTML; 898 }); 899 900 sanitized = sanitized.replaceAll(this.wafrnMediaRegex, ""); 901 902 let emojiset = new Set<string>(); 903 post.emojis.forEach((emoji) => { 904 // Post can include the same emoji more than once, causing recursive behaviour with alt/title text 905 if (emojiset.has(emoji.name)) return; 906 emojiset.add(emoji.name); 907 const strToReplace = emoji.name.startsWith(":") 908 ? emoji.name 909 : `:${emoji.name}:`; 910 sanitized = sanitized.replaceAll(strToReplace, this.emojiToHtml(emoji)); 911 }); 912 return sanitized; 913 } 914 915 getPostContentSanitized(content: string): string { 916 return sanitizeHtml(content); 917 } 918 919 async loadRepliesFromFediverse(id: string) { 920 return await this.http 921 .get( 922 `${EnvironmentService.environment.baseUrl}/loadRemoteResponses?id=${id}` 923 ) 924 .toPromise(); 925 } 926 927 getURL(urlString: string): URL { 928 let res = new URL(EnvironmentService.environment.frontUrl); 929 try { 930 res = new URL(urlString); 931 } catch (error) { 932 console.error("Invalid url: " + urlString); 933 } 934 return res; 935 } 936 937 async getDescendents(id: string): Promise<{ descendents: RawPost[] }> { 938 const response = await firstValueFrom( 939 this.http.get<unlinkedPosts>( 940 EnvironmentService.environment.baseUrl + "/v2/descendents/" + id 941 ) 942 ); 943 const res: { descendents: RawPost[] } = { descendents: [] }; 944 if (response) { 945 const emptyUser: SimplifiedUser = { 946 id: "42", 947 url: "ERROR_GETTING_USER", 948 avatar: "", 949 name: "ERROR", 950 }; 951 res.descendents = response.posts 952 .map((elem) => { 953 const user = response.users.find((usr) => usr.id === elem.userId); 954 return { 955 id: elem.id, 956 content: elem.len ? "A" : "", // HACK I know this is ugly but because legacy reasons reblogs are empty posts 957 user: user ? user : emptyUser, 958 content_warning: "", 959 createdAt: new Date(elem.createdAt), 960 updatedAt: new Date(elem.updatedAt), 961 userId: elem.userId, 962 hierarchyLevel: 69, // yeah I know 963 postTags: [], 964 privacy: elem.privacy, 965 notes: 69, 966 userLikesPostRelations: [], 967 emojis: [], 968 }; 969 }) 970 .sort((b, a) => a.createdAt.getTime() - b.createdAt.getTime()); 971 } 972 return res; 973 } 974 975 async unsilencePost(postId: string): Promise<boolean> { 976 const payload = { 977 postId: postId, 978 }; 979 const response = await firstValueFrom( 980 this.http.post<{ success: boolean }>( 981 `${EnvironmentService.environment.baseUrl}/v2/unsilencePost`, 982 payload 983 ) 984 ); 985 await this.loadFollowers(); 986 return response.success; 987 } 988 989 async silencePost(postId: string, superMute = false): Promise<boolean> { 990 const payload = { 991 postId: postId, 992 superMute: superMute.toString().toLowerCase(), 993 }; 994 const response = await firstValueFrom( 995 this.http.post<{ success: boolean }>( 996 `${EnvironmentService.environment.baseUrl}/v2/silencePost`, 997 payload 998 ) 999 ); 1000 await this.loadFollowers(); 1001 return response.success; 1002 } 1003 1004 async voteInPoll(pollId: number, votes: number[]) { 1005 let res = false; 1006 const payload = { 1007 votes: votes, 1008 }; 1009 try { 1010 const response = await firstValueFrom( 1011 this.http.post<{ success: boolean; message?: string }>( 1012 `${EnvironmentService.environment.baseUrl}/v2/pollVote/${pollId}`, 1013 payload 1014 ) 1015 ); 1016 res = response.success; 1017 this.messageService.add({ 1018 severity: res ? "success" : "error", 1019 summary: response.message 1020 ? response.message 1021 : res 1022 ? "You voted succesfuly. It can take some time to display" 1023 : "Something went wrong", 1024 }); 1025 } catch (error) { 1026 console.error(error); 1027 this.messageService.add({ 1028 severity: "error", 1029 summary: "Something went wrong", 1030 }); 1031 } 1032 return res; 1033 } 1034 1035 emojiToHtml(emoji: Emoji): string { 1036 return `<img class="post-emoji" src="${`${EnvironmentService.environment.cacheDomain}/api/v2/cache/emoji/${emoji.uuid}`}" title="${emoji.name 1037 }" alt="${emoji.name}">`; 1038 } 1039 1040 postContainsBlockedOrMuted(post: ProcessedPost[], isDashboard: boolean) { 1041 let res = false; 1042 post.forEach((fragment) => { 1043 if (this.blockedUserIds.includes(fragment.userId)) { 1044 res = true; 1045 } 1046 if (isDashboard && this.mutedUsers.includes(fragment.userId)) { 1047 res = true; 1048 } 1049 }); 1050 return res; 1051 } 1052 1053 async updateDisableRewoots(userId: string) { 1054 const res = await firstValueFrom( 1055 this.http.post(`${EnvironmentService.environment.baseUrl}/muteRewoots`, { 1056 userId: userId, 1057 }) 1058 ); 1059 this.loadFollowers(); 1060 return res; 1061 } 1062 1063 async updateDisableQuotes(userId: string) { 1064 const res = await firstValueFrom( 1065 this.http.post(`${EnvironmentService.environment.baseUrl}/muteRewoots`, { 1066 userId: userId, 1067 muteQuotes: true, 1068 }) 1069 ); 1070 this.loadFollowers(); 1071 return res; 1072 } 1073 1074 async forceRefederate(postId: string) { 1075 const res = await firstValueFrom( 1076 this.http.post( 1077 `${EnvironmentService.environment.baseUrl}/refederatePost`, 1078 { 1079 postId: postId, 1080 } 1081 ) 1082 ); 1083 this.loadFollowers(); 1084 return res; 1085 } 1086 1087 async bitePost(id: string): Promise<boolean> { 1088 let res = false; 1089 const payload = { 1090 postId: id, 1091 }; 1092 1093 try { 1094 const response = await lastValueFrom( 1095 this.http.post<{ success: boolean }>( 1096 `${EnvironmentService.environment.baseUrl}/bitePost`, 1097 payload 1098 ) 1099 ); 1100 1101 await this.loadFollowers(); 1102 res = response?.success === true; 1103 } catch (exception) { 1104 console.error(exception); 1105 } 1106 1107 return res; 1108 } 1109}