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