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