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