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