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