]> Untitled Git - lemmy-ui.git/blob - src/shared/utils.ts
Add Custom Emoji Support
[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 markdownitEmoji 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     }).showToast();
495   }
496 }
497
498 export function pictrsDeleteToast(
499   clickToDeleteText: string,
500   deletePictureText: string,
501   failedDeletePictureText: string,
502   deleteUrl: string
503 ) {
504   if (isBrowser()) {
505     let backgroundColor = `var(--light)`;
506     let toast = Toastify({
507       text: clickToDeleteText,
508       backgroundColor: backgroundColor,
509       gravity: "top",
510       position: "right",
511       duration: 10000,
512       onClick: () => {
513         if (toast) {
514           fetch(deleteUrl).then(res => {
515             toast.hideToast();
516             if (res.ok === true) {
517               alert(deletePictureText);
518             } else {
519               alert(failedDeletePictureText);
520             }
521           });
522         }
523       },
524       close: true,
525     });
526     toast.showToast();
527   }
528 }
529
530 interface NotifyInfo {
531   name: string;
532   icon?: string;
533   link: string;
534   body?: string;
535 }
536
537 export function messageToastify(info: NotifyInfo, router: any) {
538   if (isBrowser()) {
539     let htmlBody = info.body ? md.render(info.body) : "";
540     let backgroundColor = `var(--light)`;
541
542     let toast = Toastify({
543       text: `${htmlBody}<br />${info.name}`,
544       avatar: info.icon,
545       backgroundColor: backgroundColor,
546       className: "text-dark",
547       close: true,
548       gravity: "top",
549       position: "right",
550       duration: 5000,
551       escapeMarkup: false,
552       onClick: () => {
553         if (toast) {
554           toast.hideToast();
555           router.history.push(info.link);
556         }
557       },
558     });
559     toast.showToast();
560   }
561 }
562
563 export function notifyPost(post_view: PostView, router: any) {
564   let info: NotifyInfo = {
565     name: post_view.community.name,
566     icon: post_view.community.icon,
567     link: `/post/${post_view.post.id}`,
568     body: post_view.post.name,
569   };
570   notify(info, router);
571 }
572
573 export function notifyComment(comment_view: CommentView, router: any) {
574   let info: NotifyInfo = {
575     name: comment_view.creator.name,
576     icon: comment_view.creator.avatar,
577     link: `/comment/${comment_view.comment.id}`,
578     body: comment_view.comment.content,
579   };
580   notify(info, router);
581 }
582
583 export function notifyPrivateMessage(pmv: PrivateMessageView, router: any) {
584   let info: NotifyInfo = {
585     name: pmv.creator.name,
586     icon: pmv.creator.avatar,
587     link: `/inbox`,
588     body: pmv.private_message.content,
589   };
590   notify(info, router);
591 }
592
593 function notify(info: NotifyInfo, router: any) {
594   messageToastify(info, router);
595
596   if (Notification.permission !== "granted") Notification.requestPermission();
597   else {
598     var notification = new Notification(info.name, {
599       ...{ body: info.body },
600       ...(info.icon && { icon: info.icon }),
601     });
602
603     notification.onclick = (ev: Event): any => {
604       ev.preventDefault();
605       router.history.push(info.link);
606     };
607   }
608 }
609
610 export function setupTribute() {
611   return new Tribute({
612     noMatchTemplate: function () {
613       return "";
614     },
615     collection: [
616       // Emojis
617       {
618         trigger: ":",
619         menuItemTemplate: (item: any) => {
620           let shortName = `:${item.original.key}:`;
621           return `${item.original.val} ${shortName}`;
622         },
623         selectTemplate: (item: any) => {
624           let customEmoji = customEmojisLookup[item.original.key]?.custom_emoji;
625           if (customEmoji == undefined)
626             return `${item.original.val}`;
627           else
628             return `![${customEmoji.alt_text}](${customEmoji.image_url} "${customEmoji.shortcode}")`;
629         },
630         values: Object.entries(emojiShortName).map(e => {
631           return { key: e[1], val: e[0] };
632         }).concat(
633           Object.entries(customEmojisLookup).map((k) => ({
634             key: k[0],
635             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}" />`
636           }))
637         ),
638         allowSpaces: false,
639         autocompleteMode: true,
640         // TODO
641         // menuItemLimit: mentionDropdownFetchLimit,
642         menuShowMinLength: 2,
643       },
644       // Persons
645       {
646         trigger: "@",
647         selectTemplate: (item: any) => {
648           let it: PersonTribute = item.original;
649           return `[${it.key}](${it.view.person.actor_id})`;
650         },
651         values: debounce(async (text: string, cb: any) => {
652           cb(await personSearch(text));
653         }),
654         allowSpaces: false,
655         autocompleteMode: true,
656         // TODO
657         // menuItemLimit: mentionDropdownFetchLimit,
658         menuShowMinLength: 2,
659       },
660
661       // Communities
662       {
663         trigger: "!",
664         selectTemplate: (item: any) => {
665           let it: CommunityTribute = item.original;
666           return `[${it.key}](${it.view.community.actor_id})`;
667         },
668         values: debounce(async (text: string, cb: any) => {
669           cb(await communitySearch(text));
670         }),
671         allowSpaces: false,
672         autocompleteMode: true,
673         // TODO
674         // menuItemLimit: mentionDropdownFetchLimit,
675         menuShowMinLength: 2,
676       },
677     ],
678   });
679 }
680
681
682
683 function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
684   let groupedEmojis = groupBy(custom_emoji_views, x => x.custom_emoji.category);
685   for (const [category, emojis] of Object.entries(groupedEmojis)) {
686     customEmojis.push({
687       id: category,
688       name: category,
689       emojis: emojis.map(emoji =>
690       ({
691         id: emoji.custom_emoji.shortcode,
692         name: emoji.custom_emoji.shortcode,
693         keywords: emoji.keywords.map(x => x.keyword),
694         skins: [{ src: emoji.custom_emoji.image_url }]
695       }))
696     })
697   }
698   customEmojisLookup = new Map(custom_emoji_views.map(view => [view.custom_emoji.shortcode, view]));
699 }
700
701 export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
702   const emoji: EmojiMartCustomEmoji = {
703     id: custom_emoji_view.custom_emoji.shortcode,
704     name: custom_emoji_view.custom_emoji.shortcode,
705     keywords: custom_emoji_view.keywords.map(x => x.keyword),
706     skins: [{ src: custom_emoji_view.custom_emoji.image_url }]
707   };
708   let categoryIndex = customEmojis.findIndex(x => x.id == custom_emoji_view.custom_emoji.category);
709   if (categoryIndex == -1) {
710     customEmojis.push({
711       id: custom_emoji_view.custom_emoji.category,
712       name: custom_emoji_view.custom_emoji.category,
713       emojis: [emoji]
714     })
715   }
716   else {
717     let emojiIndex = customEmojis[categoryIndex].emojis.findIndex(x => x.id == custom_emoji_view.custom_emoji.shortcode);
718     if (emojiIndex == -1) {
719       customEmojis[categoryIndex].emojis.push(emoji)
720     }
721     else {
722       customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
723     }
724   }
725   customEmojisLookup[custom_emoji_view.custom_emoji.shortcode] = custom_emoji_view;
726 }
727
728 export function removeFromEmojiDataModel(id: number) {
729   let view: CustomEmojiView | undefined;
730   for (let item of customEmojisLookup.values()) {
731     if (item.custom_emoji.id === id) {
732       view = item;
733       break;
734     }
735   }
736   if (!view)
737     return;
738   const categoryIndex = customEmojis.findIndex(x => x.id == view?.custom_emoji.category);
739   const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(x => x.id == view?.custom_emoji.shortcode)
740   customEmojis[categoryIndex].emojis = customEmojis[categoryIndex].emojis.splice(emojiIndex, 1);
741
742   customEmojisLookup.delete(view?.custom_emoji.shortcode);
743 }
744
745 function setupMarkdown() {
746   const markdownItConfig: MarkdownIt.Options = {
747     html: false,
748     linkify: true,
749     typographer: true,
750   };
751
752   const emojiDefs = Object.fromEntries(Object.entries(customEmojisLookup).map((k) => [k[0], k[0]]));
753   md = new MarkdownIt(markdownItConfig)
754     .use(markdown_it_sub)
755     .use(markdown_it_sup)
756     .use(markdown_it_footnote)
757     .use(markdown_it_html5_embed, html5EmbedConfig)
758     .use(markdown_it_container, "spoiler", spoilerConfig)
759     .use(markdownitEmoji, {
760       defs: emojiDefs
761     });
762
763   mdNoImages = new MarkdownIt(markdownItConfig)
764     .use(markdown_it_sub)
765     .use(markdown_it_sup)
766     .use(markdown_it_footnote)
767     .use(markdown_it_html5_embed, html5EmbedConfig)
768     .use(markdown_it_container, "spoiler", spoilerConfig)
769     .use(markdownitEmoji, {
770       defs: emojiDefs
771     })
772     .disable("image");
773   var defaultRenderer = md.renderer.rules.image;
774   md.renderer.rules.image = function (tokens: Token[], idx: number, options: MarkdownIt.Options, env: any, self: Renderer) {
775     //Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
776     const item = tokens[idx] as any;
777     const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
778     const src: string = item.attrs[0][1];
779     const isCustomEmoji = customEmojisLookup.get(title) != undefined;
780     if (!isCustomEmoji) {
781       return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
782     }
783     const alt_text = item.content;
784     return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`;
785   }
786 }
787
788 export function getEmojiMart(onEmojiSelect: (e: any) => void, customPickerOptions: any = {}) {
789   const pickerOptions = { ...customPickerOptions, onEmojiSelect: onEmojiSelect, custom: customEmojis }
790   return new Picker(pickerOptions);
791 }
792
793 var tippyInstance: any;
794 if (isBrowser()) {
795   tippyInstance = tippy("[data-tippy-content]");
796 }
797
798 export function setupTippy() {
799   if (isBrowser()) {
800     tippyInstance.forEach((e: any) => e.destroy());
801     tippyInstance = tippy("[data-tippy-content]", {
802       delay: [500, 0],
803       // Display on "long press"
804       touch: ["hold", 500],
805     });
806   }
807 }
808
809 interface PersonTribute {
810   key: string;
811   view: PersonViewSafe;
812 }
813
814 async function personSearch(text: string): Promise<PersonTribute[]> {
815   let users = (await fetchUsers(text)).users;
816   let persons: PersonTribute[] = users.map(pv => {
817     let tribute: PersonTribute = {
818       key: `@${pv.person.name}@${hostname(pv.person.actor_id)}`,
819       view: pv,
820     };
821     return tribute;
822   });
823   return persons;
824 }
825
826 interface CommunityTribute {
827   key: string;
828   view: CommunityView;
829 }
830
831 async function communitySearch(text: string): Promise<CommunityTribute[]> {
832   let comms = (await fetchCommunities(text)).communities;
833   let communities: CommunityTribute[] = comms.map(cv => {
834     let tribute: CommunityTribute = {
835       key: `!${cv.community.name}@${hostname(cv.community.actor_id)}`,
836       view: cv,
837     };
838     return tribute;
839   });
840   return communities;
841 }
842
843 export function getListingTypeFromProps(
844   props: any,
845   defaultListingType: ListingType,
846   myUserInfo = UserService.Instance.myUserInfo
847 ): ListingType {
848   let myLt = myUserInfo?.local_user_view.local_user.default_listing_type;
849   return props.match.params.listing_type
850     ? routeListingTypeToEnum(props.match.params.listing_type)
851     : myLt
852       ? Object.values(ListingType)[myLt]
853       : defaultListingType;
854 }
855
856 export function getListingTypeFromPropsNoDefault(props: any): ListingType {
857   return props.match.params.listing_type
858     ? routeListingTypeToEnum(props.match.params.listing_type)
859     : ListingType.Local;
860 }
861
862 export function getDataTypeFromProps(props: any): DataType {
863   return props.match.params.data_type
864     ? routeDataTypeToEnum(props.match.params.data_type)
865     : DataType.Post;
866 }
867
868 export function getSortTypeFromProps(
869   props: any,
870   myUserInfo = UserService.Instance.myUserInfo
871 ): SortType {
872   let mySortType = myUserInfo?.local_user_view.local_user.default_sort_type;
873   return props.match.params.sort
874     ? routeSortTypeToEnum(props.match.params.sort)
875     : mySortType
876       ? Object.values(SortType)[mySortType]
877       : SortType.Active;
878 }
879
880 export function getPageFromProps(props: any): number {
881   return props.match.params.page ? Number(props.match.params.page) : 1;
882 }
883
884 export function getRecipientIdFromProps(props: any): number {
885   return props.match.params.recipient_id
886     ? Number(props.match.params.recipient_id)
887     : 1;
888 }
889
890 export function getIdFromProps(props: any): number | undefined {
891   let id = props.match.params.post_id;
892   return id ? Number(id) : undefined;
893 }
894
895 export function getCommentIdFromProps(props: any): number | undefined {
896   let id = props.match.params.comment_id;
897   return id ? Number(id) : undefined;
898 }
899
900 export function getUsernameFromProps(props: any): string {
901   return props.match.params.username;
902 }
903
904 export function editCommentRes(data: CommentView, comments?: CommentView[]) {
905   let found = comments?.find(c => c.comment.id == data.comment.id);
906   if (found) {
907     found.comment.content = data.comment.content;
908     found.comment.distinguished = data.comment.distinguished;
909     found.comment.updated = data.comment.updated;
910     found.comment.removed = data.comment.removed;
911     found.comment.deleted = data.comment.deleted;
912     found.counts.upvotes = data.counts.upvotes;
913     found.counts.downvotes = data.counts.downvotes;
914     found.counts.score = data.counts.score;
915   }
916 }
917
918 export function saveCommentRes(data: CommentView, comments?: CommentView[]) {
919   let found = comments?.find(c => c.comment.id == data.comment.id);
920   if (found) {
921     found.saved = data.saved;
922   }
923 }
924
925 export function updatePersonBlock(
926   data: BlockPersonResponse,
927   myUserInfo = UserService.Instance.myUserInfo
928 ) {
929   let mui = myUserInfo;
930   if (mui) {
931     if (data.blocked) {
932       mui.person_blocks.push({
933         person: mui.local_user_view.person,
934         target: data.person_view.person,
935       });
936       toast(`${i18n.t("blocked")} ${data.person_view.person.name}`);
937     } else {
938       mui.person_blocks = mui.person_blocks.filter(
939         i => i.target.id != data.person_view.person.id
940       );
941       toast(`${i18n.t("unblocked")} ${data.person_view.person.name}`);
942     }
943   }
944 }
945
946 export function updateCommunityBlock(
947   data: BlockCommunityResponse,
948   myUserInfo = UserService.Instance.myUserInfo
949 ) {
950   let mui = myUserInfo;
951   if (mui) {
952     if (data.blocked) {
953       mui.community_blocks.push({
954         person: mui.local_user_view.person,
955         community: data.community_view.community,
956       });
957       toast(`${i18n.t("blocked")} ${data.community_view.community.name}`);
958     } else {
959       mui.community_blocks = mui.community_blocks.filter(
960         i => i.community.id != data.community_view.community.id
961       );
962       toast(`${i18n.t("unblocked")} ${data.community_view.community.name}`);
963     }
964   }
965 }
966
967 export function createCommentLikeRes(
968   data: CommentView,
969   comments?: CommentView[]
970 ) {
971   let found = comments?.find(c => c.comment.id === data.comment.id);
972   if (found) {
973     found.counts.score = data.counts.score;
974     found.counts.upvotes = data.counts.upvotes;
975     found.counts.downvotes = data.counts.downvotes;
976     if (data.my_vote !== null) {
977       found.my_vote = data.my_vote;
978     }
979   }
980 }
981
982 export function createPostLikeFindRes(data: PostView, posts?: PostView[]) {
983   let found = posts?.find(p => p.post.id == data.post.id);
984   if (found) {
985     createPostLikeRes(data, found);
986   }
987 }
988
989 export function createPostLikeRes(data: PostView, post_view?: PostView) {
990   if (post_view) {
991     post_view.counts.score = data.counts.score;
992     post_view.counts.upvotes = data.counts.upvotes;
993     post_view.counts.downvotes = data.counts.downvotes;
994     if (data.my_vote !== null) {
995       post_view.my_vote = data.my_vote;
996     }
997   }
998 }
999
1000 export function editPostFindRes(data: PostView, posts?: PostView[]) {
1001   let found = posts?.find(p => p.post.id == data.post.id);
1002   if (found) {
1003     editPostRes(data, found);
1004   }
1005 }
1006
1007 export function editPostRes(data: PostView, post: PostView) {
1008   if (post) {
1009     post.post.url = data.post.url;
1010     post.post.name = data.post.name;
1011     post.post.nsfw = data.post.nsfw;
1012     post.post.deleted = data.post.deleted;
1013     post.post.removed = data.post.removed;
1014     post.post.featured_community = data.post.featured_community;
1015     post.post.featured_local = data.post.featured_local;
1016     post.post.body = data.post.body;
1017     post.post.locked = data.post.locked;
1018     post.saved = data.saved;
1019   }
1020 }
1021
1022 // TODO possible to make these generic?
1023 export function updatePostReportRes(
1024   data: PostReportView,
1025   reports?: PostReportView[]
1026 ) {
1027   let found = reports?.find(p => p.post_report.id == data.post_report.id);
1028   if (found) {
1029     found.post_report = data.post_report;
1030   }
1031 }
1032
1033 export function updateCommentReportRes(
1034   data: CommentReportView,
1035   reports?: CommentReportView[]
1036 ) {
1037   let found = reports?.find(c => c.comment_report.id == data.comment_report.id);
1038   if (found) {
1039     found.comment_report = data.comment_report;
1040   }
1041 }
1042
1043 export function updatePrivateMessageReportRes(
1044   data: PrivateMessageReportView,
1045   reports?: PrivateMessageReportView[]
1046 ) {
1047   let found = reports?.find(
1048     c => c.private_message_report.id == data.private_message_report.id
1049   );
1050   if (found) {
1051     found.private_message_report = data.private_message_report;
1052   }
1053 }
1054
1055 export function updateRegistrationApplicationRes(
1056   data: RegistrationApplicationView,
1057   applications?: RegistrationApplicationView[]
1058 ) {
1059   let found = applications?.find(
1060     ra => ra.registration_application.id == data.registration_application.id
1061   );
1062   if (found) {
1063     found.registration_application = data.registration_application;
1064     found.admin = data.admin;
1065     found.creator_local_user = data.creator_local_user;
1066   }
1067 }
1068
1069 export function commentsToFlatNodes(comments: CommentView[]): CommentNodeI[] {
1070   let nodes: CommentNodeI[] = [];
1071   for (let comment of comments) {
1072     nodes.push({ comment_view: comment, children: [], depth: 0 });
1073   }
1074   return nodes;
1075 }
1076
1077 export function convertCommentSortType(sort: SortType): CommentSortType {
1078   if (
1079     sort == SortType.TopAll ||
1080     sort == SortType.TopDay ||
1081     sort == SortType.TopWeek ||
1082     sort == SortType.TopMonth ||
1083     sort == SortType.TopYear
1084   ) {
1085     return CommentSortType.Top;
1086   } else if (sort == SortType.New) {
1087     return CommentSortType.New;
1088   } else if (sort == SortType.Hot || sort == SortType.Active) {
1089     return CommentSortType.Hot;
1090   } else {
1091     return CommentSortType.Hot;
1092   }
1093 }
1094
1095 export function buildCommentsTree(
1096   comments: CommentView[],
1097   parentComment: boolean
1098 ): CommentNodeI[] {
1099   let map = new Map<number, CommentNodeI>();
1100   let depthOffset = !parentComment
1101     ? 0
1102     : getDepthFromComment(comments[0].comment) ?? 0;
1103
1104   for (let comment_view of comments) {
1105     let depthI = getDepthFromComment(comment_view.comment) ?? 0;
1106     let depth = depthI ? depthI - depthOffset : 0;
1107     let node: CommentNodeI = {
1108       comment_view,
1109       children: [],
1110       depth,
1111     };
1112     map.set(comment_view.comment.id, { ...node });
1113   }
1114
1115   let tree: CommentNodeI[] = [];
1116
1117   // if its a parent comment fetch, then push the first comment to the top node.
1118   if (parentComment) {
1119     let cNode = map.get(comments[0].comment.id);
1120     if (cNode) {
1121       tree.push(cNode);
1122     }
1123   }
1124
1125   for (let comment_view of comments) {
1126     let child = map.get(comment_view.comment.id);
1127     if (child) {
1128       let parent_id = getCommentParentId(comment_view.comment);
1129       if (parent_id) {
1130         let parent = map.get(parent_id);
1131         // Necessary because blocked comment might not exist
1132         if (parent) {
1133           parent.children.push(child);
1134         }
1135       } else {
1136         if (!parentComment) {
1137           tree.push(child);
1138         }
1139       }
1140     }
1141   }
1142
1143   return tree;
1144 }
1145
1146 export function getCommentParentId(comment?: CommentI): number | undefined {
1147   let split = comment?.path.split(".");
1148   // remove the 0
1149   split?.shift();
1150
1151   return split && split.length > 1
1152     ? Number(split.at(split.length - 2))
1153     : undefined;
1154 }
1155
1156 export function getDepthFromComment(comment?: CommentI): number | undefined {
1157   let len = comment?.path.split(".").length;
1158   return len ? len - 2 : undefined;
1159 }
1160
1161 export function insertCommentIntoTree(
1162   tree: CommentNodeI[],
1163   cv: CommentView,
1164   parentComment: boolean
1165 ) {
1166   // Building a fake node to be used for later
1167   let node: CommentNodeI = {
1168     comment_view: cv,
1169     children: [],
1170     depth: 0,
1171   };
1172
1173   let parentId = getCommentParentId(cv.comment);
1174   if (parentId) {
1175     let parent_comment = searchCommentTree(tree, parentId);
1176     if (parent_comment) {
1177       node.depth = parent_comment.depth + 1;
1178       parent_comment.children.unshift(node);
1179     }
1180   } else if (!parentComment) {
1181     tree.unshift(node);
1182   }
1183 }
1184
1185 export function searchCommentTree(
1186   tree: CommentNodeI[],
1187   id: number
1188 ): CommentNodeI | undefined {
1189   for (let node of tree) {
1190     if (node.comment_view.comment.id === id) {
1191       return node;
1192     }
1193
1194     for (const child of node.children) {
1195       let res = searchCommentTree([child], id);
1196
1197       if (res) {
1198         return res;
1199       }
1200     }
1201   }
1202   return undefined;
1203 }
1204
1205 export const colorList: string[] = [
1206   hsl(0),
1207   hsl(50),
1208   hsl(100),
1209   hsl(150),
1210   hsl(200),
1211   hsl(250),
1212   hsl(300),
1213 ];
1214
1215 function hsl(num: number) {
1216   return `hsla(${num}, 35%, 50%, 1)`;
1217 }
1218
1219 export function hostname(url: string): string {
1220   let cUrl = new URL(url);
1221   return cUrl.port ? `${cUrl.hostname}:${cUrl.port}` : `${cUrl.hostname}`;
1222 }
1223
1224 export function validTitle(title?: string): boolean {
1225   // Initial title is null, minimum length is taken care of by textarea's minLength={3}
1226   if (!title || title.length < 3) return true;
1227
1228   const regex = new RegExp(/.*\S.*/, "g");
1229
1230   return regex.test(title);
1231 }
1232
1233 export function siteBannerCss(banner: string): string {
1234   return ` \
1235     background-image: linear-gradient( rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.8) ) ,url("${banner}"); \
1236     background-attachment: fixed; \
1237     background-position: top; \
1238     background-repeat: no-repeat; \
1239     background-size: 100% cover; \
1240
1241     width: 100%; \
1242     max-height: 100vh; \
1243     `;
1244 }
1245
1246 export function isBrowser() {
1247   return typeof window !== "undefined";
1248 }
1249
1250 export function setIsoData(context: any): IsoData {
1251   // If its the browser, you need to deserialize the data from the window
1252   if (isBrowser()) {
1253     let json = window.isoData;
1254     let routeData = json.routeData;
1255     let site_res = json.site_res;
1256
1257     let isoData: IsoData = {
1258       path: json.path,
1259       site_res,
1260       routeData,
1261     };
1262     return isoData;
1263   } else return context.router.staticContext;
1264 }
1265
1266 export function wsSubscribe(parseMessage: any): Subscription | undefined {
1267   if (isBrowser()) {
1268     return WebSocketService.Instance.subject
1269       .pipe(retryWhen(errors => errors.pipe(delay(3000), take(10))))
1270       .subscribe(
1271         msg => parseMessage(msg),
1272         err => console.error(err),
1273         () => console.log("complete")
1274       );
1275   } else {
1276     return undefined;
1277   }
1278 }
1279
1280 moment.updateLocale("en", {
1281   relativeTime: {
1282     future: "in %s",
1283     past: "%s ago",
1284     s: "<1m",
1285     ss: "%ds",
1286     m: "1m",
1287     mm: "%dm",
1288     h: "1h",
1289     hh: "%dh",
1290     d: "1d",
1291     dd: "%dd",
1292     w: "1w",
1293     ww: "%dw",
1294     M: "1M",
1295     MM: "%dM",
1296     y: "1Y",
1297     yy: "%dY",
1298   },
1299 });
1300
1301 export function saveScrollPosition(context: any) {
1302   let path: string = context.router.route.location.pathname;
1303   let y = window.scrollY;
1304   sessionStorage.setItem(`scrollPosition_${path}`, y.toString());
1305 }
1306
1307 export function restoreScrollPosition(context: any) {
1308   let path: string = context.router.route.location.pathname;
1309   let y = Number(sessionStorage.getItem(`scrollPosition_${path}`));
1310   window.scrollTo(0, y);
1311 }
1312
1313 export function showLocal(isoData: IsoData): boolean {
1314   let linked = isoData.site_res.federated_instances?.linked;
1315   return linked ? linked.length > 0 : false;
1316 }
1317
1318 export interface ChoicesValue {
1319   value: string;
1320   label: string;
1321 }
1322
1323 export function communityToChoice(cv: CommunityView): ChoicesValue {
1324   let choice: ChoicesValue = {
1325     value: cv.community.id.toString(),
1326     label: communitySelectName(cv),
1327   };
1328   return choice;
1329 }
1330
1331 export function personToChoice(pvs: PersonViewSafe): ChoicesValue {
1332   let choice: ChoicesValue = {
1333     value: pvs.person.id.toString(),
1334     label: personSelectName(pvs),
1335   };
1336   return choice;
1337 }
1338
1339 export async function fetchCommunities(q: string) {
1340   let form: Search = {
1341     q,
1342     type_: SearchType.Communities,
1343     sort: SortType.TopAll,
1344     listing_type: ListingType.All,
1345     page: 1,
1346     limit: fetchLimit,
1347     auth: myAuth(false),
1348   };
1349   let client = new LemmyHttp(httpBase);
1350   return client.search(form);
1351 }
1352
1353 export async function fetchUsers(q: string) {
1354   let form: Search = {
1355     q,
1356     type_: SearchType.Users,
1357     sort: SortType.TopAll,
1358     listing_type: ListingType.All,
1359     page: 1,
1360     limit: fetchLimit,
1361     auth: myAuth(false),
1362   };
1363   let client = new LemmyHttp(httpBase);
1364   return client.search(form);
1365 }
1366
1367 export const choicesConfig = {
1368   shouldSort: false,
1369   searchResultLimit: fetchLimit,
1370   classNames: {
1371     containerOuter: "choices custom-select px-0",
1372     containerInner:
1373       "choices__inner bg-secondary border-0 py-0 modlog-choices-font-size",
1374     input: "form-control",
1375     inputCloned: "choices__input--cloned",
1376     list: "choices__list",
1377     listItems: "choices__list--multiple",
1378     listSingle: "choices__list--single py-0",
1379     listDropdown: "choices__list--dropdown",
1380     item: "choices__item bg-secondary",
1381     itemSelectable: "choices__item--selectable",
1382     itemDisabled: "choices__item--disabled",
1383     itemChoice: "choices__item--choice",
1384     placeholder: "choices__placeholder",
1385     group: "choices__group",
1386     groupHeading: "choices__heading",
1387     button: "choices__button",
1388     activeState: "is-active",
1389     focusState: "is-focused",
1390     openState: "is-open",
1391     disabledState: "is-disabled",
1392     highlightedState: "text-info",
1393     selectedState: "text-info",
1394     flippedState: "is-flipped",
1395     loadingState: "is-loading",
1396     noResults: "has-no-results",
1397     noChoices: "has-no-choices",
1398   },
1399 };
1400
1401 export function communitySelectName(cv: CommunityView): string {
1402   return cv.community.local
1403     ? cv.community.title
1404     : `${hostname(cv.community.actor_id)}/${cv.community.title}`;
1405 }
1406
1407 export function personSelectName(pvs: PersonViewSafe): string {
1408   let pName = pvs.person.display_name ?? pvs.person.name;
1409   return pvs.person.local ? pName : `${hostname(pvs.person.actor_id)}/${pName}`;
1410 }
1411
1412 export function initializeSite(site: GetSiteResponse) {
1413   UserService.Instance.myUserInfo = site.my_user;
1414   i18n.changeLanguage(getLanguages()[0]);
1415   setupEmojiDataModel(site.custom_emojis);
1416   setupMarkdown();
1417 }
1418
1419 const SHORTNUM_SI_FORMAT = new Intl.NumberFormat("en-US", {
1420   maximumSignificantDigits: 3,
1421   //@ts-ignore
1422   notation: "compact",
1423   compactDisplay: "short",
1424 });
1425
1426 export function numToSI(value: number): string {
1427   return SHORTNUM_SI_FORMAT.format(value);
1428 }
1429
1430 export function isBanned(ps: PersonSafe): boolean {
1431   let expires = ps.ban_expires;
1432   // Add Z to convert from UTC date
1433   // TODO this check probably isn't necessary anymore
1434   if (expires) {
1435     if (ps.banned && new Date(expires + "Z") > new Date()) {
1436       return true;
1437     } else {
1438       return false;
1439     }
1440   } else {
1441     return ps.banned;
1442   }
1443 }
1444
1445 export function pushNotNull(array: any[], new_item?: any) {
1446   if (new_item) {
1447     array.push(...new_item);
1448   }
1449 }
1450
1451 export function myAuth(throwErr = true): string | undefined {
1452   return UserService.Instance.auth(throwErr);
1453 }
1454
1455 export function enableDownvotes(siteRes: GetSiteResponse): boolean {
1456   return siteRes.site_view.local_site.enable_downvotes;
1457 }
1458
1459 export function enableNsfw(siteRes: GetSiteResponse): boolean {
1460   return siteRes.site_view.local_site.enable_nsfw;
1461 }
1462
1463 export function postToCommentSortType(sort: SortType): CommentSortType {
1464   if ([SortType.Active, SortType.Hot].includes(sort)) {
1465     return CommentSortType.Hot;
1466   } else if ([SortType.New, SortType.NewComments].includes(sort)) {
1467     return CommentSortType.New;
1468   } else if (sort == SortType.Old) {
1469     return CommentSortType.Old;
1470   } else {
1471     return CommentSortType.Top;
1472   }
1473 }
1474
1475 export function myFirstDiscussionLanguageId(
1476   allLanguages: Language[],
1477   siteLanguages: number[],
1478   myUserInfo = UserService.Instance.myUserInfo
1479 ): number | undefined {
1480   return selectableLanguages(
1481     allLanguages,
1482     siteLanguages,
1483     false,
1484     false,
1485     myUserInfo
1486   ).at(0)?.id;
1487 }
1488
1489 export function canCreateCommunity(
1490   siteRes: GetSiteResponse,
1491   myUserInfo = UserService.Instance.myUserInfo
1492 ): boolean {
1493   let adminOnly = siteRes.site_view.local_site.community_creation_admin_only;
1494   return !adminOnly || amAdmin(myUserInfo);
1495 }
1496
1497 export function isPostBlocked(
1498   pv: PostView,
1499   myUserInfo = UserService.Instance.myUserInfo
1500 ): boolean {
1501   return (
1502     (myUserInfo?.community_blocks
1503       .map(c => c.community.id)
1504       .includes(pv.community.id) ||
1505       myUserInfo?.person_blocks
1506         .map(p => p.target.id)
1507         .includes(pv.creator.id)) ??
1508     false
1509   );
1510 }
1511
1512 /// Checks to make sure you can view NSFW posts. Returns true if you can.
1513 export function nsfwCheck(
1514   pv: PostView,
1515   myUserInfo = UserService.Instance.myUserInfo
1516 ): boolean {
1517   let nsfw = pv.post.nsfw || pv.community.nsfw;
1518   let myShowNsfw = myUserInfo?.local_user_view.local_user.show_nsfw ?? false;
1519   return !nsfw || (nsfw && myShowNsfw);
1520 }
1521
1522 export function getRandomFromList<T>(list: T[]): T | undefined {
1523   return list.length == 0 ? undefined : list.at(Math.floor(Math.random() * list.length));
1524 }
1525
1526 /**
1527  * This shows what language you can select
1528  *
1529  * Use showAll for the site form
1530  * Use showSite for the profile and community forms
1531  * Use false for both those to filter on your profile and site ones
1532  */
1533 export function selectableLanguages(
1534   allLanguages: Language[],
1535   siteLanguages: number[],
1536   showAll?: boolean,
1537   showSite?: boolean,
1538   myUserInfo = UserService.Instance.myUserInfo
1539 ): Language[] {
1540   let allLangIds = allLanguages.map(l => l.id);
1541   let myLangs = myUserInfo?.discussion_languages ?? allLangIds;
1542   myLangs = myLangs.length == 0 ? allLangIds : myLangs;
1543   let siteLangs = siteLanguages.length == 0 ? allLangIds : siteLanguages;
1544
1545   if (showAll) {
1546     return allLanguages;
1547   } else {
1548     if (showSite) {
1549       return allLanguages.filter(x => siteLangs.includes(x.id));
1550     } else {
1551       return allLanguages
1552         .filter(x => siteLangs.includes(x.id))
1553         .filter(x => myLangs.includes(x.id));
1554     }
1555   }
1556 }
1557 interface EmojiMartCategory {
1558   id: string,
1559   name: string,
1560   emojis: EmojiMartCustomEmoji[]
1561 }
1562
1563 interface EmojiMartCustomEmoji {
1564   id: string,
1565   name: string,
1566   keywords: string[],
1567   skins: EmojiMartSkin[]
1568 }
1569
1570 interface EmojiMartSkin {
1571   src: string
1572 }
1573
1574 const groupBy = <T>(array: T[], predicate: (value: T, index: number, array: T[]) => string) =>
1575   array.reduce((acc, value, index, array) => {
1576     (acc[predicate(value, index, array)] ||= []).push(value);
1577     return acc;
1578   }, {} as { [key: string]: T[] });