]> Untitled Git - lemmy-ui.git/blob - src/shared/utils.ts
8b01e5b48da194448a5e4cb8d784a9d24d1a1ed8
[lemmy-ui.git] / src / shared / utils.ts
1 import { isBrowser } from "@utils/browser";
2 import { debounce, groupBy } from "@utils/helpers";
3 import { Picker } from "emoji-mart";
4 import emojiShortName from "emoji-short-name";
5 import {
6   BlockCommunityResponse,
7   BlockPersonResponse,
8   CommentAggregates,
9   Comment as CommentI,
10   CommentReplyView,
11   CommentReportView,
12   CommentSortType,
13   CommentView,
14   CommunityView,
15   CustomEmojiView,
16   GetSiteMetadata,
17   GetSiteResponse,
18   Language,
19   LemmyHttp,
20   MyUserInfo,
21   PersonMentionView,
22   PersonView,
23   PostReportView,
24   PostView,
25   PrivateMessageReportView,
26   PrivateMessageView,
27   RegistrationApplicationView,
28   Search,
29   SearchType,
30   SortType,
31 } from "lemmy-js-client";
32 import { default as MarkdownIt } from "markdown-it";
33 import markdown_it_container from "markdown-it-container";
34 import markdown_it_emoji from "markdown-it-emoji/bare";
35 import markdown_it_footnote from "markdown-it-footnote";
36 import markdown_it_html5_embed from "markdown-it-html5-embed";
37 import markdown_it_sub from "markdown-it-sub";
38 import markdown_it_sup from "markdown-it-sup";
39 import Renderer from "markdown-it/lib/renderer";
40 import Token from "markdown-it/lib/token";
41 import moment from "moment";
42 import tippy from "tippy.js";
43 import Toastify from "toastify-js";
44 import { getHttpBase } from "./env";
45 import { i18n } from "./i18next";
46 import {
47   CommentNodeI,
48   DataType,
49   IsoData,
50   RouteData,
51   VoteType,
52 } from "./interfaces";
53 import { HttpService, UserService } from "./services";
54 import { RequestState } from "./services/HttpService";
55
56 let Tribute: any;
57 if (isBrowser()) {
58   Tribute = require("tributejs");
59 }
60
61 export const favIconUrl = "/static/assets/icons/favicon.svg";
62 export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
63 // TODO
64 // export const defaultFavIcon = `${window.location.protocol}//${window.location.host}${favIconPngUrl}`;
65 export const repoUrl = "https://github.com/LemmyNet";
66 export const joinLemmyUrl = "https://join-lemmy.org";
67 export const donateLemmyUrl = `${joinLemmyUrl}/donate`;
68 export const docsUrl = `${joinLemmyUrl}/docs/en/index.html`;
69 export const helpGuideUrl = `${joinLemmyUrl}/docs/en/users/01-getting-started.html`; // TODO find a way to redirect to the non-en folder
70 export const markdownHelpUrl = `${joinLemmyUrl}/docs/en/users/02-media.html`;
71 export const sortingHelpUrl = `${joinLemmyUrl}/docs/en/users/03-votes-and-ranking.html`;
72 export const archiveTodayUrl = "https://archive.today";
73 export const ghostArchiveUrl = "https://ghostarchive.org";
74 export const webArchiveUrl = "https://web.archive.org";
75 export const elementUrl = "https://element.io";
76
77 export const postRefetchSeconds: number = 60 * 1000;
78 export const fetchLimit = 40;
79 export const trendingFetchLimit = 6;
80 export const mentionDropdownFetchLimit = 10;
81 export const commentTreeMaxDepth = 8;
82 export const markdownFieldCharacterLimit = 50000;
83 export const maxUploadImages = 20;
84 export const concurrentImageUpload = 4;
85 export const updateUnreadCountsInterval = 30000;
86
87 export const relTags = "noopener nofollow";
88
89 export const emDash = "\u2014";
90
91 export type ThemeColor =
92   | "primary"
93   | "secondary"
94   | "light"
95   | "dark"
96   | "success"
97   | "danger"
98   | "warning"
99   | "info"
100   | "blue"
101   | "indigo"
102   | "purple"
103   | "pink"
104   | "red"
105   | "orange"
106   | "yellow"
107   | "green"
108   | "teal"
109   | "cyan"
110   | "white"
111   | "gray"
112   | "gray-dark";
113
114 export interface ErrorPageData {
115   error?: string;
116   adminMatrixIds?: string[];
117 }
118
119 const customEmojis: EmojiMartCategory[] = [];
120 export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
121   string,
122   CustomEmojiView
123 >();
124
125 const DEFAULT_ALPHABET =
126   "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
127
128 function getRandomCharFromAlphabet(alphabet: string): string {
129   return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
130 }
131
132 export function getIdFromString(id?: string): number | undefined {
133   return id && id !== "0" && !Number.isNaN(Number(id)) ? Number(id) : undefined;
134 }
135
136 export function getPageFromString(page?: string): number {
137   return page && !Number.isNaN(Number(page)) ? Number(page) : 1;
138 }
139
140 export function randomStr(
141   idDesiredLength = 20,
142   alphabet = DEFAULT_ALPHABET
143 ): string {
144   /**
145    * Create n-long array and map it to random chars from given alphabet.
146    * Then join individual chars as string
147    */
148   return Array.from({ length: idDesiredLength })
149     .map(() => {
150       return getRandomCharFromAlphabet(alphabet);
151     })
152     .join("");
153 }
154
155 const html5EmbedConfig = {
156   html5embed: {
157     useImageSyntax: true, // Enables video/audio embed with ![]() syntax (default)
158     attributes: {
159       audio: 'controls preload="metadata"',
160       video: 'width="100%" max-height="100%" controls loop preload="metadata"',
161     },
162   },
163 };
164
165 const spoilerConfig = {
166   validate: (params: string) => {
167     return params.trim().match(/^spoiler\s+(.*)$/);
168   },
169
170   render: (tokens: any, idx: any) => {
171     var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
172
173     if (tokens[idx].nesting === 1) {
174       // opening tag
175       return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
176     } else {
177       // closing tag
178       return "</details>\n";
179     }
180   },
181 };
182
183 export let md: MarkdownIt = new MarkdownIt();
184
185 export let mdNoImages: MarkdownIt = new MarkdownIt();
186
187 export function hotRankComment(comment_view: CommentView): number {
188   return hotRank(comment_view.counts.score, comment_view.comment.published);
189 }
190
191 export function hotRankActivePost(post_view: PostView): number {
192   return hotRank(post_view.counts.score, post_view.counts.newest_comment_time);
193 }
194
195 export function hotRankPost(post_view: PostView): number {
196   return hotRank(post_view.counts.score, post_view.post.published);
197 }
198
199 export function hotRank(score: number, timeStr: string): number {
200   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
201   const date: Date = new Date(timeStr + "Z"); // Add Z to convert from UTC date
202   const now: Date = new Date();
203   const hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
204
205   const rank =
206     (10000 * Math.log10(Math.max(1, 3 + Number(score)))) /
207     Math.pow(hoursElapsed + 2, 1.8);
208
209   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
210
211   return rank;
212 }
213
214 export function mdToHtml(text: string) {
215   return { __html: md.render(text) };
216 }
217
218 export function mdToHtmlNoImages(text: string) {
219   return { __html: mdNoImages.render(text) };
220 }
221
222 export function mdToHtmlInline(text: string) {
223   return { __html: md.renderInline(text) };
224 }
225
226 export function getUnixTime(text?: string): number | undefined {
227   return text ? new Date(text).getTime() / 1000 : undefined;
228 }
229
230 export function futureDaysToUnixTime(days?: number): number | undefined {
231   return days
232     ? Math.trunc(
233         new Date(Date.now() + 1000 * 60 * 60 * 24 * days).getTime() / 1000
234       )
235     : undefined;
236 }
237
238 const imageRegex = /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/;
239 const videoRegex = /(http)?s?:?(\/\/[^"']*\.(?:mp4|webm))/;
240 const tldRegex = /([a-z0-9]+\.)*[a-z0-9]+\.[a-z]+/;
241
242 export function isImage(url: string) {
243   return imageRegex.test(url);
244 }
245
246 export function isVideo(url: string) {
247   return videoRegex.test(url);
248 }
249
250 export function validURL(str: string) {
251   try {
252     new URL(str);
253     return true;
254   } catch {
255     return false;
256   }
257 }
258
259 export function validInstanceTLD(str: string) {
260   return tldRegex.test(str);
261 }
262
263 export function communityRSSUrl(actorId: string, sort: string): string {
264   const url = new URL(actorId);
265   return `${url.origin}/feeds${url.pathname}.xml?sort=${sort}`;
266 }
267
268 export function validEmail(email: string) {
269   const re =
270     /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
271   return re.test(String(email).toLowerCase());
272 }
273
274 export function capitalizeFirstLetter(str: string): string {
275   return str.charAt(0).toUpperCase() + str.slice(1);
276 }
277
278 export async function getSiteMetadata(url: string) {
279   const form: GetSiteMetadata = { url };
280   const client = new LemmyHttp(getHttpBase());
281   return client.getSiteMetadata(form);
282 }
283
284 export function getDataTypeString(dt: DataType) {
285   return dt === DataType.Post ? "Post" : "Comment";
286 }
287
288 export async function fetchThemeList(): Promise<string[]> {
289   return fetch("/css/themelist").then(res => res.json());
290 }
291
292 export async function setTheme(theme: string, forceReload = false) {
293   if (!isBrowser()) {
294     return;
295   }
296   if (theme === "browser" && !forceReload) {
297     return;
298   }
299   // This is only run on a force reload
300   if (theme == "browser") {
301     theme = "darkly";
302   }
303
304   const themeList = await fetchThemeList();
305
306   // Unload all the other themes
307   for (var i = 0; i < themeList.length; i++) {
308     const styleSheet = document.getElementById(themeList[i]);
309     if (styleSheet) {
310       styleSheet.setAttribute("disabled", "disabled");
311     }
312   }
313
314   document
315     .getElementById("default-light")
316     ?.setAttribute("disabled", "disabled");
317   document.getElementById("default-dark")?.setAttribute("disabled", "disabled");
318
319   // Load the theme dynamically
320   const cssLoc = `/css/themes/${theme}.css`;
321
322   loadCss(theme, cssLoc);
323   document.getElementById(theme)?.removeAttribute("disabled");
324 }
325
326 export function loadCss(id: string, loc: string) {
327   if (!document.getElementById(id)) {
328     var head = document.getElementsByTagName("head")[0];
329     var link = document.createElement("link");
330     link.id = id;
331     link.rel = "stylesheet";
332     link.type = "text/css";
333     link.href = loc;
334     link.media = "all";
335     head.appendChild(link);
336   }
337 }
338
339 export function objectFlip(obj: any) {
340   const ret = {};
341   Object.keys(obj).forEach(key => {
342     ret[obj[key]] = key;
343   });
344   return ret;
345 }
346
347 export function showAvatars(
348   myUserInfo = UserService.Instance.myUserInfo
349 ): boolean {
350   return myUserInfo?.local_user_view.local_user.show_avatars ?? true;
351 }
352
353 export function showScores(
354   myUserInfo = UserService.Instance.myUserInfo
355 ): boolean {
356   return myUserInfo?.local_user_view.local_user.show_scores ?? true;
357 }
358
359 export function isCakeDay(published: string): boolean {
360   // moment(undefined) or moment.utc(undefined) returns the current date/time
361   // moment(null) or moment.utc(null) returns null
362   const createDate = moment.utc(published).local();
363   const currentDate = moment(new Date());
364
365   return (
366     createDate.date() === currentDate.date() &&
367     createDate.month() === currentDate.month() &&
368     createDate.year() !== currentDate.year()
369   );
370 }
371
372 export function toast(text: string, background: ThemeColor = "success") {
373   if (isBrowser()) {
374     const backgroundColor = `var(--bs-${background})`;
375     Toastify({
376       text: text,
377       backgroundColor: backgroundColor,
378       gravity: "bottom",
379       position: "left",
380       duration: 5000,
381     }).showToast();
382   }
383 }
384
385 export function pictrsDeleteToast(filename: string, deleteUrl: string) {
386   if (isBrowser()) {
387     const clickToDeleteText = i18n.t("click_to_delete_picture", { filename });
388     const deletePictureText = i18n.t("picture_deleted", {
389       filename,
390     });
391     const failedDeletePictureText = i18n.t("failed_to_delete_picture", {
392       filename,
393     });
394
395     const backgroundColor = `var(--bs-light)`;
396
397     const toast = Toastify({
398       text: clickToDeleteText,
399       backgroundColor: backgroundColor,
400       gravity: "top",
401       position: "right",
402       duration: 10000,
403       onClick: () => {
404         if (toast) {
405           fetch(deleteUrl).then(res => {
406             toast.hideToast();
407             if (res.ok === true) {
408               alert(deletePictureText);
409             } else {
410               alert(failedDeletePictureText);
411             }
412           });
413         }
414       },
415       close: true,
416     });
417
418     toast.showToast();
419   }
420 }
421
422 export function setupTribute() {
423   return new Tribute({
424     noMatchTemplate: function () {
425       return "";
426     },
427     collection: [
428       // Emojis
429       {
430         trigger: ":",
431         menuItemTemplate: (item: any) => {
432           const shortName = `:${item.original.key}:`;
433           return `${item.original.val} ${shortName}`;
434         },
435         selectTemplate: (item: any) => {
436           const customEmoji = customEmojisLookup.get(
437             item.original.key
438           )?.custom_emoji;
439           if (customEmoji == undefined) return `${item.original.val}`;
440           else
441             return `![${customEmoji.alt_text}](${customEmoji.image_url} "${customEmoji.shortcode}")`;
442         },
443         values: Object.entries(emojiShortName)
444           .map(e => {
445             return { key: e[1], val: e[0] };
446           })
447           .concat(
448             Array.from(customEmojisLookup.entries()).map(k => ({
449               key: k[0],
450               val: `<img class="icon icon-emoji" src="${k[1].custom_emoji.image_url}" title="${k[1].custom_emoji.shortcode}" alt="${k[1].custom_emoji.alt_text}" />`,
451             }))
452           ),
453         allowSpaces: false,
454         autocompleteMode: true,
455         // TODO
456         // menuItemLimit: mentionDropdownFetchLimit,
457         menuShowMinLength: 2,
458       },
459       // Persons
460       {
461         trigger: "@",
462         selectTemplate: (item: any) => {
463           const it: PersonTribute = item.original;
464           return `[${it.key}](${it.view.person.actor_id})`;
465         },
466         values: debounce(async (text: string, cb: any) => {
467           cb(await personSearch(text));
468         }),
469         allowSpaces: false,
470         autocompleteMode: true,
471         // TODO
472         // menuItemLimit: mentionDropdownFetchLimit,
473         menuShowMinLength: 2,
474       },
475
476       // Communities
477       {
478         trigger: "!",
479         selectTemplate: (item: any) => {
480           const it: CommunityTribute = item.original;
481           return `[${it.key}](${it.view.community.actor_id})`;
482         },
483         values: debounce(async (text: string, cb: any) => {
484           cb(await communitySearch(text));
485         }),
486         allowSpaces: false,
487         autocompleteMode: true,
488         // TODO
489         // menuItemLimit: mentionDropdownFetchLimit,
490         menuShowMinLength: 2,
491       },
492     ],
493   });
494 }
495
496 function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
497   const groupedEmojis = groupBy(
498     custom_emoji_views,
499     x => x.custom_emoji.category
500   );
501   for (const [category, emojis] of Object.entries(groupedEmojis)) {
502     customEmojis.push({
503       id: category,
504       name: category,
505       emojis: emojis.map(emoji => ({
506         id: emoji.custom_emoji.shortcode,
507         name: emoji.custom_emoji.shortcode,
508         keywords: emoji.keywords.map(x => x.keyword),
509         skins: [{ src: emoji.custom_emoji.image_url }],
510       })),
511     });
512   }
513   customEmojisLookup = new Map(
514     custom_emoji_views.map(view => [view.custom_emoji.shortcode, view])
515   );
516 }
517
518 export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
519   const emoji: EmojiMartCustomEmoji = {
520     id: custom_emoji_view.custom_emoji.shortcode,
521     name: custom_emoji_view.custom_emoji.shortcode,
522     keywords: custom_emoji_view.keywords.map(x => x.keyword),
523     skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
524   };
525   const categoryIndex = customEmojis.findIndex(
526     x => x.id == custom_emoji_view.custom_emoji.category
527   );
528   if (categoryIndex == -1) {
529     customEmojis.push({
530       id: custom_emoji_view.custom_emoji.category,
531       name: custom_emoji_view.custom_emoji.category,
532       emojis: [emoji],
533     });
534   } else {
535     const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
536       x => x.id == custom_emoji_view.custom_emoji.shortcode
537     );
538     if (emojiIndex == -1) {
539       customEmojis[categoryIndex].emojis.push(emoji);
540     } else {
541       customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
542     }
543   }
544   customEmojisLookup.set(
545     custom_emoji_view.custom_emoji.shortcode,
546     custom_emoji_view
547   );
548 }
549
550 export function removeFromEmojiDataModel(id: number) {
551   let view: CustomEmojiView | undefined;
552   for (const item of customEmojisLookup.values()) {
553     if (item.custom_emoji.id === id) {
554       view = item;
555       break;
556     }
557   }
558   if (!view) return;
559   const categoryIndex = customEmojis.findIndex(
560     x => x.id == view?.custom_emoji.category
561   );
562   const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
563     x => x.id == view?.custom_emoji.shortcode
564   );
565   customEmojis[categoryIndex].emojis = customEmojis[
566     categoryIndex
567   ].emojis.splice(emojiIndex, 1);
568
569   customEmojisLookup.delete(view?.custom_emoji.shortcode);
570 }
571
572 function setupMarkdown() {
573   const markdownItConfig: MarkdownIt.Options = {
574     html: false,
575     linkify: true,
576     typographer: true,
577   };
578
579   const emojiDefs = Array.from(customEmojisLookup.entries()).reduce(
580     (main, [key, value]) => ({ ...main, [key]: value }),
581     {}
582   );
583   md = new MarkdownIt(markdownItConfig)
584     .use(markdown_it_sub)
585     .use(markdown_it_sup)
586     .use(markdown_it_footnote)
587     .use(markdown_it_html5_embed, html5EmbedConfig)
588     .use(markdown_it_container, "spoiler", spoilerConfig)
589     .use(markdown_it_emoji, {
590       defs: emojiDefs,
591     });
592
593   mdNoImages = new MarkdownIt(markdownItConfig)
594     .use(markdown_it_sub)
595     .use(markdown_it_sup)
596     .use(markdown_it_footnote)
597     .use(markdown_it_html5_embed, html5EmbedConfig)
598     .use(markdown_it_container, "spoiler", spoilerConfig)
599     .use(markdown_it_emoji, {
600       defs: emojiDefs,
601     })
602     .disable("image");
603   const defaultRenderer = md.renderer.rules.image;
604   md.renderer.rules.image = function (
605     tokens: Token[],
606     idx: number,
607     options: MarkdownIt.Options,
608     env: any,
609     self: Renderer
610   ) {
611     //Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
612     const item = tokens[idx] as any;
613     const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
614     const src: string = item.attrs[0][1];
615     const isCustomEmoji = customEmojisLookup.get(title) != undefined;
616     if (!isCustomEmoji) {
617       return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
618     }
619     const alt_text = item.content;
620     return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`;
621   };
622   md.renderer.rules.table_open = function () {
623     return '<table class="table">';
624   };
625 }
626
627 export function getEmojiMart(
628   onEmojiSelect: (e: any) => void,
629   customPickerOptions: any = {}
630 ) {
631   const pickerOptions = {
632     ...customPickerOptions,
633     onEmojiSelect: onEmojiSelect,
634     custom: customEmojis,
635   };
636   return new Picker(pickerOptions);
637 }
638
639 var tippyInstance: any;
640 if (isBrowser()) {
641   tippyInstance = tippy("[data-tippy-content]");
642 }
643
644 export function setupTippy() {
645   if (isBrowser()) {
646     tippyInstance.forEach((e: any) => e.destroy());
647     tippyInstance = tippy("[data-tippy-content]", {
648       delay: [500, 0],
649       // Display on "long press"
650       touch: ["hold", 500],
651     });
652   }
653 }
654
655 interface PersonTribute {
656   key: string;
657   view: PersonView;
658 }
659
660 async function personSearch(text: string): Promise<PersonTribute[]> {
661   const usersResponse = await fetchUsers(text);
662
663   return usersResponse.map(pv => ({
664     key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`,
665     view: pv,
666   }));
667 }
668
669 interface CommunityTribute {
670   key: string;
671   view: CommunityView;
672 }
673
674 async function communitySearch(text: string): Promise<CommunityTribute[]> {
675   const communitiesResponse = await fetchCommunities(text);
676
677   return communitiesResponse.map(cv => ({
678     key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`,
679     view: cv,
680   }));
681 }
682
683 export function getRecipientIdFromProps(props: any): number {
684   return props.match.params.recipient_id
685     ? Number(props.match.params.recipient_id)
686     : 1;
687 }
688
689 export function getIdFromProps(props: any): number | undefined {
690   const id = props.match.params.post_id;
691   return id ? Number(id) : undefined;
692 }
693
694 export function getCommentIdFromProps(props: any): number | undefined {
695   const id = props.match.params.comment_id;
696   return id ? Number(id) : undefined;
697 }
698
699 type ImmutableListKey =
700   | "comment"
701   | "comment_reply"
702   | "person_mention"
703   | "community"
704   | "private_message"
705   | "post"
706   | "post_report"
707   | "comment_report"
708   | "private_message_report"
709   | "registration_application";
710
711 function editListImmutable<
712   T extends { [key in F]: { id: number } },
713   F extends ImmutableListKey
714 >(fieldName: F, data: T, list: T[]): T[] {
715   return [
716     ...list.map(c => (c[fieldName].id === data[fieldName].id ? data : c)),
717   ];
718 }
719
720 export function editComment(
721   data: CommentView,
722   comments: CommentView[]
723 ): CommentView[] {
724   return editListImmutable("comment", data, comments);
725 }
726
727 export function editCommentReply(
728   data: CommentReplyView,
729   replies: CommentReplyView[]
730 ): CommentReplyView[] {
731   return editListImmutable("comment_reply", data, replies);
732 }
733
734 interface WithComment {
735   comment: CommentI;
736   counts: CommentAggregates;
737   my_vote?: number;
738   saved: boolean;
739 }
740
741 export function editMention(
742   data: PersonMentionView,
743   comments: PersonMentionView[]
744 ): PersonMentionView[] {
745   return editListImmutable("person_mention", data, comments);
746 }
747
748 export function editCommunity(
749   data: CommunityView,
750   communities: CommunityView[]
751 ): CommunityView[] {
752   return editListImmutable("community", data, communities);
753 }
754
755 export function editPrivateMessage(
756   data: PrivateMessageView,
757   messages: PrivateMessageView[]
758 ): PrivateMessageView[] {
759   return editListImmutable("private_message", data, messages);
760 }
761
762 export function editPost(data: PostView, posts: PostView[]): PostView[] {
763   return editListImmutable("post", data, posts);
764 }
765
766 export function editPostReport(
767   data: PostReportView,
768   reports: PostReportView[]
769 ) {
770   return editListImmutable("post_report", data, reports);
771 }
772
773 export function editCommentReport(
774   data: CommentReportView,
775   reports: CommentReportView[]
776 ): CommentReportView[] {
777   return editListImmutable("comment_report", data, reports);
778 }
779
780 export function editPrivateMessageReport(
781   data: PrivateMessageReportView,
782   reports: PrivateMessageReportView[]
783 ): PrivateMessageReportView[] {
784   return editListImmutable("private_message_report", data, reports);
785 }
786
787 export function editRegistrationApplication(
788   data: RegistrationApplicationView,
789   apps: RegistrationApplicationView[]
790 ): RegistrationApplicationView[] {
791   return editListImmutable("registration_application", data, apps);
792 }
793
794 export function editWith<D extends WithComment, L extends WithComment>(
795   { comment, counts, saved, my_vote }: D,
796   list: L[]
797 ) {
798   return [
799     ...list.map(c =>
800       c.comment.id === comment.id
801         ? { ...c, comment, counts, saved, my_vote }
802         : c
803     ),
804   ];
805 }
806
807 export function updatePersonBlock(
808   data: BlockPersonResponse,
809   myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
810 ) {
811   if (myUserInfo) {
812     if (data.blocked) {
813       myUserInfo.person_blocks.push({
814         person: myUserInfo.local_user_view.person,
815         target: data.person_view.person,
816       });
817       toast(`${i18n.t("blocked")} ${data.person_view.person.name}`);
818     } else {
819       myUserInfo.person_blocks = myUserInfo.person_blocks.filter(
820         i => i.target.id !== data.person_view.person.id
821       );
822       toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`);
823     }
824   }
825 }
826
827 export function updateCommunityBlock(
828   data: BlockCommunityResponse,
829   myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
830 ) {
831   if (myUserInfo) {
832     if (data.blocked) {
833       myUserInfo.community_blocks.push({
834         person: myUserInfo.local_user_view.person,
835         community: data.community_view.community,
836       });
837       toast(`${i18n.t("blocked")} ${data.community_view.community.name}`);
838     } else {
839       myUserInfo.community_blocks = myUserInfo.community_blocks.filter(
840         i => i.community.id !== data.community_view.community.id
841       );
842       toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`);
843     }
844   }
845 }
846
847 export function commentsToFlatNodes(comments: CommentView[]): CommentNodeI[] {
848   const nodes: CommentNodeI[] = [];
849   for (const comment of comments) {
850     nodes.push({ comment_view: comment, children: [], depth: 0 });
851   }
852   return nodes;
853 }
854
855 export function convertCommentSortType(sort: SortType): CommentSortType {
856   if (
857     sort == "TopAll" ||
858     sort == "TopDay" ||
859     sort == "TopWeek" ||
860     sort == "TopMonth" ||
861     sort == "TopYear"
862   ) {
863     return "Top";
864   } else if (sort == "New") {
865     return "New";
866   } else if (sort == "Hot" || sort == "Active") {
867     return "Hot";
868   } else {
869     return "Hot";
870   }
871 }
872
873 export function buildCommentsTree(
874   comments: CommentView[],
875   parentComment: boolean
876 ): CommentNodeI[] {
877   const map = new Map<number, CommentNodeI>();
878   const depthOffset = !parentComment
879     ? 0
880     : getDepthFromComment(comments[0].comment) ?? 0;
881
882   for (const comment_view of comments) {
883     const depthI = getDepthFromComment(comment_view.comment) ?? 0;
884     const depth = depthI ? depthI - depthOffset : 0;
885     const node: CommentNodeI = {
886       comment_view,
887       children: [],
888       depth,
889     };
890     map.set(comment_view.comment.id, { ...node });
891   }
892
893   const tree: CommentNodeI[] = [];
894
895   // if its a parent comment fetch, then push the first comment to the top node.
896   if (parentComment) {
897     const cNode = map.get(comments[0].comment.id);
898     if (cNode) {
899       tree.push(cNode);
900     }
901   }
902
903   for (const comment_view of comments) {
904     const child = map.get(comment_view.comment.id);
905     if (child) {
906       const parent_id = getCommentParentId(comment_view.comment);
907       if (parent_id) {
908         const parent = map.get(parent_id);
909         // Necessary because blocked comment might not exist
910         if (parent) {
911           parent.children.push(child);
912         }
913       } else {
914         if (!parentComment) {
915           tree.push(child);
916         }
917       }
918     }
919   }
920
921   return tree;
922 }
923
924 export function getCommentParentId(comment?: CommentI): number | undefined {
925   const split = comment?.path.split(".");
926   // remove the 0
927   split?.shift();
928
929   return split && split.length > 1
930     ? Number(split.at(split.length - 2))
931     : undefined;
932 }
933
934 export function getDepthFromComment(comment?: CommentI): number | undefined {
935   const len = comment?.path.split(".").length;
936   return len ? len - 2 : undefined;
937 }
938
939 // TODO make immutable
940 export function insertCommentIntoTree(
941   tree: CommentNodeI[],
942   cv: CommentView,
943   parentComment: boolean
944 ) {
945   // Building a fake node to be used for later
946   const node: CommentNodeI = {
947     comment_view: cv,
948     children: [],
949     depth: 0,
950   };
951
952   const parentId = getCommentParentId(cv.comment);
953   if (parentId) {
954     const parent_comment = searchCommentTree(tree, parentId);
955     if (parent_comment) {
956       node.depth = parent_comment.depth + 1;
957       parent_comment.children.unshift(node);
958     }
959   } else if (!parentComment) {
960     tree.unshift(node);
961   }
962 }
963
964 export function searchCommentTree(
965   tree: CommentNodeI[],
966   id: number
967 ): CommentNodeI | undefined {
968   for (const node of tree) {
969     if (node.comment_view.comment.id === id) {
970       return node;
971     }
972
973     for (const child of node.children) {
974       const res = searchCommentTree([child], id);
975
976       if (res) {
977         return res;
978       }
979     }
980   }
981   return undefined;
982 }
983
984 export const colorList: string[] = [
985   hsl(0),
986   hsl(50),
987   hsl(100),
988   hsl(150),
989   hsl(200),
990   hsl(250),
991   hsl(300),
992 ];
993
994 function hsl(num: number) {
995   return `hsla(${num}, 35%, 50%, 0.5)`;
996 }
997
998 export function hostname(url: string): string {
999   const cUrl = new URL(url);
1000   return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`;
1001 }
1002
1003 export function validTitle(title?: string): boolean {
1004   // Initial title is null, minimum length is taken care of by textarea's minLength={3}
1005   if (!title || title.length < 3) return true;
1006
1007   const regex = new RegExp(/.*\S.*/, "g");
1008
1009   return regex.test(title);
1010 }
1011
1012 export function siteBannerCss(banner: string): string {
1013   return ` \
1014     background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
1015     background-attachment: fixed; \
1016     background-position: top; \
1017     background-repeat: no-repeat; \
1018     background-size: 100% cover; \
1019
1020     width: 100%; \
1021     max-height: 100vh; \
1022     `;
1023 }
1024
1025 export function setIsoData<T extends RouteData>(context: any): IsoData<T> {
1026   // If its the browser, you need to deserialize the data from the window
1027   if (isBrowser()) {
1028     return window.isoData;
1029   } else return context.router.staticContext;
1030 }
1031
1032 moment.updateLocale("en", {
1033   relativeTime: {
1034     future: "in %s",
1035     past: "%s ago",
1036     s: "<1m",
1037     ss: "%ds",
1038     m: "1m",
1039     mm: "%dm",
1040     h: "1h",
1041     hh: "%dh",
1042     d: "1d",
1043     dd: "%dd",
1044     w: "1w",
1045     ww: "%dw",
1046     M: "1M",
1047     MM: "%dM",
1048     y: "1Y",
1049     yy: "%dY",
1050   },
1051 });
1052
1053 export function saveScrollPosition(context: any) {
1054   const path: string = context.router.route.location.pathname;
1055   const y = window.scrollY;
1056   sessionStorage.setItem(`scrollPosition_${path}`, y.toString());
1057 }
1058
1059 export function restoreScrollPosition(context: any) {
1060   const path: string = context.router.route.location.pathname;
1061   const y = Number(sessionStorage.getItem(`scrollPosition_${path}`));
1062   window.scrollTo(0, y);
1063 }
1064
1065 export function showLocal(isoData: IsoData): boolean {
1066   return isoData.site_res.site_view.local_site.federation_enabled;
1067 }
1068
1069 export interface Choice {
1070   value: string;
1071   label: string;
1072   disabled?: boolean;
1073 }
1074
1075 export function getUpdatedSearchId(id?: number | null, urlId?: number | null) {
1076   return id === null
1077     ? undefined
1078     : ((id ?? urlId) === 0 ? undefined : id ?? urlId)?.toString();
1079 }
1080
1081 export function communityToChoice(cv: CommunityView): Choice {
1082   return {
1083     value: cv.community.id.toString(),
1084     label: communitySelectName(cv),
1085   };
1086 }
1087
1088 export function personToChoice(pvs: PersonView): Choice {
1089   return {
1090     value: pvs.person.id.toString(),
1091     label: personSelectName(pvs),
1092   };
1093 }
1094
1095 function fetchSearchResults(q: string, type_: SearchType) {
1096   const form: Search = {
1097     q,
1098     type_,
1099     sort: "TopAll",
1100     listing_type: "All",
1101     page: 1,
1102     limit: fetchLimit,
1103     auth: myAuth(),
1104   };
1105
1106   return HttpService.client.search(form);
1107 }
1108
1109 export async function fetchCommunities(q: string) {
1110   const res = await fetchSearchResults(q, "Communities");
1111
1112   return res.state === "success" ? res.data.communities : [];
1113 }
1114
1115 export async function fetchUsers(q: string) {
1116   const res = await fetchSearchResults(q, "Users");
1117
1118   return res.state === "success" ? res.data.users : [];
1119 }
1120
1121 export function communitySelectName(cv: CommunityView): string {
1122   return cv.community.local
1123     ? cv.community.title
1124     : `${hostname(cv.community.actor_id)}/${cv.community.title}`;
1125 }
1126
1127 export function personSelectName({
1128   person: { display_name, name, local, actor_id },
1129 }: PersonView): string {
1130   const pName = display_name ?? name;
1131   return local ? pName : `${hostname(actor_id)}/${pName}`;
1132 }
1133
1134 export function initializeSite(site?: GetSiteResponse) {
1135   UserService.Instance.myUserInfo = site?.my_user;
1136   i18n.changeLanguage();
1137   if (site) {
1138     setupEmojiDataModel(site.custom_emojis ?? []);
1139   }
1140   setupMarkdown();
1141 }
1142
1143 const SHORTNUM_SI_FORMAT = new Intl.NumberFormat("en-US", {
1144   maximumSignificantDigits: 3,
1145   //@ts-ignore
1146   notation: "compact",
1147   compactDisplay: "short",
1148 });
1149
1150 export function numToSI(value: number): string {
1151   return SHORTNUM_SI_FORMAT.format(value);
1152 }
1153
1154 export function myAuth(): string | undefined {
1155   return UserService.Instance.auth();
1156 }
1157
1158 export function myAuthRequired(): string {
1159   return UserService.Instance.auth(true) ?? "";
1160 }
1161
1162 export function enableDownvotes(siteRes: GetSiteResponse): boolean {
1163   return siteRes.site_view.local_site.enable_downvotes;
1164 }
1165
1166 export function enableNsfw(siteRes: GetSiteResponse): boolean {
1167   return siteRes.site_view.local_site.enable_nsfw;
1168 }
1169
1170 export function postToCommentSortType(sort: SortType): CommentSortType {
1171   switch (sort) {
1172     case "Active":
1173     case "Hot":
1174       return "Hot";
1175     case "New":
1176     case "NewComments":
1177       return "New";
1178     case "Old":
1179       return "Old";
1180     default:
1181       return "Top";
1182   }
1183 }
1184
1185 export function isPostBlocked(
1186   pv: PostView,
1187   myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
1188 ): boolean {
1189   return (
1190     (myUserInfo?.community_blocks
1191       .map(c => c.community.id)
1192       .includes(pv.community.id) ||
1193       myUserInfo?.person_blocks
1194         .map(p => p.target.id)
1195         .includes(pv.creator.id)) ??
1196     false
1197   );
1198 }
1199
1200 /// Checks to make sure you can view NSFW posts. Returns true if you can.
1201 export function nsfwCheck(
1202   pv: PostView,
1203   myUserInfo = UserService.Instance.myUserInfo
1204 ): boolean {
1205   const nsfw = pv.post.nsfw || pv.community.nsfw;
1206   const myShowNsfw = myUserInfo?.local_user_view.local_user.show_nsfw ?? false;
1207   return !nsfw || (nsfw && myShowNsfw);
1208 }
1209
1210 export function getRandomFromList<T>(list: T[]): T | undefined {
1211   return list.length == 0
1212     ? undefined
1213     : list.at(Math.floor(Math.random() * list.length));
1214 }
1215
1216 /**
1217  * This shows what language you can select
1218  *
1219  * Use showAll for the site form
1220  * Use showSite for the profile and community forms
1221  * Use false for both those to filter on your profile and site ones
1222  */
1223 export function selectableLanguages(
1224   allLanguages: Language[],
1225   siteLanguages: number[],
1226   showAll?: boolean,
1227   showSite?: boolean,
1228   myUserInfo = UserService.Instance.myUserInfo
1229 ): Language[] {
1230   const allLangIds = allLanguages.map(l => l.id);
1231   let myLangs = myUserInfo?.discussion_languages ?? allLangIds;
1232   myLangs = myLangs.length == 0 ? allLangIds : myLangs;
1233   const siteLangs = siteLanguages.length == 0 ? allLangIds : siteLanguages;
1234
1235   if (showAll) {
1236     return allLanguages;
1237   } else {
1238     if (showSite) {
1239       return allLanguages.filter(x => siteLangs.includes(x.id));
1240     } else {
1241       return allLanguages
1242         .filter(x => siteLangs.includes(x.id))
1243         .filter(x => myLangs.includes(x.id));
1244     }
1245   }
1246 }
1247
1248 interface EmojiMartCategory {
1249   id: string;
1250   name: string;
1251   emojis: EmojiMartCustomEmoji[];
1252 }
1253
1254 interface EmojiMartCustomEmoji {
1255   id: string;
1256   name: string;
1257   keywords: string[];
1258   skins: EmojiMartSkin[];
1259 }
1260
1261 interface EmojiMartSkin {
1262   src: string;
1263 }
1264
1265 export function isAuthPath(pathname: string) {
1266   return /create_.*|inbox|settings|admin|reports|registration_applications/g.test(
1267     pathname
1268   );
1269 }
1270
1271 export function newVote(voteType: VoteType, myVote?: number): number {
1272   if (voteType == VoteType.Upvote) {
1273     return myVote == 1 ? 0 : 1;
1274   } else {
1275     return myVote == -1 ? 0 : -1;
1276   }
1277 }
1278
1279 export type RouteDataResponse<T extends Record<string, any>> = {
1280   [K in keyof T]: RequestState<T[K]>;
1281 };