]> Untitled Git - lemmy-ui.git/blob - src/shared/utils.ts
d6946ccea75db310b726c79d7b23b30198793ac1
[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     switch(sort) {
857         case "TopAll":
858         case "TopHour":
859         case "TopSixHour":
860         case "TopTwelveHour":
861         case "TopDay":
862         case "TopWeek":
863         case "TopMonth":
864         case "TopYear": {
865             return "Top";
866         }
867         case "New": {
868             return "New";
869         }
870         case "Hot":
871         case "Active": {
872             return "Hot";
873         }
874         default: {
875             return "Hot";
876         }
877     }
878 }
879
880 export function buildCommentsTree(
881   comments: CommentView[],
882   parentComment: boolean
883 ): CommentNodeI[] {
884   const map = new Map<number, CommentNodeI>();
885   const depthOffset = !parentComment
886     ? 0
887     : getDepthFromComment(comments[0].comment) ?? 0;
888
889   for (const comment_view of comments) {
890     const depthI = getDepthFromComment(comment_view.comment) ?? 0;
891     const depth = depthI ? depthI - depthOffset : 0;
892     const node: CommentNodeI = {
893       comment_view,
894       children: [],
895       depth,
896     };
897     map.set(comment_view.comment.id, { ...node });
898   }
899
900   const tree: CommentNodeI[] = [];
901
902   // if its a parent comment fetch, then push the first comment to the top node.
903   if (parentComment) {
904     const cNode = map.get(comments[0].comment.id);
905     if (cNode) {
906       tree.push(cNode);
907     }
908   }
909
910   for (const comment_view of comments) {
911     const child = map.get(comment_view.comment.id);
912     if (child) {
913       const parent_id = getCommentParentId(comment_view.comment);
914       if (parent_id) {
915         const parent = map.get(parent_id);
916         // Necessary because blocked comment might not exist
917         if (parent) {
918           parent.children.push(child);
919         }
920       } else {
921         if (!parentComment) {
922           tree.push(child);
923         }
924       }
925     }
926   }
927
928   return tree;
929 }
930
931 export function getCommentParentId(comment?: CommentI): number | undefined {
932   const split = comment?.path.split(".");
933   // remove the 0
934   split?.shift();
935
936   return split && split.length > 1
937     ? Number(split.at(split.length - 2))
938     : undefined;
939 }
940
941 export function getDepthFromComment(comment?: CommentI): number | undefined {
942   const len = comment?.path.split(".").length;
943   return len ? len - 2 : undefined;
944 }
945
946 // TODO make immutable
947 export function insertCommentIntoTree(
948   tree: CommentNodeI[],
949   cv: CommentView,
950   parentComment: boolean
951 ) {
952   // Building a fake node to be used for later
953   const node: CommentNodeI = {
954     comment_view: cv,
955     children: [],
956     depth: 0,
957   };
958
959   const parentId = getCommentParentId(cv.comment);
960   if (parentId) {
961     const parent_comment = searchCommentTree(tree, parentId);
962     if (parent_comment) {
963       node.depth = parent_comment.depth + 1;
964       parent_comment.children.unshift(node);
965     }
966   } else if (!parentComment) {
967     tree.unshift(node);
968   }
969 }
970
971 export function searchCommentTree(
972   tree: CommentNodeI[],
973   id: number
974 ): CommentNodeI | undefined {
975   for (const node of tree) {
976     if (node.comment_view.comment.id === id) {
977       return node;
978     }
979
980     for (const child of node.children) {
981       const res = searchCommentTree([child], id);
982
983       if (res) {
984         return res;
985       }
986     }
987   }
988   return undefined;
989 }
990
991 export const colorList: string[] = [
992   hsl(0),
993   hsl(50),
994   hsl(100),
995   hsl(150),
996   hsl(200),
997   hsl(250),
998   hsl(300),
999 ];
1000
1001 function hsl(num: number) {
1002   return `hsla(${num}, 35%, 50%, 0.5)`;
1003 }
1004
1005 export function hostname(url: string): string {
1006   const cUrl = new URL(url);
1007   return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`;
1008 }
1009
1010 export function validTitle(title?: string): boolean {
1011   // Initial title is null, minimum length is taken care of by textarea's minLength={3}
1012   if (!title || title.length < 3) return true;
1013
1014   const regex = new RegExp(/.*\S.*/, "g");
1015
1016   return regex.test(title);
1017 }
1018
1019 export function siteBannerCss(banner: string): string {
1020   return ` \
1021     background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
1022     background-attachment: fixed; \
1023     background-position: top; \
1024     background-repeat: no-repeat; \
1025     background-size: 100% cover; \
1026
1027     width: 100%; \
1028     max-height: 100vh; \
1029     `;
1030 }
1031
1032 export function setIsoData<T extends RouteData>(context: any): IsoData<T> {
1033   // If its the browser, you need to deserialize the data from the window
1034   if (isBrowser()) {
1035     return window.isoData;
1036   } else return context.router.staticContext;
1037 }
1038
1039 moment.updateLocale("en", {
1040   relativeTime: {
1041     future: "in %s",
1042     past: "%s ago",
1043     s: "<1m",
1044     ss: "%ds",
1045     m: "1m",
1046     mm: "%dm",
1047     h: "1h",
1048     hh: "%dh",
1049     d: "1d",
1050     dd: "%dd",
1051     w: "1w",
1052     ww: "%dw",
1053     M: "1M",
1054     MM: "%dM",
1055     y: "1Y",
1056     yy: "%dY",
1057   },
1058 });
1059
1060 export function saveScrollPosition(context: any) {
1061   const path: string = context.router.route.location.pathname;
1062   const y = window.scrollY;
1063   sessionStorage.setItem(`scrollPosition_${path}`, y.toString());
1064 }
1065
1066 export function restoreScrollPosition(context: any) {
1067   const path: string = context.router.route.location.pathname;
1068   const y = Number(sessionStorage.getItem(`scrollPosition_${path}`));
1069   window.scrollTo(0, y);
1070 }
1071
1072 export function showLocal(isoData: IsoData): boolean {
1073   return isoData.site_res.site_view.local_site.federation_enabled;
1074 }
1075
1076 export interface Choice {
1077   value: string;
1078   label: string;
1079   disabled?: boolean;
1080 }
1081
1082 export function getUpdatedSearchId(id?: number | null, urlId?: number | null) {
1083   return id === null
1084     ? undefined
1085     : ((id ?? urlId) === 0 ? undefined : id ?? urlId)?.toString();
1086 }
1087
1088 export function communityToChoice(cv: CommunityView): Choice {
1089   return {
1090     value: cv.community.id.toString(),
1091     label: communitySelectName(cv),
1092   };
1093 }
1094
1095 export function personToChoice(pvs: PersonView): Choice {
1096   return {
1097     value: pvs.person.id.toString(),
1098     label: personSelectName(pvs),
1099   };
1100 }
1101
1102 function fetchSearchResults(q: string, type_: SearchType) {
1103   const form: Search = {
1104     q,
1105     type_,
1106     sort: "TopAll",
1107     listing_type: "All",
1108     page: 1,
1109     limit: fetchLimit,
1110     auth: myAuth(),
1111   };
1112
1113   return HttpService.client.search(form);
1114 }
1115
1116 export async function fetchCommunities(q: string) {
1117   const res = await fetchSearchResults(q, "Communities");
1118
1119   return res.state === "success" ? res.data.communities : [];
1120 }
1121
1122 export async function fetchUsers(q: string) {
1123   const res = await fetchSearchResults(q, "Users");
1124
1125   return res.state === "success" ? res.data.users : [];
1126 }
1127
1128 export function communitySelectName(cv: CommunityView): string {
1129   return cv.community.local
1130     ? cv.community.title
1131     : `${hostname(cv.community.actor_id)}/${cv.community.title}`;
1132 }
1133
1134 export function personSelectName({
1135   person: { display_name, name, local, actor_id },
1136 }: PersonView): string {
1137   const pName = display_name ?? name;
1138   return local ? pName : `${hostname(actor_id)}/${pName}`;
1139 }
1140
1141 export function initializeSite(site?: GetSiteResponse) {
1142   UserService.Instance.myUserInfo = site?.my_user;
1143   i18n.changeLanguage();
1144   if (site) {
1145     setupEmojiDataModel(site.custom_emojis ?? []);
1146   }
1147   setupMarkdown();
1148 }
1149
1150 const SHORTNUM_SI_FORMAT = new Intl.NumberFormat("en-US", {
1151   maximumSignificantDigits: 3,
1152   //@ts-ignore
1153   notation: "compact",
1154   compactDisplay: "short",
1155 });
1156
1157 export function numToSI(value: number): string {
1158   return SHORTNUM_SI_FORMAT.format(value);
1159 }
1160
1161 export function myAuth(): string | undefined {
1162   return UserService.Instance.auth();
1163 }
1164
1165 export function myAuthRequired(): string {
1166   return UserService.Instance.auth(true) ?? "";
1167 }
1168
1169 export function enableDownvotes(siteRes: GetSiteResponse): boolean {
1170   return siteRes.site_view.local_site.enable_downvotes;
1171 }
1172
1173 export function enableNsfw(siteRes: GetSiteResponse): boolean {
1174   return siteRes.site_view.local_site.enable_nsfw;
1175 }
1176
1177 export function postToCommentSortType(sort: SortType): CommentSortType {
1178   switch (sort) {
1179     case "Active":
1180     case "Hot":
1181       return "Hot";
1182     case "New":
1183     case "NewComments":
1184       return "New";
1185     case "Old":
1186       return "Old";
1187     default:
1188       return "Top";
1189   }
1190 }
1191
1192 export function isPostBlocked(
1193   pv: PostView,
1194   myUserInfo: MyUserInfo | undefined = UserService.Instance.myUserInfo
1195 ): boolean {
1196   return (
1197     (myUserInfo?.community_blocks
1198       .map(c => c.community.id)
1199       .includes(pv.community.id) ||
1200       myUserInfo?.person_blocks
1201         .map(p => p.target.id)
1202         .includes(pv.creator.id)) ??
1203     false
1204   );
1205 }
1206
1207 /// Checks to make sure you can view NSFW posts. Returns true if you can.
1208 export function nsfwCheck(
1209   pv: PostView,
1210   myUserInfo = UserService.Instance.myUserInfo
1211 ): boolean {
1212   const nsfw = pv.post.nsfw || pv.community.nsfw;
1213   const myShowNsfw = myUserInfo?.local_user_view.local_user.show_nsfw ?? false;
1214   return !nsfw || (nsfw && myShowNsfw);
1215 }
1216
1217 export function getRandomFromList<T>(list: T[]): T | undefined {
1218   return list.length == 0
1219     ? undefined
1220     : list.at(Math.floor(Math.random() * list.length));
1221 }
1222
1223 /**
1224  * This shows what language you can select
1225  *
1226  * Use showAll for the site form
1227  * Use showSite for the profile and community forms
1228  * Use false for both those to filter on your profile and site ones
1229  */
1230 export function selectableLanguages(
1231   allLanguages: Language[],
1232   siteLanguages: number[],
1233   showAll?: boolean,
1234   showSite?: boolean,
1235   myUserInfo = UserService.Instance.myUserInfo
1236 ): Language[] {
1237   const allLangIds = allLanguages.map(l => l.id);
1238   let myLangs = myUserInfo?.discussion_languages ?? allLangIds;
1239   myLangs = myLangs.length == 0 ? allLangIds : myLangs;
1240   const siteLangs = siteLanguages.length == 0 ? allLangIds : siteLanguages;
1241
1242   if (showAll) {
1243     return allLanguages;
1244   } else {
1245     if (showSite) {
1246       return allLanguages.filter(x => siteLangs.includes(x.id));
1247     } else {
1248       return allLanguages
1249         .filter(x => siteLangs.includes(x.id))
1250         .filter(x => myLangs.includes(x.id));
1251     }
1252   }
1253 }
1254
1255 interface EmojiMartCategory {
1256   id: string;
1257   name: string;
1258   emojis: EmojiMartCustomEmoji[];
1259 }
1260
1261 interface EmojiMartCustomEmoji {
1262   id: string;
1263   name: string;
1264   keywords: string[];
1265   skins: EmojiMartSkin[];
1266 }
1267
1268 interface EmojiMartSkin {
1269   src: string;
1270 }
1271
1272 export function isAuthPath(pathname: string) {
1273   return /create_.*|inbox|settings|admin|reports|registration_applications/g.test(
1274     pathname
1275   );
1276 }
1277
1278 export function newVote(voteType: VoteType, myVote?: number): number {
1279   if (voteType == VoteType.Upvote) {
1280     return myVote == 1 ? 0 : 1;
1281   } else {
1282     return myVote == -1 ? 0 : -1;
1283   }
1284 }
1285
1286 export type RouteDataResponse<T extends Record<string, any>> = {
1287   [K in keyof T]: RequestState<T[K]>;
1288 };