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