unoffical wafrn mirror
wafrn.net
atproto
social-network
activitypub
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}