]> Untitled Git - lemmy-ui.git/blob - src/shared/utils.ts
6e3856579e890cdc1a34bd9387d2db10c8d1a59f
[lemmy-ui.git] / src / shared / utils.ts
1 import emojiShortName from "emoji-short-name";
2 import { Picker } from 'emoji-mart'
3 import {
4   BlockCommunityResponse,
5   BlockPersonResponse,
6   Comment as CommentI,
7   CommentNode as CommentNodeI,
8   CommentReportView,
9   CommentSortType,
10   CommentView,
11   CommunityModeratorView,
12   CommunityView,
13   CustomEmojiView,
14   GetSiteMetadata,
15   GetSiteResponse,
16   Language,
17   LemmyHttp,
18   LemmyWebsocket,
19   ListingType,
20   PersonSafe,
21   PersonViewSafe,
22   PostReportView,
23   PostView,
24   PrivateMessageReportView,
25   PrivateMessageView,
26   RegistrationApplicationView,
27   Search,
28   SearchType,
29   SortType,
30 } from "lemmy-js-client";
31 import { default as MarkdownIt } from "markdown-it";
32 import markdown_it_container from "markdown-it-container";
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 markdown_it_emoji from 'markdown-it-emoji/bare';
38 import moment from "moment";
39 import { Subscription } from "rxjs";
40 import { delay, retryWhen, take } from "rxjs/operators";
41 import tippy from "tippy.js";
42 import Toastify from "toastify-js";
43 import { httpBase } from "./env";
44 import { i18n, languages } from "./i18next";
45 import { DataType, IsoData } from "./interfaces";
46 import { UserService, WebSocketService } from "./services";
47 import Renderer from "markdown-it/lib/renderer";
48 import Token from "markdown-it/lib/token";
49
50 var Tribute: any;
51 if (isBrowser()) {
52   Tribute = require("tributejs");
53 }
54
55 export const wsClient = new LemmyWebsocket();
56
57 export const favIconUrl = "/static/assets/icons/favicon.svg";
58 export const favIconPngUrl = "/static/assets/icons/apple-touch-icon.png";
59 // TODO
60 // export const defaultFavIcon = `${window.location.protocol}//${window.location.host}${favIconPngUrl}`;
61 export const repoUrl = "https://github.com/LemmyNet";
62 export const joinLemmyUrl = "https://join-lemmy.org";
63 export const donateLemmyUrl = `${joinLemmyUrl}/donate`;
64 export const docsUrl = `${joinLemmyUrl}/docs/en/index.html`;
65 export const helpGuideUrl = `${joinLemmyUrl}/docs/en/about/guide.html`; // TODO find a way to redirect to the non-en folder
66 export const markdownHelpUrl = `${helpGuideUrl}#using-markdown`;
67 export const sortingHelpUrl = `${helpGuideUrl}#sorting`;
68 export const archiveTodayUrl = "https://archive.today";
69 export const ghostArchiveUrl = "https://ghostarchive.org";
70 export const webArchiveUrl = "https://web.archive.org";
71 export const elementUrl = "https://element.io";
72
73 export const postRefetchSeconds: number = 60 * 1000;
74 export const fetchLimit = 40;
75 export const trendingFetchLimit = 6;
76 export const mentionDropdownFetchLimit = 10;
77 export const commentTreeMaxDepth = 8;
78 export const markdownFieldCharacterLimit = 50000;
79
80 export const relTags = "noopener nofollow";
81
82 let customEmojis: EmojiMartCategory[] = [];
83 export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<string, CustomEmojiView>();
84
85 const DEFAULT_ALPHABET =
86   "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
87
88 function getRandomCharFromAlphabet(alphabet: string): string {
89   return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
90 }
91
92 export function randomStr(
93   idDesiredLength = 20,
94   alphabet = DEFAULT_ALPHABET
95 ): string {
96   /**
97    * Create n-long array and map it to random chars from given alphabet.
98    * Then join individual chars as string
99    */
100   return Array.from({ length: idDesiredLength })
101     .map(() => {
102       return getRandomCharFromAlphabet(alphabet);
103     })
104     .join("");
105 }
106
107 const html5EmbedConfig = {
108   html5embed: {
109     useImageSyntax: true, // Enables video/audio embed with ![]() syntax (default)
110     attributes: {
111       audio: 'controls preload="metadata"',
112       video: 'width="100%" max-height="100%" controls loop preload="metadata"',
113     },
114   },
115 };
116
117 const spoilerConfig = {
118   validate: (params: string) => {
119     return params.trim().match(/^spoiler\s+(.*)$/);
120   },
121
122   render: (tokens: any, idx: any) => {
123     var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
124
125     if (tokens[idx].nesting === 1) {
126       // opening tag
127       return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
128     } else {
129       // closing tag
130       return "</details>\n";
131     }
132   },
133 };
134
135 export let md: MarkdownIt = new MarkdownIt();
136
137 export let mdNoImages: MarkdownIt = new MarkdownIt();
138
139 export function hotRankComment(comment_view: CommentView): number {
140   return hotRank(comment_view.counts.score, comment_view.comment.published);
141 }
142
143 export function hotRankActivePost(post_view: PostView): number {
144   return hotRank(post_view.counts.score, post_view.counts.newest_comment_time);
145 }
146
147 export function hotRankPost(post_view: PostView): number {
148   return hotRank(post_view.counts.score, post_view.post.published);
149 }
150
151 export function hotRank(score: number, timeStr: string): number {
152   // Rank = ScaleFactor * sign(Score) * log(1 + abs(Score)) / (Time + 2)^Gravity
153   let date: Date = new Date(timeStr + "Z"); // Add Z to convert from UTC date
154   let now: Date = new Date();
155   let hoursElapsed: number = (now.getTime() - date.getTime()) / 36e5;
156
157   let rank =
158     (10000 * Math.log10(Math.max(1, 3 + score))) /
159     Math.pow(hoursElapsed + 2, 1.8);
160
161   // console.log(`Comment: ${comment.content}\nRank: ${rank}\nScore: ${comment.score}\nHours: ${hoursElapsed}`);
162
163   return rank;
164 }
165
166 export function mdToHtml(text: string) {
167   return { __html: md.render(text) };
168 }
169
170 export function mdToHtmlNoImages(text: string) {
171   return { __html: mdNoImages.render(text) };
172 }
173
174 export function mdToHtmlInline(text: string) {
175   return { __html: md.renderInline(text) };
176 }
177
178 export function getUnixTime(text?: string): number | undefined {
179   return text ? new Date(text).getTime() / 1000 : undefined;
180 }
181
182 export function futureDaysToUnixTime(days?: number): number | undefined {
183   return days
184     ? Math.trunc(
185       new Date(Date.now() + 1000 * 60 * 60 * 24 * days).getTime() / 1000
186     )
187     : undefined;
188 }
189
190 export function canMod(
191   creator_id: number,
192   mods?: CommunityModeratorView[],
193   admins?: PersonViewSafe[],
194   myUserInfo = UserService.Instance.myUserInfo,
195   onSelf = false
196 ): boolean {
197   // You can do moderator actions only on the mods added after you.
198   let adminsThenMods =
199     admins
200       ?.map(a => a.person.id)
201       .concat(mods?.map(m => m.moderator.id) ?? []) ?? [];
202
203   if (myUserInfo) {
204     let myIndex = adminsThenMods.findIndex(
205       id => id == myUserInfo.local_user_view.person.id
206     );
207     if (myIndex == -1) {
208       return false;
209     } else {
210       // onSelf +1 on mod actions not for yourself, IE ban, remove, etc
211       adminsThenMods = adminsThenMods.slice(0, myIndex + (onSelf ? 0 : 1));
212       return !adminsThenMods.includes(creator_id);
213     }
214   } else {
215     return false;
216   }
217 }
218
219 export function canAdmin(
220   creatorId: number,
221   admins?: PersonViewSafe[],
222   myUserInfo = UserService.Instance.myUserInfo,
223   onSelf = false
224 ): boolean {
225   return canMod(creatorId, undefined, admins, myUserInfo, onSelf);
226 }
227
228 export function isMod(
229   creatorId: number,
230   mods?: CommunityModeratorView[]
231 ): boolean {
232   return mods?.map(m => m.moderator.id).includes(creatorId) ?? false;
233 }
234
235 export function amMod(
236   mods?: CommunityModeratorView[],
237   myUserInfo = UserService.Instance.myUserInfo
238 ): boolean {
239   return myUserInfo ? isMod(myUserInfo.local_user_view.person.id, mods) : false;
240 }
241
242 export function isAdmin(creatorId: number, admins?: PersonViewSafe[]): boolean {
243   return admins?.map(a => a.person.id).includes(creatorId) ?? false;
244 }
245
246 export function amAdmin(myUserInfo = UserService.Instance.myUserInfo): boolean {
247   return myUserInfo?.local_user_view.person.admin ?? false;
248 }
249
250 export function amCommunityCreator(
251   creator_id: number,
252   mods?: CommunityModeratorView[],
253   myUserInfo = UserService.Instance.myUserInfo
254 ): boolean {
255   let myId = myUserInfo?.local_user_view.person.id;
256   // Don't allow mod actions on yourself
257   return myId == mods?.at(0)?.moderator.id && myId != creator_id;
258 }
259
260 export function amSiteCreator(
261   creator_id: number,
262   admins?: PersonViewSafe[],
263   myUserInfo = UserService.Instance.myUserInfo
264 ): boolean {
265   let myId = myUserInfo?.local_user_view.person.id;
266   return myId == admins?.at(0)?.person.id && myId != creator_id;
267 }
268
269 export function amTopMod(
270   mods: CommunityModeratorView[],
271   myUserInfo = UserService.Instance.myUserInfo
272 ): boolean {
273   return mods.at(0)?.moderator.id == myUserInfo?.local_user_view.person.id;
274 }
275
276 const imageRegex = /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/;
277 const videoRegex = /(http)?s?:?(\/\/[^"']*\.(?:mp4|webm))/;
278
279 export function isImage(url: string) {
280   return imageRegex.test(url);
281 }
282
283 export function isVideo(url: string) {
284   return videoRegex.test(url);
285 }
286
287 export function validURL(str: string) {
288   return !!new URL(str);
289 }
290
291 export function communityRSSUrl(actorId: string, sort: string): string {
292   let url = new URL(actorId);
293   return `${url.origin}/feeds${url.pathname}.xml?sort=${sort}`;
294 }
295
296 export function validEmail(email: string) {
297   let re =
298     /^(([^\s"(),.:;<>@[\\\]]+(\.[^\s"(),.:;<>@[\\\]]+)*)|(".+"))@((\[(?:\d{1,3}\.){3}\d{1,3}])|(([\dA-Za-z\-]+\.)+[A-Za-z]{2,}))$/;
299   return re.test(String(email).toLowerCase());
300 }
301
302 export function capitalizeFirstLetter(str: string): string {
303   return str.charAt(0).toUpperCase() + str.slice(1);
304 }
305
306 export function routeSortTypeToEnum(sort: string): SortType {
307   return SortType[sort];
308 }
309
310 export function listingTypeFromNum(type_: number): ListingType {
311   return Object.values(ListingType)[type_];
312 }
313
314 export function sortTypeFromNum(type_: number): SortType {
315   return Object.values(SortType)[type_];
316 }
317
318 export function routeListingTypeToEnum(type: string): ListingType {
319   return ListingType[type];
320 }
321
322 export function routeDataTypeToEnum(type: string): DataType {
323   return DataType[capitalizeFirstLetter(type)];
324 }
325
326 export function routeSearchTypeToEnum(type: string): SearchType {
327   return SearchType[type];
328 }
329
330 export async function getSiteMetadata(url: string) {
331   let form: GetSiteMetadata = { url };
332   let client = new LemmyHttp(httpBase);
333   return client.getSiteMetadata(form);
334 }
335
336 export function debounce(func: any, wait = 1000, immediate = false) {
337   // 'private' variable for instance
338   // The returned function will be able to reference this due to closure.
339   // Each call to the returned function will share this common timer.
340   let timeout: any;
341
342   // Calling debounce returns a new anonymous function
343   return function () {
344     // reference the context and args for the setTimeout function
345     var args = arguments;
346
347     // Should the function be called now? If immediate is true
348     //   and not already in a timeout then the answer is: Yes
349     var callNow = immediate && !timeout;
350
351     // This is the basic debounce behaviour where you can call this
352     //   function several times, but it will only execute once
353     //   [before or after imposing a delay].
354     //   Each time the returned function is called, the timer starts over.
355     clearTimeout(timeout);
356
357     // Set the new timeout
358     timeout = setTimeout(function () {
359       // Inside the timeout function, clear the timeout variable
360       // which will let the next execution run when in 'immediate' mode
361       timeout = null;
362
363       // Check if the function already ran with the immediate flag
364       if (!immediate) {
365         // Call the original function with apply
366         // apply lets you define the 'this' object as well as the arguments
367         //    (both captured before setTimeout)
368         func.apply(this, args);
369       }
370     }, wait);
371
372     // Immediate mode and no wait timer? Execute the function..
373     if (callNow) func.apply(this, args);
374   };
375 }
376
377 export function getLanguages(
378   override?: string,
379   myUserInfo = UserService.Instance.myUserInfo
380 ): string[] {
381   let myLang = myUserInfo?.local_user_view.local_user.interface_language;
382   let lang = override || myLang || "browser";
383
384   if (lang == "browser" && isBrowser()) {
385     return getBrowserLanguages();
386   } else {
387     return [lang];
388   }
389 }
390
391 function getBrowserLanguages(): string[] {
392   // Intersect lemmy's langs, with the browser langs
393   let langs = languages ? languages.map(l => l.code) : ["en"];
394
395   // NOTE, mobile browsers seem to be missing this list, so append en
396   let allowedLangs = navigator.languages
397     .concat("en")
398     .filter(v => langs.includes(v));
399   return allowedLangs;
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   let themeList = await fetchThemeList();
419
420   // Unload all the other themes
421   for (var i = 0; i < themeList.length; i++) {
422     let 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   let 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 = "success") {
487   if (isBrowser()) {
488     let 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(
500   clickToDeleteText: string,
501   deletePictureText: string,
502   failedDeletePictureText: string,
503   deleteUrl: string
504 ) {
505   if (isBrowser()) {
506     let backgroundColor = `var(--light)`;
507     let toast = Toastify({
508       text: clickToDeleteText,
509       backgroundColor: backgroundColor,
510       gravity: "top",
511       position: "right",
512       duration: 10000,
513       onClick: () => {
514         if (toast) {
515           fetch(deleteUrl).then(res => {
516             toast.hideToast();
517             if (res.ok === true) {
518               alert(deletePictureText);
519             } else {
520               alert(failedDeletePictureText);
521             }
522           });
523         }
524       },
525       close: true,
526     });
527     toast.showToast();
528   }
529 }
530
531 interface NotifyInfo {
532   name: string;
533   icon?: string;
534   link: string;
535   body?: string;
536 }
537
538 export function messageToastify(info: NotifyInfo, router: any) {
539   if (isBrowser()) {
540     let htmlBody = info.body ? md.render(info.body) : "";
541     let backgroundColor = `var(--light)`;
542
543     let toast = Toastify({
544       text: `${htmlBody}<br />${info.name}`,
545       avatar: info.icon,
546       backgroundColor: backgroundColor,
547       className: "text-dark",
548       close: true,
549       gravity: "top",
550       position: "right",
551       duration: 5000,
552       escapeMarkup: false,
553       onClick: () => {
554         if (toast) {
555           toast.hideToast();
556           router.history.push(info.link);
557         }
558       },
559     });
560     toast.showToast();
561   }
562 }
563
564 export function notifyPost(post_view: PostView, router: any) {
565   let info: NotifyInfo = {
566     name: post_view.community.name,
567     icon: post_view.community.icon,
568     link: `/post/${post_view.post.id}`,
569     body: post_view.post.name,
570   };
571   notify(info, router);
572 }
573
574 export function notifyComment(comment_view: CommentView, router: any) {
575   let info: NotifyInfo = {
576     name: comment_view.creator.name,
577     icon: comment_view.creator.avatar,
578     link: `/comment/${comment_view.comment.id}`,
579     body: comment_view.comment.content,
580   };
581   notify(info, router);
582 }
583
584 export function notifyPrivateMessage(pmv: PrivateMessageView, router: any) {
585   let info: NotifyInfo = {
586     name: pmv.creator.name,
587     icon: pmv.creator.avatar,
588     link: `/inbox`,
589     body: pmv.private_message.content,
590   };
591   notify(info, router);
592 }
593
594 function notify(info: NotifyInfo, router: any) {
595   messageToastify(info, router);
596
597   if (Notification.permission !== "granted") Notification.requestPermission();
598   else {
599     var notification = new Notification(info.name, {
600       ...{ body: info.body },
601       ...(info.icon && { icon: info.icon }),
602     });
603
604     notification.onclick = (ev: Event): any => {
605       ev.preventDefault();
606       router.history.push(info.link);
607     };
608   }
609 }
610
611 export function setupTribute() {
612   return new Tribute({
613     noMatchTemplate: function () {
614       return "";
615     },
616     collection: [
617       // Emojis
618       {
619         trigger: ":",
620         menuItemTemplate: (item: any) => {
621           let shortName = `:${item.original.key}:`;
622           return `${item.original.val} ${shortName}`;
623         },
624         selectTemplate: (item: any) => {
625           let customEmoji = customEmojisLookup.get(item.original.key)?.custom_emoji;
626           if (customEmoji == undefined)
627             return `${item.original.val}`;
628           else
629             return `![${customEmoji.alt_text}](${customEmoji.image_url} "${customEmoji.shortcode}")`;
630         },
631         values: Object.entries(emojiShortName).map(e => {
632           return { key: e[1], val: e[0] };
633         }).concat(
634           Array.from(customEmojisLookup.entries()).map((k) => ({
635             key: k[0],
636             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}" />`
637           }))
638         ),
639         allowSpaces: false,
640         autocompleteMode: true,
641         // TODO
642         // menuItemLimit: mentionDropdownFetchLimit,
643         menuShowMinLength: 2,
644       },
645       // Persons
646       {
647         trigger: "@",
648         selectTemplate: (item: any) => {
649           let it: PersonTribute = item.original;
650           return `[${it.key}](${it.view.person.actor_id})`;
651         },
652         values: debounce(async (text: string, cb: any) => {
653           cb(await personSearch(text));
654         }),
655         allowSpaces: false,
656         autocompleteMode: true,
657         // TODO
658         // menuItemLimit: mentionDropdownFetchLimit,
659         menuShowMinLength: 2,
660       },
661
662       // Communities
663       {
664         trigger: "!",
665         selectTemplate: (item: any) => {
666           let it: CommunityTribute = item.original;
667           return `[${it.key}](${it.view.community.actor_id})`;
668         },
669         values: debounce(async (text: string, cb: any) => {
670           cb(await communitySearch(text));
671         }),
672         allowSpaces: false,
673         autocompleteMode: true,
674         // TODO
675         // menuItemLimit: mentionDropdownFetchLimit,
676         menuShowMinLength: 2,
677       },
678     ],
679   });
680 }
681
682
683
684 function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
685   let groupedEmojis = groupBy(custom_emoji_views, x => x.custom_emoji.category);
686   for (const [category, emojis] of Object.entries(groupedEmojis)) {
687     customEmojis.push({
688       id: category,
689       name: category,
690       emojis: emojis.map(emoji =>
691       ({
692         id: emoji.custom_emoji.shortcode,
693         name: emoji.custom_emoji.shortcode,
694         keywords: emoji.keywords.map(x => x.keyword),
695         skins: [{ src: emoji.custom_emoji.image_url }]
696       }))
697     })
698   }
699   customEmojisLookup = new Map(custom_emoji_views.map(view => [view.custom_emoji.shortcode, view]));
700 }
701
702 export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
703   const emoji: EmojiMartCustomEmoji = {
704     id: custom_emoji_view.custom_emoji.shortcode,
705     name: custom_emoji_view.custom_emoji.shortcode,
706     keywords: custom_emoji_view.keywords.map(x => x.keyword),
707     skins: [{ src: custom_emoji_view.custom_emoji.image_url }]
708   };
709   let categoryIndex = customEmojis.findIndex(x => x.id == custom_emoji_view.custom_emoji.category);
710   if (categoryIndex == -1) {
711     customEmojis.push({
712       id: custom_emoji_view.custom_emoji.category,
713       name: custom_emoji_view.custom_emoji.category,
714       emojis: [emoji]
715     })
716   }
717   else {
718     let emojiIndex = customEmojis[categoryIndex].emojis.findIndex(x => x.id == custom_emoji_view.custom_emoji.shortcode);
719     if (emojiIndex == -1) {
720       customEmojis[categoryIndex].emojis.push(emoji)
721     }
722     else {
723       customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
724     }
725   }
726   customEmojisLookup.set(custom_emoji_view.custom_emoji.shortcode,custom_emoji_view);
727 }
728
729 export function removeFromEmojiDataModel(id: number) {
730   let view: CustomEmojiView | undefined;
731   for (let item of customEmojisLookup.values()) {
732     if (item.custom_emoji.id === id) {
733       view = item;
734       break;
735     }
736   }
737   if (!view)
738     return;
739   const categoryIndex = customEmojis.findIndex(x => x.id == view?.custom_emoji.category);
740   const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(x => x.id == view?.custom_emoji.shortcode)
741   customEmojis[categoryIndex].emojis = customEmojis[categoryIndex].emojis.splice(emojiIndex, 1);
742
743   customEmojisLookup.delete(view?.custom_emoji.shortcode);
744 }
745
746 function setupMarkdown() {
747   const markdownItConfig: MarkdownIt.Options = {
748     html: false,
749     linkify: true,
750     typographer: true,
751   };
752
753   const emojiDefs = Array.from(customEmojisLookup.entries()).reduce((main, [key, value]) => ({...main, [key]: value}), {})
754   md = new MarkdownIt(markdownItConfig)
755     .use(markdown_it_sub)
756     .use(markdown_it_sup)
757     .use(markdown_it_footnote)
758     .use(markdown_it_html5_embed, html5EmbedConfig)
759     .use(markdown_it_container, "spoiler", spoilerConfig)
760     .use(markdown_it_emoji, {
761       defs: emojiDefs
762     });
763
764   mdNoImages = new MarkdownIt(markdownItConfig)
765     .use(markdown_it_sub)
766     .use(markdown_it_sup)
767     .use(markdown_it_footnote)
768     .use(markdown_it_html5_embed, html5EmbedConfig)
769     .use(markdown_it_container, "spoiler", spoilerConfig)
770     .use(markdown_it_emoji, {
771       defs: emojiDefs
772     })
773     .disable("image");
774   var defaultRenderer = md.renderer.rules.image;
775   md.renderer.rules.image = function (tokens: Token[], idx: number, options: MarkdownIt.Options, env: any, self: Renderer) {
776     //Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
777     const item = tokens[idx] as any;
778     const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
779     const src: string = item.attrs[0][1];
780     const isCustomEmoji = customEmojisLookup.get(title) != undefined;
781     if (!isCustomEmoji) {
782       return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
783     }
784     const alt_text = item.content;
785     return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`;
786   }
787 }
788
789 export function getEmojiMart(onEmojiSelect: (e: any) => void, customPickerOptions: any = {}) {
790   const pickerOptions = { ...customPickerOptions, onEmojiSelect: onEmojiSelect, custom: customEmojis }
791   return new Picker(pickerOptions);
792 }
793
794 var tippyInstance: any;
795 if (isBrowser()) {
796   tippyInstance = tippy("[data-tippy-content]");
797 }
798
799 export function setupTippy() {
800   if (isBrowser()) {
801     tippyInstance.forEach((e: any) => e.destroy());
802     tippyInstance = tippy("[data-tippy-content]", {
803       delay: [500, 0],
804       // Display on "long press"
805       touch: ["hold", 500],
806     });
807   }
808 }
809
810 interface PersonTribute {
811   key: string;
812   view: PersonViewSafe;
813 }
814
815 async function personSearch(text: string): Promise<PersonTribute[]> {
816   let users = (await fetchUsers(text)).users;
817   let persons: PersonTribute[] = users.map(pv => {
818     let tribute: PersonTribute = {
819       key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`,
820       view: pv,
821     };
822     return tribute;
823   });
824   return persons;
825 }
826
827 interface CommunityTribute {
828   key: string;
829   view: CommunityView;
830 }
831
832 async function communitySearch(text: string): Promise<CommunityTribute[]> {
833   let comms = (await fetchCommunities(text)).communities;
834   let communities: CommunityTribute[] = comms.map(cv => {
835     let tribute: CommunityTribute = {
836       key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`,
837       view: cv,
838     };
839     return tribute;
840   });
841   return communities;
842 }
843
844 export function getListingTypeFromProps(
845   props: any,
846   defaultListingType: ListingType,
847   myUserInfo = UserService.Instance.myUserInfo
848 ): ListingType {
849   let myLt = myUserInfo?.local_user_view.local_user.default_listing_type;
850   return props.match.params.listing_type
851     ? routeListingTypeToEnum(props.match.params.listing_type)
852     : myLt
853       ? Object.values(ListingType)[myLt]
854       : defaultListingType;
855 }
856
857 export function getListingTypeFromPropsNoDefault(props: any): ListingType {
858   return props.match.params.listing_type
859     ? routeListingTypeToEnum(props.match.params.listing_type)
860     : ListingType.Local;
861 }
862
863 export function getDataTypeFromProps(props: any): DataType {
864   return props.match.params.data_type
865     ? routeDataTypeToEnum(props.match.params.data_type)
866     : DataType.Post;
867 }
868
869 export function getSortTypeFromProps(
870   props: any,
871   myUserInfo = UserService.Instance.myUserInfo
872 ): SortType {
873   let mySortType = myUserInfo?.local_user_view.local_user.default_sort_type;
874   return props.match.params.sort
875     ? routeSortTypeToEnum(props.match.params.sort)
876     : mySortType
877       ? Object.values(SortType)[mySortType]
878       : SortType.Active;
879 }
880
881 export function getPageFromProps(props: any): number {
882   return props.match.params.page ? Number(props.match.params.page) : 1;
883 }
884
885 export function getRecipientIdFromProps(props: any): number {
886   return props.match.params.recipient_id
887     ? Number(props.match.params.recipient_id)
888     : 1;
889 }
890
891 export function getIdFromProps(props: any): number | undefined {
892   let id = props.match.params.post_id;
893   return id ? Number(id) : undefined;
894 }
895
896 export function getCommentIdFromProps(props: any): number | undefined {
897   let id = props.match.params.comment_id;
898   return id ? Number(id) : undefined;
899 }
900
901 export function getUsernameFromProps(props: any): string {
902   return props.match.params.username;
903 }
904
905 export function editCommentRes(data: CommentView, comments?: CommentView[]) {
906   let found = comments?.find(c => c.comment.id == data.comment.id);
907   if (found) {
908     found.comment.content = data.comment.content;
909     found.comment.distinguished = data.comment.distinguished;
910     found.comment.updated = data.comment.updated;
911     found.comment.removed = data.comment.removed;
912     found.comment.deleted = data.comment.deleted;
913     found.counts.upvotes = data.counts.upvotes;
914     found.counts.downvotes = data.counts.downvotes;
915     found.counts.score = data.counts.score;
916   }
917 }
918
919 export function saveCommentRes(data: CommentView, comments?: CommentView[]) {
920   let found = comments?.find(c => c.comment.id == data.comment.id);
921   if (found) {
922     found.saved = data.saved;
923   }
924 }
925
926 export function updatePersonBlock(
927   data: BlockPersonResponse,
928   myUserInfo = UserService.Instance.myUserInfo
929 ) {
930   let mui = myUserInfo;
931   if (mui) {
932     if (data.blocked) {
933       mui.person_blocks.push({
934         person: mui.local_user_view.person,
935         target: data.person_view.person,
936       });
937       toast(`${i18n.t("blocked")} ${data.person_view.person.name}`);
938     } else {
939       mui.person_blocks = mui.person_blocks.filter(
940         i => i.target.id != data.person_view.person.id
941       );
942       toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`);
943     }
944   }
945 }
946
947 export function updateCommunityBlock(
948   data: BlockCommunityResponse,
949   myUserInfo = UserService.Instance.myUserInfo
950 ) {
951   let mui = myUserInfo;
952   if (mui) {
953     if (data.blocked) {
954       mui.community_blocks.push({
955         person: mui.local_user_view.person,
956         community: data.community_view.community,
957       });
958       toast(`${i18n.t("blocked")} ${data.community_view.community.name}`);
959     } else {
960       mui.community_blocks = mui.community_blocks.filter(
961         i => i.community.id != data.community_view.community.id
962       );
963       toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`);
964     }
965   }
966 }
967
968 export function createCommentLikeRes(
969   data: CommentView,
970   comments?: CommentView[]
971 ) {
972   let found = comments?.find(c => c.comment.id === data.comment.id);
973   if (found) {
974     found.counts.score = data.counts.score;
975     found.counts.upvotes = data.counts.upvotes;
976     found.counts.downvotes = data.counts.downvotes;
977     if (data.my_vote !== null) {
978       found.my_vote = data.my_vote;
979     }
980   }
981 }
982
983 export function createPostLikeFindRes(data: PostView, posts?: PostView[]) {
984   let found = posts?.find(p => p.post.id == data.post.id);
985   if (found) {
986     createPostLikeRes(data, found);
987   }
988 }
989
990 export function createPostLikeRes(data: PostView, post_view?: PostView) {
991   if (post_view) {
992     post_view.counts.score = data.counts.score;
993     post_view.counts.upvotes = data.counts.upvotes;
994     post_view.counts.downvotes = data.counts.downvotes;
995     if (data.my_vote !== null) {
996       post_view.my_vote = data.my_vote;
997     }
998   }
999 }
1000
1001 export function editPostFindRes(data: PostView, posts?: PostView[]) {
1002   let found = posts?.find(p => p.post.id == data.post.id);
1003   if (found) {
1004     editPostRes(data, found);
1005   }
1006 }
1007
1008 export function editPostRes(data: PostView, post: PostView) {
1009   if (post) {
1010     post.post.url = data.post.url;
1011     post.post.name = data.post.name;
1012     post.post.nsfw = data.post.nsfw;
1013     post.post.deleted = data.post.deleted;
1014     post.post.removed = data.post.removed;
1015     post.post.featured_community = data.post.featured_community;
1016     post.post.featured_local = data.post.featured_local;
1017     post.post.body = data.post.body;
1018     post.post.locked = data.post.locked;
1019     post.saved = data.saved;
1020   }
1021 }
1022
1023 // TODO possible to make these generic?
1024 export function updatePostReportRes(
1025   data: PostReportView,
1026   reports?: PostReportView[]
1027 ) {
1028   let found = reports?.find(p => p.post_report.id == data.post_report.id);
1029   if (found) {
1030     found.post_report = data.post_report;
1031   }
1032 }
1033
1034 export function updateCommentReportRes(
1035   data: CommentReportView,
1036   reports?: CommentReportView[]
1037 ) {
1038   let found = reports?.find(c => c.comment_report.id == data.comment_report.id);
1039   if (found) {
1040     found.comment_report = data.comment_report;
1041   }
1042 }
1043
1044 export function updatePrivateMessageReportRes(
1045   data: PrivateMessageReportView,
1046   reports?: PrivateMessageReportView[]
1047 ) {
1048   let found = reports?.find(
1049     c => c.private_message_report.id == data.private_message_report.id
1050   );
1051   if (found) {
1052     found.private_message_report = data.private_message_report;
1053   }
1054 }
1055
1056 export function updateRegistrationApplicationRes(
1057   data: RegistrationApplicationView,
1058   applications?: RegistrationApplicationView[]
1059 ) {
1060   let found = applications?.find(
1061     ra => ra.registration_application.id == data.registration_application.id
1062   );
1063   if (found) {
1064     found.registration_application = data.registration_application;
1065     found.admin = data.admin;
1066     found.creator_local_user = data.creator_local_user;
1067   }
1068 }
1069
1070 export function commentsToFlatNodes(comments: CommentView[]): CommentNodeI[] {
1071   let nodes: CommentNodeI[] = [];
1072   for (let comment of comments) {
1073     nodes.push({ comment_view: comment, children: [], depth: 0 });
1074   }
1075   return nodes;
1076 }
1077
1078 export function convertCommentSortType(sort: SortType): CommentSortType {
1079   if (
1080     sort == SortType.TopAll ||
1081     sort == SortType.TopDay ||
1082     sort == SortType.TopWeek ||
1083     sort == SortType.TopMonth ||
1084     sort == SortType.TopYear
1085   ) {
1086     return CommentSortType.Top;
1087   } else if (sort == SortType.New) {
1088     return CommentSortType.New;
1089   } else if (sort == SortType.Hot || sort == SortType.Active) {
1090     return CommentSortType.Hot;
1091   } else {
1092     return CommentSortType.Hot;
1093   }
1094 }
1095
1096 export function buildCommentsTree(
1097   comments: CommentView[],
1098   parentComment: boolean
1099 ): CommentNodeI[] {
1100   let map = new Map<number, CommentNodeI>();
1101   let depthOffset = !parentComment
1102     ? 0
1103     : getDepthFromComment(comments[0].comment) ?? 0;
1104
1105   for (let comment_view of comments) {
1106     let depthI = getDepthFromComment(comment_view.comment) ?? 0;
1107     let depth = depthI ? depthI - depthOffset : 0;
1108     let node: CommentNodeI = {
1109       comment_view,
1110       children: [],
1111       depth,
1112     };
1113     map.set(comment_view.comment.id, { ...node });
1114   }
1115
1116   let tree: CommentNodeI[] = [];
1117
1118   // if its a parent comment fetch, then push the first comment to the top node.
1119   if (parentComment) {
1120     let cNode = map.get(comments[0].comment.id);
1121     if (cNode) {
1122       tree.push(cNode);
1123     }
1124   }
1125
1126   for (let comment_view of comments) {
1127     let child = map.get(comment_view.comment.id);
1128     if (child) {
1129       let parent_id = getCommentParentId(comment_view.comment);
1130       if (parent_id) {
1131         let parent = map.get(parent_id);
1132         // Necessary because blocked comment might not exist
1133         if (parent) {
1134           parent.children.push(child);
1135         }
1136       } else {
1137         if (!parentComment) {
1138           tree.push(child);
1139         }
1140       }
1141     }
1142   }
1143
1144   return tree;
1145 }
1146
1147 export function getCommentParentId(comment?: CommentI): number | undefined {
1148   let split = comment?.path.split(".");
1149   // remove the 0
1150   split?.shift();
1151
1152   return split && split.length > 1
1153     ? Number(split.at(split.length - 2))
1154     : undefined;
1155 }
1156
1157 export function getDepthFromComment(comment?: CommentI): number | undefined {
1158   let len = comment?.path.split(".").length;
1159   return len ? len - 2 : undefined;
1160 }
1161
1162 export function insertCommentIntoTree(
1163   tree: CommentNodeI[],
1164   cv: CommentView,
1165   parentComment: boolean
1166 ) {
1167   // Building a fake node to be used for later
1168   let node: CommentNodeI = {
1169     comment_view: cv,
1170     children: [],
1171     depth: 0,
1172   };
1173
1174   let parentId = getCommentParentId(cv.comment);
1175   if (parentId) {
1176     let parent_comment = searchCommentTree(tree, parentId);
1177     if (parent_comment) {
1178       node.depth = parent_comment.depth + 1;
1179       parent_comment.children.unshift(node);
1180     }
1181   } else if (!parentComment) {
1182     tree.unshift(node);
1183   }
1184 }
1185
1186 export function searchCommentTree(
1187   tree: CommentNodeI[],
1188   id: number
1189 ): CommentNodeI | undefined {
1190   for (let node of tree) {
1191     if (node.comment_view.comment.id === id) {
1192       return node;
1193     }
1194
1195     for (const child of node.children) {
1196       let res = searchCommentTree([child], id);
1197
1198       if (res) {
1199         return res;
1200       }
1201     }
1202   }
1203   return undefined;
1204 }
1205
1206 export const colorList: string[] = [
1207   hsl(0),
1208   hsl(50),
1209   hsl(100),
1210   hsl(150),
1211   hsl(200),
1212   hsl(250),
1213   hsl(300),
1214 ];
1215
1216 function hsl(num: number) {
1217   return `hsla(${num}, 35%, 50%, 1)`;
1218 }
1219
1220 export function hostname(url: string): string {
1221   let cUrl = new URL(url);
1222   return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`;
1223 }
1224
1225 export function validTitle(title?: string): boolean {
1226   // Initial title is null, minimum length is taken care of by textarea's minLength={3}
1227   if (!title || title.length < 3) return true;
1228
1229   const regex = new RegExp(/.*\S.*/, "g");
1230
1231   return regex.test(title);
1232 }
1233
1234 export function siteBannerCss(banner: string): string {
1235   return ` \
1236     background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
1237     background-attachment: fixed; \
1238     background-position: top; \
1239     background-repeat: no-repeat; \
1240     background-size: 100% cover; \
1241
1242     width: 100%; \
1243     max-height: 100vh; \
1244     `;
1245 }
1246
1247 export function isBrowser() {
1248   return typeof window !== "undefined";
1249 }
1250
1251 export function setIsoData(context: any): IsoData {
1252   // If its the browser, you need to deserialize the data from the window
1253   if (isBrowser()) {
1254     let json = window.isoData;
1255     let routeData = json.routeData;
1256     let site_res = json.site_res;
1257
1258     let isoData: IsoData = {
1259       path: json.path,
1260       site_res,
1261       routeData,
1262     };
1263     return isoData;
1264   } else return context.router.staticContext;
1265 }
1266
1267 export function wsSubscribe(parseMessage: any): Subscription | undefined {
1268   if (isBrowser()) {
1269     return WebSocketService.Instance.subject
1270       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
1271       .subscribe(
1272         msg => parseMessage(msg),
1273         err => console.error(err),
1274         () => console.log("complete")
1275       );
1276   } else {
1277     return undefined;
1278   }
1279 }
1280
1281 moment.updateLocale("en", {
1282   relativeTime: {
1283     future: "in %s",
1284     past: "%s ago",
1285     s: "<1m",
1286     ss: "%ds",
1287     m: "1m",
1288     mm: "%dm",
1289     h: "1h",
1290     hh: "%dh",
1291     d: "1d",
1292     dd: "%dd",
1293     w: "1w",
1294     ww: "%dw",
1295     M: "1M",
1296     MM: "%dM",
1297     y: "1Y",
1298     yy: "%dY",
1299   },
1300 });
1301
1302 export function saveScrollPosition(context: any) {
1303   let path: string = context.router.route.location.pathname;
1304   let y = window.scrollY;
1305   sessionStorage.setItem(`scrollPosition_${path}`, y.toString());
1306 }
1307
1308 export function restoreScrollPosition(context: any) {
1309   let path: string = context.router.route.location.pathname;
1310   let y = Number(sessionStorage.getItem(`scrollPosition_${path}`));
1311   window.scrollTo(0, y);
1312 }
1313
1314 export function showLocal(isoData: IsoData): boolean {
1315   let linked = isoData.site_res.federated_instances?.linked;
1316   return linked ? linked.length > 0 : false;
1317 }
1318
1319 export interface ChoicesValue {
1320   value: string;
1321   label: string;
1322 }
1323
1324 export function communityToChoice(cv: CommunityView): ChoicesValue {
1325   let choice: ChoicesValue = {
1326     value: cv.community.id.toString(),
1327     label: communitySelectName(cv),
1328   };
1329   return choice;
1330 }
1331
1332 export function personToChoice(pvs: PersonViewSafe): ChoicesValue {
1333   let choice: ChoicesValue = {
1334     value: pvs.person.id.toString(),
1335     label: personSelectName(pvs),
1336   };
1337   return choice;
1338 }
1339
1340 export async function fetchCommunities(q: string) {
1341   let form: Search = {
1342     q,
1343     type_: SearchType.Communities,
1344     sort: SortType.TopAll,
1345     listing_type: ListingType.All,
1346     page: 1,
1347     limit: fetchLimit,
1348     auth: myAuth(false),
1349   };
1350   let client = new LemmyHttp(httpBase);
1351   return client.search(form);
1352 }
1353
1354 export async function fetchUsers(q: string) {
1355   let form: Search = {
1356     q,
1357     type_: SearchType.Users,
1358     sort: SortType.TopAll,
1359     listing_type: ListingType.All,
1360     page: 1,
1361     limit: fetchLimit,
1362     auth: myAuth(false),
1363   };
1364   let client = new LemmyHttp(httpBase);
1365   return client.search(form);
1366 }
1367
1368 export const choicesConfig = {
1369   shouldSort: false,
1370   searchResultLimit: fetchLimit,
1371   classNames: {
1372     containerOuter: "choices custom-select px-0",
1373     containerInner:
1374       "choices__inner bg-secondary border-0 py-0 modlog-choices-font-size",
1375     input: "form-control",
1376     inputCloned: "choices__input--cloned",
1377     list: "choices__list",
1378     listItems: "choices__list--multiple",
1379     listSingle: "choices__list--single py-0",
1380     listDropdown: "choices__list--dropdown",
1381     item: "choices__item bg-secondary",
1382     itemSelectable: "choices__item--selectable",
1383     itemDisabled: "choices__item--disabled",
1384     itemChoice: "choices__item--choice",
1385     placeholder: "choices__placeholder",
1386     group: "choices__group",
1387     groupHeading: "choices__heading",
1388     button: "choices__button",
1389     activeState: "is-active",
1390     focusState: "is-focused",
1391     openState: "is-open",
1392     disabledState: "is-disabled",
1393     highlightedState: "text-info",
1394     selectedState: "text-info",
1395     flippedState: "is-flipped",
1396     loadingState: "is-loading",
1397     noResults: "has-no-results",
1398     noChoices: "has-no-choices",
1399   },
1400 };
1401
1402 export function communitySelectName(cv: CommunityView): string {
1403   return cv.community.local
1404     ? cv.community.title
1405     : `${hostname(cv.community.actor_id)}/${cv.community.title}`;
1406 }
1407
1408 export function personSelectName(pvs: PersonViewSafe): string {
1409   let pName = pvs.person.display_name ?? pvs.person.name;
1410   return pvs.person.local ? pName : `${hostname(pvs.person.actor_id)}/${pName}`;
1411 }
1412
1413 export function initializeSite(site: GetSiteResponse) {
1414   UserService.Instance.myUserInfo = site.my_user;
1415   i18n.changeLanguage(getLanguages()[0]);
1416   setupEmojiDataModel(site.custom_emojis);
1417   setupMarkdown();
1418 }
1419
1420 const SHORTNUM_SI_FORMAT = new Intl.NumberFormat("en-US", {
1421   maximumSignificantDigits: 3,
1422   //@ts-ignore
1423   notation: "compact",
1424   compactDisplay: "short",
1425 });
1426
1427 export function numToSI(value: number): string {
1428   return SHORTNUM_SI_FORMAT.format(value);
1429 }
1430
1431 export function isBanned(ps: PersonSafe): boolean {
1432   let expires = ps.ban_expires;
1433   // Add Z to convert from UTC date
1434   // TODO this check probably isn't necessary anymore
1435   if (expires) {
1436     if (ps.banned && new Date(expires + "Z") > new Date()) {
1437       return true;
1438     } else {
1439       return false;
1440     }
1441   } else {
1442     return ps.banned;
1443   }
1444 }
1445
1446 export function pushNotNull(array: any[], new_item?: any) {
1447   if (new_item) {
1448     array.push(...new_item);
1449   }
1450 }
1451
1452 export function myAuth(throwErr = true): string | undefined {
1453   return UserService.Instance.auth(throwErr);
1454 }
1455
1456 export function enableDownvotes(siteRes: GetSiteResponse): boolean {
1457   return siteRes.site_view.local_site.enable_downvotes;
1458 }
1459
1460 export function enableNsfw(siteRes: GetSiteResponse): boolean {
1461   return siteRes.site_view.local_site.enable_nsfw;
1462 }
1463
1464 export function postToCommentSortType(sort: SortType): CommentSortType {
1465   if ([SortType.Active, SortType.Hot].includes(sort)) {
1466     return CommentSortType.Hot;
1467   } else if ([SortType.New, SortType.NewComments].includes(sort)) {
1468     return CommentSortType.New;
1469   } else if (sort == SortType.Old) {
1470     return CommentSortType.Old;
1471   } else {
1472     return CommentSortType.Top;
1473   }
1474 }
1475
1476 export function myFirstDiscussionLanguageId(
1477   allLanguages: Language[],
1478   siteLanguages: number[],
1479   myUserInfo = UserService.Instance.myUserInfo
1480 ): number | undefined {
1481   return selectableLanguages(
1482     allLanguages,
1483     siteLanguages,
1484     false,
1485     false,
1486     myUserInfo
1487   ).at(0)?.id;
1488 }
1489
1490 export function canCreateCommunity(
1491   siteRes: GetSiteResponse,
1492   myUserInfo = UserService.Instance.myUserInfo
1493 ): boolean {
1494   let adminOnly = siteRes.site_view.local_site.community_creation_admin_only;
1495   return !adminOnly || amAdmin(myUserInfo);
1496 }
1497
1498 export function isPostBlocked(
1499   pv: PostView,
1500   myUserInfo = UserService.Instance.myUserInfo
1501 ): boolean {
1502   return (
1503     (myUserInfo?.community_blocks
1504       .map(c => c.community.id)
1505       .includes(pv.community.id) ||
1506       myUserInfo?.person_blocks
1507         .map(p => p.target.id)
1508         .includes(pv.creator.id)) ??
1509     false
1510   );
1511 }
1512
1513 /// Checks to make sure you can view NSFW posts. Returns true if you can.
1514 export function nsfwCheck(
1515   pv: PostView,
1516   myUserInfo = UserService.Instance.myUserInfo
1517 ): boolean {
1518   let nsfw = pv.post.nsfw || pv.community.nsfw;
1519   let myShowNsfw = myUserInfo?.local_user_view.local_user.show_nsfw ?? false;
1520   return !nsfw || (nsfw && myShowNsfw);
1521 }
1522
1523 export function getRandomFromList<T>(list: T[]): T | undefined {
1524   return list.length == 0 ? undefined : list.at(Math.floor(Math.random() * list.length));
1525 }
1526
1527 /**
1528  * This shows what language you can select
1529  *
1530  * Use showAll for the site form
1531  * Use showSite for the profile and community forms
1532  * Use false for both those to filter on your profile and site ones
1533  */
1534 export function selectableLanguages(
1535   allLanguages: Language[],
1536   siteLanguages: number[],
1537   showAll?: boolean,
1538   showSite?: boolean,
1539   myUserInfo = UserService.Instance.myUserInfo
1540 ): Language[] {
1541   let allLangIds = allLanguages.map(l => l.id);
1542   let myLangs = myUserInfo?.discussion_languages ?? allLangIds;
1543   myLangs = myLangs.length == 0 ? allLangIds : myLangs;
1544   let siteLangs = siteLanguages.length == 0 ? allLangIds : siteLanguages;
1545
1546   if (showAll) {
1547     return allLanguages;
1548   } else {
1549     if (showSite) {
1550       return allLanguages.filter(x => siteLangs.includes(x.id));
1551     } else {
1552       return allLanguages
1553         .filter(x => siteLangs.includes(x.id))
1554         .filter(x => myLangs.includes(x.id));
1555     }
1556   }
1557 }
1558 interface EmojiMartCategory {
1559   id: string,
1560   name: string,
1561   emojis: EmojiMartCustomEmoji[]
1562 }
1563
1564 interface EmojiMartCustomEmoji {
1565   id: string,
1566   name: string,
1567   keywords: string[],
1568   skins: EmojiMartSkin[]
1569 }
1570
1571 interface EmojiMartSkin {
1572   src: string
1573 }
1574
1575 const groupBy = <T>(array: T[], predicate: (value: T, index: number, array: T[]) => string) =>
1576   array.reduce((acc, value, index, array) => {
1577     (acc[predicate(value, index, array)] ||= []).push(value);
1578     return acc;
1579   }, {} as { [key: string]: T[] });