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